From 27cadc34e8b71e4efa176b86554bad7331e7525b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 9 Sep 2026 12:08:51 +0200 Subject: [PATCH 001/226] feat(render): add a Vulkan renderer backend behind the platform seam Adds Optimum.Render.Vulkan, a second runtime-selectable graphics backend that emulates the GL state machine the game and its mods were written against, in the shape Zink and ANGLE use. OpenGL stays the default and is untouched: every routed method keeps its GL body behind a null check on OptimumRender.Device. The riskiest assumption was that the shaders could be translated automatically rather than hand-ported - hand-porting never extends to mod GLSL, which only exists at runtime. All 84 vanilla shaders across 4 define permutations become valid SPIR-V and render through real pipelines. What works: the client runs on Vulkan on NVIDIA and on Intel/Mesa, reaches the main menu, and renders the interface pixel-comparable with the OpenGL path at the same frame rate. Texture atlases compose and upload, blocks tesselate. What does not: a loaded world still loses the device during chunk rendering. Notable GL-to-Vulkan gaps this had to close, each of which produced a correct frame right up until it did not: - GL supplies a constant (0,0,0,1) for vertex attributes a draw does not provide. The GUI quad carries positions and UVs while gui.vsh declares six inputs, and gui.fsh discards on one of the missing ones, so the entire interface rendered as nothing. Fixed with a stride-zero defaults buffer. - The queue was not externally synchronised. Asset loading uploads textures from worker threads while the render thread submits frames. - Images were created with one mip level, so BuildMipMaps silently did nothing. - Drawing with a descriptor set the shader statically uses but nothing had bound. - The descriptor pool had no UNIFORM_BUFFER size, so the game's own UBOs could not be allocated. - Vertex flags were R32_UINT while every shader declares `in int`. Every Vulkan result is now checked. A lost device previously looked like nothing at all from inside - submits kept succeeding and the client spun at a few frames a second forever - so failures are reported where they happen instead. Cecil: 243/243 methods patched, 192 members injected, 126 patches, 0 conflicts. Tests: 908 green (173 renderer, 714 Optimum, 21 launcher), on both GPU vendors. The renderer suite runs with the validation layers and asserts an error-free message log. It previously asserted that without the layers installed, which made those assertions inert; it now excludes implicit layers so the result does not depend on which overlays a developer happens to have. --- .gitignore | 3 + .../ShaderCompatibilityScanner.cs | 12 +- Optimum.Patcher/Program.cs | 194 ++ Optimum.Render.Vulkan.Tests/AssemblyInfo.cs | 59 + .../ChunkRenderPathTests.cs | 568 ++++ .../ChunkTerrainRenderTests.cs | 404 +++ Optimum.Render.Vulkan.Tests/FrameRingTests.cs | 366 +++ .../GlStateTrackerTests.cs | 388 +++ .../MeshManagerTests.cs | 402 +++ .../Optimum.Render.Vulkan.Tests.csproj | 32 + .../PipelineCacheTests.cs | 266 ++ .../RenderTargetTests.cs | 372 +++ Optimum.Render.Vulkan.Tests/ShaderCorpus.cs | 283 ++ .../ShaderTranslationTests.cs | 203 ++ .../ShaderTranslationUnitTests.cs | 482 ++++ Optimum.Render.Vulkan.Tests/SwapchainTests.cs | 324 +++ .../TextureManagerTests.cs | 323 +++ .../VertexAttributeDefaultTests.cs | 226 ++ .../VulkanDeviceIntegrationTests.cs | 388 +++ .../VulkanDeviceTests.cs | 414 +++ .../WorldRenderPathTests.cs | 494 ++++ Optimum.Render.Vulkan/Core/DescriptorCache.cs | 293 ++ Optimum.Render.Vulkan/Core/FrameRing.cs | 282 ++ Optimum.Render.Vulkan/Core/GlEnums.cs | 173 ++ Optimum.Render.Vulkan/Core/GlStateTracker.cs | 413 +++ Optimum.Render.Vulkan/Core/MeshManager.cs | 406 +++ Optimum.Render.Vulkan/Core/PipelineCache.cs | 320 +++ .../Core/RenderTargetManager.cs | 381 +++ Optimum.Render.Vulkan/Core/RenderTrace.cs | 127 + .../Core/ShaderProgramResources.cs | 289 ++ Optimum.Render.Vulkan/Core/Swapchain.cs | 387 +++ Optimum.Render.Vulkan/Core/TextureManager.cs | 543 ++++ Optimum.Render.Vulkan/Core/VertexLayout.cs | 286 ++ Optimum.Render.Vulkan/Core/VulkanContext.cs | 607 +++++ Optimum.Render.Vulkan/Core/VulkanResources.cs | 398 +++ Optimum.Render.Vulkan/Core/WindowSurface.cs | 86 + .../Optimum.Render.Vulkan.csproj | 52 + Optimum.Render.Vulkan/Shaders/GlslParser.cs | 708 +++++ .../Shaders/GlslReservedWords.cs | 153 ++ Optimum.Render.Vulkan/Shaders/GlslType.cs | 141 + .../Shaders/ProgramInterfaceLayout.cs | 484 ++++ .../Shaders/ShaderCompiler.cs | 269 ++ .../Shaders/ShaderRewriter.cs | 316 +++ .../Shaders/ShaderTranslator.cs | 140 + Optimum.Render.Vulkan/VulkanDevice.cs | 1589 +++++++++++ Optimum.Tests/AssemblyInfo.cs | 19 + Optimum.Tests/fsr-pipeline-coverage-tests.cs | 8 +- .../vulkan-backend-integration-tests.cs | 427 +++ VULKAN-BACKEND-PLAN.md | 1528 +++++++++++ VintageStory.slnx | 8 + .../optimum-api-contracts.csproj | 2 + .../ChunkRenderer.cs.patch | 94 +- .../ClientMain.cs.patch | 39 +- .../ClientPlatformWindows.cs.patch | 2417 ++++++++++++++++- .../ClientSystemStartup.cs.patch | 21 +- .../GameWindowNative.cs.patch | 30 + .../InventoryItemRenderer.cs.patch | 40 + .../ShaderProgramBase.cs.patch | 400 +++ .../ShaderRegistry.cs.patch | 35 +- .../SvgLoader.cs.patch | 32 + .../SystemRenderFrameBufferDebug.cs.patch | 95 + .../SystemRenderOITLayers.cs.patch | 130 +- .../SystemRenderSunMoon.cs.patch | 146 + .../Vintagestory.Client.NoObf/UBO.cs.patch | 113 + .../Vintagestory.Client.NoObf/VAO.cs.patch | 28 + .../ClientProgram.cs.patch | 79 +- .../ScreenManager.cs.patch | 32 + .../Screenshot.cs.patch | 28 + patches/cecil-owned.list | 9 + scripts/check-patches.sh | 2 +- scripts/package-linux.sh | 17 + scripts/package-macos.sh | 14 + scripts/package.ps1 | 16 + .../Client/optimum-render-bootstrap.cs | 295 ++ .../Client/optimum-render-device.cs | 453 +++ .../VintagestoryApi/Config/OptimumConfig.cs | 63 + .../VintagestoryApi/VintagestoryAPI.csproj | 2 + tools/InteropProbe/InteropProbe.csproj | 18 + tools/InteropProbe/Program.cs | 264 ++ 79 files changed, 21809 insertions(+), 141 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/AssemblyInfo.cs create mode 100644 Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/FrameRingTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/MeshManagerTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/Optimum.Render.Vulkan.Tests.csproj create mode 100644 Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/RenderTargetTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/ShaderCorpus.cs create mode 100644 Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SwapchainTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/TextureManagerTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs create mode 100644 Optimum.Render.Vulkan/Core/DescriptorCache.cs create mode 100644 Optimum.Render.Vulkan/Core/FrameRing.cs create mode 100644 Optimum.Render.Vulkan/Core/GlEnums.cs create mode 100644 Optimum.Render.Vulkan/Core/GlStateTracker.cs create mode 100644 Optimum.Render.Vulkan/Core/MeshManager.cs create mode 100644 Optimum.Render.Vulkan/Core/PipelineCache.cs create mode 100644 Optimum.Render.Vulkan/Core/RenderTargetManager.cs create mode 100644 Optimum.Render.Vulkan/Core/RenderTrace.cs create mode 100644 Optimum.Render.Vulkan/Core/ShaderProgramResources.cs create mode 100644 Optimum.Render.Vulkan/Core/Swapchain.cs create mode 100644 Optimum.Render.Vulkan/Core/TextureManager.cs create mode 100644 Optimum.Render.Vulkan/Core/VertexLayout.cs create mode 100644 Optimum.Render.Vulkan/Core/VulkanContext.cs create mode 100644 Optimum.Render.Vulkan/Core/VulkanResources.cs create mode 100644 Optimum.Render.Vulkan/Core/WindowSurface.cs create mode 100644 Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj create mode 100644 Optimum.Render.Vulkan/Shaders/GlslParser.cs create mode 100644 Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs create mode 100644 Optimum.Render.Vulkan/Shaders/GlslType.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs create mode 100644 Optimum.Render.Vulkan/VulkanDevice.cs create mode 100644 Optimum.Tests/AssemblyInfo.cs create mode 100644 Optimum.Tests/vulkan-backend-integration-tests.cs create mode 100644 VULKAN-BACKEND-PLAN.md create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/GameWindowNative.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs.patch create mode 100644 sources/VintagestoryApi/Client/optimum-render-bootstrap.cs create mode 100644 sources/VintagestoryApi/Client/optimum-render-device.cs create mode 100644 tools/InteropProbe/InteropProbe.csproj create mode 100644 tools/InteropProbe/Program.cs diff --git a/.gitignore b/.gitignore index 848b2cb9..6ff83733 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ baseline/ build/ .baseline/ +# Decompiled vanilla reference tree (proprietary; local read-only aid, never committed) +/_ref/ + # Anchored to repo root: reconstructed fork trees (patches stay tracked) /VintagestoryApi/ /Cairo/ diff --git a/Optimum.Launcher/ShaderCompatibilityScanner.cs b/Optimum.Launcher/ShaderCompatibilityScanner.cs index 901ebe20..65ab2b52 100644 --- a/Optimum.Launcher/ShaderCompatibilityScanner.cs +++ b/Optimum.Launcher/ShaderCompatibilityScanner.cs @@ -33,7 +33,8 @@ private static readonly (string Token, string Name)[] IndicatorTokens = ("harmony", "Harmony"), ("registerrenderer", "RenderHook"), ("onrenderframe", "RenderHook"), - ("enumrenderstage", "RenderHook") + ("enumrenderstage", "RenderHook"), + ("opentk.graphics", "RawOpenGL") ]; private static readonly string[] OptimumBuiltInMods = @@ -309,6 +310,15 @@ private static void FinalizeReport(ShaderCompatibilityReport report) AddFeatureDecision(report, "MapPageCache", externalShaderAssets || externalShaderHooks, "external shader assets or shader hooks can dispose registered programs during reload"); + // "Vulkan" is a backend decision, not a shader feature, and is deliberately + // absent from ShaderFeatures: a scanner failure must not silently veto a + // backend the user explicitly asked for. GLSL that mods ship is fine - the + // translator compiles arbitrary sources - but a mod issuing GL calls itself + // has no path through a Vulkan device. + bool rawOpenGl = report.Sources.Any(x => x.Indicators.Contains("RawOpenGL")); + AddFeatureDecision(report, "Vulkan", rawOpenGl, + "a mod calls OpenGL directly, which the Vulkan backend cannot serve"); + if (report.ScanFailed) { foreach (string feature in ShaderFeatures) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 0e4bd7d9..a744a07a 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -117,6 +117,18 @@ "_optimumFocusLostStopwatch", "optimumFsrDisabled", "DisableOptimumFsr", + // Vulkan backend: the device-path framebuffer setup and its helpers. + "SetupOptimumFrameBuffers", + "CreateOptimumColorTarget", + "CreateOptimumDepthTarget", + "CreateOptimumFramebuffer", + // Vulkan backend: GL state the device takes as call arguments instead, + // so the routed bodies need somewhere to remember it. + "optimumClearR", + "optimumClearG", + "optimumClearB", + "optimumClearA", + "optimumBoundTexture2d", }, ["Vintagestory.Client.NoObf.ShaderPrograms"] = new() { @@ -134,6 +146,17 @@ "RestoreVanillaTransparentState", "DisableOptimumOit", }, + // Vulkan backend: the shared sampling setup for the two OIT targets. + ["Vintagestory.Client.NoObf.SystemRenderOITLayers/BeforeOIT"] = new() + { + "SetOptimumOitSampling", + }, + // Vulkan backend: shadow maps are sampled as plain depth by the debug + // overlay, which means toggling the compare mode off and back on. + ["Vintagestory.Client.NoObf.SystemRenderFrameBufferDebug"] = new() + { + "SetOptimumDepthCompare", + }, // Settings tab: inject the field, callbacks, and hook helper ["Vintagestory.Client.NoObf.GuiCompositeSettings"] = new() { @@ -204,6 +227,7 @@ "edgePoolLocationsScratch", "optimumTextureLodBias", "ApplyOptimumTextureLodBias", + "SetOptimumTextureLodBias", }, // ChunkTesselatorManager: skip RecalcPriority+Sort when the player hasn't moved // (_lastSortPlayerPos/_lastSortYaw), plus the multi-tesselator worker pool and @@ -463,6 +487,176 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisableOptimumFsr", 1), // R4: pass the configured god-rays sample limit to the post-process shader. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderPostprocessingEffects", 1), + // Vulkan backend: fixed-function state routes to OptimumRender.Device when a + // device is installed, and runs the untouched vanilla GL body when it is not. + // See VULKAN-BACKEND-PLAN.md section 3. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLWireframes", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlViewport", 4), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlScissor", 4), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlScissorFlag", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlEnableDepthTest", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDisableDepthTest", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlToggleBlend", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDisableCullFace", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlEnableCullFace", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLLineWidth", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDepthMask", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDepthFunc", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlCullFaceBack", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlCullFaceFront", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlEnableStencilTest", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDisableStencilTest", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlStencilMask", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlStencilFunc", 3), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlStencilOp", 3), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlColorMask", 4), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlClearStencil", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGLShaderVersionString", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GenSampler", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BindTexture2d", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BindTextureCubeMap", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLDeleteTexture", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGetMaxTextureSize", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGraphicsCardRenderer", 0), + // Vulkan backend: shader staging and linking. CompileShader only stages a + // stage on the device path, because GL resolves uniforms and varyings by name + // across the whole program and nothing is final until link time. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetUniformLocation", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CompileShader", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateShaderProgram", 1), + // Vulkan backend: the mod-facing uniform and texture-binding surface. A + // uniform location here is a byte offset into the generated block rather than + // a GL location, which callers never see. + // Uniform has seven two-parameter overloads, so each needs its signature. + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "System.Single" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "System.Int32" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "Vintagestory.API.MathTools.Vec2f" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "Vintagestory.API.MathTools.Vec2i" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "Vintagestory.API.MathTools.Vec3f" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "Vintagestory.API.MathTools.Vec3i" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 2, + new[] { "System.String", "Vintagestory.API.MathTools.Vec4f" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 3, + new[] { "System.String", "System.Int32", "System.Single[]" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 3, + new[] { "System.String", "System.Single", "System.Single" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 4, + new[] { "System.String", "System.Single", "System.Single", "System.Single" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniform", 5, + new[] { "System.String", "System.Single", "System.Single", "System.Single", "System.Single" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniforms2", 3), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniforms3", 3), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Uniforms4", 3), + // UniformMatrix has a float[] and a by-ref Matrix4 overload. + new("Vintagestory.Client.NoObf.ShaderProgramBase", "UniformMatrix", 2, + new[] { "System.String", "System.Single[]" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "UniformMatrix", 2, + new[] { "System.String", "OpenTK.Mathematics.Matrix4&" }), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "UniformMatrices", 3), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "UniformMatrices4x3", 3), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "BindTexture2D", 3), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "BindTextureCube", 3), + // Vulkan backend: program lifecycle. ProgramId is the device's handle. + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Use", 0), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Stop", 0), + new("Vintagestory.Client.NoObf.ShaderProgramBase", "Dispose", 0), + // Vulkan backend: mesh allocation, upload and draw. VAO.VaoId carries the + // device's mesh handle so MeshRef, which mods hold, stays unchanged. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMesh", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFullscreenTriangle", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMesh", 5), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMeshInstanced", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UploadMesh", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UpdateMesh", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DeleteMesh", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "AllocateEmptyMesh", 12), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "AllocateEmptySSBOMesh", 12), + new("Vintagestory.Client.NoObf.VAO", "Dispose", 0), + // Vulkan backend: the window's own clear-and-swap has no GL binding to call + // when the window was opened with no graphics API. + new("Vintagestory.Client.NoObf.GameWindowNative", ".ctor", 2), + // Vulkan backend: framebuffer binding, lifecycle and per-pass state. The + // two CurrentFrameBuffer properties bind on assignment, so their setters + // are the seam for every render target the client selects. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBuffer", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBufferKeepVw", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_GlDebugMode", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateFramebuffer", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffer", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffers", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "ClearFrameBuffer", 4), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "ClearFrameBuffer", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadFrameBuffer", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadFrameBuffer", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UnloadFrameBuffer", 1, + new[] { "Vintagestory.API.Client.EnumFrameBuffer" }), + // Vulkan backend: startup capability reporting, which cannot ask GL. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LogAndTestHardwareInfosStage2", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGraphicCardInfos", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Start", 0), + // Vulkan backend: error reporting comes from the validation layer. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CheckGlError", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CheckGlErrorAlways", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGetError", 0), + // Vulkan backend: texture creation, upload and mipmapping. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadCairoTexture", 2), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateCairoTexture", 3), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GenTexture", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadIntoTexture", 5), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadTexture", 4, + new[] { "Vintagestory.API.Common.IBitmap", "System.Boolean", "System.Int32", "System.Boolean" }), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BuildMipMaps", 1), + // The texture atlas upload path. Private, so it only reaches the shipped + // assembly as an explicit target - its three public wrappers delegate here + // and carry no GL of their own, which is how it was missed: nothing on the + // menu reaches it, and TextureAtlas.Upload only runs once a world loads. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateTextureFromPixels", 6), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Load3DTextureCube", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGenerateTex2DMipmaps", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BindTexture2d", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UnBindTextureCubeMap", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlClearColorRgbaf", 4), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "SmoothLines", 1), + // Vulkan backend: uniform buffers, whose handles UBO carries across. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateUBO", 4), + new("Vintagestory.Client.NoObf.UBO", "Bind", 0), + new("Vintagestory.Client.NoObf.UBO", "Unbind", 0), + new("Vintagestory.Client.NoObf.UBO", "Dispose", 0), + new("Vintagestory.Client.NoObf.UBO", "Update", 3, + new[] { "System.Object", "System.Int32", "System.Int32" }), + // Vulkan backend: the packed-face storage buffer the SSBO chunk path uses. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UpdateSSBOMesh", 2), + // Vulkan backend, world rendering: the render systems that reach past + // ClientPlatformWindows to GL directly. Each keeps its GL body behind a + // device check, the same shape as the platform routing. + new("Vintagestory.Client.NoObf.SystemRenderOITLayers/BeforeOIT", "rebuild", 0), + new("Vintagestory.Client.NoObf.SystemRenderOITLayers/BeforeOIT", "freeResources", 0), + new("Vintagestory.Client.NoObf.SystemRenderSunMoon", ".ctor", 1), + new("Vintagestory.Client.NoObf.SystemRenderSunMoon", "OnRenderFrame3DPost", 1), + new("Vintagestory.Client.NoObf.SystemRenderSunMoon", "Dispose", 1), + new("Vintagestory.Client.NoObf.SystemRenderFrameBufferDebug", "OnRenderFrame2DOverlay", 1), + new("Vintagestory.Client.NoObf.SvgLoader", "LoadSvg", 6), + new("Vintagestory.Client.NoObf.ClientMain", "OrthoMode", 3), + new("Vintagestory.Client.NoObf.ClientMain", "PerspectiveMode", 0), + new("Vintagestory.Client.NoObf.InventoryItemRenderer", "RenderItemStackToFrameBuffer", 3), + new("Vintagestory.Client.NoObf.ClientSystemStartup", "HandleLevelFinalize", 1), + new("Vintagestory.ClientNative.Screenshot", "GrabScreenshot", 4), + // Vulkan backend: the GUI depth clear between the world and the interface, + // the only raw GL left in the screen loop. + new("Vintagestory.Client.ScreenManager", "Render", 1), + // Vulkan backend: the post-process chain's remaining direct GL - viewport, + // draw-buffer selection, depth toggle and the SSAO clear. + // Vulkan backend: the device's default target and swapchain follow the + // window only if something tells them to. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Window_Resize", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "MergeTransparentRenderPass", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFinalComposition", 0), // GuiCompositeMainMenuLeft: Optimum link in main menu (no lambdas) new("Vintagestory.Client.GuiCompositeMainMenuLeft", "Compose", 0), // E3: particle spawn distance gate, before the per-particle revive loop diff --git a/Optimum.Render.Vulkan.Tests/AssemblyInfo.cs b/Optimum.Render.Vulkan.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..37789655 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AssemblyInfo.cs @@ -0,0 +1,59 @@ +using System; +using System.Runtime.CompilerServices; +using Xunit; + +// These tests drive a real GPU driver, and three of them additionally drive +// GLFW's process-global init and terminate. Neither is safe to do from several +// threads at once: xunit's default of running collections in parallel crashed +// the test host outright once the windowing tests joined the suite. +// +// The suite is a few seconds either way, so serialising it costs nothing worth +// having. +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace Optimum.Render.Vulkan.Tests; + +internal static class TestEnvironment +{ + /// + /// Keeps implicit Vulkan layers out of the test process. + /// + /// Overlays a developer happens to have installed - MangoHud, gamescope's + /// WSI layer, vendor layers - are loaded into every Vulkan instance on the + /// machine, and they can produce validation errors of their own. The + /// gamescope layer on this machine enables VK_KHR_present_mode_fifo_latest_ready + /// without the VK_KHR_swapchain it depends on, which fails the validation + /// assertions in twenty-five tests for a reason that has nothing to do with + /// this backend. Excluding them makes the suite depend only on our own calls. + /// + /// Set before any Vulkan call, because the loader reads it when an instance + /// is created. + /// + [ModuleInitializer] + internal static void DisableImplicitLayers() + { + if (Environment.GetEnvironmentVariable("VK_LOADER_LAYERS_DISABLE") != null) return; + + Environment.SetEnvironmentVariable("VK_LOADER_LAYERS_DISABLE", "~implicit~"); + + // .NET's SetEnvironmentVariable only updates the managed copy on Unix; + // the Vulkan loader is native and reads the real environment, so it has + // to be set through libc as well or nothing changes. + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + try + { + SetNativeEnvironmentVariable("VK_LOADER_LAYERS_DISABLE", "~implicit~", 1); + } + catch (DllNotFoundException) + { + } + catch (EntryPointNotFoundException) + { + } + } + } + + [System.Runtime.InteropServices.DllImport("libc", EntryPoint = "setenv")] + private static extern int SetNativeEnvironmentVariable(string name, string value, int overwrite); +} diff --git a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs new file mode 100644 index 00000000..8024eb99 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// World geometry rendered with the game's own shaders. +/// +/// The corpus test proves every vanilla program becomes valid SPIR-V; that is not +/// the same as proving a chunk draws. These build the vertex data the way the +/// tesselator does - positions, UVs, colours and the packed flags word, each in +/// its own buffer - bind the real chunkopaque program, draw, and read the result +/// back. A layout that disagrees with the shader's declared locations, or a +/// packed-flags word the shader unpacks differently, shows up here as wrong +/// pixels rather than as a validation message. +/// +/// Reaching a world in the client needs a signed-in account, so this is the +/// closest thing to a chunk that runs unattended. +/// +public class ChunkRenderPathTests +{ + private readonly ITestOutputHelper _output; + + public ChunkRenderPathTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) + { + output.WriteLine("Vulkan unavailable: " + failureReason); + } + return created; + } + + /// + /// The real chunkopaque program, compiled the way the client compiles it, + /// against a mesh shaped like a tesselated chunk. This is the single most + /// load-bearing program in the game. + /// + [SkippableFact] + public void TheRealChunkProgramTranslatesAndBuildsAPipeline() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!, state); + using var compiler = new ShaderCompiler(); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + + int built = 0; + foreach (ShaderCorpus.ShaderVariant variant in ShaderCorpus.Variants()) + { + List stages = + ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + Assert.NotEmpty(stages); + + TranslatedProgram translated = ShaderTranslator.Translate(stages, compiler); + Assert.True(translated.Success, + variant.Name + ": " + string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + // The chunk vertex layout: positions, UVs, colours and flags, + // each in its own buffer, exactly as AllocateEmptyMesh builds it. + // The mesh has to follow the variant: with SSBOs on, the shader + // reads positions out of a storage buffer and declares far fewer + // vertex inputs, and a mesh built the other way would disagree + // with it. + bool ssbo = variant.UseSsbo == 1; + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), + normalsSize: 0, + uvSize: 4 * 2 * sizeof(float), + rgbaSize: 4 * 4, + flagsSize: 4 * sizeof(int), + indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: false, ssbo: ssbo); + + int layoutId = meshes.LayoutIdOf(mesh); + VertexLayoutDescription layout = meshes.LayoutOf(layoutId); + + // Every input the shader declares is either in the mesh or gets + // GL's constant default; nothing may be left undefined. + // Guard against the loop below asserting nothing. Without SSBOs + // chunkopaque declares positions, UVs, colours and flags; with + // them, positions and most of the rest come from the storage + // buffer and only a couple of inputs remain. + int expectedInputs = ssbo ? 1 : 4; + Assert.True(program.Interface.VertexInputs.Count >= expectedInputs, + variant.Name + ": expected chunkopaque to declare at least " + expectedInputs + + " vertex inputs, saw " + program.Interface.VertexInputs.Count); + + VertexLayoutDescription merged = layout.WithDefaultsFor(program.Interface.VertexInputs); + foreach (VertexInputSlot slot in program.Interface.VertexInputs) + { + Assert.Contains(merged.Attributes, a => a.Location == (uint)slot.Location); + } + + int target = textures.Create(8, 8, Format.R8G8B8A8Unorm); + int framebuffer = targets.Create(8, 8); + targets.Attach(framebuffer, 0, target); + targets.SetDrawBuffers(framebuffer, 0b1); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(layoutId, formatsId, 1), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = merged, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + Assert.NotEqual((ulong)0, pipeline.Handle); + built++; + + meshes.Delete(mesh); + targets.Delete(framebuffer); + textures.Delete(target); + } + + Assert.Equal(4, built); + AssertNoValidationErrors(messages); + } + } + + /// + /// Every world-facing program, through translation into a real pipeline. + /// + /// The corpus test stops at valid SPIR-V. Pipeline creation is where a + /// descriptor layout that disagrees with the shader, or a vertex input the + /// mesh cannot satisfy, actually fails - and these are the programs a world + /// needs on its first frame. + /// + [SkippableTheory] + [InlineData("chunkopaque")] + [InlineData("chunkliquid")] + [InlineData("chunktransparent")] + [InlineData("chunktopsoil")] + [InlineData("chunkshadowmap")] + [InlineData("entityanimated")] + [InlineData("particlesquad")] + [InlineData("particlescube")] + [InlineData("standard")] + public void AWorldProgramBuildsAPipelineAgainstItsMeshLayout(string programName) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!, state); + using var compiler = new ShaderCompiler(); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + + List stages = ShaderCorpus.BuildProgram( + programName, files, includes, ShaderCorpus.Variants().First()); + Skip.If(stages.Count == 0, programName + " is not in the asset set."); + + TranslatedProgram translated = ShaderTranslator.Translate(stages, compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + // No normals: attribute locations are positional, and every world + // program declares xyz=0, uv=1, colour=2, flags=3 with no normal + // input at all. Including a normals buffer shifts everything after + // it by one and the shader reads colours as flags - which is exactly + // what this assertion is here to catch. + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), + normalsSize: 0, + uvSize: 4 * 2 * sizeof(float), + rgbaSize: 4 * 4, + flagsSize: 4 * sizeof(int), + indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: false, ssbo: false); + + int layoutId = meshes.LayoutIdOf(mesh); + VertexLayoutDescription merged = + meshes.LayoutOf(layoutId).WithDefaultsFor(program.Interface.VertexInputs); + + int target = textures.Create(8, 8, Format.R8G8B8A8Unorm); + int depth = textures.Create(8, 8, Format.D32Sfloat); + int framebuffer = targets.Create(8, 8); + targets.Attach(framebuffer, 0, target); + targets.Attach(framebuffer, -1, depth); + // Enough attachments for the multi-output world passes. + targets.SetDrawBuffers(framebuffer, 0b1); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(layoutId, formatsId, 1), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = merged, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + Assert.NotEqual((ulong)0, pipeline.Handle); + AssertNoValidationErrors(messages); + } + } + + /// + /// The SSBO chunk path end to end. + /// + /// With SSBOs on, positions leave the vertex input entirely: four vertices + /// are packed into one sixteen-byte face record in a storage buffer and the + /// vertex shader rebuilds them from gl_VertexIndex. Nothing about that path + /// is exercised by an ordinary mesh, and getting it wrong produces an empty + /// world rather than an error. + /// + [SkippableFact] + public unsafe void TheSsboChunkPathUploadsFaceRecordsAndDraws() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 16; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!, state); + using var compiler = new ShaderCompiler(); + + int target = textures.Create(size, size, Format.R8G8B8A8Unorm); + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, target); + targets.SetDrawBuffers(framebuffer, 0b1); + + // One face: four vertices packed into sixteen bytes, plus the six + // indices that expand it into two triangles. + int mesh = meshes.CreateEmpty( + xyzSize: 16, normalsSize: 0, uvSize: 0, rgbaSize: 0, flagsSize: 0, + indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: false, ssbo: true); + + // The record the client writes: an origin and two edge offsets, in + // the same layout FaceData uses. + float[] face = { -1f, -1f, 0f, 2f }; + int[] indices = { 0, 1, 2, 0, 2, 3 }; + + fixed (float* f = face) + { + meshes.Write(mesh, MeshManager.BufferXyz, 0, (IntPtr)f, face.Length * sizeof(float)); + } + fixed (int* i = indices) + { + meshes.Write(mesh, -1, 0, (IntPtr)i, indices.Length * sizeof(int)); + } + + // Positions come out of the storage buffer, not a vertex attribute - + // the defining property of this path. + VertexLayoutDescription layout = meshes.LayoutOf(meshes.LayoutIdOf(mesh)); + Assert.Empty(layout.Attributes); + + TranslatedProgram translated = ShaderTranslator.Translate(new[] + { + new ShaderStageSource + { + Stage = EnumShaderType.VertexShader, + Filename = "ssbo.vsh", + Code = """ + #version 430 core + layout(std430, binding = 0) readonly buffer FaceBuffer { vec4 faces[]; }; + void main(void) + { + vec4 face = faces[gl_VertexIndex >> 2]; + int corner = gl_VertexIndex & 3; + float x = face.x + ((corner == 1 || corner == 2) ? face.w : 0.0); + float y = face.y + ((corner == 2 || corner == 3) ? face.w : 0.0); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """, + }, + new ShaderStageSource + { + Stage = EnumShaderType.FragmentShader, + Filename = "ssbo.fsh", + Code = """ + #version 430 core + out vec4 outColor; + void main(void) { outColor = vec4(0.0, 1.0, 0.0, 1.0); } + """, + }, + }, compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(meshes.LayoutIdOf(mesh), formatsId, 1), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = layout, + Targets = formats, + Blend = new[] { state.BlendFor(0) }, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + Assert.NotEqual((ulong)0, pipeline.Handle); + + AssertNoValidationErrors(messages); + } + } + + /// + /// Particles draw one mesh many times with per-instance data. An instance + /// count that never reached the draw would render a single particle where + /// there should be thousands. + /// + [SkippableFact] + public unsafe void InstancedDrawsRenderEveryInstance() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 16; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!, state); + using var compiler = new ShaderCompiler(); + + int target = textures.Create(size, size, Format.R8G8B8A8Unorm); + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, target); + targets.SetDrawBuffers(framebuffer, 0b1); + + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, + rgbaSize: 0, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: false, ssbo: false); + + // A small quad in the lower-left; each instance steps it right and up, + // so a second instance is visible only if instancing works. + float[] positions = + { + -1f, -1f, 0f, + -0.5f, -1f, 0f, + -0.5f, -0.5f, 0f, + -1f, -0.5f, 0f, + }; + int[] indices = { 0, 1, 2, 0, 2, 3 }; + + fixed (float* p = positions) + { + meshes.Write(mesh, MeshManager.BufferXyz, 0, (IntPtr)p, positions.Length * sizeof(float)); + } + fixed (int* i = indices) + { + meshes.Write(mesh, -1, 0, (IntPtr)i, indices.Length * sizeof(int)); + } + + TranslatedProgram translated = ShaderTranslator.Translate(new[] + { + new ShaderStageSource + { + Stage = EnumShaderType.VertexShader, + Filename = "inst.vsh", + Code = """ + #version 330 core + layout(location = 0) in vec3 position; + void main(void) + { + float step = float(gl_InstanceID) * 0.75; + gl_Position = vec4(position.x + step, position.y + step, 0.0, 1.0); + } + """, + }, + new ShaderStageSource + { + Stage = EnumShaderType.FragmentShader, + Filename = "inst.fsh", + Code = """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4(1.0, 0.0, 1.0, 1.0); } + """, + }, + }, compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(meshes.LayoutIdOf(mesh), formatsId, 1), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = meshes.LayoutOf(meshes.LayoutIdOf(mesh)), + Targets = formats, + Blend = new[] { state.BlendFor(0) }, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + + Vk api = context!.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + SetDynamicDefaults(api, commandBuffer); + + meshes.Draw(commandBuffer, mesh, instanceCount: 2); + targets.EndRendering(commandBuffer); + }); + + byte[] pixels = ReadTexture(context!, commands, textures, target, size); + + // Instance 0 covers the lower-left, instance 1 the middle. Both being + // magenta is what distinguishes a real instanced draw from one. + Assert.Equal(255, PixelAt(pixels, size, 2, 2)[0]); + Assert.Equal(255, PixelAt(pixels, size, 2, 2)[2]); + Assert.Equal(255, PixelAt(pixels, size, 9, 9)[0]); + Assert.Equal(255, PixelAt(pixels, size, 9, 9)[2]); + + AssertNoValidationErrors(messages); + } + } + + // ------------------------------------------------------------------ helpers + + private static byte[] PixelAt(byte[] pixels, uint size, uint x, uint y) => + pixels.Skip((int)((y * size + x) * 4)).Take(4).ToArray(); + + private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) + { + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, false); + api.CmdSetDepthWriteEnable(commandBuffer, false); + api.CmdSetDepthCompareOp(commandBuffer, CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + } + + private static unsafe byte[] ReadTexture( + VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * 4; + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[(int)bytes]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } + + private static void AssertNoValidationErrors(List messages) + { + // Only what the layers reported at error severity. Advisories - a + // fragment output with no attachment, say - are prefixed as warnings and + // are not failures; treating every message as one made these assertions + // fire on notes about correct frames. + var errors = messages + .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, + StringComparison.Ordinal)) + .ToList(); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs new file mode 100644 index 00000000..28a6a3bf --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Terrain drawn with the game's own chunk shader, through the seam and nothing +/// else. +/// +/// Everything else about chunks in this project stops short of a draw: the corpus +/// proves the shaders become SPIR-V, and ChunkRenderPathTests proves they become +/// pipelines. Neither puts a block on screen. These build the vertex data a +/// tesselated chunk actually carries - positions, UVs, per-vertex colour and the +/// packed render-flags word, each in its own buffer, exactly as +/// AllocateEmptyMesh lays them out - bind the real chunkopaque program with its +/// real samplers and matrices, draw, and read the pixels back. +/// +/// A world in the client needs a signed-in account, so this is terrain rendered +/// through the backend without one: real shader, real vertex format, real device +/// path, real pixels. +/// +public class ChunkTerrainRenderTests +{ + private readonly ITestOutputHelper _output; + + public ChunkTerrainRenderTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private sealed class Shader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class Program : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = "chunkopaque"; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } + + /// + /// One tesselated block face, in the layout the chunk tesselator emits. + /// + /// Positions are chunk-local, UVs index the block atlas, the colour carries + /// baked light, and the flags word packs glow, z-offset, waving bits and the + /// normal - the field whose undefined value made the whole interface vanish + /// when vertex-attribute defaults were missing. + /// + private static MeshData BuildBlockFace() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + // A quad covering the middle of the viewport in clip space, so the draw + // is checkable by reading the centre pixel. + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + Vintagestory.API.MathTools.ColorUtil.WhiteArgb, + // Normal pointing up, no glow, no waving: the flags word a solid + // top face carries. + flags: 0); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + return mesh; + } + + /// + /// The real chunkopaque program, drawing a real tesselated face, checked by + /// reading the pixels back. + /// + [SkippableFact] + public unsafe void TheRealChunkShaderDrawsTesselatedTerrain() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + IOptimumGraphicsDevice seam = device!; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(); + + List stages = + ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + Assert.NotEmpty(stages); + + int programId = LinkFromCorpus(seam, stages, "chunkopaque"); + + // Every sampler the program declares needs something bound, or the + // draw is skipped rather than drawn - the descriptor would be + // incomplete. A single white texel stands in for the block atlas. + BindEveryDeclaredSampler(device!, seam, programId); + + int target = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + Assert.True(seam.CheckFramebufferComplete(framebuffer, out string status), status); + + int mesh = seam.CreateMesh(BuildBlockFace(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + // Cleared to magenta rather than black, because chunkopaque's output + // depends on lighting, fog and atlas contents that are all zero here - + // it legitimately shades to black. Testing "the pixel is lit" would + // then be indistinguishable from "nothing drew". Testing "the pixel + // changed" detects rasterisation whatever the shader decides to emit. + seam.ClearColor(0, 1f, 0f, 1f, 1f); + seam.ClearDepth(1f); + + seam.UseProgram(programId); + SetIdentityMatrices(seam, programId); + SetViewUniforms(seam, programId); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + seam.DrawMesh(mesh); + seam.Present(); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + + int centre = (Size / 2 * Size + Size / 2) * 4; + int corner = (2 * Size + 2) * 4; + + _output.WriteLine($"centre RGBA = {pixels[centre]}, {pixels[centre + 1]}, " + + $"{pixels[centre + 2]}, {pixels[centre + 3]}"); + _output.WriteLine($"corner RGBA = {pixels[corner]}, {pixels[corner + 1]}, " + + $"{pixels[corner + 2]}, {pixels[corner + 3]}"); + + // The face covers the middle and nothing else: geometry actually + // rasterised, in the right place, and did not cover the whole target. + bool centreChanged = !IsClearColour(pixels, centre); + bool cornerUntouched = IsClearColour(pixels, corner); + + Assert.True(centreChanged, "the block face did not rasterise"); + Assert.True(cornerUntouched, "the block face covered the whole target"); + + AssertClean(seam); + } + } + + /// + /// The same face through the shadow-map program, which renders depth only and + /// is the pass every shadowed chunk goes through first. + /// + [SkippableFact] + public void TheShadowMapProgramDrawsTerrainIntoADepthOnlyTarget() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + IOptimumGraphicsDevice seam = device!; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + + List stages = ShaderCorpus.BuildProgram( + "chunkshadowmap", files, includes, ShaderCorpus.Variants().First()); + Assert.NotEmpty(stages); + + int programId = LinkFromCorpus(seam, stages, "chunkshadowmap"); + BindEveryDeclaredSampler(device!, seam, programId); + + int depth = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 0); + + int mesh = seam.CreateMesh(BuildBlockFace(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearDepth(1f); + + seam.UseProgram(programId); + SetIdentityMatrices(seam, programId); + SetViewUniforms(seam, programId); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); + seam.SetCullFace(false); + + seam.DrawMesh(mesh); + seam.Present(); + + // A depth-only pass has nothing to read back as colour; what matters + // is that it recorded and submitted without the device refusing the + // draw or the validation layer objecting. + AssertClean(seam); + } + } + + // ------------------------------------------------------------------ helpers + + /// The magenta the target was cleared to, within 8-bit rounding. + private static bool IsClearColour(byte[] pixels, int offset) => + pixels[offset] >= 250 && pixels[offset + 1] <= 5 && pixels[offset + 2] >= 250; + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new Program { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new Shader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + /// + /// Binds a one-texel white texture to every sampler the program declares. + /// + /// The device skips a draw whose descriptor set is incomplete, which is the + /// right behaviour but would make this test pass by not drawing at all. The + /// real client binds the atlases; here a stand-in is enough to make the draw + /// legal. + /// + private static unsafe void BindEveryDeclaredSampler( + VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + { + var white = new byte[] { 255, 255, 255, 255 }; + int unit = 0; + + foreach (string samplerName in device.SamplerNamesOf(programId)) + { + int texture; + fixed (byte* pixels = white) + { + texture = seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + seam.SetSamplerUnit(programId, samplerName, unit); + seam.BindTexture(unit, texture); + unit++; + } + } + + /// + /// The scalar uniforms the chunk passes need in order to draw anything. + /// + /// These are not decoration. chunkopaque computes + /// aTest = outColor.a + ... - lod0Fade and discards when it falls below + /// alphaTest, and lod0Fade is derived from the view distances - left at zero, + /// every fragment in the world fades out and the pass draws nothing at all. + /// The client sets them each frame; a test that does not is testing a + /// configuration the game never runs. + /// + private static void SetViewUniforms(IOptimumGraphicsDevice seam, int programId) + { + SetFloat(seam, programId, "viewDistance", 1024f); + SetFloat(seam, programId, "viewDistanceLod0", 1024f); + SetFloat(seam, programId, "alphaTest", 0.001f); + SetFloat(seam, programId, "zNear", 0.1f); + SetFloat(seam, programId, "zFar", 1024f); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int programId, string name, float value) + { + int location = seam.GetUniformLocation(programId, name); + if (location >= 0) seam.SetUniform(programId, location, value); + } + + /// + /// The matrices every chunk program multiplies by. Identity leaves the mesh's + /// clip-space positions alone, which is what makes the output checkable. + /// + private static void SetIdentityMatrices(IOptimumGraphicsDevice seam, int programId) + { + float[] identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + foreach (string name in new[] + { + "projectionMatrix", "modelViewMatrix", "modelMatrix", "mvpMatrix", + "toShadowMapSpaceMatrixFar", "toShadowMapSpaceMatrixNear", + }) + { + int location = seam.GetUniformLocation(programId, name); + if (location >= 0) seam.SetUniformMatrix(programId, location, identity); + } + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } +} diff --git a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs new file mode 100644 index 00000000..f7221ea2 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Covers the frame ring and the descriptor cache against a real device. +/// +/// Both exist to make the per-frame cost of a GL-shaped renderer bearable, and +/// both have failure modes that only appear under motion: a slot reused before +/// the GPU finished with it, a resource freed while still referenced, a +/// descriptor set that silently keeps stale contents. +/// +public class FrameRingTests +{ + private readonly ITestOutputHelper _output; + + public FrameRingTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context) + { + var options = new VulkanContextOptions { Headless = true, EnableValidation = true }; + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + private sealed class TrackedResource : IDisposable + { + public bool Disposed { get; private set; } + public void Dispose() => Disposed = true; + } + + [SkippableFact] + public void FrameSlotsRotateAndCanBeCycledRepeatedly() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); + + var seen = new List(); + for (int frame = 0; frame < 6; frame++) + { + FrameSlot slot = ring.BeginFrame(); + seen.Add(slot); + ring.EndFrame(); + } + + // Two slots, alternating, and reused rather than reallocated. + Assert.Same(seen[0], seen[2]); + Assert.Same(seen[1], seen[3]); + Assert.NotSame(seen[0], seen[1]); + + context!.Api.DeviceWaitIdle(context.Device); + } + } + + /// + /// A resource handed to the ring must survive until the GPU is demonstrably + /// done with the frame that referenced it. Freeing at the moment the game + /// asks is the classic use-after-free in a Vulkan port of a GL renderer, + /// because GL let the driver worry about it. + /// + [SkippableFact] + public void DeferredDeletionsOutliveTheFrameThatQueuedThem() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); + var resource = new TrackedResource(); + + ring.BeginFrame(); + ring.DeferDeletion(resource); + ring.EndFrame(); + Assert.False(resource.Disposed, "must not be freed during the frame that queued it"); + + // The next frame drains the queue and adopts the resource. + ring.BeginFrame(); + ring.EndFrame(); + Assert.False(resource.Disposed, "must not be freed while the adopting slot is in flight"); + + ring.BeginFrame(); + ring.EndFrame(); + Assert.False(resource.Disposed, "the adopting slot has not come round yet"); + + // Back to the adopting slot: its fence has signalled, so the GPU is + // demonstrably finished with everything that frame referenced. + ring.BeginFrame(); + ring.EndFrame(); + Assert.True(resource.Disposed, "should be freed once the adopting slot's fence signalled"); + + context!.Api.DeviceWaitIdle(context.Device); + } + } + + /// + /// VAO and UBO finalizers call Dispose from the finalizer thread, so the + /// deletion queue has to accept work from threads that are not the render + /// thread while still doing the destruction on it. + /// + [SkippableFact] + public void DeletionsQueuedFromOtherThreadsAreAccepted() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); + + var resources = new List(); + for (int i = 0; i < 64; i++) resources.Add(new TrackedResource()); + + Parallel.ForEach(resources, resource => ring.DeferDeletion(resource)); + Assert.Equal(64, ring.PendingDeletionCount); + + for (int frame = 0; frame < 4; frame++) + { + ring.BeginFrame(); + ring.EndFrame(); + } + + Assert.All(resources, resource => Assert.True(resource.Disposed)); + context!.Api.DeviceWaitIdle(context.Device); + } + } + + [SkippableFact] + public void UniformAllocationsRespectTheDeviceAlignmentAndTheRegionBound() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + ulong alignment = context!.Capabilities.MinUniformBufferOffsetAlignment; + using var ring = new FrameRing(context, framesInFlight: 2, uniformRingSize: 64 * 1024); + + FrameSlot slot = ring.BeginFrame(); + + for (int i = 0; i < 16; i++) + { + Assert.True(slot.TryAllocateUniforms(100, out RingAllocation allocation)); + Assert.True(allocation.Offset % alignment == 0, + $"offset {allocation.Offset} is not aligned to {alignment}"); + Assert.NotEqual(IntPtr.Zero, allocation.Pointer); + } + + // Exhausting the region reports rather than overruns. + Assert.False(slot.TryAllocateUniforms((int)slot.UniformCapacity + 1, out _)); + + _output.WriteLine($"alignment {alignment}, used {slot.UniformBytesUsed} of {slot.UniformCapacity}"); + + ring.EndFrame(); + context.Api.DeviceWaitIdle(context.Device); + } + } + + /// + /// Each slot bump-allocates inside its own slice of one shared buffer. The + /// shared buffer is what lets descriptor sets be written once and reused, + /// since the set names the buffer and the offset travels dynamically. + /// + [SkippableFact] + public void SlotsAllocateFromDisjointRegionsOfOneSharedBuffer() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 64 * 1024); + + FrameSlot first = ring.BeginFrame(); + Assert.True(first.TryAllocateUniforms(256, out RingAllocation a)); + ring.EndFrame(); + + FrameSlot second = ring.BeginFrame(); + Assert.True(second.TryAllocateUniforms(256, out RingAllocation b)); + ring.EndFrame(); + + Assert.Equal(a.Buffer.Handle, b.Buffer.Handle); + Assert.Equal(ring.UniformBuffer.Handle, a.Buffer.Handle); + Assert.NotEqual(a.Offset, b.Offset); + + context!.Api.DeviceWaitIdle(context.Device); + } + } + + // -------------------------------------------------------- descriptor cache + + /// + /// The set and binding numbers are decided by the shader rewriter and + /// duplicated as constants in the descriptor layer so it does not depend on + /// the translation types. If the two ever drift, samplers get written into + /// the wrong set and nothing renders. + /// + [Fact] + public void DescriptorBindingConstantsAgreeWithTheShaderRewriter() + { + Assert.Equal(ProgramInterfaceLayout.DefaultBlockSet, ProgramInterfaceLayoutBindings.DefaultBlockSet); + Assert.Equal(ProgramInterfaceLayout.DefaultBlockBinding, ProgramInterfaceLayoutBindings.DefaultBlockBinding); + Assert.Equal(ProgramInterfaceLayout.SamplerSet, ProgramInterfaceLayoutBindings.SamplerSet); + Assert.Equal(ProgramInterfaceLayout.StorageSet, ProgramInterfaceLayoutBindings.StorageSet); + } + + [Fact] + public void DescriptorContentsCompareByValue() + { + var view = new ImageView(0x1234); + var sampler = new Sampler(0x5678); + + var a = new DescriptorSetContents(1, 1, + new[] { new SamplerBindingValue(0, view, sampler) }, Array.Empty()); + var b = new DescriptorSetContents(1, 1, + new[] { new SamplerBindingValue(0, view, sampler) }, Array.Empty()); + var differentTexture = new DescriptorSetContents(1, 1, + new[] { new SamplerBindingValue(0, new ImageView(0x9999), sampler) }, + Array.Empty()); + var differentProgram = new DescriptorSetContents(2, 1, + new[] { new SamplerBindingValue(0, view, sampler) }, Array.Empty()); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a, differentTexture); + Assert.NotEqual(a, differentProgram); + } + + /// + /// The point of the cache: binding the same atlas over and over, which is + /// what chunk rendering does thousands of times a frame, must cost a + /// dictionary lookup rather than an allocation and a write. + /// + [SkippableFact] + public unsafe void RepeatedIdenticalBindingsReuseOneDescriptorSet() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + using var compiler = new ShaderCompiler(); + using var program = LoadProgram(context!, compiler, "blit", programId: 1); + using var cache = new DescriptorCache(context!); + using var image = new VulkanImage(context!, 16, 16, Format.R8G8B8A8Unorm, + ImageUsageFlags.SampledBit, ImageAspectFlags.ColorBit); + + Sampler sampler = CreateSampler(context!); + DescriptorSetLayout layout = program.SetLayouts[ProgramInterfaceLayout.SamplerSet]; + + DescriptorSetContents Contents() => new(1, ProgramInterfaceLayout.SamplerSet, + new[] { new SamplerBindingValue(0, image.View, sampler) }, + Array.Empty()); + + DescriptorSet first = cache.Get(Contents(), layout); + for (int i = 0; i < 1000; i++) + { + Assert.Equal(first.Handle, cache.Get(Contents(), layout).Handle); + } + + Assert.Equal(1, cache.Count); + Assert.Equal(1, cache.Misses); + Assert.Equal(1000, cache.Hits); + _output.WriteLine($"sets: {cache.Count}, hits: {cache.Hits}, misses: {cache.Misses}"); + + context!.Api.DeviceWaitIdle(context.Device); + context.Api.DestroySampler(context.Device, sampler, null); + } + } + + /// + /// Growing past one pool must keep working. A cache that silently failed to + /// allocate would look like missing textures, not like an error. + /// + [SkippableFact] + public unsafe void TheCacheGrowsBeyondASinglePool() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + using var compiler = new ShaderCompiler(); + using var program = LoadProgram(context!, compiler, "blit", programId: 1); + using var cache = new DescriptorCache(context!); + using var image = new VulkanImage(context!, 4, 4, Format.R8G8B8A8Unorm, + ImageUsageFlags.SampledBit, ImageAspectFlags.ColorBit); + + Sampler sampler = CreateSampler(context!); + DescriptorSetLayout layout = program.SetLayouts[ProgramInterfaceLayout.SamplerSet]; + + // Distinct views over one image: cheap, and enough to make each set's + // contents unique without one device allocation per entry. + var views = new List(); + try + { + const int count = 700; + for (int i = 0; i < count; i++) + { + var viewInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image.Handle, + ViewType = ImageViewType.Type2D, + Format = Format.R8G8B8A8Unorm, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + context!.Api.CreateImageView(context.Device, &viewInfo, null, out ImageView view); + views.Add(view); + + cache.Get(new DescriptorSetContents(1, ProgramInterfaceLayout.SamplerSet, + new[] { new SamplerBindingValue(0, view, sampler) }, + Array.Empty()), layout); + } + + Assert.Equal(count, cache.Count); + Assert.Equal(count, cache.Misses); + _output.WriteLine($"grew to {cache.Count} sets across multiple pools"); + } + finally + { + context!.Api.DeviceWaitIdle(context.Device); + foreach (ImageView view in views) context.Api.DestroyImageView(context.Device, view, null); + context.Api.DestroySampler(context.Device, sampler, null); + } + } + } + + private static ShaderProgramResources LoadProgram( + VulkanContext context, ShaderCompiler compiler, string programName, int programId) + { + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = System.Linq.Enumerable.First( + ShaderCorpus.Variants(), v => v.Name == "everything-on"); + + TranslatedProgram translated = ShaderTranslator.Translate( + ShaderCorpus.BuildProgram(programName, files, includes, variant), compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + return new ShaderProgramResources(context, programId, translated); + } + + /// + /// A combined image sampler descriptor needs a real sampler; writing a null + /// handle into one crashes the driver rather than reporting an error. + /// + private static unsafe Sampler CreateSampler(VulkanContext context) + { + var createInfo = new SamplerCreateInfo + { + SType = StructureType.SamplerCreateInfo, + MagFilter = Filter.Nearest, + MinFilter = Filter.Nearest, + AddressModeU = SamplerAddressMode.ClampToEdge, + AddressModeV = SamplerAddressMode.ClampToEdge, + AddressModeW = SamplerAddressMode.ClampToEdge, + MaxLod = 1.0f, + }; + context.Api.CreateSampler(context.Device, &createInfo, null, out Sampler sampler); + return sampler; + } +} diff --git a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs new file mode 100644 index 00000000..edd28d80 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Covers the emulated GL state machine and the pipeline key it resolves into. +/// +/// State bugs are the quiet kind: a wrong blend factor or a key that collides +/// does not crash, it just renders subtly wrong somewhere deep in a scene. These +/// pin the translations against the vanilla behaviour they have to reproduce. +/// +public class GlStateTrackerTests +{ + // ------------------------------------------------------------ blend modes + + /// + /// The factor pairs come straight from ClientPlatformWindows.GlToggleBlend. + /// Every one of the game's named modes has to land on the same pair it had + /// under GL, or transparency and glow render differently. + /// + [Theory] + [InlineData(EnumBlendMode.Standard, BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha)] + [InlineData(EnumBlendMode.Brighten, BlendFactor.DstColor, BlendFactor.One)] + [InlineData(EnumBlendMode.Multiply, BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha)] + [InlineData(EnumBlendMode.PremultipliedAlpha, BlendFactor.One, BlendFactor.OneMinusSrcAlpha)] + [InlineData(EnumBlendMode.Glow, BlendFactor.SrcAlpha, BlendFactor.One)] + [InlineData(EnumBlendMode.Overlay, BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha)] + public void NamedBlendModesMatchTheVanillaFactorPairs( + EnumBlendMode mode, BlendFactor expectedSrc, BlendFactor expectedDst) + { + var tracker = new GlStateTracker(); + tracker.SetBlend(true, mode); + + AttachmentBlend blend = tracker.BlendFor(0); + Assert.True(blend.Enabled); + Assert.Equal(expectedSrc, blend.SrcColor); + Assert.Equal(expectedDst, blend.DstColor); + } + + /// + /// Glow and Overlay use BlendFuncSeparate in vanilla, so their alpha factors + /// differ from their colour ones. + /// + [Fact] + public void SeparateAlphaBlendModesKeepTheirDistinctAlphaFactors() + { + var tracker = new GlStateTracker(); + + tracker.SetBlend(true, EnumBlendMode.Glow); + Assert.Equal(BlendFactor.One, tracker.BlendFor(0).SrcAlpha); + Assert.Equal(BlendFactor.Zero, tracker.BlendFor(0).DstAlpha); + + tracker.SetBlend(true, EnumBlendMode.Overlay); + Assert.Equal(BlendFactor.One, tracker.BlendFor(0).SrcAlpha); + Assert.Equal(BlendFactor.One, tracker.BlendFor(0).DstAlpha); + + tracker.SetBlend(true, EnumBlendMode.Multiply); + Assert.Equal(BlendFactor.One, tracker.BlendFor(0).SrcAlpha); + Assert.Equal(BlendFactor.OneMinusSrcAlpha, tracker.BlendFor(0).DstAlpha); + } + + /// + /// GL's colour mask is global and Vulkan's is per attachment, so setting it + /// has to reach every one of them. + /// + [Fact] + public void ColorMaskAppliesToEveryAttachment() + { + var tracker = new GlStateTracker(); + tracker.SetColorMask(true, false, true, false); + + for (int attachment = 0; attachment < GlStateTracker.MaxColorAttachments; attachment++) + { + ColorComponentFlags mask = tracker.BlendFor(attachment).WriteMask; + Assert.Equal(ColorComponentFlags.RBit | ColorComponentFlags.BBit, mask); + } + } + + /// + /// SystemRenderOITLayers sets blend per attachment. Touching one must not + /// disturb its neighbours. + /// + [Fact] + public void PerAttachmentBlendLeavesOtherAttachmentsAlone() + { + var tracker = new GlStateTracker(); + tracker.SetBlend(true, EnumBlendMode.Standard); + + // GL_ONE, GL_ONE on attachment 3, as the OIT accumulation pass sets. + tracker.SetAttachmentBlendFunc(3, 1, 1, 1, 1); + + Assert.Equal(BlendFactor.One, tracker.BlendFor(3).SrcColor); + Assert.Equal(BlendFactor.One, tracker.BlendFor(3).DstColor); + Assert.Equal(BlendFactor.SrcAlpha, tracker.BlendFor(0).SrcColor); + Assert.Equal(BlendFactor.OneMinusSrcAlpha, tracker.BlendFor(0).DstColor); + } + + // -------------------------------------------------------------- interning + + [Fact] + public void IdenticalBlendStateInternsToTheSameId() + { + var tracker = new GlStateTracker(); + + tracker.SetBlend(true, EnumBlendMode.Standard); + int first = tracker.BlendId(2); + + tracker.SetBlend(false, EnumBlendMode.Standard); + tracker.SetBlend(true, EnumBlendMode.Standard); + int second = tracker.BlendId(2); + + Assert.Equal(first, second); + } + + [Fact] + public void ChangingBlendStateProducesADifferentId() + { + var tracker = new GlStateTracker(); + + tracker.SetBlend(true, EnumBlendMode.Standard); + int standard = tracker.BlendId(2); + + tracker.SetBlend(true, EnumBlendMode.Glow); + int glow = tracker.BlendId(2); + + Assert.NotEqual(standard, glow); + } + + /// + /// The id is cached between changes so a run of draws sharing state pays + /// nothing, but a change has to invalidate it. Getting this wrong would pin + /// the wrong pipeline for every subsequent draw. + /// + [Fact] + public void EveryMutatorInvalidatesTheCachedBlendId() + { + var mutations = new (string Name, Action Apply)[] + { + ("SetBlend", t => t.SetBlend(true, EnumBlendMode.Glow)), + ("SetColorMask", t => t.SetColorMask(true, true, false, true)), + ("SetAttachmentBlendFunc", t => t.SetAttachmentBlendFunc(0, 1, 1, 1, 1)), + ("SetAttachmentBlendEquation", t => t.SetAttachmentBlendEquation(0, 0x800A)), + }; + + foreach ((string name, Action apply) in mutations) + { + var tracker = new GlStateTracker(); + int before = tracker.BlendId(4); + apply(tracker); + Assert.True(before != tracker.BlendId(4), $"{name} did not invalidate the cached blend id"); + } + } + + /// + /// The packing squeezes eight fields into 32 bits. A collision there would + /// silently merge two different blend states onto one pipeline. + /// + [Fact] + public void AttachmentBlendPackingIsCollisionFreeAcrossTheUsedRange() + { + var seen = new Dictionary(); + var factors = new[] + { + BlendFactor.Zero, BlendFactor.One, BlendFactor.SrcAlpha, + BlendFactor.OneMinusSrcAlpha, BlendFactor.DstColor, BlendFactor.SrcAlphaSaturate, + }; + var ops = new[] { BlendOp.Add, BlendOp.Subtract, BlendOp.ReverseSubtract, BlendOp.Min, BlendOp.Max }; + + foreach (bool enabled in new[] { false, true }) + foreach (BlendFactor srcColor in factors) + foreach (BlendFactor dstColor in factors) + foreach (BlendOp colorOp in ops) + foreach (BlendFactor srcAlpha in factors) + { + var blend = new AttachmentBlend + { + Enabled = enabled, + SrcColor = srcColor, + DstColor = dstColor, + ColorOp = colorOp, + SrcAlpha = srcAlpha, + DstAlpha = BlendFactor.One, + AlphaOp = BlendOp.Add, + WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit, + }; + + uint packed = blend.Pack(); + if (seen.TryGetValue(packed, out AttachmentBlend existing)) + { + Assert.True(existing.Equals(blend), $"packing collision at 0x{packed:X8}"); + } + seen[packed] = blend; + } + + Assert.True(seen.Count > 1000, "the sweep should have covered a wide range"); + } + + // ------------------------------------------------------------ pipeline key + + [Fact] + public void PipelineKeysCompareByValue() + { + var a = new PipelineKey(1, 2, 3, 4, PolygonMode.Fill, 2); + var b = new PipelineKey(1, 2, 3, 4, PolygonMode.Fill, 2); + var c = new PipelineKey(1, 2, 3, 4, PolygonMode.Line, 2); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a, c); + } + + /// + /// Dynamic state must not reach the key: if it did, every viewport or depth + /// change would compile a new pipeline. + /// + [Fact] + public void DynamicStateDoesNotChangeThePipelineKey() + { + var tracker = new GlStateTracker(); + int target = tracker.InternTargetFormats( + new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); + + PipelineKey before = tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1); + + tracker.SetViewport(0, 0, 1920, 1080); + tracker.SetScissor(10, 10, 100, 100); + tracker.SetScissorEnabled(true); + tracker.SetDepthTest(true); + tracker.SetDepthWrite(false); + tracker.SetDepthFunc(0x0203); + tracker.SetCullEnabled(true); + tracker.SetCullBack(false); + tracker.SetStencilTest(true); + tracker.SetStencilFunc(0x0202, 1, 0xFF); + tracker.SetStencilOp(0x1E00, 0x1E00, 0x1E01); + tracker.SetLineWidth(2.5f); + + Assert.Equal(before, tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1)); + } + + [Fact] + public void PipelineStateDoesChangeThePipelineKey() + { + var tracker = new GlStateTracker(); + int target = tracker.InternTargetFormats( + new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); + + PipelineKey before = tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1); + + tracker.SetWireframe(true); + Assert.NotEqual(before, tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1)); + + tracker.SetWireframe(false); + tracker.SetProgram(42); + Assert.NotEqual(before, tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1)); + } + + /// + /// Topology is dynamic within a class but not across one, so lines and + /// triangles need separate pipelines while line list and line strip share. + /// + [Fact] + public void TopologyClassSeparatesLinesFromTrianglesButNotLineStrips() + { + var tracker = new GlStateTracker(); + int target = tracker.InternTargetFormats( + new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); + + tracker.SetTopology(EnumDrawMode.Triangles); + PipelineKey triangles = tracker.BuildKey(0, target, 1); + + tracker.SetTopology(EnumDrawMode.Lines); + PipelineKey lines = tracker.BuildKey(0, target, 1); + + tracker.SetTopology(EnumDrawMode.LineStrip); + PipelineKey lineStrip = tracker.BuildKey(0, target, 1); + + Assert.NotEqual(triangles, lines); + Assert.Equal(lines, lineStrip); + } + + [Fact] + public void RenderTargetFormatsInternByValue() + { + var tracker = new GlStateTracker(); + + int first = tracker.InternTargetFormats( + new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm, Format.R16G16B16A16Sfloat }, Format.D32Sfloat)); + int same = tracker.InternTargetFormats( + new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm, Format.R16G16B16A16Sfloat }, Format.D32Sfloat)); + int different = tracker.InternTargetFormats( + new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); + + Assert.Equal(first, same); + Assert.NotEqual(first, different); + } + + // ------------------------------------------------------------- translations + + [Theory] + [InlineData(0x0200, CompareOp.Never)] + [InlineData(0x0201, CompareOp.Less)] + [InlineData(0x0202, CompareOp.Equal)] + [InlineData(0x0203, CompareOp.LessOrEqual)] + [InlineData(0x0204, CompareOp.Greater)] + [InlineData(0x0205, CompareOp.NotEqual)] + [InlineData(0x0206, CompareOp.GreaterOrEqual)] + [InlineData(0x0207, CompareOp.Always)] + public void GlComparisonConstantsMapToVulkanCompareOps(int glFunc, CompareOp expected) + { + Assert.Equal(expected, GlEnums.CompareOpFrom(glFunc)); + } + + /// + /// GL folds the mipmap mode into the minification filter constant; Vulkan + /// splits them. LINEAR_MIPMAP_LINEAR is trilinear, which the block atlas + /// relies on. + /// + [Theory] + [InlineData(0x2600, Filter.Nearest, SamplerMipmapMode.Nearest)] + [InlineData(0x2601, Filter.Linear, SamplerMipmapMode.Nearest)] + [InlineData(0x2703, Filter.Linear, SamplerMipmapMode.Linear)] + [InlineData(0x2702, Filter.Nearest, SamplerMipmapMode.Linear)] + public void GlMinificationFiltersSplitIntoFilterAndMipmapMode( + int glFilter, Filter expectedFilter, SamplerMipmapMode expectedMode) + { + (Filter filter, SamplerMipmapMode mode) = GlEnums.MinFilterFrom(glFilter); + Assert.Equal(expectedFilter, filter); + Assert.Equal(expectedMode, mode); + } + + /// + /// RGB has no guaranteed colour-attachment support in Vulkan, so the vanilla + /// RGB8 revealage target is promoted to RGBA8 rather than failing. + /// + [Fact] + public void ThreeChannelFormatsArePromotedToFourChannels() + { + Assert.Equal(Format.R8G8B8A8Unorm, GlEnums.TextureFormatFromGl(0x8051)); + Assert.Equal(Format.R8G8B8A8Unorm, GlEnums.TextureFormatFromGl(0x1907)); + } + + [Fact] + public void DepthAndFloatFormatsMapExactly() + { + Assert.Equal(Format.D32Sfloat, GlEnums.TextureFormatFromGl(0x8DAB)); + Assert.Equal(Format.R16G16B16A16Sfloat, GlEnums.TextureFormatFromGl(0x881A)); + Assert.Equal(Format.R16Sfloat, GlEnums.TextureFormatFromGl(0x822D)); + Assert.Equal(Format.R32G32B32A32Sfloat, GlEnums.TextureFormatFromGl(0x8814)); + } + + /// + /// Not a preference: GL's counter-clockwise front face, read in a Vulkan + /// framebuffer that was never flipped, is clockwise. The game never calls + /// glFrontFace, so this is a constant and flipping it would invert culling + /// everywhere. + /// + [Fact] + public void FrontFaceIsClockwiseToMatchUnflippedGlWinding() + { + Assert.Equal(FrontFace.Clockwise, GlStateTracker.FrontFace); + } + + [Fact] + public void ResetRestoresTheDefaultsAFreshContextWouldHave() + { + var tracker = new GlStateTracker(); + tracker.SetDepthTest(true); + tracker.SetWireframe(true); + tracker.SetBlend(true, EnumBlendMode.Glow); + tracker.SetProgram(9); + + tracker.Reset(); + + Assert.False(tracker.DepthTest); + Assert.True(tracker.DepthWrite); + Assert.Equal(PolygonMode.Fill, tracker.PolygonMode); + Assert.Equal(CompareOp.Less, tracker.DepthCompare); + Assert.Equal(0, tracker.CurrentProgram); + Assert.False(tracker.BlendFor(0).Enabled); + } +} diff --git a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs new file mode 100644 index 00000000..a0e82e81 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Covers mesh creation and drawing. +/// +/// The layout derivation is the part worth pinning. The game allocates one buffer +/// per attribute and assigns attribute locations by walking the parts in a fixed +/// order, skipping absent ones - so a mesh with positions and colours but no +/// normals or UVs puts colours at location 1. The chunk shaders' explicit +/// locations depend on exactly that, and getting it wrong renders garbage rather +/// than failing. +/// +public class MeshManagerTests +{ + private readonly ITestOutputHelper _output; + + public MeshManagerTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + [SkippableFact] + public void AbsentPartsDoNotConsumeAttributeLocations() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + // Positions and colours only: no normals, no UVs, no flags. + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, + rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: true, ssbo: false); + + VertexLayoutDescription layout = meshes.LayoutOf(meshes.LayoutIdOf(mesh)); + + Assert.Equal(2, layout.Bindings.Length); + Assert.Equal(2, layout.Attributes.Length); + + // Colours take location 1 because normals and UVs were absent. + Assert.Equal(0u, layout.Attributes[0].Location); + Assert.Equal(Format.R32G32B32Sfloat, layout.Attributes[0].Format); + Assert.Equal(1u, layout.Attributes[1].Location); + Assert.Equal(Format.R8G8B8A8Unorm, layout.Attributes[1].Format); + } + } + + /// + /// The full chunk-style layout: positions, UVs, colours, flags and a custom + /// integer part, which is what chunkopaque.vsh declares at locations 0 to 4. + /// + [SkippableFact] + public void TheChunkVertexLayoutMatchesTheShadersDeclaredLocations() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + // Count stays 0 until values are added, and AllocationSize reports + // Count - so this is exactly the "declared but not yet filled" case + // that must still claim location 4. + var customInts = new CustomMeshDataPartInt(4) + { + InterleaveSizes = new[] { 1 }, + InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, + Conversion = DataConversion.Integer, + }; + Assert.Equal(0, customInts.AllocationSize); + + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 4 * 2 * sizeof(float), + rgbaSize: 4 * 4, flagsSize: 4 * sizeof(int), indicesSize: 6 * sizeof(int), + null, null, null, customInts, EnumDrawMode.Triangles, staticDraw: true, ssbo: false); + + VertexLayoutDescription layout = meshes.LayoutOf(meshes.LayoutIdOf(mesh)); + var byLocation = layout.Attributes.ToDictionary(a => a.Location, a => a.Format); + + Assert.Equal(Format.R32G32B32Sfloat, byLocation[0]); // xyz + Assert.Equal(Format.R32G32Sfloat, byLocation[1]); // uv + Assert.Equal(Format.R8G8B8A8Unorm, byLocation[2]); // rgbaLight + // Signed, because every chunk shader declares these as `in int`. + // GL let an unsigned attribute pointer feed a signed input by + // reinterpreting the bits; Vulkan requires the attribute format's + // numeric type to match the shader's exactly, and a mismatch is + // undefined behaviour rather than a reinterpretation. + Assert.Equal(Format.R32Sint, byLocation[3]); // renderFlags + Assert.Equal(Format.R32Sint, byLocation[4]); // colormapData + } + } + + /// + /// With SSBO vertex fetch the chunk shaders read positions from a storage + /// buffer keyed on gl_VertexIndex, so the position buffer must leave the + /// vertex input entirely rather than being bound twice. + /// + [SkippableFact] + public void TheSsboPathTakesPositionsOutOfTheVertexInput() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 4 * 2 * sizeof(float), + rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: true, ssbo: true); + + VulkanMesh created = meshes.Get(mesh)!; + VertexLayoutDescription layout = meshes.LayoutOf(created.LayoutId); + + // The buffer still exists, and still carries the positions. + Assert.NotNull(created.Buffers[MeshManager.BufferXyz]); + Assert.DoesNotContain(MeshManager.BufferXyz, created.BindingOrder); + + // UVs now take location 0, since positions are no longer an input. + Assert.Equal(2, layout.Attributes.Length); + Assert.Equal(Format.R32G32Sfloat, layout.Attributes[0].Format); + } + } + + [SkippableFact] + public void MeshIdsBehaveLikeGlNamesIncludingReuse() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + int first = meshes.CreateEmpty(48, 0, 0, 16, 0, 24, null, null, null, null, + EnumDrawMode.Triangles, true, false); + int second = meshes.CreateEmpty(48, 0, 0, 16, 0, 24, null, null, null, null, + EnumDrawMode.Triangles, true, false); + + Assert.True(first > 0); + Assert.NotEqual(first, second); + Assert.Null(meshes.Get(0)); + + meshes.Delete(first); + Assert.Null(meshes.Get(first)); + Assert.Equal(first, meshes.CreateEmpty(48, 0, 0, 16, 0, 24, null, null, null, null, + EnumDrawMode.Triangles, true, false)); + } + } + + /// + /// Identical layouts intern to one id so they share a pipeline; a different + /// set of parts must not. + /// + [SkippableFact] + public void IdenticalLayoutsShareAnIdAndDifferentOnesDoNot() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + int a = meshes.CreateEmpty(48, 0, 0, 16, 0, 24, null, null, null, null, + EnumDrawMode.Triangles, true, false); + int b = meshes.CreateEmpty(96, 0, 0, 32, 0, 48, null, null, null, null, + EnumDrawMode.Triangles, true, false); + int c = meshes.CreateEmpty(48, 0, 32, 16, 0, 24, null, null, null, null, + EnumDrawMode.Triangles, true, false); + + // Same parts, different sizes: one layout. + Assert.Equal(meshes.LayoutIdOf(a), meshes.LayoutIdOf(b)); + // UVs added: a different layout. + Assert.NotEqual(meshes.LayoutIdOf(a), meshes.LayoutIdOf(c)); + } + } + + /// + /// The end-to-end mesh check: build a quad, write it through the persistent + /// mapping the way the tesselator does, draw it indexed, and read the pixels. + /// + [SkippableFact] + public unsafe void AnIndexedMeshRendersWithItsVertexColours() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 16; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!, state); + using var compiler = new ShaderCompiler(); + + int target = textures.Create(size, size, Format.R8G8B8A8Unorm); + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, target); + targets.SetDrawBuffers(framebuffer, 0b1); + + // A full-target quad: positions plus colours, no normals or UVs, so + // colours land at location 1. + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, + rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, null, EnumDrawMode.Triangles, staticDraw: false, ssbo: false); + + float[] positions = + { + -1f, -1f, 0f, + 1f, -1f, 0f, + 1f, 1f, 0f, + -1f, 1f, 0f, + }; + byte[] colors = + { + 255, 0, 0, 255, + 255, 0, 0, 255, + 255, 0, 0, 255, + 255, 0, 0, 255, + }; + int[] indices = { 0, 1, 2, 0, 2, 3 }; + + fixed (float* p = positions) meshes.Write(mesh, MeshManager.BufferXyz, 0, (IntPtr)p, positions.Length * 4); + fixed (byte* c = colors) meshes.Write(mesh, MeshManager.BufferRgba, 0, (IntPtr)c, colors.Length); + fixed (int* i = indices) meshes.Write(mesh, -1, 0, (IntPtr)i, indices.Length * 4); + + TranslatedProgram translated = ShaderTranslator.Translate(new[] + { + new ShaderStageSource + { + Stage = EnumShaderType.VertexShader, + Filename = "mesh.vsh", + Code = """ + #version 330 core + layout(location = 0) in vec3 position; + layout(location = 1) in vec4 color; + out vec4 vertexColor; + void main(void) + { + gl_Position = vec4(position, 1.0); + vertexColor = color; + } + """, + }, + new ShaderStageSource + { + Stage = EnumShaderType.FragmentShader, + Filename = "mesh.fsh", + Code = """ + #version 330 core + in vec4 vertexColor; + out vec4 outColor; + void main(void) { outColor = vertexColor; } + """, + }, + }, compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(meshes.LayoutIdOf(mesh), formatsId, 1), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = meshes.LayoutOf(meshes.LayoutIdOf(mesh)), + Targets = formats, + Blend = new[] { state.BlendFor(0) }, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + + Vk api = context!.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + SetDynamicDefaults(api, commandBuffer); + + meshes.Draw(commandBuffer, mesh); + targets.EndRendering(commandBuffer); + }); + + byte[] pixels = ReadTexture(context!, commands, textures, target, size); + + // The quad covers the target, so the middle is the vertex colour. + int centre = (int)((size / 2 * size + size / 2) * 4); + Assert.Equal(255, pixels[centre + 0]); + Assert.Equal(0, pixels[centre + 1]); + Assert.Equal(255, pixels[centre + 3]); + + AssertNoValidationErrors(messages); + } + } + + private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) + { + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, false); + api.CmdSetDepthWriteEnable(commandBuffer, false); + api.CmdSetDepthCompareOp(commandBuffer, CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + } + + private static unsafe byte[] ReadTexture( + VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * 4; + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[(int)bytes]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } + + private static void AssertNoValidationErrors(List messages) + { + // Only what the layers reported at error severity. Advisories - a + // fragment output with no attachment, say - are prefixed as warnings and + // are not failures; treating every message as one made these assertions + // fire on notes about correct frames. + var errors = messages + .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, + StringComparison.Ordinal)) + .ToList(); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/Optimum.Render.Vulkan.Tests.csproj b/Optimum.Render.Vulkan.Tests/Optimum.Render.Vulkan.Tests.csproj new file mode 100644 index 00000000..0f0c389a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/Optimum.Render.Vulkan.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + false + true + annotations + + + + + + + + + + + + + + + + + + ..\.vanilla\win-x64\vintagestory\VintagestoryAPI.dll + true + + + + diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs new file mode 100644 index 00000000..6c9ef648 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Runs real game shaders through the whole chain on a real device: translate, +/// build descriptor layouts from the program's interface, create pipelines, and +/// check the cache behaves. +/// +/// This is where a mistake in the descriptor-set design surfaces. The rewriter +/// decides that samplers live in set 1 and storage buffers in set 2; nothing +/// validates that decision until a driver is asked to build a pipeline layout +/// from it alongside the SPIR-V that assumes it. +/// +public class PipelineCacheTests +{ + private readonly ITestOutputHelper _output; + + public PipelineCacheTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context, List messages) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + private static TranslatedProgram TranslateVanilla(string programName, ShaderCompiler compiler) + { + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = + ShaderCorpus.Variants().First(v => v.Name == "everything-on"); + + return ShaderTranslator.Translate( + ShaderCorpus.BuildProgram(programName, files, includes, variant), compiler); + } + + /// + /// final.fsh is the heaviest post-processing program in the game: five + /// samplers and twenty-odd loose uniforms, several of them shared with the + /// vertex stage. If a descriptor layout can be built for it, the scheme holds. + /// + [SkippableFact] + public void AVanillaProgramProducesUsableDescriptorAndPipelineLayouts() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context, messages), "No usable Vulkan device."); + + using (context) + { + using var compiler = new ShaderCompiler(); + TranslatedProgram translated = TranslateVanilla("final", compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, programId: 1, translated); + + _output.WriteLine($"uniform block : {translated.Layout.BlockSize} bytes, " + + $"{translated.Layout.Members.Count} members"); + _output.WriteLine($"samplers : {translated.Layout.Samplers.Count}"); + _output.WriteLine($"uniform blocks: {translated.Layout.UniformBlocks.Count}"); + _output.WriteLine($"storage blocks: {translated.Layout.StorageBlocks.Count}"); + + Assert.NotEqual(0ul, program.PipelineLayout.Handle); + foreach (DescriptorSetLayout layout in program.SetLayouts) + { + Assert.NotEqual(0ul, layout.Handle); + } + + // The composition pass samples the scene, bloom, glow, godrays and + // SSAO, so it must have real sampler bindings. + Assert.True(translated.Layout.Samplers.Count >= 4); + Assert.True(translated.Layout.BlockSize > 0); + + AssertNoValidationErrors(messages); + } + } + + /// + /// The chunk program is the one with a storage buffer at a binding the shader + /// declared. Building a layout for it checks that set 2 and binding 3 line up + /// between the rewriter and the descriptor layout. + /// + [SkippableFact] + public void TheChunkProgramsStorageBufferLandsWhereTheShaderExpectsIt() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context, messages), "No usable Vulkan device."); + + using (context) + { + using var compiler = new ShaderCompiler(); + TranslatedProgram translated = TranslateVanilla("chunkopaque", compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + BlockBinding storage = Assert.Single(translated.Layout.StorageBlocks); + Assert.Equal(ProgramInterfaceLayout.StorageSet, storage.Set); + Assert.Equal(3, storage.Binding); + + using var program = new ShaderProgramResources(context!, programId: 2, translated); + Assert.NotEqual(0ul, program.PipelineLayout.Handle); + + AssertNoValidationErrors(messages); + } + } + + /// + /// The cache has to return the same pipeline for the same state and a new one + /// when the state actually differs. Getting the first wrong leaks pipelines + /// and stutters; getting the second wrong renders with the wrong state. + /// + [SkippableFact] + public void PipelinesAreReusedForIdenticalStateAndRebuiltForDifferentState() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context, messages), "No usable Vulkan device."); + + using (context) + { + using var compiler = new ShaderCompiler(); + TranslatedProgram translated = TranslateVanilla("blit", compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, programId: 3, translated); + using var cache = new GraphicsPipelineCache(context!); + + var tracker = new GlStateTracker(); + tracker.SetProgram(3); + + var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); + int targetId = tracker.InternTargetFormats(targets); + int layoutId = 0; + + GraphicsPipelineCache.PipelineRequest Request() => new() + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = targets, + Blend = new[] { tracker.BlendFor(0) }, + PolygonMode = tracker.PolygonMode, + Topology = tracker.Topology, + }; + + Pipeline first = cache.Get(tracker.BuildKey(layoutId, targetId, 1), Request()); + Assert.Equal(1, cache.Count); + Assert.Equal(1, cache.Misses); + + // Same state: served from the cache, no new pipeline. + Pipeline again = cache.Get(tracker.BuildKey(layoutId, targetId, 1), Request()); + Assert.Equal(first.Handle, again.Handle); + Assert.Equal(1, cache.Count); + Assert.Equal(1, cache.Hits); + + // Dynamic state must not force a rebuild. + tracker.SetViewport(0, 0, 800, 600); + tracker.SetDepthTest(true); + cache.Get(tracker.BuildKey(layoutId, targetId, 1), Request()); + Assert.Equal(1, cache.Count); + + // Blend state is baked in, so this one does. + tracker.SetBlend(true, EnumBlendMode.Glow); + Pipeline blended = cache.Get(tracker.BuildKey(layoutId, targetId, 1), Request()); + Assert.NotEqual(first.Handle, blended.Handle); + Assert.Equal(2, cache.Count); + + _output.WriteLine($"pipelines: {cache.Count}, hits: {cache.Hits}, misses: {cache.Misses}"); + AssertNoValidationErrors(messages); + } + } + + /// + /// Compiles a pipeline for every vanilla program that has no vertex inputs - + /// the fullscreen post-processing passes - to show the cache stays small + /// rather than growing one entry per program per frame. + /// + [SkippableFact] + public void FullscreenPassesShareOnePipelinePerProgram() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context, messages), "No usable Vulkan device."); + + using (context) + { + using var compiler = new ShaderCompiler(); + using var cache = new GraphicsPipelineCache(context!); + var tracker = new GlStateTracker(); + + var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); + int targetId = tracker.InternTargetFormats(targets); + + var programs = new List(); + string[] names = { "blit", "final", "luma", "findbright", "godrays" }; + + try + { + int programId = 100; + foreach (string name in names) + { + TranslatedProgram translated = TranslateVanilla(name, compiler); + Assert.True(translated.Success, $"{name}: {string.Join("; ", translated.Errors)}"); + + var resources = new ShaderProgramResources(context!, programId, translated); + programs.Add(resources); + + tracker.SetProgram(programId); + cache.Get(tracker.BuildKey(0, targetId, 1), new GraphicsPipelineCache.PipelineRequest + { + Program = resources, + VertexLayout = VertexLayoutDescription.Empty, + Targets = targets, + Blend = new[] { tracker.BlendFor(0) }, + PolygonMode = tracker.PolygonMode, + Topology = tracker.Topology, + }); + + programId++; + } + + // One pipeline each, and drawing them again adds nothing. + Assert.Equal(names.Length, cache.Count); + + byte[] blob = cache.SerializeDriverCache(); + _output.WriteLine($"{cache.Count} pipelines, driver cache blob {blob.Length} bytes"); + + AssertNoValidationErrors(messages); + } + finally + { + foreach (ShaderProgramResources resources in programs) resources.Dispose(); + } + } + } + + private static void AssertNoValidationErrors(List messages) + { + // Only what the layers reported at error severity. Advisories - a + // fragment output with no attachment, say - are prefixed as warnings and + // are not failures; treating every message as one made these assertions + // fire on notes about correct frames. + var errors = messages + .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, + StringComparison.Ordinal)) + .ToList(); + + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs new file mode 100644 index 00000000..8412019d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Covers render targets, and specifically the semantics of glDrawBuffers. +/// +/// This is the least obvious behaviour in the whole backend. glDrawBuffers does +/// not mask writes, it selects which attachments take part, and the game leans on +/// that: the final composition pass renders into the primary framebuffer's +/// attachment 0 while sampling its attachment 1. Reproducing it as a write mask +/// would either corrupt the glow buffer or trip a feedback-loop error. +/// +public class RenderTargetTests +{ + private readonly ITestOutputHelper _output; + + public RenderTargetTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + private const string SingleOutputVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + /// + /// The composition case: one output, two attachments, only the first + /// selected. Attachment 1 must come through untouched. + /// + [SkippableFact] + public unsafe void AnAttachmentLeftOutOfDrawBuffersIsNotWritten() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 16; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int colorTexture = textures.Create(size, size, Format.R8G8B8A8Unorm); + int glowTexture = textures.Create(size, size, Format.R8G8B8A8Unorm); + + // Seed both so "unchanged" is distinguishable from "cleared". + FillTexture(context!, commands, textures, colorTexture, size, 0x11); + FillTexture(context!, commands, textures, glowTexture, size, 0x77); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, colorTexture); + targets.Attach(framebuffer, 1, glowTexture); + targets.SetDrawBuffers(framebuffer, 0b01); // attachment 0 only + + TranslatedProgram translated = Translate(compiler, SingleOutputVertex, """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4(1.0, 0.0, 0.0, 1.0); } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + byte[] color = ReadTexture(context!, commands, textures, colorTexture, size); + byte[] glow = ReadTexture(context!, commands, textures, glowTexture, size); + + // Attachment 0 was drawn into. + Assert.Equal(255, color[0]); + Assert.Equal(0, color[1]); + + // Attachment 1 kept every byte it started with. + Assert.All(glow, b => Assert.Equal(0x77, b)); + + AssertNoValidationErrors(messages); + } + } + + /// + /// With both attachments selected, a two-output shader writes both. This is + /// the ordinary MRT case the opaque pass uses for colour and glow. + /// + [SkippableFact] + public unsafe void SelectedAttachmentsAllReceiveTheirMatchingOutput() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 16; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int colorTexture = textures.Create(size, size, Format.R8G8B8A8Unorm); + int glowTexture = textures.Create(size, size, Format.R8G8B8A8Unorm); + FillTexture(context!, commands, textures, colorTexture, size, 0x00); + FillTexture(context!, commands, textures, glowTexture, size, 0x00); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, colorTexture); + targets.Attach(framebuffer, 1, glowTexture); + targets.SetDrawBuffers(framebuffer, 0b11); + + TranslatedProgram translated = Translate(compiler, SingleOutputVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + void main(void) + { + outColor = vec4(1.0, 0.0, 0.0, 1.0); + outGlow = vec4(0.0, 1.0, 0.0, 1.0); + } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 2, translated); + state.SetProgram(2); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + byte[] color = ReadTexture(context!, commands, textures, colorTexture, size); + byte[] glow = ReadTexture(context!, commands, textures, glowTexture, size); + + Assert.Equal(255, color[0]); // red + Assert.Equal(0, color[1]); + Assert.Equal(0, glow[0]); + Assert.Equal(255, glow[1]); // green + + AssertNoValidationErrors(messages); + } + } + + /// + /// Changing the draw-buffer mask changes which attachments participate, so + /// the open scope no longer describes the target and has to be restarted. + /// + [SkippableFact] + public unsafe void ChangingTheDrawBufferMaskRestartsTheRenderingScope() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + + int a = textures.Create(size, size, Format.R8G8B8A8Unorm); + int b = textures.Create(size, size, Format.R8G8B8A8Unorm); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, a); + targets.Attach(framebuffer, 1, b); + targets.SetDrawBuffers(framebuffer, 0b01); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + Assert.Equal(1, targets.ScopesOpened); + + // Same mask: the scope stands. + targets.EnsureRendering(commandBuffer); + Assert.Equal(1, targets.ScopesOpened); + + targets.SetDrawBuffers(framebuffer, 0b11); + targets.EnsureRendering(commandBuffer); + Assert.Equal(2, targets.ScopesOpened); + + targets.EndRendering(commandBuffer); + }); + + AssertNoValidationErrors(messages); + } + } + + /// + /// The attachment formats fed to the pipeline must match the attachments the + /// scope was opened with, including the gaps: a disabled slot is Undefined, + /// which keeps fragment output N aimed at slot N. + /// + [SkippableFact] + public void DisabledAttachmentsReportAnUndefinedFormatToThePipeline() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + + int framebuffer = targets.Create(8, 8); + for (int i = 0; i < 4; i++) + { + targets.Attach(framebuffer, i, textures.Create(8, 8, Format.R8G8B8A8Unorm)); + } + + // The OIT pass draws to 0 and 3 while leaving 1 and 2 out. + targets.SetDrawBuffers(framebuffer, 0b1001); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + RenderTargetFormats formats = state.TargetFormats(targets.FormatsIdOf(bound)); + + Assert.Equal(4, formats.ColorFormats.Length); + Assert.Equal(Format.R8G8B8A8Unorm, formats.ColorFormats[0]); + Assert.Equal(Format.Undefined, formats.ColorFormats[1]); + Assert.Equal(Format.Undefined, formats.ColorFormats[2]); + Assert.Equal(Format.R8G8B8A8Unorm, formats.ColorFormats[3]); + } + } + + // ------------------------------------------------------------------ helpers + + private static TranslatedProgram Translate(ShaderCompiler compiler, string vertex, string fragment) => + ShaderTranslator.Translate(new[] + { + new ShaderStageSource { Stage = EnumShaderType.VertexShader, Code = vertex, Filename = "t.vsh" }, + new ShaderStageSource { Stage = EnumShaderType.FragmentShader, Code = fragment, Filename = "t.fsh" }, + }, compiler); + + private static unsafe void RenderFullscreen( + VulkanContext context, VulkanCommands commands, RenderTargetManager targets, + GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + int framebuffer, uint size) + { + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + int attachmentCount = targets.EnabledAttachmentCount(bound); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(0, formatsId, attachmentCount), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + + Vk api = context.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, false); + api.CmdSetDepthWriteEnable(commandBuffer, false); + api.CmdSetDepthCompareOp(commandBuffer, CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + targets.EndRendering(commandBuffer); + }); + } + + private static unsafe void FillTexture( + VulkanContext context, VulkanCommands commands, TextureManager textures, + int textureId, uint size, byte value) + { + var pixels = new byte[size * size * 4]; + Array.Fill(pixels, value); + fixed (byte* data = pixels) + { + textures.Upload(textureId, 0, 0, 0, size, size, (IntPtr)data, 4); + } + } + + private static unsafe byte[] ReadTexture( + VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * 4; + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[(int)bytes]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } + + private static void AssertNoValidationErrors(List messages) + { + // Only what the layers reported at error severity. Advisories - a + // fragment output with no attachment, say - are prefixed as warnings and + // are not failures; treating every message as one made these assertions + // fire on notes about correct frames. + var errors = messages + .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, + StringComparison.Ordinal)) + .ToList(); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs new file mode 100644 index 00000000..11e0f261 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Loads the game's shaders the way the client does, so translation is tested +/// against what actually reaches the driver rather than against the files on disk. +/// +/// Two steps matter. ShaderRegistry expands #include before compiling, and +/// it de-duplicates per program, so a shader that pulls in fogandlight.fsh twice +/// gets it once. And registerDefaultShaderCodePrefixes prepends a block of +/// #defines whose values change which declarations survive the +/// preprocessor - a shader compiled with SSAOLEVEL 0 declares different uniforms +/// than the same file at SSAOLEVEL 2. +/// +internal static class ShaderCorpus +{ + /// + /// The vanilla assets, or null when this checkout has not bootstrapped. The + /// shaders are proprietary and never committed, so tests that need them skip + /// rather than fail when they are absent. + /// + public static string? AssetRoot => _assetRoot ??= FindAssetRoot(); + private static string? _assetRoot; + + private static string? FindAssetRoot() + { + string? fromEnvironment = Environment.GetEnvironmentVariable("VINTAGE_STORY_ASSETS"); + if (!string.IsNullOrEmpty(fromEnvironment) && + Directory.Exists(Path.Combine(fromEnvironment, "shaders"))) + { + return fromEnvironment; + } + + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + string candidate = Path.Combine( + directory.FullName, ".vanilla", "win-x64", "vintagestory", "assets", "game"); + if (Directory.Exists(Path.Combine(candidate, "shaders"))) + { + return candidate; + } + directory = directory.Parent; + } + + return null; + } + + /// The repository root, found by walking up to the solution file. + public static string RepositoryRoot => _repositoryRoot ??= FindRepositoryRoot(); + private static string? _repositoryRoot; + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "VintageStory.slnx"))) + { + return directory.FullName; + } + directory = directory.Parent; + } + throw new DirectoryNotFoundException("Repository root with VintageStory.slnx not found."); + } + + /// + /// Every shader file, with Optimum's own overlays replacing their vanilla + /// counterparts - which is what `make deploy` copies over the install, so it + /// is what actually runs. + /// + public static Dictionary LoadShaderFiles() + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (AssetRoot != null) + { + foreach (string path in Directory.EnumerateFiles(Path.Combine(AssetRoot, "shaders"))) + { + files[Path.GetFileName(path)] = File.ReadAllText(path); + } + } + + string overlays = Path.Combine(RepositoryRoot, "sources", "shaders"); + if (Directory.Exists(overlays)) + { + foreach (string path in Directory.EnumerateFiles(overlays)) + { + files[Path.GetFileName(path)] = File.ReadAllText(path); + } + } + + return files; + } + + public static Dictionary LoadIncludes() + { + var includes = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (AssetRoot == null) return includes; + + string directory = Path.Combine(AssetRoot, "shaderincludes"); + if (!Directory.Exists(directory)) return includes; + + foreach (string path in Directory.EnumerateFiles(directory)) + { + includes[Path.GetFileName(path)] = File.ReadAllText(path); + } + return includes; + } + + /// + /// Program base names that have both a vertex and a fragment shader, which is + /// what ShaderRegistry requires of a loadable program. + /// + public static List ProgramNames(Dictionary files) + { + return files.Keys + .Where(name => name.EndsWith(".vsh", StringComparison.OrdinalIgnoreCase)) + .Select(name => name[..^4]) + .Where(name => files.ContainsKey(name + ".fsh")) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + } + + private static readonly Regex IncludePattern = + new(@"^#include\s+(.*)", RegexOptions.Multiline | RegexOptions.Compiled); + + /// + /// Mirrors ShaderRegistry.HandleIncludes, including its de-duplication: a + /// file already pulled into this program expands to nothing the second time. + /// + public static string ExpandIncludes( + string code, Dictionary includes, HashSet? seen = null) + { + seen ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + return IncludePattern.Replace(code, match => + { + string filename = match.Groups[1].Value.Trim().ToLowerInvariant(); + if (!seen.Add(filename)) return ""; + return includes.TryGetValue(filename, out string? included) + ? ExpandIncludes(included, includes, seen) + : ""; + }); + } + + /// A set of the defines the client injects per program. + public sealed class ShaderVariant + { + public string Name = ""; + public int Fxaa; + public int SsaoLevel; + public int Bloom; + public int GodRays; + public int ShadowQuality; + public int DynLights; + public int UseSsbo; + public int WavingStuff = 1; + public int FoamEffect = 1; + public int ShinyEffect = 1; + public int NormalView; + public int UseOit = 1; + public int GreedyMesh; + public float MinBright; + public int MaxAnimatedElements = 35; + + public override string ToString() => Name; + } + + /// + /// Variants chosen to move every define that gates a declaration. The two + /// extremes catch the common cases; the middle rows catch the combinations + /// where one feature is on and its neighbours are not, which is where a + /// conditional uniform is most likely to be missed. + /// + public static IEnumerable Variants() + { + // USEOIT tracks ShaderProgramBase.Oit, which defaults to true for every + // program; only the second Entityanimated registration turns it off. The + // shaders that call into oit.fsh do so unconditionally, so a global + // USEOIT 0 is not a configuration the client can produce. + yield return new ShaderVariant + { + Name = "everything-off", + WavingStuff = 0, FoamEffect = 0, ShinyEffect = 0, + }; + yield return new ShaderVariant + { + Name = "everything-on", + Fxaa = 1, SsaoLevel = 2, Bloom = 1, GodRays = 2, ShadowQuality = 2, + DynLights = 8, UseSsbo = 1, NormalView = 1, GreedyMesh = 1, MinBright = 0.1f, + }; + yield return new ShaderVariant + { + Name = "ssao-only", + SsaoLevel = 1, DynLights = 4, + }; + yield return new ShaderVariant + { + Name = "shadows-and-ssbo", + ShadowQuality = 2, DynLights = 8, UseSsbo = 1, + }; + } + + /// + /// Reproduces registerDefaultShaderCodePrefixes, including Optimum's own + /// greedy-mesh defines from the ShaderRegistry patch. + /// + public static string PrefixFor(EnumShaderType stage, ShaderVariant variant) + { + var lines = new List(); + + if (stage == EnumShaderType.FragmentShader) + { + lines.Add($"#define FXAA {variant.Fxaa}"); + lines.Add($"#define SSAOLEVEL {variant.SsaoLevel}"); + lines.Add($"#define NORMALVIEW {variant.NormalView}"); + lines.Add($"#define BLOOM {variant.Bloom}"); + lines.Add($"#define GODRAYS {variant.GodRays}"); + lines.Add($"#define FOAMEFFECT {variant.FoamEffect}"); + lines.Add($"#define SHINYEFFECT {variant.ShinyEffect}"); + lines.Add($"#define SHADOWQUALITY {variant.ShadowQuality}"); + lines.Add($"#define DYNLIGHTS {variant.DynLights}"); + lines.Add($"#define USEOIT {variant.UseOit}"); + lines.Add($"#define GREEDYMESH {variant.GreedyMesh}"); + lines.Add($"#define GREEDYMESH_GRAD 0"); + } + else + { + lines.Add($"#define USESSBO {variant.UseSsbo}"); + lines.Add($"#define WAVINGSTUFF {variant.WavingStuff}"); + lines.Add($"#define FOAMEFFECT {variant.FoamEffect}"); + lines.Add($"#define SSAOLEVEL {variant.SsaoLevel}"); + lines.Add($"#define NORMALVIEW {variant.NormalView}"); + lines.Add($"#define SHINYEFFECT {variant.ShinyEffect}"); + lines.Add($"#define GODRAYS {variant.GodRays}"); + lines.Add($"#define MINBRIGHT {variant.MinBright.ToString(System.Globalization.CultureInfo.InvariantCulture)}"); + lines.Add($"#define SHADOWQUALITY {variant.ShadowQuality}"); + lines.Add($"#define DYNLIGHTS {variant.DynLights}"); + lines.Add($"#define MAXANIMATEDELEMENTS {variant.MaxAnimatedElements}"); + lines.Add($"#define GREEDYMESH {variant.GreedyMesh}"); + } + + return string.Join("\r\n", lines) + "\r\n"; + } + + /// Builds the two stages of one program, ready for translation. + public static List BuildProgram( + string programName, + Dictionary files, + Dictionary includes, + ShaderVariant variant) + { + var stages = new List(); + + foreach ((string extension, EnumShaderType stage) in new[] + { + (".vsh", EnumShaderType.VertexShader), + (".fsh", EnumShaderType.FragmentShader), + (".gsh", EnumShaderType.GeometryShader), + }) + { + if (!files.TryGetValue(programName + extension, out string? code)) continue; + + stages.Add(new ShaderStageSource + { + Stage = stage, + Code = ExpandIncludes(code, includes), + PrefixCode = PrefixFor(stage, variant), + Filename = programName + extension, + }); + } + + return stages; + } +} diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs new file mode 100644 index 00000000..1063b530 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The gate on the whole backend: every shader the client actually loads has to +/// survive translation to SPIR-V. +/// +/// If a shader cannot be translated automatically it would have to be +/// hand-ported, and hand-porting does not scale to mod shaders, which are GLSL +/// authored by third parties and only exist at runtime. So this is not a +/// nice-to-have test - a failure here means the approach does not hold. +/// +public class ShaderTranslationTests +{ + private readonly ITestOutputHelper _output; + + public ShaderTranslationTests(ITestOutputHelper output) => _output = output; + + [SkippableFact] + public void EveryVanillaProgramTranslatesToSpirv() + { + Skip.If(ShaderCorpus.AssetRoot == null, + "No bootstrapped game assets; run scripts/bootstrap.sh or set VINTAGE_STORY_ASSETS."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var programs = ShaderCorpus.ProgramNames(files); + + Assert.NotEmpty(programs); + + using var compiler = new ShaderCompiler(); + var failures = new List(); + int translated = 0; + + foreach (ShaderCorpus.ShaderVariant variant in ShaderCorpus.Variants()) + { + foreach (string program in programs) + { + var stages = ShaderCorpus.BuildProgram(program, files, includes, variant); + TranslatedProgram result = ShaderTranslator.Translate(stages, compiler); + + if (result.Success) + { + translated++; + continue; + } + + failures.Add($"[{variant.Name}] {program}: {string.Join("; ", result.Errors)}"); + } + } + + _output.WriteLine($"{translated} program/variant combinations translated, {failures.Count} failed."); + + if (failures.Count > 0) + { + var report = new StringBuilder(); + report.Append(failures.Count).Append(" shader program(s) failed to translate:\n"); + foreach (string failure in failures.Take(40)) + { + report.Append(" ").Append(failure).Append('\n'); + } + Assert.Fail(report.ToString()); + } + } + + [SkippableFact] + public void TranslatedProgramsProduceValidSpirvForEveryStage() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = ShaderCorpus.Variants().First(v => v.Name == "everything-on"); + + using var compiler = new ShaderCompiler(); + + foreach (string program in ShaderCorpus.ProgramNames(files)) + { + var stages = ShaderCorpus.BuildProgram(program, files, includes, variant); + TranslatedProgram result = ShaderTranslator.Translate(stages, compiler); + + Assert.True(result.Success, $"{program}: {string.Join("; ", result.Errors)}"); + Assert.Equal(stages.Count, result.Spirv.Count); + + foreach (KeyValuePair stage in result.Spirv) + { + byte[] spirv = stage.Value; + Assert.True(spirv.Length >= 20, $"{program} {stage.Key}: SPIR-V too short"); + Assert.True(spirv.Length % 4 == 0, $"{program} {stage.Key}: SPIR-V not word-aligned"); + + // 0x07230203 is the SPIR-V magic number. + uint magic = BitConverter.ToUInt32(spirv, 0); + Assert.True(magic == 0x07230203u, + $"{program} {stage.Key}: bad SPIR-V magic 0x{magic:X8}"); + } + } + } + + /// + /// The chunk shaders are the ones that would hurt most to hand-port: they + /// carry the SSBO vertex-fetch path, Optimum's greedy-mesh decode, and the + /// heaviest include graph in the game. + /// + [SkippableFact] + public void ChunkShadersTranslateWithSsboAndGreedyMeshEnabled() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = ShaderCorpus.Variants().First(v => v.Name == "everything-on"); + + using var compiler = new ShaderCompiler(); + + foreach (string program in new[] { "chunkopaque", "chunktransparent", "chunktopsoil", "chunkliquid" }) + { + Skip.IfNot(files.ContainsKey(program + ".vsh"), $"{program} not present"); + + var stages = ShaderCorpus.BuildProgram(program, files, includes, variant); + TranslatedProgram result = ShaderTranslator.Translate(stages, compiler); + + Assert.True(result.Success, $"{program}: {string.Join("; ", result.Errors)}"); + + // The storage buffer must keep the binding the shader declared: the + // mesh path binds the vertex buffer to that exact index. + if (program is "chunkopaque" or "chunktransparent" or "chunktopsoil") + { + BlockBinding? faceData = result.Layout.StorageBlocks + .FirstOrDefault(b => b.BlockName == "faceDataBuf"); + Assert.NotNull(faceData); + Assert.Equal(3, faceData!.Binding); + Assert.True(faceData.Explicit, "declared binding should be preserved, not reassigned"); + } + } + } + + /// + /// final.fsh declares "uniform float extraGamma = 1.0;" and never assigns it + /// unless colour grading is active. GL applies declared defaults at link time, + /// so the shadow buffer has to start out carrying them or the screen comes + /// back black. + /// + [SkippableFact] + public void DeclaredUniformDefaultsReachTheShadowBuffer() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = ShaderCorpus.Variants().First(v => v.Name == "everything-on"); + + using var compiler = new ShaderCompiler(); + var stages = ShaderCorpus.BuildProgram("final", files, includes, variant); + TranslatedProgram result = ShaderTranslator.Translate(stages, compiler); + + Assert.True(result.Success, string.Join("; ", result.Errors)); + + UniformMember member = result.Layout.MembersByName["extraGamma"]; + Assert.Equal("1.0", member.Initializer); + + byte[] shadow = result.Layout.CreateShadowBuffer(); + Assert.Equal(1.0f, BitConverter.ToSingle(shadow, member.Offset), 5); + } + + /// + /// A uniform named in both stages is one uniform in GL. zNear and zFar come + /// from the fogandlight includes and appear on both sides. + /// + [SkippableFact] + public void UniformsSharedBetweenStagesGetOneSlot() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = ShaderCorpus.Variants().First(v => v.Name == "everything-on"); + + using var compiler = new ShaderCompiler(); + var stages = ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + TranslatedProgram result = ShaderTranslator.Translate(stages, compiler); + + Assert.True(result.Success, string.Join("; ", result.Errors)); + + var names = result.Layout.Members.Select(m => m.Name).ToList(); + Assert.Equal(names.Count, names.Distinct(StringComparer.Ordinal).Count()); + + // Offsets must not overlap. + var ordered = result.Layout.Members.OrderBy(m => m.Offset).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + Assert.True(ordered[i].Offset >= ordered[i - 1].Offset + ordered[i - 1].Size, + $"'{ordered[i].Name}' overlaps '{ordered[i - 1].Name}'"); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs new file mode 100644 index 00000000..d939caae --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs @@ -0,0 +1,482 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Unit coverage for the translation pieces, independent of the game assets. +/// +/// The corpus test proves the pipeline works on the real shaders, but it needs a +/// bootstrapped checkout and takes seconds. These run anywhere in milliseconds and +/// pin the specific behaviours that were wrong at least once while building it. +/// +public class ShaderTranslationUnitTests +{ + private static ParsedShader Parse(string source) => GlslParser.Parse(source); + + private static ProgramInterfaceLayout LayoutOf(params (EnumShaderType Stage, string Source)[] stages) + { + var parsed = stages.Select(s => (s.Stage, Parse(s.Source))).ToList(); + return ProgramInterfaceLayout.Build(parsed); + } + + // ------------------------------------------------------------ scalar layout + + /// + /// The reason this backend requests scalar block layout: array strides stay + /// tight, so a float[] from the game lands in the block as a memcpy. Under + /// std140 a vec3[] would stride 16 and every upload would need re-striding. + /// + [Fact] + public void ScalarLayoutKeepsArrayStridesTight() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ + #version 330 core + uniform vec3 pointLights[4]; + uniform float density; + void main() {} + """)); + + UniformMember lights = layout.MembersByName["pointLights"]; + UniformMember density = layout.MembersByName["density"]; + + Assert.Equal(0, lights.Offset); + Assert.Equal(4 * 12, lights.Size); + // Tight packing: the next member starts immediately after, with no + // rounding up to a 16-byte boundary. + Assert.Equal(48, density.Offset); + Assert.Equal(52, layout.BlockSize); + } + + [Fact] + public void MatrixTypesUseGlslColumnMajorSizes() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ + #version 330 core + uniform mat4 projection; + uniform mat4x3 bones[2]; + uniform mat3 normalMatrix; + void main() {} + """)); + + Assert.Equal(64, layout.MembersByName["projection"].Size); + // matCxR is C columns of R rows: mat4x3 is 4 * 3 * 4 = 48 bytes, which is + // exactly what UniformMatrices4x3 uploads per matrix. + Assert.Equal(2 * 48, layout.MembersByName["bones"].Size); + Assert.Equal(36, layout.MembersByName["normalMatrix"].Size); + } + + // ------------------------------------------------------------------- parsing + + [Fact] + public void ArraySizeIsAcceptedOnEitherTheTypeOrTheName() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, """ + #version 330 core + uniform vec3[64] samples; + uniform vec4 rects[40]; + void main() {} + """)); + + Assert.Equal(64, layout.MembersByName["samples"].ArrayLength); + Assert.Equal(40, layout.MembersByName["rects"].ArrayLength); + } + + /// + /// fogandlight.vsh declares uniform vec4 fogSpheres[3 * 8];. GLSL + /// allows any constant expression there. + /// + [Theory] + [InlineData("8", 8)] + [InlineData("3 * 8", 24)] + [InlineData("2 + 3 * 4", 14)] + [InlineData("(2 + 3) * 4", 20)] + [InlineData("16u", 16)] + [InlineData("-4 + 8", 4)] + public void ConstantArraySizeExpressionsAreEvaluated(string expression, int expected) + { + Assert.True(GlslParser.TryEvaluateConstantInt(expression, out int value)); + Assert.Equal(expected, value); + } + + [Theory] + [InlineData("MAX_LIGHTS")] + [InlineData("3 *")] + [InlineData("")] + [InlineData("4 / 0")] + public void UnresolvableArraySizesAreRejectedRatherThanGuessed(string expression) + { + Assert.False(GlslParser.TryEvaluateConstantInt(expression, out _)); + } + + [Fact] + public void DeclaredUniformDefaultsArePreserved() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, """ + #version 330 core + uniform float extraGamma = 1.0; + uniform int steps = 3; + void main() {} + """)); + + byte[] shadow = layout.CreateShadowBuffer(); + Assert.Equal(1.0f, BitConverter.ToSingle(shadow, layout.MembersByName["extraGamma"].Offset), 5); + Assert.Equal(3, BitConverter.ToInt32(shadow, layout.MembersByName["steps"].Offset)); + } + + /// + /// "void main" must read as two tokens. Accumulating identifiers across the + /// whitespace skip once merged them into one nine-character name, so no stage + /// ever found its entry point. + /// + [Fact] + public void MainIsFoundAcrossWhitespaceAndCommentsAndArgumentForms() + { + Assert.True(Parse("#version 330 core\nvoid main(void) { }").HasMain); + Assert.True(Parse("#version 330 core\nvoid main () { }").HasMain); + Assert.True(Parse("#version 330 core\nvoid /* c */ main() { }").HasMain); + Assert.True(Parse("#version 330 core\nfloat helper() { return 1.0; }\nvoid main() { }").HasMain); + } + + [Fact] + public void FunctionsNamedLikeMainDoNotCountAsTheEntryPoint() + { + Assert.False(Parse("#version 330 core\nvoid mainImage() { }").HasMain); + Assert.False(Parse("#version 330 core\nvoid domain() { }").HasMain); + } + + // ------------------------------------------------------------------ bindings + + /// + /// chunkopaque.vsh declares layout(binding = 3, std430) readonly buffer + /// faceDataBuf and the mesh path binds the vertex buffer to that exact + /// index, so the declared binding has to survive. It also has to be + /// recognised at all: failing to step over "readonly" left it unclassified. + /// + [Fact] + public void DeclaredStorageBufferBindingsSurviveMemoryQualifiers() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ + #version 330 core + struct FaceData { vec4 position; }; + layout(binding = 3, std430) readonly buffer faceDataBuf { FaceData faces[]; }; + void main() {} + """)); + + BlockBinding block = Assert.Single(layout.StorageBlocks); + Assert.Equal("faceDataBuf", block.BlockName); + Assert.Equal(3, block.Binding); + Assert.True(block.Explicit); + Assert.Equal(ProgramInterfaceLayout.StorageSet, block.Set); + } + + [Fact] + public void SamplersBecomeDescriptorsRatherThanBlockMembers() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, """ + #version 330 core + uniform sampler2D terrainTex; + uniform sampler2DShadow shadowMapFar; + uniform float gamma; + void main() {} + """)); + + Assert.Equal(2, layout.Samplers.Count); + Assert.Equal(0, layout.SamplersByName["terrainTex"].Binding); + Assert.Equal(1, layout.SamplersByName["shadowMapFar"].Binding); + Assert.DoesNotContain("terrainTex", layout.MembersByName.Keys); + Assert.Equal(4, layout.BlockSize); + } + + // ----------------------------------------------------------------- rewriting + + private static string RewriteVertex(string source, ProgramInterfaceLayout layout) => + ShaderRewriter.Rewrite(Parse(source), layout, EnumShaderType.VertexShader, emitDepthRemap: true).Code; + + [Fact] + public void RewritingBumpsTheVersionAndWrapsMainForTheVulkanDepthRange() + { + const string source = """ + #version 330 core + uniform float scale; + void main(void) { gl_Position = vec4(scale, 0, 0, 1); } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, source)); + string code = RewriteVertex(source, layout); + + Assert.Contains("#version 450", code); + Assert.DoesNotContain("#version 330", code); + Assert.Contains("void _optimum_main(void)", code); + Assert.Contains("gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;", code); + + // No Y flip: GL and Vulkan agree on how clip space maps to memory, and + // flipping here would invert every render-to-texture round trip. + Assert.DoesNotContain("gl_Position.y = -gl_Position.y", code); + } + + [Fact] + public void LooseUniformsMoveIntoTheGeneratedBlockWithExplicitOffsets() + { + const string source = """ + #version 330 core + uniform float zNear; + uniform vec3 tint; + void main() {} + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, source)); + string code = RewriteVertex(source, layout); + + Assert.Contains("layout(scalar, set = 0, binding = 0) uniform OptimumUniforms", code); + Assert.Contains("layout(offset = 0) float zNear;", code); + Assert.Contains("layout(offset = 4) vec3 tint;", code); + // The originals are gone, so the names resolve to the block members. + Assert.DoesNotContain("uniform float zNear;", code); + } + + /// + /// bilateralblur declares uniform vec2 frameSize in the vertex stage + /// and in vec2 frameSize in the fragment stage. Emitting the union of + /// the program's uniforms into both stages redefines the varying, so each + /// stage gets only what it declared. + /// + [Fact] + public void TheGeneratedBlockCarriesOnlyTheMembersEachStageDeclared() + { + const string vertex = """ + #version 330 core + uniform vec2 frameSize; + uniform float shared1; + void main() {} + """; + const string fragment = """ + #version 330 core + in vec2 frameSize; + uniform float shared1; + out vec4 outColor; + void main() { outColor = vec4(frameSize, shared1, 1); } + """; + + ProgramInterfaceLayout layout = LayoutOf( + (EnumShaderType.VertexShader, vertex), + (EnumShaderType.FragmentShader, fragment)); + + string fragmentCode = ShaderRewriter + .Rewrite(Parse(fragment), layout, EnumShaderType.FragmentShader, emitDepthRemap: false).Code; + + Assert.Contains("shared1", fragmentCode); + // The uniform belongs to the vertex stage only; here the name is a varying. + Assert.DoesNotContain("vec2 frameSize;\n};", fragmentCode.Replace("\r", "")); + Assert.Contains("in vec2 frameSize;", fragmentCode); + } + + [Fact] + public void VaryingsGetMatchingLocationsInBothStages() + { + const string vertex = """ + #version 330 core + out vec2 texCoord; + flat out float intensity; + void main() {} + """; + const string fragment = """ + #version 330 core + in vec2 texCoord; + flat in float intensity; + out vec4 outColor; + void main() { outColor = vec4(texCoord, intensity, 1); } + """; + + ProgramInterfaceLayout layout = LayoutOf( + (EnumShaderType.VertexShader, vertex), + (EnumShaderType.FragmentShader, fragment)); + + Assert.NotEqual(layout.VaryingLocations["texCoord"], layout.VaryingLocations["intensity"]); + + string vertexCode = ShaderRewriter + .Rewrite(Parse(vertex), layout, EnumShaderType.VertexShader, emitDepthRemap: true).Code; + string fragmentCode = ShaderRewriter + .Rewrite(Parse(fragment), layout, EnumShaderType.FragmentShader, emitDepthRemap: false).Code; + + string expected = $"layout(location = {layout.VaryingLocations["texCoord"]}) out vec2 texCoord;"; + Assert.Contains(expected, vertexCode); + Assert.Contains(expected.Replace(" out ", " in "), fragmentCode); + } + + [Fact] + public void ExplicitAttributeLocationsAreLeftWhereTheShaderPutThem() + { + const string vertex = """ + #version 330 core + layout(location = 0) in vec3 vertexPositionIn; + layout(location = 3) in int renderFlagsIn; + in vec2 extra; + void main() {} + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, vertex)); + + // The unqualified one fills the lowest free slot, around the stated ones. + Assert.Equal(1, layout.VertexInputLocations["extra"]); + Assert.DoesNotContain("vertexPositionIn", layout.VertexInputLocations.Keys); + } + + [Fact] + public void FragmentOutputsWithoutLocationsGetThem() + { + const string fragment = """ + #version 330 core + out vec4 outColor; + void main() { outColor = vec4(1); } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, fragment)); + string code = ShaderRewriter + .Rewrite(Parse(fragment), layout, EnumShaderType.FragmentShader, emitDepthRemap: false).Code; + + Assert.Contains("layout(location = 0) out vec4 outColor;", code); + } + + // ------------------------------------------------------------ reserved words + + /// + /// ssao.fsh uses "sample" as a local, which 4.00 turned into a qualifier. + /// Vulkan forces the version bump, so the name has to move. + /// + [Fact] + public void IdentifiersReservedByTheNewerLanguageAreRenamed() + { + string renamed = GlslReservedWords.Rename("vec3 sample = texture(t, uv).rgb; float x = sample.r;"); + + Assert.DoesNotContain(" sample ", renamed); + Assert.Contains("_optimum_kw_sample", renamed); + } + + [Fact] + public void VulkanSpellingsOfBuiltInsAreSubstituted() + { + string renamed = GlslReservedWords.Rename("int i = gl_VertexID + gl_InstanceID;"); + + Assert.Equal("int i = gl_VertexIndex + gl_InstanceIndex;", renamed); + } + + /// + /// A word after a dot is a field or a swizzle, never a declaration. Renaming + /// it would rewrite a member of somebody else's struct. + /// + [Fact] + public void FieldsAndSwizzlesKeepTheirNames() + { + Assert.Equal("value.sample = 1.0;", GlslReservedWords.Rename("value.sample = 1.0;")); + Assert.Equal("a.filter", GlslReservedWords.Rename("a.filter")); + } + + /// + /// "buffer" is deliberately not renamed: it is a storage qualifier in the + /// chunk shaders, and renaming it would break the SSBO declaration the mesh + /// path depends on. + /// + [Fact] + public void StorageQualifiersAreNotTreatedAsRenameableIdentifiers() + { + const string declaration = "layout(binding = 3, std430) readonly buffer faceDataBuf { vec4 f[]; };"; + Assert.Equal(declaration, GlslReservedWords.Rename(declaration)); + } + + [Fact] + public void SourceWithNothingToRenameIsReturnedUnchanged() + { + const string source = "#version 330 core\nvoid main() { gl_Position = vec4(0); }"; + Assert.Same(source, GlslReservedWords.Rename(source)); + } + + // ------------------------------------------------------------ untouched code + + /// + /// The rewriter edits spans and copies everything else verbatim, so a + /// construct it does not model survives rather than being mangled. That is + /// what lets third-party mod shaders through. + /// + [Fact] + public void UnrecognisedConstructsPassThroughVerbatim() + { + const string source = """ + #version 330 core + struct Light { vec3 position; float radius; }; + const int MAX = 4; + float attenuate(Light l, vec3 p) { return l.radius / distance(l.position, p); } + void main() {} + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, source)); + string code = RewriteVertex(source, layout); + + Assert.Contains("struct Light { vec3 position; float radius; };", code); + Assert.Contains("const int MAX = 4;", code); + Assert.Contains("float attenuate(Light l, vec3 p)", code); + } + + /// + /// shaderc enforces a #version floor of 140 during preprocessing, before the + /// rewriter can raise the version itself, so a lower-versioned source has to + /// be lifted first. The client's hardcoded minimal-GUI program is written to + /// 130 and is the reason this exists. + /// + [Theory] + [InlineData("#version 130\nvoid main() {}", "#version 450")] + [InlineData("#version 110\r\nvoid main() {}", "#version 450")] + [InlineData("#version 120 \nvoid main() {}", "#version 450")] + public void PreprocessingRaisesVersionsBelowShadercFloor(string source, string expected) + { + Assert.StartsWith(expected, ShaderCompiler.RaiseVersionForPreprocessing(source)); + } + + /// + /// Samplers are descriptor bindings rather than members of the generated + /// uniform block, but the client resolves every declared uniform by name and + /// reads -1 as "the shader does not use this". Returning -1 for samplers told + /// it that every texture uniform in the game was unused. + /// + [Fact] + public void SamplersResolveToLocationsDistinctFromBlockOffsets() + { + const string source = """ + #version 330 core + uniform sampler2D terrainTex; + uniform sampler2D terrainTexLinear; + uniform float alphaTest; + void main() {} + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + + Assert.Equal(2, layout.Samplers.Count); + Assert.Contains(layout.Samplers, s => s.Name == "terrainTex"); + Assert.Contains(layout.Samplers, s => s.Name == "terrainTexLinear"); + + // A sampler is not a block member, so it has no byte offset to hand out. + Assert.DoesNotContain("terrainTex", layout.MembersByName.Keys); + Assert.Contains("alphaTest", layout.MembersByName.Keys); + } + + /// + /// Sources already at or above the floor are left exactly as written - every + /// vanilla shader is in this group, so translation must not shift underneath + /// them. + /// + [Theory] + [InlineData("#version 140\nvoid main() {}")] + [InlineData("#version 330 core\nvoid main() {}")] + [InlineData("#version 450\nvoid main() {}")] + [InlineData("void main() {}")] + [InlineData("")] + public void PreprocessingLeavesAcceptableVersionsAlone(string source) + { + Assert.Equal(source, ShaderCompiler.RaiseVersionForPreprocessing(source)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SwapchainTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs new file mode 100644 index 00000000..93ddf401 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs @@ -0,0 +1,324 @@ +using System; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Optimum.Render.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Exercises the presentation path against a real window. +/// +/// This is the one part of the backend that cannot be tested headlessly: a +/// swapchain needs a surface, and a surface needs a window. The window is created +/// with ClientApi.NoApi, which is exactly the change the client needs - +/// GLFW must not create an OpenGL context alongside the Vulkan surface. +/// +/// Skips where there is no display or no Vulkan-capable window system, so a +/// headless CI machine reports these as skipped rather than failing. +/// +public class SwapchainTests +{ + private readonly ITestOutputHelper _output; + + public SwapchainTests(ITestOutputHelper output) => _output = output; + + /// + /// Creates a hidden window with no graphics API attached, the way the + /// patched client will. + /// + private static unsafe bool TryCreateWindow( + ITestOutputHelper output, int width, int height, out Window* window) + { + window = null; + try + { + if (!GLFW.Init()) + { + output.WriteLine("GLFW could not initialise; no display?"); + return false; + } + + if (!GLFW.VulkanSupported()) + { + output.WriteLine("GLFW reports no Vulkan support on this window system."); + return false; + } + + GLFW.WindowHint(WindowHintClientApi.ClientApi, ClientApi.NoApi); + GLFW.WindowHint(WindowHintBool.Visible, false); + + window = GLFW.CreateWindow(width, height, "Optimum swapchain test", null, null); + if (window == null) + { + output.WriteLine("GLFW could not create a window."); + return false; + } + return true; + } + catch (Exception error) + { + output.WriteLine("Windowing unavailable: " + error.Message); + return false; + } + } + + [SkippableFact] + public unsafe void ADeviceComesUpAgainstARealWindowAndPresentsFrames() + { + const int width = 320; + const int height = 240; + + Skip.IfNot(TryCreateWindow(_output, width, height, out Window* window), "No usable window system."); + + try + { + var device = new VulkanDevice { DebugMode = true }; + if (!device.Initialize((IntPtr)window, width, height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + using (device) + { + IOptimumGraphicsDevice seam = device; + _output.WriteLine($"presenting on {seam.RendererString}"); + + int programId = LinkFullscreenProgram(seam); + + // Several frames, so the ring rotates and the swapchain cycles + // through more than one image. + for (int frame = 0; frame < 8; frame++) + { + seam.BeginFrame(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.1f, 0.2f, 0.3f, 1f); + + seam.UseProgram(programId); + seam.SetViewport(0, 0, width, height); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.DrawFullscreenTriangle(); + + seam.Present(); + } + + AssertClean(seam); + } + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + /// + /// A resize has to rebuild both the swapchain and the offscreen target the + /// client renders into, and keep presenting afterwards. + /// + [SkippableFact] + public unsafe void ResizingRebuildsTheChainAndKeepsPresenting() + { + const int width = 256; + const int height = 192; + + Skip.IfNot(TryCreateWindow(_output, width, height, out Window* window), "No usable window system."); + + try + { + var device = new VulkanDevice { DebugMode = true }; + if (!device.Initialize((IntPtr)window, width, height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + using (device) + { + IOptimumGraphicsDevice seam = device; + int programId = LinkFullscreenProgram(seam); + + void RenderFrames(int count, int w, int h) + { + for (int frame = 0; frame < count; frame++) + { + seam.BeginFrame(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, w, h); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + } + + RenderFrames(4, width, height); + + seam.Resize(width * 2, height * 2); + RenderFrames(4, width * 2, height * 2); + + seam.Resize(width, height); + RenderFrames(4, width, height); + + AssertClean(seam); + } + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + /// + /// Toggling vsync swaps the present mode, which means rebuilding the chain + /// while frames are in flight. + /// + [SkippableFact] + public unsafe void TogglingVsyncRebuildsTheChainCleanly() + { + const int width = 256; + const int height = 192; + + Skip.IfNot(TryCreateWindow(_output, width, height, out Window* window), "No usable window system."); + + try + { + var device = new VulkanDevice { DebugMode = true }; + if (!device.Initialize((IntPtr)window, width, height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + using (device) + { + IOptimumGraphicsDevice seam = device; + int programId = LinkFullscreenProgram(seam); + + foreach (bool vsync in new[] { false, true, false }) + { + seam.SetVSync(vsync); + for (int frame = 0; frame < 3; frame++) + { + seam.BeginFrame(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, width, height); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + } + + AssertClean(seam); + } + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + private sealed class TestShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private static int LinkFullscreenProgram(IOptimumGraphicsDevice device) + { + var vertex = new TestShader + { + Type = EnumShaderType.VertexShader, + Code = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """, + }; + var fragment = new TestShader + { + Type = EnumShaderType.FragmentShader, + Code = """ + #version 330 core + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = vec4(uv, 0.5, 1.0); } + """, + }; + + Assert.True(device.CompileShader(vertex)); + Assert.True(device.CompileShader(fragment)); + + var program = new SeamProgram { VertexShader = vertex, FragmentShader = fragment }; + int programId = device.LinkProgram(program); + Assert.True(programId > 0, device.GetError() ?? "link failed"); + return programId; + } + + /// Minimal IShaderProgram; the device only reads the stage properties. + private sealed class SeamProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId => 0; + public string PassName => "swapchain-test"; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + + public void Use() { } + public void Stop() { } + public bool Compile() => true; + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + public bool HasUniform(string uniformName) => false; + } + + private static void AssertClean(IOptimumGraphicsDevice device) + { + string? diagnostics = device.GetError(); + if (diagnostics == null) return; + + Assert.False( + diagnostics.Contains("Error", StringComparison.OrdinalIgnoreCase) + || diagnostics.Contains("VUID", StringComparison.Ordinal), + "validation errors:\n" + diagnostics); + } +} diff --git a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs new file mode 100644 index 00000000..f2216ea7 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs @@ -0,0 +1,323 @@ +using System; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Covers the texture handle table, the sampler cache, and a real upload and +/// readback through the GPU. +/// +/// The handle table matters more than it looks: the game's public API exposes raw +/// texture ids as fields that mods read and hand back +/// (LoadedTexture.TextureId, FrameBufferRef.ColorTextureIds), so +/// ids have to behave like GL names including reuse after deletion. +/// +public class TextureManagerTests +{ + private readonly ITestOutputHelper _output; + + public TextureManagerTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context) + { + var options = new VulkanContextOptions { Headless = true, EnableValidation = true }; + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + [SkippableFact] + public void TextureIdsBehaveLikeGlNamesIncludingReuse() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + int first = textures.Create(16, 16, Format.R8G8B8A8Unorm); + int second = textures.Create(16, 16, Format.R8G8B8A8Unorm); + + // Zero is never a real texture, as in GL. + Assert.True(first > 0); + Assert.NotEqual(first, second); + Assert.Null(textures.Get(0)); + Assert.NotNull(textures.Get(first)); + + textures.Delete(first); + Assert.Null(textures.Get(first)); + + // A freed name is handed out again rather than growing the table. + int third = textures.Create(8, 8, Format.R8G8B8A8Unorm); + Assert.Equal(first, third); + Assert.Equal(2, textures.Count); + } + } + + /// + /// glTexParameter changes state on the texture and touches no GPU object. + /// The sampler only materialises when the texture is bound. + /// + [SkippableFact] + public void TextureParametersUpdateSamplerStateWithoutCreatingObjects() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + int id = textures.Create(16, 16, Format.R8G8B8A8Unorm); + + textures.SetParameter(id, GlEnums.TextureMagFilter, 0x2601); // GL_LINEAR + textures.SetParameter(id, GlEnums.TextureMinFilter, 0x2703); // LINEAR_MIPMAP_LINEAR + textures.SetParameter(id, GlEnums.TextureWrapS, 0x812F); // CLAMP_TO_EDGE + textures.SetParameter(id, GlEnums.TextureLodBias, -0.5f); + textures.SetParameter(id, GlEnums.TextureCompareMode, GlEnums.TextureCompareRefToTexture); + + SamplerState state = textures.Get(id)!.State; + Assert.Equal(Filter.Linear, state.MagFilter); + Assert.Equal(Filter.Linear, state.MinFilter); + Assert.Equal(SamplerMipmapMode.Linear, state.MipmapMode); + Assert.Equal(SamplerAddressMode.ClampToEdge, state.AddressU); + Assert.Equal(SamplerAddressMode.Repeat, state.AddressV); + Assert.Equal(-0.5f, state.LodBias); + Assert.True(state.CompareEnable); + + // Nothing has been bound, so no sampler exists yet. + Assert.Equal(0, textures.Samplers.Count); + } + } + + /// + /// Optimum's FSR path sets a negative LOD bias on the terrain samplers, so a + /// bias change has to produce a genuinely different sampler rather than + /// reusing one that ignores it. + /// + [SkippableFact] + public void SamplersInternByStateAndDistinguishLodBias() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var cache = new SamplerCache(context!); + + SamplerState nearest = SamplerState.Default; + SamplerState alsoNearest = SamplerState.Default; + SamplerState biased = SamplerState.Default with { LodBias = -0.58f }; + SamplerState linear = SamplerState.Default with { MagFilter = Filter.Linear }; + + Assert.Equal(cache.Get(nearest).Handle, cache.Get(alsoNearest).Handle); + Assert.NotEqual(cache.Get(nearest).Handle, cache.Get(biased).Handle); + Assert.NotEqual(cache.Get(nearest).Handle, cache.Get(linear).Handle); + Assert.Equal(3, cache.Count); + + // Repeating the same requests adds nothing. + for (int i = 0; i < 100; i++) cache.Get(nearest); + Assert.Equal(3, cache.Count); + } + } + + /// + /// The end-to-end check for the texture path: bytes uploaded from the CPU come + /// back byte-identical off the GPU. + /// + [SkippableFact] + public unsafe void UploadedPixelsSurviveARoundTripThroughTheGpu() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + const uint size = 8; + int id = textures.Create(size, size, Format.R8G8B8A8Unorm); + + var source = new byte[size * size * 4]; + for (int i = 0; i < source.Length; i++) source[i] = (byte)(i * 7 % 251); + + fixed (byte* pixels = source) + { + textures.Upload(id, 0, 0, 0, size, size, (IntPtr)pixels, bytesPerPixel: 4); + } + + VulkanTexture texture = textures.Get(id)!; + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, texture.Layout); + + using var readback = new VulkanBuffer(context!, (ulong)source.Length, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context!.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[source.Length]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + + Assert.Equal(source, result); + } + } + + /// + /// The block atlas is mipmapped, and the chain is built by successive blits. + /// A wrong barrier here shows up as validation errors, not wrong pixels. + /// + [SkippableFact] + public unsafe void MipmapGenerationBuildsTheWholeChainCleanly() + { + var messages = new System.Collections.Generic.List(); + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + Skip.IfNot(VulkanContext.TryCreate(options, out VulkanContext? context, out string? reason), reason ?? ""); + + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + const uint size = 64; + int id = textures.Create(size, size, Format.R8G8B8A8Unorm, generateMipmaps: true); + + VulkanTexture texture = textures.Get(id)!; + Assert.Equal(TextureManager.MipLevelsFor(size, size), texture.MipLevels); + Assert.Equal(7u, texture.MipLevels); // 64, 32, 16, 8, 4, 2, 1 + + var source = new byte[size * size * 4]; + Array.Fill(source, (byte)200); + fixed (byte* pixels = source) + { + textures.Upload(id, 0, 0, 0, size, size, (IntPtr)pixels, bytesPerPixel: 4); + } + + textures.GenerateMipmaps(id); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, texture.Layout); + + var errors = messages.FindAll(m => + m.Contains("Error", StringComparison.OrdinalIgnoreCase) || m.Contains("VUID", StringComparison.Ordinal)); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } + } + + [Theory] + [InlineData(1u, 1u, 1u)] + [InlineData(2u, 2u, 2u)] + [InlineData(64u, 64u, 7u)] + [InlineData(1024u, 512u, 11u)] + [InlineData(4096u, 4096u, 13u)] + public void MipLevelCountMatchesTheGlRule(uint width, uint height, uint expected) + { + Assert.Equal(expected, TextureManager.MipLevelsFor(width, height)); + } + + [SkippableFact] + public void CubeAndArrayTexturesReportTheirLayers() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + int cube = textures.Create(32, 32, Format.R8G8B8A8Unorm, cube: true); + Assert.Equal(6u, textures.Get(cube)!.Layers); + + // The OIT accumulation target is a three-layer array. + int array = textures.Create(32, 32, Format.R16G16B16A16Sfloat, layers: 3); + Assert.Equal(3u, textures.Get(array)!.Layers); + } + } + + [SkippableFact] + public void DepthFormatsGetADepthAspectAndAttachmentUsage() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + int depth = textures.Create(64, 64, Format.D32Sfloat); + Assert.Equal(ImageAspectFlags.DepthBit, textures.Get(depth)!.Aspect); + + int color = textures.Create(64, 64, Format.R8G8B8A8Unorm); + Assert.Equal(ImageAspectFlags.ColorBit, textures.Get(color)!.Aspect); + } + } + + /// + /// Vulkan has four fixed border colours where GL takes any value. The SSAO + /// targets clamp to opaque white, which has to survive the rounding. + /// + [SkippableFact] + public void BorderColoursRoundToTheNearestFixedVulkanValue() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + int id = textures.Create(4, 4, Format.R8G8B8A8Unorm); + + textures.SetBorderColor(id, 1f, 1f, 1f, 1f); + Assert.Equal(BorderColor.FloatOpaqueWhite, textures.Get(id)!.State.BorderColor); + + textures.SetBorderColor(id, 0f, 0f, 0f, 1f); + Assert.Equal(BorderColor.FloatOpaqueBlack, textures.Get(id)!.State.BorderColor); + + textures.SetBorderColor(id, 0f, 0f, 0f, 0f); + Assert.Equal(BorderColor.FloatTransparentBlack, textures.Get(id)!.State.BorderColor); + } + } + + /// + /// A deleted texture must outlive any frame that might still reference it, + /// so deletion routes through the frame ring rather than freeing immediately. + /// + [SkippableFact] + public void DeletionThroughTheFrameRingIsDeferred() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); + + int id = textures.Create(16, 16, Format.R8G8B8A8Unorm); + textures.Delete(id, ring); + + // Gone from the table straight away, but not yet destroyed. + Assert.Null(textures.Get(id)); + Assert.Equal(1, ring.PendingDeletionCount); + + for (int frame = 0; frame < 4; frame++) + { + ring.BeginFrame(); + ring.EndFrame(); + } + Assert.Equal(0, ring.PendingDeletionCount); + + context!.Api.DeviceWaitIdle(context.Device); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs new file mode 100644 index 00000000..8eb0b7e7 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// GL answers a read of a vertex attribute the draw does not supply with the +/// current generic attribute, which defaults to (0, 0, 0, 1). Vulkan has no such +/// thing, and the difference is not academic: the GUI quad carries only positions +/// and UVs while gui.vsh declares six inputs, and gui.fsh discards a fragment +/// based on one of the missing ones. Undefined there meant the entire interface +/// rendered as nothing, with no validation message to say why. +/// +public class VertexAttributeDefaultTests +{ + private static VertexInputSlot Slot(string name, int location, string type) + { + Assert.True(GlslType.TryParse(type, out GlslType parsed), "unknown type " + type); + return new VertexInputSlot(name, location, parsed); + } + + /// The GUI quad's own shape: positions at 0, UVs at 1, nothing else. + private static VertexLayoutDescription QuadLayout() => new( + new[] + { + new VertexBinding(0, 12, PerInstance: false), + new VertexBinding(1, 8, PerInstance: false), + }, + new[] + { + new VertexAttribute(0, 0, Format.R32G32B32Sfloat, 0), + new VertexAttribute(1, 1, Format.R32G32Sfloat, 0), + }); + + [Fact] + public void MissingAttributesGetABindingOfTheirOwn() + { + var declared = new List + { + Slot("vertexPositionIn", 0, "vec3"), + Slot("uvIn", 1, "vec2"), + Slot("colorIn", 2, "vec4"), + Slot("renderFlagsIn", 3, "int"), + Slot("damageEffectIn", 4, "float"), + Slot("jointId", 5, "int"), + }; + + VertexLayoutDescription merged = QuadLayout().WithDefaultsFor(declared); + + Assert.Equal(6, merged.Attributes.Length); + Assert.Equal(3, merged.Bindings.Length); + Assert.Equal(VertexLayoutDescription.DefaultAttributeBinding, merged.Bindings[^1].Binding); + + // Stride zero is what makes every vertex read the same constant. + Assert.Equal(0u, merged.Bindings[^1].Stride); + Assert.False(merged.Bindings[^1].PerInstance); + } + + [Fact] + public void SuppliedAttributesAreLeftOnTheirOwnBinding() + { + var declared = new List + { + Slot("vertexPositionIn", 0, "vec3"), + Slot("uvIn", 1, "vec2"), + Slot("colorIn", 2, "vec4"), + }; + + VertexLayoutDescription merged = QuadLayout().WithDefaultsFor(declared); + + VertexAttribute position = merged.Attributes.Single(a => a.Location == 0); + VertexAttribute uv = merged.Attributes.Single(a => a.Location == 1); + Assert.Equal(0u, position.Binding); + Assert.Equal(1u, uv.Binding); + + VertexAttribute color = merged.Attributes.Single(a => a.Location == 2); + Assert.Equal(VertexLayoutDescription.DefaultAttributeBinding, color.Binding); + } + + /// + /// Integer attributes have to read integer zeros, so they take the second + /// half of the defaults buffer rather than reinterpreting float bits. + /// + [Theory] + [InlineData("float", Format.R32Sfloat, 0u)] + [InlineData("vec2", Format.R32G32Sfloat, 0u)] + [InlineData("vec3", Format.R32G32B32Sfloat, 0u)] + [InlineData("vec4", Format.R32G32B32A32Sfloat, 0u)] + [InlineData("int", Format.R32Sint, 16u)] + [InlineData("ivec4", Format.R32G32B32A32Sint, 16u)] + [InlineData("uint", Format.R32Sint, 16u)] + public void DefaultsUseTheFormatAndHalfMatchingTheDeclaredType( + string type, Format expectedFormat, uint expectedOffset) + { + VertexLayoutDescription merged = + VertexLayoutDescription.Empty.WithDefaultsFor(new[] { Slot("x", 7, type) }); + + VertexAttribute attribute = Assert.Single(merged.Attributes); + Assert.Equal(7u, attribute.Location); + Assert.Equal(expectedFormat, attribute.Format); + Assert.Equal(expectedOffset, attribute.Offset); + } + + /// + /// A layout that already covers everything must come back untouched, so no + /// pipeline gains a binding it will never have a buffer for. + /// + [Fact] + public void NothingIsAddedWhenTheMeshCoversEveryDeclaredInput() + { + var declared = new List + { + Slot("vertexPositionIn", 0, "vec3"), + Slot("uvIn", 1, "vec2"), + }; + + VertexLayoutDescription layout = QuadLayout(); + VertexLayoutDescription merged = layout.WithDefaultsFor(declared); + + Assert.Same(layout, merged); + } + + /// + /// A program declaring no inputs at all - the fullscreen passes - must not + /// grow a binding either. + /// + [Fact] + public void NothingIsAddedForAProgramWithNoDeclaredInputs() + { + VertexLayoutDescription layout = QuadLayout(); + Assert.Same(layout, layout.WithDefaultsFor(Array.Empty())); + } + + /// + /// The layout the parser produces for gui.vsh is the case this all exists + /// for, so it is pinned against the real shader's declarations rather than a + /// hand-written list. + /// + [Fact] + public void TheGuiShaderDeclaresEveryInputItReads() + { + const string source = """ + #version 330 core + layout(location = 0) in vec3 vertexPositionIn; + layout(location = 1) in vec2 uvIn; + layout(location = 2) in vec4 colorIn; + layout(location = 3) in int renderFlagsIn; + layout(location = 4) in float damageEffectIn; + layout(location = 5) in int jointId; + void main() { gl_Position = vec4(vertexPositionIn, 1.0); } + """; + + ProgramInterfaceLayout layout = ProgramInterfaceLayout.Build( + new[] { (EnumShaderType.VertexShader, GlslParser.Parse(source)) }); + + Assert.Equal(6, layout.VertexInputs.Count); + Assert.Contains(layout.VertexInputs, s => s.Name == "damageEffectIn" && s.Location == 4); + Assert.Contains(layout.VertexInputs, s => s.Name == "renderFlagsIn" && s.Location == 3); + } + + /// + /// Sampler locations have to be tellable apart from uniform block offsets, + /// which start at zero, and from GL's "not found" answer of -1 - otherwise + /// assigning a sampler its texture unit would write into the uniform block + /// at some arbitrary offset instead. + /// + [Theory] + [InlineData(-2, true)] + [InlineData(-3, true)] + [InlineData(-100, true)] + [InlineData(-1, false)] + [InlineData(0, false)] + [InlineData(36, false)] + public void SamplerLocationsAreDisjointFromBlockOffsets(int location, bool isSampler) + { + Assert.Equal(isSampler, ShaderProgramResources.IsSamplerLocation(location)); + } + + /// + /// Asking for Vulkan by name gets it wherever it runs; the automatic setting + /// is a default nobody chose, so it only selects the backend on drivers the + /// backend has been exercised against. + /// + [SkippableFact] + public void AnExplicitRendererChoiceIgnoresTheAutomaticAllowList() + { + Skip.IfNot(VulkanDevice.IsSupported(out string? probeReason), probeReason ?? "No usable Vulkan device."); + + // Explicit: allowed on whatever this machine has. + Assert.True(VulkanDevice.IsSupported(false, out _, out string driver)); + Assert.False(string.IsNullOrWhiteSpace(driver)); + + // Automatic: allowed too, because both development drivers are on the + // list. If this ever fails the machine grew a driver worth adding. + Assert.True(VulkanDevice.IsSupported(true, out string automaticReason, out _), + "automatic selection refused driver '" + driver + "': " + automaticReason); + } + + /// + /// The allow-list itself, which is the part with the logic. Only the drivers + /// the backend is actually run against may be picked automatically; anything + /// unrecognised stays on OpenGL rather than becoming the first person to try + /// the backend on that driver. + /// + [Theory] + [InlineData("NVIDIA", true)] + [InlineData("Intel open-source Mesa driver", true)] + [InlineData("Intel Corporation", true)] + [InlineData("radv", true)] + [InlineData("AMD proprietary driver", true)] + [InlineData("Mesa llvmpipe", true)] + [InlineData("SwiftShader", false)] + [InlineData("MoltenVK", false)] + [InlineData("unknown", false)] + [InlineData("", false)] + public void AutomaticSelectionOnlyTakesKnownDrivers(string driverName, bool allowed) + { + Assert.Equal(allowed, VulkanDevice.IsAllowedForAutomaticSelection(driverName)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs new file mode 100644 index 00000000..aa6a1965 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -0,0 +1,388 @@ +using System; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Drives the backend the way the client will: through +/// and nothing else. +/// +/// Every other test in this project reaches past the seam into a specific +/// manager. This one deliberately does not, because the seam is the contract that +/// has to hold - ClientPlatformWindows will only ever see these methods, +/// in this order, with GL's semantics assumed. +/// +public class VulkanDeviceIntegrationTests +{ + private readonly ITestOutputHelper _output; + + public VulkanDeviceIntegrationTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + /// + /// A minimal shader stand-in. The client passes its own IShader and + /// IShaderProgram implementations across the seam, so the device must work + /// against the interfaces rather than any concrete type. + /// + private sealed class TestShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class TestProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = "test"; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + + public void Use() { } + public void Stop() { } + public bool Compile() => true; + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + public bool HasUniform(string uniformName) => false; + } + + private static int LinkProgram( + IOptimumGraphicsDevice device, string vertexCode, string fragmentCode, string name = "test") + { + var vertex = new TestShader { Type = EnumShaderType.VertexShader, Code = vertexCode }; + var fragment = new TestShader { Type = EnumShaderType.FragmentShader, Code = fragmentCode }; + + Assert.True(device.CompileShader(vertex)); + Assert.True(device.CompileShader(fragment)); + + var program = new TestProgram { PassName = name, VertexShader = vertex, FragmentShader = fragment }; + int programId = device.LinkProgram(program); + Assert.True(programId > 0, device.GetError() ?? "link failed"); + return programId; + } + + [SkippableFact] + public void TheDeviceReportsItsCapabilitiesThroughTheSeam() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + + _output.WriteLine($"backend : {seam.BackendName}"); + _output.WriteLine($"renderer : {seam.RendererString}"); + _output.WriteLine($"vendor : {seam.VendorString}"); + _output.WriteLine($"version : {seam.VersionString}"); + _output.WriteLine($"shaders : {seam.ShaderVersionString}"); + _output.WriteLine($"max tex : {seam.MaxTextureSize}"); + + Assert.Equal("Vulkan", seam.BackendName); + Assert.True(seam.MaxTextureSize >= 4096); + Assert.True(seam.SupportsSSBOs); + + // The client parses this to decide whether a shader's #version is + // supported, so it has to read as a GLSL version number. + Assert.Matches(@"^\d\.\d+$", seam.ShaderVersionString); + } + } + + /// + /// The whole path, driven only through the seam: compile, link, create a + /// target, set state, draw, read back. + /// + [SkippableFact] + public unsafe void AFrameCanBeRenderedEntirelyThroughTheSeam() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 32; + + int programId = LinkProgram(seam, """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """, """ + #version 330 core + uniform vec4 tint; + out vec4 outColor; + void main(void) { outColor = tint; } + """); + + int texture = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + Assert.True(seam.CheckFramebufferComplete(framebuffer, out _)); + + // The uniform reaches the shader through the generated block. + int tint = seam.GetUniformLocation(programId, "tint"); + Assert.True(tint >= 0); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + + seam.UseProgram(programId); + seam.SetUniform(programId, tint, 0.25f, 0.5f, 0.75f, 1f); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + _output.WriteLine($"centre RGBA = {pixels[centre]}, {pixels[centre + 1]}, " + + $"{pixels[centre + 2]}, {pixels[centre + 3]}"); + + // 0.25, 0.5, 0.75 in 8-bit, within rounding. + Assert.InRange(pixels[centre + 0], 60, 68); + Assert.InRange(pixels[centre + 1], 124, 132); + Assert.InRange(pixels[centre + 2], 187, 195); + Assert.Equal(255, pixels[centre + 3]); + + AssertClean(seam); + } + } + + /// + /// Uniforms set at any point before a draw have to persist for the life of + /// the program, which is what GL promises and what every render system + /// assumes when it sets a uniform once and draws many times. + /// + [SkippableFact] + public unsafe void UniformsPersistAcrossDrawsAndFrames() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 16; + + int programId = LinkProgram(seam, """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """, """ + #version 330 core + uniform float level; + out vec4 outColor; + void main(void) { outColor = vec4(level, level, level, 1.0); } + """); + + int texture = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int level = seam.GetUniformLocation(programId, "level"); + seam.UseProgram(programId); + seam.SetUniform(programId, level, 1.0f); + + // Two frames, with the uniform set only in the first. + for (int frame = 0; frame < 2; frame++) + { + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, size, size); + seam.UseProgram(programId); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(255, pixels[centre]); + + AssertClean(seam); + } + } + + /// + /// Texture ids are public API surface - mods read + /// LoadedTexture.TextureId and hand it back - so they have to behave + /// like GL names, including being reused after deletion. + /// + [SkippableFact] + public void TextureAndFramebufferIdsBehaveLikeGlNames() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + + int first = seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int second = seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + Assert.True(first > 0); + Assert.NotEqual(first, second); + + int framebuffer = seam.CreateFramebuffer(8, 8); + Assert.True(framebuffer > 0); + + seam.DeleteTexture(first); + seam.DeleteFramebuffer(framebuffer); + } + } + + /// + /// Sampler uniforms are pointed at texture units, and a texture bound to that + /// unit has to reach the shader. This is the path every textured draw takes. + /// + [SkippableFact] + public unsafe void ATextureBoundToAUnitIsSampledByTheShader() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 16; + + int programId = LinkProgram(seam, """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """, """ + #version 330 core + uniform sampler2D source; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(source, uv); } + """); + + // A source texture filled with a known colour. + var sourcePixels = new byte[size * size * 4]; + for (int i = 0; i < sourcePixels.Length; i += 4) + { + sourcePixels[i + 0] = 10; + sourcePixels[i + 1] = 200; + sourcePixels[i + 2] = 30; + sourcePixels[i + 3] = 255; + } + + int source; + fixed (byte* data = sourcePixels) + { + source = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)data, false); + } + + int target = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(programId); + seam.SetSamplerUnit(programId, "source", 0); + seam.BindTexture(0, source); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(10, pixels[centre + 0]); + Assert.Equal(200, pixels[centre + 1]); + Assert.Equal(30, pixels[centre + 2]); + + AssertClean(seam); + } + } + + private static void AssertClean(IOptimumGraphicsDevice device) + { + string? diagnostics = device.GetError(); + if (diagnostics == null) return; + + Assert.False( + diagnostics.Contains("Error", StringComparison.OrdinalIgnoreCase) + || diagnostics.Contains("VUID", StringComparison.Ordinal), + "validation errors:\n" + diagnostics); + } +} diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs new file mode 100644 index 00000000..a3ada581 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Brings up a real Vulkan device and renders through it. +/// +/// These need a working ICD, so they skip on a machine without one rather than +/// failing - the same rule the shader corpus follows for game assets. Where they +/// do run they are the only check that the SPIR-V this backend generates is +/// something a driver will actually accept, which no amount of CPU-side testing +/// can establish. +/// +public class VulkanDeviceTests +{ + private readonly ITestOutputHelper _output; + + public VulkanDeviceTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context) + { + var messages = new List(); + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) + { + output.WriteLine("Vulkan unavailable: " + failureReason); + } + foreach (string message in messages) + { + output.WriteLine("[validation] " + message); + } + return created; + } + + [SkippableFact] + public void ADeviceMeetingTheBackendsRequirementsCanBeSelected() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + VulkanCapabilities capabilities = context!.Capabilities; + + _output.WriteLine($"device : {capabilities.DeviceName}"); + _output.WriteLine($"driver : {capabilities.DriverName}"); + _output.WriteLine($"api : {VulkanContext.VersionString(capabilities.ApiVersion)}"); + _output.WriteLine($"type : {capabilities.DeviceType}"); + _output.WriteLine($"max 2D : {capabilities.MaxImageDimension2D}"); + _output.WriteLine($"max LOD bias: {capabilities.MaxSamplerLodBias}"); + _output.WriteLine($"bound sets : {capabilities.MaxBoundDescriptorSets}"); + _output.WriteLine($"UBO align : {capabilities.MinUniformBufferOffsetAlignment}"); + + Assert.True(capabilities.ApiVersion >= VulkanContext.MinimumApiVersion); + Assert.True(capabilities.MultiDrawIndirect, "chunk rendering needs indirect multidraw"); + + // The backend claims three descriptor sets: uniforms, samplers, + // storage. Vulkan guarantees at least four, but assert it rather + // than assume. + Assert.True(capabilities.MaxBoundDescriptorSets >= 3); + + // FSR's mip bias needs the sampler LOD bias to actually do something. + Assert.True(capabilities.MaxSamplerLodBias >= 2.0f); + + // The vanilla shadow maps go to 4096 at the top quality setting. + Assert.True(capabilities.MaxImageDimension2D >= 4096); + } + } + + /// + /// Walks every physical device on the machine and reports which of them this + /// backend would accept. + /// + /// This is the vendor matrix in miniature. The requirements are deliberately + /// modest - Vulkan 1.3 with dynamic rendering, synchronization2, scalar block + /// layout, timeline semaphores, independent blend and indirect multidraw - + /// and a device that fails them falls back to OpenGL rather than breaking, so + /// the interesting output here is the list, not a pass or fail. + /// + [SkippableFact] + public void EveryPhysicalDeviceIsAssessedForBackendSupport() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? probe), "No usable Vulkan device."); + probe!.Dispose(); + + int usable = 0; + for (int index = 0; index < 8; index++) + { + var options = new VulkanContextOptions { Headless = true, PreferredDeviceIndex = index }; + if (!VulkanContext.TryCreate(options, out VulkanContext? context, out string? failureReason)) + { + if (failureReason != null && failureReason.Contains("out of range", StringComparison.Ordinal)) + { + break; + } + _output.WriteLine($"device {index}: rejected - {failureReason}"); + continue; + } + + using (context) + { + usable++; + VulkanCapabilities capabilities = context!.Capabilities; + _output.WriteLine( + $"device {index}: usable - {capabilities.DeviceName} " + + $"({capabilities.DriverName}, {VulkanContext.VersionString(capabilities.ApiVersion)}, " + + $"{capabilities.DeviceType})"); + } + } + + Assert.True(usable > 0, "at least one device should be usable if the probe succeeded"); + } + + /// + /// The end-to-end check: a GLSL 330 shader pair goes through the translator, + /// becomes a real pipeline, renders, and the pixels come back correct. + /// + /// It also pins the coordinate convention. The vertex shader maps clip Y to + /// the varying, and with no Y flip anywhere, NDC y = -1 lands in framebuffer + /// row 0. So the first row of memory must carry the low value and the last + /// row the high one - exactly what OpenGL produces, which is what makes + /// render-to-texture round trips and screenshots come out unchanged. + /// + [SkippableFact] + public unsafe void ATranslatedShaderRendersAndReadsBackWithGlCoordinates() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + const uint width = 64; + const uint height = 64; + const Format format = Format.R8G8B8A8Unorm; + + using var compiler = new ShaderCompiler(); + TranslatedProgram program = ShaderTranslator.Translate(new[] + { + new ShaderStageSource + { + Stage = EnumShaderType.VertexShader, + Filename = "smoke.vsh", + Code = """ + #version 330 core + out vec2 texCoord; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """, + }, + new ShaderStageSource + { + Stage = EnumShaderType.FragmentShader, + Filename = "smoke.fsh", + Code = """ + #version 330 core + in vec2 texCoord; + out vec4 outColor; + void main(void) { outColor = vec4(texCoord.x, texCoord.y, 0.0, 1.0); } + """, + }, + }, compiler); + + Assert.True(program.Success, string.Join("; ", program.Errors)); + + Vk api = context!.Api; + using var commands = new VulkanCommands(context); + using var target = new VulkanImage(context, width, height, format, + ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit, + ImageAspectFlags.ColorBit); + using var readback = new VulkanBuffer(context, width * height * 4, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + ShaderModule vertexModule = CreateModule(context, program.Spirv[EnumShaderType.VertexShader]); + ShaderModule fragmentModule = CreateModule(context, program.Spirv[EnumShaderType.FragmentShader]); + + var layoutInfo = new PipelineLayoutCreateInfo { SType = StructureType.PipelineLayoutCreateInfo }; + api.CreatePipelineLayout(context.Device, &layoutInfo, null, out PipelineLayout pipelineLayout); + + Pipeline pipeline = CreatePipeline(context, vertexModule, fragmentModule, pipelineLayout, format); + + commands.SubmitAndWait(commandBuffer => + { + commands.TransitionImage(commandBuffer, target, ImageLayout.ColorAttachmentOptimal, + ImageAspectFlags.ColorBit); + + var attachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = target.View, + ImageLayout = ImageLayout.ColorAttachmentOptimal, + LoadOp = AttachmentLoadOp.Clear, + StoreOp = AttachmentStoreOp.Store, + ClearValue = new ClearValue(new ClearColorValue(0f, 0f, 0f, 1f)), + }; + + var rendering = new RenderingInfo + { + SType = StructureType.RenderingInfo, + RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(width, height)), + LayerCount = 1, + ColorAttachmentCount = 1, + PColorAttachments = &attachment, + }; + + api.CmdBeginRendering(commandBuffer, &rendering); + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + // Viewport height stays positive: the backend never flips Y. + var viewport = new Viewport(0, 0, width, height, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(width, height)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + api.CmdEndRendering(commandBuffer); + + commands.TransitionImage(commandBuffer, target, ImageLayout.TransferSrcOptimal, + ImageAspectFlags.ColorBit); + + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(width, height, 1), + }; + api.CmdCopyImageToBuffer(commandBuffer, target.Handle, ImageLayout.TransferSrcOptimal, + readback.Handle, 1, ®ion); + }); + + var pixels = new byte[width * height * 4]; + Marshal.Copy(readback.Mapped, pixels, 0, pixels.Length); + + byte RedAt(uint x, uint y) => pixels[(y * width + x) * 4 + 0]; + byte GreenAt(uint x, uint y) => pixels[(y * width + x) * 4 + 1]; + byte AlphaAt(uint x, uint y) => pixels[(y * width + x) * 4 + 3]; + + _output.WriteLine($"row 0 centre: R={RedAt(width / 2, 0)} G={GreenAt(width / 2, 0)}"); + _output.WriteLine($"row {height - 1} centre: R={RedAt(width / 2, height - 1)} G={GreenAt(width / 2, height - 1)}"); + + // The triangle covers the whole target, so nothing is left cleared. + Assert.Equal(255, AlphaAt(width / 2, height / 2)); + + // X increases left to right in both APIs. + Assert.True(RedAt(1, height / 2) < 32, "left edge should be low red"); + Assert.True(RedAt(width - 2, height / 2) > 223, "right edge should be high red"); + + // The convention that matters: row 0 is NDC y = -1, so it carries the + // low value. A backend that flipped Y would invert this, and with it + // every render-to-texture pass and every screenshot. + Assert.True(GreenAt(width / 2, 0) < 32, + $"row 0 should hold texCoord.y near 0, got {GreenAt(width / 2, 0)}"); + Assert.True(GreenAt(width / 2, height - 1) > 223, + $"last row should hold texCoord.y near 1, got {GreenAt(width / 2, height - 1)}"); + + api.DestroyPipeline(context.Device, pipeline, null); + api.DestroyPipelineLayout(context.Device, pipelineLayout, null); + api.DestroyShaderModule(context.Device, vertexModule, null); + api.DestroyShaderModule(context.Device, fragmentModule, null); + } + } + + private static unsafe ShaderModule CreateModule(VulkanContext context, byte[] spirv) + { + fixed (byte* code = spirv) + { + var createInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + + Result result = context.Api.CreateShaderModule(context.Device, &createInfo, null, out ShaderModule module); + Assert.Equal(Result.Success, result); + return module; + } + } + + private static unsafe Pipeline CreatePipeline( + VulkanContext context, ShaderModule vertex, ShaderModule fragment, + PipelineLayout layout, Format colorFormat) + { + Vk api = context.Api; + byte* entryPoint = (byte*)Silk.NET.Core.Native.SilkMarshal.StringToPtr("main"); + + try + { + var stages = stackalloc PipelineShaderStageCreateInfo[2]; + stages[0] = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.VertexBit, + Module = vertex, + PName = entryPoint, + }; + stages[1] = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.FragmentBit, + Module = fragment, + PName = entryPoint, + }; + + // No vertex buffers: the fullscreen triangle is generated from + // gl_VertexIndex, exactly as the GL path draws it. + var vertexInput = new PipelineVertexInputStateCreateInfo + { + SType = StructureType.PipelineVertexInputStateCreateInfo, + }; + var inputAssembly = new PipelineInputAssemblyStateCreateInfo + { + SType = StructureType.PipelineInputAssemblyStateCreateInfo, + Topology = PrimitiveTopology.TriangleList, + }; + var viewportState = new PipelineViewportStateCreateInfo + { + SType = StructureType.PipelineViewportStateCreateInfo, + ViewportCount = 1, + ScissorCount = 1, + }; + var rasterizer = new PipelineRasterizationStateCreateInfo + { + SType = StructureType.PipelineRasterizationStateCreateInfo, + PolygonMode = PolygonMode.Fill, + CullMode = CullModeFlags.None, + // Clockwise is front when GL's counter-clockwise winding is read + // in an unflipped Vulkan framebuffer. + FrontFace = FrontFace.Clockwise, + LineWidth = 1.0f, + }; + var multisample = new PipelineMultisampleStateCreateInfo + { + SType = StructureType.PipelineMultisampleStateCreateInfo, + RasterizationSamples = SampleCountFlags.Count1Bit, + }; + var blendAttachment = new PipelineColorBlendAttachmentState + { + ColorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit, + BlendEnable = false, + }; + var colorBlend = new PipelineColorBlendStateCreateInfo + { + SType = StructureType.PipelineColorBlendStateCreateInfo, + AttachmentCount = 1, + PAttachments = &blendAttachment, + }; + + var dynamicStates = stackalloc DynamicState[2] + { + DynamicState.Viewport, + DynamicState.Scissor, + }; + var dynamicState = new PipelineDynamicStateCreateInfo + { + SType = StructureType.PipelineDynamicStateCreateInfo, + DynamicStateCount = 2, + PDynamicStates = dynamicStates, + }; + + // Dynamic rendering: attachment formats are named here instead of by + // a render-pass object. + Format format = colorFormat; + var renderingInfo = new PipelineRenderingCreateInfo + { + SType = StructureType.PipelineRenderingCreateInfo, + ColorAttachmentCount = 1, + PColorAttachmentFormats = &format, + }; + + var createInfo = new GraphicsPipelineCreateInfo + { + SType = StructureType.GraphicsPipelineCreateInfo, + PNext = &renderingInfo, + StageCount = 2, + PStages = stages, + PVertexInputState = &vertexInput, + PInputAssemblyState = &inputAssembly, + PViewportState = &viewportState, + PRasterizationState = &rasterizer, + PMultisampleState = &multisample, + PColorBlendState = &colorBlend, + PDynamicState = &dynamicState, + Layout = layout, + }; + + Result result = api.CreateGraphicsPipelines( + context.Device, default, 1, &createInfo, null, out Pipeline pipeline); + Assert.Equal(Result.Success, result); + return pipeline; + } + finally + { + Silk.NET.Core.Native.SilkMarshal.Free((nint)entryPoint); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs new file mode 100644 index 00000000..63e444f8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs @@ -0,0 +1,494 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The device features the world passes need, which the menu never touches. +/// +/// Reaching a world in the real client needs a signed-in account, so these drive +/// the same device paths directly instead: the layered accumulation target and +/// six-attachment blending that weighted-blended OIT sets up, the depth-only +/// shadow map, and the occlusion query the sun uses to size its glare. Each runs +/// with validation on and asserts a clean message log, because the failures these +/// guard against are silent - a legal frame that draws the wrong thing. +/// +public class WorldRenderPathTests +{ + private readonly ITestOutputHelper _output; + + public WorldRenderPathTests(ITestOutputHelper output) => _output = output; + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) + { + output.WriteLine("Vulkan unavailable: " + failureReason); + } + return created; + } + + /// + /// OIT accumulates into three layers of one 2D array texture, attached a + /// layer at a time to colour attachments 3, 4 and 5. Each attachment has to + /// reach its own layer: a layer index dropped somewhere in the attach path + /// would have all three writing over each other, which still renders and + /// still validates. + /// + [SkippableFact] + public unsafe void EachOitAccumulationLayerIsWrittenSeparately() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + const uint layers = 3; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int accumulation = textures.Create(size, size, Format.R8G8B8A8Unorm, layers); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, accumulation, 0); + targets.Attach(framebuffer, 1, accumulation, 1); + targets.Attach(framebuffer, 2, accumulation, 2); + targets.SetDrawBuffers(framebuffer, 0b111); + + TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outA; + layout(location = 1) out vec4 outB; + layout(location = 2) out vec4 outC; + void main(void) + { + outA = vec4(1.0, 0.0, 0.0, 1.0); + outB = vec4(0.0, 1.0, 0.0, 1.0); + outC = vec4(0.0, 0.0, 1.0, 1.0); + } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + // Red into layer 0, green into layer 1, blue into layer 2. + Assert.Equal(new byte[] { 255, 0, 0 }, FirstPixel(context!, commands, textures, accumulation, size, 0)); + Assert.Equal(new byte[] { 0, 255, 0 }, FirstPixel(context!, commands, textures, accumulation, size, 1)); + Assert.Equal(new byte[] { 0, 0, 255 }, FirstPixel(context!, commands, textures, accumulation, size, 2)); + + AssertNoValidationErrors(messages); + } + } + + /// + /// The OIT pass gives each attachment its own blend factors in one draw: + /// revealage multiplies down from one while accumulation adds up from zero. + /// A per-attachment blend state collapsed into a single shared one would + /// produce a plausible-looking but wrong composite. + /// + [SkippableFact] + public unsafe void AttachmentsKeepIndependentBlendFactors() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int reveal = textures.Create(size, size, Format.R8G8B8A8Unorm); + int accum = textures.Create(size, size, Format.R8G8B8A8Unorm); + + // Revealage starts at one, accumulation at zero. + FillTexture(textures, reveal, size, 255); + FillTexture(textures, accum, size, 0); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, reveal); + targets.Attach(framebuffer, 1, accum); + targets.SetDrawBuffers(framebuffer, 0b11); + + // Attachment 0: dst * src (GL_ZERO, GL_SRC_COLOR reversed as the OIT + // pass writes it - factor pair 774/0 is DST_COLOR, ZERO). + state.SetBlend(true, EnumBlendMode.Standard); + state.SetAttachmentBlendFunc(0, 774, 0, 774, 0); + // Attachment 1: additive. + state.SetAttachmentBlendFunc(1, 1, 1, 1, 1); + + TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outReveal; + layout(location = 1) out vec4 outAccum; + void main(void) + { + outReveal = vec4(0.5, 0.5, 0.5, 1.0); + outAccum = vec4(0.25, 0.25, 0.25, 1.0); + } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + byte[] revealPixels = ReadTexture(context!, commands, textures, reveal, size); + byte[] accumPixels = ReadTexture(context!, commands, textures, accum, size); + + // dst(1.0) * src(0.5) = 0.5, so revealage came down rather than + // being replaced. + Assert.InRange(revealPixels[0], 120, 136); + // 0 + 0.25 = 0.25, so accumulation added rather than multiplying. + Assert.InRange(accumPixels[0], 56, 72); + + AssertNoValidationErrors(messages); + } + } + + /// + /// The shadow passes render depth with no colour attachment at all. A target + /// that quietly requires one would fail to build a pipeline, and a depth + /// attachment that never got stored would leave every shadow lookup reading + /// the clear value. + /// + [SkippableFact] + public unsafe void ADepthOnlyTargetStoresWhatWasDrawn() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int depth = textures.Create(size, size, Format.D32Sfloat); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, -1, depth); + targets.SetDrawBuffers(framebuffer, 0); + + // Draws at a fixed clip depth; after the Vulkan remap that is 0.75. + TranslatedProgram translated = Translate(compiler, """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.5, 1.0); + } + """, """ + #version 330 core + void main(void) { } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + state.SetDepthTest(true); + state.SetDepthWrite(true); + state.SetDepthFunc(0x203); // GL_LEQUAL + + // The shadow pass clears depth to one before drawing; without that + // the comparison runs against undefined contents and rejects + // everything, which is a property of the test rather than the device. + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.ClearDepth(commandBuffer, 1f); + targets.EndRendering(commandBuffer); + }); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size, + depthTest: true); + + float stored = ReadDepth(context!, commands, textures, depth, size); + + // (0.5 + 1.0) * 0.5 = 0.75 - the GL-to-Vulkan depth remap, measured + // rather than assumed. + Assert.InRange(stored, 0.74f, 0.76f); + + AssertNoValidationErrors(messages); + } + } + + /// + /// The sun's glare is sized by an occlusion query counting the samples its + /// quad passed. A query that never produced a result would leave the glare + /// pinned at its last value forever, which looks like a lighting bug rather + /// than a query one. + /// + [SkippableFact] + public unsafe void AnOcclusionQueryCountsTheSamplesThatPassed() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int color = textures.Create(size, size, Format.R8G8B8A8Unorm); + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, color); + targets.SetDrawBuffers(framebuffer, 0b1); + + TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(1.0); } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + var poolInfo = new QueryPoolCreateInfo + { + SType = StructureType.QueryPoolCreateInfo, + QueryType = QueryType.Occlusion, + QueryCount = 1, + }; + Assert.Equal(Result.Success, + context!.Api.CreateQueryPool(context.Device, &poolInfo, null, out QueryPool pool)); + + ulong passed; + try + { + RenderFullscreen(context, commands, targets, pipelines, state, program, framebuffer, size, + depthTest: false, queryPool: pool); + + Assert.Equal(Result.Success, context.Api.GetQueryPoolResults( + context.Device, pool, 0, 1, (nuint)sizeof(ulong), &passed, sizeof(ulong), + QueryResultFlags.Result64Bit | QueryResultFlags.ResultWaitBit)); + } + finally + { + context.Api.DestroyQueryPool(context.Device, pool, null); + } + + // The triangle covers the whole 8x8 target. + Assert.Equal((ulong)(size * size), passed); + + AssertNoValidationErrors(messages); + } + } + + // ------------------------------------------------------------------ helpers + + private static TranslatedProgram Translate(ShaderCompiler compiler, string vertex, string fragment) => + ShaderTranslator.Translate(new[] + { + new ShaderStageSource { Stage = EnumShaderType.VertexShader, Code = vertex, Filename = "t.vsh" }, + new ShaderStageSource { Stage = EnumShaderType.FragmentShader, Code = fragment, Filename = "t.fsh" }, + }, compiler); + + private static unsafe void RenderFullscreen( + VulkanContext context, VulkanCommands commands, RenderTargetManager targets, + GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + int framebuffer, uint size, bool depthTest = false, QueryPool queryPool = default) + { + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + int attachmentCount = targets.EnabledAttachmentCount(bound); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(0, formatsId, attachmentCount), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + commands.SubmitAndWait(commandBuffer => + { + Vk api = context.Api; + if (queryPool.Handle != 0) + { + api.CmdResetQueryPool(commandBuffer, queryPool, 0, 1); + } + + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, depthTest); + api.CmdSetDepthWriteEnable(commandBuffer, depthTest); + api.CmdSetDepthCompareOp(commandBuffer, depthTest ? CompareOp.LessOrEqual : CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + + if (queryPool.Handle != 0) + { + api.CmdBeginQuery(commandBuffer, queryPool, 0, 0); + } + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + if (queryPool.Handle != 0) + { + api.CmdEndQuery(commandBuffer, queryPool, 0); + } + + targets.EndRendering(commandBuffer); + }); + } + + private static unsafe void FillTexture(TextureManager textures, int textureId, uint size, byte value) + { + var pixels = new byte[size * size * 4]; + Array.Fill(pixels, value); + fixed (byte* data = pixels) + { + textures.Upload(textureId, 0, 0, 0, size, size, (IntPtr)data, 4); + } + } + + private static unsafe byte[] ReadTexture( + VulkanContext context, VulkanCommands commands, TextureManager textures, + int textureId, uint size, uint layer = 0) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * 4; + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, layer, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[(int)bytes]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } + + private static byte[] FirstPixel( + VulkanContext context, VulkanCommands commands, TextureManager textures, + int textureId, uint size, uint layer) => + ReadTexture(context, commands, textures, textureId, size, layer).Take(3).ToArray(); + + private static unsafe float ReadDepth( + VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * sizeof(float); + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.DepthBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new float[size * size]; + fixed (float* destination = result) + { + System.Buffer.MemoryCopy((void*)readback.Mapped, destination, (long)bytes, (long)bytes); + } + return result[0]; + } + + private static void AssertNoValidationErrors(List messages) + { + // Only what the layers reported at error severity. Advisories - a + // fragment output with no attachment, say - are prefixed as warnings and + // are not failures; treating every message as one made these assertions + // fire on notes about correct frames. + var errors = messages + .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, + StringComparison.Ordinal)) + .ToList(); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs new file mode 100644 index 00000000..17f7e854 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Core; + +/// One combined image sampler binding. +internal readonly record struct SamplerBindingValue(uint Binding, ImageView View, Sampler Sampler); + +/// One buffer binding. +internal readonly record struct BufferBindingValue(uint Binding, Buffer Buffer, ulong Offset, ulong Range); + +/// +/// The contents of one descriptor set, used as a cache key. +/// +/// A set is written once and never updated, so identical contents can always +/// share a handle. That immutability is what makes the cache safe: there is no +/// moment where a set the GPU is reading gets rewritten. +/// +internal sealed class DescriptorSetContents : IEquatable +{ + public int ProgramId { get; } + public int SetIndex { get; } + public SamplerBindingValue[] Samplers { get; } + public BufferBindingValue[] Buffers { get; } + + private readonly int _hash; + + public DescriptorSetContents( + int programId, int setIndex, SamplerBindingValue[] samplers, BufferBindingValue[] buffers) + { + ProgramId = programId; + SetIndex = setIndex; + Samplers = samplers; + Buffers = buffers; + + var hash = new HashCode(); + hash.Add(programId); + hash.Add(setIndex); + foreach (SamplerBindingValue sampler in samplers) + { + hash.Add(sampler.Binding); + hash.Add(sampler.View.Handle); + hash.Add(sampler.Sampler.Handle); + } + foreach (BufferBindingValue buffer in buffers) + { + hash.Add(buffer.Binding); + hash.Add(buffer.Buffer.Handle); + hash.Add(buffer.Offset); + hash.Add(buffer.Range); + } + _hash = hash.ToHashCode(); + } + + public bool Equals(DescriptorSetContents? other) + { + if (other is null || other._hash != _hash) return false; + if (ProgramId != other.ProgramId || SetIndex != other.SetIndex) return false; + if (Samplers.Length != other.Samplers.Length) return false; + if (Buffers.Length != other.Buffers.Length) return false; + + for (int i = 0; i < Samplers.Length; i++) + { + if (!Samplers[i].Equals(other.Samplers[i])) return false; + } + for (int i = 0; i < Buffers.Length; i++) + { + if (!Buffers[i].Equals(other.Buffers[i])) return false; + } + return true; + } + + public override bool Equals(object? obj) => Equals(obj as DescriptorSetContents); + public override int GetHashCode() => _hash; +} + +/// +/// Hands out descriptor sets, reusing them whenever the same bindings come back. +/// +/// This is the single largest CPU win available in a Vulkan backend for a game +/// like this one. Chunk rendering binds the same terrain atlas thousands of times +/// per frame; without a cache each of those is a set allocation and a write, and +/// with one they are a dictionary lookup. Published measurements put descriptor +/// caching at roughly a third off frame time in CPU-heavy scenes. +/// +/// Sets are allocated from pools that are never reset. They are immutable once +/// written, so a set can outlive any number of frames safely, and the working set +/// is bounded by how many distinct texture combinations the game actually uses - +/// a few hundred, not a few hundred thousand. +/// +internal sealed unsafe class DescriptorCache : IDisposable +{ + private const uint SetsPerPool = 512; + + private readonly VulkanContext _context; + private readonly Dictionary _sets = new(); + private readonly List _pools = new(); + private DescriptorPool _current; + private uint _remainingInCurrent; + private bool _disposed; + + public int Count => _sets.Count; + public long Hits { get; private set; } + public long Misses { get; private set; } + + public DescriptorCache(VulkanContext context) => _context = context; + + public DescriptorSet Get(DescriptorSetContents contents, DescriptorSetLayout layout) + { + if (_sets.TryGetValue(contents, out DescriptorSet existing)) + { + Hits++; + return existing; + } + + Misses++; + DescriptorSet set = Allocate(layout); + Write(set, contents); + _sets[contents] = set; + return set; + } + + private DescriptorSet Allocate(DescriptorSetLayout layout) + { + if (_remainingInCurrent == 0) GrowPool(); + + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _current, + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + + DescriptorSet set; + Result result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &set); + + // A pool can fail before its nominal capacity when one layout uses more + // of a type than the pool budgeted. Growing and retrying once is the + // documented way to handle that. + if (result != Result.Success) + { + GrowPool(); + allocateInfo.DescriptorPool = _current; + result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &set); + if (result != Result.Success) + { + throw new InvalidOperationException("vkAllocateDescriptorSets failed: " + result); + } + } + + _remainingInCurrent--; + return set; + } + + private void GrowPool() + { + // A pool can only satisfy the descriptor types it was sized for. The + // generated block is a dynamic uniform buffer, but the game also declares + // uniform blocks of its own - entityanimated's ElementTransforms is one - + // and those are plain uniform buffers. Without a size for that type the + // allocation fails, the set is never written, and the first draw that + // uses it takes the device down. + var sizes = stackalloc DescriptorPoolSize[4] + { + new DescriptorPoolSize(DescriptorType.UniformBufferDynamic, SetsPerPool), + new DescriptorPoolSize(DescriptorType.UniformBuffer, SetsPerPool * 2), + new DescriptorPoolSize(DescriptorType.CombinedImageSampler, SetsPerPool * 8), + new DescriptorPoolSize(DescriptorType.StorageBuffer, SetsPerPool * 2), + }; + + var createInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + PoolSizeCount = 4, + PPoolSizes = sizes, + MaxSets = SetsPerPool, + }; + + if (_context.Api.CreateDescriptorPool(_context.Device, &createInfo, null, out DescriptorPool pool) + != Result.Success) + { + throw new InvalidOperationException("vkCreateDescriptorPool failed"); + } + + _pools.Add(pool); + _current = pool; + _remainingInCurrent = SetsPerPool; + } + + private void Write(DescriptorSet set, DescriptorSetContents contents) + { + int writeCount = contents.Samplers.Length + contents.Buffers.Length; + if (writeCount == 0) return; + + var writes = new WriteDescriptorSet[writeCount]; + var imageInfos = new DescriptorImageInfo[contents.Samplers.Length]; + var bufferInfos = new DescriptorBufferInfo[contents.Buffers.Length]; + + fixed (DescriptorImageInfo* imagePtr = imageInfos) + fixed (DescriptorBufferInfo* bufferPtr = bufferInfos) + { + int index = 0; + + for (int i = 0; i < contents.Samplers.Length; i++) + { + SamplerBindingValue sampler = contents.Samplers[i]; + imageInfos[i] = new DescriptorImageInfo + { + ImageView = sampler.View, + Sampler = sampler.Sampler, + ImageLayout = ImageLayout.ShaderReadOnlyOptimal, + }; + writes[index++] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = sampler.Binding, + DescriptorCount = 1, + DescriptorType = DescriptorType.CombinedImageSampler, + PImageInfo = imagePtr + i, + }; + } + + for (int i = 0; i < contents.Buffers.Length; i++) + { + BufferBindingValue buffer = contents.Buffers[i]; + bufferInfos[i] = new DescriptorBufferInfo + { + Buffer = buffer.Buffer, + Offset = buffer.Offset, + Range = buffer.Range, + }; + + // Set 0 binding 0 is the generated uniform block, bound as a + // dynamic descriptor so the per-draw ring offset travels + // separately and the set itself never has to change. + bool isDynamicUniform = + contents.SetIndex == ProgramInterfaceLayoutBindings.DefaultBlockSet + && buffer.Binding == ProgramInterfaceLayoutBindings.DefaultBlockBinding; + + writes[index++] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = buffer.Binding, + DescriptorCount = 1, + DescriptorType = contents.SetIndex == ProgramInterfaceLayoutBindings.StorageSet + ? DescriptorType.StorageBuffer + : isDynamicUniform + ? DescriptorType.UniformBufferDynamic + : DescriptorType.UniformBuffer, + PBufferInfo = bufferPtr + i, + }; + } + + fixed (WriteDescriptorSet* writesPtr = writes) + { + _context.Api.UpdateDescriptorSets(_context.Device, (uint)writeCount, writesPtr, 0, null); + } + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _sets.Clear(); + foreach (DescriptorPool pool in _pools) + { + _context.Api.DestroyDescriptorPool(_context.Device, pool, null); + } + _pools.Clear(); + } +} + +/// +/// The set and binding numbers the shader rewriter assigns. +/// +/// Duplicated here as plain constants so the descriptor layer does not depend on +/// the shader translation types; the pair is checked against each other by test. +/// +internal static class ProgramInterfaceLayoutBindings +{ + public const int DefaultBlockSet = 0; + public const int DefaultBlockBinding = 0; + public const int SamplerSet = 1; + public const int StorageSet = 2; +} diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs new file mode 100644 index 00000000..1a9a3518 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Core; + +/// Where a uniform upload landed in the ring buffer. +internal readonly record struct RingAllocation(Buffer Buffer, uint Offset, IntPtr Pointer); + +/// +/// One frame's worth of transient GPU state. +/// +/// Everything here is reset wholesale rather than freed piecemeal: the command +/// pool, and the bump cursor into this slot's slice of the shared uniform ring. A +/// slot is only reused once its fence says the GPU has finished with it, which is +/// also what makes deferred deletion safe. +/// +internal sealed unsafe class FrameSlot : IDisposable +{ + private readonly VulkanContext _context; + private readonly ulong _alignment; + private readonly ulong _regionStart; + private readonly ulong _regionSize; + private readonly VulkanBuffer _uniformRing; + private ulong _cursor; + private bool _disposed; + + public CommandPool CommandPool { get; } + public CommandBuffer CommandBuffer { get; private set; } + public Fence Fence { get; } + + /// + /// Resources the GPU may still be reading. They are destroyed when this + /// slot's fence signals, never at the moment the game asks. + /// + private readonly List _pendingDeletions = new(); + + public FrameSlot(VulkanContext context, VulkanBuffer uniformRing, ulong regionStart, ulong regionSize) + { + _context = context; + _uniformRing = uniformRing; + _regionStart = regionStart; + _regionSize = regionSize; + _alignment = Math.Max(1, context.Capabilities.MinUniformBufferOffsetAlignment); + + Vk api = context.Api; + + var poolInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = context.GraphicsQueueFamily, + Flags = CommandPoolCreateFlags.TransientBit, + }; + api.CreateCommandPool(context.Device, &poolInfo, null, out CommandPool commandPool); + CommandPool = commandPool; + + // Created signalled so the first frame does not wait on a fence that + // will never be submitted. + var fenceInfo = new FenceCreateInfo + { + SType = StructureType.FenceCreateInfo, + Flags = FenceCreateFlags.SignaledBit, + }; + api.CreateFence(context.Device, &fenceInfo, null, out Fence fence); + Fence = fence; + } + + /// + /// Waits for the GPU to finish with this slot, then recycles it. This is the + /// only point where deferred deletions actually happen. + /// + public void BeginFrame(ConcurrentQueue incomingDeletions) + { + Vk api = _context.Api; + Fence fence = Fence; + + VulkanResult.Check(api.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue), + "vkWaitForFences at the start of a frame"); + VulkanResult.Check(api.ResetFences(_context.Device, 1, &fence), + "vkResetFences at the start of a frame"); + + foreach (IDisposable pending in _pendingDeletions) pending.Dispose(); + _pendingDeletions.Clear(); + + // Deletions queued from a finalizer thread join this slot, so they too + // wait a full frame cycle before the resource is destroyed. + while (incomingDeletions.TryDequeue(out IDisposable? deletion)) + { + _pendingDeletions.Add(deletion); + } + + api.ResetCommandPool(_context.Device, CommandPool, 0); + _cursor = 0; + + var allocateInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = CommandPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + CommandBuffer commandBuffer; + api.AllocateCommandBuffers(_context.Device, &allocateInfo, &commandBuffer); + CommandBuffer = commandBuffer; + + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + api.BeginCommandBuffer(commandBuffer, &begin); + } + + /// + /// Bump-allocates uniform space in this slot's region of the shared ring. + /// Returns false when the region is exhausted, which the caller reports + /// rather than crashing on. + /// + public bool TryAllocateUniforms(int size, out RingAllocation allocation) + { + ulong aligned = (_cursor + _alignment - 1) / _alignment * _alignment; + if (aligned + (ulong)size > _regionSize) + { + allocation = default; + return false; + } + + ulong absolute = _regionStart + aligned; + allocation = new RingAllocation( + _uniformRing.Handle, (uint)absolute, _uniformRing.Mapped + (int)absolute); + _cursor = aligned + (ulong)size; + return true; + } + + /// + /// Closes the command buffer and submits it against this slot's fence. + /// + /// Every frame that begins must end here. resets the + /// fence, so a slot that is begun and never submitted would leave the fence + /// unsignalled and deadlock the next time the ring came round to it. + /// + public void EndFrameAndSubmit( + Semaphore waitSemaphore = default, + Semaphore signalSemaphore = default, + PipelineStageFlags waitStage = PipelineStageFlags.ColorAttachmentOutputBit) + { + Vk api = _context.Api; + CommandBuffer commandBuffer = CommandBuffer; + api.EndCommandBuffer(commandBuffer); + + Semaphore wait = waitSemaphore; + Semaphore signal = signalSemaphore; + PipelineStageFlags stage = waitStage; + + var submit = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer, + WaitSemaphoreCount = wait.Handle == 0 ? 0u : 1u, + PWaitSemaphores = wait.Handle == 0 ? null : &wait, + PWaitDstStageMask = wait.Handle == 0 ? null : &stage, + SignalSemaphoreCount = signal.Handle == 0 ? 0u : 1u, + PSignalSemaphores = signal.Handle == 0 ? null : &signal, + }; + + // Shares the queue with off-thread setup submissions; see QueueLock. + lock (_context.QueueLock) + { + VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, Fence), + "vkQueueSubmit for a frame"); + } + } + + public void DeferDeletion(IDisposable resource) => _pendingDeletions.Add(resource); + + public ulong UniformBytesUsed => _cursor; + public ulong UniformCapacity => _regionSize; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (IDisposable pending in _pendingDeletions) pending.Dispose(); + _pendingDeletions.Clear(); + + Vk api = _context.Api; + api.DestroyFence(_context.Device, Fence, null); + api.DestroyCommandPool(_context.Device, CommandPool, null); + } +} + +/// +/// Rotates through a small number of frame slots. +/// +/// Two in flight is the default: enough to keep the GPU fed while the CPU records +/// the next frame, few enough that input latency stays close to what the OpenGL +/// path had. Frame generation will want a third later. +/// +/// The uniform ring is one buffer for the whole ring rather than one per slot, +/// with each slot bump-allocating inside its own slice. That is what lets +/// descriptor sets be written once and reused forever: the set names the buffer, +/// and the per-draw offset travels as a dynamic offset instead. A buffer per slot +/// would mean rewriting every set every frame, which is the cost this design +/// exists to avoid. +/// +internal sealed class FrameRing : IDisposable +{ + private readonly FrameSlot[] _slots; + private readonly VulkanBuffer _uniformRing; + private readonly ConcurrentQueue _incomingDeletions = new(); + private int _index = -1; + private bool _disposed; + + public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRingSize = 16 * 1024 * 1024) + { + _uniformRing = new VulkanBuffer(context, uniformRingSize, + BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + ulong regionSize = uniformRingSize / (ulong)framesInFlight; + _slots = new FrameSlot[framesInFlight]; + for (int i = 0; i < framesInFlight; i++) + { + _slots[i] = new FrameSlot(context, _uniformRing, regionSize * (ulong)i, regionSize); + } + } + + public int FramesInFlight => _slots.Length; + + /// The buffer every uniform descriptor points at. + public Buffer UniformBuffer => _uniformRing.Handle; + + public FrameSlot Current => _index < 0 + ? throw new InvalidOperationException("BeginFrame has not been called yet") + : _slots[_index]; + + public FrameSlot BeginFrame() + { + _index = (_index + 1) % _slots.Length; + FrameSlot slot = _slots[_index]; + slot.BeginFrame(_incomingDeletions); + return slot; + } + + /// Ends and submits the current frame. Pairs with every BeginFrame. + public void EndFrame( + Semaphore waitSemaphore = default, + Semaphore signalSemaphore = default) => + Current.EndFrameAndSubmit(waitSemaphore, signalSemaphore); + + /// + /// Queues a resource for destruction once the GPU is done with it. + /// + /// Safe from any thread. The game's VAO and UBO finalizers call Dispose from + /// the finalizer thread, so this cannot assume it is on the render thread; + /// the queue is drained at the next BeginFrame, which is. + /// + /// The resource is adopted by the slot that drains the queue and destroyed + /// when that slot next comes round, so it survives up to two full ring cycles + /// rather than one. That is deliberately conservative: the alternative is to + /// know which frames referenced it, which the GL-shaped API this backend sits + /// behind never tells us. + /// + public void DeferDeletion(IDisposable resource) => _incomingDeletions.Enqueue(resource); + + public int PendingDeletionCount => _incomingDeletions.Count; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + while (_incomingDeletions.TryDequeue(out IDisposable? deletion)) deletion.Dispose(); + foreach (FrameSlot slot in _slots) slot.Dispose(); + _uniformRing.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Core/GlEnums.cs b/Optimum.Render.Vulkan/Core/GlEnums.cs new file mode 100644 index 00000000..cefdfb17 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/GlEnums.cs @@ -0,0 +1,173 @@ +using System; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Translates the raw OpenGL constants the game passes around into Vulkan enums. +/// +/// The client and its mods hand these across the backend seam as plain integers - +/// GlStencilFunc(515, 1, 255), GlDepthFunc, BlendFunc(770, 771) +/// - because that is what they already hold and what the GL path consumed. Keeping +/// them as integers at the boundary is what lets a mod calling +/// IRenderAPI.GlStencilFunc work on this backend without knowing it exists. +/// +internal static class GlEnums +{ + // Blend factors. + private const int Zero = 0; + private const int One = 1; + private const int SrcColor = 0x0300; + private const int OneMinusSrcColor = 0x0301; + private const int SrcAlpha = 0x0302; + private const int OneMinusSrcAlpha = 0x0303; + private const int DstAlpha = 0x0304; + private const int OneMinusDstAlpha = 0x0305; + private const int DstColor = 0x0306; + private const int OneMinusDstColor = 0x0307; + private const int SrcAlphaSaturate = 0x0308; + + public static BlendFactor BlendFactorFrom(int glFactor) => glFactor switch + { + Zero => BlendFactor.Zero, + One => BlendFactor.One, + SrcColor => BlendFactor.SrcColor, + OneMinusSrcColor => BlendFactor.OneMinusSrcColor, + SrcAlpha => BlendFactor.SrcAlpha, + OneMinusSrcAlpha => BlendFactor.OneMinusSrcAlpha, + DstAlpha => BlendFactor.DstAlpha, + OneMinusDstAlpha => BlendFactor.OneMinusDstAlpha, + DstColor => BlendFactor.DstColor, + OneMinusDstColor => BlendFactor.OneMinusDstColor, + SrcAlphaSaturate => BlendFactor.SrcAlphaSaturate, + _ => BlendFactor.One, + }; + + public static BlendOp BlendOpFrom(int glEquation) => glEquation switch + { + 0x8006 => BlendOp.Add, // GL_FUNC_ADD + 0x800A => BlendOp.Subtract, // GL_FUNC_SUBTRACT + 0x800B => BlendOp.ReverseSubtract, // GL_FUNC_REVERSE_SUBTRACT + 0x8007 => BlendOp.Min, // GL_MIN + 0x8008 => BlendOp.Max, // GL_MAX + _ => BlendOp.Add, + }; + + public static CompareOp CompareOpFrom(int glFunc) => glFunc switch + { + 0x0200 => CompareOp.Never, + 0x0201 => CompareOp.Less, + 0x0202 => CompareOp.Equal, + 0x0203 => CompareOp.LessOrEqual, + 0x0204 => CompareOp.Greater, + 0x0205 => CompareOp.NotEqual, + 0x0206 => CompareOp.GreaterOrEqual, + 0x0207 => CompareOp.Always, + _ => CompareOp.Less, + }; + + public static StencilOp StencilOpFrom(int glOp) => glOp switch + { + 0x1E00 => StencilOp.Keep, + 0x0000 => StencilOp.Zero, + 0x1E01 => StencilOp.Replace, + 0x1E02 => StencilOp.IncrementAndClamp, + 0x1E03 => StencilOp.DecrementAndClamp, + 0x150A => StencilOp.Invert, + 0x8507 => StencilOp.IncrementAndWrap, + 0x8508 => StencilOp.DecrementAndWrap, + _ => StencilOp.Keep, + }; + + public static PrimitiveTopology TopologyFrom(EnumDrawMode mode) => mode switch + { + EnumDrawMode.Triangles => PrimitiveTopology.TriangleList, + EnumDrawMode.Lines => PrimitiveTopology.LineList, + EnumDrawMode.LineStrip => PrimitiveTopology.LineStrip, + _ => PrimitiveTopology.TriangleList, + }; + + /// + /// Vulkan can only change topology dynamically within a class, so the class + /// is part of the pipeline key while the exact topology is not. + /// + public static int TopologyClassOf(PrimitiveTopology topology) => topology switch + { + PrimitiveTopology.PointList => 0, + PrimitiveTopology.LineList or PrimitiveTopology.LineStrip => 1, + _ => 2, + }; + + public static Format TextureFormatFrom(EnumTextureInternalFormat format) => format switch + { + EnumTextureInternalFormat.Rgba8 => Format.R8G8B8A8Unorm, + EnumTextureInternalFormat.Rgba16f => Format.R16G16B16A16Sfloat, + EnumTextureInternalFormat.R16f => Format.R16Sfloat, + EnumTextureInternalFormat.DepthComponent32 => Format.D32Sfloat, + _ => Format.R8G8B8A8Unorm, + }; + + /// + /// Formats the vanilla framebuffers use that the public enum does not name. + /// SetupDefaultFrameBuffers passes these as raw GL internal formats. + /// + public static Format TextureFormatFromGl(int glInternalFormat) => glInternalFormat switch + { + 0x8058 => Format.R8G8B8A8Unorm, // GL_RGBA8 + 0x881A => Format.R16G16B16A16Sfloat, // GL_RGBA16F + 0x822D => Format.R16Sfloat, // GL_R16F + 0x8C3A => Format.B10G11R11UfloatPack32,// GL_R11F_G11F_B10F + 0x8814 => Format.R32G32B32A32Sfloat, // GL_RGBA32F + 0x8051 => Format.R8G8B8A8Unorm, // GL_RGB8, promoted: RGB is not a + 0x1907 => Format.R8G8B8A8Unorm, // GL_RGB guaranteed attachment format + 0x8DAB => Format.D32Sfloat, // GL_DEPTH_COMPONENT32F + 0x81A5 => Format.D16Unorm, // GL_DEPTH_COMPONENT16 + // GL_BGRA. The GL bodies use it as a source pixel format against an + // RGBA8 internal format; here it names a BGRA-ordered image, so Cairo + // and GUI uploads land with their channels in the right places. + 0x80E1 => Format.B8G8R8A8Unorm, + _ => Format.R8G8B8A8Unorm, + }; + + public static Filter FilterFrom(int glFilter) => glFilter switch + { + 0x2600 => Filter.Nearest, // GL_NEAREST + 0x2601 => Filter.Linear, // GL_LINEAR + _ => Filter.Nearest, + }; + + /// + /// GL's minification filters fold the mipmap mode into the same constant. + /// + public static (Filter Filter, SamplerMipmapMode MipmapMode) MinFilterFrom(int glFilter) => glFilter switch + { + 0x2600 => (Filter.Nearest, SamplerMipmapMode.Nearest), // GL_NEAREST + 0x2601 => (Filter.Linear, SamplerMipmapMode.Nearest), // GL_LINEAR + 0x2700 => (Filter.Nearest, SamplerMipmapMode.Nearest), // NEAREST_MIPMAP_NEAREST + 0x2701 => (Filter.Linear, SamplerMipmapMode.Nearest), // LINEAR_MIPMAP_NEAREST + 0x2702 => (Filter.Nearest, SamplerMipmapMode.Linear), // NEAREST_MIPMAP_LINEAR + 0x2703 => (Filter.Linear, SamplerMipmapMode.Linear), // LINEAR_MIPMAP_LINEAR + _ => (Filter.Nearest, SamplerMipmapMode.Nearest), + }; + + public static SamplerAddressMode AddressModeFrom(int glWrap) => glWrap switch + { + 0x2901 => SamplerAddressMode.Repeat, // GL_REPEAT + 0x812F => SamplerAddressMode.ClampToEdge, // GL_CLAMP_TO_EDGE + 0x812D => SamplerAddressMode.ClampToBorder, // GL_CLAMP_TO_BORDER + 0x8370 => SamplerAddressMode.MirroredRepeat, // GL_MIRRORED_REPEAT + _ => SamplerAddressMode.ClampToEdge, + }; + + // Texture parameter names the client actually sets. + public const int TextureMinFilter = 0x2801; + public const int TextureMagFilter = 0x2800; + public const int TextureWrapS = 0x2802; + public const int TextureWrapT = 0x2803; + public const int TextureCompareMode = 0x884C; + public const int TextureLodBias = 0x8501; + public const int TextureBorderColor = 0x1004; + public const int TextureCompareModeNone = 0; + public const int TextureCompareRefToTexture = 0x884E; +} diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs new file mode 100644 index 00000000..ff27d82e --- /dev/null +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -0,0 +1,413 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Core; + +/// Blend configuration for one colour attachment. +internal struct AttachmentBlend : IEquatable +{ + public bool Enabled; + public BlendFactor SrcColor; + public BlendFactor DstColor; + public BlendOp ColorOp; + public BlendFactor SrcAlpha; + public BlendFactor DstAlpha; + public BlendOp AlphaOp; + public ColorComponentFlags WriteMask; + + public static AttachmentBlend Default => new() + { + Enabled = false, + SrcColor = BlendFactor.SrcAlpha, + DstColor = BlendFactor.OneMinusSrcAlpha, + ColorOp = BlendOp.Add, + SrcAlpha = BlendFactor.SrcAlpha, + DstAlpha = BlendFactor.OneMinusSrcAlpha, + AlphaOp = BlendOp.Add, + WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit, + }; + + /// + /// Squeezes the whole attachment state into 32 bits so a set of eight hashes + /// as cheaply as an array of ints. Every field is a small enum; the widest is + /// a blend factor at 19 values. + /// + public readonly uint Pack() + { + uint packed = Enabled ? 1u : 0u; + packed |= (uint)SrcColor << 1; + packed |= (uint)DstColor << 6; + packed |= (uint)ColorOp << 11; + packed |= (uint)SrcAlpha << 14; + packed |= (uint)DstAlpha << 19; + packed |= (uint)AlphaOp << 24; + packed |= (uint)WriteMask << 27; + return packed; + } + + public readonly bool Equals(AttachmentBlend other) => Pack() == other.Pack(); + public override readonly bool Equals(object? obj) => obj is AttachmentBlend other && Equals(other); + public override readonly int GetHashCode() => (int)Pack(); +} + +/// +/// Interns a value so it can be compared as an int. +/// +/// The pipeline key is looked up on every draw, so it has to be small and cheap +/// to hash. Interning the bulky parts - the blend set, the render target formats, +/// the vertex layout - turns each into one integer and leaves the key at six. +/// +internal sealed class Interner where T : notnull +{ + private readonly Dictionary _ids; + private readonly List _values = new(); + + public Interner(IEqualityComparer? comparer = null) => _ids = new Dictionary(comparer); + + public int Intern(T value) + { + if (_ids.TryGetValue(value, out int id)) return id; + + id = _values.Count; + _values.Add(value); + _ids[value] = id; + return id; + } + + public T Get(int id) => _values[id]; + public int Count => _values.Count; +} + +/// The attachment formats a pipeline renders into. +internal sealed class RenderTargetFormats : IEquatable +{ + public Format[] ColorFormats { get; } + public Format DepthFormat { get; } + + public RenderTargetFormats(Format[] colorFormats, Format depthFormat) + { + ColorFormats = colorFormats; + DepthFormat = depthFormat; + } + + public bool Equals(RenderTargetFormats? other) + { + if (other is null) return false; + if (DepthFormat != other.DepthFormat) return false; + if (ColorFormats.Length != other.ColorFormats.Length) return false; + + for (int i = 0; i < ColorFormats.Length; i++) + { + if (ColorFormats[i] != other.ColorFormats[i]) return false; + } + return true; + } + + public override bool Equals(object? obj) => Equals(obj as RenderTargetFormats); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(DepthFormat); + foreach (Format format in ColorFormats) hash.Add(format); + return hash.ToHashCode(); + } +} + +/// A set of per-attachment blend states, interned as a unit. +internal sealed class BlendSignature : IEquatable +{ + private readonly uint[] _packed; + private readonly int _hash; + + public BlendSignature(ReadOnlySpan attachments) + { + _packed = new uint[attachments.Length]; + var hash = new HashCode(); + for (int i = 0; i < attachments.Length; i++) + { + _packed[i] = attachments[i].Pack(); + hash.Add(_packed[i]); + } + _hash = hash.ToHashCode(); + } + + public bool Equals(BlendSignature? other) + { + if (other is null || other._hash != _hash || other._packed.Length != _packed.Length) return false; + for (int i = 0; i < _packed.Length; i++) + { + if (_packed[i] != other._packed[i]) return false; + } + return true; + } + + public override bool Equals(object? obj) => Equals(obj as BlendSignature); + public override int GetHashCode() => _hash; +} + +/// +/// Everything a graphics pipeline is built from that Vulkan cannot change +/// dynamically. +/// +/// Vulkan 1.3 makes viewport, scissor, cull mode, front face, depth test/write/ +/// compare, stencil state and line width dynamic, so none of them appear here and +/// none of them cause a pipeline to be created. What is left is the shader +/// program, the vertex layout, the attachment formats, the blend set, the fill +/// mode and the topology class - and all but the last two are interned to an int. +/// +internal readonly record struct PipelineKey( + int ProgramId, + int VertexLayoutId, + int TargetFormatsId, + int BlendId, + PolygonMode PolygonMode, + int TopologyClass); + +/// +/// The emulated OpenGL state machine. +/// +/// The game and its mods drive rendering the way GL asks them to: set a piece of +/// state, set another, bind a texture to a unit, draw. Reproducing that protocol +/// is what lets every render system and every mod keep working unchanged, so this +/// class records state rather than executing it, and a draw resolves the record +/// into a pipeline key plus a handful of dynamic-state commands. +/// +/// It is the same approach Zink and ANGLE take, narrowed to the state this one +/// game actually touches. +/// +internal sealed class GlStateTracker +{ + public const int MaxColorAttachments = 8; + public const int MaxTextureUnits = 16; + + private readonly AttachmentBlend[] _blend = new AttachmentBlend[MaxColorAttachments]; + private readonly Interner _blendSignatures = new(); + private readonly Interner _targetFormats = new(); + + private int _cachedBlendId = -1; + private ColorComponentFlags _colorWriteMask = + ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + + public GlStateTracker() + { + for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; + } + + // ------------------------------------------------------------ dynamic state + + public Rect2D Viewport { get; private set; } + public Rect2D Scissor { get; private set; } + public bool ScissorEnabled { get; private set; } + + public bool DepthTest { get; private set; } + public bool DepthWrite { get; private set; } = true; + public CompareOp DepthCompare { get; private set; } = CompareOp.Less; + + public bool CullEnabled { get; private set; } + public CullModeFlags CullMode { get; private set; } = CullModeFlags.BackBit; + + public bool StencilTest { get; private set; } + public uint StencilWriteMask { get; private set; } = 0xFF; + public uint StencilCompareMask { get; private set; } = 0xFF; + public uint StencilReference { get; private set; } + public CompareOp StencilCompare { get; private set; } = CompareOp.Always; + public StencilOp StencilFail { get; private set; } = StencilOp.Keep; + public StencilOp StencilDepthFail { get; private set; } = StencilOp.Keep; + public StencilOp StencilPass { get; private set; } = StencilOp.Keep; + + public float LineWidth { get; private set; } = 1.0f; + public PrimitiveTopology Topology { get; private set; } = PrimitiveTopology.TriangleList; + + // ------------------------------------------------------------- pipeline state + + public PolygonMode PolygonMode { get; private set; } = PolygonMode.Fill; + public int CurrentProgram { get; private set; } + + /// + /// The front face is a constant, not a setting. GL's counter-clockwise + /// winding, read in a Vulkan framebuffer with no Y flip, is clockwise. The + /// game never calls glFrontFace, so nothing varies it. + /// + public const FrontFace FrontFace = Silk.NET.Vulkan.FrontFace.Clockwise; + + // -------------------------------------------------------------------- setters + + public void SetViewport(int x, int y, int width, int height) => + Viewport = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + + public void SetScissor(int x, int y, int width, int height) => + Scissor = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + + public void SetScissorEnabled(bool enabled) => ScissorEnabled = enabled; + + public void SetDepthTest(bool enabled) => DepthTest = enabled; + public void SetDepthWrite(bool enabled) => DepthWrite = enabled; + public void SetDepthFunc(int glFunc) => DepthCompare = GlEnums.CompareOpFrom(glFunc); + + public void SetCullEnabled(bool enabled) => CullEnabled = enabled; + public void SetCullBack(bool back) => CullMode = back ? CullModeFlags.BackBit : CullModeFlags.FrontBit; + + public void SetStencilTest(bool enabled) => StencilTest = enabled; + public void SetStencilMask(int mask) => StencilWriteMask = (uint)mask; + + public void SetStencilFunc(int func, int reference, int mask) + { + StencilCompare = GlEnums.CompareOpFrom(func); + StencilReference = (uint)reference; + StencilCompareMask = (uint)mask; + } + + public void SetStencilOp(int fail, int depthFail, int pass) + { + StencilFail = GlEnums.StencilOpFrom(fail); + StencilDepthFail = GlEnums.StencilOpFrom(depthFail); + StencilPass = GlEnums.StencilOpFrom(pass); + } + + public void SetLineWidth(float width) => LineWidth = width; + public void SetTopology(EnumDrawMode mode) => Topology = GlEnums.TopologyFrom(mode); + public void SetWireframe(bool enabled) => PolygonMode = enabled ? PolygonMode.Line : PolygonMode.Fill; + public void SetProgram(int programId) => CurrentProgram = programId; + + /// + /// GL's colour mask is global; Vulkan's is per attachment. Setting it here + /// replicates it across all of them, which is what the GL behaviour means. + /// + public void SetColorMask(bool r, bool g, bool b, bool a) + { + ColorComponentFlags mask = 0; + if (r) mask |= ColorComponentFlags.RBit; + if (g) mask |= ColorComponentFlags.GBit; + if (b) mask |= ColorComponentFlags.BBit; + if (a) mask |= ColorComponentFlags.ABit; + + if (mask == _colorWriteMask) return; + _colorWriteMask = mask; + + for (int i = 0; i < _blend.Length; i++) _blend[i].WriteMask = mask; + _cachedBlendId = -1; + } + + /// + /// Applies one of the game's named blend modes to every attachment, matching + /// the factor pairs ClientPlatformWindows.GlToggleBlend selects. + /// + public void SetBlend(bool enabled, EnumBlendMode mode) + { + (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = mode switch + { + EnumBlendMode.Brighten => (BlendFactor.DstColor, BlendFactor.One, + BlendFactor.DstColor, BlendFactor.One), + EnumBlendMode.Multiply => (BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + EnumBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + EnumBlendMode.Glow => (BlendFactor.SrcAlpha, BlendFactor.One, + BlendFactor.One, BlendFactor.Zero), + EnumBlendMode.Overlay => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.One), + _ => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, + BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), + }; + + for (int i = 0; i < _blend.Length; i++) + { + _blend[i].Enabled = enabled; + _blend[i].SrcColor = srcColor; + _blend[i].DstColor = dstColor; + _blend[i].ColorOp = BlendOp.Add; + _blend[i].SrcAlpha = srcAlpha; + _blend[i].DstAlpha = dstAlpha; + _blend[i].AlphaOp = BlendOp.Add; + } + _cachedBlendId = -1; + } + + /// + /// Per-attachment blend, which the OIT and SSAO passes use through + /// glBlendFunci and glBlendEquationi. + /// + public void SetAttachmentBlendFunc(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) + { + if ((uint)attachment >= MaxColorAttachments) return; + + _blend[attachment].SrcColor = GlEnums.BlendFactorFrom(srcColor); + _blend[attachment].DstColor = GlEnums.BlendFactorFrom(dstColor); + _blend[attachment].SrcAlpha = GlEnums.BlendFactorFrom(srcAlpha); + _blend[attachment].DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); + _cachedBlendId = -1; + } + + public void SetAttachmentBlendEquation(int attachment, int equation) + { + if ((uint)attachment >= MaxColorAttachments) return; + + BlendOp op = GlEnums.BlendOpFrom(equation); + _blend[attachment].ColorOp = op; + _blend[attachment].AlphaOp = op; + _cachedBlendId = -1; + } + + // ---------------------------------------------------------------------- keys + + /// + /// Interns the current blend set. The id is cached and only recomputed after + /// a blend change, so a run of draws sharing state pays nothing. + /// + public int BlendId(int attachmentCount) + { + if (_cachedBlendId >= 0) return _cachedBlendId; + + int count = Math.Clamp(attachmentCount, 0, MaxColorAttachments); + _cachedBlendId = _blendSignatures.Intern(new BlendSignature(_blend.AsSpan(0, count))); + return _cachedBlendId; + } + + public int InternTargetFormats(RenderTargetFormats formats) => _targetFormats.Intern(formats); + public RenderTargetFormats TargetFormats(int id) => _targetFormats.Get(id); + + /// Blend state for one attachment, for pipeline creation. + public AttachmentBlend BlendFor(int attachment) => _blend[attachment]; + + public PipelineKey BuildKey(int vertexLayoutId, int targetFormatsId, int attachmentCount) => new( + ProgramId: CurrentProgram, + VertexLayoutId: vertexLayoutId, + TargetFormatsId: targetFormatsId, + BlendId: BlendId(attachmentCount), + PolygonMode: PolygonMode, + TopologyClass: GlEnums.TopologyClassOf(Topology)); + + /// + /// Restores the defaults a fresh GL context would have. Called when the + /// device is created and whenever the client resets its own state wholesale. + /// + public void Reset() + { + for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; + _cachedBlendId = -1; + _colorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + + DepthTest = false; + DepthWrite = true; + DepthCompare = CompareOp.Less; + CullEnabled = false; + CullMode = CullModeFlags.BackBit; + ScissorEnabled = false; + StencilTest = false; + StencilWriteMask = 0xFF; + StencilCompareMask = 0xFF; + StencilReference = 0; + StencilCompare = CompareOp.Always; + StencilFail = StencilDepthFail = StencilPass = StencilOp.Keep; + LineWidth = 1.0f; + Topology = PrimitiveTopology.TriangleList; + PolygonMode = PolygonMode.Fill; + CurrentProgram = 0; + } +} diff --git a/Optimum.Render.Vulkan/Core/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs new file mode 100644 index 00000000..8238d430 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// A mesh: one buffer per attribute, plus indices. +/// +/// The per-attribute layout is not a choice - it is how the game allocates. Its +/// mesh allocator makes a separate GL buffer for positions, normals, UVs, +/// colours and flags, and only the four "custom" parts are interleaved. Matching +/// that exactly is what lets the existing upload paths, including the +/// persistently mapped writes the chunk tesselator does, work unchanged. +/// +internal sealed class VulkanMesh : IDisposable +{ + public VulkanBuffer?[] Buffers { get; } = new VulkanBuffer?[MeshManager.MaxBuffers]; + public VulkanBuffer? Indices { get; set; } + + public int IndexCount { get; set; } + public EnumDrawMode DrawMode { get; set; } = EnumDrawMode.Triangles; + public bool Persistent { get; set; } + public bool Ssbo { get; set; } + + public VertexLayoutDescription Layout { get; set; } = VertexLayoutDescription.Empty; + public int LayoutId { get; set; } = -1; + + /// Which buffers actually feed vertex bindings, in binding order. + public List BindingOrder { get; } = new(); + + public void Dispose() + { + foreach (VulkanBuffer? buffer in Buffers) buffer?.Dispose(); + Array.Clear(Buffers); + Indices?.Dispose(); + Indices = null; + } +} + +/// +/// Owns meshes and hands out integer ids, mirroring the GL VAO the game's +/// MeshRef wraps. +/// +internal sealed unsafe class MeshManager : IDisposable +{ + /// xyz, normals, uv, rgba, flags, then the four custom parts. + public const int MaxBuffers = 9; + + public const int BufferXyz = 0; + public const int BufferNormals = 1; + public const int BufferUv = 2; + public const int BufferRgba = 3; + public const int BufferFlags = 4; + public const int BufferCustomFloat = 5; + public const int BufferCustomShort = 6; + public const int BufferCustomInt = 7; + public const int BufferCustomByte = 8; + + private const int GlUnsignedByte = 0x1401; + private const int GlShort = 0x1402; + private const int GlUnsignedShort = 0x1403; + private const int GlUnsignedInt = 0x1405; + + /// + /// The flags and custom-int attributes are fed to shader inputs declared + /// in int. GL let an unsigned pointer feed a signed input - it + /// reinterprets - but Vulkan requires the attribute format's numeric type to + /// match the shader's exactly, so these are signed here. + /// + private const int GlInt = 0x1404; + + private const int GlFloat = 0x1406; + private const int GlInt2101010Rev = 0x8D9F; + + private readonly VulkanContext _context; + private readonly GlStateTracker _state; + private readonly Interner _layouts = new(); + private readonly List _meshes = new(); + private readonly Stack _freeIds = new(); + private bool _disposed; + + /// + /// The layout of a pass with no vertex buffers, reserved as id 0. + /// + /// The fullscreen post-processing passes generate their vertices from + /// gl_VertexIndex and bind nothing, but they still need a layout id for the + /// pipeline key. Interning the empty layout first guarantees the id exists + /// even before any mesh has been created. + /// + public const int EmptyLayoutId = 0; + + public MeshManager(VulkanContext context, GlStateTracker state) + { + _context = context; + _state = state; + _meshes.Add(null); // 0 is never a real mesh + + int emptyId = _layouts.Intern(VertexLayoutDescription.Empty); + if (emptyId != EmptyLayoutId) + { + throw new InvalidOperationException("the empty vertex layout must intern first"); + } + } + + public VulkanMesh? Get(int id) => id > 0 && id < _meshes.Count ? _meshes[id] : null; + + public int Count + { + get + { + int live = 0; + foreach (VulkanMesh? mesh in _meshes) + { + if (mesh != null) live++; + } + return live; + } + } + + /// + /// Creates a mesh with the given per-part byte sizes, matching the shape of + /// the game's AllocateEmptyMesh. A part with size 0 is absent, and absent + /// parts do not consume an attribute location - which is what makes the chunk + /// shaders' location numbering line up without any per-shader knowledge here. + /// + public int CreateEmpty( + int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, + CustomMeshDataPartFloat? customFloats, CustomMeshDataPartShort? customShorts, + CustomMeshDataPartByte? customBytes, CustomMeshDataPartInt? customInts, + EnumDrawMode drawMode, bool staticDraw, bool ssbo) + { + var mesh = new VulkanMesh + { + DrawMode = drawMode, + Persistent = !staticDraw, + Ssbo = ssbo, + }; + + var builder = new VertexLayoutBuilder(); + + // The order here is the order the GL allocator assigns attribute slots. + AddDedicated(mesh, builder, BufferXyz, xyzSize, 3, GlFloat, normalized: false, integer: false, ssbo); + AddDedicated(mesh, builder, BufferNormals, normalsSize, 4, GlInt2101010Rev, normalized: true, integer: false, ssbo); + AddDedicated(mesh, builder, BufferUv, uvSize, 2, GlFloat, normalized: false, integer: false, ssbo); + AddDedicated(mesh, builder, BufferRgba, rgbaSize, 4, GlUnsignedByte, normalized: true, integer: false, ssbo); + AddDedicated(mesh, builder, BufferFlags, flagsSize, 1, GlInt, normalized: false, integer: true, ssbo); + + AddCustom(mesh, builder, BufferCustomFloat, customFloats?.AllocationSize * 4 ?? 0, + customFloats?.InterleaveSizes, customFloats?.InterleaveOffsets, + customFloats?.InterleaveStride ?? 0, GlFloat, false, false, + customFloats?.Instanced ?? false); + + AddCustom(mesh, builder, BufferCustomShort, customShorts?.AllocationSize * 2 ?? 0, + customShorts?.InterleaveSizes, customShorts?.InterleaveOffsets, + customShorts?.InterleaveStride ?? 0, GlShort, + customShorts?.Conversion == DataConversion.NormalizedFloat, + customShorts?.Conversion == DataConversion.Integer, + customShorts?.Instanced ?? false); + + AddCustom(mesh, builder, BufferCustomInt, customInts?.AllocationSize * 4 ?? 0, + customInts?.InterleaveSizes, customInts?.InterleaveOffsets, + customInts?.InterleaveStride ?? 0, GlInt, + customInts?.Conversion == DataConversion.NormalizedFloat, + customInts?.Conversion == DataConversion.Integer, + customInts?.Instanced ?? false); + + AddCustom(mesh, builder, BufferCustomByte, customBytes?.AllocationSize ?? 0, + customBytes?.InterleaveSizes, customBytes?.InterleaveOffsets, + customBytes?.InterleaveStride ?? 0, GlUnsignedByte, + customBytes?.Conversion == DataConversion.NormalizedFloat, + customBytes?.Conversion == DataConversion.Integer, + customBytes?.Instanced ?? false); + + if (indicesSize > 0) + { + mesh.Indices = CreateBuffer(indicesSize, BufferUsageFlags.IndexBufferBit, mesh.Persistent); + mesh.IndexCount = indicesSize / sizeof(int); + } + + mesh.Layout = builder.Build(); + mesh.LayoutId = _layouts.Intern(mesh.Layout); + + return Register(mesh); + } + + private void AddDedicated( + VulkanMesh mesh, VertexLayoutBuilder builder, int slot, int byteSize, + int components, int glType, bool normalized, bool integer, bool ssbo) + { + if (byteSize <= 0) return; + + // The SSBO path reads positions through a storage buffer rather than the + // vertex input, so that buffer needs the extra usage bit. + BufferUsageFlags usage = BufferUsageFlags.VertexBufferBit; + if (ssbo && slot == BufferXyz) usage |= BufferUsageFlags.StorageBufferBit; + + mesh.Buffers[slot] = CreateBuffer(byteSize, usage, mesh.Persistent); + + // With SSBO vertex fetch the position buffer is not a vertex binding. + if (ssbo && slot == BufferXyz) return; + + mesh.BindingOrder.Add(slot); + builder.AddDedicated( + VertexLayoutBuilder.FormatFor(components, glType, normalized, integer), + VertexLayoutBuilder.SizeOf(components, glType)); + } + + private void AddCustom( + VulkanMesh mesh, VertexLayoutBuilder builder, int slot, int byteSize, + int[]? interleaveSizes, int[]? interleaveOffsets, int stride, + int glType, bool normalized, bool integer, bool instanced) + { + // Presence of the part decides whether it takes an attribute location, + // not how much data it currently holds. The GL allocator does the same: + // it adds the attribute pointers whenever the part is non-null, and + // AllocationSize returns Count, which is zero for a part that will be + // filled after allocation. Gating on size here would shift every later + // location and silently misfeed the shader. + if (interleaveSizes == null || interleaveSizes.Length == 0) return; + + // Vulkan rejects a zero-sized buffer, so an empty part still gets a + // minimal allocation to keep the binding valid. + mesh.Buffers[slot] = CreateBuffer( + Math.Max(byteSize, 4), BufferUsageFlags.VertexBufferBit, mesh.Persistent); + mesh.BindingOrder.Add(slot); + + var members = new (Format, uint)[interleaveSizes.Length]; + uint packedStride = 0; + for (int i = 0; i < interleaveSizes.Length; i++) + { + uint offset = interleaveOffsets != null && i < interleaveOffsets.Length + ? (uint)interleaveOffsets[i] + : packedStride; + + members[i] = (VertexLayoutBuilder.FormatFor(interleaveSizes[i], glType, normalized, integer), offset); + packedStride += VertexLayoutBuilder.SizeOf(interleaveSizes[i], glType); + } + + builder.AddInterleaved(members, stride > 0 ? (uint)stride : packedStride, instanced); + } + + private VulkanBuffer CreateBuffer(int byteSize, BufferUsageFlags usage, bool persistent) + { + // A dynamic mesh is host visible and stays mapped, because the game + // writes straight through the pointer while the GPU may still be + // reading - the same lack of synchronisation GL allowed and the chunk + // tesselator relies on. + MemoryPropertyFlags properties = persistent + ? MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + : MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit + | MemoryPropertyFlags.HostCoherentBit; + + try + { + return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, properties); + } + catch (InvalidOperationException) + { + // No resizable BAR: fall back to a plain host-visible allocation. + return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + } + } + + private int Register(VulkanMesh mesh) + { + if (_freeIds.Count > 0) + { + int reused = _freeIds.Pop(); + _meshes[reused] = mesh; + return reused; + } + + _meshes.Add(mesh); + return _meshes.Count - 1; + } + + /// The persistently mapped pointer for a part, or zero. + public IntPtr MappedPointer(int meshId, int slot) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null) return IntPtr.Zero; + + if (slot < 0) return mesh.Indices?.Mapped ?? IntPtr.Zero; + return slot < MaxBuffers ? mesh.Buffers[slot]?.Mapped ?? IntPtr.Zero : IntPtr.Zero; + } + + /// Writes bytes into a mesh buffer through its mapping. + public void Write(int meshId, int slot, int byteOffset, IntPtr source, int byteCount) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null || source == IntPtr.Zero || byteCount <= 0) return; + + VulkanBuffer? buffer = slot < 0 ? mesh.Indices : mesh.Buffers[slot]; + if (buffer?.Mapped is null or 0) return; + if ((ulong)(byteOffset + byteCount) > buffer.Size) return; + + System.Buffer.MemoryCopy( + (void*)source, (void*)(buffer.Mapped + byteOffset), byteCount, byteCount); + } + + public void Delete(int meshId, FrameRing? ring = null) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null) return; + + _meshes[meshId] = null; + _freeIds.Push(meshId); + + if (ring != null) ring.DeferDeletion(mesh); + else mesh.Dispose(); + } + + // --------------------------------------------------------------------- draw + + /// Binds the mesh's vertex and index buffers. + public void Bind(CommandBuffer commandBuffer, VulkanMesh mesh) + { + Vk api = _context.Api; + + if (mesh.BindingOrder.Count > 0) + { + var buffers = new Buffer[mesh.BindingOrder.Count]; + var offsets = new ulong[mesh.BindingOrder.Count]; + for (int i = 0; i < mesh.BindingOrder.Count; i++) + { + buffers[i] = mesh.Buffers[mesh.BindingOrder[i]]!.Handle; + } + + fixed (Buffer* buffersPtr = buffers) + fixed (ulong* offsetsPtr = offsets) + { + api.CmdBindVertexBuffers(commandBuffer, 0, (uint)buffers.Length, buffersPtr, offsetsPtr); + } + } + + if (mesh.Indices != null) + { + // Always 32-bit: the game's index arrays are int[]. + api.CmdBindIndexBuffer(commandBuffer, mesh.Indices.Handle, 0, IndexType.Uint32); + } + } + + public void Draw(CommandBuffer commandBuffer, int meshId, int instanceCount = 1) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null || mesh.IndexCount == 0) return; + + Bind(commandBuffer, mesh); + _context.Api.CmdDrawIndexed(commandBuffer, (uint)mesh.IndexCount, (uint)instanceCount, 0, 0, 0); + } + + /// + /// The multidraw the chunk renderer issues once per pool, replacing + /// glMultiDrawElements. Records go through an indirect buffer. + /// + public void DrawMulti( + CommandBuffer commandBuffer, int meshId, + int[] indicesStarts, int[] indicesSizes, int groupCount, VulkanBuffer indirectScratch) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null || groupCount <= 0) return; + + Bind(commandBuffer, mesh); + + var commands = (DrawIndexedIndirectCommand*)indirectScratch.Mapped; + if (commands == null) return; + + int capacity = (int)(indirectScratch.Size / (ulong)sizeof(DrawIndexedIndirectCommand)); + int count = Math.Min(groupCount, capacity); + + for (int i = 0; i < count; i++) + { + commands[i] = new DrawIndexedIndirectCommand + { + IndexCount = (uint)indicesSizes[i], + InstanceCount = 1, + // GL takes a byte offset; Vulkan takes an index count. + FirstIndex = (uint)(indicesStarts[i] / sizeof(int)), + VertexOffset = 0, + FirstInstance = 0, + }; + } + + _context.Api.CmdDrawIndexedIndirect(commandBuffer, indirectScratch.Handle, 0, (uint)count, + (uint)sizeof(DrawIndexedIndirectCommand)); + } + + public int LayoutIdOf(int meshId) => Get(meshId)?.LayoutId ?? -1; + public VertexLayoutDescription LayoutOf(int layoutId) => _layouts.Get(layoutId); + public int LayoutCount => _layouts.Count; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (VulkanMesh? mesh in _meshes) mesh?.Dispose(); + _meshes.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs new file mode 100644 index 00000000..7b93b97b --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Creates graphics pipelines on demand and remembers them. +/// +/// Vulkan wants pipeline state baked ahead of time; GL lets it change one call +/// before a draw. Bridging that is the job here: state changes are recorded by +/// , and the first draw that needs a given +/// combination compiles a pipeline for it. Because Vulkan 1.3 makes viewport, +/// scissor, cull, front face, depth and stencil dynamic, the combinations that +/// remain are few - roughly a few hundred across the whole game - and after the +/// first minutes of play the cache stops growing. +/// +/// A driver-side backs it so that +/// even those first compiles are cheap on a second run. +/// +internal sealed unsafe class GraphicsPipelineCache : IDisposable +{ + private readonly VulkanContext _context; + private readonly Dictionary _pipelines = new(); + private readonly Silk.NET.Vulkan.PipelineCache _driverCache; + private bool _disposed; + + /// How many pipelines have been compiled, for diagnostics. + public int Count => _pipelines.Count; + + /// How many lookups were served from the cache. + public long Hits { get; private set; } + + /// How many lookups had to compile. + public long Misses { get; private set; } + + public GraphicsPipelineCache(VulkanContext context, byte[]? initialData = null) + { + _context = context; + + fixed (byte* data = initialData) + { + var createInfo = new PipelineCacheCreateInfo + { + SType = StructureType.PipelineCacheCreateInfo, + InitialDataSize = (nuint)(initialData?.Length ?? 0), + PInitialData = initialData is { Length: > 0 } ? data : null, + }; + + // A rejected blob is not an error: the driver simply starts cold. + if (context.Api.CreatePipelineCache( + context.Device, &createInfo, null, out Silk.NET.Vulkan.PipelineCache cache) == Result.Success) + { + _driverCache = cache; + } + } + } + + /// Everything a pipeline needs that is not already in the key. + internal sealed class PipelineRequest + { + public required ShaderProgramResources Program { get; init; } + public required VertexLayoutDescription VertexLayout { get; init; } + public required RenderTargetFormats Targets { get; init; } + public required AttachmentBlend[] Blend { get; init; } + public required PolygonMode PolygonMode { get; init; } + public required PrimitiveTopology Topology { get; init; } + } + + public Pipeline Get(PipelineKey key, PipelineRequest request) + { + if (_pipelines.TryGetValue(key, out Pipeline existing)) + { + Hits++; + return existing; + } + + Misses++; + Pipeline pipeline = Create(request); + _pipelines[key] = pipeline; + return pipeline; + } + + private Pipeline Create(PipelineRequest request) + { + Vk api = _context.Api; + byte* entryPoint = (byte*)SilkMarshal.StringToPtr("main"); + + var stages = new List(); + foreach (KeyValuePair module in request.Program.Modules) + { + stages.Add(new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = module.Key switch + { + EnumShaderType.VertexShader => ShaderStageFlags.VertexBit, + EnumShaderType.FragmentShader => ShaderStageFlags.FragmentBit, + _ => ShaderStageFlags.GeometryBit, + }, + Module = module.Value, + PName = entryPoint, + }); + } + + var bindings = new VertexInputBindingDescription[request.VertexLayout.Bindings.Length]; + for (int i = 0; i < bindings.Length; i++) + { + VertexBinding binding = request.VertexLayout.Bindings[i]; + bindings[i] = new VertexInputBindingDescription + { + Binding = binding.Binding, + Stride = binding.Stride, + InputRate = binding.PerInstance ? VertexInputRate.Instance : VertexInputRate.Vertex, + }; + } + + var attributes = new VertexInputAttributeDescription[request.VertexLayout.Attributes.Length]; + for (int i = 0; i < attributes.Length; i++) + { + VertexAttribute attribute = request.VertexLayout.Attributes[i]; + attributes[i] = new VertexInputAttributeDescription + { + Location = attribute.Location, + Binding = attribute.Binding, + Format = attribute.Format, + Offset = attribute.Offset, + }; + } + + var blendAttachments = new PipelineColorBlendAttachmentState[request.Targets.ColorFormats.Length]; + for (int i = 0; i < blendAttachments.Length; i++) + { + AttachmentBlend blend = i < request.Blend.Length ? request.Blend[i] : AttachmentBlend.Default; + blendAttachments[i] = new PipelineColorBlendAttachmentState + { + BlendEnable = blend.Enabled, + SrcColorBlendFactor = blend.SrcColor, + DstColorBlendFactor = blend.DstColor, + ColorBlendOp = blend.ColorOp, + SrcAlphaBlendFactor = blend.SrcAlpha, + DstAlphaBlendFactor = blend.DstAlpha, + AlphaBlendOp = blend.AlphaOp, + ColorWriteMask = blend.WriteMask, + }; + } + + // Everything Vulkan 1.3 lets us change without a new pipeline. Keeping + // this list wide is what keeps the cache small. + var dynamicStates = new[] + { + DynamicState.Viewport, + DynamicState.Scissor, + DynamicState.LineWidth, + DynamicState.CullMode, + DynamicState.FrontFace, + DynamicState.PrimitiveTopology, + DynamicState.DepthTestEnable, + DynamicState.DepthWriteEnable, + DynamicState.DepthCompareOp, + DynamicState.StencilTestEnable, + DynamicState.StencilOp, + DynamicState.StencilCompareMask, + DynamicState.StencilWriteMask, + DynamicState.StencilReference, + }; + + try + { + fixed (PipelineShaderStageCreateInfo* stagesPtr = stages.ToArray()) + fixed (VertexInputBindingDescription* bindingsPtr = bindings) + fixed (VertexInputAttributeDescription* attributesPtr = attributes) + fixed (PipelineColorBlendAttachmentState* blendPtr = blendAttachments) + fixed (DynamicState* dynamicPtr = dynamicStates) + fixed (Format* colorFormatsPtr = request.Targets.ColorFormats) + { + var vertexInput = new PipelineVertexInputStateCreateInfo + { + SType = StructureType.PipelineVertexInputStateCreateInfo, + VertexBindingDescriptionCount = (uint)bindings.Length, + PVertexBindingDescriptions = bindings.Length == 0 ? null : bindingsPtr, + VertexAttributeDescriptionCount = (uint)attributes.Length, + PVertexAttributeDescriptions = attributes.Length == 0 ? null : attributesPtr, + }; + + var inputAssembly = new PipelineInputAssemblyStateCreateInfo + { + SType = StructureType.PipelineInputAssemblyStateCreateInfo, + Topology = request.Topology, + }; + + var viewportState = new PipelineViewportStateCreateInfo + { + SType = StructureType.PipelineViewportStateCreateInfo, + ViewportCount = 1, + ScissorCount = 1, + }; + + var rasterizer = new PipelineRasterizationStateCreateInfo + { + SType = StructureType.PipelineRasterizationStateCreateInfo, + PolygonMode = request.PolygonMode, + // Cull mode and front face are dynamic; these are placeholders. + CullMode = CullModeFlags.None, + FrontFace = GlStateTracker.FrontFace, + LineWidth = 1.0f, + }; + + var multisample = new PipelineMultisampleStateCreateInfo + { + SType = StructureType.PipelineMultisampleStateCreateInfo, + RasterizationSamples = SampleCountFlags.Count1Bit, + }; + + var depthStencil = new PipelineDepthStencilStateCreateInfo + { + SType = StructureType.PipelineDepthStencilStateCreateInfo, + DepthTestEnable = false, + DepthWriteEnable = true, + DepthCompareOp = CompareOp.Less, + StencilTestEnable = false, + }; + + var colorBlend = new PipelineColorBlendStateCreateInfo + { + SType = StructureType.PipelineColorBlendStateCreateInfo, + AttachmentCount = (uint)blendAttachments.Length, + PAttachments = blendAttachments.Length == 0 ? null : blendPtr, + }; + + var dynamicState = new PipelineDynamicStateCreateInfo + { + SType = StructureType.PipelineDynamicStateCreateInfo, + DynamicStateCount = (uint)dynamicStates.Length, + PDynamicStates = dynamicPtr, + }; + + // Dynamic rendering names the attachment formats here, so there + // is no render pass or framebuffer object anywhere in the design. + var renderingInfo = new PipelineRenderingCreateInfo + { + SType = StructureType.PipelineRenderingCreateInfo, + ColorAttachmentCount = (uint)request.Targets.ColorFormats.Length, + PColorAttachmentFormats = request.Targets.ColorFormats.Length == 0 ? null : colorFormatsPtr, + DepthAttachmentFormat = request.Targets.DepthFormat, + }; + + var createInfo = new GraphicsPipelineCreateInfo + { + SType = StructureType.GraphicsPipelineCreateInfo, + PNext = &renderingInfo, + StageCount = (uint)stages.Count, + PStages = stagesPtr, + PVertexInputState = &vertexInput, + PInputAssemblyState = &inputAssembly, + PViewportState = &viewportState, + PRasterizationState = &rasterizer, + PMultisampleState = &multisample, + PDepthStencilState = &depthStencil, + PColorBlendState = &colorBlend, + PDynamicState = &dynamicState, + Layout = request.Program.PipelineLayout, + }; + + Result result = api.CreateGraphicsPipelines( + _context.Device, _driverCache, 1, &createInfo, null, out Pipeline pipeline); + + if (result != Result.Success) + { + throw new InvalidOperationException("vkCreateGraphicsPipelines failed: " + result); + } + return pipeline; + } + } + finally + { + SilkMarshal.Free((nint)entryPoint); + } + } + + /// + /// The driver's cache blob, to be written next to the SPIR-V cache so the + /// next run starts warm. + /// + public byte[] SerializeDriverCache() + { + if (_driverCache.Handle == 0) return Array.Empty(); + + nuint size = 0; + _context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, null); + if (size == 0) return Array.Empty(); + + var data = new byte[(int)size]; + fixed (byte* dataPtr = data) + { + _context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, dataPtr); + } + return data; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + Vk api = _context.Api; + foreach (Pipeline pipeline in _pipelines.Values) + { + api.DestroyPipeline(_context.Device, pipeline, null); + } + _pipelines.Clear(); + + if (_driverCache.Handle != 0) + { + api.DestroyPipelineCache(_context.Device, _driverCache, null); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs new file mode 100644 index 00000000..6f1038ea --- /dev/null +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -0,0 +1,381 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// One attachment slot of a framebuffer. +internal struct AttachmentSlot +{ + public int TextureId; + public uint Layer; + + public readonly bool IsBound => TextureId > 0; +} + +/// +/// A render target: colour attachments in GL's positional slots, an optional +/// depth attachment, and the draw-buffer mask. +/// +internal sealed class VulkanFramebuffer +{ + public int Id; + public uint Width; + public uint Height; + + public AttachmentSlot[] Color = new AttachmentSlot[GlStateTracker.MaxColorAttachments]; + public int DepthTextureId; + + /// + /// Bit i set means fragment output i is written. GL's glDrawBuffers selects + /// a subset of attachments rather than merely masking writes, so a cleared + /// bit means the attachment is not part of the rendering scope at all. + /// + public uint DrawBufferMask = 1; + + /// Cached interned id of the attachment formats, or -1 when stale. + public int FormatsId = -1; +} + +/// +/// Owns framebuffers and drives dynamic rendering scopes. +/// +/// The subtle part is glDrawBuffers. It does not mask writes - it selects +/// which attachments participate - and the game depends on that: the final +/// composition pass renders into the primary framebuffer's attachment 0 while +/// sampling its attachment 1, which is only legal because attachment 1 is not +/// part of the draw. Vulkan agrees, as long as the excluded attachments are left +/// out of vkCmdBeginRendering and moved to a shader-readable layout, so +/// the mask is honoured positionally: a disabled slot becomes a null attachment, +/// keeping fragment output N aimed at slot N. +/// +internal sealed unsafe class RenderTargetManager : IDisposable +{ + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly GlStateTracker _state; + + private readonly List _framebuffers = new(); + private readonly Stack _freeIds = new(); + + private VulkanFramebuffer? _bound; + private bool _renderingActive; + private bool _disposed; + + /// How many rendering scopes have been opened, for diagnostics. + public long ScopesOpened { get; private set; } + + public RenderTargetManager(VulkanContext context, TextureManager textures, GlStateTracker state) + { + _context = context; + _textures = textures; + _state = state; + + // Index 0 is the default framebuffer, installed separately. + _framebuffers.Add(null); + } + + public VulkanFramebuffer? Bound => _bound; + public bool RenderingActive => _renderingActive; + + public VulkanFramebuffer? Get(int id) => + id > 0 && id < _framebuffers.Count ? _framebuffers[id] : null; + + public int Create(uint width, uint height) + { + var framebuffer = new VulkanFramebuffer { Width = width, Height = height }; + + if (_freeIds.Count > 0) + { + int reused = _freeIds.Pop(); + framebuffer.Id = reused; + _framebuffers[reused] = framebuffer; + return reused; + } + + _framebuffers.Add(framebuffer); + framebuffer.Id = _framebuffers.Count - 1; + return framebuffer.Id; + } + + public void Attach(int framebufferId, int attachmentIndex, int textureId, uint layer = 0) + { + VulkanFramebuffer? framebuffer = Get(framebufferId); + if (framebuffer == null) return; + + if (attachmentIndex < 0) + { + framebuffer.DepthTextureId = textureId; + } + else if (attachmentIndex < GlStateTracker.MaxColorAttachments) + { + framebuffer.Color[attachmentIndex] = new AttachmentSlot { TextureId = textureId, Layer = layer }; + } + + framebuffer.FormatsId = -1; + } + + public void SetDrawBuffers(int framebufferId, uint mask) + { + VulkanFramebuffer? framebuffer = Get(framebufferId); + if (framebuffer == null || framebuffer.DrawBufferMask == mask) return; + + framebuffer.DrawBufferMask = mask; + framebuffer.FormatsId = -1; + + // The set of attachments changed, so the current scope no longer + // describes what is being rendered into. + if (_bound == framebuffer) _needsRestart = true; + } + + private bool _needsRestart; + + /// + /// Binds a framebuffer. Nothing is recorded here: GL lets a bind be followed + /// by more state changes before anything is drawn, so the scope opens lazily + /// at the first draw or clear. + /// + public void Bind(CommandBuffer commandBuffer, int framebufferId) + { + VulkanFramebuffer? framebuffer = Get(framebufferId); + if (ReferenceEquals(framebuffer, _bound)) return; + + EndRendering(commandBuffer); + _bound = framebuffer; + _needsRestart = false; + } + + public void Delete(int framebufferId) + { + VulkanFramebuffer? framebuffer = Get(framebufferId); + if (framebuffer == null) return; + + if (ReferenceEquals(framebuffer, _bound)) _bound = null; + _framebuffers[framebufferId] = null; + _freeIds.Push(framebufferId); + } + + // ------------------------------------------------------------------- scopes + + /// + /// Opens a rendering scope if one is not already open, transitioning every + /// participating attachment into its attachment layout and every excluded + /// one into a shader-readable layout. + /// + public void EnsureRendering(CommandBuffer commandBuffer) + { + if (_renderingActive && !_needsRestart) return; + if (_bound == null) return; + + if (_renderingActive) EndRendering(commandBuffer); + + VulkanFramebuffer framebuffer = _bound; + int highest = HighestEnabledAttachment(framebuffer); + int count = highest + 1; + + var attachments = new RenderingAttachmentInfo[Math.Max(count, 0)]; + + for (int i = 0; i < count; i++) + { + bool enabled = (framebuffer.DrawBufferMask & (1u << i)) != 0; + AttachmentSlot slot = framebuffer.Color[i]; + + if (!enabled || !slot.IsBound) + { + // A null view keeps fragment output i pointed at slot i while + // discarding its writes, which is what a cleared draw-buffer bit + // means in GL. + attachments[i] = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = default, + ImageLayout = ImageLayout.Undefined, + LoadOp = AttachmentLoadOp.DontCare, + StoreOp = AttachmentStoreOp.DontCare, + }; + + // An attachment left out of the draw may be sampled instead, so + // it has to be readable. + if (slot.IsBound) + { + VulkanTexture? excluded = _textures.Get(slot.TextureId); + if (excluded != null) + { + _textures.TransitionTexture(commandBuffer, excluded, ImageLayout.ShaderReadOnlyOptimal); + } + } + continue; + } + + VulkanTexture? texture = _textures.Get(slot.TextureId); + if (texture == null) + { + attachments[i] = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo }; + continue; + } + + _textures.TransitionTexture(commandBuffer, texture, ImageLayout.ColorAttachmentOptimal); + + attachments[i] = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + // The slot's layer, not the whole image: an array attached once + // per layer must reach a different layer each time. + ImageView = texture.ViewOfLayer(slot.Layer), + ImageLayout = ImageLayout.ColorAttachmentOptimal, + // LOAD preserves what is already there, which is GL's model: a + // framebuffer keeps its contents until something clears it. + LoadOp = AttachmentLoadOp.Load, + StoreOp = AttachmentStoreOp.Store, + }; + } + + RenderingAttachmentInfo depthAttachment = default; + bool hasDepth = false; + if (framebuffer.DepthTextureId > 0) + { + VulkanTexture? depth = _textures.Get(framebuffer.DepthTextureId); + if (depth != null) + { + _textures.TransitionTexture(commandBuffer, depth, ImageLayout.DepthAttachmentOptimal); + depthAttachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = depth.View, + ImageLayout = ImageLayout.DepthAttachmentOptimal, + LoadOp = AttachmentLoadOp.Load, + StoreOp = AttachmentStoreOp.Store, + }; + hasDepth = true; + } + } + + fixed (RenderingAttachmentInfo* attachmentsPtr = attachments) + { + var rendering = new RenderingInfo + { + SType = StructureType.RenderingInfo, + RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(framebuffer.Width, framebuffer.Height)), + LayerCount = 1, + ColorAttachmentCount = (uint)attachments.Length, + PColorAttachments = attachments.Length == 0 ? null : attachmentsPtr, + PDepthAttachment = hasDepth ? &depthAttachment : null, + }; + + _context.Api.CmdBeginRendering(commandBuffer, &rendering); + } + + _renderingActive = true; + _needsRestart = false; + ScopesOpened++; + } + + /// + /// Closes the scope. Uploads and layout transitions have to happen outside + /// one, so this is called before them and the scope reopens on the next draw. + /// + public void EndRendering(CommandBuffer commandBuffer) + { + if (!_renderingActive) return; + _context.Api.CmdEndRendering(commandBuffer); + _renderingActive = false; + } + + // ------------------------------------------------------------------- clears + + public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) + { + if (_bound == null) return; + EnsureRendering(commandBuffer); + if (!_renderingActive) return; + + var clear = new ClearAttachment + { + AspectMask = ImageAspectFlags.ColorBit, + ColorAttachment = (uint)attachment, + ClearValue = new ClearValue(new ClearColorValue(r, g, b, a)), + }; + var rect = new ClearRect + { + Rect = new Rect2D(new Offset2D(0, 0), new Extent2D(_bound.Width, _bound.Height)), + BaseArrayLayer = 0, + LayerCount = 1, + }; + _context.Api.CmdClearAttachments(commandBuffer, 1, &clear, 1, &rect); + } + + public void ClearDepth(CommandBuffer commandBuffer, float depth) + { + if (_bound == null || _bound.DepthTextureId <= 0) return; + EnsureRendering(commandBuffer); + if (!_renderingActive) return; + + var clear = new ClearAttachment + { + AspectMask = ImageAspectFlags.DepthBit, + ClearValue = new ClearValue(depthStencil: new ClearDepthStencilValue(depth, 0)), + }; + var rect = new ClearRect + { + Rect = new Rect2D(new Offset2D(0, 0), new Extent2D(_bound.Width, _bound.Height)), + BaseArrayLayer = 0, + LayerCount = 1, + }; + _context.Api.CmdClearAttachments(commandBuffer, 1, &clear, 1, &rect); + } + + // ------------------------------------------------------------------ formats + + /// + /// The attachment formats of the bound target, for the pipeline key. Disabled + /// slots report so the pipeline agrees with + /// the null attachments the scope was opened with. + /// + public int FormatsIdOf(VulkanFramebuffer framebuffer) + { + if (framebuffer.FormatsId >= 0) return framebuffer.FormatsId; + + int count = HighestEnabledAttachment(framebuffer) + 1; + var colorFormats = new Format[Math.Max(count, 0)]; + + for (int i = 0; i < count; i++) + { + bool enabled = (framebuffer.DrawBufferMask & (1u << i)) != 0; + AttachmentSlot slot = framebuffer.Color[i]; + VulkanTexture? texture = enabled && slot.IsBound ? _textures.Get(slot.TextureId) : null; + colorFormats[i] = texture?.Format ?? Format.Undefined; + } + + Format depthFormat = Format.Undefined; + if (framebuffer.DepthTextureId > 0) + { + depthFormat = _textures.Get(framebuffer.DepthTextureId)?.Format ?? Format.Undefined; + } + + framebuffer.FormatsId = _state.InternTargetFormats(new RenderTargetFormats(colorFormats, depthFormat)); + return framebuffer.FormatsId; + } + + public int EnabledAttachmentCount(VulkanFramebuffer framebuffer) => + HighestEnabledAttachment(framebuffer) + 1; + + private static int HighestEnabledAttachment(VulkanFramebuffer framebuffer) + { + int highest = -1; + for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + { + if ((framebuffer.DrawBufferMask & (1u << i)) != 0 && framebuffer.Color[i].IsBound) + { + highest = i; + } + } + return highest; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _framebuffers.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Core/RenderTrace.cs b/Optimum.Render.Vulkan/Core/RenderTrace.cs new file mode 100644 index 00000000..aed8d51a --- /dev/null +++ b/Optimum.Render.Vulkan/Core/RenderTrace.cs @@ -0,0 +1,127 @@ +using System; +using System.Globalization; +using System.IO; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// A trace of what the device was actually asked to draw, for the cases where +/// the frame is legal - the validation layer says nothing - but wrong. +/// +/// Off unless OPTIMUM_RENDER_TRACE names a file, so it costs one static bool +/// check in a release build and never appears in a normal session. It is a +/// debugging aid rather than diagnostics the game consumes: the client's own +/// error channel carries validation messages already. +/// +internal static class RenderTrace +{ + private static readonly object Gate = new(); + private static readonly string? Path = Environment.GetEnvironmentVariable("OPTIMUM_RENDER_TRACE"); + + public static bool Enabled => Path != null; + + public static void Write(string line) + { + if (Path == null) return; + lock (Gate) + { + File.AppendAllText(Path, line + "\n"); + } + } + + /// + /// Records a texture upload along with a checksum of its first rows, which + /// is what distinguishes "the image never got the pixels" from "the image is + /// correct but never sampled". + /// + public static unsafe void TextureCreated( + int id, int width, int height, Format format, IntPtr pixels, int bytesPerPixel) + { + if (Path == null) return; + + long sum = 0; + int nonZero = 0; + if (pixels != IntPtr.Zero && bytesPerPixel > 0) + { + int sampled = Math.Min(width * height * bytesPerPixel, 64 * 1024); + byte* bytes = (byte*)pixels; + for (int i = 0; i < sampled; i++) + { + sum += bytes[i]; + if (bytes[i] != 0) nonZero++; + } + } + + Write(string.Format(CultureInfo.InvariantCulture, + "tex create id={0} {1}x{2} format={3} bpp={4} bytesum={5} nonzero={6}", + id, width, height, format, bytesPerPixel, sum, nonZero)); + } + + /// + /// Dumps one named uniform out of a program's shadow buffer, which is what + /// separates "the CPU wrote nonsense" from "the CPU was right and the GPU + /// read it from the wrong place". + /// + public static void Uniforms(Shaders.ProgramInterfaceLayout layout, byte[] shadow, string name) + { + if (Path == null) return; + if (!layout.MembersByName.TryGetValue(name, out Shaders.UniformMember? member)) return; + + int floats = Math.Min(member.Size / sizeof(float), 16); + var text = new System.Text.StringBuilder(); + text.Append(" ").Append(name).Append(" @").Append(member.Offset).Append(" ="); + for (int i = 0; i < floats; i++) + { + text.Append(' ').Append( + BitConverter.ToSingle(shadow, member.Offset + i * sizeof(float)) + .ToString("0.###", CultureInfo.InvariantCulture)); + } + Write(text.ToString()); + } + + public static void UniformInt(Shaders.ProgramInterfaceLayout layout, byte[] shadow, string name) + { + if (Path == null) return; + if (!layout.MembersByName.TryGetValue(name, out Shaders.UniformMember? member)) return; + + Write(" " + name + " @" + member.Offset + " = " + BitConverter.ToInt32(shadow, member.Offset)); + } + + /// + /// Writes each stage's rewritten GLSL beside the trace file, so the source + /// the driver actually compiled can be read rather than reconstructed. + /// + public static void DumpProgramSources(string passName, Shaders.TranslatedProgram translated) + { + if (Path == null) return; + + string directory = System.IO.Path.GetDirectoryName(Path) ?? "."; + // The hardcoded minimal-GUI program has no pass name at all. + string safeName = string.IsNullOrWhiteSpace(passName) + ? "unnamed" + : string.Join("_", passName.Split(System.IO.Path.GetInvalidFileNameChars())); + + foreach (var stage in translated.RewrittenSource) + { + string file = System.IO.Path.Combine(directory, "shader-" + safeName + "-" + stage.Key + ".glsl"); + try + { + File.WriteAllText(file, stage.Value); + } + catch (IOException) + { + // Losing a debug dump must not disturb the run. + } + } + } + + public static void Draw(int meshId, int programId, int indexCount, bool depthTest, bool blend, float depthRangeHint) + { + if (Path == null) return; + + Write(string.Format(CultureInfo.InvariantCulture, + "draw mesh={0} program={1} indices={2} depthTest={3} blend={4} z={5}", + meshId, programId, indexCount, depthTest, blend, depthRangeHint)); + } +} diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs new file mode 100644 index 00000000..f4f01f26 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Everything the GPU needs for one linked shader program: the modules, the +/// descriptor set layouts derived from its interface, the pipeline layout, and +/// the CPU-side shadow of its uniform block. +/// +/// The shadow buffer is what makes GL's uniform protocol work. The game sets +/// uniforms one at a time by name, at any point before a draw, and expects the +/// values to persist for the life of the program. So writes land in this buffer, +/// and a draw copies it into the frame's uniform ring only when something +/// changed. +/// +internal sealed unsafe class ShaderProgramResources : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + public int ProgramId { get; } + public ProgramInterfaceLayout Interface { get; } + + public Dictionary Modules { get; } = new(); + + /// Set 0 uniforms, set 1 samplers, set 2 storage buffers. + public DescriptorSetLayout[] SetLayouts { get; } = new DescriptorSetLayout[3]; + public PipelineLayout PipelineLayout { get; private set; } + + /// CPU mirror of the generated uniform block. + public byte[] UniformShadow { get; } + + /// True when the shadow has changed since it was last uploaded. + public bool UniformsDirty { get; private set; } = true; + + /// + /// Which texture unit each sampler uniform points at. In GL this is just an + /// int uniform; here it is the link between a bound texture and a descriptor. + /// + public Dictionary SamplerUnits { get; } = new(StringComparer.Ordinal); + + public ShaderProgramResources( + VulkanContext context, int programId, TranslatedProgram translated) + { + _context = context; + ProgramId = programId; + Interface = translated.Layout; + UniformShadow = translated.Layout.CreateShadowBuffer(); + + foreach (KeyValuePair stage in translated.Spirv) + { + Modules[stage.Key] = CreateModule(stage.Value); + } + + // Sampler uniforms default to the unit matching their binding, which is + // the order the game's own texture-location bookkeeping assigns. + foreach (SamplerBinding sampler in Interface.Samplers) + { + SamplerUnits[sampler.Name] = sampler.Binding; + } + + CreateSetLayouts(); + CreatePipelineLayout(); + } + + private ShaderModule CreateModule(byte[] spirv) + { + fixed (byte* code = spirv) + { + var createInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + + if (_context.Api.CreateShaderModule(_context.Device, &createInfo, null, out ShaderModule module) + != Result.Success) + { + throw new InvalidOperationException("vkCreateShaderModule failed"); + } + return module; + } + } + + /// + /// Builds one layout per set. Stage visibility is set to all graphics stages + /// rather than tracked per binding: the sets are tiny, the cost of a wider + /// visibility is nil, and a uniform shared between stages - which GL makes + /// routine - would otherwise need its visibility recomputed on every link. + /// + private void CreateSetLayouts() + { + const ShaderStageFlags allGraphics = + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit | ShaderStageFlags.GeometryBit; + + var uniformBindings = new List(); + if (Interface.HasUniformBlock) + { + uniformBindings.Add(new DescriptorSetLayoutBinding + { + Binding = ProgramInterfaceLayout.DefaultBlockBinding, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = allGraphics, + }); + } + foreach (BlockBinding block in Interface.UniformBlocks) + { + uniformBindings.Add(new DescriptorSetLayoutBinding + { + Binding = (uint)block.Binding, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = allGraphics, + }); + } + + var samplerBindings = new List(); + foreach (SamplerBinding sampler in Interface.Samplers) + { + samplerBindings.Add(new DescriptorSetLayoutBinding + { + Binding = (uint)sampler.Binding, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = 1, + StageFlags = allGraphics, + }); + } + + var storageBindings = new List(); + foreach (BlockBinding block in Interface.StorageBlocks) + { + storageBindings.Add(new DescriptorSetLayoutBinding + { + Binding = (uint)block.Binding, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = 1, + StageFlags = allGraphics, + }); + } + + SetLayouts[ProgramInterfaceLayout.DefaultBlockSet] = CreateSetLayout(uniformBindings); + SetLayouts[ProgramInterfaceLayout.SamplerSet] = CreateSetLayout(samplerBindings); + SetLayouts[ProgramInterfaceLayout.StorageSet] = CreateSetLayout(storageBindings); + } + + private DescriptorSetLayout CreateSetLayout(List bindings) + { + // An empty set is still created rather than skipped, so set numbering + // stays fixed: samplers are always set 1 whether or not the program has + // uniforms, which keeps the rewriter's binding decisions valid. + DescriptorSetLayoutBinding[] array = bindings.ToArray(); + fixed (DescriptorSetLayoutBinding* bindingsPtr = array) + { + var createInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = (uint)array.Length, + PBindings = array.Length == 0 ? null : bindingsPtr, + }; + + if (_context.Api.CreateDescriptorSetLayout( + _context.Device, &createInfo, null, out DescriptorSetLayout layout) != Result.Success) + { + throw new InvalidOperationException("vkCreateDescriptorSetLayout failed"); + } + return layout; + } + } + + private void CreatePipelineLayout() + { + fixed (DescriptorSetLayout* setLayouts = SetLayouts) + { + var createInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = (uint)SetLayouts.Length, + PSetLayouts = setLayouts, + }; + + if (_context.Api.CreatePipelineLayout( + _context.Device, &createInfo, null, out PipelineLayout layout) != Result.Success) + { + throw new InvalidOperationException("vkCreatePipelineLayout failed"); + } + PipelineLayout = layout; + } + } + + // ------------------------------------------------------------------ uniforms + + /// + /// Resolves a uniform name to its byte offset in the block, or -1 when the + /// program does not use it. Callers treat this as opaque, exactly as they + /// treat a GL uniform location. + /// + /// + /// The first sampler location. Sampler locations run downwards from here so + /// they can never collide with a uniform block offset, which is always zero + /// or positive, nor with GL's "not found" answer of -1. + /// + private const int FirstSamplerLocation = -2; + + /// Whether a location handed out by names a sampler. + public static bool IsSamplerLocation(int location) => location <= FirstSamplerLocation; + + private static int SamplerIndexOf(int location) => FirstSamplerLocation - location; + + /// + /// Resolves a uniform name to an opaque location, the way glGetUniformLocation + /// does. + /// + /// Samplers are not members of the generated block - they are descriptor + /// bindings - but the client looks every declared uniform up by name and + /// treats a -1 as "the shader does not use this". Returning -1 for samplers + /// would tell it that every texture uniform in the game is unused, so they + /// get locations of their own from a disjoint range. + /// + public int LocationOf(string name) + { + if (Interface.MembersByName.TryGetValue(name, out UniformMember? member)) + { + return member.Offset; + } + + for (int i = 0; i < Interface.Samplers.Count; i++) + { + if (string.Equals(Interface.Samplers[i].Name, name, StringComparison.Ordinal)) + { + return FirstSamplerLocation - i; + } + } + return -1; + } + + /// + /// Points the sampler at at a texture unit. + /// + /// GL assigns a sampler's unit by writing an int to its uniform location, so + /// a client that resolved a location and set it as an int lands here rather + /// than writing into the uniform block. + /// + public void SetSamplerUnitByLocation(int location, int unit) + { + int index = SamplerIndexOf(location); + if (index < 0 || index >= Interface.Samplers.Count) return; + + SamplerUnits[Interface.Samplers[index].Name] = unit; + } + + /// Writes raw bytes at an offset previously handed out by . + public void SetUniform(int offset, ReadOnlySpan data) + { + if (offset < 0 || offset + data.Length > UniformShadow.Length) return; + + Span destination = UniformShadow.AsSpan(offset, data.Length); + if (data.SequenceEqual(destination)) return; + + data.CopyTo(destination); + UniformsDirty = true; + } + + public void MarkUniformsClean() => UniformsDirty = false; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + Vk api = _context.Api; + api.DestroyPipelineLayout(_context.Device, PipelineLayout, null); + + foreach (DescriptorSetLayout layout in SetLayouts) + { + if (layout.Handle != 0) api.DestroyDescriptorSetLayout(_context.Device, layout, null); + } + foreach (ShaderModule module in Modules.Values) + { + api.DestroyShaderModule(_context.Device, module, null); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/Swapchain.cs b/Optimum.Render.Vulkan/Core/Swapchain.cs new file mode 100644 index 00000000..cd0f72ff --- /dev/null +++ b/Optimum.Render.Vulkan/Core/Swapchain.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The presentation chain, and the only place in the backend where the image is +/// flipped. +/// +/// Everything upstream renders in OpenGL's orientation, because GL and Vulkan +/// agree on how clip space maps to framebuffer memory and differ only in which +/// corner they name the origin. That keeps every intermediate target, every +/// render-to-texture round trip and every screenshot byte-identical to the GL +/// path. Only scanout disagrees - the display reads row 0 at the top - so the +/// correction happens once, here, as an inverted blit at present time. +/// +internal sealed unsafe class Swapchain : IDisposable +{ + private readonly VulkanContext _context; + private readonly KhrSurface _surfaceApi; + private readonly KhrSwapchain _swapchainApi; + private readonly SurfaceKHR _surface; + + private SwapchainKHR _handle; + private Image[] _images = Array.Empty(); + private ImageView[] _views = Array.Empty(); + private Semaphore[] _imageAvailable = Array.Empty(); + private Semaphore[] _renderFinished = Array.Empty(); + private int _semaphoreIndex; + private bool _disposed; + + public Format Format { get; private set; } = Format.B8G8R8A8Unorm; + public Extent2D Extent { get; private set; } + public PresentModeKHR PresentMode { get; private set; } = PresentModeKHR.FifoKhr; + public uint ImageCount => (uint)_images.Length; + + /// Set when the surface reports the chain is stale and it must be rebuilt. + public bool NeedsRecreation { get; private set; } + + private Swapchain(VulkanContext context, KhrSurface surfaceApi, KhrSwapchain swapchainApi, SurfaceKHR surface) + { + _context = context; + _surfaceApi = surfaceApi; + _swapchainApi = swapchainApi; + _surface = surface; + } + + public static bool TryCreate( + VulkanContext context, SurfaceKHR surface, uint width, uint height, bool vsync, + out Swapchain? swapchain, out string? failureReason) + { + swapchain = null; + failureReason = null; + + if (!context.Api.TryGetInstanceExtension(context.Instance, out KhrSurface surfaceApi)) + { + failureReason = "VK_KHR_surface unavailable"; + return false; + } + if (!context.Api.TryGetDeviceExtension(context.Instance, context.Device, out KhrSwapchain swapchainApi)) + { + failureReason = "VK_KHR_swapchain unavailable"; + return false; + } + + // The graphics queue has to be able to present. A separate present queue + // is possible in principle but does not occur on any desktop driver, and + // supporting it would add a queue-ownership transfer to every frame. + surfaceApi.GetPhysicalDeviceSurfaceSupport( + context.PhysicalDevice, context.GraphicsQueueFamily, surface, + out Silk.NET.Core.Bool32 supported); + if (!supported) + { + failureReason = "the graphics queue family cannot present to this surface"; + return false; + } + + var created = new Swapchain(context, surfaceApi, swapchainApi, surface); + if (!created.Build(width, height, vsync, out failureReason)) + { + created.Dispose(); + return false; + } + + swapchain = created; + return true; + } + + private bool Build(uint width, uint height, bool vsync, out string? failureReason) + { + failureReason = null; + + _surfaceApi.GetPhysicalDeviceSurfaceCapabilities( + _context.PhysicalDevice, _surface, out SurfaceCapabilitiesKHR capabilities); + + Extent = ChooseExtent(capabilities, width, height); + if (Extent.Width == 0 || Extent.Height == 0) + { + failureReason = "surface has zero extent"; + return false; + } + + Format = ChooseFormat(out ColorSpaceKHR colorSpace); + PresentMode = ChoosePresentMode(vsync); + + uint imageCount = capabilities.MinImageCount + 1; + if (capabilities.MaxImageCount > 0 && imageCount > capabilities.MaxImageCount) + { + imageCount = capabilities.MaxImageCount; + } + + var createInfo = new SwapchainCreateInfoKHR + { + SType = StructureType.SwapchainCreateInfoKhr, + Surface = _surface, + MinImageCount = imageCount, + ImageFormat = Format, + ImageColorSpace = colorSpace, + ImageExtent = Extent, + ImageArrayLayers = 1, + // Transfer destination because the frame is blitted in rather than + // rendered directly: the game renders into its own targets and the + // last step copies the result across, flipping it on the way. + ImageUsage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit, + ImageSharingMode = SharingMode.Exclusive, + PreTransform = capabilities.CurrentTransform, + CompositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr, + PresentMode = PresentMode, + Clipped = true, + OldSwapchain = default, + }; + + if (_swapchainApi.CreateSwapchain(_context.Device, &createInfo, null, out SwapchainKHR handle) + != Result.Success) + { + failureReason = "vkCreateSwapchainKHR failed"; + return false; + } + _handle = handle; + + uint count = 0; + _swapchainApi.GetSwapchainImages(_context.Device, _handle, ref count, null); + _images = new Image[count]; + fixed (Image* imagesPtr = _images) + { + _swapchainApi.GetSwapchainImages(_context.Device, _handle, ref count, imagesPtr); + } + + _views = new ImageView[count]; + for (int i = 0; i < count; i++) + { + var viewInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = _images[i], + ViewType = ImageViewType.Type2D, + Format = Format, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + _context.Api.CreateImageView(_context.Device, &viewInfo, null, out _views[i]); + } + + CreateSemaphores((int)count); + NeedsRecreation = false; + return true; + } + + private void CreateSemaphores(int count) + { + DestroySemaphores(); + + _imageAvailable = new Semaphore[count]; + _renderFinished = new Semaphore[count]; + + var createInfo = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo }; + for (int i = 0; i < count; i++) + { + _context.Api.CreateSemaphore(_context.Device, &createInfo, null, out _imageAvailable[i]); + _context.Api.CreateSemaphore(_context.Device, &createInfo, null, out _renderFinished[i]); + } + _semaphoreIndex = 0; + } + + private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities, uint width, uint height) + { + // A driver that pins the extent wins; otherwise clamp what we asked for. + if (capabilities.CurrentExtent.Width != uint.MaxValue) + { + return capabilities.CurrentExtent; + } + + return new Extent2D( + Math.Clamp(width, capabilities.MinImageExtent.Width, capabilities.MaxImageExtent.Width), + Math.Clamp(height, capabilities.MinImageExtent.Height, capabilities.MaxImageExtent.Height)); + } + + /// + /// Prefers a plain 8-bit BGRA format in sRGB colour space. The game's default + /// framebuffer is linear - it never enables GL_FRAMEBUFFER_SRGB - so an + /// _SRGB image format would apply a conversion the GL path never did and + /// wash the picture out. + /// + private Format ChooseFormat(out ColorSpaceKHR colorSpace) + { + uint count = 0; + _surfaceApi.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _surface, ref count, null); + + var formats = new SurfaceFormatKHR[count]; + fixed (SurfaceFormatKHR* formatsPtr = formats) + { + _surfaceApi.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _surface, ref count, formatsPtr); + } + + foreach (SurfaceFormatKHR candidate in formats) + { + if (candidate.Format is Format.B8G8R8A8Unorm or Format.R8G8B8A8Unorm) + { + colorSpace = candidate.ColorSpace; + return candidate.Format; + } + } + + if (formats.Length > 0) + { + colorSpace = formats[0].ColorSpace; + return formats[0].Format; + } + + colorSpace = ColorSpaceKHR.SpaceSrgbNonlinearKhr; + return Format.B8G8R8A8Unorm; + } + + /// + /// FIFO when vsync is on, since it is the only mode guaranteed present. + /// Otherwise mailbox if the driver has it - it drops frames instead of + /// tearing - and immediate as the fallback. + /// + private PresentModeKHR ChoosePresentMode(bool vsync) + { + if (vsync) return PresentModeKHR.FifoKhr; + + uint count = 0; + _surfaceApi.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _surface, ref count, null); + + var modes = new PresentModeKHR[count]; + fixed (PresentModeKHR* modesPtr = modes) + { + _surfaceApi.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _surface, ref count, modesPtr); + } + + foreach (PresentModeKHR mode in modes) + { + if (mode == PresentModeKHR.MailboxKhr) return mode; + } + foreach (PresentModeKHR mode in modes) + { + if (mode == PresentModeKHR.ImmediateKhr) return mode; + } + return PresentModeKHR.FifoKhr; + } + + /// + /// Acquires the next image. Returns false when the chain is stale, which the + /// caller turns into a rebuild rather than an error - a resize or a monitor + /// change is ordinary. + /// + public bool TryAcquire(out uint imageIndex, out Semaphore waitSemaphore, out Semaphore signalSemaphore) + { + imageIndex = 0; + waitSemaphore = _imageAvailable[_semaphoreIndex]; + signalSemaphore = _renderFinished[_semaphoreIndex]; + + Result result = _swapchainApi.AcquireNextImage( + _context.Device, _handle, ulong.MaxValue, waitSemaphore, default, ref imageIndex); + + if (result is Result.ErrorOutOfDateKhr) + { + NeedsRecreation = true; + return false; + } + if (result == Result.SuboptimalKhr) + { + // Usable this frame; rebuilt before the next one. + NeedsRecreation = true; + return true; + } + // Anything else - a lost device above all - is reported rather than + // turned into a quiet "no image this frame", which reads as a freeze. + VulkanResult.Check(result, "vkAcquireNextImageKHR"); + return result == Result.Success; + } + + public Image ImageAt(uint index) => _images[index]; + public ImageView ViewAt(uint index) => _views[index]; + + public void Present(uint imageIndex, Semaphore waitSemaphore) + { + SwapchainKHR handle = _handle; + Semaphore wait = waitSemaphore; + uint index = imageIndex; + + var presentInfo = new PresentInfoKHR + { + SType = StructureType.PresentInfoKhr, + WaitSemaphoreCount = 1, + PWaitSemaphores = &wait, + SwapchainCount = 1, + PSwapchains = &handle, + PImageIndices = &index, + }; + + // Presenting is a queue operation like any other, so it takes the same + // lock as submission. + Result result; + lock (_context.QueueLock) + { + result = _swapchainApi.QueuePresent(_context.GraphicsQueue, &presentInfo); + } + if (result is Result.ErrorOutOfDateKhr or Result.SuboptimalKhr) + { + NeedsRecreation = true; + } + else + { + VulkanResult.Check(result, "vkQueuePresentKHR"); + } + + _semaphoreIndex = (_semaphoreIndex + 1) % Math.Max(_imageAvailable.Length, 1); + } + + public bool Recreate(uint width, uint height, bool vsync, out string? failureReason) + { + _context.Api.DeviceWaitIdle(_context.Device); + DestroyChain(); + return Build(width, height, vsync, out failureReason); + } + + private void DestroyChain() + { + foreach (ImageView view in _views) + { + if (view.Handle != 0) _context.Api.DestroyImageView(_context.Device, view, null); + } + _views = Array.Empty(); + _images = Array.Empty(); + + if (_handle.Handle != 0) + { + _swapchainApi.DestroySwapchain(_context.Device, _handle, null); + _handle = default; + } + } + + private void DestroySemaphores() + { + foreach (Semaphore semaphore in _imageAvailable) + { + if (semaphore.Handle != 0) _context.Api.DestroySemaphore(_context.Device, semaphore, null); + } + foreach (Semaphore semaphore in _renderFinished) + { + if (semaphore.Handle != 0) _context.Api.DestroySemaphore(_context.Device, semaphore, null); + } + _imageAvailable = Array.Empty(); + _renderFinished = Array.Empty(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _context.Api.DeviceWaitIdle(_context.Device); + DestroyChain(); + DestroySemaphores(); + + if (_surface.Handle != 0) + { + _surfaceApi.DestroySurface(_context.Instance, _surface, null); + } + + _swapchainApi.Dispose(); + _surfaceApi.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs new file mode 100644 index 00000000..7d7f85d1 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -0,0 +1,543 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The sampler state GL keeps on the texture object. +/// +/// In GL these live on the texture and are changed with glTexParameter; in Vulkan +/// they belong to a separate immutable sampler object. Keeping them here as a +/// value and resolving to a cached sampler at bind time reproduces the GL +/// behaviour without creating an object per texture. +/// +internal readonly record struct SamplerState( + Filter MagFilter, + Filter MinFilter, + SamplerMipmapMode MipmapMode, + SamplerAddressMode AddressU, + SamplerAddressMode AddressV, + float LodBias, + bool CompareEnable, + float MaxAnisotropy, + BorderColor BorderColor) +{ + public static SamplerState Default => new( + Filter.Nearest, Filter.Nearest, SamplerMipmapMode.Nearest, + SamplerAddressMode.Repeat, SamplerAddressMode.Repeat, + 0f, false, 1f, BorderColor.FloatOpaqueBlack); +} + +/// A texture, its memory, its view, and the GL state attached to it. +internal sealed unsafe class VulkanTexture : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + public Image Image { get; init; } + public DeviceMemory Memory { get; init; } + public ImageView View { get; init; } + public Format Format { get; init; } + public uint Width { get; init; } + public uint Height { get; init; } + public uint MipLevels { get; init; } + public uint Layers { get; init; } + public ImageAspectFlags Aspect { get; init; } + + /// Mutable, as glTexParameter is. + public SamplerState State { get; set; } = SamplerState.Default; + + /// Tracked because Vulkan offers no way to query it. + public ImageLayout Layout { get; set; } = ImageLayout.Undefined; + + /// + /// Single-layer views, created on demand and keyed by layer. + /// + /// covers the whole image, which is what a sampler wants. + /// A colour attachment pointed at one layer of an array needs a view of that + /// layer alone - the OIT accumulation target is one array attached three + /// times, once per layer, and a whole-image view there sends all three + /// attachments to the same layer. + /// + private readonly Dictionary _layerViews = new(); + + public VulkanTexture(VulkanContext context) => _context = context; + + public ImageView ViewOfLayer(uint layer) + { + if (layer == 0 && Layers <= 1) return View; + if (_layerViews.TryGetValue(layer, out ImageView existing)) return existing; + + var createInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = Image, + ViewType = ImageViewType.Type2D, + Format = Format, + SubresourceRange = new ImageSubresourceRange(Aspect, 0, MipLevels, layer, 1), + }; + + if (_context.Api.CreateImageView(_context.Device, &createInfo, null, out ImageView view) != Result.Success) + { + return View; + } + _layerViews[layer] = view; + return view; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + Vk api = _context.Api; + foreach (ImageView layerView in _layerViews.Values) + { + if (layerView.Handle != 0) api.DestroyImageView(_context.Device, layerView, null); + } + _layerViews.Clear(); + if (View.Handle != 0) api.DestroyImageView(_context.Device, View, null); + if (Image.Handle != 0) api.DestroyImage(_context.Device, Image, null); + if (Memory.Handle != 0) + { + api.FreeMemory(_context.Device, Memory, null); + VulkanMemory.NoteFree(); + } + } +} + +/// +/// Interns sampler objects by their state. +/// +/// The game has a handful of distinct sampler configurations - nearest and linear, +/// clamped and repeating, plus the shadow-comparison and mip-bias variants - but +/// sets them on hundreds of textures. One object per distinct state rather than +/// per texture keeps the count in single digits. +/// +internal sealed unsafe class SamplerCache : IDisposable +{ + private readonly VulkanContext _context; + private readonly Dictionary _samplers = new(); + private bool _disposed; + + public int Count => _samplers.Count; + + public SamplerCache(VulkanContext context) => _context = context; + + public Sampler Get(SamplerState state) + { + if (_samplers.TryGetValue(state, out Sampler existing)) return existing; + + float maxAnisotropy = _context.Capabilities.SamplerAnisotropy + ? Math.Max(1f, state.MaxAnisotropy) + : 1f; + + var createInfo = new SamplerCreateInfo + { + SType = StructureType.SamplerCreateInfo, + MagFilter = state.MagFilter, + MinFilter = state.MinFilter, + MipmapMode = state.MipmapMode, + AddressModeU = state.AddressU, + AddressModeV = state.AddressV, + AddressModeW = SamplerAddressMode.ClampToEdge, + MipLodBias = Math.Clamp(state.LodBias, -_context.Capabilities.MaxSamplerLodBias, + _context.Capabilities.MaxSamplerLodBias), + AnisotropyEnable = maxAnisotropy > 1f, + MaxAnisotropy = maxAnisotropy, + CompareEnable = state.CompareEnable, + // Shadow maps sample with a less-or-equal comparison, matching the + // GL_COMPARE_REF_TO_TEXTURE mode the shadow passes enable. + CompareOp = CompareOp.LessOrEqual, + MinLod = 0f, + MaxLod = Vk.LodClampNone, + BorderColor = state.BorderColor, + UnnormalizedCoordinates = false, + }; + + if (_context.Api.CreateSampler(_context.Device, &createInfo, null, out Sampler sampler) != Result.Success) + { + throw new InvalidOperationException("vkCreateSampler failed"); + } + + _samplers[state] = sampler; + return sampler; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (Sampler sampler in _samplers.Values) + { + _context.Api.DestroySampler(_context.Device, sampler, null); + } + _samplers.Clear(); + } +} + +/// +/// Owns every texture and hands out integer ids in place of GL names. +/// +/// The ids have to stay integers because the game's public API exposes them: +/// LoadedTexture.TextureId and FrameBufferRef.ColorTextureIds are +/// fields mods read and pass back. So this is a handle table, and 0 means "no +/// texture" exactly as it does in GL. +/// +internal sealed unsafe class TextureManager : IDisposable +{ + private readonly VulkanContext _context; + private readonly VulkanCommands _commands; + private readonly List _textures = new(); + private readonly Stack _freeIds = new(); + private bool _disposed; + + public SamplerCache Samplers { get; } + + public TextureManager(VulkanContext context, VulkanCommands commands) + { + _context = context; + _commands = commands; + Samplers = new SamplerCache(context); + + // Index 0 is reserved so a zero id never names a real texture. + _textures.Add(null); + } + + public int Count + { + get + { + int live = 0; + foreach (VulkanTexture? texture in _textures) + { + if (texture != null) live++; + } + return live; + } + } + + public VulkanTexture? Get(int id) => + id > 0 && id < _textures.Count ? _textures[id] : null; + + private int Register(VulkanTexture texture) + { + if (_freeIds.Count > 0) + { + int reused = _freeIds.Pop(); + _textures[reused] = texture; + return reused; + } + + _textures.Add(texture); + return _textures.Count - 1; + } + + /// + /// Creates a texture. Usage always includes transfer source and destination + /// so uploads, readback and mipmap generation need no advance warning, which + /// is the GL model where any texture can be updated at any time. + /// + public int Create( + uint width, uint height, Format format, + uint layers = 1, bool cube = false, bool generateMipmaps = false, + ImageUsageFlags extraUsage = 0) + { + // GL tolerates a zero-sized texture - it creates nothing and carries on - + // while Vulkan rejects the extent outright. The client asks for one when + // a render target is sized from a window dimension that is still zero, so + // this clamps rather than throwing, matching GL's forgiveness. + width = Math.Max(1, width); + height = Math.Max(1, height); + + uint mipLevels = generateMipmaps ? MipLevelsFor(width, height) : 1; + ImageAspectFlags aspect = IsDepthFormat(format) + ? ImageAspectFlags.DepthBit + : ImageAspectFlags.ColorBit; + + ImageUsageFlags usage = + ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit + | extraUsage + | (IsDepthFormat(format) + ? ImageUsageFlags.DepthStencilAttachmentBit + : ImageUsageFlags.ColorAttachmentBit); + + var imageInfo = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type2D, + Format = format, + Extent = new Extent3D(width, height, 1), + MipLevels = mipLevels, + ArrayLayers = cube ? 6 : layers, + Samples = SampleCountFlags.Count1Bit, + Tiling = ImageTiling.Optimal, + Usage = usage, + SharingMode = SharingMode.Exclusive, + InitialLayout = ImageLayout.Undefined, + Flags = cube ? ImageCreateFlags.CreateCubeCompatibleBit : 0, + }; + + Vk api = _context.Api; + if (api.CreateImage(_context.Device, &imageInfo, null, out Image image) != Result.Success) + { + throw new InvalidOperationException("vkCreateImage failed"); + } + + api.GetImageMemoryRequirements(_context.Device, image, out MemoryRequirements requirements); + var allocateInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = VulkanMemory.FindMemoryType( + _context, requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit), + }; + DeviceMemory memory = VulkanMemory.Allocate(_context, allocateInfo, + $"a {width}x{height} {format} image"); + api.BindImageMemory(_context.Device, image, memory, 0); + + uint viewLayers = cube ? 6 : layers; + var viewInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = cube ? ImageViewType.TypeCube + : layers > 1 ? ImageViewType.Type2DArray + : ImageViewType.Type2D, + Format = format, + SubresourceRange = new ImageSubresourceRange(aspect, 0, mipLevels, 0, viewLayers), + }; + api.CreateImageView(_context.Device, &viewInfo, null, out ImageView view); + + var texture = new VulkanTexture(_context) + { + Image = image, + Memory = memory, + View = view, + Format = format, + Width = width, + Height = height, + MipLevels = mipLevels, + Layers = viewLayers, + Aspect = aspect, + }; + + return Register(texture); + } + + /// + /// Uploads pixels into a region. Staging plus a copy, then back to a + /// shader-readable layout, submitted and waited on. That is stronger + /// ordering than GL guarantees, which makes it correct; recording the copy + /// inline in the frame's command buffer is the later optimisation. + /// + public void Upload( + int textureId, int level, int x, int y, uint width, uint height, + IntPtr pixels, int bytesPerPixel, uint layer = 0) + { + VulkanTexture? texture = Get(textureId); + if (texture == null || pixels == IntPtr.Zero) return; + + ulong size = (ulong)width * height * (ulong)bytesPerPixel; + if (size == 0) return; + + using var staging = new VulkanBuffer(_context, size, + BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + System.Buffer.MemoryCopy((void*)pixels, (void*)staging.Mapped, (long)size, (long)size); + + _commands.SubmitAndWait(commandBuffer => + { + TransitionTexture(commandBuffer, texture, ImageLayout.TransferDstOptimal); + + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(texture.Aspect, (uint)level, layer, 1), + ImageOffset = new Offset3D(x, y, 0), + ImageExtent = new Extent3D(width, height, 1), + }; + _context.Api.CmdCopyBufferToImage(commandBuffer, staging.Handle, texture.Image, + ImageLayout.TransferDstOptimal, 1, ®ion); + + TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + }); + } + + /// + /// Builds the mip chain by successive blits, which is how every Vulkan + /// implementation of glGenerateMipmap works. + /// + public void GenerateMipmaps(int textureId) + { + VulkanTexture? texture = Get(textureId); + if (texture == null || texture.MipLevels <= 1) return; + + _commands.SubmitAndWait(commandBuffer => + { + Vk api = _context.Api; + int mipWidth = (int)texture.Width; + int mipHeight = (int)texture.Height; + + TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + + for (uint level = 1; level < texture.MipLevels; level++) + { + int nextWidth = Math.Max(1, mipWidth / 2); + int nextHeight = Math.Max(1, mipHeight / 2); + + TransitionRange(commandBuffer, texture, level, 1, + ImageLayout.Undefined, ImageLayout.TransferDstOptimal); + + var blit = new ImageBlit + { + SrcSubresource = new ImageSubresourceLayers(texture.Aspect, level - 1, 0, texture.Layers), + DstSubresource = new ImageSubresourceLayers(texture.Aspect, level, 0, texture.Layers), + }; + blit.SrcOffsets.Element0 = new Offset3D(0, 0, 0); + blit.SrcOffsets.Element1 = new Offset3D(mipWidth, mipHeight, 1); + blit.DstOffsets.Element0 = new Offset3D(0, 0, 0); + blit.DstOffsets.Element1 = new Offset3D(nextWidth, nextHeight, 1); + + api.CmdBlitImage(commandBuffer, + texture.Image, ImageLayout.TransferSrcOptimal, + texture.Image, ImageLayout.TransferDstOptimal, + 1, &blit, Filter.Linear); + + TransitionRange(commandBuffer, texture, level, 1, + ImageLayout.TransferDstOptimal, ImageLayout.TransferSrcOptimal); + + mipWidth = nextWidth; + mipHeight = nextHeight; + } + + texture.Layout = ImageLayout.TransferSrcOptimal; + TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + }); + } + + /// + /// Applies a glTexParameter. Nothing touches the GPU: the state lives on the + /// texture and resolves to a cached sampler when it is next bound. + /// + public void SetParameter(int textureId, int parameterName, float value) + { + VulkanTexture? texture = Get(textureId); + if (texture == null) return; + + SamplerState state = texture.State; + int integer = (int)value; + + texture.State = parameterName switch + { + GlEnums.TextureMinFilter => ApplyMinFilter(state, integer), + GlEnums.TextureMagFilter => state with { MagFilter = GlEnums.FilterFrom(integer) }, + GlEnums.TextureWrapS => state with { AddressU = GlEnums.AddressModeFrom(integer) }, + GlEnums.TextureWrapT => state with { AddressV = GlEnums.AddressModeFrom(integer) }, + GlEnums.TextureLodBias => state with { LodBias = value }, + GlEnums.TextureCompareMode => state with + { + CompareEnable = integer == GlEnums.TextureCompareRefToTexture, + }, + _ => state, + }; + } + + private static SamplerState ApplyMinFilter(SamplerState state, int glFilter) + { + (Filter filter, SamplerMipmapMode mode) = GlEnums.MinFilterFrom(glFilter); + return state with { MinFilter = filter, MipmapMode = mode }; + } + + /// + /// Vulkan offers four fixed border colours where GL takes an arbitrary one. + /// The SSAO targets use opaque white; anything else rounds to the nearest of + /// the four rather than failing. + /// + public void SetBorderColor(int textureId, float r, float g, float b, float a) + { + VulkanTexture? texture = Get(textureId); + if (texture == null) return; + + bool opaque = a >= 0.5f; + bool white = (r + g + b) / 3f >= 0.5f; + + texture.State = texture.State with + { + BorderColor = opaque + ? white ? BorderColor.FloatOpaqueWhite : BorderColor.FloatOpaqueBlack + : BorderColor.FloatTransparentBlack, + }; + } + + public void Delete(int textureId, FrameRing? ring = null) + { + VulkanTexture? texture = Get(textureId); + if (texture == null) return; + + _textures[textureId] = null; + _freeIds.Push(textureId); + + // Handing it to the ring means it outlives any frame still referencing it. + if (ring != null) ring.DeferDeletion(texture); + else texture.Dispose(); + } + + // ------------------------------------------------------------------ barriers + + public void TransitionTexture(CommandBuffer commandBuffer, VulkanTexture texture, ImageLayout target) + { + if (texture.Layout == target) return; + TransitionRange(commandBuffer, texture, 0, texture.MipLevels, texture.Layout, target); + texture.Layout = target; + } + + private void TransitionRange( + CommandBuffer commandBuffer, VulkanTexture texture, + uint baseMip, uint mipCount, ImageLayout from, ImageLayout to) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.AllCommandsBit, + SrcAccessMask = AccessFlags2.MemoryWriteBit, + DstStageMask = PipelineStageFlags2.AllCommandsBit, + DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + OldLayout = from, + NewLayout = to, + Image = texture.Image, + SubresourceRange = new ImageSubresourceRange(texture.Aspect, baseMip, mipCount, 0, texture.Layers), + }; + + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); + } + + // -------------------------------------------------------------------- helpers + + public static uint MipLevelsFor(uint width, uint height) => + (uint)Math.Floor(Math.Log2(Math.Max(width, height))) + 1; + + public static bool IsDepthFormat(Format format) => format is + Format.D16Unorm or Format.D32Sfloat or Format.D24UnormS8Uint or Format.D32SfloatS8Uint + or Format.X8D24UnormPack32 or Format.D16UnormS8Uint; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (VulkanTexture? texture in _textures) texture?.Dispose(); + _textures.Clear(); + Samplers.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Core/VertexLayout.cs b/Optimum.Render.Vulkan/Core/VertexLayout.cs new file mode 100644 index 00000000..9be6a952 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VertexLayout.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// One vertex buffer feeding the pipeline. +internal readonly record struct VertexBinding(uint Binding, uint Stride, bool PerInstance); + +/// One vertex attribute read out of a binding. +internal readonly record struct VertexAttribute(uint Location, uint Binding, Format Format, uint Offset); + +/// +/// The vertex input state of a mesh. +/// +/// Vintage Story gives every attribute its own buffer rather than interleaving +/// one - separate allocations for positions, normals, UVs, colours, flags, plus +/// up to four custom parts that are interleaved and may be per-instance. +/// So a binding here is usually one buffer with one attribute, and the custom +/// parts are the exception. +/// +/// There are on the order of fifteen distinct layouts in the whole game, so they +/// are interned and the id goes into the pipeline key. +/// +internal sealed class VertexLayoutDescription : IEquatable +{ + public VertexBinding[] Bindings { get; } + public VertexAttribute[] Attributes { get; } + + private readonly int _hash; + + public VertexLayoutDescription(VertexBinding[] bindings, VertexAttribute[] attributes) + { + Bindings = bindings; + Attributes = attributes; + + var hash = new HashCode(); + foreach (VertexBinding binding in bindings) hash.Add(binding); + foreach (VertexAttribute attribute in attributes) hash.Add(attribute); + _hash = hash.ToHashCode(); + } + + /// The layout of a pass that generates its vertices in the shader. + public static VertexLayoutDescription Empty { get; } = + new(Array.Empty(), Array.Empty()); + + /// + /// The binding the constant-default attribute buffer occupies. + /// + /// Meshes number their bindings from zero and never approach this, and the + /// Vulkan minimum for maxVertexInputBindings is 16, so 15 is always available + /// and never collides. + /// + public const uint DefaultAttributeBinding = 15; + + /// + /// Adds constant-default attributes for every location the program declares + /// but this layout does not provide. + /// + /// GL answers a read of an unbound vertex attribute with the current generic + /// attribute, which defaults to (0, 0, 0, 1); Vulkan leaves it undefined. + /// That difference is not cosmetic - gui.fsh discards a fragment based on a + /// damage effect fed by an attribute the GUI quad never carries, so undefined + /// there means the entire interface vanishes with no validation message. + /// + public VertexLayoutDescription WithDefaultsFor(IReadOnlyList declared) + { + List? added = null; + foreach (VertexInputSlot slot in declared) + { + bool present = false; + foreach (VertexAttribute attribute in Attributes) + { + if (attribute.Location == (uint)slot.Location) { present = true; break; } + } + if (present) continue; + + added ??= new List(); + added.Add(new VertexAttribute( + (uint)slot.Location, DefaultAttributeBinding, + DefaultFormatFor(slot.Type), DefaultOffsetFor(slot.Type))); + } + + if (added == null) return this; + + var bindings = new VertexBinding[Bindings.Length + 1]; + Array.Copy(Bindings, bindings, Bindings.Length); + // Stride zero: every vertex reads the same constant. + bindings[^1] = new VertexBinding(DefaultAttributeBinding, 0, PerInstance: false); + + var attributes = new VertexAttribute[Attributes.Length + added.Count]; + Array.Copy(Attributes, attributes, Attributes.Length); + for (int i = 0; i < added.Count; i++) attributes[Attributes.Length + i] = added[i]; + + return new VertexLayoutDescription(bindings, attributes); + } + + /// + /// Integer attributes have to read integer zeros and floats floating-point + /// ones, so the default buffer holds both and the type picks the half. + /// + private static uint DefaultOffsetFor(GlslType type) => IsIntegerType(type) ? 16u : 0u; + + private static Format DefaultFormatFor(GlslType type) + { + bool integer = IsIntegerType(type); + return type.ComponentCount switch + { + 1 => integer ? Format.R32Sint : Format.R32Sfloat, + 2 => integer ? Format.R32G32Sint : Format.R32G32Sfloat, + 3 => integer ? Format.R32G32B32Sint : Format.R32G32B32Sfloat, + _ => integer ? Format.R32G32B32A32Sint : Format.R32G32B32A32Sfloat, + }; + } + + private static bool IsIntegerType(GlslType type) => + type.Name.StartsWith("i", StringComparison.Ordinal) || + type.Name.StartsWith("u", StringComparison.Ordinal) || + type.Name == "int" || type.Name == "uint" || type.Name == "bool"; + + public bool Equals(VertexLayoutDescription? other) + { + if (other is null || other._hash != _hash) return false; + if (Bindings.Length != other.Bindings.Length) return false; + if (Attributes.Length != other.Attributes.Length) return false; + + for (int i = 0; i < Bindings.Length; i++) + { + if (!Bindings[i].Equals(other.Bindings[i])) return false; + } + for (int i = 0; i < Attributes.Length; i++) + { + if (!Attributes[i].Equals(other.Attributes[i])) return false; + } + return true; + } + + public override bool Equals(object? obj) => Equals(obj as VertexLayoutDescription); + public override int GetHashCode() => _hash; +} + +/// +/// Builds a vertex layout the way the game's mesh allocator describes one. +/// +/// The GL path calls glVertexAttribPointer per attribute with a raw type +/// constant, so the mapping from those constants to Vulkan formats is the whole +/// job. Getting one wrong shows up as garbled geometry rather than an error, +/// which is why the mapping is tested rather than trusted. +/// +internal sealed class VertexLayoutBuilder +{ + private readonly List _bindings = new(); + private readonly List _attributes = new(); + private uint _nextLocation; + + /// + /// Adds an attribute backed by its own tightly packed buffer, which is how + /// positions, normals, UVs, colours and flags arrive. + /// + public VertexLayoutBuilder AddDedicated(Format format, uint stride, bool perInstance = false) + { + uint binding = (uint)_bindings.Count; + _bindings.Add(new VertexBinding(binding, stride, perInstance)); + _attributes.Add(new VertexAttribute(_nextLocation++, binding, format, 0)); + return this; + } + + /// + /// Adds an interleaved group sharing one buffer, which is how the custom + /// mesh data parts arrive - several attributes at different offsets within a + /// common stride, optionally advancing per instance. + /// + public VertexLayoutBuilder AddInterleaved( + ReadOnlySpan<(Format Format, uint Offset)> members, uint stride, bool perInstance) + { + uint binding = (uint)_bindings.Count; + _bindings.Add(new VertexBinding(binding, stride, perInstance)); + foreach ((Format format, uint offset) in members) + { + _attributes.Add(new VertexAttribute(_nextLocation++, binding, format, offset)); + } + return this; + } + + public VertexLayoutDescription Build() => new(_bindings.ToArray(), _attributes.ToArray()); + + // ------------------------------------------------------------ format mapping + + private const int GlByte = 0x1400; + private const int GlUnsignedByte = 0x1401; + private const int GlShort = 0x1402; + private const int GlUnsignedShort = 0x1403; + private const int GlInt = 0x1404; + private const int GlUnsignedInt = 0x1405; + private const int GlFloat = 0x1406; + private const int GlInt2101010Rev = 0x8D9F; + + /// + /// Maps a glVertexAttribPointer description to a Vulkan format. + /// + /// 1 to 4, or 4 for a packed 2-10-10-10 normal. + /// The GL type constant. + /// + /// True when GL would scale integers into [0,1] or [-1,1]. False with + /// false means the shader sees the raw value as a + /// float; true with integer means an integer attribute. + /// + /// True for glVertexAttribIPointer. + public static Format FormatFor(int components, int glType, bool normalized, bool integer) + { + // The packed normal format the game uses for entity and particle normals. + if (glType == GlInt2101010Rev) + { + return normalized ? Format.A2B10G10R10SNormPack32 : Format.A2B10G10R10SintPack32; + } + + return glType switch + { + GlFloat => components switch + { + 1 => Format.R32Sfloat, + 2 => Format.R32G32Sfloat, + 3 => Format.R32G32B32Sfloat, + _ => Format.R32G32B32A32Sfloat, + }, + GlUnsignedByte => Select(components, normalized, integer, + (Format.R8Unorm, Format.R8G8Unorm, Format.R8G8B8Unorm, Format.R8G8B8A8Unorm), + (Format.R8Uint, Format.R8G8Uint, Format.R8G8B8Uint, Format.R8G8B8A8Uint), + (Format.R8Uscaled, Format.R8G8Uscaled, Format.R8G8B8Uscaled, Format.R8G8B8A8Uscaled)), + GlByte => Select(components, normalized, integer, + (Format.R8SNorm, Format.R8G8SNorm, Format.R8G8B8SNorm, Format.R8G8B8A8SNorm), + (Format.R8Sint, Format.R8G8Sint, Format.R8G8B8Sint, Format.R8G8B8A8Sint), + (Format.R8Sscaled, Format.R8G8Sscaled, Format.R8G8B8Sscaled, Format.R8G8B8A8Sscaled)), + GlUnsignedShort => Select(components, normalized, integer, + (Format.R16Unorm, Format.R16G16Unorm, Format.R16G16B16Unorm, Format.R16G16B16A16Unorm), + (Format.R16Uint, Format.R16G16Uint, Format.R16G16B16Uint, Format.R16G16B16A16Uint), + (Format.R16Uscaled, Format.R16G16Uscaled, Format.R16G16B16Uscaled, Format.R16G16B16A16Uscaled)), + GlShort => Select(components, normalized, integer, + (Format.R16SNorm, Format.R16G16SNorm, Format.R16G16B16SNorm, Format.R16G16B16A16SNorm), + (Format.R16Sint, Format.R16G16Sint, Format.R16G16B16Sint, Format.R16G16B16A16Sint), + (Format.R16Sscaled, Format.R16G16Sscaled, Format.R16G16B16Sscaled, Format.R16G16B16A16Sscaled)), + GlUnsignedInt => components switch + { + 1 => Format.R32Uint, + 2 => Format.R32G32Uint, + 3 => Format.R32G32B32Uint, + _ => Format.R32G32B32A32Uint, + }, + GlInt => components switch + { + 1 => Format.R32Sint, + 2 => Format.R32G32Sint, + 3 => Format.R32G32B32Sint, + _ => Format.R32G32B32A32Sint, + }, + _ => Format.R32G32B32A32Sfloat, + }; + } + + private static Format Select( + int components, bool normalized, bool integer, + (Format, Format, Format, Format) norm, + (Format, Format, Format, Format) asInteger, + (Format, Format, Format, Format) scaled) + { + (Format one, Format two, Format three, Format four) = + integer ? asInteger : normalized ? norm : scaled; + + return components switch { 1 => one, 2 => two, 3 => three, _ => four }; + } + + /// Bytes one vertex of this format occupies. + public static uint SizeOf(int components, int glType) + { + if (glType == GlInt2101010Rev) return 4; + + uint componentSize = glType switch + { + GlByte or GlUnsignedByte => 1u, + GlShort or GlUnsignedShort => 2u, + _ => 4u, + }; + return componentSize * (uint)Math.Clamp(components, 1, 4); + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs new file mode 100644 index 00000000..fef19d8a --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -0,0 +1,607 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Silk.NET.Core; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.EXT; + +namespace Optimum.Render.Vulkan.Core; + +/// How the context should be brought up. +internal sealed class VulkanContextOptions +{ + /// No surface, no swapchain. Used by tests and capability probes. + public bool Headless; + + /// Turns on the validation layers and the debug messenger. + public bool EnableValidation; + + /// Pins a physical device by index; -1 picks automatically. + public int PreferredDeviceIndex = -1; + + /// Instance extensions the window system needs (from GLFW). + public string[] RequiredInstanceExtensions = Array.Empty(); + + /// Called with each validation message when validation is on. + public Action? DebugCallback; +} + +/// What the chosen device can do, once it is up. +internal sealed class VulkanCapabilities +{ + public string DeviceName = ""; + public string DriverName = ""; + public uint ApiVersion; + public PhysicalDeviceType DeviceType; + public uint MaxImageDimension2D; + public bool WideLines; + public bool FillModeNonSolid; + public bool SamplerAnisotropy; + public bool MultiDrawIndirect; + public float MaxSamplerLodBias; + public int MaxBoundDescriptorSets; + public ulong MinUniformBufferOffsetAlignment; +} + +/// +/// Instance, physical device, logical device and queue. +/// +/// Device selection and feature negotiation are the one place this backend is +/// allowed to give up. Anything missing here means the session falls back to +/// OpenGL with a logged reason rather than failing, so every check reports +/// instead of throwing. +/// +internal sealed unsafe class VulkanContext : IDisposable +{ + /// + /// The floor. 1.3 makes dynamic rendering and synchronization2 core, which + /// removes render-pass and framebuffer objects from the design entirely, and + /// brings the dynamic pipeline state that keeps the pipeline cache small. + /// Everything below it stays on OpenGL. + /// + public static readonly uint MinimumApiVersion = Vk.Version13; + + public Vk Api { get; private set; } = null!; + public Instance Instance { get; private set; } + public PhysicalDevice PhysicalDevice { get; private set; } + public Device Device { get; private set; } + public Queue GraphicsQueue { get; private set; } + public uint GraphicsQueueFamily { get; private set; } + + /// + /// Guards every submission to . + /// + /// Vulkan requires a queue to be externally synchronised: vkQueueSubmit and + /// vkQueuePresentKHR from two threads at once is undefined behaviour, and in + /// practice loses the device. The client does exactly that - asset loading + /// uploads textures off the main thread, each upload being its own + /// submit-and-wait, while the render thread is submitting frames. GL made + /// this impossible by having one context on one thread; here it has to be + /// enforced. + /// + public object QueueLock { get; } = new(); + public VulkanCapabilities Capabilities { get; private set; } = new(); + + /// + /// Whether the validation layers are actually loaded, which is not the same + /// as having been asked for: the layer has to be installed on the machine. + /// Worth being able to check, because "no validation messages" otherwise + /// reads as "nothing is wrong". + /// + public bool ValidationEnabled { get; private set; } + + /// Marks a diagnostic the layers reported at error severity. + public const string ErrorPrefix = "[error] "; + + private ExtDebugUtils? _debugUtils; + private DebugUtilsMessengerEXT _debugMessenger; + private Action? _debugCallback; + private PfnDebugUtilsMessengerCallbackEXT _debugDelegate; + private bool _disposed; + + private const string ValidationLayer = "VK_LAYER_KHRONOS_validation"; + + /// + /// Brings the context up, or explains why it cannot. Never throws for an + /// ordinary unsupported-hardware outcome. + /// + public static bool TryCreate( + VulkanContextOptions options, out VulkanContext? context, out string? failureReason) + { + context = null; + failureReason = null; + + var created = new VulkanContext(); + try + { + created.Api = Vk.GetApi(); + } + catch (Exception error) + { + failureReason = "no Vulkan loader: " + error.Message; + return false; + } + + try + { + if (!created.CreateInstance(options, out failureReason)) { created.Dispose(); return false; } + if (!created.SelectPhysicalDevice(options, out failureReason)) { created.Dispose(); return false; } + if (!created.CreateDevice(options, out failureReason)) { created.Dispose(); return false; } + } + catch (Exception error) + { + failureReason = error.Message; + created.Dispose(); + return false; + } + + context = created; + return true; + } + + // ------------------------------------------------------------------ instance + + private bool CreateInstance(VulkanContextOptions options, out string? failureReason) + { + failureReason = null; + + uint loaderVersion = Vk.Version10; + if (Api.EnumerateInstanceVersion(ref loaderVersion) != Result.Success) + { + loaderVersion = Vk.Version10; + } + if (loaderVersion < MinimumApiVersion) + { + failureReason = + $"Vulkan loader reports {VersionString(loaderVersion)}, " + + $"but {VersionString(MinimumApiVersion)} is required"; + return false; + } + + var extensions = new List(options.RequiredInstanceExtensions); + bool validation = options.EnableValidation && HasValidationLayer(); + if (validation) + { + extensions.Add(ExtDebugUtils.ExtensionName); + } + + byte* applicationName = (byte*)SilkMarshal.StringToPtr("Optimum"); + byte* engineName = (byte*)SilkMarshal.StringToPtr("Optimum.Render.Vulkan"); + nint extensionsPtr = SilkMarshal.StringArrayToPtr(extensions); + nint layersPtr = validation ? SilkMarshal.StringArrayToPtr(new[] { ValidationLayer }) : 0; + + try + { + var applicationInfo = new ApplicationInfo + { + SType = StructureType.ApplicationInfo, + PApplicationName = applicationName, + ApplicationVersion = new Version32(1, 0, 0), + PEngineName = engineName, + EngineVersion = new Version32(1, 0, 0), + ApiVersion = MinimumApiVersion, + }; + + var createInfo = new InstanceCreateInfo + { + SType = StructureType.InstanceCreateInfo, + PApplicationInfo = &applicationInfo, + EnabledExtensionCount = (uint)extensions.Count, + PpEnabledExtensionNames = (byte**)extensionsPtr, + EnabledLayerCount = validation ? 1u : 0u, + PpEnabledLayerNames = validation ? (byte**)layersPtr : null, + }; + + Result result = Api.CreateInstance(&createInfo, null, out Instance instance); + if (result != Result.Success) + { + failureReason = "vkCreateInstance failed: " + result; + return false; + } + Instance = instance; + } + finally + { + SilkMarshal.Free((nint)applicationName); + SilkMarshal.Free((nint)engineName); + SilkMarshal.Free(extensionsPtr); + if (layersPtr != 0) SilkMarshal.Free(layersPtr); + } + + if (validation) + { + SetUpDebugMessenger(options); + } + + return true; + } + + private bool HasValidationLayer() + { + uint count = 0; + if (Api.EnumerateInstanceLayerProperties(ref count, null) != Result.Success || count == 0) + { + return false; + } + + var layers = new LayerProperties[count]; + fixed (LayerProperties* layersPtr = layers) + { + if (Api.EnumerateInstanceLayerProperties(ref count, layersPtr) != Result.Success) + { + return false; + } + + for (int i = 0; i < count; i++) + { + if (SilkMarshal.PtrToString((nint)layersPtr[i].LayerName) == ValidationLayer) + { + return true; + } + } + } + return false; + } + + private void SetUpDebugMessenger(VulkanContextOptions options) + { + if (!Api.TryGetInstanceExtension(Instance, out ExtDebugUtils debugUtils)) return; + + _debugUtils = debugUtils; + _debugCallback = options.DebugCallback; + ValidationEnabled = true; + _debugDelegate = new PfnDebugUtilsMessengerCallbackEXT(OnDebugMessage); + + var createInfo = new DebugUtilsMessengerCreateInfoEXT + { + SType = StructureType.DebugUtilsMessengerCreateInfoExt, + MessageSeverity = DebugUtilsMessageSeverityFlagsEXT.WarningBitExt + | DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt, + MessageType = DebugUtilsMessageTypeFlagsEXT.GeneralBitExt + | DebugUtilsMessageTypeFlagsEXT.ValidationBitExt + | DebugUtilsMessageTypeFlagsEXT.PerformanceBitExt, + PfnUserCallback = _debugDelegate, + }; + + _debugUtils.CreateDebugUtilsMessenger(Instance, &createInfo, null, out _debugMessenger); + } + + private uint OnDebugMessage( + DebugUtilsMessageSeverityFlagsEXT severity, + DebugUtilsMessageTypeFlagsEXT types, + DebugUtilsMessengerCallbackDataEXT* data, + void* userData) + { + string? message = SilkMarshal.PtrToString((nint)data->PMessage); + if (message != null) + { + // Severity is prefixed rather than dropped. The layers report real + // spec violations alongside advisories - "this fragment output has + // no attachment and the write is unused" is a note, not a fault - + // and the client turns diagnostics into thrown exceptions through + // CheckGlError. Without the distinction every advisory would read as + // a GL error and abort a frame that was fine. + string prefix = severity.HasFlag(DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt) + ? ErrorPrefix + : "[warning] "; + _debugCallback?.Invoke(prefix + message); + } + return Vk.False; + } + + // ----------------------------------------------------------- device selection + + private bool SelectPhysicalDevice(VulkanContextOptions options, out string? failureReason) + { + failureReason = null; + + uint count = 0; + Api.EnumeratePhysicalDevices(Instance, ref count, null); + if (count == 0) + { + failureReason = "no Vulkan physical devices"; + return false; + } + + var devices = new PhysicalDevice[count]; + fixed (PhysicalDevice* devicesPtr = devices) + { + Api.EnumeratePhysicalDevices(Instance, ref count, devicesPtr); + } + + if (options.PreferredDeviceIndex >= 0) + { + if (options.PreferredDeviceIndex >= devices.Length) + { + failureReason = $"device index {options.PreferredDeviceIndex} out of range ({devices.Length} present)"; + return false; + } + + PhysicalDevice pinned = devices[options.PreferredDeviceIndex]; + if (!IsUsable(pinned, out string? why)) + { + failureReason = $"pinned device is unusable: {why}"; + return false; + } + PhysicalDevice = pinned; + return true; + } + + // Prefer a discrete GPU, then integrated, then anything usable. The + // handheld this targets has only an integrated one; a desktop with both + // should get the fast one. + var rejections = new List(); + PhysicalDevice best = default; + int bestScore = -1; + + foreach (PhysicalDevice candidate in devices) + { + if (!IsUsable(candidate, out string? why)) + { + rejections.Add(why!); + continue; + } + + PhysicalDeviceProperties properties = Api.GetPhysicalDeviceProperties(candidate); + int score = properties.DeviceType switch + { + PhysicalDeviceType.DiscreteGpu => 3, + PhysicalDeviceType.IntegratedGpu => 2, + PhysicalDeviceType.VirtualGpu => 1, + _ => 0, + }; + + if (score > bestScore) + { + bestScore = score; + best = candidate; + } + } + + if (bestScore < 0) + { + failureReason = "no usable Vulkan device: " + string.Join("; ", rejections); + return false; + } + + PhysicalDevice = best; + return true; + } + + /// + /// A device is usable when it meets the API floor, exposes a graphics queue, + /// and supports the features the renderer is built on. + /// + private bool IsUsable(PhysicalDevice device, out string? reason) + { + PhysicalDeviceProperties properties = Api.GetPhysicalDeviceProperties(device); + string name = SilkMarshal.PtrToString((nint)properties.DeviceName) ?? "unknown"; + + if (properties.ApiVersion < MinimumApiVersion) + { + reason = $"{name} reports {VersionString(properties.ApiVersion)}, " + + $"below {VersionString(MinimumApiVersion)}"; + return false; + } + + if (!TryFindGraphicsQueue(device, out _)) + { + reason = $"{name} has no graphics queue family"; + return false; + } + + var vulkan13 = new PhysicalDeviceVulkan13Features { SType = StructureType.PhysicalDeviceVulkan13Features }; + var vulkan12 = new PhysicalDeviceVulkan12Features + { + SType = StructureType.PhysicalDeviceVulkan12Features, + PNext = &vulkan13, + }; + var features = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan12, + }; + Api.GetPhysicalDeviceFeatures2(device, &features); + + var missing = new List(); + if (!vulkan13.DynamicRendering) missing.Add("dynamicRendering"); + if (!vulkan13.Synchronization2) missing.Add("synchronization2"); + // Scalar layout is what lets the game's tightly packed float[] uniform + // uploads land in the generated block as a memcpy. + if (!vulkan12.ScalarBlockLayout) missing.Add("scalarBlockLayout"); + if (!vulkan12.TimelineSemaphore) missing.Add("timelineSemaphore"); + // The OIT and SSAO passes set blend state per attachment. + if (!features.Features.IndependentBlend) missing.Add("independentBlend"); + // Chunk rendering issues one indirect multidraw per pool. + if (!features.Features.MultiDrawIndirect) missing.Add("multiDrawIndirect"); + + if (missing.Count > 0) + { + reason = $"{name} lacks {string.Join(", ", missing)}"; + return false; + } + + reason = null; + return true; + } + + private bool TryFindGraphicsQueue(PhysicalDevice device, out uint family) + { + family = 0; + uint count = 0; + Api.GetPhysicalDeviceQueueFamilyProperties(device, ref count, null); + if (count == 0) return false; + + var families = new QueueFamilyProperties[count]; + fixed (QueueFamilyProperties* familiesPtr = families) + { + Api.GetPhysicalDeviceQueueFamilyProperties(device, ref count, familiesPtr); + } + + for (uint i = 0; i < count; i++) + { + if (families[i].QueueFlags.HasFlag(QueueFlags.GraphicsBit)) + { + family = i; + return true; + } + } + return false; + } + + // -------------------------------------------------------------- logical device + + private bool CreateDevice(VulkanContextOptions options, out string? failureReason) + { + failureReason = null; + + if (!TryFindGraphicsQueue(PhysicalDevice, out uint family)) + { + failureReason = "graphics queue family disappeared between selection and creation"; + return false; + } + GraphicsQueueFamily = family; + + float priority = 1.0f; + var queueCreateInfo = new DeviceQueueCreateInfo + { + SType = StructureType.DeviceQueueCreateInfo, + QueueFamilyIndex = family, + QueueCount = 1, + PQueuePriorities = &priority, + }; + + PhysicalDeviceFeatures available = Api.GetPhysicalDeviceFeatures(PhysicalDevice); + + var enabledFeatures = new PhysicalDeviceFeatures + { + IndependentBlend = true, + MultiDrawIndirect = true, + // Optional. Wireframe debug and thick lines degrade rather than fail. + FillModeNonSolid = available.FillModeNonSolid, + WideLines = available.WideLines, + SamplerAnisotropy = available.SamplerAnisotropy, + DepthClamp = available.DepthClamp, + ShaderClipDistance = available.ShaderClipDistance, + OcclusionQueryPrecise = available.OcclusionQueryPrecise, + }; + + var vulkan13 = new PhysicalDeviceVulkan13Features + { + SType = StructureType.PhysicalDeviceVulkan13Features, + DynamicRendering = true, + Synchronization2 = true, + }; + var vulkan12 = new PhysicalDeviceVulkan12Features + { + SType = StructureType.PhysicalDeviceVulkan12Features, + PNext = &vulkan13, + ScalarBlockLayout = true, + TimelineSemaphore = true, + }; + var features2 = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan12, + Features = enabledFeatures, + }; + + var deviceExtensions = new List(); + if (!options.Headless) deviceExtensions.Add("VK_KHR_swapchain"); + + nint extensionsPtr = deviceExtensions.Count > 0 + ? SilkMarshal.StringArrayToPtr(deviceExtensions) + : 0; + + try + { + var createInfo = new DeviceCreateInfo + { + SType = StructureType.DeviceCreateInfo, + PNext = &features2, + QueueCreateInfoCount = 1, + PQueueCreateInfos = &queueCreateInfo, + EnabledExtensionCount = (uint)deviceExtensions.Count, + PpEnabledExtensionNames = extensionsPtr == 0 ? null : (byte**)extensionsPtr, + }; + + Result result = Api.CreateDevice(PhysicalDevice, &createInfo, null, out Device device); + if (result != Result.Success) + { + failureReason = "vkCreateDevice failed: " + result; + return false; + } + Device = device; + } + finally + { + if (extensionsPtr != 0) SilkMarshal.Free(extensionsPtr); + } + + GraphicsQueue = Api.GetDeviceQueue(Device, family, 0); + Capabilities = ReadCapabilities(); + return true; + } + + private VulkanCapabilities ReadCapabilities() + { + PhysicalDeviceProperties properties = Api.GetPhysicalDeviceProperties(PhysicalDevice); + PhysicalDeviceFeatures features = Api.GetPhysicalDeviceFeatures(PhysicalDevice); + + var driverProperties = new PhysicalDeviceDriverProperties + { + SType = StructureType.PhysicalDeviceDriverProperties, + }; + var properties2 = new PhysicalDeviceProperties2 + { + SType = StructureType.PhysicalDeviceProperties2, + PNext = &driverProperties, + }; + Api.GetPhysicalDeviceProperties2(PhysicalDevice, &properties2); + + return new VulkanCapabilities + { + DeviceName = SilkMarshal.PtrToString((nint)properties.DeviceName) ?? "unknown", + DriverName = SilkMarshal.PtrToString((nint)driverProperties.DriverName) ?? "unknown", + ApiVersion = properties.ApiVersion, + DeviceType = properties.DeviceType, + MaxImageDimension2D = properties.Limits.MaxImageDimension2D, + WideLines = features.WideLines, + FillModeNonSolid = features.FillModeNonSolid, + SamplerAnisotropy = features.SamplerAnisotropy, + MultiDrawIndirect = features.MultiDrawIndirect, + MaxSamplerLodBias = properties.Limits.MaxSamplerLodBias, + MaxBoundDescriptorSets = (int)properties.Limits.MaxBoundDescriptorSets, + MinUniformBufferOffsetAlignment = properties.Limits.MinUniformBufferOffsetAlignment, + }; + } + + public static string VersionString(uint version) => + $"{version >> 22}.{(version >> 12) & 0x3FF}.{version & 0xFFF}"; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (Device.Handle != 0) + { + Api.DeviceWaitIdle(Device); + Api.DestroyDevice(Device, null); + } + + if (_debugUtils != null && _debugMessenger.Handle != 0) + { + _debugUtils.DestroyDebugUtilsMessenger(Instance, _debugMessenger, null); + _debugUtils.Dispose(); + } + + if (Instance.Handle != 0) + { + Api.DestroyInstance(Instance, null); + } + + Api?.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs new file mode 100644 index 00000000..b143ab87 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -0,0 +1,398 @@ +using System; +using System.Threading; +using Silk.NET.Vulkan; + +// Silk.NET.Vulkan.Buffer collides with System.Buffer. +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Core; + +/// A device buffer with its backing memory. +internal sealed unsafe class VulkanBuffer : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + public Buffer Handle { get; } + public DeviceMemory Memory { get; } + public ulong Size { get; } + /// Non-zero when the allocation is host visible and mapped. + public IntPtr Mapped { get; private set; } + + public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, MemoryPropertyFlags properties) + { + _context = context; + Size = size; + + var createInfo = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = size, + Usage = usage, + SharingMode = SharingMode.Exclusive, + }; + + Vk api = context.Api; + if (api.CreateBuffer(context.Device, &createInfo, null, out Buffer buffer) != Result.Success) + { + throw new InvalidOperationException("vkCreateBuffer failed"); + } + Handle = buffer; + + api.GetBufferMemoryRequirements(context.Device, buffer, out MemoryRequirements requirements); + + var allocateInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = VulkanMemory.FindMemoryType(context, requirements.MemoryTypeBits, properties), + }; + + DeviceMemory memory = VulkanMemory.Allocate(context, allocateInfo, $"a {size} byte buffer"); + Memory = memory; + api.BindBufferMemory(context.Device, buffer, memory, 0); + + if (properties.HasFlag(MemoryPropertyFlags.HostVisibleBit)) + { + void* mapped; + api.MapMemory(context.Device, memory, 0, size, 0, &mapped); + Mapped = (IntPtr)mapped; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + Vk api = _context.Api; + if (Mapped != IntPtr.Zero) + { + api.UnmapMemory(_context.Device, Memory); + Mapped = IntPtr.Zero; + } + api.DestroyBuffer(_context.Device, Handle, null); + api.FreeMemory(_context.Device, Memory, null); + VulkanMemory.NoteFree(); + } +} + +/// An image, its memory and a default view. +internal sealed unsafe class VulkanImage : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + public Image Handle { get; } + public DeviceMemory Memory { get; } + public ImageView View { get; } + public Format Format { get; } + public uint Width { get; } + public uint Height { get; } + + /// + /// Tracked so transitions can name the right old layout. Vulkan has no way to + /// query it, so the backend has to remember. + /// + public ImageLayout Layout { get; set; } = ImageLayout.Undefined; + + public VulkanImage( + VulkanContext context, uint width, uint height, Format format, + ImageUsageFlags usage, ImageAspectFlags aspect) + { + _context = context; + Width = width; + Height = height; + Format = format; + + var createInfo = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type2D, + Format = format, + Extent = new Extent3D(width, height, 1), + MipLevels = 1, + ArrayLayers = 1, + Samples = SampleCountFlags.Count1Bit, + Tiling = ImageTiling.Optimal, + Usage = usage, + SharingMode = SharingMode.Exclusive, + InitialLayout = ImageLayout.Undefined, + }; + + Vk api = context.Api; + if (api.CreateImage(context.Device, &createInfo, null, out Image image) != Result.Success) + { + throw new InvalidOperationException("vkCreateImage failed"); + } + Handle = image; + + api.GetImageMemoryRequirements(context.Device, image, out MemoryRequirements requirements); + var allocateInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = VulkanMemory.FindMemoryType( + context, requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit), + }; + DeviceMemory memory = VulkanMemory.Allocate(context, allocateInfo, "an image"); + Memory = memory; + api.BindImageMemory(context.Device, image, memory, 0); + + var viewInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = ImageViewType.Type2D, + Format = format, + SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1), + }; + if (api.CreateImageView(context.Device, &viewInfo, null, out ImageView view) != Result.Success) + { + throw new InvalidOperationException("vkCreateImageView failed"); + } + View = view; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + Vk api = _context.Api; + api.DestroyImageView(_context.Device, View, null); + api.DestroyImage(_context.Device, Handle, null); + api.FreeMemory(_context.Device, Memory, null); + VulkanMemory.NoteFree(); + } +} + +/// +/// Turns a Vulkan result into a failure that says something. +/// +/// The backend used to ignore every result it got back. A lost device then +/// looked like nothing at all from inside: submits kept "succeeding", fence +/// waits returned immediately, and the client spun at a few frames a second +/// forever with no error anywhere - the fault was only visible to an external +/// overlay. A hang with no message is the worst possible failure mode, so every +/// submit, wait, acquire and present is checked, and a device loss is reported +/// where it happens rather than inferred later. +/// +internal static class VulkanResult +{ + /// Set once the device is gone, so the failure is reported once and not per call. + public static volatile bool DeviceLost; + + /// Called with a description the moment something fails. + public static Action? OnFailure; + + public static void Check(Result result, string operation) + { + if (result == Result.Success || result == Result.SuboptimalKhr) return; + + bool lost = result is Result.ErrorDeviceLost; + if (lost && DeviceLost) return; + if (lost) DeviceLost = true; + + string message = lost + ? operation + " reported the device was lost. The GPU driver aborted the work this " + + "backend submitted; the session cannot continue." + : operation + " failed with " + result; + + OnFailure?.Invoke(message); + throw new InvalidOperationException(message); + } +} + +internal static unsafe class VulkanMemory +{ + /// + /// How many device allocations are currently outstanding. + /// + /// Vulkan caps this per device - commonly 4096 - and every buffer and image + /// here owns its own allocation, so a world with a few hundred chunk meshes + /// approaches the limit fast. Past it vkAllocateMemory starts failing, and an + /// unchecked failure binds a null handle and faults the GPU rather than + /// reporting anything. Counted so the failure can name its cause. + /// + private static int _liveAllocations; + + public static int LiveAllocations => Volatile.Read(ref _liveAllocations); + + public static void NoteAllocation() => Interlocked.Increment(ref _liveAllocations); + + public static void NoteFree() => Interlocked.Decrement(ref _liveAllocations); + + /// + /// Allocates device memory, failing with a message that says what ran out. + /// + public static DeviceMemory Allocate(VulkanContext context, MemoryAllocateInfo allocateInfo, string what) + { + Result result = context.Api.AllocateMemory(context.Device, &allocateInfo, null, out DeviceMemory memory); + if (result != Result.Success) + { + throw new InvalidOperationException( + $"vkAllocateMemory failed for {what} with {result} after {LiveAllocations} live allocations " + + $"({allocateInfo.AllocationSize} bytes requested)"); + } + + NoteAllocation(); + return memory; + } + + /// + /// Picks a memory type satisfying both the resource's type mask and the + /// requested properties. + /// + public static uint FindMemoryType(VulkanContext context, uint typeBits, MemoryPropertyFlags properties) + { + context.Api.GetPhysicalDeviceMemoryProperties(context.PhysicalDevice, out PhysicalDeviceMemoryProperties memory); + + for (uint i = 0; i < memory.MemoryTypeCount; i++) + { + bool typeAllowed = (typeBits & (1u << (int)i)) != 0; + if (!typeAllowed) continue; + + MemoryPropertyFlags flags = memory.MemoryTypes[(int)i].PropertyFlags; + if ((flags & properties) == properties) return i; + } + + throw new InvalidOperationException($"no memory type with {properties}"); + } +} + +/// +/// A command pool plus a synchronous submit helper. +/// +/// The renderer records into per-frame buffers, but setup work - uploads, layout +/// transitions, readback - wants a one-shot submit that waits. Keeping that in +/// one place stops each call site from inventing its own fence handling. +/// +internal sealed unsafe class VulkanCommands : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + public CommandPool Pool { get; } + + public VulkanCommands(VulkanContext context) + { + _context = context; + + var createInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = context.GraphicsQueueFamily, + Flags = CommandPoolCreateFlags.ResetCommandBufferBit, + }; + + if (context.Api.CreateCommandPool(context.Device, &createInfo, null, out CommandPool pool) != Result.Success) + { + throw new InvalidOperationException("vkCreateCommandPool failed"); + } + Pool = pool; + } + + public CommandBuffer Allocate() + { + var allocateInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = Pool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + + CommandBuffer buffer; + _context.Api.AllocateCommandBuffers(_context.Device, &allocateInfo, &buffer); + return buffer; + } + + /// + /// Records, submits and waits. For setup and readback, not frames. + /// + /// The whole body is serialised, not just the submit: the command pool is + /// shared, and Vulkan requires external synchronisation for allocating from + /// and freeing to a pool as much as for submitting to a queue. Texture + /// uploads reach this from asset-loading worker threads while the render + /// thread is submitting frames. + /// + public void SubmitAndWait(Action record) + { + lock (_context.QueueLock) + { + SubmitAndWaitLocked(record); + } + } + + private void SubmitAndWaitLocked(Action record) + { + Vk api = _context.Api; + CommandBuffer commandBuffer = Allocate(); + + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + api.BeginCommandBuffer(commandBuffer, &begin); + record(commandBuffer); + api.EndCommandBuffer(commandBuffer); + + var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo }; + api.CreateFence(_context.Device, &fenceInfo, null, out Fence fence); + + var submit = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer, + }; + VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, fence), + "vkQueueSubmit for a setup command buffer"); + VulkanResult.Check(api.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue), + "vkWaitForFences for a setup command buffer"); + + api.DestroyFence(_context.Device, fence, null); + api.FreeCommandBuffers(_context.Device, Pool, 1, &commandBuffer); + } + + /// + /// Moves an image between layouts with a synchronization2 barrier, widening + /// the stage and access masks to "all commands" rather than deriving tight + /// ones. Setup paths are not hot, and a correct broad barrier beats a clever + /// narrow one that is subtly wrong. + /// + public void TransitionImage(CommandBuffer commandBuffer, VulkanImage image, ImageLayout target, ImageAspectFlags aspect) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.AllCommandsBit, + SrcAccessMask = AccessFlags2.MemoryWriteBit, + DstStageMask = PipelineStageFlags2.AllCommandsBit, + DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + OldLayout = image.Layout, + NewLayout = target, + Image = image.Handle, + SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1), + }; + + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + + _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); + image.Layout = target; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _context.Api.DestroyCommandPool(_context.Device, Pool, null); + } +} diff --git a/Optimum.Render.Vulkan/Core/WindowSurface.cs b/Optimum.Render.Vulkan/Core/WindowSurface.cs new file mode 100644 index 00000000..a63b7185 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/WindowSurface.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Creates a Vulkan presentation surface for a GLFW window. +/// +/// The client already opens its window through OpenTK's GLFW bindings, so the +/// surface is created from the same window pointer rather than by standing up a +/// second windowing stack. The only change the client needs is to ask for +/// ContextAPI.NoAPI so GLFW does not create an OpenGL context alongside. +/// +internal static unsafe class WindowSurface +{ + /// Whether a Vulkan loader is reachable at all. + public static bool VulkanSupported() + { + try + { + return GLFW.VulkanSupported(); + } + catch + { + return false; + } + } + + /// + /// The instance extensions the platform needs before a surface can be made. + /// These must be enabled at instance creation, which is why they are asked + /// for before the context exists. + /// + public static string[] RequiredInstanceExtensions() + { + try + { + return GLFW.GetRequiredInstanceExtensions() ?? Array.Empty(); + } + catch + { + return Array.Empty(); + } + } + + /// + /// Creates the surface. The window pointer is the GLFW handle the client + /// already holds. + /// + public static bool TryCreate( + VulkanContext context, IntPtr windowHandle, out SurfaceKHR surface, out string? failureReason) + { + surface = default; + failureReason = null; + + if (windowHandle == IntPtr.Zero) + { + failureReason = "no window handle"; + return false; + } + + try + { + var instanceHandle = new VkHandle(context.Instance.Handle); + int result = GLFW.CreateWindowSurface( + instanceHandle, (Window*)windowHandle, null, out VkHandle surfaceHandle); + + if (result != 0) + { + failureReason = "glfwCreateWindowSurface failed with " + result; + return false; + } + + surface = new SurfaceKHR((ulong)surfaceHandle.Handle); + return true; + } + catch (Exception error) + { + failureReason = "surface creation threw: " + error.Message; + return false; + } + } +} diff --git a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj new file mode 100644 index 00000000..b7ea0bda --- /dev/null +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -0,0 +1,52 @@ + + + + + + net10.0 + Optimum.Render.Vulkan + Optimum.Render.Vulkan + true + ..\bin\$(Configuration) + annotations + + true + + + + + + + + + + + + + + + + + + + + ..\.vanilla\win-x64\vintagestory\VintagestoryAPI.dll + false + + + + + + + + diff --git a/Optimum.Render.Vulkan/Shaders/GlslParser.cs b/Optimum.Render.Vulkan/Shaders/GlslParser.cs new file mode 100644 index 00000000..102deae8 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/GlslParser.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Optimum.Render.Vulkan.Shaders; + +internal enum GlslDeclarationKind +{ + /// A loose uniform float x; - GL's default uniform block. + DefaultUniform, + /// A sampler or image handle, which becomes a descriptor. + OpaqueUniform, + /// An explicit layout(std140) uniform Block { ... };. + UniformBlock, + /// An explicit layout(std430) buffer Block { ... };. + StorageBlock, + Input, + Output, + /// Anything the parser deliberately does not touch. + Other +} + +/// One top-level declaration, with the source span it occupies. +internal sealed class GlslDeclaration +{ + public GlslDeclarationKind Kind; + public int Start; + public int Length; + + public string TypeName = ""; + public string Name = ""; + + /// 0 when the declaration is not an array. + public int ArrayLength; + + /// The array size as written, when it did not parse as an integer. + public string? UnresolvedArraySize; + + /// Right-hand side of a default value, or null. GL allows these on + /// uniforms and several shaders rely on them (final.fsh's extraGamma). + public string? Initializer; + + /// Contents of layout(...) as written, or null. + public string? LayoutQualifiers; + + /// + /// Absolute span of the layout(...) clause. When the declaration has + /// none, this is a zero-length span at the point one would be inserted, so + /// the rewriter can treat "replace" and "add" as the same operation. + /// + public int LayoutStart; + public int LayoutLength; + + /// Explicit location from a layout qualifier, else -1. + public int Location = -1; + + /// Interpolation and auxiliary qualifiers preceding the type. + public string Qualifiers = ""; + + public int End => Start + Length; +} + +/// The parse of one shader stage. +internal sealed class ParsedShader +{ + public string Source = ""; + public List Declarations = new(); + + /// Span of the #version line, or (-1, 0) when absent. + public int VersionStart = -1; + public int VersionLength; + public int VersionNumber; + + /// Spans of every #extension line. + public List<(int Start, int Length)> ExtensionDirectives = new(); + + /// Span of the identifier main in its function signature. + public int MainNameStart = -1; + + public bool HasMain => MainNameStart >= 0; +} + +/// +/// A deliberately shallow GLSL reader. +/// +/// It understands top-level declarations and nothing else: it tracks brace depth, +/// skips comments and strings, and classifies each statement at depth zero. It +/// never builds an expression tree and never looks inside a function body. +/// +/// That shallowness is the point. The rewriter edits spans of the original source +/// rather than regenerating it, so any construct this parser does not recognise - +/// including whatever a mod author writes - survives verbatim. The failure mode +/// is "left alone", not "mangled". +/// +internal static class GlslParser +{ + public static ParsedShader Parse(string source) + { + var result = new ParsedShader { Source = source }; + int position = 0; + int length = source.Length; + + while (position < length) + { + position = SkipTrivia(source, position); + if (position >= length) break; + + if (source[position] == '#') + { + position = ReadDirective(source, position, result); + continue; + } + + int statementStart = position; + position = ReadTopLevelStatement(source, position, out bool hadBraceBlock, out int mainNameStart); + + if (mainNameStart >= 0 && result.MainNameStart < 0) + { + result.MainNameStart = mainNameStart; + } + + if (position > statementStart) + { + GlslDeclaration? declaration = + Classify(source, statementStart, position - statementStart, hadBraceBlock); + if (declaration != null) + { + result.Declarations.Add(declaration); + } + } + else + { + // Defensive: never spin on an unexpected character. + position = statementStart + 1; + } + } + + return result; + } + + // ------------------------------------------------------------------ scanning + + private static int SkipTrivia(string source, int position) + { + int length = source.Length; + while (position < length) + { + char c = source[position]; + if (c == '/' && position + 1 < length) + { + if (source[position + 1] == '/') + { + while (position < length && source[position] != '\n') position++; + continue; + } + if (source[position + 1] == '*') + { + position += 2; + while (position + 1 < length && !(source[position] == '*' && source[position + 1] == '/')) + { + position++; + } + position = Math.Min(position + 2, length); + continue; + } + } + if (!char.IsWhiteSpace(c)) break; + position++; + } + return position; + } + + /// Reads a preprocessor line, recording #version and #extension. + private static int ReadDirective(string source, int position, ParsedShader result) + { + int start = position; + int length = source.Length; + + // A directive can be continued with a trailing backslash. + while (position < length) + { + if (source[position] == '\\' && position + 1 < length && + (source[position + 1] == '\n' || source[position + 1] == '\r')) + { + position += 2; + continue; + } + if (source[position] == '\n') break; + position++; + } + + string line = source.Substring(start, position - start); + string trimmed = line.TrimStart('#', ' ', '\t'); + + if (trimmed.StartsWith("version", StringComparison.Ordinal)) + { + result.VersionStart = start; + result.VersionLength = position - start; + foreach (string token in trimmed.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out int version)) + { + result.VersionNumber = version; + break; + } + } + } + else if (trimmed.StartsWith("extension", StringComparison.Ordinal)) + { + result.ExtensionDirectives.Add((start, position - start)); + } + + return position; + } + + /// + /// Reads one top-level statement. A declaration ends at its semicolon; a + /// function definition ends at the closing brace of its body. A block + /// declaration has both - uniform B { ... } name; - and the trailing + /// semicolon is included. + /// + private static int ReadTopLevelStatement(string source, int position, out bool hadBraceBlock, out int mainNameStart) + { + int length = source.Length; + int depth = 0; + hadBraceBlock = false; + mainNameStart = -1; + + int lastIdentifierStart = -1; + int lastIdentifierLength = 0; + + while (position < length) + { + position = SkipTrivia(source, position); + if (position >= length) break; + + char c = source[position]; + + // Read a whole identifier in one go. Accumulating character by + // character across the trivia skip would run "void main" together + // into a single nine-character token and lose the function name. + if (IsIdentifierStart(c)) + { + int identifierStart = position; + while (position < length && IsIdentifierPart(source[position])) position++; + lastIdentifierStart = identifierStart; + lastIdentifierLength = position - identifierStart; + continue; + } + + if (c == '{') + { + // The identifier immediately before a top-level '(' ... '{' is the + // function name. Only "main" matters, and only at depth 0. + if (depth == 0) + { + hadBraceBlock = true; + } + depth++; + position++; + continue; + } + + if (c == '}') + { + depth--; + position++; + if (depth <= 0) + { + int after = SkipTrivia(source, position); + if (after < length && source[after] == ';') + { + return after + 1; + } + return position; + } + continue; + } + + if (c == '(' && depth == 0 && lastIdentifierLength == 4 && + string.CompareOrdinal(source, lastIdentifierStart, "main", 0, 4) == 0) + { + mainNameStart = lastIdentifierStart; + } + + if (c == ';' && depth == 0) + { + return position + 1; + } + + position++; + } + + return position; + } + + private static bool IsIdentifierStart(char c) => char.IsLetter(c) || c == '_'; + + private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + + // ---------------------------------------------------------------- classifying + + /// + /// Qualifiers that may sit between a layout clause and the storage keyword. + /// The parser steps over them to reach the part it cares about. + /// + /// The memory qualifiers matter as much as the interpolation ones: the chunk + /// shaders declare readonly buffer faceDataBuf, and failing to step + /// over readonly leaves the storage block unrecognised, which lands it + /// in the wrong descriptor set. + /// + private static readonly string[] SkippableQualifiers = + { + "flat", "smooth", "noperspective", "centroid", "sample", "invariant", "precise", + "highp", "mediump", "lowp", + "readonly", "writeonly", "coherent", "volatile", "restrict", + }; + + private static GlslDeclaration? Classify(string source, int start, int length, bool hadBraceBlock) + { + string text = source.Substring(start, length); + string stripped = StripComments(text); + + var declaration = new GlslDeclaration { Start = start, Length = length }; + + int cursor = 0; + // Default the insertion point to the head of the declaration, so a + // declaration with no layout clause still has a valid place to gain one. + declaration.LayoutStart = start; + declaration.LayoutLength = 0; + + ReadLayoutInto(stripped, ref cursor, declaration, start); + + var qualifiers = new List(); + string? storage = null; + + while (true) + { + int save = cursor; + string? word = ReadIdentifier(stripped, ref cursor); + if (word == null) { cursor = save; break; } + + if (word == "uniform" || word == "buffer" || word == "in" || word == "out" || + word == "attribute" || word == "varying" || word == "shared") + { + storage = word; + break; + } + + if (Array.IndexOf(SkippableQualifiers, word) >= 0) + { + qualifiers.Add(word); + continue; + } + + if (word == "layout") + { + cursor = save; + ReadLayoutInto(stripped, ref cursor, declaration, start); + continue; + } + + // A const, a struct, a function, a plain global: not ours. + cursor = save; + break; + } + + declaration.Qualifiers = string.Join(" ", qualifiers); + + if (storage == null) + { + declaration.Kind = GlslDeclarationKind.Other; + return declaration; + } + + // An interface block: "uniform Name { ... }" / "buffer Name { ... }". + if (hadBraceBlock && (storage == "uniform" || storage == "buffer")) + { + declaration.Kind = storage == "uniform" + ? GlslDeclarationKind.UniformBlock + : GlslDeclarationKind.StorageBlock; + declaration.Name = ReadIdentifier(stripped, ref cursor) ?? ""; + return declaration; + } + + if (hadBraceBlock) + { + declaration.Kind = GlslDeclarationKind.Other; + return declaration; + } + + string? typeName = ReadIdentifier(stripped, ref cursor); + if (typeName == null) + { + declaration.Kind = GlslDeclarationKind.Other; + return declaration; + } + declaration.TypeName = typeName; + + // C-style array-on-the-type: "uniform vec3[64] samples;" (ssao.fsh). + ReadArraySuffix(stripped, ref cursor, declaration); + + string? name = ReadIdentifier(stripped, ref cursor); + if (name == null) + { + declaration.Kind = GlslDeclarationKind.Other; + return declaration; + } + declaration.Name = name; + + // Array-on-the-name: "uniform vec3 pointLights[100];". + ReadArraySuffix(stripped, ref cursor, declaration); + + SkipSpace(stripped, ref cursor); + if (cursor < stripped.Length && stripped[cursor] == '=') + { + cursor++; + int initializerStart = cursor; + int end = stripped.IndexOf(';', cursor); + if (end < 0) end = stripped.Length; + declaration.Initializer = stripped.Substring(initializerStart, end - initializerStart).Trim(); + cursor = end; + } + + // A comma-separated declaration list ("uniform float a, b;") is legal GLSL + // but appears nowhere in this game or its shaders. Leaving it alone is + // safer than half-handling it: it will fail to compile with a clear + // message rather than silently losing a uniform. + SkipSpace(stripped, ref cursor); + if (cursor < stripped.Length && stripped[cursor] == ',') + { + declaration.Kind = GlslDeclarationKind.Other; + return declaration; + } + + declaration.Kind = storage switch + { + "uniform" => GlslType.IsOpaqueTypeName(typeName) + ? GlslDeclarationKind.OpaqueUniform + : GlslDeclarationKind.DefaultUniform, + "in" or "attribute" => GlslDeclarationKind.Input, + "out" or "varying" => GlslDeclarationKind.Output, + _ => GlslDeclarationKind.Other, + }; + + return declaration; + } + + private static void ReadArraySuffix(string text, ref int cursor, GlslDeclaration declaration) + { + SkipSpace(text, ref cursor); + if (cursor >= text.Length || text[cursor] != '[') return; + + int close = text.IndexOf(']', cursor); + if (close < 0) return; + + string inside = text.Substring(cursor + 1, close - cursor - 1).Trim(); + cursor = close + 1; + + if (TryEvaluateConstantInt(inside, out int size)) + { + declaration.ArrayLength = size; + } + else + { + declaration.UnresolvedArraySize = inside; + } + } + + /// + /// Evaluates an integer constant expression from an array size. + /// + /// GLSL permits any constant expression there and the shaders use it - + /// fogandlight.vsh declares uniform vec4 fogSpheres[3 * 8];. By this + /// point the preprocessor has already substituted every macro, so what is + /// left is arithmetic over literals. + /// + internal static bool TryEvaluateConstantInt(string expression, out int value) + { + int cursor = 0; + value = 0; + + if (!TryParseAdditive(expression, ref cursor, out int result)) return false; + + SkipSpace(expression, ref cursor); + if (cursor != expression.Length) return false; + + value = result; + return true; + } + + private static bool TryParseAdditive(string text, ref int cursor, out int value) + { + value = 0; + if (!TryParseMultiplicative(text, ref cursor, out int left)) return false; + + while (true) + { + SkipSpace(text, ref cursor); + if (cursor >= text.Length) break; + + char op = text[cursor]; + if (op != '+' && op != '-') break; + + cursor++; + if (!TryParseMultiplicative(text, ref cursor, out int right)) return false; + left = op == '+' ? left + right : left - right; + } + + value = left; + return true; + } + + private static bool TryParseMultiplicative(string text, ref int cursor, out int value) + { + value = 0; + if (!TryParseUnary(text, ref cursor, out int left)) return false; + + while (true) + { + SkipSpace(text, ref cursor); + if (cursor >= text.Length) break; + + char op = text[cursor]; + if (op != '*' && op != '/' && op != '%') break; + + cursor++; + if (!TryParseUnary(text, ref cursor, out int right)) return false; + if (op != '*' && right == 0) return false; + + left = op switch + { + '*' => left * right, + '/' => left / right, + _ => left % right, + }; + } + + value = left; + return true; + } + + private static bool TryParseUnary(string text, ref int cursor, out int value) + { + value = 0; + SkipSpace(text, ref cursor); + if (cursor >= text.Length) return false; + + char c = text[cursor]; + if (c == '+' || c == '-') + { + cursor++; + if (!TryParseUnary(text, ref cursor, out int inner)) return false; + value = c == '-' ? -inner : inner; + return true; + } + + if (c == '(') + { + cursor++; + if (!TryParseAdditive(text, ref cursor, out int inner)) return false; + SkipSpace(text, ref cursor); + if (cursor >= text.Length || text[cursor] != ')') return false; + cursor++; + value = inner; + return true; + } + + if (!char.IsDigit(c)) return false; + + int start = cursor; + while (cursor < text.Length && char.IsDigit(text[cursor])) cursor++; + + // A trailing 'u' suffix is legal on an integer literal. + if (cursor < text.Length && (text[cursor] == 'u' || text[cursor] == 'U')) cursor++; + + return int.TryParse( + text.AsSpan(start, cursor - start).TrimEnd('u').TrimEnd('U'), + NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + /// + /// Reads a layout(...) clause if one is present and records both its + /// contents and its absolute span on the declaration. + /// + private static void ReadLayoutInto(string text, ref int cursor, GlslDeclaration declaration, int declarationStart) + { + SkipSpace(text, ref cursor); + int clauseStart = cursor; + + string? qualifiers = ReadLayoutQualifier(text, ref cursor); + if (qualifiers == null) return; + + declaration.LayoutQualifiers = qualifiers; + declaration.LayoutStart = declarationStart + clauseStart; + declaration.LayoutLength = cursor - clauseStart; + declaration.Location = ReadLocation(qualifiers); + } + + private static string? ReadLayoutQualifier(string text, ref int cursor) + { + int save = cursor; + SkipSpace(text, ref cursor); + if (!MatchWord(text, ref cursor, "layout")) { cursor = save; return null; } + + SkipSpace(text, ref cursor); + if (cursor >= text.Length || text[cursor] != '(') { cursor = save; return null; } + + int depth = 0; + int start = cursor + 1; + while (cursor < text.Length) + { + if (text[cursor] == '(') depth++; + else if (text[cursor] == ')') + { + depth--; + if (depth == 0) + { + string inside = text.Substring(start, cursor - start); + cursor++; + return inside; + } + } + cursor++; + } + + cursor = save; + return null; + } + + private static int ReadLocation(string qualifiers) + { + foreach (string part in qualifiers.Split(',')) + { + int equals = part.IndexOf('='); + if (equals < 0) continue; + if (part.AsSpan(0, equals).Trim().SequenceEqual("location") && + int.TryParse(part.AsSpan(equals + 1).Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int location)) + { + return location; + } + } + return -1; + } + + private static bool MatchWord(string text, ref int cursor, string word) + { + if (cursor + word.Length > text.Length) return false; + if (string.CompareOrdinal(text, cursor, word, 0, word.Length) != 0) return false; + int after = cursor + word.Length; + if (after < text.Length && (char.IsLetterOrDigit(text[after]) || text[after] == '_')) return false; + cursor = after; + return true; + } + + private static string? ReadIdentifier(string text, ref int cursor) + { + SkipSpace(text, ref cursor); + if (cursor >= text.Length || !IsIdentifierStart(text[cursor])) return null; + + int start = cursor; + while (cursor < text.Length && (char.IsLetterOrDigit(text[cursor]) || text[cursor] == '_')) cursor++; + return text.Substring(start, cursor - start); + } + + private static void SkipSpace(string text, ref int cursor) + { + while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) cursor++; + } + + /// + /// Blanks comments while preserving offsets, so spans stay valid. The + /// production path sees preprocessed source with comments already gone; this + /// keeps the parser usable on raw source in tests and on mod shaders that + /// reach it by another route. + /// + internal static string StripComments(string text) + { + var builder = new StringBuilder(text); + int i = 0; + while (i < text.Length) + { + if (text[i] == '/' && i + 1 < text.Length) + { + if (text[i + 1] == '/') + { + while (i < text.Length && text[i] != '\n') builder[i++] = ' '; + continue; + } + if (text[i + 1] == '*') + { + int end = text.IndexOf("*/", i + 2, StringComparison.Ordinal); + end = end < 0 ? text.Length : end + 2; + while (i < end) + { + if (text[i] != '\n') builder[i] = ' '; + i++; + } + continue; + } + } + i++; + } + return builder.ToString(); + } +} diff --git a/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs b/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs new file mode 100644 index 00000000..cf9b75f5 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// Renames identifiers that GLSL 330 allows but GLSL 450 reserves. +/// +/// Vulkan requires #version 450, and the language gained keywords between +/// the two versions. A shader that used one of them as an ordinary variable name +/// compiled fine against 330 and becomes a syntax error at 450 - +/// ssao.fsh has a local called sample, which 4.00 turned into an +/// interpolation qualifier. +/// +/// The rename is a token-level substitution, so it catches declarations and uses +/// alike without needing to understand the code around them. +/// +internal static class GlslReservedWords +{ + private const string Prefix = "_optimum_kw_"; + + /// + /// Words reserved by 4.x that a 330 shader could legitimately have used as a + /// name. + /// + /// buffer and shared are deliberately absent. Both became + /// storage qualifiers in 4.30 and both are used as qualifiers by these + /// shaders - chunkopaque.vsh declares readonly buffer faceDataBuf - + /// so renaming them would break the declaration this backend depends on. A + /// 330 shader using either as a variable name is possible in principle and + /// would fail to compile with a clear message; that is the better trade. + /// + private static readonly string[] Reserved = + { + "sample", "patch", "subroutine", "precise", + "resource", "filter", "active", "common", "partition", "superp", + "input", "output", + }; + + /// + /// Built-ins GL and Vulkan spell differently. + /// + /// The values also differ in principle - gl_VertexIndex counts from + /// the draw's vertex offset and gl_InstanceIndex from its first + /// instance, where the GL originals count from zero - but this client issues + /// no draw with a non-zero base vertex or first instance, so the two agree + /// everywhere they are used. + /// + private static readonly (string From, string To)[] BuiltinRenames = + { + ("gl_VertexID", "gl_VertexIndex"), + ("gl_InstanceID", "gl_InstanceIndex"), + }; + + private static readonly Dictionary Renames = BuildRenames(); + + private static Dictionary BuildRenames() + { + var renames = new Dictionary(StringComparer.Ordinal); + foreach (string word in Reserved) renames[word] = Prefix + word; + foreach ((string from, string to) in BuiltinRenames) renames[from] = to; + return renames; + } + + /// Longest key, used to skip identifiers that cannot match. + private static readonly int LongestRename = MaxKeyLength(); + + private static int MaxKeyLength() + { + int longest = 0; + foreach (string key in Renames.Keys) longest = Math.Max(longest, key.Length); + return longest; + } + + /// + /// Returns the source with reserved identifiers renamed, or the original + /// string when nothing needed changing. + /// + public static string Rename(string source) + { + if (string.IsNullOrEmpty(source)) return source; + + StringBuilder? builder = null; + int copiedTo = 0; + int position = 0; + int length = source.Length; + + while (position < length) + { + char c = source[position]; + + // Line comments and block comments are gone after preprocessing, but + // this class is cheap to make safe against raw source too. + if (c == '/' && position + 1 < length) + { + if (source[position + 1] == '/') + { + while (position < length && source[position] != '\n') position++; + continue; + } + if (source[position + 1] == '*') + { + int end = source.IndexOf("*/", position + 2, StringComparison.Ordinal); + position = end < 0 ? length : end + 2; + continue; + } + } + + if (!IsIdentifierStart(c)) + { + position++; + continue; + } + + int start = position; + while (position < length && IsIdentifierPart(source[position])) position++; + + // A word preceded by '.' is a struct field or a swizzle, never a + // declaration, and renaming it would break the member it names. + if (IsMemberAccess(source, start)) + { + continue; + } + + int wordLength = position - start; + if (wordLength > LongestRename) continue; + + string word = source.Substring(start, wordLength); + if (!Renames.TryGetValue(word, out string? replacement)) continue; + + builder ??= new StringBuilder(length + 64); + builder.Append(source, copiedTo, start - copiedTo); + builder.Append(replacement); + copiedTo = position; + } + + if (builder == null) return source; + + builder.Append(source, copiedTo, length - copiedTo); + return builder.ToString(); + } + + private static bool IsMemberAccess(string source, int identifierStart) + { + int i = identifierStart - 1; + while (i >= 0 && (source[i] == ' ' || source[i] == '\t')) i--; + return i >= 0 && source[i] == '.'; + } + + private static bool IsIdentifierStart(char c) => char.IsLetter(c) || c == '_'; + private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; +} diff --git a/Optimum.Render.Vulkan/Shaders/GlslType.cs b/Optimum.Render.Vulkan/Shaders/GlslType.cs new file mode 100644 index 00000000..c4e47e1e --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/GlslType.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// The GLSL types that can appear in a uniform declaration, with their scalar +/// block layout sizes. +/// +/// Scalar layout (GL_EXT_scalar_block_layout) aligns every aggregate to its +/// component's natural alignment rather than rounding up to 16 bytes. For the +/// 32-bit types this game uses that means alignment is always 4 and size is +/// simply the component count times 4 - which is exactly how a tightly packed +/// float[] is laid out on the CPU. +/// +/// That equivalence is the whole reason this backend asks for scalar layout. +/// The game feeds uniforms as raw arrays: Uniforms3("pointLights", count, +/// float[]) sends count * 3 floats with no padding, and +/// UniformMatrices4x3 sends 12 floats per matrix. Under std140 a +/// vec3[] has a 16-byte stride and a mat4x3[] a 64-byte one, so +/// every array upload would need re-striding on the CPU. Under scalar layout the +/// setter is a memcpy at a recorded offset. +/// +internal readonly struct GlslType : IEquatable +{ + /// The name as written in the shader, e.g. "vec3", "mat4x3". + public string Name { get; } + + /// Columns for a matrix; 1 for scalars and vectors. + public int Columns { get; } + + /// Components per column: 3 for vec3 and for a column of mat4x3. + public int Rows { get; } + + /// Bytes per scalar component. 4 for every type this game uses. + public int ScalarSize { get; } + + /// + /// True for samplers and images: opaque handles that live in descriptors, not + /// in the uniform block. + /// + public bool IsOpaque { get; } + + private GlslType(string name, int columns, int rows, int scalarSize, bool isOpaque) + { + Name = name; + Columns = columns; + Rows = rows; + ScalarSize = scalarSize; + IsOpaque = isOpaque; + } + + /// Total components, e.g. 12 for mat4x3. + public int ComponentCount => Columns * Rows; + + /// Size of one element in bytes under scalar layout. + public int Size => ComponentCount * ScalarSize; + + /// + /// Alignment under scalar layout: the component's own alignment, never + /// rounded up. This is what keeps array strides tight. + /// + public int Alignment => ScalarSize; + + public bool IsMatrix => Columns > 1; + + public bool Equals(GlslType other) => Name == other.Name; + public override bool Equals(object? obj) => obj is GlslType other && Equals(other); + public override int GetHashCode() => Name?.GetHashCode(StringComparison.Ordinal) ?? 0; + public override string ToString() => Name; + + private static GlslType Numeric(string name, int columns, int rows) => + new(name, columns, rows, 4, isOpaque: false); + + private static GlslType Opaque(string name) => + new(name, 1, 1, 0, isOpaque: true); + + private static readonly Dictionary ByName = BuildTable(); + + private static Dictionary BuildTable() + { + var table = new Dictionary(StringComparer.Ordinal); + + void Add(GlslType type) => table[type.Name] = type; + + // Scalars. bool is 4 bytes in a uniform block, as in GL. + Add(Numeric("float", 1, 1)); + Add(Numeric("int", 1, 1)); + Add(Numeric("uint", 1, 1)); + Add(Numeric("bool", 1, 1)); + + // Vectors. + for (int n = 2; n <= 4; n++) + { + Add(Numeric("vec" + n, 1, n)); + Add(Numeric("ivec" + n, 1, n)); + Add(Numeric("uvec" + n, 1, n)); + Add(Numeric("bvec" + n, 1, n)); + } + + // Matrices. GLSL matCxR is C columns of R rows, and matN is matNxN. + for (int columns = 2; columns <= 4; columns++) + { + Add(Numeric("mat" + columns, columns, columns)); + for (int rows = 2; rows <= 4; rows++) + { + Add(Numeric($"mat{columns}x{rows}", columns, rows)); + } + } + + // Opaque handles. Only the ones the game and its shaders actually use, + // plus the obvious neighbours so a mod shader is not rejected for using + // a sampler type this list happened to omit. + foreach (string sampler in new[] + { + "sampler1D", "sampler2D", "sampler3D", "samplerCube", + "sampler2DShadow", "sampler1DShadow", "samplerCubeShadow", + "sampler2DArray", "sampler2DArrayShadow", "sampler1DArray", + "sampler2DMS", "sampler2DMSArray", "samplerBuffer", + "isampler2D", "isampler3D", "isamplerCube", "isampler2DArray", + "usampler2D", "usampler3D", "usamplerCube", "usampler2DArray", + "image2D", "image3D", "imageCube", "image2DArray", + }) + { + Add(Opaque(sampler)); + } + + return table; + } + + /// + /// Resolves a type name. Returns false for anything unrecognised - a struct, + /// or a type this table does not model - which the rewriter treats as a + /// declaration to leave alone rather than an error. + /// + public static bool TryParse(string name, out GlslType type) => ByName.TryGetValue(name, out type); + + /// True for a name that is a sampler or image handle. + public static bool IsOpaqueTypeName(string name) => + ByName.TryGetValue(name, out GlslType type) && type.IsOpaque; +} diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs new file mode 100644 index 00000000..94203a6e --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// One member of the generated default-uniform block. +/// One vertex input a program declares, and where it lives. +internal readonly record struct VertexInputSlot(string Name, int Location, GlslType Type); + +internal sealed class UniformMember +{ + public string Name = ""; + public GlslType Type; + /// 0 when the member is not an array. + public int ArrayLength; + /// Byte offset into the block. + public int Offset; + /// Total bytes, counting every array element. + public int Size; + /// Default value as written in the shader, or null. + public string? Initializer; + + public int ElementCount => ArrayLength == 0 ? 1 : ArrayLength; +} + +internal sealed class SamplerBinding +{ + public string Name = ""; + public string TypeName = ""; + public int Binding; +} + +internal sealed class BlockBinding +{ + public string BlockName = ""; + public int Set; + public int Binding; + /// True when the shader declared the binding itself. + public bool Explicit; +} + +/// +/// The complete interface of a linked program: uniforms, samplers, blocks, and +/// the location assignments for every stage boundary. +/// +/// All of it has to be resolved per program rather than per stage, because GL +/// links by name and Vulkan links by number. Two consequences drive the design: +/// +/// A uniform named in two stages is one uniform in GL - zNear is declared +/// in both the vertex and fragment shader and carries one value - so the backend +/// generates a single uniform block, byte-identical in every stage, whose members +/// are the union of what the stages declare. +/// +/// A varying has no location in GL, but SPIR-V requires one on every user-defined +/// input and output, and the vertex output and fragment input must agree. The +/// vanilla shaders declare bare out vec2 texCoord;, so the backend assigns +/// those numbers itself and hands the same assignment to both stages. +/// +/// This is why a stage cannot be compiled to SPIR-V alone, and why +/// CompileShader only stages work that LinkProgram finishes. +/// +internal sealed class ProgramInterfaceLayout +{ + public const string BlockTypeName = "OptimumUniforms"; + public const string BlockInstanceName = "_optimum"; + public const int DefaultBlockSet = 0; + public const int DefaultBlockBinding = 0; + public const int SamplerSet = 1; + public const int StorageSet = 2; + + /// Members in declaration order, vertex stage first. + public List Members { get; } = new(); + public Dictionary MembersByName { get; } = new(StringComparer.Ordinal); + + /// + /// Which members each stage actually declared. + /// + /// The generated block is emitted per stage rather than whole, because a name + /// that is a uniform in one stage can be something else entirely in another: + /// bilateralblur.vsh declares uniform vec2 frameSize while its + /// fragment shader declares in vec2 frameSize. Emitting the union into + /// both stages would redefine the varying. Members carry explicit offsets, so + /// each stage sees a subset of one shared buffer layout. + /// + public Dictionary> MembersByStage { get; } = new(); + + public List Samplers { get; } = new(); + public Dictionary SamplersByName { get; } = new(StringComparer.Ordinal); + + public List UniformBlocks { get; } = new(); + public List StorageBlocks { get; } = new(); + + /// Stage-to-stage varying locations, keyed by variable name. + public Dictionary VaryingLocations { get; } = new(StringComparer.Ordinal); + + /// Vertex attribute locations for inputs that declared none. + public Dictionary VertexInputLocations { get; } = new(StringComparer.Ordinal); + + /// + /// Every vertex input the program declares, whatever supplies it. + /// + /// A shader routinely reads attributes the mesh does not carry - the GUI + /// quad has only positions and UVs, while gui.vsh also declares a colour, a + /// render-flags int, a damage effect and a joint id. GL answers those reads + /// with the constant generic attribute, so the draw is well defined; Vulkan + /// has no equivalent and the values are undefined. Knowing the full set is + /// what lets the device supply the same constants. + /// + public List VertexInputs { get; } = new(); + + /// Fragment output locations for outputs that declared none. + public Dictionary FragmentOutputLocations { get; } = new(StringComparer.Ordinal); + + /// Size of the generated block in bytes; 0 when it has no members. + public int BlockSize { get; private set; } + + public bool HasUniformBlock => BlockSize > 0; + + /// Diagnostics that made the layout unusable. + public List Errors { get; } = new(); + public bool HasErrors => Errors.Count > 0; + + /// + /// Builds the shadow buffer the CPU writes uniforms into, pre-filled with any + /// initialisers the shaders declared. GL applies those defaults at link time + /// and shaders rely on it: final.fsh never assigns extraGamma unless + /// colour grading is active and expects the declared 1.0. + /// + public byte[] CreateShadowBuffer() + { + var buffer = new byte[Math.Max(BlockSize, 0)]; + foreach (UniformMember member in Members) + { + if (member.Initializer != null) + { + WriteInitializer(buffer, member); + } + } + return buffer; + } + + private static void WriteInitializer(byte[] buffer, UniformMember member) + { + // Only scalar literal defaults are honoured. Every initialiser in the + // shipped shaders is one; a constructor expression would need an + // evaluator to be worth supporting. + if (member.ArrayLength != 0 || member.Type.ComponentCount != 1) return; + + string text = member.Initializer!.Trim(); + switch (member.Type.Name) + { + case "float": + if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out float f)) + { + BitConverter.TryWriteBytes(buffer.AsSpan(member.Offset), f); + } + break; + case "int": + case "uint": + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int i)) + { + BitConverter.TryWriteBytes(buffer.AsSpan(member.Offset), i); + } + break; + case "bool": + BitConverter.TryWriteBytes(buffer.AsSpan(member.Offset), text == "true" ? 1 : 0); + break; + } + } + + /// + /// Unions the stages into one layout. Stages arrive in a fixed order - vertex, + /// fragment, geometry - so the result is deterministic and the SPIR-V cache + /// key is stable across runs. + /// + /// + /// Locations from IShaderProgram's BindAttribLocation map, for mods + /// that name attributes through the API instead of a layout qualifier. + /// + public static ProgramInterfaceLayout Build( + IReadOnlyList<(EnumShaderType Stage, ParsedShader Parsed)> stages, + IReadOnlyDictionary? declaredAttributes = null) + { + var layout = new ProgramInterfaceLayout(); + int offset = 0; + int nextUniformBlockBinding = DefaultBlockBinding + 1; + int nextStorageBinding = 0; + + foreach ((EnumShaderType stage, ParsedShader parsed) in stages) + { + foreach (GlslDeclaration declaration in parsed.Declarations) + { + switch (declaration.Kind) + { + case GlslDeclarationKind.DefaultUniform: + AddDefaultUniform(layout, declaration, stage, ref offset); + break; + case GlslDeclarationKind.OpaqueUniform: + AddSampler(layout, declaration); + break; + case GlslDeclarationKind.UniformBlock: + AddBlock(layout.UniformBlocks, declaration, DefaultBlockSet, ref nextUniformBlockBinding); + break; + case GlslDeclarationKind.StorageBlock: + AddBlock(layout.StorageBlocks, declaration, StorageSet, ref nextStorageBinding); + break; + } + } + } + + AssignInterfaceLocations(layout, stages, declaredAttributes); + + layout.BlockSize = offset; + return layout; + } + + // ------------------------------------------------------------------ uniforms + + private static void AddDefaultUniform( + ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage, ref int offset) + { + if (!GlslType.TryParse(declaration.TypeName, out GlslType type)) + { + // A struct-typed uniform, or a type this backend does not model. The + // rewriter leaves the declaration alone, so the shader still compiles; + // it simply is not settable through the generated block. + return; + } + + if (!layout.MembersByStage.TryGetValue(stage, out HashSet? stageMembers)) + { + stageMembers = new HashSet(StringComparer.Ordinal); + layout.MembersByStage[stage] = stageMembers; + } + stageMembers.Add(declaration.Name); + + if (declaration.UnresolvedArraySize != null) + { + layout.Errors.Add( + $"uniform '{declaration.Name}' has array size '{declaration.UnresolvedArraySize}' " + + $"which did not resolve to a constant in the {stage} stage"); + return; + } + + if (layout.MembersByName.TryGetValue(declaration.Name, out UniformMember? existing)) + { + // Declared in more than one stage. GL merges them; so do we, but only + // when they agree - a mismatch is a bug GL would reject at link time. + if (!existing.Type.Equals(type) || existing.ArrayLength != declaration.ArrayLength) + { + layout.Errors.Add( + $"uniform '{declaration.Name}' is declared as '{existing.Type.Name}' " + + $"and '{type.Name}' in different stages"); + } + return; + } + + offset = Align(offset, type.Alignment); + + var member = new UniformMember + { + Name = declaration.Name, + Type = type, + ArrayLength = declaration.ArrayLength, + Offset = offset, + Initializer = declaration.Initializer, + }; + member.Size = type.Size * member.ElementCount; + offset += member.Size; + + layout.Members.Add(member); + layout.MembersByName[member.Name] = member; + } + + private static void AddSampler(ProgramInterfaceLayout layout, GlslDeclaration declaration) + { + if (layout.SamplersByName.ContainsKey(declaration.Name)) return; + + var binding = new SamplerBinding + { + Name = declaration.Name, + TypeName = declaration.TypeName, + Binding = layout.Samplers.Count, + }; + layout.Samplers.Add(binding); + layout.SamplersByName[binding.Name] = binding; + } + + private static void AddBlock( + List blocks, GlslDeclaration declaration, int set, ref int nextBinding) + { + foreach (BlockBinding existing in blocks) + { + if (existing.BlockName == declaration.Name) return; + } + + // A shader that names its own binding keeps it: chunkopaque.vsh declares + // "layout(binding = 3, std430) readonly buffer faceDataBuf", and the mesh + // path binds the vertex buffer to that exact index. + int declared = ReadQualifierInt(declaration.LayoutQualifiers, "binding"); + blocks.Add(new BlockBinding + { + BlockName = declaration.Name, + Set = set, + Binding = declared >= 0 ? declared : nextBinding, + Explicit = declared >= 0, + }); + + if (declared < 0) nextBinding++; + else if (declared >= nextBinding) nextBinding = declared + 1; + } + + // ----------------------------------------------------------------- locations + + /// + /// Assigns the numbers SPIR-V demands and GLSL 330 leaves implicit: vertex + /// attribute locations, stage-to-stage varying locations, and fragment output + /// locations. Explicit qualifiers already in the source always win, and the + /// generated numbers fill the gaps around them. + /// + private static void AssignInterfaceLocations( + ProgramInterfaceLayout layout, + IReadOnlyList<(EnumShaderType Stage, ParsedShader Parsed)> stages, + IReadOnlyDictionary? declaredAttributes) + { + var usedVertexInputs = new HashSet(); + var usedVaryings = new HashSet(); + var usedFragmentOutputs = new HashSet(); + + // Pass one: record every location the shaders stated outright. + foreach ((EnumShaderType stage, ParsedShader parsed) in stages) + { + foreach (GlslDeclaration declaration in parsed.Declarations) + { + if (declaration.Location < 0) continue; + + if (stage == EnumShaderType.VertexShader && declaration.Kind == GlslDeclarationKind.Input) + { + Occupy(usedVertexInputs, declaration.Location, LocationSpan(declaration)); + RecordVertexInput(layout, declaration, declaration.Location); + } + else if (stage == EnumShaderType.FragmentShader && declaration.Kind == GlslDeclarationKind.Output) + { + Occupy(usedFragmentOutputs, declaration.Location, LocationSpan(declaration)); + } + else + { + layout.VaryingLocations[declaration.Name] = declaration.Location; + Occupy(usedVaryings, declaration.Location, LocationSpan(declaration)); + } + } + } + + // Attribute locations bound through the API rather than the shader. + if (declaredAttributes != null) + { + foreach (KeyValuePair attribute in declaredAttributes) + { + layout.VertexInputLocations[attribute.Key] = attribute.Value; + Occupy(usedVertexInputs, attribute.Value, 1); + } + } + + // Pass two: fill in the rest. + foreach ((EnumShaderType stage, ParsedShader parsed) in stages) + { + foreach (GlslDeclaration declaration in parsed.Declarations) + { + if (declaration.Location >= 0) continue; + int span = LocationSpan(declaration); + + if (stage == EnumShaderType.VertexShader && declaration.Kind == GlslDeclarationKind.Input) + { + // A name already present came from declaredAttributes - bound + // through the API rather than the shader - and still needs + // recording, because the location is known but the type only + // appears here. + if (layout.VertexInputLocations.TryGetValue(declaration.Name, out int bound)) + { + RecordVertexInput(layout, declaration, bound); + continue; + } + int assigned = Reserve(usedVertexInputs, span); + layout.VertexInputLocations[declaration.Name] = assigned; + RecordVertexInput(layout, declaration, assigned); + } + else if (stage == EnumShaderType.FragmentShader && declaration.Kind == GlslDeclarationKind.Output) + { + if (layout.FragmentOutputLocations.ContainsKey(declaration.Name)) continue; + layout.FragmentOutputLocations[declaration.Name] = Reserve(usedFragmentOutputs, span); + } + else if (declaration.Kind is GlslDeclarationKind.Input or GlslDeclarationKind.Output) + { + // A varying. The first stage to mention the name fixes the + // number; the matching stage reads it back out of the map, so + // vertex out and fragment in always agree. + if (layout.VaryingLocations.ContainsKey(declaration.Name)) continue; + layout.VaryingLocations[declaration.Name] = Reserve(usedVaryings, span); + } + } + } + } + + /// + /// How many consecutive locations a variable consumes. A vector of any width + /// fits in one; a matrix takes one per column; an array multiplies by its + /// length. + /// + /// + /// Notes a vertex input so the device can supply GL's constant default when + /// the mesh does not carry it. Arrays and matrices are skipped: nothing in + /// the game declares one as a vertex input, and spanning several locations + /// would need a default per column rather than per attribute. + /// + private static void RecordVertexInput( + ProgramInterfaceLayout layout, GlslDeclaration declaration, int location) + { + if (location < 0) return; + if (declaration.ArrayLength != 0) return; + if (!GlslType.TryParse(declaration.TypeName, out GlslType type)) return; + if (type.IsMatrix || type.IsOpaque) return; + + foreach (VertexInputSlot existing in layout.VertexInputs) + { + if (existing.Location == location) return; + } + layout.VertexInputs.Add(new VertexInputSlot(declaration.Name, location, type)); + } + + private static int LocationSpan(GlslDeclaration declaration) + { + int elements = declaration.ArrayLength == 0 ? 1 : declaration.ArrayLength; + int perElement = GlslType.TryParse(declaration.TypeName, out GlslType type) && type.IsMatrix + ? type.Columns + : 1; + return Math.Max(1, elements * perElement); + } + + private static void Occupy(HashSet used, int start, int span) + { + for (int i = 0; i < span; i++) used.Add(start + i); + } + + private static int Reserve(HashSet used, int span) + { + int candidate = 0; + while (true) + { + bool free = true; + for (int i = 0; i < span; i++) + { + if (used.Contains(candidate + i)) { free = false; break; } + } + if (free) + { + Occupy(used, candidate, span); + return candidate; + } + candidate++; + } + } + + private static int ReadQualifierInt(string? qualifiers, string key) + { + if (qualifiers == null) return -1; + foreach (string part in qualifiers.Split(',')) + { + int equals = part.IndexOf('='); + if (equals < 0) continue; + if (part.AsSpan(0, equals).Trim().SequenceEqual(key) && + int.TryParse(part.AsSpan(equals + 1).Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int value)) + { + return value; + } + } + return -1; + } + + private static int Align(int value, int alignment) => + alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment; +} diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs new file mode 100644 index 00000000..911307b2 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -0,0 +1,269 @@ +using System; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using Silk.NET.Shaderc; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// Outcome of a preprocess or compile step. +internal sealed class ShaderCompileResult +{ + public bool Success; + public string? Error; + public string PreprocessedText = ""; + public byte[] Spirv = Array.Empty(); +} + +/// +/// Wraps shaderc for the two jobs the backend needs: resolving the preprocessor +/// before the rewriter looks at the source, and turning rewritten GLSL into +/// SPIR-V. +/// +/// Preprocessing is a separate pass on purpose. The game builds a block of +/// #defines per program - FXAA, SSAOLEVEL, SHADOWQUALITY, DYNLIGHTS and a +/// dozen more - and the shaders wrap declarations in #if on them, so the +/// set of uniforms a stage declares is not knowable until the conditionals are +/// resolved. Running a real preprocessor first means the rewriter only ever sees +/// straight-line declarations and never has to reason about conditionals. +/// +internal sealed unsafe class ShaderCompiler : IDisposable +{ + private readonly Shaderc _api; + private readonly Compiler* _compiler; + private bool _disposed; + + public ShaderCompiler() + { + _api = Shaderc.GetApi(); + _compiler = _api.CompilerInitialize(); + if (_compiler == null) + { + throw new InvalidOperationException("shaderc failed to initialise"); + } + } + + /// + /// Splices the program's #define prefix in after the version line and + /// resolves the preprocessor, exactly where and how the OpenGL path does it + /// in ClientPlatformWindows.CompileShader. + /// + public ShaderCompileResult Preprocess(string code, string prefixCode, string filename, EnumShaderType stage) + { + string spliced = SplicePrefix(RaiseVersionForPreprocessing(code), prefixCode); + var result = new ShaderCompileResult(); + + CompileOptions* options = CreateOptions(); + try + { + CompilationResult* compiled = CompileWith( + spliced, filename, stage, options, preprocessOnly: true); + try + { + if (!Succeeded(compiled, out string? error)) + { + result.Error = error; + return result; + } + + result.PreprocessedText = ReadBytesAsText(compiled); + result.Success = true; + return result; + } + finally + { + _api.ResultRelease(compiled); + } + } + finally + { + _api.CompileOptionsRelease(options); + } + } + + /// Compiles already-rewritten Vulkan GLSL to SPIR-V. + public ShaderCompileResult Compile(string code, string filename, EnumShaderType stage) + { + var result = new ShaderCompileResult(); + + CompileOptions* options = CreateOptions(); + try + { + CompilationResult* compiled = CompileWith(code, filename, stage, options, preprocessOnly: false); + try + { + if (!Succeeded(compiled, out string? error)) + { + result.Error = error; + return result; + } + + nuint length = _api.ResultGetLength(compiled); + byte* bytes = (byte*)_api.ResultGetBytes(compiled); + var spirv = new byte[(int)length]; + fixed (byte* destination = spirv) + { + Buffer.MemoryCopy(bytes, destination, spirv.Length, (long)length); + } + + result.Spirv = spirv; + result.Success = true; + return result; + } + finally + { + _api.ResultRelease(compiled); + } + } + finally + { + _api.CompileOptionsRelease(options); + } + } + + /// + /// Reproduces the vanilla splice: the prefix goes immediately after the + /// newline that ends the #version line, because a version directive + /// must be the first thing in a translation unit. + /// + internal static string SplicePrefix(string code, string prefixCode) + { + if (string.IsNullOrEmpty(prefixCode)) return code; + + int versionIndex = code.IndexOf("#version", StringComparison.Ordinal); + int insertAt = code.IndexOf('\n', Math.Max(0, versionIndex)) + 1; + if (insertAt <= 0) return prefixCode + code; + + return code.Insert(insertAt, prefixCode); + } + + /// + /// The lowest #version shaderc will preprocess for a SPIR-V target. + /// + /// The check runs during preprocessing, so a source below the floor is + /// rejected before the rewriter ever gets to raise it. Every vanilla shader + /// is well above this; it bites on the client's hardcoded 130 minimal-GUI + /// program and would bite on any mod shader written to an old version. + /// + private const int MinimumPreprocessVersion = 140; + + /// + /// Raises a below-floor #version to the version the rewriter targets + /// anyway, so preprocessing sees something shaderc will accept. + /// + /// Only sources below the floor are touched, which means no vanilla shader + /// changes at all. For the ones that do change, __VERSION__ becomes + /// 450 during preprocessing - a real difference, but the alternative is a + /// shader that cannot be compiled for this backend. + /// + internal static string RaiseVersionForPreprocessing(string code) + { + if (string.IsNullOrEmpty(code)) return code; + + int versionIndex = code.IndexOf("#version", StringComparison.Ordinal); + if (versionIndex < 0) return code; + + int numberStart = versionIndex + "#version".Length; + while (numberStart < code.Length && (code[numberStart] == ' ' || code[numberStart] == '\t')) + { + numberStart++; + } + int numberEnd = numberStart; + while (numberEnd < code.Length && char.IsAsciiDigit(code[numberEnd])) + { + numberEnd++; + } + if (numberEnd == numberStart) return code; + + if (!int.TryParse(code.AsSpan(numberStart, numberEnd - numberStart), + NumberStyles.Integer, CultureInfo.InvariantCulture, out int version)) + { + return code; + } + if (version >= MinimumPreprocessVersion) return code; + + return string.Concat( + code.AsSpan(0, numberStart), "450", code.AsSpan(numberEnd)); + } + + private CompileOptions* CreateOptions() + { + CompileOptions* options = _api.CompileOptionsInitialize(); + _api.CompileOptionsSetSourceLanguage(options, SourceLanguage.Glsl); + _api.CompileOptionsSetTargetEnv(options, TargetEnv.Vulkan, (uint)EnvVersion.Vulkan13); + _api.CompileOptionsSetTargetSpirv(options, SpirvVersion.Shaderc15); + _api.CompileOptionsSetOptimizationLevel(options, OptimizationLevel.Performance); + // The game resolves its own #include directives through ShaderRegistry + // before a stage ever reaches this class, so no include resolver is + // installed; an #include reaching shaderc is a genuine error. + return options; + } + + private CompilationResult* CompileWith( + string code, string filename, EnumShaderType stage, CompileOptions* options, bool preprocessOnly) + { + byte[] source = Encoding.UTF8.GetBytes(code); + byte[] name = Encoding.UTF8.GetBytes(filename ?? "shader"); + byte[] entry = Encoding.UTF8.GetBytes("main"); + + fixed (byte* sourcePtr = source) + fixed (byte* namePtr = name) + fixed (byte* entryPtr = entry) + { + ShaderKind kind = ToShaderKind(stage); + return preprocessOnly + ? _api.CompileIntoPreprocessedText( + _compiler, sourcePtr, (nuint)source.Length, kind, namePtr, entryPtr, options) + : _api.CompileIntoSpv( + _compiler, sourcePtr, (nuint)source.Length, kind, namePtr, entryPtr, options); + } + } + + private bool Succeeded(CompilationResult* result, out string? error) + { + if (result == null) + { + error = "shaderc returned no result"; + return false; + } + + if (_api.ResultGetCompilationStatus(result) == CompilationStatus.Success) + { + error = null; + return true; + } + + byte* message = _api.ResultGetErrorMessage(result); + error = message == null ? "unknown shaderc error" : Marshal.PtrToStringUTF8((IntPtr)message); + return false; + } + + private string ReadBytesAsText(CompilationResult* result) + { + nuint length = _api.ResultGetLength(result); + byte* bytes = (byte*)_api.ResultGetBytes(result); + return length == 0 || bytes == null + ? "" + : Encoding.UTF8.GetString(bytes, (int)length); + } + + private static ShaderKind ToShaderKind(EnumShaderType stage) => stage switch + { + EnumShaderType.VertexShader => ShaderKind.VertexShader, + EnumShaderType.FragmentShader => ShaderKind.FragmentShader, + EnumShaderType.GeometryShader => ShaderKind.GeometryShader, + _ => ShaderKind.VertexShader, + }; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_compiler != null) + { + _api.CompilerRelease(_compiler); + } + _api.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs new file mode 100644 index 00000000..aecf674a --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// The result of rewriting one stage for Vulkan. +internal sealed class RewrittenShader +{ + public string Code = ""; + public List Errors { get; } = new(); + public bool HasErrors => Errors.Count > 0; +} + +/// +/// Turns a stage of GLSL 330 into GLSL 450 that glslang will accept for Vulkan. +/// +/// It works by editing spans of the original source rather than regenerating it. +/// Anything the parser did not classify is copied through byte for byte, so a +/// construct this backend has never seen - in a mod shader, say - survives intact +/// instead of being mangled. The failure mode is a shader that still says what it +/// said before. +/// +/// Five things actually change: +/// +/// The version becomes 450 and the original #extension lines are dropped, +/// since they name GL extensions that either do not exist or are already core in +/// Vulkan GLSL. +/// +/// Loose uniforms move into one generated block. GL's default uniform block has +/// no Vulkan equivalent, and this game declares 488 of them. +/// +/// Samplers, uniform blocks and storage buffers gain descriptor set and binding +/// numbers, keeping any the shader already stated. +/// +/// Vertex inputs, varyings and fragment outputs gain the explicit locations +/// SPIR-V requires and GLSL 330 left implicit. +/// +/// The last stage before rasterisation gains a wrapper around main that +/// remaps clip depth from GL's [-w, w] to Vulkan's [0, w]. Nothing else about the +/// coordinate system is touched: no Y flip, no matrix rewriting. GL and Vulkan +/// agree on the relationship between clip space, framebuffer memory and texture +/// coordinates; they disagree only on what to call the origin, and on depth. +/// +internal static class ShaderRewriter +{ + private const string MainReplacementName = "_optimum_main"; + + private readonly record struct Edit(int Start, int Length, string Replacement); + + public static RewrittenShader Rewrite( + ParsedShader parsed, + ProgramInterfaceLayout layout, + EnumShaderType stage, + bool emitDepthRemap) + { + var result = new RewrittenShader(); + string source = parsed.Source; + var edits = new List(); + + AddHeaderEdits(parsed, layout, stage, edits); + + foreach (GlslDeclaration declaration in parsed.Declarations) + { + switch (declaration.Kind) + { + case GlslDeclarationKind.DefaultUniform: + // Its storage now lives in the generated block. Members keep + // their names there, so every use site still compiles. + if (layout.MembersByName.ContainsKey(declaration.Name)) + { + edits.Add(new Edit(declaration.Start, declaration.Length, "")); + } + break; + + case GlslDeclarationKind.OpaqueUniform: + if (layout.SamplersByName.TryGetValue(declaration.Name, out SamplerBinding? sampler)) + { + edits.Add(LayoutEdit(declaration, new (string, string)[] + { + ("set", ProgramInterfaceLayout.SamplerSet.ToString(CultureInfo.InvariantCulture)), + ("binding", sampler.Binding.ToString(CultureInfo.InvariantCulture)), + })); + } + break; + + case GlslDeclarationKind.UniformBlock: + AddBlockEdit(layout.UniformBlocks, declaration, edits); + break; + + case GlslDeclarationKind.StorageBlock: + AddBlockEdit(layout.StorageBlocks, declaration, edits); + break; + + case GlslDeclarationKind.Input: + case GlslDeclarationKind.Output: + AddLocationEdit(layout, declaration, stage, edits); + break; + } + } + + if (emitDepthRemap) + { + AddDepthRemapEdits(parsed, edits, result); + } + + result.Code = ApplyEdits(source, edits); + return result; + } + + // -------------------------------------------------------------------- header + + private static void AddHeaderEdits( + ParsedShader parsed, ProgramInterfaceLayout layout, EnumShaderType stage, List edits) + { + string block = BuildUniformBlock(layout, stage); + + var header = new StringBuilder(); + header.Append("#version 450\n"); + if (block.Length > 0) + { + header.Append("#extension GL_EXT_scalar_block_layout : require\n"); + } + header.Append(block); + + if (parsed.VersionStart >= 0) + { + edits.Add(new Edit(parsed.VersionStart, parsed.VersionLength, header.ToString().TrimEnd('\n'))); + } + else + { + edits.Add(new Edit(0, 0, header.ToString())); + } + + // The originals name GL extensions - GL_ARB_explicit_attrib_location and + // friends - that Vulkan GLSL either lacks or already includes. + foreach ((int start, int length) in parsed.ExtensionDirectives) + { + edits.Add(new Edit(start, length, "")); + } + } + + /// + /// Emits the block that replaces GL's default uniform block, carrying only + /// the members this stage declared. + /// + /// Members keep their original names and the block is anonymous, so every + /// reference in the shader body resolves unchanged. Each member states its + /// offset explicitly, which is what lets a stage declare a subset without + /// disturbing the shared layout the CPU writes into - and what avoids + /// redefining a name that is a varying in the other stage. + /// + private static string BuildUniformBlock(ProgramInterfaceLayout layout, EnumShaderType stage) + { + if (!layout.HasUniformBlock) return ""; + if (!layout.MembersByStage.TryGetValue(stage, out HashSet? stageMembers)) return ""; + if (stageMembers.Count == 0) return ""; + + var builder = new StringBuilder(); + builder.Append(CultureInfo.InvariantCulture, $"\nlayout(scalar, set = {ProgramInterfaceLayout.DefaultBlockSet}"); + builder.Append(CultureInfo.InvariantCulture, $", binding = {ProgramInterfaceLayout.DefaultBlockBinding}) uniform "); + builder.Append(ProgramInterfaceLayout.BlockTypeName); + builder.Append("\n{\n"); + + foreach (UniformMember member in layout.Members) + { + if (!stageMembers.Contains(member.Name)) continue; + + builder.Append(CultureInfo.InvariantCulture, $" layout(offset = {member.Offset}) "); + builder.Append(member.Type.Name).Append(' ').Append(member.Name); + if (member.ArrayLength > 0) + { + builder.Append('[').Append(member.ArrayLength.ToString(CultureInfo.InvariantCulture)).Append(']'); + } + builder.Append(";\n"); + } + + builder.Append("};\n"); + return builder.ToString(); + } + + // -------------------------------------------------------------- declarations + + private static void AddBlockEdit(List blocks, GlslDeclaration declaration, List edits) + { + foreach (BlockBinding block in blocks) + { + if (block.BlockName != declaration.Name) continue; + + // The memory layout qualifier the shader chose (std140 / std430) is + // preserved: those blocks are filled by UBO uploads whose striding + // already matches, and only the default block needs scalar rules. + edits.Add(LayoutEdit(declaration, new (string, string)[] + { + ("set", block.Set.ToString(CultureInfo.InvariantCulture)), + ("binding", block.Binding.ToString(CultureInfo.InvariantCulture)), + })); + return; + } + } + + private static void AddLocationEdit( + ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage, List edits) + { + int location; + if (stage == EnumShaderType.VertexShader && declaration.Kind == GlslDeclarationKind.Input) + { + if (!layout.VertexInputLocations.TryGetValue(declaration.Name, out location)) return; + } + else if (stage == EnumShaderType.FragmentShader && declaration.Kind == GlslDeclarationKind.Output) + { + if (!layout.FragmentOutputLocations.TryGetValue(declaration.Name, out location)) return; + } + else + { + if (!layout.VaryingLocations.TryGetValue(declaration.Name, out location)) return; + } + + edits.Add(LayoutEdit(declaration, new (string, string)[] + { + ("location", location.ToString(CultureInfo.InvariantCulture)), + })); + } + + /// + /// Produces an edit that replaces the declaration's layout(...) clause + /// with one carrying the given keys, preserving any others it already had. + /// When there was no clause, the span is empty and this inserts one. + /// + private static Edit LayoutEdit(GlslDeclaration declaration, (string Key, string Value)[] additions) + { + var parts = new List(); + var overridden = new HashSet(StringComparer.Ordinal); + foreach ((string key, _) in additions) overridden.Add(key); + + if (declaration.LayoutQualifiers != null) + { + foreach (string raw in declaration.LayoutQualifiers.Split(',')) + { + string part = raw.Trim(); + if (part.Length == 0) continue; + + int equals = part.IndexOf('='); + string key = (equals < 0 ? part : part[..equals]).Trim(); + if (overridden.Contains(key)) continue; + + parts.Add(part); + } + } + + foreach ((string key, string value) in additions) + { + parts.Add($"{key} = {value}"); + } + + return new Edit(declaration.LayoutStart, declaration.LayoutLength, $"layout({string.Join(", ", parts)}) "); + } + + // ---------------------------------------------------------------- depth remap + + /// + /// Wraps main so clip-space depth lands in Vulkan's [0, w] range. + /// + /// Doing it here rather than by folding a correction into the projection + /// matrix keeps every matrix in the game untouched - the frustum culler, the + /// shadow orthographic projections and any matrix a mod builds all keep + /// working, and the CPU-side code never has to know which backend is running. + /// + private static void AddDepthRemapEdits(ParsedShader parsed, List edits, RewrittenShader result) + { + if (!parsed.HasMain) + { + result.Errors.Add("stage has no main() to wrap for the Vulkan depth range"); + return; + } + + edits.Add(new Edit(parsed.MainNameStart, "main".Length, MainReplacementName)); + + edits.Add(new Edit(parsed.Source.Length, 0, + "\n\nvoid main()\n{\n" + + " " + MainReplacementName + "();\n" + + " gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;\n" + + "}\n")); + } + + // --------------------------------------------------------------------- edits + + /// + /// Applies edits back to front so earlier offsets stay valid. Overlapping + /// edits are a programming error here, not a shader error, so they assert + /// rather than being silently resolved. + /// + private static string ApplyEdits(string source, List edits) + { + edits.Sort(static (a, b) => b.Start != a.Start ? b.Start.CompareTo(a.Start) : b.Length.CompareTo(a.Length)); + + var builder = new StringBuilder(source); + int previousStart = int.MaxValue; + + foreach (Edit edit in edits) + { + if (edit.Start + edit.Length > previousStart) + { + throw new InvalidOperationException( + $"overlapping shader edits at {edit.Start}..{edit.Start + edit.Length} and {previousStart}"); + } + builder.Remove(edit.Start, edit.Length); + builder.Insert(edit.Start, edit.Replacement); + previousStart = edit.Start; + } + + return builder.ToString(); + } +} diff --git a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs new file mode 100644 index 00000000..eb025501 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// One stage's input to translation. +internal sealed class ShaderStageSource +{ + public EnumShaderType Stage; + /// Include-expanded GLSL, as ShaderRegistry produces it. + public string Code = ""; + /// The program's #define block. + public string PrefixCode = ""; + public string Filename = "shader"; +} + +/// A whole program, translated and ready to become pipeline stages. +internal sealed class TranslatedProgram +{ + public ProgramInterfaceLayout Layout = new(); + public Dictionary Spirv { get; } = new(); + /// The rewritten GLSL per stage, kept for diagnostics. + public Dictionary RewrittenSource { get; } = new(); + public List Errors { get; } = new(); + public bool Success => Errors.Count == 0; +} + +/// +/// Drives a program from GLSL 330 to SPIR-V. +/// +/// The order is forced by GL's semantics rather than chosen: preprocess every +/// stage first (declarations hide behind #if), then parse them all, then +/// resolve the program-wide interface, and only then rewrite and compile. Nothing +/// can be finalised per stage because uniforms and varyings are matched by name +/// across the whole program. +/// +internal static class ShaderTranslator +{ + /// + /// Stages in the order the interface layout walks them. Fixed rather than + /// incidental, so uniform offsets and varying locations - and therefore the + /// SPIR-V cache key - are identical from run to run. + /// + private static readonly EnumShaderType[] StageOrder = + { + EnumShaderType.VertexShader, + EnumShaderType.FragmentShader, + EnumShaderType.GeometryShader, + }; + + public static TranslatedProgram Translate( + IReadOnlyList stages, + ShaderCompiler compiler, + IReadOnlyDictionary? declaredAttributes = null) + { + var program = new TranslatedProgram(); + + var ordered = new List(); + foreach (EnumShaderType stage in StageOrder) + { + foreach (ShaderStageSource candidate in stages) + { + if (candidate.Stage == stage) ordered.Add(candidate); + } + } + + // Preprocess and parse. + var parsed = new List<(EnumShaderType Stage, ParsedShader Parsed)>(); + foreach (ShaderStageSource stage in ordered) + { + ShaderCompileResult preprocessed = + compiler.Preprocess(stage.Code, stage.PrefixCode, stage.Filename, stage.Stage); + + if (!preprocessed.Success) + { + program.Errors.Add($"{stage.Filename}: preprocessing failed: {preprocessed.Error}"); + continue; + } + + // Rename identifiers 4.50 reserved before anything reads the source, + // so the parser and the rewriter both see the same names. + string source = GlslReservedWords.Rename(preprocessed.PreprocessedText); + parsed.Add((stage.Stage, GlslParser.Parse(source))); + } + + if (program.Errors.Count > 0) return program; + + program.Layout = ProgramInterfaceLayout.Build(parsed, declaredAttributes); + foreach (string error in program.Layout.Errors) + { + program.Errors.Add(error); + } + if (program.Errors.Count > 0) return program; + + // The depth remap belongs on the last stage before rasterisation. + EnumShaderType depthRemapStage = EnumShaderType.VertexShader; + foreach ((EnumShaderType stage, _) in parsed) + { + if (stage == EnumShaderType.GeometryShader) depthRemapStage = stage; + } + + // Rewrite and compile. + foreach ((EnumShaderType stage, ParsedShader shader) in parsed) + { + string filename = FilenameFor(ordered, stage); + + RewrittenShader rewritten = + ShaderRewriter.Rewrite(shader, program.Layout, stage, emitDepthRemap: stage == depthRemapStage); + + program.RewrittenSource[stage] = rewritten.Code; + + foreach (string error in rewritten.Errors) + { + program.Errors.Add($"{filename}: {error}"); + } + if (rewritten.HasErrors) continue; + + ShaderCompileResult compiled = compiler.Compile(rewritten.Code, filename, stage); + if (!compiled.Success) + { + program.Errors.Add($"{filename}: {compiled.Error}"); + continue; + } + + program.Spirv[stage] = compiled.Spirv; + } + + return program; + } + + private static string FilenameFor(IReadOnlyList stages, EnumShaderType stage) + { + foreach (ShaderStageSource candidate in stages) + { + if (candidate.Stage == stage) return candidate.Filename; + } + return stage.ToString(); + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs new file mode 100644 index 00000000..4c05878e --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -0,0 +1,1589 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan; + +/// +/// The Vulkan implementation of Optimum's graphics backend seam. +/// +/// It presents the OpenGL protocol the game and its mods were written against - +/// set state, set named uniforms on the active program, bind textures to units, +/// draw a mesh - and resolves that into Vulkan at the moment of a draw. Nothing +/// here asks the client to change how it renders, which is the whole point: the +/// alternative would break every render system and every graphics mod. +/// +/// Ownership is flat and explicit. Each manager owns one kind of object and hands +/// out integer ids, because the game's public API exposes raw GL names as fields +/// that mods read and pass back. +/// +public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice +{ + private VulkanContext _context = null!; + private VulkanCommands _setupCommands = null!; + private GlStateTracker _state = null!; + private TextureManager _textures = null!; + private MeshManager _meshes = null!; + private RenderTargetManager _targets = null!; + private GraphicsPipelineCache _pipelines = null!; + private DescriptorCache _descriptors = null!; + private FrameRing _frames = null!; + private ShaderCompiler _shaderCompiler = null!; + + private readonly Dictionary _programs = new(); + private readonly Dictionary _stagedStages = new(); + private readonly List _diagnostics = new(); + + /// + /// Whether the last draw got its own slice of the frame's uniform ring. A + /// failure means the draw fell back to whatever is at offset 0, which is a + /// silently wrong frame rather than a crash - worth being able to see. + /// + private bool _lastUniformAllocationOk = true; + + /// Sixteen bytes of float defaults followed by sixteen of int. + private const ulong DefaultAttributeBufferSize = 32; + + private VulkanBuffer? _defaultAttributes; + + /// + /// A one-texel image that stands in for any sampler the client has not bound. + /// See the placeholder note in BindDescriptors. + /// + private int _placeholderTexture; + + /// Texture bound to each unit, and any sampler overriding the texture's own state. + private readonly int[] _boundTextures = new int[GlStateTracker.MaxTextureUnits]; + private readonly Sampler[] _unitSamplerOverrides = new Sampler[GlStateTracker.MaxTextureUnits]; + + private int _nextProgramId = 1; + private bool _frameActive; + private bool _disposed; + + /// A stage that has been preprocessed but not yet linked. + private sealed class StagedStage + { + public EnumShaderType Stage; + public string Code = ""; + public string PrefixCode = ""; + public string Filename = "shader"; + } + + // ------------------------------------------------------------------ lifecycle + + public string BackendName => "Vulkan"; + + /// + /// Whether this machine can run the backend, decided without a window. + /// + /// The check has to happen before the window is created, because a window + /// opened with no graphics API cannot be handed back to OpenGL without being + /// destroyed and reopened. Creating an instance and a device is the only + /// honest way to know - driver support for the required 1.3 features is not + /// something that can be inferred from a vendor string. + /// + /// + /// Whether OPTIMUM_VULKAN_VALIDATION asks for the validation layers. + /// + /// Read once: the answer cannot change within a process, because the layers + /// are baked into the instance. + /// + private static readonly string? ValidationSetting = + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_VALIDATION"); + + private static readonly bool ValidationRequestedByEnvironment = + !string.IsNullOrEmpty(ValidationSetting); + + /// + /// Where validation messages are mirrored, when the variable names a path + /// rather than just switching the layers on. + /// + /// The client's own error channel only surfaces them when it happens to call + /// CheckGlError, and a device-lost kills the process before that; a file gets + /// the message that preceded the loss. + /// + private static readonly string? ValidationLogPath = + ValidationSetting != null && ValidationSetting.Contains('/') ? ValidationSetting : null; + + private static void MirrorValidationMessage(string message) + { + if (ValidationLogPath == null) return; + try + { + System.IO.File.AppendAllText(ValidationLogPath, message + "\n"); + } + catch (System.IO.IOException) + { + } + } + + public static bool IsSupported(out string failureReason) + { + string driver; + return IsSupported(false, out failureReason, out driver); + } + + /// + /// Probes for a usable device, and on the "auto" setting also decides whether + /// this driver is one the backend is trusted on. + /// + /// An explicit "vulkan" means the user asked for it and gets it wherever it + /// runs at all. "auto" is the setting a player never chose, so it takes the + /// backend only on driver families it has actually been exercised against; + /// everything else stays on OpenGL, which is the path that certainly works. + /// The list is deliberately about the driver rather than the GPU model: + /// behaviour that breaks a backend lives in the driver. + /// + public static bool IsSupported(bool automatic, out string failureReason, out string driverName) + { + driverName = "unknown"; + + var options = new VulkanContextOptions { Headless = true }; + if (!VulkanContext.TryCreate(options, out VulkanContext? context, out string? reason)) + { + failureReason = reason ?? "no usable Vulkan device"; + return false; + } + + try + { + driverName = context!.Capabilities.DriverName ?? "unknown"; + if (automatic && !IsAllowedForAutomaticSelection(driverName)) + { + failureReason = "the automatic setting does not select Vulkan on this driver (" + + driverName + "); set Renderer to \"vulkan\" to use it anyway"; + return false; + } + } + finally + { + context!.Dispose(); + } + + failureReason = null!; + return true; + } + + /// + /// The driver families the backend is regularly run against. Matching is on a + /// substring of the reported driver name, because vendors version the rest of + /// the string freely. + /// + private static readonly string[] AutomaticSelectionAllowList = + { + // Exercised continuously during development, both test suite and client. + "NVIDIA", + // Mesa's Intel driver, which is also what the Arc target uses on Linux. + "Intel open-source Mesa driver", + "Mesa", + // Windows Intel driver, the Claw's own. + "Intel Corporation", + // Mesa's AMD driver. + "radv", + "AMD proprietary driver", + }; + + internal static bool IsAllowedForAutomaticSelection(string driverName) + { + foreach (string allowed in AutomaticSelectionAllowList) + { + if (driverName.Contains(allowed, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + public bool Initialize(IntPtr windowHandle, int width, int height, out string failureReason) + { + bool headless = windowHandle == IntPtr.Zero; + + var options = new VulkanContextOptions + { + // A window handle of zero means no presentation surface, which is how + // capability probes and tests bring the device up. + Headless = headless, + // Validation layers are chosen when the instance is created, so the + // client's GlDebugMode setting is too late to turn them on - it is + // applied to DebugMode only after the device exists. OPTIMUM_VULKAN_VALIDATION + // is the way to get them for a real client session, which is the + // only place the world-loading paths actually run. + EnableValidation = DebugMode || ValidationRequestedByEnvironment, + DebugCallback = message => + { + _diagnostics.Add(message); + MirrorValidationMessage(message); + }, + // Surface extensions have to be enabled at instance creation, before + // any surface can exist, so the window system is asked first. + RequiredInstanceExtensions = headless + ? Array.Empty() + : WindowSurface.RequiredInstanceExtensions(), + }; + + if (!VulkanContext.TryCreate(options, out VulkanContext? context, out failureReason)) + { + return false; + } + + _context = context!; + // Any hard Vulkan failure now reaches the client's error channel and the + // validation log instead of turning into a silent stall. + VulkanResult.OnFailure = message => + { + // A failed Vulkan call is an error by definition, so it carries the + // same prefix the layers' error-severity messages do and reaches the + // client through GetError. + _diagnostics.Add(VulkanContext.ErrorPrefix + message); + MirrorValidationMessage(message); + }; + MirrorValidationMessage("--- device up on " + _context.Capabilities.DeviceName + + "; validation layers " + (_context.ValidationEnabled ? "ENABLED" : "NOT AVAILABLE")); + _setupCommands = new VulkanCommands(_context); + _state = new GlStateTracker(); + _textures = new TextureManager(_context, _setupCommands); + _meshes = new MeshManager(_context, _state); + _targets = new RenderTargetManager(_context, _textures, _state); + _pipelines = new GraphicsPipelineCache(_context); + _descriptors = new DescriptorCache(_context); + _frames = new FrameRing(_context); + _shaderCompiler = new ShaderCompiler(); + CreateDefaultAttributeBuffer(); + CreatePlaceholderTexture(); + + if (!headless) + { + if (!WindowSurface.TryCreate(_context, windowHandle, out SurfaceKHR surface, out string? surfaceError)) + { + failureReason = surfaceError ?? "could not create a presentation surface"; + return false; + } + + if (!Swapchain.TryCreate(_context, surface, (uint)width, (uint)height, _vsync, + out Swapchain? swapchain, out string? swapchainError)) + { + failureReason = swapchainError ?? "could not create a swapchain"; + return false; + } + + _swapchain = swapchain; + CreateDefaultFramebuffer((uint)width, (uint)height); + } + + failureReason = null!; + return true; + } + + private Swapchain? _swapchain; + private bool _vsync = true; + private int _defaultFramebuffer; + private int _defaultColor; + private int _defaultDepth; + private uint _windowWidth; + private uint _windowHeight; + + /// + /// The target the client renders into when it asks for the default + /// framebuffer. + /// + /// It is an ordinary offscreen target rather than the swapchain image, + /// because the game reads it back for screenshots and because presenting is + /// where the one flip happens. Rendering straight into a swapchain image + /// would put that flip in the middle of the pipeline. + /// + private void CreateDefaultFramebuffer(uint width, uint height) + { + _windowWidth = Math.Max(width, 1); + _windowHeight = Math.Max(height, 1); + + _defaultColor = _textures.Create(_windowWidth, _windowHeight, Format.R8G8B8A8Unorm); + _defaultDepth = _textures.Create(_windowWidth, _windowHeight, Format.D32Sfloat); + + _defaultFramebuffer = _targets.Create(_windowWidth, _windowHeight); + _targets.Attach(_defaultFramebuffer, 0, _defaultColor); + _targets.Attach(_defaultFramebuffer, -1, _defaultDepth); + _targets.SetDrawBuffers(_defaultFramebuffer, 0b1); + + if (RenderTrace.Enabled) + { + RenderTrace.Write("default framebuffer id=" + _defaultFramebuffer + + " " + _windowWidth + "x" + _windowHeight + + " swapchain=" + (_swapchain == null + ? "none" + : _swapchain.Extent.Width + "x" + _swapchain.Extent.Height)); + } + } + + /// + /// Builds the buffer that stands in for GL's constant generic vertex + /// attribute, holding (0, 0, 0, 1) as floats and again as integers. + /// + /// GL guarantees that value for any attribute the draw does not supply, and + /// shaders here rely on it - a vertex flags word of zero means no glow and no + /// z-offset, a damage effect of zero means no discard. Vulkan has no such + /// default, so the value has to come from somewhere real: this buffer, bound + /// at a reserved binding with stride zero so every vertex reads it. + /// + private void CreateDefaultAttributeBuffer() + { + _defaultAttributes = new VulkanBuffer(_context, DefaultAttributeBufferSize, + BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + var floats = new float[] { 0f, 0f, 0f, 1f }; + var integers = new int[] { 0, 0, 0, 1 }; + + fixed (float* source = floats) + { + System.Buffer.MemoryCopy(source, (void*)_defaultAttributes.Mapped, 16, 16); + } + fixed (int* source = integers) + { + System.Buffer.MemoryCopy(source, (void*)(_defaultAttributes.Mapped + 16), 16, 16); + } + } + + /// + /// Builds the one-texel image that fills any sampler binding the client left + /// empty. Opaque black, which is what GL reads from an unbound texture. + /// + private void CreatePlaceholderTexture() + { + var texel = new byte[] { 0, 0, 0, 255 }; + fixed (byte* pixels = texel) + { + _placeholderTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm); + _textures.Upload(_placeholderTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4); + } + } + + private void DestroyDefaultFramebuffer() + { + if (_defaultFramebuffer > 0) _targets.Delete(_defaultFramebuffer); + if (_defaultColor > 0) _textures.Delete(_defaultColor, _frames); + if (_defaultDepth > 0) _textures.Delete(_defaultDepth, _frames); + + _defaultFramebuffer = 0; + _defaultColor = 0; + _defaultDepth = 0; + } + + public string RendererString => _context?.Capabilities.DeviceName ?? "Vulkan"; + public string VendorString => _context?.Capabilities.DriverName ?? "unknown"; + public string VersionString => _context == null + ? "unknown" + : VulkanContext.VersionString(_context.Capabilities.ApiVersion); + + /// + /// Reported as a GLSL version because the client parses it to decide whether + /// a shader's #version is supported. The backend accepts everything the + /// translator accepts, which is well above what any shader in the game asks + /// for. + /// + public string ShaderVersionString => "4.50"; + + public int MaxTextureSize => (int)(_context?.Capabilities.MaxImageDimension2D ?? 0); + public bool SupportsThickLines => _context?.Capabilities.WideLines ?? false; + public bool SupportsSSBOs => true; + + public bool DebugMode { get; set; } + + /// + /// Drains queued diagnostics, reporting only what the layers called an + /// error. + /// + /// The client turns a non-null result into a thrown exception via + /// CheckGlError, so this has to mean "something is actually wrong" - the + /// GL call it stands in for, glGetError, never reported advice. Warnings are + /// still dropped into the trace for anyone reading it. + /// + public string GetError() + { + if (_diagnostics.Count == 0) return null!; + + var errors = new List(); + foreach (string diagnostic in _diagnostics) + { + if (diagnostic.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) + { + errors.Add(diagnostic); + } + } + _diagnostics.Clear(); + + return errors.Count == 0 ? null! : string.Join("\n", errors); + } + + // ---------------------------------------------------------------------- frame + + public void BeginFrame() + { + _frames.BeginFrame(); + _frameActive = true; + } + + public void Present() + { + if (!_frameActive) return; + + CommandBuffer commandBuffer = _frames.Current.CommandBuffer; + + // Any open rendering scope has to close before the command buffer ends. + _targets.EndRendering(commandBuffer); + + if (_swapchain == null) + { + // Headless: nothing to present, but the frame still has to be + // submitted or the slot's fence would never signal. + _frames.EndFrame(); + _frameActive = false; + return; + } + + if (!_swapchain.TryAcquire(out uint imageIndex, + out Semaphore imageAvailable, out Semaphore renderFinished)) + { + _frames.EndFrame(); + _frameActive = false; + RecreateSwapchain(); + return; + } + + BlitToSwapchain(commandBuffer, imageIndex); + + _frames.EndFrame(imageAvailable, renderFinished); + _frameActive = false; + + _swapchain.Present(imageIndex, renderFinished); + if (_swapchain.NeedsRecreation) RecreateSwapchain(); + } + + /// + /// Copies the rendered frame into the acquired swapchain image, flipped. + /// + /// This inverted blit is the entire Y-flip story for the backend. Everything + /// upstream stays in OpenGL's orientation, which is what keeps intermediate + /// targets and screenshots byte-identical to the GL path; the display wants + /// row 0 at the top, so the source rows are read bottom-to-top exactly once, + /// here. + /// + private void BlitToSwapchain(CommandBuffer commandBuffer, uint imageIndex) + { + VulkanTexture? source = _textures.Get(_defaultColor); + if (source == null || _swapchain == null) return; + + Vk api = _context.Api; + Image destination = _swapchain.ImageAt(imageIndex); + + _textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal); + TransitionSwapchainImage(commandBuffer, destination, + ImageLayout.Undefined, ImageLayout.TransferDstOptimal); + + var blit = new ImageBlit + { + SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + }; + // Source Y runs backwards: this is the flip. + blit.SrcOffsets.Element0 = new Offset3D(0, (int)source.Height, 0); + blit.SrcOffsets.Element1 = new Offset3D((int)source.Width, 0, 1); + blit.DstOffsets.Element0 = new Offset3D(0, 0, 0); + blit.DstOffsets.Element1 = new Offset3D((int)_swapchain.Extent.Width, (int)_swapchain.Extent.Height, 1); + + api.CmdBlitImage(commandBuffer, + source.Image, ImageLayout.TransferSrcOptimal, + destination, ImageLayout.TransferDstOptimal, + 1, &blit, Filter.Linear); + + TransitionSwapchainImage(commandBuffer, destination, + ImageLayout.TransferDstOptimal, ImageLayout.PresentSrcKhr); + } + + private void TransitionSwapchainImage( + CommandBuffer commandBuffer, Image image, ImageLayout from, ImageLayout to) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.AllCommandsBit, + SrcAccessMask = AccessFlags2.MemoryWriteBit, + DstStageMask = PipelineStageFlags2.AllCommandsBit, + DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + OldLayout = from, + NewLayout = to, + Image = image, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); + } + + private void RecreateSwapchain() + { + if (_swapchain == null || _windowWidth == 0 || _windowHeight == 0) return; + + if (!_swapchain.Recreate(_windowWidth, _windowHeight, _vsync, out string? failureReason)) + { + _diagnostics.Add("swapchain recreation failed: " + failureReason); + } + } + + public void Resize(int width, int height) + { + if (_swapchain == null || width <= 0 || height <= 0) return; + if ((uint)width == _windowWidth && (uint)height == _windowHeight) return; + + _context.Api.DeviceWaitIdle(_context.Device); + + DestroyDefaultFramebuffer(); + CreateDefaultFramebuffer((uint)width, (uint)height); + RecreateSwapchain(); + } + + public void SetVSync(bool enabled) + { + if (_vsync == enabled) return; + _vsync = enabled; + RecreateSwapchain(); + } + + private CommandBuffer Commands => _frames.Current.CommandBuffer; + + // ------------------------------------------------------------------ raw state + + public void SetViewport(int x, int y, int width, int height) => _state.SetViewport(x, y, width, height); + public void SetScissor(int x, int y, int width, int height) => _state.SetScissor(x, y, width, height); + public void SetScissorEnabled(bool enabled) => _state.SetScissorEnabled(enabled); + public bool ScissorEnabled => _state.ScissorEnabled; + + public void SetDepthTest(bool enabled) => _state.SetDepthTest(enabled); + public void SetDepthMask(bool enabled) => _state.SetDepthWrite(enabled); + public void SetDepthFunc(int func) => _state.SetDepthFunc(func); + + public void SetCullFace(bool enabled) => _state.SetCullEnabled(enabled); + public void SetCullFaceMode(bool back) => _state.SetCullBack(back); + + public void SetBlend(bool enabled, EnumBlendMode mode) => _state.SetBlend(enabled, mode); + + public void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) => + _state.SetAttachmentBlendFunc(attachment, srcColor, dstColor, srcAlpha, dstAlpha); + + public void SetBlendEquation(int attachment, int mode) => + _state.SetAttachmentBlendEquation(attachment, mode); + + public void SetColorMask(bool r, bool g, bool b, bool a) => _state.SetColorMask(r, g, b, a); + + public void SetStencilTest(bool enabled) => _state.SetStencilTest(enabled); + public void SetStencilMask(int mask) => _state.SetStencilMask(mask); + public void SetStencilFunc(int func, int refValue, int mask) => _state.SetStencilFunc(func, refValue, mask); + public void SetStencilOp(int sfail, int dpfail, int dppass) => _state.SetStencilOp(sfail, dpfail, dppass); + + public void SetWireframe(bool enabled) => _state.SetWireframe(enabled); + public void SetLineWidth(float width) => _state.SetLineWidth(width); + + // -------------------------------------------------------------------- shaders + + /// + /// Stages a shader. No SPIR-V is produced here because GL resolves uniforms + /// and varyings by name across the whole program, so nothing about a stage is + /// final until its siblings are known. + /// + public bool CompileShader(IShader shader) + { + if (shader?.Code == null) return false; + + _stagedStages[shader] = new StagedStage + { + Stage = shader.Type, + Code = shader.Code, + PrefixCode = shader.PrefixCode ?? "", + Filename = shader.Type.ToString(), + }; + return true; + } + + public int LinkProgram(IShaderProgram program) + { + var stages = new List(); + AddStage(stages, program.VertexShader, EnumShaderType.VertexShader, program.PassName); + AddStage(stages, program.FragmentShader, EnumShaderType.FragmentShader, program.PassName); + AddStage(stages, program.GeometryShader, EnumShaderType.GeometryShader, program.PassName); + + if (stages.Count == 0) + { + _diagnostics.Add($"shader program '{program.PassName}' has no stages"); + return 0; + } + + TranslatedProgram translated = ShaderTranslator.Translate(stages, _shaderCompiler); + if (!translated.Success) + { + foreach (string error in translated.Errors) + { + _diagnostics.Add($"{program.PassName}: {error}"); + } + return 0; + } + + int programId = _nextProgramId++; + if (RenderTrace.Enabled) + { + RenderTrace.DumpProgramSources(program.PassName, translated); + RenderTrace.Write("program " + programId + " '" + program.PassName + "' uniformBlockBytes=" + + translated.Layout.BlockSize); + foreach (UniformMember member in translated.Layout.Members) + { + RenderTrace.Write(" uniform " + member.Name + " offset=" + member.Offset + + " type=" + member.Type + " count=" + member.ArrayLength); + } + } + _programs[programId] = new ShaderProgramResources(_context, programId, translated); + return programId; + } + + private void AddStage(List stages, IShader? shader, EnumShaderType stage, string passName) + { + if (shader == null || !_stagedStages.TryGetValue(shader, out StagedStage? staged)) return; + + stages.Add(new ShaderStageSource + { + Stage = stage, + Code = staged.Code, + PrefixCode = staged.PrefixCode, + Filename = passName + StageExtension(stage), + }); + } + + private static string StageExtension(EnumShaderType stage) => stage switch + { + EnumShaderType.VertexShader => ".vsh", + EnumShaderType.FragmentShader => ".fsh", + _ => ".gsh", + }; + + public void DeleteProgram(int programId) + { + if (!_programs.Remove(programId, out ShaderProgramResources? program)) return; + _frames.DeferDeletion(program); + } + + public void UseProgram(int programId) => _state.SetProgram(programId); + + public int GetUniformLocation(int programId, string name) => + _programs.TryGetValue(programId, out ShaderProgramResources? program) ? program.LocationOf(name) : -1; + + // ------------------------------------------------------------------- uniforms + + private void Write(int programId, int location, ReadOnlySpan data) + { + if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) + { + program.SetUniform(location, data); + } + } + + public void SetUniform(int programId, int location, float value) => + Write(programId, location, new ReadOnlySpan(&value, sizeof(float))); + + public void SetUniform(int programId, int location, int value) + { + // Assigning a sampler its texture unit is an int write to its uniform + // location in GL. Here the sampler is a descriptor binding, so the same + // call has to reach the unit table instead of the uniform block. + if (ShaderProgramResources.IsSamplerLocation(location)) + { + if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) + { + program.SetSamplerUnitByLocation(location, value); + } + return; + } + Write(programId, location, new ReadOnlySpan(&value, sizeof(int))); + } + + public void SetUniform(int programId, int location, float x, float y) + { + float* values = stackalloc float[2] { x, y }; + Write(programId, location, new ReadOnlySpan(values, 2 * sizeof(float))); + } + + public void SetUniform(int programId, int location, float x, float y, float z) + { + float* values = stackalloc float[3] { x, y, z }; + Write(programId, location, new ReadOnlySpan(values, 3 * sizeof(float))); + } + + public void SetUniform(int programId, int location, float x, float y, float z, float w) + { + float* values = stackalloc float[4] { x, y, z, w }; + Write(programId, location, new ReadOnlySpan(values, 4 * sizeof(float))); + } + + // The array setters are a straight memcpy because the generated block uses + // scalar layout, where a float[] packs exactly as the shader expects. Under + // std140 each of these would need re-striding on the way in. + private void WriteArray(int programId, int location, int count, float[] values, int componentsPerElement) + { + int floats = Math.Min(values.Length, count * componentsPerElement); + if (floats <= 0) return; + + fixed (float* source = values) + { + Write(programId, location, new ReadOnlySpan(source, floats * sizeof(float))); + } + } + + public void SetUniformArray1(int programId, int location, int count, float[] values) => + WriteArray(programId, location, count, values, 1); + + public void SetUniformArray2(int programId, int location, int count, float[] values) => + WriteArray(programId, location, count, values, 2); + + public void SetUniformArray3(int programId, int location, int count, float[] values) => + WriteArray(programId, location, count, values, 3); + + public void SetUniformArray4(int programId, int location, int count, float[] values) => + WriteArray(programId, location, count, values, 4); + + public void SetUniformMatrix(int programId, int location, float[] matrix) => + WriteArray(programId, location, 1, matrix, 16); + + public void SetUniformMatrices(int programId, int location, int count, float[] matrices) => + WriteArray(programId, location, count, matrices, 16); + + public void SetUniformMatrices4x3(int programId, int location, int count, float[] matrices) => + WriteArray(programId, location, count, matrices, 12); + + /// + /// The samplers a linked program declares, in declaration order. + /// + /// Not part of the seam - the client never needs it, because it binds the + /// samplers it knows by name. It exists so a test can bind every sampler a + /// real program declares without hardcoding the list, since a draw whose + /// descriptor set is incomplete is skipped rather than drawn. + /// + internal List SamplerNamesOf(int programId) + { + var names = new List(); + if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) + { + foreach (SamplerBinding sampler in program.Interface.Samplers) names.Add(sampler.Name); + } + return names; + } + + public void SetSamplerUnit(int programId, string samplerName, int unit) + { + if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) + { + program.SamplerUnits[samplerName] = unit; + } + } + + // ------------------------------------------------------------ uniform buffers + + private readonly Dictionary _uniformBuffers = new(); + private int _nextUniformBufferId = 1; + + public int CreateUniformBuffer(int programId, int bindingPoint, string blockName, int size) + { + var buffer = new VulkanBuffer(_context, (ulong)Math.Max(size, 4), + BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + int id = _nextUniformBufferId++; + _uniformBuffers[id] = buffer; + return id; + } + + public void UpdateUniformBuffer(int handle, IntPtr data, int offset, int size) + { + if (!_uniformBuffers.TryGetValue(handle, out VulkanBuffer? buffer)) return; + if (buffer.Mapped == IntPtr.Zero || data == IntPtr.Zero) return; + if ((ulong)(offset + size) > buffer.Size) return; + + System.Buffer.MemoryCopy((void*)data, (void*)(buffer.Mapped + offset), size, size); + } + + public void BindUniformBuffer(int handle) { } + public void UnbindUniformBuffer(int handle) { } + + public void DeleteUniformBuffer(int handle) + { + if (_uniformBuffers.Remove(handle, out VulkanBuffer? buffer)) + { + _frames.DeferDeletion(buffer); + } + } + + // -------------------------------------------------------------------- textures + + public int CreateTexture2D( + int width, int height, EnumTextureInternalFormat internalFormat, + EnumTexturePixelFormat pixelFormat, IntPtr pixels, bool generateMipmaps) + { + int id = _textures.Create((uint)width, (uint)height, + GlEnums.TextureFormatFrom(internalFormat), generateMipmaps: generateMipmaps); + + if (pixels != IntPtr.Zero) + { + _textures.Upload(id, 0, 0, 0, (uint)width, (uint)height, pixels, BytesPerPixel(internalFormat)); + if (generateMipmaps) _textures.GenerateMipmaps(id); + } + return id; + } + + public int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPtr pixels, int bytesPerPixel, + bool generateMipmaps = false) + { + Format format = GlEnums.TextureFormatFromGl(glInternalFormat); + int id = _textures.Create((uint)width, (uint)height, format, generateMipmaps: generateMipmaps); + + if (pixels != IntPtr.Zero && bytesPerPixel > 0) + { + _textures.Upload(id, 0, 0, 0, (uint)width, (uint)height, pixels, bytesPerPixel); + } + RenderTrace.TextureCreated(id, width, height, format, pixels, bytesPerPixel); + return id; + } + + public int CreateTextureCubeRaw(int size, int glInternalFormat, IntPtr[] facePixels, int bytesPerPixel) + { + Format format = GlEnums.TextureFormatFromGl(glInternalFormat); + int id = _textures.Create((uint)size, (uint)size, format, cube: true); + + for (uint face = 0; face < 6 && face < facePixels.Length; face++) + { + if (facePixels[face] == IntPtr.Zero) continue; + _textures.Upload(id, 0, 0, 0, (uint)size, (uint)size, + facePixels[face], bytesPerPixel, face); + } + return id; + } + + public int CreateTextureCube( + int size, EnumTextureInternalFormat internalFormat, + EnumTexturePixelFormat pixelFormat, IntPtr[] facePixels) + { + Format format = GlEnums.TextureFormatFrom(internalFormat); + int id = _textures.Create((uint)size, (uint)size, format, cube: true); + + for (uint face = 0; face < 6 && face < facePixels.Length; face++) + { + if (facePixels[face] == IntPtr.Zero) continue; + _textures.Upload(id, 0, 0, 0, (uint)size, (uint)size, + facePixels[face], BytesPerPixel(internalFormat), face); + } + return id; + } + + public int CreateTexture2DArray( + int width, int height, int layers, + EnumTextureInternalFormat internalFormat, EnumTexturePixelFormat pixelFormat) => + _textures.Create((uint)width, (uint)height, + GlEnums.TextureFormatFrom(internalFormat), layers: (uint)layers); + + public void UploadTexture2D( + int textureId, int level, int x, int y, int width, int height, + EnumTexturePixelFormat pixelFormat, IntPtr pixels) => + _textures.Upload(textureId, level, x, y, (uint)width, (uint)height, pixels, + pixelFormat == EnumTexturePixelFormat.Red ? 1 : 4); + + public void GenerateMipmaps(int textureId) => _textures.GenerateMipmaps(textureId); + + public void DeleteTexture(int textureId) => _textures.Delete(textureId, _frames); + + public void SetTextureParameter(int textureId, int parameterName, int value) => + _textures.SetParameter(textureId, parameterName, value); + + public void SetTextureParameter(int textureId, int parameterName, float value) => + _textures.SetParameter(textureId, parameterName, value); + + public int GetTextureParameter(int textureId, int parameterName) + { + VulkanTexture? texture = _textures.Get(textureId); + if (texture == null) return 0; + + return parameterName == GlEnums.TextureCompareMode + ? texture.State.CompareEnable ? GlEnums.TextureCompareRefToTexture : GlEnums.TextureCompareModeNone + : 0; + } + + public void BindTexture(int unit, int textureId) + { + if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; + _boundTextures[unit] = textureId; + } + + public void BindTextureCube(int unit, int textureId) => BindTexture(unit, textureId); + + private readonly Dictionary _standaloneSamplers = new(); + private int _nextSamplerId = 1; + + public int CreateSampler(bool linear) + { + int id = _nextSamplerId++; + _standaloneSamplers[id] = SamplerState.Default with + { + MagFilter = linear ? Filter.Linear : Filter.Nearest, + MinFilter = linear ? Filter.Linear : Filter.Nearest, + MipmapMode = linear ? SamplerMipmapMode.Linear : SamplerMipmapMode.Nearest, + }; + return id; + } + + public void SetSamplerParameter(int samplerId, int parameterName, float value) + { + if (!_standaloneSamplers.TryGetValue(samplerId, out SamplerState state)) return; + + _standaloneSamplers[samplerId] = parameterName == GlEnums.TextureLodBias + ? state with { LodBias = value } + : state; + } + + public void BindSampler(int unit, int samplerId) + { + if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; + + _unitSamplerOverrides[unit] = samplerId > 0 && _standaloneSamplers.TryGetValue(samplerId, out SamplerState state) + ? _textures.Samplers.Get(state) + : default; + } + + public void DeleteSampler(int samplerId) => _standaloneSamplers.Remove(samplerId); + + private static int BytesPerPixel(EnumTextureInternalFormat format) => format switch + { + EnumTextureInternalFormat.Rgba8 => 4, + EnumTextureInternalFormat.Rgba16f => 8, + EnumTextureInternalFormat.R16f => 2, + EnumTextureInternalFormat.DepthComponent32 => 4, + _ => 4, + }; + + // ---------------------------------------------------------------- framebuffers + + public int CreateFramebuffer(int width, int height) => _targets.Create((uint)width, (uint)height); + + public void AttachTexture(int framebufferId, EnumFramebufferAttachment attachment, int textureId, int layer) + { + int index = attachment == EnumFramebufferAttachment.DepthAttachment + ? -1 + : (int)attachment - (int)EnumFramebufferAttachment.ColorAttachment0; + + _targets.Attach(framebufferId, index, textureId, (uint)layer); + } + + public void SetDrawBuffers(int framebufferId, int attachmentMask) => + _targets.SetDrawBuffers(framebufferId, (uint)attachmentMask); + + public bool CheckFramebufferComplete(int framebufferId, out string status) + { + // Dynamic rendering has no framebuffer object to validate, so + // completeness reduces to having a target with attachments. + VulkanFramebuffer? framebuffer = _targets.Get(framebufferId); + if (framebuffer == null) + { + status = "no such framebuffer"; + return false; + } + + status = "complete"; + return true; + } + + public void BindFramebuffer(int framebufferId) + { + if (_frameActive) _targets.Bind(Commands, framebufferId); + } + + public void BindDefaultFramebuffer() + { + if (_frameActive) _targets.Bind(Commands, _defaultFramebuffer); + } + + public void DeleteFramebuffer(int framebufferId) => _targets.Delete(framebufferId); + + public void ClearColor(int attachment, float r, float g, float b, float a) + { + if (RenderTrace.Enabled) + { + RenderTrace.Write("clearColor attachment=" + attachment + " target=" + + (_targets.Bound?.Id ?? -1) + " rgba=" + r + "," + g + "," + b + "," + a); + } + if (_frameActive) _targets.ClearColor(Commands, attachment, r, g, b, a); + } + + public void ClearDepth(float depth) + { + if (RenderTrace.Enabled) + { + RenderTrace.Write("clearDepth target=" + (_targets.Bound?.Id ?? -1) + " depth=" + depth); + } + if (_frameActive) _targets.ClearDepth(Commands, depth); + } + + public void ClearStencil() { } + + // --------------------------------------------------------------------- meshes + + public int CreateMesh(MeshData data, bool staticDraw) + { + int vertices = data.VerticesCount; + int id = _meshes.CreateEmpty( + data.xyz != null ? vertices * 3 * sizeof(float) : 0, + data.Normals != null ? vertices * sizeof(int) : 0, + data.Uv != null ? vertices * 2 * sizeof(float) : 0, + data.Rgba != null ? vertices * 4 : 0, + data.Flags != null ? vertices * sizeof(int) : 0, + data.IndicesCount * sizeof(int), + data.CustomFloats, data.CustomShorts, data.CustomBytes, data.CustomInts, + data.mode, staticDraw, ssbo: false); + + UpdateMesh(id, data); + return id; + } + + public int CreateEmptyMesh( + int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, + CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, + CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, + EnumDrawMode drawMode, bool staticDraw, bool ssbo) => + _meshes.CreateEmpty(xyzSize, normalsSize, uvSize, rgbaSize, flagsSize, indicesSize, + customFloats, customShorts, customBytes, customInts, drawMode, staticDraw, ssbo); + + public void UpdateMesh(int meshId, MeshData data) + { + int vertices = data.VerticesCount; + + if (data.xyz != null) + { + fixed (float* source = data.xyz) + { + _meshes.Write(meshId, MeshManager.BufferXyz, 0, (IntPtr)source, vertices * 3 * sizeof(float)); + } + } + if (data.Uv != null) + { + fixed (float* source = data.Uv) + { + _meshes.Write(meshId, MeshManager.BufferUv, 0, (IntPtr)source, vertices * 2 * sizeof(float)); + } + } + if (data.Rgba != null) + { + fixed (byte* source = data.Rgba) + { + _meshes.Write(meshId, MeshManager.BufferRgba, 0, (IntPtr)source, vertices * 4); + } + } + if (data.Flags != null) + { + fixed (int* source = data.Flags) + { + _meshes.Write(meshId, MeshManager.BufferFlags, 0, (IntPtr)source, vertices * sizeof(int)); + } + } + if (data.Normals != null) + { + fixed (int* source = data.Normals) + { + _meshes.Write(meshId, MeshManager.BufferNormals, 0, (IntPtr)source, vertices * sizeof(int)); + } + } + if (data.Indices != null) + { + fixed (int* source = data.Indices) + { + _meshes.Write(meshId, -1, 0, (IntPtr)source, data.IndicesCount * sizeof(int)); + } + } + } + + /// + /// The SSBO chunk path packs four vertices into one face record and stores + /// them in the xyz slot, which CreateEmptyMesh gave StorageBufferBit usage + /// and no vertex-attribute binding when ssbo was set. Writing it is a plain + /// buffer write; the shader reads it through gl_VertexIndex. + /// + public void UpdateMeshStorageBuffer(int meshId, IntPtr data, int byteOffset, int byteSize) => + _meshes.Write(meshId, MeshManager.BufferXyz, byteOffset, data, byteSize); + + public IntPtr GetMappedPointer(int meshId, EnumMeshBufferPart part) => part switch + { + EnumMeshBufferPart.Xyz => _meshes.MappedPointer(meshId, MeshManager.BufferXyz), + EnumMeshBufferPart.Normals => _meshes.MappedPointer(meshId, MeshManager.BufferNormals), + EnumMeshBufferPart.Uv => _meshes.MappedPointer(meshId, MeshManager.BufferUv), + EnumMeshBufferPart.Rgba => _meshes.MappedPointer(meshId, MeshManager.BufferRgba), + EnumMeshBufferPart.Flags => _meshes.MappedPointer(meshId, MeshManager.BufferFlags), + EnumMeshBufferPart.CustomFloats => _meshes.MappedPointer(meshId, MeshManager.BufferCustomFloat), + EnumMeshBufferPart.CustomShorts => _meshes.MappedPointer(meshId, MeshManager.BufferCustomShort), + EnumMeshBufferPart.CustomInts => _meshes.MappedPointer(meshId, MeshManager.BufferCustomInt), + EnumMeshBufferPart.CustomBytes => _meshes.MappedPointer(meshId, MeshManager.BufferCustomByte), + _ => _meshes.MappedPointer(meshId, -1), + }; + + public void DeleteMesh(int meshId) => _meshes.Delete(meshId, _frames); + + // ---------------------------------------------------------------------- draws + + public void DrawMesh(int meshId) => DrawMeshInstanced(meshId, 1); + + public void DrawMeshInstanced(int meshId, int instanceCount) + { + if (!PrepareDraw(_meshes.LayoutIdOf(meshId), out CommandBuffer commandBuffer)) return; + if (RenderTrace.Enabled) + { + RenderTrace.Write("draw mesh=" + meshId + " program=" + _state.CurrentProgram + + " indices=" + (_meshes.Get(meshId)?.IndexCount ?? -1) + + " tex0=" + _boundTextures[0] + + " target=" + (_targets.Bound?.Id ?? -1) + + " depthTest=" + _state.DepthTest + " depthWrite=" + _state.DepthWrite + + " depthFunc=" + _state.DepthCompare + " blend=" + _state.BlendFor(0).Enabled + + " cull=" + _state.CullEnabled + "/" + _state.CullMode + + " scissor=" + _state.ScissorEnabled + + " viewport=" + _state.Viewport.Offset.X + "," + _state.Viewport.Offset.Y + " " + + _state.Viewport.Extent.Width + "x" + _state.Viewport.Extent.Height + + " blendSrc=" + _state.BlendFor(0).SrcColor + " blendDst=" + _state.BlendFor(0).DstColor + + " uniforms=" + _lastUniformAllocationOk); + } + _meshes.Draw(commandBuffer, meshId, instanceCount); + } + + public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo) + { + if (!PrepareDraw(_meshes.LayoutIdOf(meshId), out CommandBuffer commandBuffer)) return; + + VulkanBuffer indirect = EnsureIndirectScratch(groupCount); + _meshes.DrawMulti(commandBuffer, meshId, indicesStarts, indicesSizes, groupCount, indirect); + } + + public void DrawFullscreenTriangle() + { + if (!PrepareDraw(MeshManager.EmptyLayoutId, out CommandBuffer commandBuffer)) return; + if (RenderTrace.Enabled) + { + RenderTrace.Write("fullscreen program=" + _state.CurrentProgram + + " tex0=" + _boundTextures[0] + " target=" + (_targets.Bound?.Id ?? -1)); + } + _context.Api.CmdDraw(commandBuffer, 3, 1, 0, 0); + } + + /// + /// Resolves everything a draw needs: the rendering scope, the pipeline for + /// the current state, the descriptor sets for the bound textures, the uniform + /// upload, and the dynamic state. This is where the recorded GL state finally + /// becomes Vulkan commands. + /// + private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) + { + commandBuffer = default; + if (!_frameActive) + { + if (RenderTrace.Enabled) RenderTrace.Write("draw skipped: no active frame"); + return false; + } + + VulkanFramebuffer? target = _targets.Bound; + if (target == null) + { + if (RenderTrace.Enabled) RenderTrace.Write("draw skipped: no bound render target"); + return false; + } + + if (!_programs.TryGetValue(_state.CurrentProgram, out ShaderProgramResources? program)) + { + if (RenderTrace.Enabled) + { + RenderTrace.Write("draw skipped: program " + _state.CurrentProgram + " not resident"); + } + return false; + } + + commandBuffer = Commands; + _targets.EnsureRendering(commandBuffer); + + int formatsId = _targets.FormatsIdOf(target); + RenderTargetFormats formats = _state.TargetFormats(formatsId); + int attachmentCount = _targets.EnabledAttachmentCount(target); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = _state.BlendFor(i); + + // A mesh that no longer exists reports -1; falling back to the reserved + // empty layout keeps the key valid rather than indexing past the interner. + int layoutId = vertexLayoutId >= 0 ? vertexLayoutId : MeshManager.EmptyLayoutId; + + // GL supplies a constant for any attribute the mesh does not carry; this + // is where that promise is kept. The pipeline key already names both the + // program and the mesh layout, so the merged result is stable per entry. + VertexLayoutDescription meshLayout = _meshes.LayoutOf(layoutId); + VertexLayoutDescription vertexLayout = meshLayout.WithDefaultsFor(program.Interface.VertexInputs); + if (RenderTrace.Enabled && !ReferenceEquals(meshLayout, vertexLayout)) + { + RenderTrace.Write(" defaults added: mesh had " + meshLayout.Attributes.Length + + " attributes, program declares " + program.Interface.VertexInputs.Count + + ", merged " + vertexLayout.Attributes.Length); + } + + Pipeline pipeline = _pipelines.Get( + _state.BuildKey(layoutId, formatsId, attachmentCount), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = vertexLayout, + Targets = formats, + Blend = blend, + PolygonMode = _state.PolygonMode, + Topology = _state.Topology, + }); + + Vk api = _context.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + // The mesh binds its own buffers from zero; the defaults sit above them + // and are bound whenever the pipeline actually declares that binding. + if (vertexLayout.Bindings.Length > 0 && + vertexLayout.Bindings[^1].Binding == VertexLayoutDescription.DefaultAttributeBinding && + _defaultAttributes != null) + { + Buffer defaults = _defaultAttributes.Handle; + ulong offset = 0; + api.CmdBindVertexBuffers(commandBuffer, + VertexLayoutDescription.DefaultAttributeBinding, 1, &defaults, &offset); + } + + BindDescriptors(commandBuffer, program); + ApplyDynamicState(commandBuffer, target); + return true; + } + + private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program) + { + Vk api = _context.Api; + + // Set 0: the generated uniform block, uploaded into this frame's ring and + // reached through a dynamic offset so the set itself never changes. + uint dynamicOffset = 0; + if (program.Interface.HasUniformBlock) + { + _lastUniformAllocationOk = + _frames.Current.TryAllocateUniforms(program.UniformShadow.Length, out RingAllocation allocation); + if (_lastUniformAllocationOk) + { + fixed (byte* source = program.UniformShadow) + { + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, + program.UniformShadow.Length, program.UniformShadow.Length); + } + dynamicOffset = allocation.Offset; + program.MarkUniformsClean(); + } + + var uniformContents = new DescriptorSetContents( + program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, + Array.Empty(), + new[] + { + new BufferBindingValue( + ProgramInterfaceLayout.DefaultBlockBinding, + _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length), + }); + + DescriptorSet uniformSet = _descriptors.Get( + uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); + + uint offset = dynamicOffset; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, 1, &offset); + } + + // Set 1: one combined image sampler per declared sampler, resolved through + // the unit each sampler uniform points at. + if (program.Interface.Samplers.Count > 0) + { + var bindings = new SamplerBindingValue[program.Interface.Samplers.Count]; + for (int i = 0; i < bindings.Length; i++) + { + SamplerBinding declared = program.Interface.Samplers[i]; + int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) + ? mapped + : declared.Binding; + + ImageView view = default; + Sampler sampler = default; + + if ((uint)unit < GlStateTracker.MaxTextureUnits) + { + VulkanTexture? texture = _textures.Get(_boundTextures[unit]); + if (texture != null) + { + view = texture.View; + // A sampler bound to the unit overrides the texture's own + // state, which is what glBindSampler means. + sampler = _unitSamplerOverrides[unit].Handle != 0 + ? _unitSamplerOverrides[unit] + : _textures.Samplers.Get(texture.State); + } + } + + bindings[i] = new SamplerBindingValue((uint)declared.Binding, view, sampler); + } + + // A sampler the client left unbound gets the placeholder rather than + // an empty descriptor. Leaving the set unbound is not an option: the + // shader statically uses set 1, and drawing without it is undefined + // behaviour that costs the device rather than one texture. GL is + // permissive here - sampling an unbound texture reads black and the + // draw proceeds - so the placeholder is also the closer emulation. + VulkanTexture? placeholder = _textures.Get(_placeholderTexture); + for (int i = 0; i < bindings.Length; i++) + { + if (bindings[i].View.Handle != 0 && bindings[i].Sampler.Handle != 0) continue; + if (placeholder == null) break; + + bindings[i] = new SamplerBindingValue( + bindings[i].Binding, placeholder.View, _textures.Samplers.Get(placeholder.State)); + } + + bool complete = true; + foreach (SamplerBindingValue binding in bindings) + { + if (binding.View.Handle == 0 || binding.Sampler.Handle == 0) { complete = false; break; } + } + + if (complete) + { + DescriptorSet samplerSet = _descriptors.Get( + new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.SamplerSet, + bindings, Array.Empty()), + program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); + + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); + } + else if (RenderTrace.Enabled) + { + RenderTrace.Write("draw with an incomplete sampler set on program " + program.ProgramId); + } + } + } + + private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target) + { + Vk api = _context.Api; + + Rect2D viewport = _state.Viewport; + var vulkanViewport = new Viewport( + viewport.Offset.X, viewport.Offset.Y, + viewport.Extent.Width, viewport.Extent.Height, 0f, 1f); + api.CmdSetViewport(commandBuffer, 0, 1, &vulkanViewport); + + // GL leaves the whole target writable when the scissor test is off; + // Vulkan always has a scissor, so "off" becomes the full target. + Rect2D scissor = _state.ScissorEnabled + ? _state.Scissor + : new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + + api.CmdSetCullMode(commandBuffer, _state.CullEnabled ? _state.CullMode : CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, _state.Topology); + + api.CmdSetDepthTestEnable(commandBuffer, _state.DepthTest); + api.CmdSetDepthWriteEnable(commandBuffer, _state.DepthWrite); + api.CmdSetDepthCompareOp(commandBuffer, _state.DepthCompare); + + api.CmdSetStencilTestEnable(commandBuffer, _state.StencilTest); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + _state.StencilFail, _state.StencilPass, _state.StencilDepthFail, _state.StencilCompare); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, _state.StencilCompareMask); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, _state.StencilWriteMask); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, _state.StencilReference); + + api.CmdSetLineWidth(commandBuffer, _context.Capabilities.WideLines ? _state.LineWidth : 1.0f); + } + + private VulkanBuffer? _indirectScratch; + + private VulkanBuffer EnsureIndirectScratch(int groupCount) + { + ulong needed = (ulong)Math.Max(groupCount, 1) * (ulong)sizeof(DrawIndexedIndirectCommand); + if (_indirectScratch != null && _indirectScratch.Size >= needed) return _indirectScratch; + + if (_indirectScratch != null) _frames.DeferDeletion(_indirectScratch); + + _indirectScratch = new VulkanBuffer(_context, Math.Max(needed * 2, 4096), + BufferUsageFlags.IndirectBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + return _indirectScratch; + } + + // -------------------------------------------------------------------- queries + + private readonly Dictionary _queries = new(); + private int _nextQueryId = 1; + + public int CreateOcclusionQuery() + { + var createInfo = new QueryPoolCreateInfo + { + SType = StructureType.QueryPoolCreateInfo, + QueryType = QueryType.Occlusion, + QueryCount = 1, + }; + _context.Api.CreateQueryPool(_context.Device, &createInfo, null, out QueryPool pool); + + int id = _nextQueryId++; + _queries[id] = pool; + return id; + } + + public void BeginOcclusionQuery(int queryId) + { + if (!_frameActive || !_queries.TryGetValue(queryId, out QueryPool pool)) return; + + _context.Api.CmdResetQueryPool(Commands, pool, 0, 1); + _context.Api.CmdBeginQuery(Commands, pool, 0, 0); + } + + public void EndOcclusionQuery(int queryId) + { + if (_frameActive && _queries.TryGetValue(queryId, out QueryPool pool)) + { + _context.Api.CmdEndQuery(Commands, pool, 0); + } + } + + public bool IsQueryResultAvailable(int queryId) + { + if (!_queries.TryGetValue(queryId, out QueryPool pool)) return false; + + ulong result = 0; + Result status = _context.Api.GetQueryPoolResults( + _context.Device, pool, 0, 1, sizeof(ulong), &result, sizeof(ulong), QueryResultFlags.Result64Bit); + return status == Result.Success; + } + + public int GetQueryResult(int queryId) + { + if (!_queries.TryGetValue(queryId, out QueryPool pool)) return 0; + + ulong result = 0; + _context.Api.GetQueryPoolResults( + _context.Device, pool, 0, 1, sizeof(ulong), &result, sizeof(ulong), + QueryResultFlags.Result64Bit | QueryResultFlags.ResultWaitBit); + return (int)Math.Min(result, int.MaxValue); + } + + public void DeleteQuery(int queryId) + { + if (_queries.Remove(queryId, out QueryPool pool)) + { + _context.Api.DestroyQueryPool(_context.Device, pool, null); + } + } + + // ------------------------------------------------------------------- readback + + /// + /// Reads back the bound target's first colour attachment as BGRA8, rows + /// bottom-up. + /// + /// Bottom-up is not an accident: it is what glReadPixels produces, and + /// the existing screenshot and AVI paths already expect it. Because the + /// backend never flips Y, the image in memory is laid out exactly as GL laid + /// it out, so those paths keep working untouched. + /// + public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) + { + if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; + + VulkanFramebuffer? target = _targets.Bound; + if (target == null) return; + + VulkanTexture? texture = _textures.Get(target.Color[0].TextureId); + if (texture == null) return; + + // Readback has to see finished work, so any recording frame is closed + // out first rather than racing it. + if (_frameActive) Present(); + _context.Api.DeviceWaitIdle(_context.Device); + + ulong bytes = (ulong)width * (ulong)height * 4; + using var readback = new VulkanBuffer(_context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + ImageLayout restore = texture.Layout; + _setupCommands.SubmitAndWait(commandBuffer => + { + _textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageOffset = new Offset3D(x, y, 0), + ImageExtent = new Extent3D((uint)width, (uint)height, 1), + }; + _context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + System.Buffer.MemoryCopy((void*)readback.Mapped, (void*)destination, (long)bytes, (long)bytes); + + if (restore != ImageLayout.Undefined) + { + _setupCommands.SubmitAndWait(commandBuffer => + _textures.TransitionTexture(commandBuffer, texture, restore)); + } + } + + // ------------------------------------------------------------------- teardown + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (_context != null) + { + _context.Api.DeviceWaitIdle(_context.Device); + } + + foreach (ShaderProgramResources program in _programs.Values) program.Dispose(); + _programs.Clear(); + + foreach (VulkanBuffer buffer in _uniformBuffers.Values) buffer.Dispose(); + _uniformBuffers.Clear(); + + foreach (QueryPool pool in _queries.Values) + { + _context?.Api.DestroyQueryPool(_context.Device, pool, null); + } + _queries.Clear(); + + _indirectScratch?.Dispose(); + _defaultAttributes?.Dispose(); + _swapchain?.Dispose(); + _shaderCompiler?.Dispose(); + _frames?.Dispose(); + _descriptors?.Dispose(); + _pipelines?.Dispose(); + _targets?.Dispose(); + _meshes?.Dispose(); + _textures?.Dispose(); + _setupCommands?.Dispose(); + _context?.Dispose(); + } +} diff --git a/Optimum.Tests/AssemblyInfo.cs b/Optimum.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..6ee3b31f --- /dev/null +++ b/Optimum.Tests/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using Xunit; + +// Twelve test classes in this assembly mutate OptimumDiagnostics, whose counters +// are plain statics shared by the whole process while the recording context is +// [ThreadStatic]. Several also flip StutterWatchEnabled process-wide, which makes +// production animation and launch-task code record into those same counters from +// whatever else happens to be running. +// +// xunit runs collections in parallel by default, so those classes raced: over +// repeated full runs the failure moved between EntityAnimationDiagnosticsCoverage, +// LaunchTaskTimeBudget and OptimumStatus, always as a count that did not match. +// The race predates the Vulkan backend work; adding test classes changed the +// scheduling enough to surface it nearly every run. +// +// Serialising the assembly removes the whole class of failure for a suite that +// runs in well under a second. The better long-term fix is to scope the counters +// per context so the tests do not share global state at all, at which point this +// can go. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Optimum.Tests/fsr-pipeline-coverage-tests.cs b/Optimum.Tests/fsr-pipeline-coverage-tests.cs index 3d9c2956..7b3c29a0 100644 --- a/Optimum.Tests/fsr-pipeline-coverage-tests.cs +++ b/Optimum.Tests/fsr-pipeline-coverage-tests.cs @@ -78,9 +78,15 @@ public void TerrainBiasCoversTextureObjectsAndCustomSamplers() // TexParameter/SamplerParameter calls at all. Assert.Contains("if (ClientSettings.OptimumRenderScale >= 1.0f)", chunkRenderer); Assert.Contains("MathF.Log2(Math.Clamp(Vintagestory.API.Config.OptimumConfig.EffectiveRenderScale, 0.5f, 1.0f))", chunkRenderer); - Assert.Contains("(TextureParameterName)34049, textureLodBias", chunkRenderer); + // The bias reaches every block atlas through SetOptimumTextureLodBias, + // which routes to the device and keeps the GL call as its fallback. The + // caller still computes the value; only the application moved. + Assert.Contains("SetOptimumTextureLodBias(textureLodBias)", chunkRenderer); + Assert.Contains("(TextureParameterName)34049, bias", chunkRenderer); + Assert.Contains("OptimumGlConstants.TextureLodBias, bias", chunkRenderer); Assert.Contains("if (OptimumConfig.EffectiveRenderScale < 1.0f)", shaderRegistry); Assert.Contains("(SamplerParameterName)34049, terrainLodBias", shaderRegistry); + Assert.Contains("OptimumGlConstants.TextureLodBias, terrainLodBias", shaderRegistry); Assert.Contains("terrainTexLinear", shaderRegistry); } diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs new file mode 100644 index 00000000..3998bf72 --- /dev/null +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -0,0 +1,427 @@ +using System; +using System.IO; +using System.Linq; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Pins the shape of the Vulkan backend's integration with the client. +/// +/// The backend itself is covered by Optimum.Render.Vulkan.Tests, which needs a +/// GPU. What is checked here is the wiring that reaches into vanilla code, where +/// a mistake is silent rather than loud: a transplant that stops compiling, a +/// duplicate type that only surfaces on a full build, or a backend that stops +/// being reachable because the launcher no longer ships its assembly. +/// +public class VulkanBackendIntegrationTests +{ + private const string ClientProgramPatch = + "patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch"; + + /// + /// The renderer has to be chosen before the window opens. A window created + /// with no graphics API cannot be handed back to OpenGL without being + /// destroyed, so a decision made after AttemptToOpenWindow would be + /// too late to act on cheaply. + /// + [Fact] + public void TheRendererIsChosenBeforeTheWindowIsCreated() + { + string patch = Read(ClientProgramPatch); + string added = AddedLines(patch); + + Assert.Contains("OptimumRenderBootstrap.ShouldTryVulkan", added); + Assert.Contains("ContextAPI.NoAPI", added); + + int decision = added.IndexOf("ShouldTryVulkan", StringComparison.Ordinal); + int noApi = added.IndexOf("ContextAPI.NoAPI", StringComparison.Ordinal); + int install = added.IndexOf("OptimumRenderBootstrap.Install", StringComparison.Ordinal); + + Assert.True(decision < noApi, "the backend decision must precede the API choice"); + Assert.True(noApi < install, "the window must be configured before the device is installed"); + } + + /// + /// If the device cannot be created after the probe passed, the window has no + /// graphics API and is unusable for OpenGL. It has to be replaced, or the + /// client renders GL calls into nothing. + /// + [Fact] + public void AFailedInstallReopensTheWindowForOpenGl() + { + string added = AddedLines(Read(ClientProgramPatch)); + + Assert.Contains("OptimumRenderBootstrap.Install", added); + Assert.Contains("OptimumRender.FallBackToOpenGL", added); + Assert.Contains("ContextAPI.OpenGL", added); + Assert.Contains("AttemptToOpenWindow", added); + } + + /// + /// A transplanted method must not contain a lambda the compiler caches in a + /// generated closure class: injection clones only the named method, and the + /// verifier then rejects the unresolvable self-reference. ClientProgram::Start + /// is a transplant target, so everything added to it is written lambda-free. + /// + [Fact] + public void TheTransplantedStartBodyStaysLambdaFree() + { + string added = AddedLines(Read(ClientProgramPatch)); + + Assert.DoesNotContain("=>", added); + Assert.DoesNotContain("delegate", added); + } + + /// + /// The client reaches the renderer only through the seam. A direct reference + /// would put an assembly dependency on a renderer implementation into a + /// vanilla assembly, which is exactly what the reflective load avoids. + /// + [Fact] + public void TheClientNeverNamesTheRendererAssemblyDirectly() + { + string added = AddedLines(Read(ClientProgramPatch)); + + Assert.DoesNotContain("VulkanDevice", added); + Assert.DoesNotContain("Optimum.Render.Vulkan", added); + } + + /// + /// The seam and its bootstrap live in the contracts assembly, which the API + /// patcher merges by type-forwarding rather than duplicating. + /// + [Fact] + public void TheSeamShipsInTheContractsAssembly() + { + string contracts = Read("optimum-api-contracts/optimum-api-contracts.csproj"); + + Assert.Contains("optimum-render-device.cs", contracts); + Assert.Contains("optimum-render-bootstrap.cs", contracts); + } + + /// + /// A type compiled into both the fork and contracts is CS0433 on a full + /// build, and only on a full build - which is why it is asserted here rather + /// than left to be discovered. + /// + [Fact] + public void TheForkExcludesTheTypesThatLiveInContracts() + { + string fork = Read("sources/VintagestoryApi/VintagestoryAPI.csproj"); + + Assert.Contains(" + /// OpenGL stays the default until the backend reaches parity, and an + /// unrecognised value degrades to it rather than failing to parse. + /// + [Fact] + public void OpenGlRemainsTheDefaultRenderer() + { + string config = Read("sources/VintagestoryApi/Config/OptimumConfig.cs"); + + Assert.Contains("public static string Renderer = \"opengl\";", config); + Assert.Contains("public string Renderer { get; set; } = \"opengl\";", config); + // The switch falls through to opengl for anything it does not recognise. + Assert.Contains("_ => \"opengl\",", config); + } + + /// + /// ClientProgram is Cecil-owned, so its patch ships through a method + /// transplant rather than a recompiled assembly. Dropping it from the list + /// would make the patch look applied while shipping nothing. + /// + [Fact] + public void ClientProgramRemainsCecilOwned() + { + string owned = Read("patches/cecil-owned.list"); + Assert.Contains("patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch", owned); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("new(\"Vintagestory.Client.ClientProgram\", \"Start\", 2)", patcher); + } + + private const string PlatformPatch = + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"; + + /// + /// The guarantee the whole design rests on: with no device installed, the + /// client runs the vanilla GL body. Each branch is added in front of + /// the original code rather than replacing it, so an OpenGL session costs one + /// null check and behaves exactly as it always did. + /// + /// Checked by confirming the vanilla GL call is still present alongside the + /// device call for a representative spread of the routed methods. + /// + [Theory] + [InlineData("SetViewport", "GL.Viewport(x, y, width, height);")] + [InlineData("SetScissor", "GL.Scissor(x, y, width, height);")] + [InlineData("SetDepthMask", "GL.DepthMask(flag);")] + [InlineData("SetStencilMask", "GL.StencilMask(mask);")] + [InlineData("SetColorMask", "GL.ColorMask(r, g, b, a);")] + [InlineData("SetCullFaceMode", "GL.CullFace((TriangleFace)1029);")] + [InlineData("DeleteTexture", "GL.DeleteTexture(id);")] + public void RoutedMethodsKeepTheirVanillaOpenGlBody(string deviceCall, string vanillaCall) + { + string patch = Read(PlatformPatch); + + Assert.Contains("optimumDevice." + deviceCall, patch); + // The vanilla line survives, either as untouched context or as an added + // line where the branch was inserted above it. + Assert.Contains(vanillaCall, patch); + } + + /// + /// A branch that is not registered as a transplant target compiles into the + /// donor and then ships nothing, because Optimum patches the vanilla + /// assembly rather than replacing it. That failure is silent. + /// + [Fact] + public void EveryRoutedPlatformMethodIsRegisteredAsATransplantTarget() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + string[] routed = + { + "GlViewport", "GlScissor", "GlScissorFlag", + "GlEnableDepthTest", "GlDisableDepthTest", "GlDepthMask", "GlDepthFunc", + "GlEnableCullFace", "GlDisableCullFace", "GlCullFaceBack", "GlCullFaceFront", + "GlToggleBlend", "GlColorMask", "GLWireframes", "GLLineWidth", + "GlEnableStencilTest", "GlDisableStencilTest", "GlStencilMask", + "GlStencilFunc", "GlStencilOp", "GlClearStencil", + "GetGLShaderVersionString", "GenSampler", "BindTexture2d", "BindTextureCubeMap", + "GLDeleteTexture", "GlGetMaxTextureSize", "GetGraphicsCardRenderer", + }; + + foreach (string method in routed) + { + Assert.True( + patcher.Contains($"\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"{method}\"", + StringComparison.Ordinal), + $"{method} is routed to the device but is not a Cecil transplant target"); + } + } + + /// + /// The frame is bracketed by the device, and the OpenGL path still reaches + /// SwapBuffers. Losing either end would either never present or present + /// twice. + /// + [Fact] + public void TheDeviceBracketsTheFrameAndOpenGlStillSwaps() + { + string added = AddedLines(Read(PlatformPatch)); + + Assert.Contains("optimumDevice.BeginFrame();", added); + Assert.Contains("optimumDevice.Present();", added); + + int begin = added.IndexOf("optimumDevice.BeginFrame();", StringComparison.Ordinal); + int present = added.IndexOf("optimumDevice.Present();", StringComparison.Ordinal); + Assert.True(begin < present, "the frame must be opened before it is presented"); + + // The vanilla swap survives for the OpenGL path. + Assert.Contains("SwapBuffers();", Read(PlatformPatch)); + } + + /// + /// ClientPlatformWindows bodies are transplant targets too, so the branches + /// added to them must stay lambda-free for the same reason ClientProgram's do. + /// + [Fact] + public void ThePlatformBranchesStayLambdaFree() + { + string added = AddedLines(Read(PlatformPatch)); + + foreach (string line in added.Split('\n')) + { + if (!line.Contains("optimumDevice", StringComparison.Ordinal)) continue; + Assert.DoesNotContain("=>", line); + } + } + + private const string ShaderProgramBasePatch = + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch"; + + /// + /// A uniform location on the device path is a byte offset into the generated + /// block, not a GL location. That works only because the setters read the + /// same uniformLocations dictionary the routed GetUniformLocation + /// filled, so the two must stay in agreement. + /// + [Fact] + public void UniformSettersUseTheLocationTheDeviceHandedOut() + { + string added = AddedLines(Read(ShaderProgramBasePatch)); + + Assert.Contains("optimumDevice.SetUniform(ProgramId, uniformLocations[uniformName]", added); + Assert.Contains("optimumDevice.SetUniformArray1(ProgramId, uniformLocations[uniformName]", added); + Assert.Contains("optimumDevice.SetUniformMatrix(ProgramId, uniformLocations[uniformName]", added); + + string platform = AddedLines(Read(PlatformPatch)); + Assert.Contains("optimumDevice.GetUniformLocation(program.ProgramId, name)", platform); + } + + /// + /// The Vec2i overload casts to float in the GL body, so the shader sees a + /// vec2; the Vec3i overload does not, so it sees an ivec3. Scalar layout + /// stores that as three consecutive ints, which is why the components are + /// written at separate offsets rather than through the float path. + /// + [Fact] + public void IntegerVectorUniformsKeepTheirIntegerRepresentation() + { + string added = AddedLines(Read(ShaderProgramBasePatch)); + + Assert.Contains("optimumOffset + 4", added); + Assert.Contains("optimumOffset + 8", added); + // The Vec2i overload keeps the cast the GL body performs. + Assert.Contains("(float)value.X, (float)value.Y", added); + } + + /// + /// Binding a texture is three separate operations in GL - aim the sampler at + /// a unit, activate it, bind the texture - and the device keeps that split. + /// A unit with a stale sampler override would silently ignore the texture's + /// own filtering, so the override is cleared when there is no custom sampler. + /// + [Fact] + public void TextureBindingAimsTheSamplerAndClearsAnyStaleOverride() + { + string added = AddedLines(Read(ShaderProgramBasePatch)); + + Assert.Contains("optimumDevice.SetSamplerUnit(ProgramId, samplerName, textureNumber)", added); + Assert.Contains("optimumDevice.BindTexture(textureNumber, textureId)", added); + Assert.Contains("optimumDevice.BindSampler(textureNumber, 0)", added); + } + + /// + /// Compiling a stage cannot produce SPIR-V on its own, because GL matches + /// uniforms and varyings by name across the whole program. The device stages + /// the source and does the real work at link time, where it also assigns the + /// program id the caller stores. + /// + [Fact] + public void ShaderStagesAreStagedAtCompileAndTranslatedAtLink() + { + string added = AddedLines(Read(PlatformPatch)); + + Assert.Contains("optimumDevice.CompileShader(shader)", added); + Assert.Contains("optimumDevice.LinkProgram(program)", added); + Assert.Contains("program.ProgramId = optimumProgramId;", added); + // A link failure is reported the same way the GL path reports one. + Assert.Contains("Link error in shader program for pass", added); + } + + /// + /// ShaderProgramBase is patched via Cecil transplant like the rest, so it has + /// to be declared owned or the patch reports as applied while shipping + /// nothing. + /// + [Fact] + public void ShaderProgramBaseIsDeclaredCecilOwned() + { + string owned = Read("patches/cecil-owned.list"); + Assert.Contains( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch", owned); + } + + /// + /// Render systems index FrameBuffers by EnumFrameBuffer, so the + /// device path has to populate the same slots the GL path does. A missing or + /// misplaced one is a null reference or a wrong target deep in a pass, not a + /// startup failure. + /// + [Theory] + [InlineData("list[0]", "Primary")] + [InlineData("list[1]", "Transparent")] + [InlineData("list[2]", "BlurHorizontalMedRes")] + [InlineData("list[3]", "BlurVerticalMedRes")] + [InlineData("list[4]", "FindBright")] + [InlineData("list[5]", "LiquidDepth")] + [InlineData("list[7]", "GodRays")] + [InlineData("list[8]", "BlurVerticalLowRes")] + [InlineData("list[9]", "BlurHorizontalLowRes")] + [InlineData("list[10]", "Luma")] + [InlineData("list[11]", "ShadowmapFar")] + [InlineData("list[12]", "ShadowmapNear")] + [InlineData("list[13]", "SSAO")] + public void TheDevicePathPopulatesEveryFramebufferSlot(string slot, string name) + { + string added = AddedLines(Read(PlatformPatch)); + Assert.True(added.Contains(slot + " =", StringComparison.Ordinal), + $"the device framebuffer setup never assigns {slot} ({name})"); + } + + /// + /// The Transparent target shares Primary's depth texture in the GL path. + /// Giving it its own would let transparent geometry depth-test against an + /// empty buffer and draw through the world. + /// + [Fact] + public void TheTransparentTargetSharesPrimaryDepth() + { + string added = AddedLines(Read(PlatformPatch)); + + Assert.Contains("transparent.DepthTextureId = primary.DepthTextureId;", added); + Assert.Contains( + "device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0)", + added); + } + + /// + /// SSAO turns the Primary target into a four-attachment G-buffer. Getting the + /// count wrong changes the draw-buffer mask and silently drops the position + /// or normal write. + /// + [Fact] + public void SsaoWidensPrimaryToFourAttachments() + { + string added = AddedLines(Read(PlatformPatch)); + + Assert.Contains("int primaryAttachments = (SetupSSAO ? 4 : 2);", added); + Assert.Contains("device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", added); + } + + /// + /// The SSAO noise pattern and sample kernel come from a fixed seed, and the + /// two draw from the same generator in a fixed order. Reordering them changes + /// the occlusion pattern even though nothing fails. + /// + [Fact] + public void SsaoNoiseAndKernelKeepTheirSeedAndOrder() + { + string added = AddedLines(Read(PlatformPatch)); + + Assert.Contains("new Random(5)", added); + + int noise = added.IndexOf("noise[texel * 4]", StringComparison.Ordinal); + int kernel = added.IndexOf("ssaoKernel[sample * 3]", StringComparison.Ordinal); + Assert.True(noise >= 0 && kernel >= 0); + Assert.True(noise < kernel, "the noise texels must be drawn before the sample kernel"); + } + + /// + /// The device path's helpers are injected members, not just donor code. An + /// unregistered one compiles and then is missing at runtime. + /// + [Fact] + public void TheFramebufferHelpersAreInjectedMembers() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"SetupOptimumFrameBuffers\"", patcher); + Assert.Contains("\"CreateOptimumColorTarget\"", patcher); + Assert.Contains("\"CreateOptimumDepthTarget\"", patcher); + } + + private static string AddedLines(string patch) => + string.Join('\n', patch + .Split('\n') + .Where(line => line.StartsWith('+') && !line.StartsWith("+++"))); + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} diff --git a/VULKAN-BACKEND-PLAN.md b/VULKAN-BACKEND-PLAN.md new file mode 100644 index 00000000..03799f4e --- /dev/null +++ b/VULKAN-BACKEND-PLAN.md @@ -0,0 +1,1528 @@ +# Vulkan renderer backend: implementation plan + +Vintage Story renders through OpenGL 3.3 (4.3 with SSBOs) via OpenTK. Every +upscaler and frame-generation SDK worth shipping (XeSS-SR/FG, DLSS-SR/FG, FSR 2/3) +speaks D3D12 or Vulkan, and frame generation additionally has to own presentation. +This plan adds a Vulkan renderer to Optimum as a second, runtime-selectable +graphics backend behind the existing `ClientPlatformAbstract` seam. OpenGL stays +exactly as it is and remains the default until the Vulkan path reaches parity; +every render system, every shader, and every mod that talks to `IRenderAPI` / +`IShaderAPI` keeps working unchanged, because the backend emulates the GL state +machine the game and its mods were written against. The design is the same shape +as Zink and ANGLE's Vulkan backend, specialised to the 102 GL entry points this +one game actually uses. + +Line references below point at the vanilla 1.22.7 decompile in `_ref/` (raw +`ilspycmd` output). The bootstrapped, patched tree in `build/` has different line +numbers. + +## 0. Status + +**The client runs on Vulkan and its interface renders correctly.** It reaches the +main menu, holds ~165 FPS / 6.1 ms, logs zero errors and zero validation messages, +and translates and links all 45 shader programs the menu needs at runtime. The +login screen is pixel-comparable with the OpenGL path. 872 tests passing (137 +renderer, 714 Optimum, 21 launcher). Cecil: 230/230 required methods patched, 189 +members injected. 121 patches, 0 conflicts. + +Phase 1 was previously called complete at 187 methods; running the client showed +that was premature. Roughly thirty more entry points were still on raw GL and only +reachable at runtime - the two `CurrentFrameBuffer` property setters that bind on +assignment, framebuffer lifecycle and per-pass state, the texture loaders, uniform +buffers, error checking, `ShaderProgramBase.Use/Stop/Dispose`, the post-process +chain, `ScreenManager`'s depth clear, and `GameWindowNative`'s constructor. A +static sweep for `GL.` call sites missed property accessors and anything a static +reading cannot prove is reached. **The lesson is in section 12: parity claims come +from running the client, not from auditing call sites.** + +Three bugs found only by running, each invisible to the validation layer: + +1. **The default render target did not follow the window.** `Install` received + `ClientSettings.ScreenWidth/Height` - the *windowed* size - while the window + had already opened fullscreen, and nothing called `Resize`. The target stayed + 1280x850 while the viewport and swapchain were 2561x1601, so every draw was + clipped to the top-left corner. The background survived only because it is a + tiling texture that the present blit stretched over the screen, which is what + made this look like a GUI problem rather than a sizing one. +2. **Vertex attributes the mesh does not supply.** GL answers a read of an unbound + attribute with the constant generic attribute, defaulting to (0, 0, 0, 1); + Vulkan has no equivalent. The GUI quad carries positions and UVs while + `gui.vsh` declares six inputs, and `gui.fsh` discards a fragment based on one + of the missing ones. See section 6a. +3. **shaderc enforces its `#version` floor during preprocessing**, before the + rewriter can raise the version to 450. Targeting OpenGL instead is worse - that + floor is 330. `ShaderCompiler.RaiseVersionForPreprocessing` lifts sources below + 140, which in vanilla is only the hardcoded `#version 130` minimal-GUI program. + +**Phase 2, world rendering: the GL leak sites are closed.** Every render system +that reached past `ClientPlatformWindows` to GL directly now routes through the +device — `ChunkRenderer` (atlas LOD bias, sampler unbinding), `SystemRenderOITLayers` +(the layered accumulation target, six-attachment blending, its own textures), +`SystemRenderSunMoon` (occlusion queries, colour mask), `SystemRenderFrameBufferDebug` +(shadow-map compare mode), `SvgLoader`, `ShaderRegistry` (terrain sampler bias), +`ClientMain`, `InventoryItemRenderer`, `ClientSystemStartup` and `Screenshot`. +242/242 Cecil methods, 192 members injected, 126 patches, 0 conflicts. + +Two more defects came out of it, both silent: + +4. **Sampler uniforms had no locations.** `LocationOf` searched only the generated + uniform block, and samplers are descriptor bindings, so every texture uniform + in the game resolved to -1 — which the client reads as "the shader does not use + this". Sampler names now get locations from a disjoint negative range, and an + int written to one assigns its texture unit rather than landing in the block. +5. **Attachment layer indices were dropped.** A colour attachment used + `texture.View`, the whole-image view, so the OIT accumulation array — one + texture attached three times, once per layer — sent all three attachments to + layer 0. Attachments now take a per-layer view. Caught by a test, not by the + validation layer, which had nothing to complain about. + +Reaching a world in the real client needs a signed-in account - the session key is +RSA-signed by the vendor and `--rndWorld` does not bypass the check - so the world +paths are covered by GPU tests instead, against the real game shaders: + +- `WorldRenderPathTests`: the layered OIT accumulation target, per-attachment + blend factors, a depth-only shadow target, and an occlusion query. The layer + bug above is exactly what this suite was written to catch. +- `ChunkRenderPathTests`: the real `chunkopaque` program across all four define + variants, with the mesh built to match each variant (SSBO on means positions + leave the vertex input, so the mesh has to follow); every world-facing program - + `chunkopaque`, `chunkliquid`, `chunktransparent`, `chunktopsoil`, + `chunkshadowmap`, `entityanimated`, `particlesquad`, `particlescube`, + `standard` - built into a real `VkPipeline` against a real mesh layout; the SSBO + chunk path; and an instanced draw verified by reading back both instances. +- `ChunkTerrainRenderTests`: **terrain actually drawn.** A tesselated block face + in the real vertex format - positions, UVs, per-vertex colour and the packed + render-flags word, each in its own buffer - goes through `chunkopaque` and + `chunkshadowmap` via `IOptimumGraphicsDevice` and nothing else, and the pixels + come back. The target is cleared to magenta rather than black, because the chunk + shader legitimately shades to black with no lighting bound, and "the pixel is + lit" would be indistinguishable from "nothing drew"; asserting the pixel + *changed* detects rasterisation whatever the shader emits. + + Worth recording, because it took a while to see: the first version of that test + drew nothing at all, with a clean validation log and a correctly issued + six-index draw. The cause was the test, not the device - chunkopaque computes + `aTest = outColor.a + ... - lod0Fade` and discards below `alphaTest`, and + `lod0Fade` comes from the view distances. Left at zero, every fragment in the + world fades out. The client sets those uniforms every frame. That the discard + fires correctly on translated SPIR-V is itself evidence the translation + preserves the shader's semantics. + +**Vendor matrix, both rows green.** All 173 renderer tests - terrain rendering +included - pass on NVIDIA (proprietary driver) and on Intel UHD (Mesa), selected +with `VK_ICD_FILENAMES`. The client itself also runs on both and renders +identically: same interface, same layout, same 165 FPS, zero errors and zero +validation messages on each. That covers the Arc row's driver family ahead of the +target handheld. + +**`auto` backend selection is implemented** (section 4's allow-list). `auto` and +`vulkan` are no longer the same decision: an explicit `vulkan` is a choice the +player made and is honoured wherever the backend runs at all, while `auto` is a +default nobody chose and takes Vulkan only on driver families the backend is +exercised against - NVIDIA, Mesa (Intel and radv), the Windows Intel driver and +AMD's proprietary one. Anything else stays on OpenGL with a reason that names the +driver and says how to override it. The list keys on the driver rather than the +GPU model, because the behaviour that breaks a backend lives in the driver. + +What is still untested rather than unimplemented: actual terrain, entities and +particles drawn from a loaded world, and the SSIM comparison against GL +screenshots. Both need a signed-in session on the test data path - the session key +is RSA-signed by the vendor in `SessionManager.IsCachedSessionKeyValid`, the gate +sits in `ScreenManager` init ahead of every screen, and neither `--rndWorld` nor +`-c` reaches `HandleArgs` without passing it. The offline path +(`DoGameInitStage3`) still requires a previously cached valid session, so it does +not help a machine that has never signed in. + +The packaging scripts now ship `Optimum.Render.Vulkan.dll`, the Silk.NET +assemblies and native shaderc on all three platforms; the renderer project sets +`CopyLocalLockFileAssemblies` so its dependencies reach the output directory at +all, since nothing in the tree references it. + +The riskiest assumption in this plan was that the 84 shaders could be translated +automatically rather than hand-ported, because hand-porting does not extend to mod +shaders, which are GLSL authored by third parties and only exist at runtime. +`Optimum.Render.Vulkan` now translates **all 84 shaders across 4 define +permutations - 336 program/variant combinations - to valid SPIR-V**. + +That SPIR-V has been proven against real drivers, not just the validator: a +GLSL 330 pair goes through translation, becomes a `VkPipeline`, renders offscreen +through dynamic rendering, and reads back correct pixels with **zero validation +messages**. The same test pins the coordinate convention empirically - framebuffer +row 0 carries `texCoord.y` near 0 and the last row near 1, which is GL's +orientation - so a future change that introduces a Y flip fails a test rather than +inverting every render-to-texture pass silently. + +Both physical devices on the development machine are accepted by the backend's +feature requirements: + +| Device | Driver | API | Verdict | +| --- | --- | --- | --- | +| Intel UHD (ADL-S GT1) | Mesa 26.2.2 | 1.4.354 | usable | +| NVIDIA RTX 4070 Laptop | NVIDIA 610.57 | 1.4.341 | usable | + +Shipped so far: + +| Piece | File | +| --- | --- | +| Backend seam | `sources/VintagestoryApi/Client/optimum-render-device.cs` | +| GLSL type model, scalar layout rules | `Optimum.Render.Vulkan/Shaders/GlslType.cs` | +| Declaration parser | `Optimum.Render.Vulkan/Shaders/GlslParser.cs` | +| Program interface layout | `Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs` | +| Reserved-word and built-in renaming | `Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs` | +| Rewriter | `Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs` | +| shaderc wrapper | `Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs` | +| Orchestrator | `Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs` | +| Instance, device selection, feature negotiation | `Optimum.Render.Vulkan/Core/VulkanContext.cs` | +| Buffers, images, memory, commands, barriers | `Optimum.Render.Vulkan/Core/VulkanResources.cs` | +| GL constant translation | `Optimum.Render.Vulkan/Core/GlEnums.cs` | +| Emulated GL state machine, pipeline key, interning | `Optimum.Render.Vulkan/Core/GlStateTracker.cs` | +| Vertex layouts and attribute format mapping | `Optimum.Render.Vulkan/Core/VertexLayout.cs` | +| Per-program modules, descriptor layouts, uniform shadow | `Optimum.Render.Vulkan/Core/ShaderProgramResources.cs` | +| Pipeline cache with on-disk driver blob | `Optimum.Render.Vulkan/Core/PipelineCache.cs` | +| Frame ring, uniform ring, deferred deletion | `Optimum.Render.Vulkan/Core/FrameRing.cs` | +| Descriptor set cache | `Optimum.Render.Vulkan/Core/DescriptorCache.cs` | +| Textures, sampler cache, mipmaps | `Optimum.Render.Vulkan/Core/TextureManager.cs` | +| Render targets and dynamic rendering scopes | `Optimum.Render.Vulkan/Core/RenderTargetManager.cs` | +| Meshes, vertex buffers, indexed and indirect draws | `Optimum.Render.Vulkan/Core/MeshManager.cs` | +| Window surface creation via the client's own GLFW | `Optimum.Render.Vulkan/Core/WindowSurface.cs` | +| Swapchain, present, resize, vsync, the single Y flip | `Optimum.Render.Vulkan/Core/Swapchain.cs` | +| **The device implementing the seam** | `Optimum.Render.Vulkan/VulkanDevice.cs` | + +Verified against real hardware, not just the validator: pixels round-trip through +texture upload and readback; an indexed mesh renders with its vertex colours; a +multi-attachment target honours `glDrawBuffers` selection, including the +composition case where attachment 0 is written while attachment 1 is left +untouched and readable. Every GPU test runs with validation layers on and asserts +the message log is clean. + +### Further corrections the build forced + +Beyond the six shader-translation fixes above, building the device found three +more design errors: + +7. **The uniform ring must be one buffer for the whole frame ring**, not one per + slot. Descriptor sets are only reusable across frames if the set names a + buffer that does not change; the per-draw offset then travels as a dynamic + offset. A buffer per slot would mean rewriting every set every frame, which is + the exact cost the descriptor cache exists to avoid. +8. **A frame that begins must submit.** `BeginFrame` resets the slot's fence, so a + slot begun and never submitted leaves the fence unsignalled and deadlocks the + ring on its next rotation. The ring now has an explicit `EndFrame`. +9. **A custom mesh part claims its attribute location by being declared, not by + holding data.** `CustomMeshDataPart.AllocationSize` returns `Count`, which is + zero for a part allocated now and filled later, and the GL allocator adds the + attribute pointers regardless. Gating on size shifted every subsequent + location and would have misfed the chunk shaders. +10. **The empty vertex layout needs a reserved id.** The fullscreen + post-processing passes bind no vertex buffers but still need a layout id for + the pipeline key, and asking the interner for id 0 before any mesh existed + indexed past the end of it. +11. **The GPU tests cannot run in parallel.** Three of them drive GLFW's + process-global init and terminate, which is not thread safe, and xunit runs + collections concurrently by default. The full suite crashed the test host + outright until parallelisation was disabled for the assembly. + +The presentation design settled as: the client renders into an ordinary offscreen +target that stands in for the default framebuffer, and presenting blits that into +the acquired swapchain image **with the source rows read bottom-to-top**. That +inverted blit is the entire Y-flip story - one image copy, at the very end - +which is what leaves every intermediate target, render-to-texture round trip and +screenshot byte-identical to the OpenGL path. Rendering straight into a swapchain +image would have put the flip in the middle of the pipeline instead. + +`VulkanDevice` is exercised only through `IOptimumGraphicsDevice` by +`VulkanDeviceIntegrationTests`, because that is all `ClientPlatformWindows` will +ever see: compile a GLSL 330 pair, link, create a target, set a uniform, draw, +read the pixels back, and check the value survived. Sampler units, uniform +persistence across frames, and GL-style id reuse are covered the same way. + +`SwapchainTests` brings the device up against a real hidden GLFW window created +with `ClientApi.NoApi` - the one change the client's window creation needs - and +presents frames through it, across resizes and vsync toggles, on Wayland and +with validation on. + +### Client integration: the Cecil question is settled + +The plan listed "can Cecil transplant the window-creation region" as the +integration risk with no precedent (Phase 0 spike 2). It is answered, with a +working artifact rather than an argument: + +- `ClientProgram::Start` was **already** a transplant target, and transplanted + bodies already reference contracts types in twenty places through the FSR work. +- Backend selection now lives in that method and survives the transplant: + `126/126 required methods patched`, and the decompiled patched assembly + contains `OptimumRenderBootstrap.ShouldTryVulkan` and + `OptimumRender.FallBackToOpenGL` at the right points. +- The whole change set reconstructs from tracked files: a fresh + `scripts/bootstrap.sh` restores it, then build, Cecil patch and every suite + pass. + +The order is forced by the window. A window created with `ContextAPI.NoAPI` +cannot be handed back to OpenGL, so the decision is final before it opens: +`ShouldTryVulkan` loads the backend assembly and creates a throwaway device to +answer it. If the device still fails afterwards - the probe passed but the +surface or swapchain did not - the window is closed and reopened for OpenGL. + +Guardrails, in `Optimum.Tests/vulkan-backend-integration-tests.cs`: the decision +precedes the API choice, a failed install reopens the window, the transplanted +body stays lambda-free, the client never names the renderer assembly, the fork +excludes the contracts types (CS0433 only shows up on a full build), and OpenGL +remains the default. + +### Two things the repository taught along the way + +- **New Optimum-owned API source belongs in the working tree, not `sources/`.** + `extract-patches.sh` wipes `sources/` and regenerates it from the tree, so + files authored directly into `sources/` are deleted on the next extraction. + The tree is the input; `sources/` is the output. +- **`VintagestoryAPI.csproj` is carried as a `sources/` overlay, not a patch.** + Bootstrap applies patches and *then* copies `sources/` over them, so an overlay + silently wins over a patch for the same file. The exclusion entries go in the + overlay. + +### The branch pattern, in the client + +`ClientPlatformWindows` now routes 28 methods to the device, all in the shape +section 3 describes: + +```csharp +IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; +if (optimumDevice != null) { optimumDevice.SetViewport(x, y, width, height); return; } +GL.Viewport(x, y, width, height); // vanilla body, untouched +``` + +Covered so far: the whole fixed-function state group (viewport, scissor, depth, +cull, blend including the per-attachment SSAO overrides, colour mask, stencil, +wireframe, line width), texture binding and deletion, sampler creation, the +capability strings, and the frame lifecycle - `window_RenderFrame` now brackets +`frameHandler.OnNewFrame` with `BeginFrame`/`Present` on the device path and +still reaches `SwapBuffers` on the OpenGL one. + +Three invariants are pinned by test, because each fails silently rather than +loudly: + +- **Off is vanilla.** Every routed method keeps its original GL body; the branch + is inserted in front of it, never in place of it. +- **Every routed method is a registered transplant target.** One that is not + compiles into the donor and then ships nothing, since Optimum patches the + vanilla assembly rather than replacing it. +- **The branches stay lambda-free**, for the same Cecil reason as everywhere else. + +Shaders route too. `CompileShader` only *stages* a stage on the device path, +because GL matches uniforms and varyings by name across the whole program and +nothing is final until link; `CreateShaderProgram` links, and the device returns +the program id the caller stores, exactly as `glCreateProgram` did. +`ShaderProgramBase`'s whole uniform and texture-binding surface follows - the +seventeen setters, both matrix forms, and `BindTexture2D`/`BindTextureCube`. + +Two details there were worth getting right rather than pattern-matching: + +- **`Vec2i` is a vec2 but `Vec3i` is an ivec3.** The GL body casts the first to + float and leaves the second as integers. Scalar block layout stores an ivec3 as + three consecutive 32-bit ints, so the components are written at separate + offsets rather than through the float path. +- **A stale sampler override silently wins.** Binding a texture with no custom + sampler now clears the unit's override, or the texture's own filtering would be + ignored. + +### A pre-existing test race this surfaced, and the stopgap for it + +Twelve test classes mutate `OptimumDiagnostics`, whose counters are **plain +statics** shared by the process while the recording context is `[ThreadStatic]`. +Several also flip `StutterWatchEnabled` process-wide, which makes production +animation and launch-task code record into those same counters from whatever else +is running concurrently. + +xunit runs collections in parallel, so those classes raced. The failure moved +between `EntityAnimationDiagnosticsCoverage`, `LaunchTaskTimeBudget` and +`OptimumStatus` across runs, always as a count that did not match. The race +predates this work, but adding test classes changed the scheduling enough to +surface it nearly every run - which made the suite effectively red. + +`Optimum.Tests/AssemblyInfo.cs` therefore disables parallelisation for that +assembly. Six consecutive full runs are green and the suite went from 380 ms to +1 s, which is a fair price for removing the whole class of failure. **This is a +stopgap in shared test infrastructure, not the real fix** - scoping the counters +per context so the tests stop sharing global state would let it be removed. + +### Meshes, and a third Cecil constraint + +Mesh allocation, upload, update, deletion and all four draw forms now route. +`VAO.VaoId` carries the device's mesh handle, so `MeshRef` - public API that mods +hold - is unchanged, and `VAO.Dispose` is routed too: on the device path the vbo +fields are all zero and `VaoId` is not a GL vertex array, so falling through would +hand an unrelated integer to `glDeleteVertexArray`. That method also runs on the +finalizer thread, which is precisely why the device defers deletion rather than +destroying inline. + +Routing `UpdateMesh` exposed a **third** transplant constraint, alongside cached +lambdas and closure classes: the decompiled body contained a `string.Format` call +rendered as a params-span, which the compiler lowers through a generated +`::InlineArrayFirstElementRef` helper. Cecil clones +only the named method, so the transplant referenced a helper absent from the +vanilla assembly and the verifier refused to write any output at all. Passing the +four arguments directly emits no helper. Worth remembering: **any C# lowering that +generates a hidden helper type breaks a transplant**, not just lambdas. + +`check-patches.sh` also earned its keep here - it caught `VAO.cs.patch` as an +orphan before it could ship as a patch that applies but transplants nothing. + +### The framebuffer set + +`SetupDefaultFrameBuffers` is four hundred lines of raw GL that generates its own +names and attaches its own textures - there is no seam inside it to route +through. So the device path is a separate `SetupOptimumFrameBuffers` that mirrors +the layout: every slot index, size and format, the Primary G-buffer widening to +four attachments under SSAO, the Transparent target sharing Primary's depth +texture, the shadow maps sized by quality, and Optimum's own FSR intermediate at +native resolution. + +The SSAO block needed care beyond attachments. Its noise texture and 64-sample +kernel are drawn from one `Random(5)` in a fixed order, so the device path draws +them in the same order from the same seed - reordering changes the occlusion +pattern without failing. The noise texture also forced a small seam extension: +`EnumTextureInternalFormat` names only four formats, and this setup uses GL_RGB +and GL_RGBA32F, so `CreateTexture2DRaw` takes the GL constant directly, matching +how the seam already accepts GL constants for texture parameters. + +Guardrails: every `EnumFrameBuffer` slot is asserted populated, the shared depth +texture is asserted shared, the SSAO attachment widening is pinned, and the noise +and kernel are pinned to their seed and order. + +### Next + +Phase 1 is done: the client can select the backend, and every graphics call it +makes reaches the device. Phase 2 is world rendering, and it is a different kind +of work - running the real client on Vulkan and fixing what renders wrong. That +needs the game in front of a person rather than a test, and it is where the +remaining time in the estimate sits. + +### What the build corrected in this plan + +Six things were wrong or missing in the design as first written. All are fixed in +code and in section 6 below. + +1. **Varyings need explicit locations.** SPIR-V requires a `location` on every + user-defined input and output; GLSL 330 leaves them implicit and the vanilla + shaders declare bare `out vec2 texCoord;`. Vertex output and fragment input + must agree, so this is program-wide state, not a per-stage rewrite. The + original plan did not mention it. +2. **The generated uniform block must be emitted per stage, not whole.** + `bilateralblur.vsh` declares `uniform vec2 frameSize` while its fragment + shader declares `in vec2 frameSize`. Emitting the program's whole uniform + union into both stages redefines the varying. Each stage now gets only the + members it declared, with `layout(offset = N)` on each so they still share one + buffer layout. +3. **GLSL 4.x reserved words have to be renamed.** Vulkan forces `#version 450`, + and `ssao.fsh` uses `sample` as a local, which 4.00 turned into a qualifier. +4. **`gl_VertexID` is not `gl_VertexIndex`.** The plan assumed glslang would map + the GL spelling under Vulkan semantics; it does not, and 17 shaders use it. + The values differ in principle - the Vulkan built-ins count from the draw's + vertex offset and first instance - but this client issues no draw with either + set, so they agree wherever they are used. +5. **Array sizes can be constant expressions.** `fogandlight.vsh` declares + `uniform vec4 fogSpheres[3 * 8];`, which needs evaluating, not just parsing. +6. **Memory qualifiers must be stepped over.** `chunkopaque.vsh` declares + `readonly buffer faceDataBuf`, and not skipping `readonly` left the storage + block unclassified, which silently put it in the wrong descriptor set. It + still compiled, which is why this needed a test rather than a run. + +### Cross-checks against current practice + +- **Dynamic rendering over render-pass objects** is what NVIDIA recommends for + Vulkan 1.3 and removes the render-pass and framebuffer object graph entirely. + Confirmed; section 5 unchanged. +- **Cached descriptor sets** remain the right default. Arm's measurements put + descriptor-set caching at roughly a third off frame time in CPU-heavy scenes, + and `VK_EXT_descriptor_buffer` is a later, optional refinement rather than a + starting point. Confirmed; section 5.8 unchanged. +- **Scalar block layout** behaves as the plan assumed and is the reason array + uploads stay a memcpy. Confirmed and now covered by tests. +- **No Y flip** survived scrutiny. The common advice for porting an application + is a negative viewport height, but that is wrong for an emulation layer: it + would invert every render-to-texture round trip unless texture coordinates were + flipped to match. GL and Vulkan actually agree on how clip space maps to + framebuffer memory and to texture coordinates; they differ only in which corner + they call the origin, and in depth range. Flipping nothing reproduces GL bit for + bit, and scanout is corrected once in `Present`. + +## 1. Background and problem statement + +### Why a backend and not a bridge + +`tools/InteropProbe` run on the target device (MSI Claw 8 AI+ A2VM, Arc 140V, +driver 32.0.101.8992, Windows 11 26200) shows every cross-API sharing route open: +`WGL_NV_DX_interop2` with all four entry points, `GL_EXT_memory_object_win32` and +`GL_EXT_semaphore_win32`. Mesa on the Linux dev box exposes the `_fd` variants. So +a GL-rendered scene *can* be handed to a Vulkan or D3D device for super +resolution without a backend, and that remains the cheapest way to get XeSS-SR on +the Claw (section 13). + +The backend buys three things the bridge cannot: + +1. **Presentation ownership.** Frame generation hooks `Present`/`vkQueuePresentKHR`. + With Vulkan owning the swapchain, DLSS-FG runs natively and XeSS-FG runs behind + a D3D12 proxy fed through `VK_KHR_external_memory_win32`, the same proxy shape + Skyrim Community Shaders uses in front of a D3D11 renderer. +2. **Escaping Intel's OpenGL driver.** `ClientSystemStartup.cs:1076` already + special-cases `"Arc(TM)"` to work around it. On the Claw, Intel's Vulkan driver + is the well-maintained one; its GL driver is the weak link in every interop + chain. +3. **A modern API under the renderer** for later work: multithreaded command + recording, explicit memory, and native SR integration at the final-blit seam + rather than through a second device. + +### What the renderer looks like today + +Measured on the vanilla decompile: + +| Quantity | Value | Where | +| --- | --- | --- | +| `ClientPlatformAbstract` abstract members | 147 | `VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs` | +| `ClientPlatformWindows.cs` size | 3,755 lines, 519 GL call sites | `…/ClientPlatformWindows.cs` | +| GL call sites in the whole client | 700 in 19 files | 74% inside `ClientPlatformWindows` | +| Distinct GL entry points used | **102** | Appendix A | +| Shader programs / files / includes | 42 / 84 (`.vsh`+`.fsh`) / 19 | `assets/game/shaders`, `shaderincludes` | +| Geometry shaders | 0 | | +| `#version` | `330 core` in all 84 files | rewritten to 430 for the 5 SSBO vertex shaders | +| Default-block `uniform` declarations | 488 | must move into a block for Vulkan | +| `layout(std140)` uniform blocks | 3 | `entityanimated.vsh`, `gui.vsh`, `shadowmapentityanimated.vsh` | +| SSBO vertex shaders | 5 | `chunkopaque`, `chunktransparent`, `chunktopsoil`, `chunkshadowmap`, `decals` | +| Max vertex attribute location | 9 | `clouds.vsh` | +| Max fragment output location | 3 (4 MRTs) | Primary FBO with SSAO | +| Framebuffer slots | 18 vanilla (0–17), 18 claimed by Optimum FSR | `EnumFrameBuffer` | +| GL threads | 1 (the GLFW window thread) | tesselators hand off via `EnqueueMainThreadTask` | + +The frame is a fixed linear sequence in `ScreenManager.Render` +(`VintagestoryLib/Vintagestory.Client/ScreenManager.cs:707`): + +``` +ClearFrameBuffer(Default) → ClearFrameBuffer(Primary) → LoadFrameBuffer(Primary) +→ CurrentScreen.RenderToPrimary (shadow maps, opaque, OIT/transparent, entities, particles…) +→ RenderPostprocessingEffects (bloom, godrays, SSAO) +→ RenderFinalComposition (final.fsh into Primary attachment 0) +→ BlitPrimaryToDefault (Optimum FSR1 lives here) +→ GUI into the default framebuffer +→ SwapBuffers (ClientPlatformWindows.cs:508) +``` + +### The seam is real but leaks + +`ClientPlatformAbstract` is a genuine backend interface: windowing, input, audio, +bitmaps, screenshots, meshes, textures, framebuffers, shaders and the post chain +all go through it. But 19 files call `GL.*` directly (section 7), and two things +outside the platform class are graphics-critical: + +- `ShaderProgramBase` (`…/ShaderProgramBase.cs`) issues `GL.Uniform*`, + `GL.UseProgram`, `GL.ActiveTexture/BindTexture/BindSampler` itself, 38 sites. + This is the mod-facing `IShaderProgram` implementation. +- `VAO` and `UBO` (`…/VAO.cs`, `…/UBO.cs`) own GL buffer handles and delete them + in `Dispose`, from finalizers too. + +Everything the game and mods do with graphics reduces to an immediate-mode, +GL-shaped protocol: set state, set named uniforms on the active program, bind +textures to units, draw a `MeshRef`. That protocol is the contract this plan +preserves. + +## 2. Constraints, principles and non-goals + +### Licensing + +`LICENSE-SCOPE.md` keeps `patches/**`, `sources/**` and `Vintagestory/**` outside +the MIT grant, and `NOTICE` forbids redistributing Anego-owned material. The +Vulkan device is new code and can be MIT. Anything that is a modified copy of the +decompile (transplanted method bodies, the branch points in `ClientPlatformWindows`) +stays in `patches/` under the existing scope. The plan is arranged so the GL +implementation is never *extracted* into an MIT assembly: it stays where it is. + +### The Cecil transplant rules + +Optimum does not ship a recompiled `VintagestoryLib.dll`. `Optimum.Patcher` +transplants method bodies, injects members and whole types from the compiled +donor into the vanilla assembly at launch (`Optimum.Launcher/Program.cs:31`, +`Optimum.Patcher/Program.cs`). Two rules from `Optimum.Tests/cecil-transplant-lambda-tests.cs` +shape everything below: + +- A transplanted method must not contain a lambda that the compiler caches in a + `<>c` class (LINQ predicates, non-capturing lambdas). The `ClientProgram.cs` + patch already carries the comment "written lambda-free so the body survives the + Mono.Cecil transplant"; the FSR patch replaced + `ArrayUtil.CreateFilled(…, n => GL.GenTexture())` with a loop for this reason. +- Whole injected types are cloned with their nested types + (`Optimum.Patcher/MemberInjector.cs:356-382`) but are still verified against + the vanilla target; large, generic-heavy code is a poor fit for injection. + +Consequence: **the Vulkan device lives in its own normal assembly** with +unrestricted C#, and only thin, lambda-free branch points are transplanted into +`VintagestoryLib`. + +### Mod compatibility contract + +Mods reach graphics through `IRenderAPI` (85 methods, `VintagestoryAPI/Vintagestory.API.Client/IRenderAPI.cs`), +`IShaderAPI`, `IShaderProgram`, `MeshRef`, `UBORef`, `LoadedTexture`, +`FrameBufferRef`, all of which expose GL integer ids (`LoadedTexture.TextureId`, +`FrameBufferRef.ColorTextureIds`, `UBORef.Handle`). Mods author GLSL 330 and +register it with `RegisterFileShaderProgram`. Some mods (and the bundled +FluffyClouds in `VSEssentials`) call `GL.*` directly. + +The contract: everything reachable through the API works on Vulkan unchanged, +including mod GLSL. Raw-GL mods are detected before the window opens and force +the GL backend for that session (section 9). + +### Principles + +- **OFF is vanilla.** With `Renderer = "opengl"` the client executes the vanilla + GL code, byte for byte where Optimum has not already patched it. The Vulkan + path is a branch that is never taken, the same rule `GREEDYMESH 0` follows. +- **Additive, selectable, self-disabling.** A Vulkan initialisation failure, a + GL-bound mod, or a crash marker from the previous session falls back to GL with + a logged reason, mirroring `DisableOptimumFsr`. +- **Emulate, don't refactor.** No render system is rewritten. The device + implements the GL state machine; render systems keep calling + `GlToggleBlend`, `Uniform("name", …)`, `BindTexture2D`, `RenderMesh`. + +### Non-goals + +- Rewriting render systems or mods against an explicit modern API. +- Removing OpenGL. Hardware without Vulkan 1.3 (pre-2016 GPUs, macOS without + MoltenVK work) keeps the GL path indefinitely. +- Multithreaded command recording in the first release. The device is + single-threaded by construction; the seams for recording chunk draws on + workers are left open but not built. +- macOS. MoltenVK's Vulkan 1.3 coverage is incomplete and Optimum's macOS builds + are archival. macOS is GL-only in this plan. +- A D3D12 backend. The abstraction is API-neutral so one could follow, but the + decision on 2026-09-08 was Vulkan as the renderer with a D3D12 proxy only for + XeSS-FG. + +### Decisions taken 2026-09-08 + +1. Vulkan is the renderer API, not D3D12. XeSS-SR, DLSS-SR/FG and FSR 2/3 run + natively; XeSS-FG/XeLL (D3D12-only) come later through a D3D12 presentation + proxy fed by Vulkan external memory. +2. Primary target device is the Arc 140V handheld; dev box is CachyOS. Both + platforms are first-class; Windows and Linux ship together. +3. OpenGL remains available and default until parity; Vulkan is opt-in, then + `auto` per vendor once the vendor matrix is clean. +4. TAA with a real velocity buffer is built regardless of backend; it is + shader and matrix work and proceeds on GL in parallel (section 11). + +## 3. Architecture + +``` +ClientProgram (window: GLFW via OpenTK, ContextAPI.NoAPI when Vulkan) + └─ ClientPlatformWindows (windowing, input, audio, single-player server: unchanged) + ├─ graphics methods (transplanted): if (device != null) device.X(…) else { vanilla GL } + └─ IOptimumGraphicsDevice device ← null = OpenGL, vanilla code runs + └─ Optimum.Render.Vulkan.dll : VulkanDevice + ├─ GL state emulation (blend, depth, cull, scissor, units, current program, current FBO) + ├─ handle tables (textures, buffers/meshes, framebuffers, samplers, queries, programs) + ├─ shader pipeline (preprocess → rewrite → shaderc → SPIR-V cache → link) + ├─ pipeline cache, descriptor set cache, uniform ring buffer + ├─ frame lifecycle (2 frames in flight, deferred deletion, uploads with pass break) + └─ swapchain + present (the only Y flip in the system) +ShaderProgramBase / VAO / UBO / 15 leak sites: same branch, same device +``` + +### The additive-branch pattern + +Every graphics method that Optimum must route gets one shape: + +```csharp +public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendMode.Standard) +{ + IOptimumGraphicsDevice device = OptimumRender.Device; + if (device != null) { device.ToggleBlend(on, blendMode); return; } + // vanilla body, untouched + if (on) { GL.Enable((EnableCap)3042); … } +} +``` + +The static `OptimumRender.Device` is null on the GL path. The cost is one null +check per call; the benefit is that the GL body stays vanilla and every method is +a trivially lambda-free transplant. Where a vanilla method has a lambda, the +existing FSR precedent applies: replace it with a loop or a named method in the +same patch. + +### Where the abstraction lives + +`IOptimumGraphicsDevice`, its handle structs and enums go into +`optimum-api-contracts`, which the API patcher merges into `VintagestoryAPI.dll` +(`Optimum.Patcher/api-patcher.cs`, namespace convention `Vintagestory.API.Config` +per `optimum-api-bridge.cs:11`). Rationale: transplanted bodies in +`VintagestoryLib`, `VSEssentials` and `VSSurvivalMod` can all reference it without +adding a new `AssemblyRef` to a vanilla assembly, which is the one Cecil path +this repository has not exercised. The interface takes only API-level types +(`MeshData`, `MeshRef`, `IShader`, `IShaderProgram`, `IBitmap`/`BitmapRef`, +`SKBitmap`, Cairo `ImageSurface`, `FrameBufferRef`, `FramebufferAttrs`) so +`Optimum.Render.Vulkan.dll` references `VintagestoryAPI.dll` and Silk.NET, never +`VintagestoryLib.dll`. It is documented as an internal contract, not a mod API. + +### Threading + +All GL calls today happen on the GLFW thread; tesselators and the atlas manager +marshal uploads via `EnqueueMainThreadTask` (`ClientMain.cs:1032`, +`TextureAtlasManager.cs:151-166`). The device inherits that: one thread, one +command buffer in flight per frame, a debug-build assertion on the owning thread +id. `VAO`/`UBO` finalizers call `Dispose` from the finalizer thread; the device +queues those into a lock-free deferred-deletion list drained on the render +thread, which also solves the in-flight-resource problem (section 5.3). + +### Backend selection + +`OptimumConfig.Renderer` ∈ `opengl` (default) | `vulkan` | `auto`. Resolution +happens in the launcher-injected hook before `ClientProgram` builds +`NativeWindowSettings` (`ClientProgram.cs:281-295`): + +1. `opengl` → nothing changes. +2. `vulkan`/`auto` → run the mod scan (section 9). Any GL-bound mod → `opengl`, + with a launcher log line and a one-time in-game notice. +3. Crash-loop guard: `.optimum/vulkan-session.lock` written at device creation, + deleted on clean shutdown. Present at startup → this session runs `opengl` + and records the fallback; the next clean GL session clears it. +4. Create the Vulkan instance and pick a device (section 5.2). Any failure → + `opengl`. +5. Only then does the window open with `ContextAPI.NoAPI`. + +`auto` additionally consults a vendor/driver allow-list that starts empty and +grows as the matrix in section 10 goes green. The settings tab shows the active +backend and the fallback reason. + +## 4. The device contract + +The interface mirrors the graphics half of `ClientPlatformAbstract` plus the +operations that leak around it. Grouped, with the GL entry points each group +absorbs (full mapping in Appendix A): + +| Group | Operations | Absorbs | +| --- | --- | --- | +| Capabilities | `Init`, `Shutdown`, `MaxTextureSize`, `SupportsThickLines`, `Renderer/Vendor/Version` strings, `ShaderVersionString` | `GetString`, `GetInteger`, `GetFloat`, `GetError` | +| Fixed-function state | viewport, scissor+flag, depth test/mask/func, cull enable/face, blend enable+mode, per-attachment blend func/equation, color mask, stencil test/func/op/mask, polygon mode, line width | `Enable`, `Disable`, `DepthFunc`, `DepthMask`, `CullFace`, `BlendFunc`, `BlendFuncSeparate`, `BlendEquation`, `ColorMask`, `Stencil*`, `Scissor`, `Viewport`, `PolygonMode`, `LineWidth`, `IsEnabled`, `Hint` (ignored), `DepthRange` (no-op, see below) | +| Meshes | `AllocateEmptyMesh`, `AllocateEmptySSBOMesh`, `UploadMesh`, `UpdateMesh`, `UpdateSSBOMesh`, `DeleteMesh`, `Map` (persistent pointer), `RenderMesh`, `RenderMeshMulti` (starts/sizes/groupCount, ssbo flag), `RenderMeshInstanced`, `RenderFullscreenTriangle` | `Gen/Bind/DeleteBuffer(s)`, `BufferData`, `BufferSubData`, `BufferStorage`, `MapBufferRange`, `Gen/Bind/DeleteVertexArray`, `VertexAttrib(I)Pointer`, `VertexAttribDivisor`, `EnableVertexAttribArray`, `BindBufferBase`, `DrawElements`, `DrawElementsInstanced`, `DrawArrays`, `MultiDrawElements` | +| Textures | create 2D/2D-array/cube, upload (rect, mip), sub-upload, generate mipmaps, delete, `SetTexParameter(handle, pname, value)`, bind to unit, `BindSampler(unit, sampler)`, `GenSampler`, `SamplerParameter` | `GenTexture`, `BindTexture`, `TexImage2D/3D`, `TexSubImage2D`, `TexParameter`, `GetTexParameter`, `GenerateMipmap`, `ActiveTexture`, `DeleteTexture`, `Gen/Bind/DeleteSampler`, `SamplerParameter`, `ReadPixels` | +| Framebuffers | `CreateFramebuffer(attrs)`, `AttachTexture(fbo, attachment, tex, layer)`, `SetDrawBuffers(fbo, mask)`, `Bind(fbo)`, `ClearColor(attachment, rgba)`, `ClearDepth`, `ClearStencil`, `CheckStatus`, `Delete`, `CurrentFramebuffer` (read) | `Gen/Bind/DeleteFramebuffer`, `FramebufferTexture2D`, `FramebufferTextureLayer`, `DrawBuffer(s)`, `ReadBuffer(s)`, `Clear`, `ClearColor`, `ClearBuffer`, `GetInteger(FRAMEBUFFER_BINDING/VIEWPORT)` | +| Shaders | `CompileShader(IShader)`, `LinkProgram(IShaderProgram)`, `UseProgram`, `GetUniformLocation`, `SetUniform*` (float/int/vec/mat, arrays), `SetSamplerUnit(program, name, unit)`, `BindUniformBlock`, `CreateUBO`/`UpdateUBO`/`DeleteUBO`, `DeleteProgram` | `Create/Compile/Delete/Attach/DetachShader`, `ShaderSource`, `GetShader(InfoLog)`, `Create/Link/Use/DeleteProgram`, `GetProgram(InfoLog)`, `BindAttribLocation`, `GetUniformLocation`, `Uniform1/2/3/4`, `UniformMatrix4`, `UniformMatrix4x3`, `GetUniformBlockIndex`, `UniformBlockBinding` | +| Queries | `CreateOcclusionQuery`, `Begin`, `End`, `IsResultAvailable`, `GetResult`, `Delete` | `GenQueries`, `BeginQuery`, `EndQuery`, `GetQueryObject`, `DeleteQuery` | +| Frame | `BeginFrame`, `Present`, `Resize`, `SetVSync`, `ReadbackDefaultFramebuffer` (screenshots, AVI) | `SwapBuffers`, `ReadPixels` | +| Debug | `SetDebugMode` (validation layers), `CheckError` (validation message drain), wireframe | `DebugMessageCallback`, `GetError` | + +### Handles + +Every id the game stores stays an `int`. The device keeps dense handle tables +(`textures[id]`, `buffers[id]`, `framebuffers[id]`, `samplers[id]`, `programs[id]`, +`queries[id]`) with generation counters in debug builds to catch use-after-delete. +`LoadedTexture.TextureId`, `FrameBufferRef.FboId/DepthTextureId/ColorTextureIds`, +`UBORef.Handle`, `VAO.*VboId` all remain valid opaque ints; mods that pass them +through the API never notice. `VAO` gains no new fields: the device keeps its own +`VkMesh` record keyed by `VaoId`. + +### The emulated state machine + +The device tracks exactly the GL state the game touches: blend enable + per +attachment (factor pairs, equation), depth test/mask/func, cull enable/face, +scissor rect + flag, viewport, color mask, stencil, polygon mode, line width, +`DrawBuffers` mask of the bound framebuffer, current program, 16 texture units +(target, texture, override sampler), current framebuffer. State changes are +recorded, not executed; a draw resolves them into a pipeline key, dynamic state +commands and descriptor sets (section 5.8). This is precisely how Zink and ANGLE +work and it is why render systems need no changes. + +Texture parameter state is **per texture**, as in GL: `TexParameter` on the +texture bound to the active unit mutates that texture's min/mag filter, wrap, +LOD bias and compare mode; `BindSampler(unit, s)` overrides it for that unit. +Both resolve to an immutable `VkSampler` from a cache keyed by the parameter +tuple. The 107 `TexParameter` call sites and the FSR mip-bias +`SamplerParameter` calls in `ShaderRegistry.cs.patch` therefore work unchanged. + +### Coordinate conventions + +This is the part most GL-on-Vulkan ports get wrong, so the rule is stated once: + +- **No Y flip anywhere except the final present.** Rendering with an unflipped + viewport puts NDC y = −1 at image row 0 in both APIs, so every intermediate + render target, every CPU-uploaded texture, every sampled UV and every + `gl_FragCoord` read is bit-identical to GL. Screenshots and `ReadPixels` come + back bottom-up, exactly as the GL path produces them + (`Vintagestory.ClientNative/Screenshot.cs:73`). +- **Front face = `VK_FRONT_FACE_CLOCKWISE`.** GL's default CCW winding in a + y-up framebuffer is CW in Vulkan's y-down framebuffer with unflipped NDC. The + game never calls `GL.FrontFace`, so this is a constant. `GlCullFaceBack/Front` + map 1:1. +- **Depth range.** GL clip z ∈ [−w, w], Vulkan z ∈ [0, w]. The shader rewriter + wraps every vertex `main` and appends `gl_Position.z = (gl_Position.z + + gl_Position.w) * 0.5;` (section 6). Projection matrices, the frustum culler, + shadow orthos (`SystemRenderShadowMap.cs:163`) and mod matrices stay untouched. +- **The present pass** samples the internal default-framebuffer image with v + flipped into the swapchain image. This is also the seam where upscalers and a + frame-generation proxy attach later. +- `GL.DepthRange(0, 20000)` (`ScreenManager.cs:738`, `ClientMain.cs:1570`) and + `ClearBuffer(GL_DEPTH, 20000)` are clamped to [0, 1] by GL and are therefore + no-ops / clear-to-1.0. The device treats them the same; nobody should port + them literally. + +## 5. The Vulkan device + +### 5.1 Bindings, versions, features + +- **Silk.NET 2.23.0**: `Silk.NET.Vulkan`, `.Extensions.KHR`, `.Extensions.EXT`, + `Silk.NET.Shaderc` + `.Native` (runtime GLSL→SPIR-V), `Silk.NET.SPIRV.Cross.Native` + (debug-only reflection cross-check). All MIT/Apache-2.0, compatible with the + MIT half of the repository. +- **Vulkan 1.3 minimum.** Dynamic rendering, synchronization2, the 1.3-core + dynamic states (viewport/scissor with count, cull mode, front face, topology + within class, depth test/write/compare, stencil) and `maintenance4` remove + render-pass objects and most pipeline permutations. Arc 140V, RDNA, Turing+, + and Mesa ANV/RADV all report 1.3 or 1.4. Anything lower runs GL. +- **Required features:** `independentBlend` (OIT and SSAO use per-attachment + blend), `multiDrawIndirect` (chunk multidraw), `scalarBlockLayout` (1.2 core; + makes the generated uniform block match GL client memory byte for byte), + `timelineSemaphore` (1.2 core). **Optional:** `fillModeNonSolid` (wireframe + debug), `wideLines` (sets `SupportsThickLines`), `samplerAnisotropy`, + `VK_EXT_line_rasterization`. +- **Extensions:** `VK_KHR_swapchain`, surface extensions from + `glfwGetRequiredInstanceExtensions`, `VK_EXT_debug_utils` in debug. Phase 5 + adds `VK_KHR_external_memory_{win32,fd}` and `VK_KHR_external_semaphore_{win32,fd}`. + +### 5.2 Instance, device, surface, window + +OpenTK 4.9.4's GLFW bindings already expose `glfwVulkanSupported`, +`glfwGetRequiredInstanceExtensions`, `glfwCreateWindowSurface`, +`glfwGetPhysicalDevicePresentationSupport` and `glfwGetInstanceProcAddress` +(verified in the package). The window is created by the existing +`NativeWindowSettings` block with `API = ContextAPI.NoAPI`; +`GameWindowNative`'s constructor (`GameWindowNative.cs:24-26`) does +`GL.ClearColor/Clear/SwapBuffers` and is patched to skip them when +`OptimumRender.Device != null`. `window_RenderFrame`'s `SwapBuffers` +(`ClientPlatformWindows.cs:508`, already a transplant target for frame pacing) +becomes `device.Present()`. + +Device selection: prefer the adapter that presents to the surface; among those, +discrete over integrated unless `OptimumConfig.VulkanDeviceIndex` pins one. +Queue: one graphics+present queue (transfers on the same queue keep ordering +trivial). Validation layers and a debug messenger when `GlDebugMode` is on, with +messages routed through the existing `DebugCallback` logging shape +(`ClientPlatformWindows.cs:2017`). + +### 5.3 Frame lifecycle + +Two frames in flight (three later for frame generation). Per frame slot: a +command pool reset at frame start, one primary command buffer, a host-visible +**uniform ring** (16 MB), a **staging ring** (32 MB, grows), an **indirect-draw +ring**, a descriptor pool reset, and a **deferred deletion list** drained when +that slot's fence signals. `VAO.Dispose`/`UBO.Dispose`/`GLDeleteTexture` push +onto the current slot's list, so a resource used this frame is never destroyed +before its fence. Finalizer-thread disposes go through a concurrent queue into +the same list on the render thread. + +`Present` ends any open rendering, transitions the default-framebuffer image, +records the flip-blit into the acquired swapchain image, submits with the slot's +fence and a timeline semaphore, presents, and acquires the next image lazily on +first use of the default framebuffer in the following frame. + +### 5.4 Memory + +An own allocator rather than VMA: Silk.NET does not ship VMA, Optimum's +packaging is deliberately native-light, and the allocation pattern is simple. +Three pools: + +- **Device-local block pool** (128 MB blocks, first-fit free list with + coalescing) for static meshes, textures, render targets. Chunk mesh churn is + the load; free-list coalescing keeps fragmentation bounded, and a per-frame + defragmentation budget is a later option. +- **Host-visible, coherent ring buffers** for uniforms, staging, indirect + records; reset per frame slot. +- **Host-visible persistent buffers** for dynamic meshes (below). +- **Dedicated allocations** for images above 64 MB and for swapchain-sized targets. + +**Persistent mapping.** `AllocateEmptyMesh` maps dynamic (`!staticDraw`) buffers +with `MAP_WRITE|PERSISTENT|COHERENT` and the game writes straight into the +pointer (`ClientPlatformWindows.cs:2800-2843`, `updateVAO`). GL offers no +protection against writing a buffer the GPU is still reading and the game relies +on that being fine. The device reproduces the semantics with host-visible mapped +buffers (device-local + host-visible when ReBAR exposes it) and the same lack of +sync, so behaviour matches GL. An opt-in per-mesh double buffer (alternate by +frame slot) is the fix if tearing appears; it costs memory, not code paths. + +### 5.5 Meshes and draws + +`VAO`'s one-buffer-per-attribute layout (`xyz`, `normals`, `uv`, `rgba`, `flags`, +four custom parts with interleave stride/offsets, optional per-instance +divisor; `ClientPlatformWindows.cs:2787-2998`) maps to one `VkVertexInputBinding` +per buffer and one attribute per slot. The device derives a **vertex layout +signature** at allocation (formats, strides, rates, slot count) and interns it; +there are roughly fifteen distinct layouts in the game (Appendix C) and the +signature is part of the pipeline key. + +- `RenderMesh` → bind vertex buffers + index buffer (always `UNSIGNED_INT`, + `VK_INDEX_TYPE_UINT32`), resolve pipeline/descriptors, `vkCmdDrawIndexed`. +- `RenderMeshInstanced` → `instanceCount`. +- `RenderMesh(starts, sizes, groupCount)` (`MultiDrawElements`, + `ClientPlatformWindows.cs:1041-1062`) → write `groupCount` records into the + indirect ring, one `vkCmdDrawIndexedIndirect`. +- The SSBO path binds the mesh's `xyz` buffer as storage binding 3 plus the shared + `singleIndexBufferId`; the chunk shaders fetch vertices by `gl_VertexID`. The + device binds the same buffer as a storage descriptor (set 2, binding 3) and + the shared index buffer; nothing else changes. +- `RenderFullscreenTriangle` (`DrawArrays(3)`) → `vkCmdDraw(3)` with an empty + vertex-input state. +- `UpdateMesh` non-persistent → staging ring + `vkCmdCopyBuffer` with a pass + break if rendering is open (section 5.6); persistent → memcpy through the + mapped pointer exactly as today. + +### 5.6 Textures and samplers + +`VkTexture { image, memory, default view, per-layer views, format, mips, layers, +glState (filters, wrap, lodBias, compareMode), layout }`. Format map from the +GL enums the API exposes (`EnumTextureInternalFormat`: `Rgba8`, `Rgba16f`, `R16f`, +`DepthComponent32`; plus the vanilla-internal `RGB8` reveal, `RGB`, `RGBA32F`): + +| GL | Vulkan | +| --- | --- | +| `RGBA8` (32856) | `R8G8B8A8_UNORM` (BGRA uploads swizzled in the staging copy) | +| `RGBA16F` (34842) | `R16G16B16A16_SFLOAT` | +| `R16F` (33325) | `R16_SFLOAT` | +| `RGB8` (32849) / `RGB` (6407) | `R8G8B8A8_UNORM` (alpha ignored; RGB is not a guaranteed colour-attachment format) | +| `RGBA32F` (34836) | `R32G32B32A32_SFLOAT` | +| `DEPTH_COMPONENT32F` (33191) | `D32_SFLOAT` | +| default framebuffer depth/stencil | `D24_UNORM_S8_UINT` or `D32_SFLOAT_S8_UINT` (first supported) | + +- Uploads (`LoadTexture*`, `LoadIntoTexture`, `TexSubImage2D` from + `InventoryItemRenderer.cs:194` and `CloudRendererMap.cs:320`, Cairo/Skia + surfaces) copy into the staging ring and record `vkCmdCopyBufferToImage` with + layout transitions **inline in the frame command buffer at the point of the + call**. GL guarantees an upload between two draws is visible to the second + one; to keep that, the device ends the open `vkCmdBeginRendering` scope, + records the copy and barriers, and resumes with `LOAD_OP_LOAD`. Dynamic + rendering makes the break cheap; it happens a handful of times per frame + (atlas updates, GUI textures). +- `GenerateMipmap` → blit chain, as DXVK/Zink do. +- Cube maps (`Load3DTextureCube`) → 6-layer image with a cube view. +- 2D array textures with layered attachments (OIT accumulation, 3 layers of + `RGBA16F`, `SystemRenderOITLayers.cs:89-99`) → per-layer views attached as + colour attachments 3–5. +- `TEXTURE_COMPARE_MODE` toggling on shadow maps + (`SystemRenderFrameBufferDebug.cs:141-184`) → the texture's sampler state + gains `compareEnable`; the sampler cache resolves it. +- Samplers: `GenSampler(linear)` → handle over an immutable `VkSampler`; + `SamplerParameter(LOD_BIAS)` replaces the underlying object via the handle + table, so Optimum's FSR mip bias works. + +### 5.7 Render targets + +`FrameBufferRef.FboId` indexes an FBO record: attachment list (texture handle, +layer), draw-buffer mask, size. `LoadFrameBuffer` only records "current"; the +next draw begins rendering. Two GL behaviours are load-bearing: + +- **`DrawBuffers` selects the attachment subset, not just write masks.** + `RenderFinalComposition` (`ClientPlatformWindows.cs:1947-1996`) renders into + Primary attachment 0 with `DrawBuffers(1)` while sampling Primary attachment 1 + (`GlowParts2D`). Legal in GL because they are different textures. In Vulkan + attachment 1 must not be part of the rendering scope, so `vkCmdBeginRendering` + receives only the enabled attachments and attachment 1 is transitioned to + `SHADER_READ_ONLY_OPTIMAL`. `DrawBuffers(0)` (`EntityBehaviorHideWaterSurface.cs:114`) + is a depth-only scope. A change of the mask restarts the scope. +- **Clears.** `ClearFrameBuffer` variants and `ClearBuffer(COLOR, i, …)` map to + `vkCmdClearAttachments` inside the scope (start a scope if none is open). + Promoting a clear that immediately precedes a scope into `LOAD_OP_CLEAR` is a + later optimisation; correctness first. +- **Layouts** are tracked per image with synchronization2 barriers: + `COLOR/DEPTH_ATTACHMENT_OPTIMAL` while attached, `SHADER_READ_ONLY_OPTIMAL` + when bound to a unit, `TRANSFER_*` around uploads/readbacks. A texture that is + both currently attached and bound (a true GL feedback loop) is undefined in GL + too; the device logs it once in debug and proceeds. +- The vanilla default-framebuffer inventory is in Appendix B; the device + allocates identically sized images from the same `SetupDefaultFrameBuffers` + patch, which is already a transplant target for FSR. + +### 5.8 Pipelines and descriptors + +**Pipeline key** = program id · vertex layout id · colour formats[] · depth +format · enabled-attachment mask · per-attachment blend (enable, src/dst colour +and alpha factors, equations) · polygon mode · topology (`Triangles`, `Lines`, +`LineStrip` — outside one dynamic class, so keyed). Everything else (viewport, +scissor, cull, front face, depth test/write/func, stencil, line width) is 1.3 +dynamic state. Estimate: 42 programs × ~4 layouts × ~5 target sets × ~7 blend +states, in practice a few hundred pipelines, warmed from the on-disk +`VkPipelineCache`. + +**Descriptor sets**, one layout per program produced by the shader rewriter: + +- set 0, binding 0: the generated uniform block (`UNIFORM_BUFFER_DYNAMIC`); + bindings 1..n: the program's declared `std140` blocks (`CreateUBO`). +- set 1: combined image samplers, one binding per `sampler2D`/`sampler2DShadow`/ + `samplerCube` in declaration order (the same order `ShaderProgram.collectUniformNames` + assigns `textureLocations`, `ShaderProgram.cs:56-65`). +- set 2: storage buffers (SSBO vertex fetch at binding 3, entity animation data). + +Per draw: the program's uniform shadow buffer, if dirty, is copied into the +uniform ring and bound through the dynamic offset; sampler bindings resolve unit +→ (view, sampler) and hit a **descriptor-set cache** keyed by that tuple list +(chunks bind the same atlas thousands of times; hit rate is effectively 100%). +Bindless descriptor indexing would remove even that lookup but requires +rewriting sampler *use sites* in GLSL, which is out of scope for an automatic +rewriter; it is a later optimisation, not a requirement. + +### 5.9 Queries, readback, screenshots + +`SystemRenderSunMoon` uses one occlusion query (`SAMPLES_PASSED`, result +availability polled, `SystemRenderSunMoon.cs:57-127`) → a small query pool with +`vkGetQueryPoolResults(…AVAILABILITY_BIT)` mirroring `QUERY_RESULT_AVAILABLE`. +`GrabScreenshot`/`SaveScreenshot`/AVI recording read the default framebuffer → +copy the internal default image to a host-visible buffer, wait the frame fence, +return rows bottom-up as GL does. + +### 5.10 Caches + +- SPIR-V per stage keyed by SHA-256 of the *rewritten* source plus rewriter + version, stored under `.optimum/cache/spirv/`. +- `VkPipelineCache` blob keyed by device UUID and driver version, stored next to + it. Both are advisory; a miss recompiles. + +### 5.11 Swapchain, present, resize + +`B8G8R8A8_UNORM` (GL's default framebuffer is linear; the game never enables +`FRAMEBUFFER_SRGB`), `FIFO` when `VsyncMode != 0`, `MAILBOX` if available else +`IMMEDIATE` otherwise; `SetVSync` recreates. `Window_Resize` +(`ClientPlatformWindows.cs:745`) already calls `RebuildFrameBuffers`; the device +recreates the swapchain and default images in the same place. Minimised (0×0) +windows skip acquire. `OUT_OF_DATE`/`SUBOPTIMAL` recreate on the next frame. + +## 6. Shaders + +### The pipeline + +Today (`ClientPlatformWindows.cs:3691-3713`): `Shader.Code` is the include-expanded +source (`ShaderRegistry.HandleIncludes`), `PrefixCode` is the `#define` block +from `registerDefaultShaderCodePrefixes` spliced after the `#version` line, the +version is rewritten to 430 for SSBO shaders, and each stage compiles +independently; `CreateShaderProgram` links and `ShaderProgram.Compile` collects +uniform names with a regex and calls `GetUniformLocation`. + +On Vulkan, `CompileShader(IShader)` cannot produce SPIR-V by itself because GL +links uniforms *by name across stages*: `zNear` in both stages is one uniform. +The generated block must therefore be identical in every stage of a program. +So: + +1. `CompileShader`: splice prefix as today → **shaderc preprocess** (resolves + the `#define FXAA 1 … #if` structure so the rewriter never sees + conditionals) → parse declarations → stage a `StagedShader`. Returns true if + preprocessing succeeded; errors surface through the same `Shader compile + error in {file}` log line. +2. `LinkProgram`: union the stages' declarations → build one **program uniform + layout** → emit the rewritten source per stage → **shaderc compile** to + SPIR-V (cache hit or miss) → create shader modules and descriptor-set layouts + → `GetUniformLocation(name)` returns the byte offset into the block or −1. + Link errors surface as `Link error in shader program for pass {name}`. + +### The rewriter + +Runs on preprocessed GLSL, works on top-level declarations only, never on +function bodies except for one wrapper. Transforms: + +| Input | Output | +| --- | --- | +| `#version 330 core` / `#version 130` (`ShaderProgramMinimalGui.cs`) | `#version 450` + `#extension GL_EXT_scalar_block_layout : require`; other `#extension` lines dropped | +| `uniform float x; uniform vec3 v[8]; uniform float g = 1.0;` (default block) | members of `layout(scalar, set=0, binding=0) uniform OptimumUniforms { … };`, each carrying `layout(offset = N)`. Emitted **per stage**, holding only what that stage declared: a name that is a uniform in one stage can be a varying in another (`bilateralblur`), and the explicit offsets let the subsets share one buffer layout. Initialisers are pre-filled into the shadow buffer (`final.fsh` relies on `extraGamma = 1.0`) | +| `uniform sampler2D t;` | `layout(set=1, binding=N) uniform sampler2D t;` | +| `layout(std140) uniform Block { … };` | `layout(std140, set=0, binding=1+k) uniform Block { … };` - the memory-layout qualifier is preserved, since those blocks are filled by UBO uploads whose striding already matches | +| `layout(binding=3, std430) readonly buffer B { … };` | `layout(binding=3, std430, set=2) readonly buffer B { … };` - a binding the shader declared is kept, because the mesh path binds the vertex buffer to that exact index | +| `out vec2 texCoord;` (varying, no location) | `layout(location=N) out vec2 texCoord;`, with the matching `in` in the next stage given the same N. SPIR-V requires locations that GLSL 330 left implicit, so they are assigned program-wide | +| `out vec4 outColor;` without location (`blit.fsh`) | `layout(location=0) out vec4 outColor;`, filling the lowest slot left free by any explicit ones | +| vertex `void main() { … }` | renamed `_optimum_main`; new `main` calls it then applies the depth remap | +| `sample`, `patch`, `subroutine`, … as identifiers (`ssao.fsh`) | renamed `_optimum_kw_*`. 4.x reserved words that 330 allowed as names. `buffer` and `shared` are deliberately excluded: they are storage qualifiers in these shaders | +| `gl_VertexID`, `gl_InstanceID` | rewritten to `gl_VertexIndex` / `gl_InstanceIndex`. glslang does **not** accept the GL spellings for Vulkan. The Vulkan built-ins count from the draw's vertex offset and first instance, but this client sets neither | +| `gl_FragCoord` | unchanged; identical semantics under the no-flip convention | + +`scalar` layout is what makes `Uniform1(count, float[])`, `Uniforms3(count, +float[])`, `UniformMatrix4x3` and the `fogSpheres`/`pointLights`/`colorMapRects` +arrays pack exactly as GL client memory does (`float[]` stride 4, `vec3[]` +stride 12, `mat4x3` 48 bytes). With `std140` the CPU side would have to re-stride +every array upload; with `scalar` the setter is a `memcpy` at the recorded +offset. Shadow buffers are per program; the `Uniform(name, …)` overloads in +`ShaderProgramBase` become `device.SetUniform(programId, offset, span)`. + +Sampler uniforms: `BindTexture2D(name, texId, unit)` (`ShaderProgramBase.cs:207-219`) +does `Uniform1(loc, unit); ActiveTexture(unit); BindTexture(id)` in GL. The device +records name→unit on the program and unit→texture on the context; at draw the +program's set-1 bindings resolve through both maps. `SystemRenderOITLayers.cs:33` +setting a sampler to unit 7 directly is the same `SetSamplerUnit` operation. + +`program.attributes` / `BindAttribLocation` (`ClientPlatformWindows.cs:3737-3740`): +every shipped shader already declares explicit locations (Appendix C). The +rewriter honours `attributes` by rewriting the `in` declaration's location when +a mod uses the API instead of `layout(location)`. + +### Mod shaders + +Mod GLSL goes through the identical path from `RegisterFileShaderProgram`. What +fails is what would fail on a stricter GL driver: syntax glslang rejects, or +constructs the rewriter does not recognise at top level. Failure sets +`LoadError` and logs, exactly as a GL compile error does today, and the mod's +own fallback logic runs. Shader hot reload (`ReloadShaders`, the `.rs` command) +disposes programs, which invalidates their pipelines. + +### CI coverage + +A test compiles all 84 vanilla shaders, the 19 includes' users, Optimum's own +`sources/shaders/*` overlays and `ShaderProgramMinimalGui` through +preprocess → rewrite → shaderc on Linux CI (`Silk.NET.Shaderc.Native` runs +headless). Golden files pin the rewriter output for a handful of representative +shaders so a rewriter change is a visible diff. + +## 6a. Constant defaults for unsupplied vertex attributes + +GL guarantees a value for every vertex attribute a shader reads, whether or not +the draw supplies one: an unbound attribute reads the *current generic vertex +attribute*, which starts at `(0, 0, 0, 1)`. Vulkan has no equivalent. A pipeline +declares exactly the attributes its vertex input state names, and a shader input +with no matching attribute reads undefined values. + +The game relies on the GL behaviour constantly, because meshes are built from +whichever `MeshData` parts a call site needs while shaders declare the full set. +The GUI quad is the clearest case: `QuadMeshUtilExt.GetQuadModelData()` builds it +with `withRgba: false` and no flags, so it carries positions and UVs alone, while +`gui.vsh` declares six inputs - adding `colorIn`, `renderFlagsIn`, +`damageEffectIn` and `jointId`. Three of those feed real logic: + +- `renderFlagsIn` supplies the glow level and the packed normal. +- `jointId` indexes the animation transform array. +- `damageEffectIn` becomes `damageEffectV`, and `gui.fsh` opens with + `if (def > 0) { ... if (f < def - 1.3) discard; }`. + +Undefined there does not warn, does not fail validation, and does not crash. It +discards every fragment, so the whole interface renders as nothing over a correct +background - which reads as a texture or blending fault and is not. + +The device therefore supplies the defaults itself, the way Zink and ANGLE do. +`ProgramInterfaceLayout.VertexInputs` records every input a program declares with +its location and type. At draw time +`VertexLayoutDescription.WithDefaultsFor(declared)` merges the mesh's layout with +one extra attribute per missing location, all pointing at a reserved binding +(`DefaultAttributeBinding`, 15 - meshes number from zero and the Vulkan minimum +for `maxVertexInputBindings` is 16, so it never collides) whose stride is zero, so +every vertex reads the same constant. The buffer behind it is 32 bytes: +`(0, 0, 0, 1)` as floats, then again as integers, because an integer attribute has +to read integer zeros rather than reinterpret float bits. + +The pipeline key already names both the program and the mesh layout, so the merged +result is stable per cache entry and costs nothing per draw beyond one extra +`vkCmdBindVertexBuffers`. + +## 7. Render systems and GL leak sites + +Every direct `GL.*` use outside `ClientPlatformWindows`, with its treatment: + +| File | Lines | What it does | Treatment | +| --- | --- | --- | --- | +| `ShaderProgramBase.cs` | 38 sites | uniforms, use/stop, bind texture/sampler, dispose | branch every method to the device; already the mod-facing implementation | +| `VAO.cs` | `Dispose` | deletes buffers/VAO | branch to `device.DeleteMesh(VaoId)` (deferred) | +| `UBO.cs` | all | bind base 0, `BufferData`/`SubData` | branch to `device.UpdateUBO` (ring copy) | +| `SystemRenderOITLayers.cs` | 33–115 | array texture, layered attachments, `BlendFunci`, `ClearBuffer` per attachment, direct unit binds | branch inside the already-patched file (Optimum owns it, with `optimumOitDisabled` fallback) | +| `ChunkRenderer.cs` | 323–331 | `BindSampler(unit, 0)` ×9 | `device.BindSampler(unit, 0)` | +| `SystemRenderSunMoon.cs` | 57–127, 425 | occlusion query, `ColorMask` | query group | +| `SystemRenderFrameBufferDebug.cs` | 141–184 | `TEXTURE_COMPARE_MODE` on shadow maps | `device.SetTexParameter(id, COMPARE_MODE, …)` | +| `SvgLoader.cs` | 87–93 | texture from raw pointer | `device.CreateTexture2D(rgba8, ptr)` | +| `ScreenManager.cs` | 737–738 | `ClearBuffer(depth, 20000)`, `DepthRange` | clear depth 1.0; no-op | +| `ClientMain.cs` | 1570, 1580 | `DepthRange` | no-op | +| `InventoryItemRenderer.cs` | 194 | `TexSubImage2D` clear of an atlas region | sub-upload | +| `ClientSystemStartup.cs` | 1076 | `GetString(RENDERER).Contains("Arc(TM)")` gate for SSBOs | `device.Renderer` string; the Arc SSBO workaround is a GL-driver bug and is skipped on Vulkan | +| `Screenshot.cs` | 73 | `ReadPixels` BGRA | readback group | +| `GameWindowNative.cs` | 24–26 | clear + swap in ctor | skipped on Vulkan | +| `VSEssentials/FluffyClouds/CloudRendererMap.cs` | 181–381 | own FBO with 2 MRTs, save/restore of `FRAMEBUFFER_BINDING`/`VIEWPORT`, `TexSubImage2D` of 16-bit data, `Uniform3` via `GetUniformLocation`, blend/depth toggles | runtime patch (`patches/runtime/VSEssentials`): use `IRenderAPI.CreateFramebuffer`/`LoadFrameBuffer` and `device` getters for the save/restore; this is the largest port outside the platform class (36 sites) | +| `VSEssentials/FluffyClouds/CloudRendererVolumetric.cs` | 79–82 | depth/blend toggles | `IRenderAPI` equivalents | +| `VSSurvivalMod/…/EntityBehaviorHideWaterSurface.cs` | 114, 131 | `DrawBuffers(0)` then `DrawBuffers(6)` | `device.SetDrawBuffers` | + +Render systems that only use the platform API (`SystemRenderTerrain`, `Entities`, +`Particles`, `Decals`, `NightSky`, `SkyColor`, `ShadowMap`, `Aim`, `InsideBlock`, +`PlayerEffects`, `DebugWireframes`, `PlayerAimAcc`, `RiftTest`, `InventoryItemRenderer` +beyond line 194, `ChunkRenderer` beyond the sampler resets) need no change. The +post chain (`RenderPostprocessingEffects` 1818–1949, `RenderFinalComposition` +1951–2016, `MergeTransparentRenderPass` 1795–1816, `BlitPrimaryToDefault` 2036) +is inside `ClientPlatformWindows` and becomes transplant targets with the branch. + +## 8. Shipping it in Optimum's framework + +### New projects + +| Project | Licence scope | Contents | +| --- | --- | --- | +| `optimum-api-contracts` (extended) | MIT | `IOptimumGraphicsDevice`, handle/enum types, `OptimumRender` static holder, `OptimumRenderBackend` enum | +| `Optimum.Render.Vulkan` | MIT | the device, allocator, shader pipeline, caches; references `VintagestoryAPI`, Silk.NET | +| `Optimum.Render.Vulkan.Tests` | MIT | rewriter goldens, allocator, pipeline-key, state-machine, lavapipe smoke | + +### Patches (scope: `patches/`) + +- `ClientPlatformWindows.cs.patch`: the branch in every graphics method (the + method index at `ClientPlatformWindows.cs:1004-3755`, roughly 95 methods) plus + `Start` (line-width probe → `device.SupportsThickLines`), `window_RenderFrame` + (present), `Window_Resize`, `LogAndTestHardwareInfosStage2`. +- `ClientProgram.cs.patch`: backend resolution before `NativeWindowSettings` + (`ClientProgram.cs:281`), `ContextAPI.NoAPI`, skip `AttemptToOpenWindow`'s GL + version fallback loop on Vulkan, `AllowSSBOs` decided by the device. +- `GameWindowNative.cs.patch`, `ShaderProgramBase.cs.patch`, `ShaderProgram.cs.patch` + (uniform collection stays; location lookup goes through the device), + `Shader.cs.patch` (`EnsureVersionSupported` short-circuits on Vulkan), + `ShaderRegistry.cs.patch` (existing; sampler parameter calls branch), + `VAO.cs.patch`, `UBO.cs.patch`, and the leak sites in section 7. +- `Optimum.Patcher/Program.cs`: new `typesToInject` (none for the device; the + holder lives in contracts), `membersToInject` for the new fields, and the + transplant `targets`. `patches/cecil-owned.list` updated accordingly; + `check-patches.sh` keeps them honest. + +### Launcher + +- `AssemblyLoader` already resolves from the launcher directory; + `Optimum.Render.Vulkan.dll` and the shaderc native library ship next to + `Optimum.exe`. The device is instantiated by name through + `Assembly.Load` + `Activator.CreateInstance` from the contracts holder, so + `VintagestoryLib` never references the Vulkan assembly. +- `ShaderCompatibilityScanner` (`Optimum.Launcher/ShaderCompatibilityScanner.cs`) + gains a **backend scan**: metadata-only inspection of mod assemblies for + references to `OpenTK.Graphics.OpenGL`, `OpenTK.Graphics.OpenGL4`, + `OpenTK.Graphics.ES30` and P/Invokes into `opengl32`/`libGL`. Results land in + the existing `shader-compatibility.json` as a `glBoundMods` list; + `OptimumConfig` exposes `IsShaderFeatureDisabled("Vulkan")` in the same style. + A second, advisory token scan flags Harmony mods that name + `ClientPlatformWindows`, `ShaderProgramBase` or `VAO`, because they may patch + internals the branch bypasses; those produce a warning, not a fallback. + +### Config and settings + +`OptimumConfig`: `Renderer`, `VulkanValidation`, `VulkanDeviceIndex`, +`VulkanPresentMode`, `VulkanDynamicMeshDoubleBuffer`. Persisted through +`OptimumConfigData` like `RenderScale`. Settings tab: a "Renderer" dropdown +(restart required), the active backend and fallback reason, a validation +toggle. Same `GuiCompositeSettings` injection pattern as the existing entries. + +### Build, deploy, package + +- `Directory.Build.props` unchanged; the new projects join `VintageStory.slnx` + (the solution-integrity test checks listed projects exist). +- `Makefile deploy` copies `Optimum.Render.Vulkan.dll`, `Silk.NET.*.dll` and the + shaderc native library into `VANILLA_DIR`/`INSTALL_DIR` alongside + `Optimum.Api.Contracts.dll`. +- Packaging: the Vulkan loader is system-provided (`vulkan-1.dll` from the + driver on Windows; `libvulkan.so.1` on Linux). The AppImage lists it as a + runtime dependency; the installer's prerequisite scan reports its absence as + "Vulkan backend unavailable, OpenGL will be used", never as a hard failure. +- CI: `ci-installer.yml`/`ci-scripts.yml` gain the shader-corpus and lavapipe + jobs (section 10). + +### Keeping up with upstream + +Each Vintage Story release re-verifies transplant targets. The branch pattern +keeps every graphics-method transplant a two-line delta over vanilla, so a +rebase is a `check-patches.sh` run plus re-reading the vanilla body for new GL +calls. Appendix A's entry-point list is the checklist: a new `GL.*` symbol in the +decompile means a new device operation. + +## 9. Compatibility policy + +| Situation | Behaviour | +| --- | --- | +| `Renderer = opengl` | vanilla GL path; no Vulkan code loads | +| Mod references `OpenTK.Graphics.OpenGL*` or P/Invokes GL | session forced to `opengl`; log + one-time notice naming the mod | +| Mod uses only `IRenderAPI`/`IShaderAPI` and GLSL 330 | works on Vulkan; shader failures degrade per mod as today | +| Harmony mod patching platform internals | warning; runs on Vulkan; user can pin `opengl` | +| Vulkan < 1.3, missing required feature, no presentable queue | `opengl` with reason | +| Previous Vulkan session left a crash marker | one `opengl` session, marker cleared | +| macOS | `opengl` | +| Wayland/X11 | both via GLFW surfaces; Wayland tested explicitly (the code base already special-cases `IsWaylandSession`) | + +The GL SSBO gate for Arc (`ClientSystemStartup.cs:1076`) and the 4.3-fallback +loop in `AttemptToOpenWindow` are GL-driver workarounds and are bypassed on +Vulkan; `UseSSBOs` on Vulkan is true whenever `multiDrawIndirect` is present. + +## 10. Testing strategy + +- **Unit (no GPU):** rewriter goldens; uniform layout offsets against + hand-computed `scalar` rules; pipeline-key hashing and equality; allocator + free-list behaviour; state-machine → pipeline/dynamic-state resolution; + deferred deletion ordering; format map; the GL-shape parity of `Appendix A` + (a test that greps the decompile for `GL.` symbols and fails on one the + mapping table lacks). +- **Shader corpus:** every vanilla and Optimum shader through the full pipeline + in CI; failure is a CI failure. +- **Headless device smoke in CI:** lavapipe (`VK_ICD_FILENAMES` → Mesa's + `lvp_icd.json`) creates the device, compiles the corpus, renders a triangle + and the GUI quad off-screen and checks pixels. No window needed. +- **Parity harness (local, per phase gate):** launch with `--rndWorld -p creativebuilding` + on a fixed seed and position (reuse `scripts/trace-teleport.sh`'s mechanism), + capture `GrabScreenshot` on both backends, compare with SSIM per settings + permutation (SSAO 0/1/2, shadows 0/1/2, bloom, godrays, FXAA, render scale). + Threshold starts loose (0.95) and tightens to 0.99 by Phase 3; known, + explained differences (blend precision, mip selection) are listed. +- **Validation-clean:** debug runs with layers enabled must produce zero errors + through a scripted session (menu → world → weather → water → night → exit). +- **Performance:** `scripts/benchmark-frametime.sh` and + `scripts/benchmark-renderscale.sh` on both backends; the Phase 3 gate is + Vulkan ≥ GL mean FPS and ≤ GL p99 frame time on each vendor. +- **Vendor matrix:** Intel Arc 140V (Windows), Intel iGPU (Mesa ANV), AMD + (RADV + Windows), NVIDIA (Windows + Linux proprietary), lavapipe. Each is a + row in the release checklist before `auto` includes it. +- **Patch-shape tests** in `Optimum.Tests`, following + `fsr-pipeline-coverage-tests.cs`: the transplanted methods contain the branch, + contain no `<>c` lambdas, and the cecil-owned list matches `Program.cs`. + +### Definition of done per phase + +Each phase below ends with: its tests green in CI, the parity harness at its +threshold, validation-clean, and a written entry in `docs/releases/` naming +what still falls back to GL. + +## 11. Rollout plan + +Estimates assume one experienced graphics engineer, full-time-ish, and are +ranges because the unknowns are driver behaviour and Cecil surprises, not +design. + +### Phase 0 — spikes (2–3 weeks) + +1. `ContextAPI.NoAPI` window + Vulkan surface through OpenTK's GLFW on CachyOS + (X11 and Wayland) and the Claw; swapchain clear-to-colour at 120 Hz. +2. Cecil: transplant the `ClientProgram` constructor region (or IL-hook the + `NativeWindowSettings` construction via `Optimum.Patcher/ILHook.cs`) and + confirm transplanted bodies can call into the contracts holder. This is the + one integration risk with no precedent for the size involved. +3. Rewriter prototype over the full corpus; report compile rate. Target: 84/84. +4. Allocator decision confirmed by a chunk-churn simulation. + +Exit: a Vulkan window shows the main menu background colour; the shader corpus +compiles; the patch pipeline produces a launchable DLL with the branch in one +method. + +### Phase 1 — device core and GUI (3–4 weeks) + +Frames in flight, uniform ring, textures (Cairo/Skia uploads, mipmaps), +samplers, default framebuffer + present flip, pipeline/descriptor caches, the +GUI and `MinimalGui` programs, screenshots. + +Exit: main menu and settings screens are pixel-identical to GL; the loading +screen works; resize and fullscreen toggles work. + +### Phase 2 — world rendering (6–10 weeks) + +Chunk VAO and SSBO paths with indirect multidraw, shadow maps, entities +(animation UBO/SSBO), particles (instanced streams), sky/night sky/celestial +objects with the occlusion query, clouds (FluffyClouds port), decals, block +highlights, held item, wireframe debug, dynamic meshes with persistent +mapping. + +Exit: in-world parity ≥ 0.97 SSIM without post effects; no validation errors; +frame time within 20% of GL. + +### Phase 3 — post-processing and parity (4–6 weeks) + +OIT layers (layered attachments, per-attachment blend), transparent merge, +bloom, god rays, SSAO (4-MRT primary, `BlendEquation` on attachments 2/3), +luma/final composition with the attachment-subset rule, FXAA, Optimum's FSR1 +at the blit, AVI recording, frame-buffer debug view. + +Exit: parity ≥ 0.99 across the settings permutations; Vulkan ≥ GL on mean FPS +and ≤ GL on p99 per vendor; `Renderer = vulkan` ships opt-in. + +### Phase 4 — hardening and `auto` (4–8 weeks) + +Mod scan and crash-loop guard, caches, device-lost and low-memory handling, +monitor/DPI/present-mode changes, Wayland, AMD/NVIDIA rows of the matrix, the +per-vendor allow-list for `auto`, installer/packaging, documentation. + +Exit: `auto` selects Vulkan on the green rows; GL remains default elsewhere. + +### Phase 5 — the payoff (sized separately) + +TAA resolve moves to the device's present seam; XeSS-SR, DLSS-SR and FSR 2 run +natively on the Vulkan device with the velocity buffer and jitter from the +parallel TAA work; the D3D12 presentation proxy for XeSS-FG/XeLL imports the +Vulkan-exported final image and depth/motion vectors through +`VK_KHR_external_memory_win32`, which is the direction of interop DXVK-NVAPI +and vkd3d already exercise daily. + +### In parallel, on OpenGL, from now + +Jitter in `ClientMain.Set3DProjection` (`ClientMain.cs:1419`, the single +projection choke point), a velocity MRT on the Primary FBO, previous-frame +matrices, and a TAA resolve at `BlitPrimaryToDefault`. None of it is +API-specific; it lands on GL first and ports to the device as a shader set. + +Total to an opt-in, parity-level Vulkan renderer: roughly **5–8 months**. + +## 12. Risks and open questions + +### What running the client taught, and how to debug it + +A Vulkan frame can be entirely legal and entirely wrong. All three bugs that stood +between "the backend initialises" and "the interface renders" produced **zero +validation messages**: a render target smaller than the viewport, undefined vertex +attributes, and a `#version` floor applied a step earlier than expected. The +validation layer answers "is this API usage legal", never "is this the frame the +GL path would have produced". + +Two things follow. + +First, **parity is claimed by running the client, not by auditing call sites.** The +audit that produced "187/187, Phase 1 complete" swept for `GL.` and missed every +property accessor plus everything a static reading cannot prove is reached. Thirty +more entry points surfaced the moment a real frame ran. + +Second, the backend carries its own trace, because the validation layer cannot +answer the questions that matter here. Setting `OPTIMUM_RENDER_TRACE` to a file +path turns on `Optimum.Render.Vulkan.Core.RenderTrace`, which records per draw: +the mesh and program, the resolved index count, the bound texture, the target, +depth/blend/cull/scissor/viewport state, whether the uniform ring allocation +succeeded, and whether constant defaults were merged into the vertex layout. It +also writes each program's uniform block layout with byte offsets, a checksum of +every texture upload, the reason any draw was skipped, and each stage's rewritten +GLSL beside the trace file. It is off unless the variable is set, so it costs one +static null check in a release build. + +The single most useful line proved to be the one comparing the default framebuffer +against the swapchain extent - `default framebuffer id=1 1280x850 +swapchain=2561x1601` located in seconds what hours of reasoning about blending and +depth had not. + +| Risk | Likelihood | Mitigation | +| --- | --- | --- | +| Intel Windows Vulkan driver quirks on Arc (descriptor limits, dynamic rendering corner cases) | medium | validation-clean gate; Arc is a first-class matrix row from Phase 1; `auto` allow-list | +| Cecil cannot transplant the `ClientProgram` constructor cleanly | medium | Phase 0 spike; fallback is an IL hook at the window-settings site | +| Third-party GL-bound mods (Volumetric Shading-class mods) | certain | detected before the window opens; forced GL; clear notice | +| Harmony mods touching platform internals | medium | advisory scan; `opengl` pin; document the branch pattern for mod authors | +| Persistent-mapped dynamic meshes tear worse than GL | low–medium | per-mesh double buffer option | +| Pipeline/descriptor churn regresses frame time | low | caches, dynamic state, indirect multidraw; benchmark gate | +| Memory fragmentation from chunk churn in the block pool | medium | coalescing free list; defragmentation budget; telemetry in the stutter watch | +| Wayland surface/present edge cases | medium | explicit Wayland row in the matrix | +| Upstream release moves GL code | certain, per release | two-line branch deltas; Appendix A checklist | +| Native dependency size (shaderc ~10 MB per platform) | low | acceptable; matches existing SkiaSharp footprint | + +Open questions for the maintainer: + +1. Vulkan 1.3 floor — confirm nothing you care about sits below it. +2. Abstraction in API contracts (mod-visible, one Cecil path) versus a private + assembly (cleaner, unexercised Cecil path). This plan chooses contracts. +3. Hand-maintained Vulkan-GLSL copies of the 84 shaders instead of the rewriter? + The rewriter is recommended because it is the only way mod GLSL works, but a + hybrid (rewriter by default, hand-written override per shader) is cheap to + allow. +4. Whether Phase 5's D3D12 proxy should reuse Community Shaders' `DX12SwapChain` + design directly, given you wrote it. + +## 13. Considered alternatives + +- **GL sidecar + interop (no backend).** Probe-proven on both platforms. Gets + XeSS-SR/DLSS-SR/FSR 2 with a fraction of the work by sharing the Primary + textures out at `BlitPrimaryToDefault`. Cannot own presentation, so no frame + generation, and stays on Intel's GL driver. Remains the right fallback for + hardware that never reaches Vulkan 1.3 and is not precluded by this plan. +- **Zink.** Mesa's GL-on-Vulkan would move the game off Intel's GL driver on + Linux with an environment variable, and is a useful sanity check for the + parity harness. It gives no control over the device or swapchain and does not + exist as a shipping path on Windows. +- **D3D12 backend.** Native XeSS-FG, but Linux only through VKD3D-Proton, which + undoes the native Linux client. The `IOptimumGraphicsDevice` boundary is + API-neutral; if a D3D12 device is ever wanted, it slots in beside the Vulkan + one without touching the game. + +## 14. Files and surfaces touched + +### New + +- `optimum-api-contracts/optimum-render-device.cs` — `IOptimumGraphicsDevice`, + `OptimumRender`, handle and enum types. +- `Optimum.Render.Vulkan/` — `VulkanDevice.cs`, `VulkanContext.cs` (instance, + device, queues), `Swapchain.cs`, `FrameSlots.cs`, `Allocator.cs`, + `Resources/{Textures,Buffers,Meshes,Framebuffers,Samplers,Queries}.cs`, + `State/{StateTracker,PipelineKey,PipelineCache,DescriptorCache}.cs`, + `Shaders/{Preprocessor,Rewriter,UniformLayout,Compiler,SpirvCache}.cs`, + `Present.cs`, `Readback.cs`, `Diagnostics.cs`. +- `Optimum.Render.Vulkan.Tests/`. +- `sources/shaders/optimum-present.{vsh,fsh}` — the flip-blit. +- Documentation: this plan; a per-phase entry in `docs/releases/`. + +### Modified + +- `patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch` + (the bulk), `ClientProgram.cs.patch`, `GameWindowNative.cs.patch` (new), + `ShaderProgramBase.cs.patch` (new), `ShaderProgram.cs.patch` (new), + `Shader.cs.patch` (new), `ShaderRegistry.cs.patch`, `VAO.cs.patch` (new), + `UBO.cs.patch` (new), `SystemRenderOITLayers.cs.patch`, `ChunkRenderer.cs.patch`, + `SystemRenderSunMoon.cs.patch` (new), `SystemRenderFrameBufferDebug.cs.patch` + (new), `SvgLoader.cs.patch`, `ScreenManager.cs.patch` (new), `ClientMain.cs.patch`, + `InventoryItemRenderer.cs.patch` (new), `ClientSystemStartup.cs.patch`, + `Vintagestory.ClientNative/Screenshot.cs.patch` (new). +- `patches/runtime/VSEssentials/…/CloudRendererMap.cs.patch`, + `CloudRendererVolumetric.cs.patch` (new); `patches/runtime/VSSurvivalMod/…/EntityBehaviorHideWaterSurface.cs.patch` (new). +- `Optimum.Patcher/Program.cs`, `patches/cecil-owned.list`. +- `Optimum.Launcher/ShaderCompatibilityScanner.cs`, `Program.cs` (backend + resolution, crash marker), `AssemblyLoader.cs` (no change expected; verify). +- `sources/VintagestoryApi/Config/OptimumConfig.cs`, `GuiCompositeSettings.cs.patch`. +- `Makefile`, `scripts/package-*.sh|ps1`, installer prerequisite list, + `VintageStory.slnx`, CI workflows, `README.md` feature list. + +### Kept as-is + +Every render system not named in section 7, all vanilla GLSL, the FSR1 shaders, +the frame-pacing and background-FPS logic, the whole server side. + +--- + +## Appendix A — GL entry points → device operations + +All 102 distinct `GL.*` symbols in the client, from the decompile +(`grep -rhoE '\bGL\.[A-Za-z0-9_]+' _ref`). Counts are call sites. + +| Device group | Entry points (count) | +| --- | --- | +| Texture state | `TexParameter` (107), `BindTexture` (40), `GenTexture` (29), `TexImage2D` (25), `DeleteTexture` (11), `BindSampler` (11), `TexSubImage2D` (7), `ActiveTexture` (7), `TexImage3D` (1), `GetTexParameter` (1), `GenerateMipmap` (2), `GenSampler` (1), `DeleteSampler` (1), `SamplerParameter` (2) | +| Buffers / meshes | `BindBuffer` (43), `GenBuffer` (16), `BufferData` (16), `VertexAttribPointer` (16), `BindVertexArray` (14), `VertexAttribDivisor` (13), `DeleteBuffer` (11), `BufferSubData` (9), `VertexAttribIPointer` (8), `BindBufferBase` (4), `GenVertexArray` (3), `EnableVertexAttribArray` (3), `BufferStorage` (3), `MapBufferRange` (2), `DeleteVertexArray` (1), `DeleteBuffers` (1) | +| Draws | `MultiDrawElements` (2), `DrawElementsInstanced` (1), `DrawElements` (1), `DrawArrays` (1) | +| Framebuffers | `FramebufferTexture2D` (19), `DrawBuffer` (18), `ClearBuffer` (18), `GenFramebuffer` (16), `DrawBuffers` (13), `BindFramebuffer` (8), `FramebufferTextureLayer` (3), `DeleteFramebuffer` (3), `ReadBuffer` (3), `ReadBuffers` (1), `Clear` (3), `ClearColor` (2), `ReadPixels` (1) | +| Fixed-function state | `Viewport` (18), `Enable` (15), `Disable` (14), `BlendFunc` (14), `DepthFunc` (6), `BlendEquation` (5), `ColorMask` (3), `DepthRange` (3, no-op), `BlendFuncSeparate` (3), `LineWidth` (2), `CullFace` (2), `Hint` (2, ignored), `Scissor` (1), `PolygonMode` (1), `DepthMask` (1), `StencilOp` (1), `StencilMask` (1), `StencilFunc` (1), `IsEnabled` (1) | +| Shaders / uniforms | `Uniform3` (6), `Uniform1` (6), `Uniform2` (4), `GetUniformLocation` (4), `UniformMatrix4` (3), `Uniform4` (3), `DetachShader` (3), `DeleteShader` (3), `AttachShader` (3), `UseProgram` (2), `UniformMatrix4x3` (1), `UniformBlockBinding` (1), `ShaderSource` (1), `LinkProgram` (1), `GetUniformBlockIndex` (1), `GetShaderInfoLog` (1), `GetShader` (1), `GetProgramInfoLog` (1), `GetProgram` (1), `DeleteProgram` (1), `CreateShader` (1), `CreateProgram` (1), `CompileShader` (1), `BindAttribLocation` (1) | +| Queries | `GetQueryObject` (2), `GenQueries` (1), `EndQuery` (1), `DeleteQuery` (1), `BeginQuery` (1) | +| Capabilities / debug | `GetString` (12), `GetInteger` (7), `GetError` (4), `GetFloat` (2), `MaxVertexUniformComponents` (1), `MaxUniformBlockSize` (1), `DebugMessageCallback` (1) | + +Notable absences that simplify the device: no `FrontFace`, no `PolygonOffset`, +no `ClipControl`, no multisample state, no `TexStorage`, no compute, no +transform feedback, no `PrimitiveRestart`. + +## Appendix B — vanilla framebuffer inventory + +From `SetupDefaultFrameBuffers` (`ClientPlatformWindows.cs:1155-1561`); +`num × num2` is the window size × `ssaaLevel`. + +| Slot | Enum | Size | Attachments | +| --- | --- | --- | --- | +| 0 | Primary | full | D32F depth; RGBA8 colour; RGBA8 glow; + RGBA16F position, RGBA16F normal when SSAO | +| 1 | Transparent | full | RGBA16F accumulation; R16F revealage; RGBA8 glow (Optimum OIT adds a 3-layer RGBA16F array + RGB8 reveal) | +| 2, 3 | BlurHorizontal/VerticalMedRes | ½ | RGBA8 | +| 4 | FindBright | full | RGBA16F | +| 5 | LiquidDepth | ¼ | D32F | +| 7 | GodRays | ½ | RGBA16F | +| 8, 9 | BlurVertical/HorizontalLowRes | ¼ | RGBA8 | +| 10 | Luma | full | RGBA16F | +| 11, 12 | ShadowmapFar/Near | by shadow quality | D32F | +| 13–17 | SSAO and blurs | full or ½ | RGB / RGBA32F / RGBA8 | +| 18 | Optimum FSR intermediate | native window | RGBA8 | + +## Appendix C — vertex attribute contracts + +Explicit `layout(location)` declarations in the vanilla vertex shaders; the +device's vertex-layout signatures are derived from the `MeshData` parts that +feed each one. + +| Program | Locations | +| --- | --- | +| chunkopaque / chunktransparent / chunkshadowmap | 0 xyz vec3 · 1 uv vec2 · 2 rgbaLight vec4 (u8 norm) · 3 renderFlags int · 4 colormapData int | +| chunktopsoil | … · 4 uv2 vec2 · 5 colormapData int | +| chunkliquid | 0 xyz · 1 uv · 2 rgbaLight · 3 renderFlags · 4 flowVector vec2 · 5 colormapData · 6 waterFlags int | +| entityanimated / gui | 0 pos vec3 · 1 uv · 2 color vec4 · 3 flags int · 4 damageEffect float · 5 jointId int | +| standard | 0 pos · 1 uv · 2 color · 3 flags · 4 glowSub float | +| helditem | 0 pos · 1 uv · 2 modelColor · 3 flags | +| particlesquad | 0 vertexPosition · 1 uv · 2 baseColor · 3 renderFlags · 4 particlePosition (inst) · 5 scale (inst) · 6 particleDir (inst) · 7 rgbaLight (inst) · 8 rgbaBlock (inst) | +| particlescube | 0 pos · 1 normal vec4 (int-2-10-10-10 norm) · 2 uv · 3 renderFlags · 4–8 instanced as above | +| instanced | 0 pos · 1 uv · 2 rgbaBlock · 3 renderFlags · 4 rgbaLight · 5–8 transform mat4 (inst) | +| decals | 0 pos · 1 decalUv · 2 rgbaLight · 3 renderFlags · 4 blockUv · 5 decalUvSize · 6 decalUvStart | +| lines | 0 quadCoord · 1 uv · 2 pointA · 3 pointB | +| sky | 0 pos · 1 color | +| clouds | 0 pos · 1 rgbaBase · 2 flags · 3 cloudTileOffset · 4 neibCloudThickness vec4 · 5–9 floats | +| fullscreen passes (blit, final, blur, fsr-*, ssao, godrays, luma, findbright, transparentcompose) | none; `gl_VertexID` triangle | diff --git a/VintageStory.slnx b/VintageStory.slnx index 47d6300a..d1924e4b 100644 --- a/VintageStory.slnx +++ b/VintageStory.slnx @@ -18,9 +18,17 @@ + + + + + + + + Exe + net10.0 + Optimum.InteropProbe + InteropProbe + true + + + + + + + diff --git a/tools/InteropProbe/Program.cs b/tools/InteropProbe/Program.cs new file mode 100644 index 00000000..eec06a12 --- /dev/null +++ b/tools/InteropProbe/Program.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using OpenTK.Graphics.OpenGL4; +using OpenTK.Mathematics; +using OpenTK.Windowing.Desktop; + +namespace Optimum.InteropProbe; + +/// +/// Reports which OpenGL-to-D3D / OpenGL-to-Vulkan texture sharing paths the +/// current driver actually exposes. +/// +/// Vintage Story renders in OpenGL, but every upscaler worth having (XeSS, +/// DLSS, FSR2+) and all frame generation run in D3D or Vulkan. Bridging that +/// gap needs the driver to let us share a texture across APIs. Which of the +/// candidate routes exist is a per-driver question with no reliable answer +/// short of asking the driver, and Intel's OpenGL driver is the one most +/// likely to come up short - so ask it directly rather than trusting forum +/// archaeology. +/// +/// Extension strings are necessary but not sufficient: drivers do advertise +/// entry points that then fail in use. A missing extension is decisive, a +/// present one warrants a real allocate-and-import test afterwards. +/// +internal static class Program +{ + private static int Main() + { + Console.WriteLine("Optimum OpenGL interop probe"); + Console.WriteLine("============================"); + Console.WriteLine(); + + NativeWindow window; + try + { + window = CreateHiddenContext(); + } + catch (Exception error) + { + Console.WriteLine("Could not create an OpenGL context: " + error.Message); + return 1; + } + + using (window) + { + window.Context.MakeCurrent(); + + Console.WriteLine("Vendor : " + GL.GetString(StringName.Vendor)); + Console.WriteLine("Renderer : " + GL.GetString(StringName.Renderer)); + Console.WriteLine("Version : " + GL.GetString(StringName.Version)); + Console.WriteLine("OS : " + RuntimeInformation.OSDescription); + Console.WriteLine(); + + HashSet glExtensions = ReadGlExtensions(); + HashSet wglExtensions = ReadWglExtensions(); + Console.WriteLine($"Reported {glExtensions.Count} GL extensions" + + (OperatingSystem.IsWindows() ? $", {wglExtensions.Count} WGL extensions" : string.Empty)); + Console.WriteLine(); + + ReportDirect3DRoute(glExtensions, wglExtensions); + ReportVulkanRoute(glExtensions); + } + + return 0; + } + + private static NativeWindow CreateHiddenContext() + { + // A 3.3 core context matches what the client itself asks for, so the + // driver hands back the same extension set the real renderer sees. + return new NativeWindow(new NativeWindowSettings + { + ClientSize = new Vector2i(64, 64), + StartVisible = false, + Title = "Optimum interop probe", + APIVersion = new Version(3, 3), + Profile = OpenTK.Windowing.Common.ContextProfile.Core, + }); + } + + /// + /// The D3D route, and the one the Skyrim Community Shaders architecture + /// plugs into: share the scene texture with a D3D11 device, hand it to + /// XeSS (libxess_dx11 on Intel adapters), and let a D3D12 proxy swapchain + /// own presentation for frame generation. + /// + private static void ReportDirect3DRoute(HashSet glExtensions, HashSet wglExtensions) + { + Console.WriteLine("Route A - OpenGL to Direct3D"); + Console.WriteLine("----------------------------"); + + if (!OperatingSystem.IsWindows()) + { + Console.WriteLine(" n/a Windows only; rerun this on the target device."); + Console.WriteLine(); + return; + } + + // The extension string and the entry point can disagree. Report both: + // an exported wglDXOpenDeviceNV with no advertised string still means + // the code path exists to be tested. + bool interop = wglExtensions.Contains("WGL_NV_DX_interop"); + bool interop2 = wglExtensions.Contains("WGL_NV_DX_interop2"); + Report("WGL_NV_DX_interop", interop, "D3D9 sharing"); + Report("WGL_NV_DX_interop2", interop2, "D3D10/11 sharing - the one that matters"); + + foreach (string name in new[] + { + "wglDXOpenDeviceNV", + "wglDXRegisterObjectNV", + "wglDXLockObjectsNV", + "wglDXUnlockObjectsNV", + }) + { + Report(name + " (entry point)", WglGetProcAddress(name) != IntPtr.Zero, null); + } + + // D3D12 resources can also be imported straight into GL, skipping the + // D3D11 hop entirely. Cleaner when present, but the less-implemented + // of the two routes. + Report("GL_EXT_memory_object_win32", glExtensions.Contains("GL_EXT_memory_object_win32"), + "direct D3D11/D3D12 resource import"); + Report("GL_EXT_semaphore_win32", glExtensions.Contains("GL_EXT_semaphore_win32"), + "D3D12 fence import"); + + Console.WriteLine(); + if (interop2) + { + Console.WriteLine(" => Viable. Share via WGL_NV_DX_interop2 into D3D11, then reuse the"); + Console.WriteLine(" existing D3D11/D3D12 proxy design unchanged."); + } + else if (glExtensions.Contains("GL_EXT_memory_object_win32")) + { + Console.WriteLine(" => Viable, but only via direct D3D12 resource import. No D3D11 hop,"); + Console.WriteLine(" so the sharing layer differs from the Skyrim one."); + } + else + { + Console.WriteLine(" => Blocked. This driver exposes no OpenGL-to-D3D sharing path;"); + Console.WriteLine(" reaching XeSS would mean a real renderer backend, not a bridge."); + } + + Console.WriteLine(); + } + + /// + /// The Vulkan route: share into a Vulkan device and run XeSS-SR, DLSS-SR + /// or FSR2 there. Super resolution only - frame generation needs to own + /// presentation, and XeSS-FG is D3D12-only regardless. + /// + private static void ReportVulkanRoute(HashSet glExtensions) + { + Console.WriteLine("Route B - OpenGL to Vulkan"); + Console.WriteLine("--------------------------"); + + bool memory = glExtensions.Contains("GL_EXT_memory_object"); + bool semaphore = glExtensions.Contains("GL_EXT_semaphore"); + Report("GL_EXT_memory_object", memory, "shared allocations"); + Report("GL_EXT_semaphore", semaphore, "cross-API sync"); + + // The base extensions are platform-agnostic; the handle type is not. + // Windows imports NT handles, Linux imports file descriptors, and a + // driver can ship the base pair without either. + bool handles; + if (OperatingSystem.IsWindows()) + { + bool memoryWin32 = glExtensions.Contains("GL_EXT_memory_object_win32"); + bool semaphoreWin32 = glExtensions.Contains("GL_EXT_semaphore_win32"); + Report("GL_EXT_memory_object_win32", memoryWin32, "NT handle import"); + Report("GL_EXT_semaphore_win32", semaphoreWin32, "NT handle import"); + handles = memoryWin32 && semaphoreWin32; + } + else + { + bool memoryFd = glExtensions.Contains("GL_EXT_memory_object_fd"); + bool semaphoreFd = glExtensions.Contains("GL_EXT_semaphore_fd"); + Report("GL_EXT_memory_object_fd", memoryFd, "fd import"); + Report("GL_EXT_semaphore_fd", semaphoreFd, "fd import"); + handles = memoryFd && semaphoreFd; + } + + Console.WriteLine(); + Console.WriteLine(memory && semaphore && handles + ? " => Viable. Super resolution can run in a Vulkan sidecar with the\n renderer left in OpenGL." + : " => Blocked. No Vulkan sharing path on this driver."); + Console.WriteLine(); + } + + private static void Report(string name, bool present, string? note) + { + string suffix = note is null ? string.Empty : " - " + note; + Console.WriteLine($" {(present ? "yes " : "NO ")} {name}{suffix}"); + } + + private static HashSet ReadGlExtensions() + { + var extensions = new HashSet(StringComparer.Ordinal); + GL.GetInteger(GetPName.NumExtensions, out int count); + for (int i = 0; i < count; i++) + { + extensions.Add(GL.GetString(StringNameIndexed.Extensions, i)); + } + + return extensions; + } + + /// + /// WGL extensions live outside the GL extension string and need a device + /// context to query, so they are absent from the list above even when the + /// driver supports them. + /// + private static HashSet ReadWglExtensions() + { + var extensions = new HashSet(StringComparer.Ordinal); + if (!OperatingSystem.IsWindows()) + { + return extensions; + } + + IntPtr address = WglGetProcAddress("wglGetExtensionsStringARB"); + if (address == IntPtr.Zero) + { + address = WglGetProcAddress("wglGetExtensionsStringEXT"); + if (address == IntPtr.Zero) + { + return extensions; + } + + var readExt = Marshal.GetDelegateForFunctionPointer(address); + AddAll(extensions, Marshal.PtrToStringAnsi(readExt())); + return extensions; + } + + var readArb = Marshal.GetDelegateForFunctionPointer(address); + AddAll(extensions, Marshal.PtrToStringAnsi(readArb(WglGetCurrentDC()))); + return extensions; + } + + private static void AddAll(HashSet target, string? spaceSeparated) + { + if (string.IsNullOrWhiteSpace(spaceSeparated)) + { + return; + } + + foreach (string name in spaceSeparated.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + target.Add(name); + } + } + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate IntPtr WglGetExtensionsStringArb(IntPtr deviceContext); + + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + private delegate IntPtr WglGetExtensionsStringExt(); + + [DllImport("opengl32.dll", EntryPoint = "wglGetProcAddress", CharSet = CharSet.Ansi)] + private static extern IntPtr WglGetProcAddress(string name); + + [DllImport("opengl32.dll", EntryPoint = "wglGetCurrentDC")] + private static extern IntPtr WglGetCurrentDC(); +} From 98e6d549d1549e7585625e845f3f35374d0fbed1 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 9 Sep 2026 13:23:17 +0200 Subject: [PATCH 002/226] fix(render): reach a rendered world on the Vulkan backend Four GL semantics with no direct Vulkan equivalent, each of which the world render path depends on. Together they take the client from losing the device during chunk rendering to running in-game with a clean validation log. Sampled textures are transitioned before the draw. GL has no image layout, so a texture may be sampled straight after an upload or after a pass rendered into it; Vulkan requires SHADER_READ_ONLY_OPTIMAL at the point the descriptor is read and forbids a transition inside a rendering scope. A texture caught in the wrong layout now closes the scope, transitions, and the scope reopens. An attachment masked out of glDrawBuffers is not part of the scope. The composition pass renders into attachment 0 while sampling attachment 1, and the transition loop was bounded by the attachment count, so it never reached the slot above the highest enabled one - leaving exactly the texture being sampled in the colour-attachment layout. A uniform block the shader declares is now fed by the client's UBO. The buffers were created and updated but never written into a descriptor set, so every draw read a binding that had never been updated. Buffers are matched to blocks by name, which is what the client's glBindBufferBase to point 0 identifies; Unbind deliberately does not break the association, because the vanilla call clears only the generic target. A block with no buffer yet reads from a zeroed placeholder rather than leaving the descriptor undefined. A negative glScissor origin is clipped instead of rejected. A dialog running off the top of the screen produces y = -72; GL clips and keeps the remainder, Vulkan rejects the draw. Each fix has a test that fails without it. --- .../GlStateTrackerTests.cs | 34 +++ .../VulkanDeviceIntegrationTests.cs | 259 ++++++++++++++++++ Optimum.Render.Vulkan/Core/GlStateTracker.cs | 22 +- .../Core/RenderTargetManager.cs | 51 +++- Optimum.Render.Vulkan/Core/VulkanContext.cs | 2 + Optimum.Render.Vulkan/VulkanDevice.cs | 192 +++++++++++-- 6 files changed, 528 insertions(+), 32 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs index edd28d80..29959a94 100644 --- a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs +++ b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs @@ -367,6 +367,40 @@ public void FrontFaceIsClockwiseToMatchUnflippedGlWinding() Assert.Equal(FrontFace.Clockwise, GlStateTracker.FrontFace); } + /// + /// The game scissors dialogs that run off the top of the screen, which gives + /// glScissor a negative y. GL clips such a rectangle and keeps the visible + /// part; Vulkan rejects the negative offset and drops the draw, so the same + /// region has to be expressed without one. + /// + [Fact] + public void ANegativeScissorOriginIsClippedToTheSameVisibleRegion() + { + var tracker = new GlStateTracker(); + + tracker.SetScissor(-20, -72, 300, 200); + + Assert.Equal(0, tracker.Scissor.Offset.X); + Assert.Equal(0, tracker.Scissor.Offset.Y); + + // The rectangle still ends where it did: -20 + 300 and -72 + 200. + Assert.Equal(280u, tracker.Scissor.Extent.Width); + Assert.Equal(128u, tracker.Scissor.Extent.Height); + } + + [Fact] + public void AScissorEntirelyOffscreenBecomesEmptyRatherThanNegative() + { + var tracker = new GlStateTracker(); + + tracker.SetScissor(-50, -50, 20, 20); + + Assert.Equal(0, tracker.Scissor.Offset.X); + Assert.Equal(0, tracker.Scissor.Offset.Y); + Assert.Equal(0u, tracker.Scissor.Extent.Width); + Assert.Equal(0u, tracker.Scissor.Extent.Height); + } + [Fact] public void ResetRestoresTheDefaultsAFreshContextWouldHave() { diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index aa6a1965..223b3dc1 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -375,6 +375,265 @@ void main(void) } } + /// + /// The multi-pass case the whole renderer is built out of: one pass renders + /// into a texture, a later pass in the same frame samples it. + /// + /// In GL that needs nothing at all. In Vulkan the texture is left in the + /// colour-attachment layout by the first pass and a shader read of it in that + /// layout is invalid - the driver is entitled to abandon the work, and on this + /// machine it did, losing the device partway through the first world load. + /// The device has to notice and transition it before the second draw. + /// + [SkippableFact] + public unsafe void ATextureRenderedIntoIsSampledCorrectlyByALaterPass() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 16; + + const string fullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + int writeProgram = LinkProgram(seam, fullscreenVertex, """ + #version 330 core + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = vec4(40.0 / 255.0, 90.0 / 255.0, 160.0 / 255.0, 1.0); } + """); + + int copyProgram = LinkProgram(seam, fullscreenVertex, """ + #version 330 core + uniform sampler2D source; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(source, uv); } + """); + + int intermediate = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int final = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + int firstPass = seam.CreateFramebuffer(size, size); + seam.AttachTexture(firstPass, EnumFramebufferAttachment.ColorAttachment0, intermediate, 0); + seam.SetDrawBuffers(firstPass, 0b1); + + int secondPass = seam.CreateFramebuffer(size, size); + seam.AttachTexture(secondPass, EnumFramebufferAttachment.ColorAttachment0, final, 0); + seam.SetDrawBuffers(secondPass, 0b1); + + seam.BeginFrame(); + + // Pass one leaves `intermediate` as a colour attachment. + seam.BindFramebuffer(firstPass); + seam.UseProgram(writeProgram); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + + // Pass two samples it. Nothing here announces the change of role. + seam.BindFramebuffer(secondPass); + seam.UseProgram(copyProgram); + seam.SetSamplerUnit(copyProgram, "source", 0); + seam.BindTexture(0, intermediate); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(secondPass); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(40, pixels[centre + 0]); + Assert.Equal(90, pixels[centre + 1]); + Assert.Equal(160, pixels[centre + 2]); + + AssertClean(seam); + } + } + + /// + /// The composition case: render into attachment 0 of a framebuffer while + /// sampling its attachment 1, which glDrawBuffers has masked off. + /// + /// The masked slot is not part of the rendering scope, so it must be readable + /// rather than held in the colour-attachment layout the previous pass left it + /// in. It is also the one slot a scope-bounded transition loop never reaches, + /// because it sits above the highest enabled attachment. + /// + [SkippableFact] + public unsafe void AnAttachmentMaskedOutOfTheDrawCanBeSampledByIt() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 16; + + const string fullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + // Pass one writes both attachments, leaving both as colour attachments. + int fillProgram = LinkProgram(seam, fullscreenVertex, """ + #version 330 core + in vec2 uv; + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + void main(void) + { + outColor = vec4(0.0, 0.0, 0.0, 1.0); + outGlow = vec4(20.0 / 255.0, 130.0 / 255.0, 240.0 / 255.0, 1.0); + } + """, "fill"); + + // Pass two writes attachment 0 only, reading attachment 1. + int composeProgram = LinkProgram(seam, fullscreenVertex, """ + #version 330 core + uniform sampler2D glow; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = texture(glow, uv); } + """, "compose"); + + int color = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glowTexture = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, color, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment1, glowTexture, 0); + + seam.BeginFrame(); + + seam.BindFramebuffer(framebuffer); + seam.SetDrawBuffers(framebuffer, 0b11); + seam.UseProgram(fillProgram); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + + // Attachment 1 drops out of the scope and becomes an input. + seam.SetDrawBuffers(framebuffer, 0b01); + seam.UseProgram(composeProgram); + seam.SetSamplerUnit(composeProgram, "glow", 0); + seam.BindTexture(0, glowTexture); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.SetDrawBuffers(framebuffer, 0b01); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(20, pixels[centre + 0]); + Assert.Equal(130, pixels[centre + 1]); + Assert.Equal(240, pixels[centre + 2]); + + AssertClean(seam); + } + } + + /// + /// A uniform block the shader declares itself, fed by the client's own UBO. + /// + /// The client creates one of these per program and updates it directly; the + /// device has to route it into the descriptor set for the block of that name, + /// or the binding is read without ever having been written. + /// + [SkippableFact] + public unsafe void AClientUniformBufferSuppliesTheBlockTheShaderDeclares() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 16; + + int programId = LinkProgram(seam, """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """, """ + #version 330 core + layout(std140) uniform Tint { vec4 tint; }; + out vec4 outColor; + void main(void) { outColor = tint; } + """); + + int target = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int ubo = seam.CreateUniformBuffer(programId, 0, "Tint", sizeof(float) * 4); + Assert.True(ubo > 0); + + var tint = new float[] { 60f / 255f, 120f / 255f, 180f / 255f, 1f }; + fixed (float* values = tint) + { + seam.UpdateUniformBuffer(ubo, (IntPtr)values, 0, sizeof(float) * 4); + } + seam.BindUniformBuffer(ubo); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(programId); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(60, pixels[centre + 0]); + Assert.Equal(120, pixels[centre + 1]); + Assert.Equal(180, pixels[centre + 2]); + + AssertClean(seam); + } + } + private static void AssertClean(IOptimumGraphicsDevice device) { string? diagnostics = device.GetError(); diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs index ff27d82e..144de767 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -240,8 +240,26 @@ public GlStateTracker() public void SetViewport(int x, int y, int width, int height) => Viewport = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); - public void SetScissor(int x, int y, int width, int height) => - Scissor = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + /// + /// Records the scissor rectangle, clipped to the positive quadrant. + /// + /// glScissor takes a signed origin and the game passes negative ones - a + /// dialog that extends past the top of the screen produces y = -72. GL clips + /// the rectangle against the framebuffer and keeps the visible remainder; + /// Vulkan rejects a negative offset outright. Moving the origin back to zero + /// and taking the same amount off the extent leaves the identical region. + /// + public void SetScissor(int x, int y, int width, int height) + { + int clippedX = Math.Max(0, x); + int clippedY = Math.Max(0, y); + width -= clippedX - x; + height -= clippedY - y; + + Scissor = new Rect2D( + new Offset2D(clippedX, clippedY), + new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + } public void SetScissorEnabled(bool enabled) => ScissorEnabled = enabled; diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 6f1038ea..763f374c 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -130,6 +130,28 @@ public void SetDrawBuffers(int framebufferId, uint mask) private bool _needsRestart; + /// + /// Whether a texture takes part in the rendering scope the bound framebuffer + /// is about to open, and so has to keep its attachment layout. + /// + /// Only slots the draw actually writes count. A colour attachment masked out + /// of glDrawBuffers is not part of the scope at all, and the composition pass + /// samples exactly such a slot - so it has to stay transitionable, or it is + /// read in the colour-attachment layout it was left in. + /// + public bool IsAttachmentOfBound(int textureId) + { + if (_bound == null || textureId <= 0) return false; + if (_bound.DepthTextureId == textureId) return true; + + for (int i = 0; i < _bound.Color.Length; i++) + { + if (_bound.Color[i].TextureId != textureId) continue; + if ((_bound.DrawBufferMask & (1u << i)) != 0) return true; + } + return false; + } + /// /// Binds a framebuffer. Nothing is recorded here: GL lets a bind be followed /// by more state changes before anything is drawn, so the scope opens lazily @@ -175,6 +197,24 @@ public void EnsureRendering(CommandBuffer commandBuffer) var attachments = new RenderingAttachmentInfo[Math.Max(count, 0)]; + // Every slot the draw does not write may be sampled instead, so it has to + // be readable. This runs over all of them, not just the ones below the + // highest enabled index: the composition pass renders into attachment 0 + // while sampling attachment 1, and a loop bounded by the attachment count + // would never reach the slot it samples. + for (int i = 0; i < framebuffer.Color.Length; i++) + { + AttachmentSlot unused = framebuffer.Color[i]; + if (!unused.IsBound) continue; + if ((framebuffer.DrawBufferMask & (1u << i)) != 0) continue; + + VulkanTexture? excluded = _textures.Get(unused.TextureId); + if (excluded != null) + { + _textures.TransitionTexture(commandBuffer, excluded, ImageLayout.ShaderReadOnlyOptimal); + } + } + for (int i = 0; i < count; i++) { bool enabled = (framebuffer.DrawBufferMask & (1u << i)) != 0; @@ -193,17 +233,6 @@ public void EnsureRendering(CommandBuffer commandBuffer) LoadOp = AttachmentLoadOp.DontCare, StoreOp = AttachmentStoreOp.DontCare, }; - - // An attachment left out of the draw may be sampled instead, so - // it has to be readable. - if (slot.IsBound) - { - VulkanTexture? excluded = _textures.Get(slot.TextureId); - if (excluded != null) - { - _textures.TransitionTexture(commandBuffer, excluded, ImageLayout.ShaderReadOnlyOptimal); - } - } continue; } diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index fef19d8a..4c14573b 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -42,6 +42,7 @@ internal sealed class VulkanCapabilities public float MaxSamplerLodBias; public int MaxBoundDescriptorSets; public ulong MinUniformBufferOffsetAlignment; + public ulong MaxUniformBufferRange; } /// @@ -574,6 +575,7 @@ private VulkanCapabilities ReadCapabilities() MaxSamplerLodBias = properties.Limits.MaxSamplerLodBias, MaxBoundDescriptorSets = (int)properties.Limits.MaxBoundDescriptorSets, MinUniformBufferOffsetAlignment = properties.Limits.MinUniformBufferOffsetAlignment, + MaxUniformBufferRange = properties.Limits.MaxUniformBufferRange, }; } diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 4c05878e..82fcad27 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -57,6 +57,7 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice /// See the placeholder note in BindDescriptors. /// private int _placeholderTexture; + private VulkanBuffer? _placeholderUniforms; /// Texture bound to each unit, and any sampler overriding the texture's own state. private readonly int[] _boundTextures = new int[GlStateTracker.MaxTextureUnits]; @@ -254,6 +255,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _shaderCompiler = new ShaderCompiler(); CreateDefaultAttributeBuffer(); CreatePlaceholderTexture(); + CreatePlaceholderUniformBuffer(); if (!headless) { @@ -361,6 +363,32 @@ private void CreatePlaceholderTexture() } } + /// + /// Builds the zero-filled buffer that fills any shader-declared uniform block + /// the client has not supplied a buffer for yet. + /// + /// Same reasoning as the placeholder texture: leaving the binding undefined + /// makes every draw with that program invalid, so a program whose UBO has not + /// been created yet would take the whole frame down rather than read zeroes. + /// GL reads zeroes from an unbacked block, so this is also the closer match. + /// + private void CreatePlaceholderUniformBuffer() + { + // Large enough for the blocks the game declares - the animation transform + // block is the biggest at a few tens of kilobytes - and clamped to what + // the device will actually let a descriptor address. + ulong size = Math.Min(65536UL, Math.Max(16384UL, _context!.Capabilities.MaxUniformBufferRange)); + + _placeholderUniforms = new VulkanBuffer(_context, size, + BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + if (_placeholderUniforms.Mapped != IntPtr.Zero) + { + new Span((void*)_placeholderUniforms.Mapped, (int)size).Clear(); + } + } + private void DestroyDefaultFramebuffer() { if (_defaultFramebuffer > 0) _targets.Delete(_defaultFramebuffer); @@ -793,6 +821,21 @@ public void SetSamplerUnit(int programId, string samplerName, int unit) // ------------------------------------------------------------ uniform buffers private readonly Dictionary _uniformBuffers = new(); + + /// Block name each uniform buffer was created for. + private readonly Dictionary _uniformBufferBlocks = new(); + + /// + /// The buffer currently supplying each named block. + /// + /// The client's UBO binds with glBindBufferBase to binding point 0 and names + /// the block when it creates the buffer, so the block name is what actually + /// identifies which declaration a buffer feeds. Vulkan has no such global + /// binding point, so the association is kept here and resolved per draw + /// against the program's own declared blocks. + /// + private readonly Dictionary _boundUniformBuffers = new(StringComparer.Ordinal); + private int _nextUniformBufferId = 1; public int CreateUniformBuffer(int programId, int bindingPoint, string blockName, int size) @@ -803,6 +846,11 @@ public int CreateUniformBuffer(int programId, int bindingPoint, string blockName int id = _nextUniformBufferId++; _uniformBuffers[id] = buffer; + _uniformBufferBlocks[id] = blockName ?? ""; + + // GL's glBindBufferBase in the client's constructor takes effect at once, + // and a buffer is only ever created to be used. + if (!string.IsNullOrEmpty(blockName)) _boundUniformBuffers[blockName] = id; return id; } @@ -815,11 +863,32 @@ public void UpdateUniformBuffer(int handle, IntPtr data, int offset, int size) System.Buffer.MemoryCopy((void*)data, (void*)(buffer.Mapped + offset), size, size); } - public void BindUniformBuffer(int handle) { } + public void BindUniformBuffer(int handle) + { + if (_uniformBufferBlocks.TryGetValue(handle, out string? blockName) && blockName.Length > 0) + { + _boundUniformBuffers[blockName] = handle; + } + } + + /// + /// Deliberately does not break the block association. + /// + /// The client's Unbind is glBindBuffer(UNIFORM_BUFFER, 0), which clears the + /// generic target and leaves the glBindBufferBase index binding standing - + /// and the index binding is what feeds the shader. Dropping the association + /// here would unbind the block the client still expects to be supplied. + /// public void UnbindUniformBuffer(int handle) { } public void DeleteUniformBuffer(int handle) { + if (_uniformBufferBlocks.Remove(handle, out string? blockName) && + _boundUniformBuffers.TryGetValue(blockName, out int bound) && bound == handle) + { + _boundUniformBuffers.Remove(blockName); + } + if (_uniformBuffers.Remove(handle, out VulkanBuffer? buffer)) { _frames.DeferDeletion(buffer); @@ -1211,6 +1280,10 @@ private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) } commandBuffer = Commands; + + // Before the scope opens, not after: a layout transition is illegal + // inside one, so anything this draw samples has to be put right first. + TransitionSampledTextures(commandBuffer, program); _targets.EnsureRendering(commandBuffer); int formatsId = _targets.FormatsIdOf(target); @@ -1268,44 +1341,124 @@ private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) return true; } + /// + /// Puts every texture this draw samples into the layout a shader read needs. + /// + /// GL has no notion of image layout: a texture uploaded a moment ago, or one + /// an earlier pass rendered into, can be sampled straight away. Vulkan wants + /// it in SHADER_READ_ONLY_OPTIMAL at the point the descriptor is accessed and + /// rejects the draw otherwise, and a transition cannot be recorded inside a + /// rendering scope - so a texture found in the wrong layout closes the scope, + /// transitions, and the scope reopens around the draw. + /// + /// An attachment of the framebuffer being drawn into is skipped: it has to + /// keep its attachment layout, EnsureRendering already transitions the ones + /// left out of the draw, and sampling what you are writing is a feedback loop + /// GL does not allow either. + /// + private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgramResources program) + { + if (program.Interface.Samplers.Count == 0) return; + + bool placeholderNeeded = false; + + for (int i = 0; i < program.Interface.Samplers.Count; i++) + { + SamplerBinding declared = program.Interface.Samplers[i]; + int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) + ? mapped + : declared.Binding; + + VulkanTexture? texture = (uint)unit < GlStateTracker.MaxTextureUnits + ? _textures.Get(_boundTextures[unit]) + : null; + + if (texture == null) + { + // BindDescriptors will reach for the placeholder here, so that is + // what this draw actually samples. + placeholderNeeded = true; + continue; + } + + if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; + if (_targets.IsAttachmentOfBound(_boundTextures[unit])) continue; + + _targets.EndRendering(commandBuffer); + _textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + } + + if (!placeholderNeeded) return; + + VulkanTexture? placeholder = _textures.Get(_placeholderTexture); + if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) return; + + _targets.EndRendering(commandBuffer); + _textures.TransitionTexture(commandBuffer, placeholder, ImageLayout.ShaderReadOnlyOptimal); + } + private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program) { Vk api = _context.Api; // Set 0: the generated uniform block, uploaded into this frame's ring and - // reached through a dynamic offset so the set itself never changes. + // reached through a dynamic offset so the set itself never changes, plus + // one entry for every block the shader declared for itself. uint dynamicOffset = 0; - if (program.Interface.HasUniformBlock) + bool hasGeneratedBlock = program.Interface.HasUniformBlock; + + if (hasGeneratedBlock || program.Interface.UniformBlocks.Count > 0) { - _lastUniformAllocationOk = - _frames.Current.TryAllocateUniforms(program.UniformShadow.Length, out RingAllocation allocation); - if (_lastUniformAllocationOk) + var buffers = new List(1 + program.Interface.UniformBlocks.Count); + + if (hasGeneratedBlock) + { + _lastUniformAllocationOk = + _frames.Current.TryAllocateUniforms(program.UniformShadow.Length, out RingAllocation allocation); + if (_lastUniformAllocationOk) + { + fixed (byte* source = program.UniformShadow) + { + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, + program.UniformShadow.Length, program.UniformShadow.Length); + } + dynamicOffset = allocation.Offset; + program.MarkUniformsClean(); + } + + buffers.Add(new BufferBindingValue( + ProgramInterfaceLayout.DefaultBlockBinding, + _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length)); + } + + // A block the shader declares is fed by whichever UBO the client + // created under that name; one it has not created yet reads zeroes + // rather than leaving the descriptor undefined. + foreach (BlockBinding block in program.Interface.UniformBlocks) { - fixed (byte* source = program.UniformShadow) + VulkanBuffer? blockBuffer = null; + if (_boundUniformBuffers.TryGetValue(block.BlockName, out int handle)) { - System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, - program.UniformShadow.Length, program.UniformShadow.Length); + _uniformBuffers.TryGetValue(handle, out blockBuffer); } - dynamicOffset = allocation.Offset; - program.MarkUniformsClean(); + blockBuffer ??= _placeholderUniforms; + if (blockBuffer == null) continue; + + buffers.Add(new BufferBindingValue( + (uint)block.Binding, blockBuffer.Handle, 0, blockBuffer.Size)); } var uniformContents = new DescriptorSetContents( program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, - Array.Empty(), - new[] - { - new BufferBindingValue( - ProgramInterfaceLayout.DefaultBlockBinding, - _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length), - }); + Array.Empty(), buffers.ToArray()); DescriptorSet uniformSet = _descriptors.Get( uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); uint offset = dynamicOffset; api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, 1, &offset); + ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, + hasGeneratedBlock ? 1u : 0u, hasGeneratedBlock ? &offset : null); } // Set 1: one combined image sampler per declared sampler, resolved through @@ -1575,6 +1728,7 @@ public void Dispose() _indirectScratch?.Dispose(); _defaultAttributes?.Dispose(); + _placeholderUniforms?.Dispose(); _swapchain?.Dispose(); _shaderCompiler?.Dispose(); _frames?.Dispose(); From cc6a5f91e9c114466259a6005c9a6ecc9aa8bdbd Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 9 Sep 2026 14:20:45 +0200 Subject: [PATCH 003/226] fix(render): key descriptor sets on resource lifetime, not handle The intermittent device loss during world load was a GPU read of freed memory, roughly one launch in three. A Vulkan handle identifies an object only while it lives: destroy an image view and the driver may hand the identical handle value to the next one created. The descriptor set cache keyed on handles, so a texture created after another was deleted could inherit its predecessor's handle, hit the cache, and be served a set still pointing at the dead texture's memory. The loading screen deletes and recreates text textures continuously, which is why it struck there and why it struck at random - it depended on whether the allocator had recycled that address yet. Validation never caught it because the layers track handles too, and the successor's handle is legitimately live. Every texture and buffer now carries a process-unique id that is never reused, and the cache key carries it, so a successor can no longer collide with its predecessor. Deleting a texture or uniform buffer evicts every set naming it; the sets are freed a full ring cycle later, once no frame that could have bound one is still executing. Pools allow individual frees and reclaim the capacity, so eviction does not leak pool space. Also adds the diagnostic that found it, kept because a lost device is otherwise unfalsifiable: VK_NV_device_diagnostic_checkpoints marks each draw, upload and present, and VK_EXT_device_fault reports the faulting address. Both are enabled where the driver offers them and cost nothing when the GPU is healthy. The report that identified this bug read: last completed: draw with program 15 'gui' mesh 3 into framebuffer 1; DeviceFaultAddressTypeReadInvalidExt at 0x1a3b2000 A uniform ring exhausted mid-frame now says so once per frame as well, rather than letting the draw silently read another draw's block. --- .../DescriptorCacheLifetimeTests.cs | 56 ++++ .../GpuCheckpointTests.cs | 71 +++++ .../VulkanDeviceIntegrationTests.cs | 112 ++++++++ Optimum.Render.Vulkan/Core/DescriptorCache.cs | 249 +++++++++++++++--- Optimum.Render.Vulkan/Core/GpuCheckpoints.cs | 99 +++++++ Optimum.Render.Vulkan/Core/TextureManager.cs | 14 + Optimum.Render.Vulkan/Core/VulkanContext.cs | 184 +++++++++++++ Optimum.Render.Vulkan/Core/VulkanResources.cs | 42 +++ Optimum.Render.Vulkan/VulkanDevice.cs | 149 ++++++++++- 9 files changed, 928 insertions(+), 48 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/DescriptorCacheLifetimeTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/GpuCheckpointTests.cs create mode 100644 Optimum.Render.Vulkan/Core/GpuCheckpoints.cs diff --git a/Optimum.Render.Vulkan.Tests/DescriptorCacheLifetimeTests.cs b/Optimum.Render.Vulkan.Tests/DescriptorCacheLifetimeTests.cs new file mode 100644 index 00000000..551a1460 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/DescriptorCacheLifetimeTests.cs @@ -0,0 +1,56 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The descriptor cache key must tell two resources apart even when the driver +/// has given them the same handle value, which it does once the first is +/// destroyed. This is the pure half of the loading-screen crash; the live half +/// is in . +/// +public class DescriptorCacheLifetimeTests +{ + [Fact] + public void TwoResourcesWithTheSameHandleAreDifferentKeys() + { + var view = new ImageView(0x1234); + var sampler = new Sampler(0x99); + + var first = new DescriptorSetContents(1, 1, + new[] { new SamplerBindingValue(0, view, sampler, Resource: 10) }, Array.Empty()); + var successor = new DescriptorSetContents(1, 1, + new[] { new SamplerBindingValue(0, view, sampler, Resource: 11) }, Array.Empty()); + var same = new DescriptorSetContents(1, 1, + new[] { new SamplerBindingValue(0, view, sampler, Resource: 10) }, Array.Empty()); + + Assert.NotEqual(first, successor); + Assert.Equal(first, same); + Assert.Equal(first.GetHashCode(), same.GetHashCode()); + } + + [Fact] + public void BuffersAreToldApartByLifetimeIdToo() + { + var buffer = new Silk.NET.Vulkan.Buffer(0x5555); + + var first = new DescriptorSetContents(1, 0, Array.Empty(), + new[] { new BufferBindingValue(1, buffer, 0, 256, Resource: 20) }); + var successor = new DescriptorSetContents(1, 0, Array.Empty(), + new[] { new BufferBindingValue(1, buffer, 0, 256, Resource: 21) }); + + Assert.NotEqual(first, successor); + } + + [Fact] + public void ResourceIdsNeverRepeat() + { + ulong a = ResourceIds.Next(); + ulong b = ResourceIds.Next(); + + Assert.NotEqual(0UL, a); + Assert.True(b > a); + } +} diff --git a/Optimum.Render.Vulkan.Tests/GpuCheckpointTests.cs b/Optimum.Render.Vulkan.Tests/GpuCheckpointTests.cs new file mode 100644 index 00000000..884a1f9e --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/GpuCheckpointTests.cs @@ -0,0 +1,71 @@ +using Optimum.Render.Vulkan.Core; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The device-loss diagnostic. The marker is the only part with logic of its +/// own: the driver stores whatever value it is handed and returns it verbatim, +/// so what matters is that the value decodes to what was encoded. +/// +public class GpuCheckpointTests +{ + private readonly ITestOutputHelper _output; + + public GpuCheckpointTests(ITestOutputHelper output) => _output = output; + + [Fact] + public void ADrawMarkerRoundTripsItsKindAndPayload() + { + nint marker = CheckpointMarker.Draw(CheckpointKind.DrawMulti, program: 0xBEEF, target: 7, mesh: 123456); + + Assert.NotEqual((nint)0, marker); + Assert.Equal(CheckpointKind.DrawMulti, CheckpointMarker.KindOf(marker)); + Assert.Equal(123456u, CheckpointMarker.BOf(marker)); + Assert.Equal( + "multi-draw with program 48879 'chunkopaque' mesh 123456 into framebuffer 7", + CheckpointMarker.Describe(marker, id => id == 0xBEEF ? "chunkopaque" : null)); + } + + [Fact] + public void EveryKindIsNonZeroAndReadsBackDistinctly() + { + nint frame = CheckpointMarker.FrameBegin(42); + nint upload = CheckpointMarker.Upload(9, 4096, 2048); + nint mips = CheckpointMarker.Mipmaps(9, 13); + nint blit = CheckpointMarker.PresentBlit(2, 42); + nint fullscreen = CheckpointMarker.Draw(CheckpointKind.Fullscreen, 3, 1, 0); + + Assert.All(new[] { frame, upload, mips, blit, fullscreen }, m => Assert.NotEqual((nint)0, m)); + + Assert.Equal("frame 42 begins", CheckpointMarker.Describe(frame)); + Assert.Equal("upload of 4096x2048 texels into texture 9", CheckpointMarker.Describe(upload)); + Assert.Equal("mipmap generation for texture 9 (13 levels)", CheckpointMarker.Describe(mips)); + Assert.Equal("blit of frame 42 into swapchain image 2", CheckpointMarker.Describe(blit)); + Assert.Equal("fullscreen draw with program 3 into framebuffer 1", CheckpointMarker.Describe(fullscreen)); + } + + /// + /// On a driver that offers checkpoints, reading them from a healthy queue + /// must at least not fail: the crash path calls this with nothing to lose, + /// and a diagnostic that throws is worse than none. + /// + [SkippableFact] + public void CheckpointsCanBeReadFromAHealthyQueue() + { + var options = new VulkanContextOptions { Headless = true }; + Skip.IfNot(VulkanContext.TryCreate(options, out VulkanContext? context, out string? reason), + "No usable Vulkan device: " + reason); + + using (context) + { + _output.WriteLine("checkpoints: " + context!.CheckpointsAvailable + + ", device fault: " + context.DeviceFaultAvailable); + Skip.IfNot(context.CheckpointsAvailable, "Driver has no VK_NV_device_diagnostic_checkpoints."); + + var checkpoints = context.ReadQueueCheckpoints(); + Assert.NotNull(checkpoints); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index 223b3dc1..a2c474a1 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -634,6 +634,118 @@ void main(void) } } + /// + /// The loading-screen crash. A texture is deleted and a new one takes its + /// place; the driver may give the new image view the very handle value the + /// old one had. A set cache keyed by handle then serves the stale set and the + /// GPU reads freed memory. The cache must drop a deleted texture's sets and + /// serve a successor its own, and the deferred free must land only after + /// every frame that could have bound the old set has finished - which the + /// validation layer checks for us. + /// + [SkippableFact] + public unsafe void ADeletedTextureTakesItsDescriptorSetsWithIt() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 8; + + int program = LinkProgram(seam, """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """, """ + #version 330 core + uniform sampler2D source; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(source, uv); } + """); + + int target = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int first = SolidTexture(seam, size, 10, 20, 30); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, first); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + seam.Present(); + + int cachedWhileAlive = device!.CachedDescriptorSets; + Assert.True(cachedWhileAlive >= 1, "the draw should have cached a sampler set"); + + seam.DeleteTexture(first); + + // The next frame evicts the set; two more let the deferred free run + // once the frame that bound it has signalled its fence. + for (int i = 0; i < 3; i++) + { + seam.BeginFrame(); + seam.Present(); + } + Assert.Equal(cachedWhileAlive - 1, device.CachedDescriptorSets); + + int second = SolidTexture(seam, size, 200, 100, 50); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, second); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(200, pixels[centre + 0]); + Assert.Equal(100, pixels[centre + 1]); + Assert.Equal(50, pixels[centre + 2]); + + AssertClean(seam); + } + } + + private static unsafe int SolidTexture(IOptimumGraphicsDevice seam, int size, byte r, byte g, byte b) + { + var pixels = new byte[size * size * 4]; + for (int i = 0; i < pixels.Length; i += 4) + { + pixels[i + 0] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = 255; + } + + fixed (byte* data = pixels) + { + return seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)data, false); + } + } + private static void AssertClean(IOptimumGraphicsDevice device) { string? diagnostics = device.GetError(); diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs index 17f7e854..c8c55c9e 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorCache.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -6,11 +6,19 @@ namespace Optimum.Render.Vulkan.Core; -/// One combined image sampler binding. -internal readonly record struct SamplerBindingValue(uint Binding, ImageView View, Sampler Sampler); +/// +/// One combined image sampler binding. +/// +/// is the texture's lifetime id, which is what +/// separates this binding from a later texture that inherits the same view +/// handle. Zero means the resource is permanent and needs no tracking. +/// +internal readonly record struct SamplerBindingValue( + uint Binding, ImageView View, Sampler Sampler, ulong Resource = 0); -/// One buffer binding. -internal readonly record struct BufferBindingValue(uint Binding, Buffer Buffer, ulong Offset, ulong Range); +/// One buffer binding. as for samplers. +internal readonly record struct BufferBindingValue( + uint Binding, Buffer Buffer, ulong Offset, ulong Range, ulong Resource = 0); /// /// The contents of one descriptor set, used as a cache key. @@ -44,6 +52,7 @@ public DescriptorSetContents( hash.Add(sampler.Binding); hash.Add(sampler.View.Handle); hash.Add(sampler.Sampler.Handle); + hash.Add(sampler.Resource); } foreach (BufferBindingValue buffer in buffers) { @@ -51,6 +60,7 @@ public DescriptorSetContents( hash.Add(buffer.Buffer.Handle); hash.Add(buffer.Offset); hash.Add(buffer.Range); + hash.Add(buffer.Resource); } _hash = hash.ToHashCode(); } @@ -86,20 +96,42 @@ public bool Equals(DescriptorSetContents? other) /// with one they are a dictionary lookup. Published measurements put descriptor /// caching at roughly a third off frame time in CPU-heavy scenes. /// -/// Sets are allocated from pools that are never reset. They are immutable once -/// written, so a set can outlive any number of frames safely, and the working set -/// is bounded by how many distinct texture combinations the game actually uses - -/// a few hundred, not a few hundred thousand. +/// Sets are immutable once written, so a set can outlive any number of frames +/// safely - as long as the resources it names do. A set that names a deleted +/// texture is the one thing this cache must never serve again: the driver may +/// give the next texture the same view handle, and a lookup by handle would then +/// hand a draw a set pointing at freed memory. That is why the key carries each +/// resource's lifetime id and why a deleted resource evicts its sets, with the +/// actual free deferred until no frame can still be reading them. +/// +/// The working set is bounded by how many distinct texture combinations the +/// game actually uses at once - a few hundred, not a few hundred thousand - and +/// eviction keeps churn, such as the GUI's re-rendered text, from growing it. /// internal sealed unsafe class DescriptorCache : IDisposable { private const uint SetsPerPool = 512; + /// A pool and how many sets it can still hand out. + private sealed class PoolSlot + { + public DescriptorPool Pool; + public uint Remaining; + } + + private readonly record struct CachedSet(DescriptorSet Set, PoolSlot Pool); + private readonly VulkanContext _context; - private readonly Dictionary _sets = new(); - private readonly List _pools = new(); - private DescriptorPool _current; - private uint _remainingInCurrent; + private readonly Dictionary _sets = new(); + private readonly List _pools = new(); + private PoolSlot? _current; + + /// Every cached key that names a given resource, for eviction. + private readonly Dictionary> _byResource = new(); + + /// Resources deleted since the last collection. Any thread may add. + private readonly System.Collections.Concurrent.ConcurrentQueue _pendingReleases = new(); + private bool _disposed; public int Count => _sets.Count; @@ -110,53 +142,184 @@ internal sealed unsafe class DescriptorCache : IDisposable public DescriptorSet Get(DescriptorSetContents contents, DescriptorSetLayout layout) { - if (_sets.TryGetValue(contents, out DescriptorSet existing)) + if (_sets.TryGetValue(contents, out CachedSet existing)) { Hits++; - return existing; + return existing.Set; } Misses++; - DescriptorSet set = Allocate(layout); - Write(set, contents); - _sets[contents] = set; - return set; + CachedSet cached = Allocate(layout); + Write(cached.Set, contents); + _sets[contents] = cached; + Index(contents); + return cached.Set; } - private DescriptorSet Allocate(DescriptorSetLayout layout) + /// + /// Notes that a resource is going away, so no set naming it is handed out + /// again. Safe from any thread; the sets themselves are reclaimed on the + /// render thread by . + /// + public void Release(ulong resource) { - if (_remainingInCurrent == 0) GrowPool(); + if (resource != 0) _pendingReleases.Enqueue(resource); + } - var allocateInfo = new DescriptorSetAllocateInfo + /// + /// Drops every set that names a released resource and returns the work of + /// freeing them, or null when there is none. + /// + /// The sets leave the dictionary here, so no draw recorded from now on can + /// bind them. A frame still executing may be reading one, though, so the + /// caller hands the result to the frame ring and the free itself happens + /// once that frame's fence has signalled. Render thread only. + /// + public IDisposable? CollectReleases() + { + List? doomed = null; + + while (_pendingReleases.TryDequeue(out ulong resource)) { - SType = StructureType.DescriptorSetAllocateInfo, - DescriptorPool = _current, - DescriptorSetCount = 1, - PSetLayouts = &layout, - }; + if (!_byResource.Remove(resource, out List? keys)) continue; - DescriptorSet set; - Result result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &set); + foreach (DescriptorSetContents key in keys) + { + // A set naming the resource twice is listed twice, and the + // second removal simply finds nothing. + if (!_sets.Remove(key, out CachedSet cached)) continue; + + Unindex(key, resource); + (doomed ??= new List()).Add(cached); + } + } + + return doomed == null ? null : new FreedSets(this, doomed); + } + + private void Index(DescriptorSetContents contents) + { + foreach (SamplerBindingValue sampler in contents.Samplers) IndexResource(sampler.Resource, contents); + foreach (BufferBindingValue buffer in contents.Buffers) IndexResource(buffer.Resource, contents); + } + + private void IndexResource(ulong resource, DescriptorSetContents contents) + { + if (resource == 0) return; + + if (!_byResource.TryGetValue(resource, out List? keys)) + { + keys = new List(1); + _byResource[resource] = keys; + } + keys.Add(contents); + } + + /// + /// Removes a key from the lists of every other resource it names, so a + /// long-lived resource does not accumulate keys evicted on account of the + /// short-lived ones sampled alongside it. + /// + private void Unindex(DescriptorSetContents key, ulong except) + { + foreach (SamplerBindingValue sampler in key.Samplers) UnindexResource(sampler.Resource, except, key); + foreach (BufferBindingValue buffer in key.Buffers) UnindexResource(buffer.Resource, except, key); + } + + private void UnindexResource(ulong resource, ulong except, DescriptorSetContents key) + { + if (resource == 0 || resource == except) return; + if (!_byResource.TryGetValue(resource, out List? keys)) return; + + keys.Remove(key); + if (keys.Count == 0) _byResource.Remove(resource); + } + + /// Frees a batch of sets back to their pools, once it is safe to. + private sealed class FreedSets : IDisposable + { + private readonly DescriptorCache _cache; + private readonly List _sets; + + public FreedSets(DescriptorCache cache, List sets) + { + _cache = cache; + _sets = sets; + } + + public void Dispose() => _cache.Free(_sets); + } + + private void Free(List sets) + { + // The ring can drain after the cache is gone, and the pools with it. + if (_disposed) return; + + foreach (CachedSet cached in sets) + { + DescriptorSet set = cached.Set; + _context.Api.FreeDescriptorSets(_context.Device, cached.Pool.Pool, 1, &set); + cached.Pool.Remaining++; + } + } + + private CachedSet Allocate(DescriptorSetLayout layout) + { + // Freed sets hand capacity back to whichever pool they came from, so + // any pool with room will do, not only the newest. + PoolSlot? slot = _current is { Remaining: > 0 } ? _current : null; + if (slot == null) + { + foreach (PoolSlot candidate in _pools) + { + if (candidate.Remaining > 0) + { + slot = candidate; + break; + } + } + } + slot ??= GrowPool(); + + Result result = AllocateFrom(slot, layout, out DescriptorSet set); // A pool can fail before its nominal capacity when one layout uses more - // of a type than the pool budgeted. Growing and retrying once is the - // documented way to handle that. + // of a type than the pool budgeted, or when frees have fragmented it. + // Growing and retrying once is the documented way to handle that; the + // pool that refused is written off rather than asked again every miss. if (result != Result.Success) { - GrowPool(); - allocateInfo.DescriptorPool = _current; - result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &set); + slot.Remaining = 0; + slot = GrowPool(); + result = AllocateFrom(slot, layout, out set); if (result != Result.Success) { throw new InvalidOperationException("vkAllocateDescriptorSets failed: " + result); } } - _remainingInCurrent--; - return set; + slot.Remaining--; + _current = slot; + return new CachedSet(set, slot); + } + + private Result AllocateFrom(PoolSlot slot, DescriptorSetLayout layout, out DescriptorSet set) + { + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = slot.Pool, + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + + DescriptorSet allocated; + Result result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &allocated); + set = allocated; + return result; } - private void GrowPool() + private PoolSlot GrowPool() { // A pool can only satisfy the descriptor types it was sized for. The // generated block is a dynamic uniform buffer, but the game also declares @@ -175,6 +338,8 @@ private void GrowPool() var createInfo = new DescriptorPoolCreateInfo { SType = StructureType.DescriptorPoolCreateInfo, + // Evicted sets are freed individually, which a pool has to allow. + Flags = DescriptorPoolCreateFlags.FreeDescriptorSetBit, PoolSizeCount = 4, PPoolSizes = sizes, MaxSets = SetsPerPool, @@ -186,9 +351,10 @@ private void GrowPool() throw new InvalidOperationException("vkCreateDescriptorPool failed"); } - _pools.Add(pool); - _current = pool; - _remainingInCurrent = SetsPerPool; + var slot = new PoolSlot { Pool = pool, Remaining = SetsPerPool }; + _pools.Add(slot); + _current = slot; + return slot; } private void Write(DescriptorSet set, DescriptorSetContents contents) @@ -270,9 +436,10 @@ public void Dispose() _disposed = true; _sets.Clear(); - foreach (DescriptorPool pool in _pools) + _byResource.Clear(); + foreach (PoolSlot slot in _pools) { - _context.Api.DestroyDescriptorPool(_context.Device, pool, null); + _context.Api.DestroyDescriptorPool(_context.Device, slot.Pool, null); } _pools.Clear(); } diff --git a/Optimum.Render.Vulkan/Core/GpuCheckpoints.cs b/Optimum.Render.Vulkan/Core/GpuCheckpoints.cs new file mode 100644 index 00000000..4299fb71 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/GpuCheckpoints.cs @@ -0,0 +1,99 @@ +using System; + +namespace Optimum.Render.Vulkan.Core; + +/// What a GPU checkpoint marker stands for. +internal enum CheckpointKind : byte +{ + None = 0, + FrameBegin = 1, + Draw = 2, + DrawMulti = 3, + Fullscreen = 4, + Upload = 5, + Mipmaps = 6, + PresentBlit = 7, +} + +/// +/// Packs what a GPU checkpoint refers to into the pointer-sized marker the +/// driver records, and reads it back after a device loss. +/// +/// VK_NV_device_diagnostic_checkpoints stores the marker value and hands it +/// straight back; nobody dereferences it. So it can carry data rather than point +/// at any, which is what makes a checkpoint per draw affordable: nothing is +/// allocated, and nothing has to be kept alive for a loss that may never come. +/// +/// Layout, high to low: 4 bits of kind, 28 bits of "a", 32 bits of "b". The +/// kind is never zero, so neither is the marker, which keeps "no checkpoint" +/// distinguishable from a real one. +/// +internal static class CheckpointMarker +{ + private const int KindShift = 60; + private const int AShift = 32; + private const ulong AMask = (1UL << 28) - 1; + private const ulong BMask = 0xFFFFFFFFUL; + + public static nint Pack(CheckpointKind kind, uint a, uint b) => + (nint)(long)(((ulong)kind << KindShift) | (((ulong)a & AMask) << AShift) | ((ulong)b & BMask)); + + public static CheckpointKind KindOf(nint marker) => (CheckpointKind)((ulong)(long)marker >> KindShift); + public static uint AOf(nint marker) => (uint)(((ulong)(long)marker >> AShift) & AMask); + public static uint BOf(nint marker) => (uint)((ulong)(long)marker & BMask); + + public static nint FrameBegin(uint frame) => Pack(CheckpointKind.FrameBegin, 0, frame); + + public static nint Draw(CheckpointKind kind, int program, int target, int mesh) => + Pack(kind, ((uint)Math.Clamp(target, 0, 0xFFF) << 16) | ((uint)program & 0xFFFF), (uint)mesh); + + public static nint Upload(int texture, uint width, uint height) => + Pack(CheckpointKind.Upload, (uint)texture, (Math.Min(width, 0xFFFFu) << 16) | Math.Min(height, 0xFFFFu)); + + public static nint Mipmaps(int texture, uint levels) => Pack(CheckpointKind.Mipmaps, (uint)texture, levels); + + public static nint PresentBlit(uint image, uint frame) => Pack(CheckpointKind.PresentBlit, image, frame); + + /// Renders a marker for a person, naming the program where one is known. + public static string Describe(nint marker, Func? programName = null) + { + CheckpointKind kind = KindOf(marker); + uint a = AOf(marker); + uint b = BOf(marker); + + switch (kind) + { + case CheckpointKind.FrameBegin: + return "frame " + b + " begins"; + + case CheckpointKind.Draw: + case CheckpointKind.DrawMulti: + case CheckpointKind.Fullscreen: + { + int program = (int)(a & 0xFFFF); + int target = (int)(a >> 16); + string name = programName?.Invoke(program) is { } known ? " '" + known + "'" : ""; + string what = kind switch + { + CheckpointKind.DrawMulti => "multi-draw", + CheckpointKind.Fullscreen => "fullscreen draw", + _ => "draw", + }; + string mesh = kind == CheckpointKind.Fullscreen ? "" : " mesh " + b; + return what + " with program " + program + name + mesh + " into framebuffer " + target; + } + + case CheckpointKind.Upload: + return "upload of " + (b >> 16) + "x" + (b & 0xFFFF) + " texels into texture " + a; + + case CheckpointKind.Mipmaps: + return "mipmap generation for texture " + a + " (" + b + " levels)"; + + case CheckpointKind.PresentBlit: + return "blit of frame " + b + " into swapchain image " + a; + + default: + return "unknown marker 0x" + ((ulong)(long)marker).ToString("x"); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index 7d7f85d1..9d58884d 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -40,6 +40,10 @@ internal sealed unsafe class VulkanTexture : IDisposable public Image Image { get; init; } public DeviceMemory Memory { get; init; } public ImageView View { get; init; } + + /// Never reused, unlike ; see . + public ulong Id { get; } = ResourceIds.Next(); + public Format Format { get; init; } public uint Width { get; init; } public uint Height { get; init; } @@ -353,6 +357,11 @@ public void Upload( _commands.SubmitAndWait(commandBuffer => { + if (_context.CheckpointsAvailable) + { + _context.CmdSetCheckpoint(commandBuffer, CheckpointMarker.Upload(textureId, width, height)); + } + TransitionTexture(commandBuffer, texture, ImageLayout.TransferDstOptimal); var region = new BufferImageCopy @@ -383,6 +392,11 @@ public void GenerateMipmaps(int textureId) int mipWidth = (int)texture.Width; int mipHeight = (int)texture.Height; + if (_context.CheckpointsAvailable) + { + _context.CmdSetCheckpoint(commandBuffer, CheckpointMarker.Mipmaps(textureId, texture.MipLevels)); + } + TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); for (uint level = 1; level < texture.MipLevels; level++) diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 4c14573b..6fc06f01 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -95,6 +95,25 @@ internal sealed unsafe class VulkanContext : IDisposable /// Marks a diagnostic the layers reported at error severity. public const string ErrorPrefix = "[error] "; + /// + /// Whether the driver records GPU checkpoints (VK_NV_device_diagnostic_checkpoints). + /// + /// A device loss otherwise says only that the GPU gave up. With checkpoints + /// the driver also reports the last marker each pipeline stage reached, which + /// names the draw or copy it was executing when it stopped. NVIDIA only; on + /// by default where present, off with OPTIMUM_VULKAN_CHECKPOINTS=0. + /// + public bool CheckpointsAvailable { get; private set; } + + /// Whether VK_EXT_device_fault can describe a loss after the fact. + public bool DeviceFaultAvailable { get; private set; } + + // Loaded by address rather than through an extension package: two entry + // points do not justify a dependency and another native DLL to ship. + private nint _cmdSetCheckpoint; + private nint _getQueueCheckpointData; + private ExtDeviceFault? _deviceFault; + private ExtDebugUtils? _debugUtils; private DebugUtilsMessengerEXT _debugMessenger; private Action? _debugCallback; @@ -475,6 +494,38 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso PhysicalDeviceFeatures available = Api.GetPhysicalDeviceFeatures(PhysicalDevice); + // Diagnostics for a lost device. Both are optional and cost nothing when + // the GPU is healthy, so they are taken wherever the driver offers them. + HashSet deviceExtensionsAvailable = EnumerateDeviceExtensions(); + bool checkpointsDisabled = + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_CHECKPOINTS") is "0" or "off" or "false"; + bool wantCheckpoints = !checkpointsDisabled && IntPtr.Size == 8 + && deviceExtensionsAvailable.Contains("VK_NV_device_diagnostic_checkpoints"); + + var faultFeatures = new PhysicalDeviceFaultFeaturesEXT + { + SType = StructureType.PhysicalDeviceFaultFeaturesExt, + }; + bool wantDeviceFault = false; + if (deviceExtensionsAvailable.Contains("VK_EXT_device_fault")) + { + var query = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &faultFeatures, + }; + Api.GetPhysicalDeviceFeatures2(PhysicalDevice, &query); + wantDeviceFault = faultFeatures.DeviceFault; + + // Re-request only the feature that is wanted; the query may have + // reported others this backend has no use for. + faultFeatures = new PhysicalDeviceFaultFeaturesEXT + { + SType = StructureType.PhysicalDeviceFaultFeaturesExt, + DeviceFault = wantDeviceFault, + }; + } + var enabledFeatures = new PhysicalDeviceFeatures { IndependentBlend = true, @@ -491,6 +542,7 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso var vulkan13 = new PhysicalDeviceVulkan13Features { SType = StructureType.PhysicalDeviceVulkan13Features, + PNext = wantDeviceFault ? &faultFeatures : null, DynamicRendering = true, Synchronization2 = true, }; @@ -510,6 +562,8 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso var deviceExtensions = new List(); if (!options.Headless) deviceExtensions.Add("VK_KHR_swapchain"); + if (wantCheckpoints) deviceExtensions.Add("VK_NV_device_diagnostic_checkpoints"); + if (wantDeviceFault) deviceExtensions.Add("VK_EXT_device_fault"); nint extensionsPtr = deviceExtensions.Count > 0 ? SilkMarshal.StringArrayToPtr(deviceExtensions) @@ -541,10 +595,140 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso } GraphicsQueue = Api.GetDeviceQueue(Device, family, 0); + LoadDiagnosticExtensions(wantCheckpoints, wantDeviceFault); Capabilities = ReadCapabilities(); return true; } + private HashSet EnumerateDeviceExtensions() + { + var names = new HashSet(StringComparer.Ordinal); + + uint count = 0; + Result result = Api.EnumerateDeviceExtensionProperties(PhysicalDevice, (byte*)null, &count, null); + if (result != Result.Success || count == 0) return names; + + var properties = new ExtensionProperties[count]; + fixed (ExtensionProperties* propertiesPtr = properties) + { + Api.EnumerateDeviceExtensionProperties(PhysicalDevice, (byte*)null, &count, propertiesPtr); + + // The name is a fixed-size buffer, readable only through a pointer. + for (int i = 0; i < count; i++) + { + string? name = SilkMarshal.PtrToString((nint)propertiesPtr[i].ExtensionName); + if (name != null) names.Add(name); + } + } + return names; + } + + private void LoadDiagnosticExtensions(bool checkpoints, bool deviceFault) + { + if (checkpoints) + { + nint set = (nint)Api.GetDeviceProcAddr(Device, "vkCmdSetCheckpointNV").Handle; + nint get = (nint)Api.GetDeviceProcAddr(Device, "vkGetQueueCheckpointDataNV").Handle; + if (set != 0 && get != 0) + { + _cmdSetCheckpoint = set; + _getQueueCheckpointData = get; + CheckpointsAvailable = true; + } + } + + if (deviceFault && Api.TryGetDeviceExtension(Instance, Device, out ExtDeviceFault fault)) + { + _deviceFault = fault; + DeviceFaultAvailable = true; + } + } + + /// Records a checkpoint marker into the command stream. No-op without the extension. + public void CmdSetCheckpoint(CommandBuffer commandBuffer, nint marker) + { + if (_cmdSetCheckpoint == 0) return; + ((delegate* unmanaged)_cmdSetCheckpoint)(commandBuffer, (void*)marker); + } + + /// + /// The last checkpoint each stage of the graphics queue reached. Meaningful + /// after a device loss. The caller synchronises the queue. + /// + public List<(PipelineStageFlags Stage, nint Marker)> ReadQueueCheckpoints() + { + var checkpoints = new List<(PipelineStageFlags, nint)>(); + if (_getQueueCheckpointData == 0) return checkpoints; + + var get = (delegate* unmanaged)_getQueueCheckpointData; + + uint count = 0; + get(GraphicsQueue, &count, null); + if (count == 0) return checkpoints; + + var data = new CheckpointDataNV[count]; + for (int i = 0; i < data.Length; i++) data[i].SType = StructureType.CheckpointDataNV; + fixed (CheckpointDataNV* dataPtr = data) + { + get(GraphicsQueue, &count, dataPtr); + } + + for (int i = 0; i < count; i++) + { + checkpoints.Add((data[i].Stage, (nint)data[i].PCheckpointMarker)); + } + return checkpoints; + } + + /// The driver's own account of a device loss, or null without the extension. + public string? ReadDeviceFault() + { + if (_deviceFault == null) return null; + + var counts = new DeviceFaultCountsEXT { SType = StructureType.DeviceFaultCountsExt }; + if (_deviceFault.GetDeviceFaultInfo(Device, &counts, null) != Result.Success) return null; + + var addresses = new DeviceFaultAddressInfoEXT[Math.Max(counts.AddressInfoCount, 1u)]; + var vendors = new DeviceFaultVendorInfoEXT[Math.Max(counts.VendorInfoCount, 1u)]; + var info = new DeviceFaultInfoEXT { SType = StructureType.DeviceFaultInfoExt }; + + // The binary blob is vendor-private and can be large; it is not asked for. + counts.VendorBinarySize = 0; + + var text = new System.Text.StringBuilder(); + + fixed (DeviceFaultAddressInfoEXT* addressPtr = addresses) + fixed (DeviceFaultVendorInfoEXT* vendorPtr = vendors) + { + info.PAddressInfos = counts.AddressInfoCount > 0 ? addressPtr : null; + info.PVendorInfos = counts.VendorInfoCount > 0 ? vendorPtr : null; + + Result result = _deviceFault.GetDeviceFaultInfo(Device, &counts, &info); + if (result != Result.Success && result != Result.Incomplete) return null; + + // The description strings are fixed-size buffers, readable only + // through a pointer, so everything is formatted while still pinned. + DeviceFaultInfoEXT* infoPtr = &info; + string description = SilkMarshal.PtrToString((nint)infoPtr->Description) ?? ""; + text.Append("Driver fault report: '").Append(description.Trim()).Append('\''); + + for (int i = 0; i < counts.AddressInfoCount; i++) + { + text.Append("; ").Append(addressPtr[i].AddressType) + .Append(" at 0x").Append(addressPtr[i].ReportedAddress.ToString("x")) + .Append(" (precision ").Append(addressPtr[i].AddressPrecision).Append(')'); + } + for (int i = 0; i < counts.VendorInfoCount; i++) + { + string vendor = SilkMarshal.PtrToString((nint)vendorPtr[i].Description) ?? ""; + text.Append("; vendor code ").Append(vendorPtr[i].VendorFaultCode) + .Append(" data ").Append(vendorPtr[i].VendorFaultData) + .Append(" '").Append(vendor.Trim()).Append('\''); + } + } + return text.ToString(); + } + private VulkanCapabilities ReadCapabilities() { PhysicalDeviceProperties properties = Api.GetPhysicalDeviceProperties(PhysicalDevice); diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index b143ab87..47514d75 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -7,6 +7,23 @@ namespace Optimum.Render.Vulkan.Core; +/// +/// Process-unique ids for device resources. +/// +/// A Vulkan handle identifies an object only while it lives: destroy an image +/// view and the driver is free to hand the very same handle value to the next +/// one created. Anything that remembers a resource by handle - the descriptor +/// set cache does - would then mistake the newcomer for the dead one and serve +/// a set that points at freed memory. An id that is never reused is what such +/// a cache has to key on instead. +/// +internal static class ResourceIds +{ + private static long _next; + + public static ulong Next() => (ulong)Interlocked.Increment(ref _next); +} + /// A device buffer with its backing memory. internal sealed unsafe class VulkanBuffer : IDisposable { @@ -16,6 +33,9 @@ internal sealed unsafe class VulkanBuffer : IDisposable public Buffer Handle { get; } public DeviceMemory Memory { get; } public ulong Size { get; } + + /// Never reused, unlike ; see . + public ulong Id { get; } = ResourceIds.Next(); /// Non-zero when the allocation is host visible and mapped. public IntPtr Mapped { get; private set; } @@ -186,6 +206,13 @@ internal static class VulkanResult /// Called with a description the moment something fails. public static Action? OnFailure; + /// + /// Asked to describe a device loss after the fact, so the message can say + /// what the GPU was doing rather than only that it stopped. Null when + /// nothing on the device can answer. + /// + public static Func? DescribeDeviceLoss; + public static void Check(Result result, string operation) { if (result == Result.Success || result == Result.SuboptimalKhr) return; @@ -199,6 +226,21 @@ public static void Check(Result result, string operation) "backend submitted; the session cannot continue." : operation + " failed with " + result; + if (lost) + { + string? detail; + try + { + detail = DescribeDeviceLoss?.Invoke(); + } + catch (Exception e) + { + detail = "Describing the loss itself failed: " + e.Message; + } + if (!string.IsNullOrEmpty(detail)) message += " " + detail; + message += " (" + VulkanMemory.LiveAllocations + " live device allocations.)"; + } + OnFailure?.Invoke(message); throw new InvalidOperationException(message); } diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 82fcad27..491101aa 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -33,10 +33,19 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice private RenderTargetManager _targets = null!; private GraphicsPipelineCache _pipelines = null!; private DescriptorCache _descriptors = null!; + + /// How many descriptor sets the cache currently holds. For tests. + internal int CachedDescriptorSets => _descriptors.Count; private FrameRing _frames = null!; private ShaderCompiler _shaderCompiler = null!; private readonly Dictionary _programs = new(); + + /// Pass names by program id, so a device-loss report can name the shader. + private readonly Dictionary _programNames = new(); + + private uint _frameCounter; + private uint _uniformExhaustionReportedFrame = uint.MaxValue; private readonly Dictionary _stagedStages = new(); private readonly List _diagnostics = new(); @@ -242,8 +251,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _diagnostics.Add(VulkanContext.ErrorPrefix + message); MirrorValidationMessage(message); }; + VulkanResult.DescribeDeviceLoss = DescribeDeviceLoss; MirrorValidationMessage("--- device up on " + _context.Capabilities.DeviceName + - "; validation layers " + (_context.ValidationEnabled ? "ENABLED" : "NOT AVAILABLE")); + "; validation layers " + (_context.ValidationEnabled ? "ENABLED" : "NOT AVAILABLE") + + "; GPU checkpoints " + (_context.CheckpointsAvailable ? "ENABLED" : "NOT AVAILABLE") + + "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE")); _setupCommands = new VulkanCommands(_context); _state = new GlStateTracker(); _textures = new TextureManager(_context, _setupCommands); @@ -392,8 +404,8 @@ private void CreatePlaceholderUniformBuffer() private void DestroyDefaultFramebuffer() { if (_defaultFramebuffer > 0) _targets.Delete(_defaultFramebuffer); - if (_defaultColor > 0) _textures.Delete(_defaultColor, _frames); - if (_defaultDepth > 0) _textures.Delete(_defaultDepth, _frames); + if (_defaultColor > 0) ReleaseTexture(_defaultColor); + if (_defaultDepth > 0) ReleaseTexture(_defaultDepth); _defaultFramebuffer = 0; _defaultColor = 0; @@ -452,8 +464,86 @@ public void BeginFrame() { _frames.BeginFrame(); _frameActive = true; + _frameCounter++; + Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); + + // Sets naming resources deleted since last frame leave the cache now and + // are freed once the ring has cycled past every frame that could have + // bound them. + IDisposable? freedSets = _descriptors.CollectReleases(); + if (freedSets != null) _frames.DeferDeletion(freedSets); + } + + /// + /// Leaves a marker the driver reports back if the GPU stops. Free when the + /// extension is absent; one small command otherwise. + /// + private void Checkpoint(CommandBuffer commandBuffer, nint marker) + { + if (_context.CheckpointsAvailable) _context.CmdSetCheckpoint(commandBuffer, marker); + } + + /// + /// What the GPU was doing when it was lost, from the driver's checkpoint and + /// fault records. VulkanResult.Check calls this on the first loss. + /// + /// Reading checkpoints wants the queue synchronised like any other queue + /// call, but the thread that noticed the loss may already hold the lock, or + /// another may be inside a submit that is about to fail. A bounded wait keeps + /// the crash report from deadlocking behind the crash it is describing. + /// + private string? DescribeDeviceLoss() + { + if (_context == null) return null; + + var text = new System.Text.StringBuilder(); + + if (_context.CheckpointsAvailable) + { + bool locked = System.Threading.Monitor.TryEnter(_context.QueueLock, 2000); + try + { + List<(PipelineStageFlags Stage, nint Marker)> checkpoints = _context.ReadQueueCheckpoints(); + if (checkpoints.Count == 0) + { + text.Append("The driver recorded no GPU checkpoints."); + } + else + { + text.Append("Last GPU checkpoint per stage -"); + foreach ((PipelineStageFlags stage, nint marker) in checkpoints) + { + text.Append(' ').Append(StageName(stage)).Append(": ") + .Append(CheckpointMarker.Describe(marker, ProgramNameOf)).Append(';'); + } + } + } + finally + { + if (locked) System.Threading.Monitor.Exit(_context.QueueLock); + } + } + else + { + text.Append("GPU checkpoints are not available on this driver."); + } + + string? fault = _context.DeviceFaultAvailable ? _context.ReadDeviceFault() : null; + if (fault != null) text.Append(' ').Append(fault).Append('.'); + + return text.ToString(); } + private string? ProgramNameOf(int programId) => + _programNames.TryGetValue(programId, out string? name) ? name : null; + + private static string StageName(PipelineStageFlags stage) => stage switch + { + PipelineStageFlags.TopOfPipeBit => "last started", + PipelineStageFlags.BottomOfPipeBit => "last completed", + _ => stage.ToString(), + }; + public void Present() { if (!_frameActive) return; @@ -481,6 +571,7 @@ public void Present() return; } + Checkpoint(commandBuffer, CheckpointMarker.PresentBlit(imageIndex, _frameCounter)); BlitToSwapchain(commandBuffer, imageIndex); _frames.EndFrame(imageAvailable, renderFinished); @@ -676,6 +767,7 @@ public int LinkProgram(IShaderProgram program) } } _programs[programId] = new ShaderProgramResources(_context, programId, translated); + _programNames[programId] = program.PassName ?? ""; return programId; } @@ -702,6 +794,7 @@ private void AddStage(List stages, IShader? shader, EnumShade public void DeleteProgram(int programId) { if (!_programs.Remove(programId, out ShaderProgramResources? program)) return; + _programNames.Remove(programId); _frames.DeferDeletion(program); } @@ -891,6 +984,9 @@ public void DeleteUniformBuffer(int handle) if (_uniformBuffers.Remove(handle, out VulkanBuffer? buffer)) { + // Same hazard as a texture: a set naming this buffer must not + // survive to be served for a successor with the same handle. + _descriptors.Release(buffer.Id); _frames.DeferDeletion(buffer); } } @@ -970,7 +1066,23 @@ public void UploadTexture2D( public void GenerateMipmaps(int textureId) => _textures.GenerateMipmaps(textureId); - public void DeleteTexture(int textureId) => _textures.Delete(textureId, _frames); + public void DeleteTexture(int textureId) => ReleaseTexture(textureId); + + /// + /// Deletes a texture and evicts every descriptor set that names it. + /// + /// The eviction is the important half. The texture itself is destroyed a + /// ring cycle later, but a cached set would outlive it and, once the driver + /// reused the view handle for a new texture, be served to draws of that new + /// texture - which is a GPU read of freed memory. The GUI re-renders its text + /// into fresh textures constantly, so this was the loading-screen crash. + /// + private void ReleaseTexture(int textureId) + { + VulkanTexture? texture = _textures.Get(textureId); + if (texture != null) _descriptors.Release(texture.Id); + _textures.Delete(textureId, _frames); + } public void SetTextureParameter(int textureId, int parameterName, int value) => _textures.SetParameter(textureId, parameterName, value); @@ -1211,6 +1323,8 @@ public void UpdateMeshStorageBuffer(int meshId, IntPtr data, int byteOffset, int public void DrawMeshInstanced(int meshId, int instanceCount) { if (!PrepareDraw(_meshes.LayoutIdOf(meshId), out CommandBuffer commandBuffer)) return; + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.Draw, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); if (RenderTrace.Enabled) { RenderTrace.Write("draw mesh=" + meshId + " program=" + _state.CurrentProgram + @@ -1232,6 +1346,8 @@ public void DrawMeshInstanced(int meshId, int instanceCount) public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo) { if (!PrepareDraw(_meshes.LayoutIdOf(meshId), out CommandBuffer commandBuffer)) return; + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.DrawMulti, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); VulkanBuffer indirect = EnsureIndirectScratch(groupCount); _meshes.DrawMulti(commandBuffer, meshId, indicesStarts, indicesSizes, groupCount, indirect); @@ -1240,6 +1356,8 @@ public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, i public void DrawFullscreenTriangle() { if (!PrepareDraw(MeshManager.EmptyLayoutId, out CommandBuffer commandBuffer)) return; + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.Fullscreen, _state.CurrentProgram, _targets.Bound?.Id ?? 0, 0)); if (RenderTrace.Enabled) { RenderTrace.Write("fullscreen program=" + _state.CurrentProgram + @@ -1425,6 +1543,20 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources dynamicOffset = allocation.Offset; program.MarkUniformsClean(); } + else if (_uniformExhaustionReportedFrame != _frameCounter) + { + // The draw goes ahead reading offset zero of the ring, which + // is some other draw's block: wrong, and for a shader that + // loops on a uniform count, possibly fatal. Said once per + // frame so a long frame does not flood the log. + _uniformExhaustionReportedFrame = _frameCounter; + string message = VulkanContext.ErrorPrefix + "uniform ring exhausted in frame " + _frameCounter + + " (" + _frames.Current.UniformBytesUsed + " of " + _frames.Current.UniformCapacity + + " bytes used) at a draw with program " + program.ProgramId + + " '" + ProgramNameOf(program.ProgramId) + "'"; + _diagnostics.Add(message); + MirrorValidationMessage(message); + } buffers.Add(new BufferBindingValue( ProgramInterfaceLayout.DefaultBlockBinding, @@ -1445,7 +1577,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if (blockBuffer == null) continue; buffers.Add(new BufferBindingValue( - (uint)block.Binding, blockBuffer.Handle, 0, blockBuffer.Size)); + (uint)block.Binding, blockBuffer.Handle, 0, blockBuffer.Size, blockBuffer.Id)); } var uniformContents = new DescriptorSetContents( @@ -1475,6 +1607,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources ImageView view = default; Sampler sampler = default; + ulong resource = 0; if ((uint)unit < GlStateTracker.MaxTextureUnits) { @@ -1482,6 +1615,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if (texture != null) { view = texture.View; + resource = texture.Id; // A sampler bound to the unit overrides the texture's own // state, which is what glBindSampler means. sampler = _unitSamplerOverrides[unit].Handle != 0 @@ -1490,7 +1624,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources } } - bindings[i] = new SamplerBindingValue((uint)declared.Binding, view, sampler); + bindings[i] = new SamplerBindingValue((uint)declared.Binding, view, sampler, resource); } // A sampler the client left unbound gets the placeholder rather than @@ -1506,7 +1640,8 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if (placeholder == null) break; bindings[i] = new SamplerBindingValue( - bindings[i].Binding, placeholder.View, _textures.Samplers.Get(placeholder.State)); + bindings[i].Binding, placeholder.View, _textures.Samplers.Get(placeholder.State), + placeholder.Id); } bool complete = true; From 1f3b5e924b10eacc24d83ea2391499229c05b4eb Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 9 Sep 2026 19:57:12 +0200 Subject: [PATCH 004/226] fix(render): render terrain, clouds and outlines correctly on Vulkan The Vulkan backend reached a world but drew it wrong: terrain was a checkerboard of unrelated block textures, whole chunk-shaped wedges were missing and turned transparent as the camera moved, clouds were flat hexagons, and the block selection outline was filled triangles. Four independent faults, all in how GL's conventions were carried across the seam rather than in the game's data. Vertex normalization. AddCustoms binds custom shorts with GL_UNSIGNED_SHORT unless the part converts to integer, and the tesselator relies on it: AddPackedUV stores a UV as (short)(ushort)(u * 0x8000), deliberately letting values above 0x7FFF wrap, because GL reads them back unsigned. Binding them signed halved the range and so doubled every coordinate, spreading a swathe of the atlas over each face. Multi-draw ranges. glMultiDrawElements takes an array of pointers, so MeshDataPool sizes indicesStartsByte as maxPartsPerPool * 2 and packs each 64-bit offset into two ints - which is why it is twice the length of indicesSizes. Reading one int per group gave every second draw the high word of its predecessor, zero, and it rendered from the start of the pool instead of its own range. Cloud tiles. The renderer builds RGBA16 tiles as signed shorts and hands them to GL as GL_SHORT, which converts to unsigned normalized storage on upload. The raw upload path copied the bytes through untouched, so UploadTexture2DNormalizedShorts now performs that conversion. Primitive topology. A mesh's draw mode was recorded but never applied, so Lines and LineStrip meshes rasterised as triangles. It is now selected from the mesh before pipeline lookup and dynamic state, and reset for fullscreen draws that have no mesh. Alongside those, the suballocator that made a loaded world affordable (one vkAllocateMemory per resource reached 17,500 of them and 200 ms frames; blocks of 64 MB bring it to dozens), the SSBO chunk path, a snapshot for the atlas-composition feedback loop where a texture is sampled while bound as a colour attachment, sampler LOD clamps that honour GL's non-mipmapping filters, and diagnostics behind environment variables: OPTIMUM_DUMP_TEXTURES reads a texture back off the GPU, OPTIMUM_VULKAN_DEDICATED_MEMORY restores one allocation per resource to isolate suballocation faults. 222 tests pass. Verified in a real world on an RTX 4070: correct block textures, terrain continuous while turning and walking, clouds shaded and irregular, thin block outlines, and zero validation errors. --- Optimum.Patcher/Program.cs | 9 + Optimum.Patcher/mod-patcher.cs | 9 + Optimum.Render.Vulkan.Tests/AllocatorTests.cs | 278 ++++++++ .../ChunkTerrainRenderTests.cs | 669 +++++++++++++++++- .../MeshDrawRangeTests.cs | 47 ++ .../MeshManagerTests.cs | 171 ++++- .../TextureManagerTests.cs | 57 ++ .../VertexAttributeDefaultTests.cs | 8 +- .../VulkanDeviceIntegrationTests.cs | 268 +++++++ Optimum.Render.Vulkan/Core/DescriptorCache.cs | 9 +- Optimum.Render.Vulkan/Core/GlEnums.cs | 10 + Optimum.Render.Vulkan/Core/MeshManager.cs | 174 ++++- .../Core/RenderTargetManager.cs | 43 +- Optimum.Render.Vulkan/Core/TextureDump.cs | 137 ++++ Optimum.Render.Vulkan/Core/TextureManager.cs | 78 +- Optimum.Render.Vulkan/Core/VulkanAllocator.cs | 369 ++++++++++ Optimum.Render.Vulkan/Core/VulkanContext.cs | 12 + Optimum.Render.Vulkan/Core/VulkanResources.cs | 82 ++- Optimum.Render.Vulkan/Core/VulkanStats.cs | 85 +++ Optimum.Render.Vulkan/VulkanDevice.cs | 660 +++++++++++++++-- .../Newclouds/CloudRendererMap.cs.patch | 173 +++++ .../CloudRendererVolumetric.cs.patch | 30 + .../ClientPlatformAbstract.cs.patch | 27 + .../ClientPlatformWindows.cs.patch | 219 ++++-- patches/cecil-owned.list | 1 + scripts/package-linux.sh | 23 + 26 files changed, 3426 insertions(+), 222 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/AllocatorTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/MeshDrawRangeTests.cs create mode 100644 Optimum.Render.Vulkan/Core/TextureDump.cs create mode 100644 Optimum.Render.Vulkan/Core/VulkanAllocator.cs create mode 100644 Optimum.Render.Vulkan/Core/VulkanStats.cs create mode 100644 patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch create mode 100644 patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index a744a07a..842c0e01 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -121,6 +121,7 @@ "SetupOptimumFrameBuffers", "CreateOptimumColorTarget", "CreateOptimumDepthTarget", + "CreateOptimumPlaceholderTarget", "CreateOptimumFramebuffer", // Vulkan backend: GL state the device takes as call arguments instead, // so the routed bodies need somewhere to remember it. @@ -129,6 +130,7 @@ "optimumClearB", "optimumClearA", "optimumBoundTexture2d", + "optimumScissorEnabled", }, ["Vintagestory.Client.NoObf.ShaderPrograms"] = new() { @@ -481,6 +483,9 @@ new[] { "System.Int32[]", "System.Int32", "System.Int32", "Vintagestory.Client.NoObf.VAO", "System.Boolean" }), // ClientPlatformWindows: frame pacing + background FPS (inline in window_RenderFrame, no lambdas) new("Vintagestory.Client.NoObf.ClientPlatformWindows", "window_RenderFrame", 1), + // The shared index buffer is freed at shutdown, after the GL binding is gone + // on the device path; the raw call throws there instead of freeing it. + new("Vintagestory.Client.NoObf.ClientPlatformAbstract", "DisposeIndexBuffer", 0), // FSR: allocate the native intermediate and replace the final bilinear blit. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "SetupDefaultFrameBuffers", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BlitPrimaryToDefault", 0), @@ -587,6 +592,10 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBuffer", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBufferKeepVw", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_GlDebugMode", 1), + // The scissor flag is read back by the runtime atlas upload; the device + // keeps no queryable state, so the routed setter remembers it. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "get_GlScissorFlagEnabled", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlScissorFlag", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateFramebuffer", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffer", 2), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffers", 1), diff --git a/Optimum.Patcher/mod-patcher.cs b/Optimum.Patcher/mod-patcher.cs index 28cc8566..b84ba2be 100644 --- a/Optimum.Patcher/mod-patcher.cs +++ b/Optimum.Patcher/mod-patcher.cs @@ -135,6 +135,15 @@ private static Manifest EssentialsManifest() }, Methods: [ + // Vulkan backend: the cloud renderers call OpenGL directly for + // their map framebuffer, tile textures and state; on the device + // path those go through the render seam instead. + new("FluffyClouds.CloudRendererMap", "FreeGlResources", 0), + new("FluffyClouds.CloudRendererMap", "OnRenderFrame", 2), + new("FluffyClouds.CloudRendererMap", "WriteTexture", 0), + new("FluffyClouds.CloudRendererMap", "makeTexture", 4), + new("FluffyClouds.CloudRendererMap", "InitCloudTiles", 1), + new("FluffyClouds.CloudRendererVolumetric", "OnRenderFrame", 2), new("Vintagestory.GameContent.BlockEntityParticleEmitter", "OnGameTick", 1), new("Vintagestory.GameContent.EntityBehaviorCollectEntities", "OnGameTick", 1), new("Vintagestory.GameContent.EntityBehaviorRepulseAgents", "OnGameTick", 1), diff --git a/Optimum.Render.Vulkan.Tests/AllocatorTests.cs b/Optimum.Render.Vulkan.Tests/AllocatorTests.cs new file mode 100644 index 00000000..45f693e2 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AllocatorTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The suballocator. A device allocation is a driver-tracked object whose cost +/// is paid on every submit, so what matters is the count, not the bytes: a +/// loaded world at one allocation per resource reached eighteen thousand of them +/// and 200 ms frames. +/// +public class AllocatorTests +{ + private readonly ITestOutputHelper _output; + + public AllocatorTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context) + { + var options = new VulkanContextOptions { Headless = true }; + bool created = VulkanContext.TryCreate(options, out context, out string? reason); + if (!created) output.WriteLine("Vulkan unavailable: " + reason); + return created; + } + + /// + /// The regression that matters. A chunk mesh is several buffers, and a world + /// is thousands of chunks; that has to stay in the tens of allocations. + /// + [SkippableFact] + public void AWorldsWorthOfBuffersCostsTensOfAllocationsNotThousands() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + int before = VulkanMemory.LiveAllocations; + var buffers = new List(); + + // 2000 buffers of 64 KB: about the shape of a loaded world's meshes. + for (int i = 0; i < 2000; i++) + { + buffers.Add(new VulkanBuffer(context!, 64 * 1024, + BufferUsageFlags.VertexBufferBit | BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit)); + } + + int used = VulkanMemory.LiveAllocations - before; + _output.WriteLine($"2000 buffers cost {used} device allocations, " + + $"{context!.Allocator.BlockCount} blocks live"); + + // 2000 * 64 KB is 128 MB, so two 64 MB blocks is the floor; a handful + // of extra for alignment is fine. Anything near 2000 is the old bug. + Assert.InRange(used, 1, 16); + + foreach (VulkanBuffer buffer in buffers) buffer.Dispose(); + } + } + + [SkippableFact] + public void EveryBufferGetsDistinctMappedMemoryWithinItsBlock() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var buffers = new List(); + for (int i = 0; i < 64; i++) + { + buffers.Add(new VulkanBuffer(context!, 4096, + BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit)); + } + + // Each buffer writes its own index through its own pointer; if two + // shared a region, or a pointer were the block base rather than the + // buffer's slice, the values would not survive. + for (int i = 0; i < buffers.Count; i++) + { + Assert.NotEqual(IntPtr.Zero, buffers[i].Mapped); + unsafe { *(int*)buffers[i].Mapped = i; } + } + for (int i = 0; i < buffers.Count; i++) + { + unsafe { Assert.Equal(i, *(int*)buffers[i].Mapped); } + } + + foreach (VulkanBuffer buffer in buffers) buffer.Dispose(); + } + } + + /// + /// No two live resources may ever share a byte. + /// + /// Distinct pointers are not the same claim: two regions can start in + /// different places and still overlap, and the free list is where that goes + /// wrong - a range merged with a neighbour it does not touch, or a split + /// that loses its alignment padding, hands the same bytes out twice. On the + /// GPU that is one resource writing over another's contents, which reads as + /// corrupt geometry or textures rather than as a crash. + /// + /// So this runs the shape chunk streaming actually has - many live at once, + /// mixed sizes and alignments, a churn of frees and fresh allocations - and + /// checks every live pair after every round. + /// + [SkippableFact] + public void LiveAllocationsNeverOverlapUnderChurn() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + // Sizes a chunk pool really asks for: the xyz slot is large, the + // flag and colour streams small, and index buffers in between. + int[] sizes = { 1024, 4096, 12_288, 65_536, 262_144, 3072, 48_000, 6144 }; + + var live = new List(); + var random = new Random(20260909); + + void AssertNoOverlap(int round) + { + // Grouped by memory, since regions in different allocations are + // unrelated however their offsets compare. + var byMemory = new Dictionary>(); + + for (int i = 0; i < live.Count; i++) + { + MemoryAllocation allocation = live[i].Allocation; + ulong memory = allocation.Memory.Handle; + if (!byMemory.TryGetValue(memory, out var regions)) + { + regions = new List<(ulong, ulong, int)>(); + byMemory[memory] = regions; + } + regions.Add((allocation.Offset, allocation.Offset + allocation.Size, i)); + } + + foreach ((ulong memory, var regions) in byMemory) + { + regions.Sort((a, b) => a.Start.CompareTo(b.Start)); + for (int i = 1; i < regions.Count; i++) + { + Assert.True(regions[i].Start >= regions[i - 1].End, + $"round {round}: buffers {regions[i - 1].Index} and {regions[i].Index} overlap in " + + $"memory {memory:x}: [{regions[i - 1].Start}, {regions[i - 1].End}) and " + + $"[{regions[i].Start}, {regions[i].End})"); + } + } + } + + for (int round = 0; round < 12; round++) + { + for (int i = 0; i < 120; i++) + { + live.Add(new VulkanBuffer(context!, (ulong)sizes[random.Next(sizes.Length)], + BufferUsageFlags.VertexBufferBit | BufferUsageFlags.StorageBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit)); + } + + AssertNoOverlap(round); + + // Drop a scattered half, so the free list has to merge some + // neighbours and keep others apart. + for (int i = live.Count - 1; i >= 0; i--) + { + if (random.Next(2) != 0) continue; + live[i].Dispose(); + live.RemoveAt(i); + } + + AssertNoOverlap(round); + } + + // And the bytes themselves survive, which no offset arithmetic can + // fake: each buffer stamps its own identity and reads it back. + for (int i = 0; i < live.Count; i++) + { + unsafe { *(int*)live[i].Mapped = i * 7919; } + } + for (int i = 0; i < live.Count; i++) + { + unsafe + { + Assert.Equal(i * 7919, *(int*)live[i].Mapped); + } + } + + _output.WriteLine($"{live.Count} live buffers in {context!.Allocator.BlockCount} blocks"); + foreach (VulkanBuffer buffer in live) buffer.Dispose(); + } + } + + /// + /// Chunk streaming frees and reallocates constantly. Freed space has to come + /// back, or a long session grows blocks without bound. + /// + [SkippableFact] + public void FreedSpaceIsReusedRatherThanGrowingThePool() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const int batch = 200; + for (int round = 0; round < 5; round++) + { + var buffers = new List(); + for (int i = 0; i < batch; i++) + { + buffers.Add(new VulkanBuffer(context!, 128 * 1024, + BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit)); + } + foreach (VulkanBuffer buffer in buffers) buffer.Dispose(); + + _output.WriteLine($"round {round}: {context!.Allocator.BlockCount} blocks"); + } + + // Five rounds of the same batch must not leave five rounds of blocks. + Assert.InRange(context!.Allocator.BlockCount, 0, 4); + } + } + + /// + /// A block only ever holds one kind of resource, which is what makes + /// bufferImageGranularity a non-issue. Mixing them in one block is the + /// classic source of corruption that only shows on some hardware. + /// + [SkippableFact] + public void ImagesAndBuffersNeverShareABlock() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + var buffer = new VulkanBuffer(context!, 4096, + BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + int textureId = textures.Create(64, 64, Format.R8G8B8A8Unorm); + VulkanTexture? texture = textures.Get(textureId); + Assert.NotNull(texture); + + Assert.NotEqual(buffer.MemoryHandleForTest, texture!.Allocation.Memory.Handle); + + buffer.Dispose(); + } + } + + /// A resource too large to pool gets its own block rather than forcing a huge one. + [SkippableFact] + public void AnOversizedResourceGetsItsOwnBlockAndGivesItBack() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + int before = context!.Allocator.BlockCount; + + // Larger than the pooling threshold: an atlas is this shape. + var big = new VulkanBuffer(context, 48UL * 1024 * 1024, + BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + Assert.Equal(before + 1, context.Allocator.BlockCount); + + big.Dispose(); + Assert.Equal(before, context.Allocator.BlockCount); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs index 28a6a3bf..b117db84 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs @@ -33,6 +33,16 @@ public class ChunkTerrainRenderTests public ChunkTerrainRenderTests(ITestOutputHelper output) => _output = output; private const int Size = 64; + private const int UpNormalFlags = 7 << 18; + + private static float[] CreateFaceRecord() + { + var record = new float[16]; + // unpackNormal normalizes the packed vector. An all-zero flags word + // would normalize (0,0,0), producing NaNs on some drivers. + Array.Fill(record, BitConverter.Int32BitsToSingle(UpNormalFlags), 8, 4); + return record; + } private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) { @@ -124,7 +134,7 @@ private static MeshData BuildBlockFace() Vintagestory.API.MathTools.ColorUtil.WhiteArgb, // Normal pointing up, no glow, no waving: the flags word a solid // top face carries. - flags: 0); + flags: UpNormalFlags); } foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) @@ -134,6 +144,92 @@ private static MeshData BuildBlockFace() return mesh; } + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public unsafe void TheTopsoilShaderSamplesTheGrassTileWithPackedSecondaryUvs(bool ssbo) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + var variant = ShaderCorpus.Variants().First(); + variant.UseSsbo = ssbo ? 1 : 0; + int program = LinkFromCorpus(seam, ShaderCorpus.BuildProgram("chunktopsoil", + ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant), "chunktopsoil"); + int unit = BindEveryDeclaredSampler(device!, seam, program); + + // The top grass texture occupies tile (2,1), one tile to the right + // of its packed UV origin, as in the real topsoil atlas. Signed + // normalization doubles the UVs and sends them into a red tile. + var atlasPixels = new byte[4 * 4 * 4]; + for (int i = 0; i < 16; i++) + { + atlasPixels[i * 4] = 255; + atlasPixels[i * 4 + 3] = 255; + } + atlasPixels[6 * 4] = 0; + atlasPixels[6 * 4 + 1] = 255; + int atlas; + fixed (byte* pixels = atlasPixels) + atlas = seam.CreateTexture2D(4, 4, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + foreach (string name in new[] { "terrainTex", "terrainTexLinear" }) + { + seam.SetSamplerUnit(program, name, unit); + seam.BindTexture(unit++, atlas); + } + + MeshData face = BuildBlockFace(); + face.CustomShorts = new CustomMeshDataPartShort(8) + { + InterleaveSizes = new[] { 2 }, InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, Conversion = DataConversion.NormalizedFloat, + }; + for (int i = 0; i < 4; i++) + face.CustomShorts.AddPackedUV(0.375f, 0.375f, isU2: false, isV2: false); + + int mesh = seam.CreateEmptyMesh(48, 0, 32, 16, 16, 24, + null, face.CustomShorts, null, null, EnumDrawMode.Triangles, false, ssbo); + seam.UpdateMesh(mesh, face); + if (ssbo) + { + var record = CreateFaceRecord(); + record[0] = -0.5f; record[1] = -0.5f; + record[4] = 0.5f; record[13] = 0.5f; + fixed (float* pixels = record) + seam.UpdateMeshStorageBuffer(mesh, (IntPtr)pixels, 0, 64); + } + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 1); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 1f, 0f, 1f, 1f); + seam.UseProgram(program); + SetIdentityMatrices(seam, program); + SetViewUniforms(seam, program); + seam.SetUniform(program, seam.GetUniformLocation(program, "blockTextureSize"), 0.25f, 0.25f); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetCullFace(true); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo); + seam.Present(); + + byte[] output = ReadTarget(seam, framebuffer); + int centre = (Size / 2 * Size + Size / 2) * 4; + Assert.True(output[centre + 1] > 20 && output[centre] < 5 && output[centre + 2] < 5, + $"Expected grass green; got {output[centre]}, {output[centre + 1]}, {output[centre + 2]}"); + Assert.True(IsClearColour(output, 0), "The pooled face extended outside its geometry."); + AssertClean(seam); + } + } + /// /// The real chunkopaque program, drawing a real tesselated face, checked by /// reading the pixels back. @@ -290,6 +386,562 @@ public void TheShadowMapProgramDrawsTerrainIntoADepthOnlyTarget() // ------------------------------------------------------------------ helpers /// The magenta the target was cleared to, within 8-bit rounding. + /// + /// The path the world actually renders through: an SSBO pool, one packed + /// face record in its storage slot, the fixed quad index pattern, the storage + /// descriptor set, and culling on. The attribute-variant test above covers + /// none of that, which is how a world of shards got past the suite. + /// + /// The record follows the shader's std430 FaceData - xyz, uv, xyzA, uvSize, + /// flags[4], xyzB, colormapData, 64 bytes - and the decode is + /// xyz + ((v+1)&2)*xyzA + (v&2)*xyzB, so xyzA and xyzB are half the + /// quad's two edges. The flags encode an upward normal; their integer bits + /// are preserved in the float array used to upload the record. + /// + [SkippableFact] + public unsafe void TheSsboChunkPathDrawsAFaceFromAPackedRecordWithCullingOn() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + IOptimumGraphicsDevice seam = device!; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + // The SSBO variant without greedy meshing: a greedy-meshed quad takes + // its tile counts from the record's flags. This test exercises the + // ordinary packed-face path, without greedy tiling. + ShaderCorpus.ShaderVariant variant = + ShaderCorpus.Variants().First(v => v.UseSsbo == 1 && v.GreedyMesh == 0); + List stages = + ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + Assert.NotEmpty(stages); + + int programId = LinkFromCorpus(seam, stages, "chunkopaque"); + BindEveryDeclaredSampler(device!, seam, programId); + + int target = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + // Four vertices and six indices, sized as the game sizes a pool: the + // xyz figure is positions, and the device scales it for face records. + int mesh = seam.CreateEmptyMesh( + xyzSize: 4 * 12, normalsSize: 0, uvSize: 0, rgbaSize: 4 * 4, flagsSize: 0, + indicesSize: 6 * sizeof(int), null, null, null, null, + EnumDrawMode.Triangles, staticDraw: false, ssbo: true); + Assert.True(mesh > 0, seam.GetError() ?? "SSBO mesh allocation failed"); + + // In the game's order: the ordinary vertex data first, then the face + // records. The MeshData carries a (zero) xyz array like the game's + // does, which must not reach the storage slot - the device skips it + // for an SSBO mesh, and this is where that is exercised. + var colours = new MeshData(4, 6) { Rgba = new byte[16], RgbaOffset = 0, VerticesCount = 4 }; + Array.Fill(colours.Rgba, (byte)255); + seam.UpdateMesh(mesh, colours); + + // A quad from (-0.5,-0.5) to (0.5,0.5): xyz is the first corner, xyzA + // half of the edge to the second, xyzB half of the edge to the fourth. + var record = CreateFaceRecord(); + record[0] = -0.5f; record[1] = -0.5f; record[2] = 0f; // xyz + record[4] = 0.5f; record[5] = 0f; record[6] = 0f; // xyzA + record[12] = 0f; record[13] = 0.5f; record[14] = 0f; // xyzB + fixed (float* bytes = record) + { + seam.UpdateMeshStorageBuffer(mesh, (IntPtr)bytes, 0, 64); + } + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 1f, 0f, 1f, 1f); + seam.ClearDepth(1f); + seam.UseProgram(programId); + SetIdentityMatrices(seam, programId); + SetViewUniforms(seam, programId); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + // On, as the chunk pass has it. The quad winds counter-clockwise in + // GL's terms, so it is a front face and must survive. + seam.SetCullFace(true); + seam.SetBlend(false, EnumBlendMode.Standard); + + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo: true); + seam.Present(); + + int centre = (Size / 2 * Size + Size / 2) * 4; + int corner = (2 * Size + 2) * 4; + byte[] pixels = ReadTarget(seam, framebuffer); + _output.WriteLine($"multi-draw, culling on: centre RGBA = {pixels[centre]}, {pixels[centre + 1]}, " + + $"{pixels[centre + 2]}, {pixels[centre + 3]}"); + bool rasterised = !IsClearColour(pixels, centre); + + // A failure is only useful if it says which part failed, so the same + // face is tried again with culling off and through the single-draw + // path, and the message reports what each of those did. + string diagnosis = ""; + if (!rasterised) + { + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 1f, 0f, 1f, 1f); + seam.ClearDepth(1f); + seam.UseProgram(programId); + seam.SetCullFace(false); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo: true); + seam.Present(); + bool withoutCulling = !IsClearColour(ReadTarget(seam, framebuffer), centre); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 1f, 0f, 1f, 1f); + seam.ClearDepth(1f); + seam.UseProgram(programId); + seam.SetCullFace(true); + seam.DrawMesh(mesh); + seam.Present(); + bool singleDraw = !IsClearColour(ReadTarget(seam, framebuffer), centre); + + diagnosis = $" (culling off: {(withoutCulling ? "rasterised" : "nothing")}; " + + $"single draw with culling: {(singleDraw ? "rasterised" : "nothing")}; " + + $"diagnostics: {seam.GetError() ?? "none"})"; + } + + Assert.True(rasterised, "the face record did not rasterise through the multi-draw path" + diagnosis); + Assert.True(IsClearColour(pixels, corner), "the face covered the whole target"); + + AssertClean(seam); + } + } + + /// + /// A face record's UV must reach the atlas as the record wrote it. + /// + /// The chunk shaders unpack UVs out of the storage record rather than a + /// vertex attribute: vdata.uv is the origin as 16-bit fixed point and + /// vdata.uvSize the span, and UnpackUv divides both by 32768. Both are + /// ints sitting between the record's vec3s, so any disagreement between the + /// struct C# writes and the one the shader reads lands there first - and + /// misreading the span for the origin, or the other way round, maps a swathe + /// of the atlas across a single block face instead of one block texture. + /// + /// So this draws the same face twice against a four-texel atlas, once with + /// the origin on the red texel and once on the green one, with a zero span + /// both times. Reading back a red face and then a green one is only possible + /// if the record's origin arrived exactly and the span really was zero. + /// + [SkippableFact] + public unsafe void AFaceRecordSamplesTheAtlasWhereItsPackedUvPointsTo() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + IOptimumGraphicsDevice seam = device!; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = + ShaderCorpus.Variants().First(v => v.UseSsbo == 1 && v.GreedyMesh == 0); + List stages = + ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + Assert.NotEmpty(stages); + + int programId = LinkFromCorpus(seam, stages, "chunkopaque"); + int nextUnit = BindEveryDeclaredSampler(device!, seam, programId); + + // A two-by-two atlas: red, green on the bottom row, blue and white on + // the top. Nearest filtering, so a UV inside a texel is that texel + // and nothing is blended in from its neighbours. + var atlasPixels = new byte[] + { + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, + }; + int atlas; + fixed (byte* source = atlasPixels) + { + atlas = seam.CreateTexture2D(2, 2, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)source, false); + } + seam.SetTextureParameter(atlas, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(atlas, OptimumGlConstants.TextureMagFilter, 9728); + + // Both terrain samplers: the base texture and the one the colormap + // include samples through. + foreach (string samplerName in new[] { "terrainTex", "terrainTexLinear" }) + { + seam.SetSamplerUnit(programId, samplerName, nextUnit); + seam.BindTexture(nextUnit, atlas); + nextUnit++; + } + + int target = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int mesh = seam.CreateEmptyMesh( + xyzSize: 4 * 12, normalsSize: 0, uvSize: 0, rgbaSize: 4 * 4, flagsSize: 0, + indicesSize: 6 * sizeof(int), null, null, null, null, + EnumDrawMode.Triangles, staticDraw: false, ssbo: true); + Assert.True(mesh > 0, seam.GetError() ?? "SSBO mesh allocation failed"); + + var colours = new MeshData(4, 6) { Rgba = new byte[16], RgbaOffset = 0, VerticesCount = 4 }; + Array.Fill(colours.Rgba, (byte)255); + seam.UpdateMesh(mesh, colours); + + // The record as FaceData writes it: xyz then the packed origin at + // offset 12, the two half-edges, and the packed span at offset 28. + byte[] DrawAt(float u, float v) + { + var record = CreateFaceRecord(); + record[0] = -0.5f; record[1] = -0.5f; record[2] = 0f; // xyz + record[4] = 0.5f; record[5] = 0f; record[6] = 0f; // xyzA + record[12] = 0f; record[13] = 0.5f; record[14] = 0f; // xyzB + + int packedUv = (int)(u * 32768f + 0.5f) + ((int)(v * 32768f + 0.5f) << 16); + var record32 = new int[16]; + Buffer.BlockCopy(record, 0, record32, 0, 64); + record32[3] = packedUv; // uv + record32[7] = 0; // uvSize: no span, so every corner samples the origin + + fixed (int* bytes = record32) + { + seam.UpdateMeshStorageBuffer(mesh, (IntPtr)bytes, 0, 64); + } + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearDepth(1f); + seam.UseProgram(programId); + SetIdentityMatrices(seam, programId); + SetViewUniforms(seam, programId); + SetFloat(seam, programId, "subpixelPaddingX", 0f); + SetFloat(seam, programId, "subpixelPaddingY", 0f); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthFunc(0x203); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo: true); + seam.Present(); + return ReadTarget(seam, framebuffer); + } + + int centre = (Size / 2 * Size + Size / 2) * 4; + + // The centre of the bottom-left texel, then of the bottom-right one. + byte[] onRed = DrawAt(0.25f, 0.25f); + byte[] onGreen = DrawAt(0.75f, 0.25f); + + _output.WriteLine($"origin on red -> {onRed[centre]}, {onRed[centre + 1]}, {onRed[centre + 2]}"); + _output.WriteLine($"origin on green -> {onGreen[centre]}, {onGreen[centre + 1]}, {onGreen[centre + 2]}"); + + // Lighting scales the sampled colour, so the check is which channel + // came through, not how bright it is. Anything drawn at all rules out + // a face that never rasterised. + Assert.True(onRed[centre] > 0 || onGreen[centre + 1] > 0, + "the face did not rasterise, so nothing was sampled"); + Assert.True(onRed[centre] > onRed[centre + 1], + $"a UV origin on the red texel sampled elsewhere: " + + $"{onRed[centre]}, {onRed[centre + 1]}, {onRed[centre + 2]}"); + Assert.True(onGreen[centre + 1] > onGreen[centre], + $"a UV origin on the green texel sampled elsewhere: " + + $"{onGreen[centre]}, {onGreen[centre + 1]}, {onGreen[centre + 2]}"); + + AssertClean(seam); + } + } + + /// + /// Most real chunk faces have a negative UV span, and the shader recovers it + /// by sign extension rather than by reading a signed field. + /// + /// FaceData packs the span as two 15-bit fields and turns a negative delta + /// into its positive complement first - a du of -1/128 is stored as 32512 - + /// so UnpackUv subtracts 32768 back off whenever the field's top bit is set: + /// (uvs & 0x7FFF) - ((uvs & 0x4000) << 1) for u, and the same + /// for v out of the high half. Lose either of those and the span flips from + /// a fraction of a block texture to very nearly the whole atlas, which maps + /// a swathe of unrelated block textures across every face. + /// + /// The packed values here are the ones a real world produced, read back out + /// of the storage buffer at draw time. + /// + [SkippableFact] + public unsafe void AFaceRecordWithANegativeUvSpanStaysOnItsOwnBlockTexture() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + IOptimumGraphicsDevice seam = device!; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = + ShaderCorpus.Variants().First(v => v.UseSsbo == 1 && v.GreedyMesh == 0); + List stages = + ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + Assert.NotEmpty(stages); + + int programId = LinkFromCorpus(seam, stages, "chunkopaque"); + int nextUnit = BindEveryDeclaredSampler(device!, seam, programId); + + // An eight-by-eight atlas that is green everywhere except the one + // texel this face's UVs fall in, which is red. A correct unpack sees + // only that texel; a span that lost its sign sweeps most of the + // atlas and drags the green in. + const int atlasSize = 8; + var atlasPixels = new byte[atlasSize * atlasSize * 4]; + for (int i = 0; i < atlasSize * atlasSize; i++) + { + atlasPixels[i * 4] = 0; + atlasPixels[i * 4 + 1] = 255; + atlasPixels[i * 4 + 2] = 0; + atlasPixels[i * 4 + 3] = 255; + } + + // uv origin (2048, 15488) / 32768 = (0.0625, 0.4727): column 0, row 3. + int redTexel = (3 * atlasSize + 0) * 4; + atlasPixels[redTexel] = 255; + atlasPixels[redTexel + 1] = 0; + + int atlas; + fixed (byte* source = atlasPixels) + { + atlas = seam.CreateTexture2D(atlasSize, atlasSize, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)source, false); + } + seam.SetTextureParameter(atlas, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(atlas, OptimumGlConstants.TextureMagFilter, 9728); + + foreach (string samplerName in new[] { "terrainTex", "terrainTexLinear" }) + { + seam.SetSamplerUnit(programId, samplerName, nextUnit); + seam.BindTexture(nextUnit, atlas); + nextUnit++; + } + + int target = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int mesh = seam.CreateEmptyMesh( + xyzSize: 4 * 12, normalsSize: 0, uvSize: 0, rgbaSize: 4 * 4, flagsSize: 0, + indicesSize: 6 * sizeof(int), null, null, null, null, + EnumDrawMode.Triangles, staticDraw: false, ssbo: true); + Assert.True(mesh > 0, seam.GetError() ?? "SSBO mesh allocation failed"); + + var colours = new MeshData(4, 6) { Rgba = new byte[16], RgbaOffset = 0, VerticesCount = 4 }; + Array.Fill(colours.Rgba, (byte)255); + seam.UpdateMesh(mesh, colours); + + var record = CreateFaceRecord(); + record[0] = -0.5f; record[1] = -0.5f; record[2] = 0f; + record[4] = 0.5f; record[5] = 0f; record[6] = 0f; + record[12] = 0f; record[13] = 0.5f; record[14] = 0f; + + var record32 = new int[16]; + Buffer.BlockCopy(record, 0, record32, 0, 64); + record32[3] = unchecked((int)0x3C800800); // uv: 2048, 15488 + record32[7] = unchecked((int)0x7E007F00); // uvSize: -256, -512 + + fixed (int* bytes = record32) + { + seam.UpdateMeshStorageBuffer(mesh, (IntPtr)bytes, 0, 64); + } + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 1f, 1f); + seam.ClearDepth(1f); + seam.UseProgram(programId); + SetIdentityMatrices(seam, programId); + SetViewUniforms(seam, programId); + SetFloat(seam, programId, "subpixelPaddingX", 0f); + SetFloat(seam, programId, "subpixelPaddingY", 0f); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthFunc(0x203); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo: true); + seam.Present(); + + byte[] pixels = ReadTarget(seam, framebuffer); + + // Four points spread across the face: with a span of a fraction of a + // texel every one of them is the red texel, whereas a lost sign puts + // three of the corners somewhere else entirely. + foreach ((int x, int y) in new[] { (Size / 2, Size / 2), (24, 24), (40, 24), (24, 40) }) + { + int at = (y * Size + x) * 4; + _output.WriteLine($"({x},{y}) -> {pixels[at]}, {pixels[at + 1]}, {pixels[at + 2]}"); + Assert.True(pixels[at] > pixels[at + 1], + $"the face sampled off its own block texture at ({x},{y}): " + + $"{pixels[at]}, {pixels[at + 1]}, {pixels[at + 2]}"); + } + + AssertClean(seam); + } + } + + /// + /// Rebinding a sampler's texture between two draws of the same mesh in one + /// frame has to reach the second draw. + /// + /// This is the shape of the chunk pass: the renderer walks the atlas pages, + /// binds page i to terrainTex and terrainTexLinear, draws the pools that + /// belong to that page, and moves on - all within one frame and, for a mesh + /// that spans pages, on the same mesh. A descriptor set cached per program + /// and unit rather than per texture would serve the first page's atlas to + /// every later draw, which puts real block textures on blocks they do not + /// belong to. + /// + [SkippableFact] + public unsafe void RebindingAnAtlasBetweenDrawsChangesWhatTheSecondDrawSamples() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + IOptimumGraphicsDevice seam = device!; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = + ShaderCorpus.Variants().First(v => v.UseSsbo == 1 && v.GreedyMesh == 0); + List stages = + ShaderCorpus.BuildProgram("chunkopaque", files, includes, variant); + Assert.NotEmpty(stages); + + int programId = LinkFromCorpus(seam, stages, "chunkopaque"); + int nextUnit = BindEveryDeclaredSampler(device!, seam, programId); + + int SolidPage(byte r, byte g, byte b) + { + var texels = new byte[] { r, g, b, 255 }; + fixed (byte* source = texels) + { + int id = seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)source, false); + seam.SetTextureParameter(id, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(id, OptimumGlConstants.TextureMagFilter, 9728); + return id; + } + } + + int firstPage = SolidPage(255, 0, 0); + int secondPage = SolidPage(0, 255, 0); + + int terrainUnit = nextUnit; + int linearUnit = nextUnit + 1; + seam.SetSamplerUnit(programId, "terrainTex", terrainUnit); + seam.SetSamplerUnit(programId, "terrainTexLinear", linearUnit); + + int target = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int mesh = seam.CreateEmptyMesh( + xyzSize: 4 * 12, normalsSize: 0, uvSize: 0, rgbaSize: 4 * 4, flagsSize: 0, + indicesSize: 6 * sizeof(int), null, null, null, null, + EnumDrawMode.Triangles, staticDraw: false, ssbo: true); + Assert.True(mesh > 0, seam.GetError() ?? "SSBO mesh allocation failed"); + + var colours = new MeshData(4, 6) { Rgba = new byte[16], RgbaOffset = 0, VerticesCount = 4 }; + Array.Fill(colours.Rgba, (byte)255); + seam.UpdateMesh(mesh, colours); + + var record = CreateFaceRecord(); + record[0] = -0.5f; record[1] = -0.5f; record[2] = 0f; + record[4] = 0.5f; record[5] = 0f; record[6] = 0f; + record[12] = 0f; record[13] = 0.5f; record[14] = 0f; + fixed (float* bytes = record) + { + seam.UpdateMeshStorageBuffer(mesh, (IntPtr)bytes, 0, 64); + } + + // Both pages drawn in one frame, exactly as the chunk pass does it. + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 1f, 1f); + seam.ClearDepth(1f); + seam.UseProgram(programId); + SetIdentityMatrices(seam, programId); + SetViewUniforms(seam, programId); + SetFloat(seam, programId, "subpixelPaddingX", 0f); + SetFloat(seam, programId, "subpixelPaddingY", 0f); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + seam.BindTexture(terrainUnit, firstPage); + seam.BindTexture(linearUnit, firstPage); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo: true); + + seam.BindTexture(terrainUnit, secondPage); + seam.BindTexture(linearUnit, secondPage); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, ssbo: true); + + seam.Present(); + + byte[] pixels = ReadTarget(seam, framebuffer); + int centre = (Size / 2 * Size + Size / 2) * 4; + _output.WriteLine($"after rebinding to the green page -> " + + $"{pixels[centre]}, {pixels[centre + 1]}, {pixels[centre + 2]}"); + + Assert.True(pixels[centre + 1] > pixels[centre], + "the second draw kept sampling the first page's atlas: " + + $"{pixels[centre]}, {pixels[centre + 1]}, {pixels[centre + 2]}"); + + AssertClean(seam); + } + } + + private static unsafe byte[] ReadTarget(IOptimumGraphicsDevice seam, int framebuffer) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + private static bool IsClearColour(byte[] pixels, int offset) => pixels[offset] >= 250 && pixels[offset + 1] <= 5 && pixels[offset + 2] >= 250; @@ -326,7 +978,8 @@ private static int LinkFromCorpus( /// real client binds the atlases; here a stand-in is enough to make the draw /// legal. /// - private static unsafe void BindEveryDeclaredSampler( + /// The first texture unit the program did not claim. + private static unsafe int BindEveryDeclaredSampler( VulkanDevice device, IOptimumGraphicsDevice seam, int programId) { var white = new byte[] { 255, 255, 255, 255 }; @@ -344,6 +997,8 @@ private static unsafe void BindEveryDeclaredSampler( seam.BindTexture(unit, texture); unit++; } + + return unit; } /// @@ -363,6 +1018,16 @@ private static void SetViewUniforms(IOptimumGraphicsDevice seam, int programId) SetFloat(seam, programId, "alphaTest", 0.001f); SetFloat(seam, programId, "zNear", 0.1f); SetFloat(seam, programId, "zFar", 1024f); + SetFloat(seam, programId, "shadowRangeFar", 1024f); + SetFloat(seam, programId, "shadowRangeNear", 64f); + SetFloat(seam, programId, "shadowMapWidthInv", 1f); + SetFloat(seam, programId, "shadowMapHeightInv", 1f); + int ambient = seam.GetUniformLocation(programId, "rgbaAmbientIn"); + if (ambient >= 0) seam.SetUniform(programId, ambient, 1f, 1f, 1f); + // The underwater include samples at gl_FragCoord / frameSize even in + // an air scene. Leaving this at zero gives undefined texture reads. + int frameSize = seam.GetUniformLocation(programId, "frameSize"); + if (frameSize >= 0) seam.SetUniform(programId, frameSize, (float)Size, (float)Size); } private static void SetFloat(IOptimumGraphicsDevice seam, int programId, string name, float value) diff --git a/Optimum.Render.Vulkan.Tests/MeshDrawRangeTests.cs b/Optimum.Render.Vulkan.Tests/MeshDrawRangeTests.cs new file mode 100644 index 00000000..fa6689aa --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/MeshDrawRangeTests.cs @@ -0,0 +1,47 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +public class MeshDrawRangeTests +{ + [Fact] + public void PoolOffsetsArePointerSizedAndStayPairedWithTheirCounts() + { + // MeshDataPool allocates two ints per GL pointer and writes the low + // word at group * 2. Spare capacity is not another group to draw. + int[] starts = { 48, 0, 28536, 0, 21600, 0, 123456, 0 }; + int[] sizes = { 384, 5400, 144, 999 }; + var commands = new DrawIndexedIndirectCommand[3]; + + MeshManager.WriteIndirectCommands(commands, starts, sizes); + + Assert.Equal(new uint[] { 12, 7134, 5400 }, Array.ConvertAll(commands, c => c.FirstIndex)); + Assert.Equal(new uint[] { 384, 5400, 144 }, Array.ConvertAll(commands, c => c.IndexCount)); + Assert.All(commands, command => + { + Assert.Equal(1u, command.InstanceCount); + Assert.Equal(0, command.VertexOffset); + Assert.Equal(0u, command.FirstInstance); + }); + } + + [Fact] + public void ZeroGroupsNeedNoOffsets() + { + MeshManager.WriteIndirectCommands(Span.Empty, + ReadOnlySpan.Empty, ReadOnlySpan.Empty); + } + + [Fact] + public void LargeByteOffsetsRetainBothWordsBeforeConversionToAnIndex() + { + var commands = new DrawIndexedIndirectCommand[2]; + MeshManager.WriteIndirectCommands(commands, + new[] { unchecked((int)0x80000000), 0, 24, 1 }, new[] { 6, 12 }); + Assert.Equal(0x20000000u, commands[0].FirstIndex); + Assert.Equal(0x40000006u, commands[1].FirstIndex); + } +} diff --git a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs index a0e82e81..900b9f2f 100644 --- a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs @@ -41,6 +41,63 @@ private static bool TryCreateContext( return created; } + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void PooledTopsoilUsesUnsignedNormalizedShortUvs(bool ssbo) + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var meshes = new MeshManager(context!, new GlStateTracker()); + var uv2 = new CustomMeshDataPartShort(8) + { + InterleaveSizes = new[] { 2 }, + InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, + Conversion = DataConversion.NormalizedFloat, + }; + int mesh = meshes.CreateEmpty(48, 0, 32, 16, 16, 24, + null, uv2, null, null, EnumDrawMode.Triangles, staticDraw: false, ssbo: ssbo); + + VertexLayoutDescription layout = meshes.Get(mesh)!.Layout; + Assert.Equal(Format.R16G16Unorm, layout.Attributes[^1].Format); + Assert.Equal(4u, layout.Bindings[^1].Stride); + } + } + + [SkippableTheory] + [InlineData(DataConversion.NormalizedFloat, false, Format.R16G16Unorm)] + [InlineData(DataConversion.Float, false, Format.R16G16Uscaled)] + [InlineData(DataConversion.Integer, false, Format.R16G16Sint)] + [InlineData(DataConversion.NormalizedFloat, true, Format.R16G16SNorm)] + [InlineData(DataConversion.Float, true, Format.R16G16Sscaled)] + [InlineData(DataConversion.Integer, true, Format.R16G16Sint)] + public void ShortSignednessMatchesTheTwoGlAllocationPaths(DataConversion conversion, bool uploaded, Format expected) + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var meshes = new MeshManager(context!, new GlStateTracker()); + var shorts = new CustomMeshDataPartShort(8) + { + InterleaveSizes = new[] { 2 }, + InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, + Conversion = conversion, + Instanced = true, + }; + int mesh = meshes.CreateEmpty(48, 0, 0, 0, 0, 24, + null, shorts, null, null, EnumDrawMode.Triangles, staticDraw: false, + ssbo: false, signedCustomShorts: uploaded); + VertexLayoutDescription layout = meshes.Get(mesh)!.Layout; + Assert.Equal(expected, layout.Attributes[^1].Format); + Assert.True(layout.Bindings[^1].PerInstance); + } + } + [SkippableFact] public void AbsentPartsDoNotConsumeAttributeLocations() { @@ -123,9 +180,15 @@ public void TheChunkVertexLayoutMatchesTheShadersDeclaredLocations() /// With SSBO vertex fetch the chunk shaders read positions from a storage /// buffer keyed on gl_VertexIndex, so the position buffer must leave the /// vertex input entirely rather than being bound twice. + /// + /// The same goes for normals, UVs and flags: the face record carries all + /// three, and GL's SSBO allocator creates neither a buffer nor an attribute + /// pointer for them. Binding one anyway pushes rgba off location 0, and the + /// chunk shaders declare rgbaLightIn there - so the block light would be + /// read from the UV stream, tinting terrain by its atlas coordinates. /// [SkippableFact] - public void TheSsboPathTakesPositionsOutOfTheVertexInput() + public void TheSsboPathBindsOnlyTheColoursTheChunkShadersDeclare() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); @@ -135,21 +198,115 @@ public void TheSsboPathTakesPositionsOutOfTheVertexInput() var state = new GlStateTracker(); using var meshes = new MeshManager(context!, state); + // The pool passes its configured sizes whichever path it is on, so + // normals, UVs and flags all arrive non-zero here. int mesh = meshes.CreateEmpty( - xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 4 * 2 * sizeof(float), - rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + xyzSize: 4 * 3 * sizeof(float), normalsSize: 4 * sizeof(int), + uvSize: 4 * 2 * sizeof(float), rgbaSize: 4 * 4, flagsSize: 4 * sizeof(int), + indicesSize: 6 * sizeof(int), null, null, null, null, EnumDrawMode.Triangles, staticDraw: true, ssbo: true); VulkanMesh created = meshes.Get(mesh)!; VertexLayoutDescription layout = meshes.LayoutOf(created.LayoutId); - // The buffer still exists, and still carries the positions. + // The position buffer still exists, and still carries the records. Assert.NotNull(created.Buffers[MeshManager.BufferXyz]); Assert.DoesNotContain(MeshManager.BufferXyz, created.BindingOrder); - // UVs now take location 0, since positions are no longer an input. - Assert.Equal(2, layout.Attributes.Length); - Assert.Equal(Format.R32G32Sfloat, layout.Attributes[0].Format); + // The other three do not exist at all, as in GL. + Assert.Null(created.Buffers[MeshManager.BufferNormals]); + Assert.Null(created.Buffers[MeshManager.BufferUv]); + Assert.Null(created.Buffers[MeshManager.BufferFlags]); + + // Leaving colours alone at location 0. + Assert.Single(layout.Attributes); + Assert.Equal(0u, layout.Attributes[0].Location); + Assert.Equal(Format.R8G8B8A8Unorm, layout.Attributes[0].Format); + } + } + + /// + /// The chunk meshes carry two custom ints per vertex: the colormap data and + /// one more. On the SSBO path the record already holds the colormap data, so + /// GL drops that member, halves the stride and halves the buffer - and the + /// remaining member reads from the offset its predecessor used. + /// + [SkippableFact] + public void TheSsboPathDropsTheCustomIntTheFaceRecordAlreadyCarries() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + CustomMeshDataPartInt TwoPerVertex() => new(8) + { + InterleaveSizes = new[] { 1, 1 }, + InterleaveOffsets = new[] { 0, 4 }, + InterleaveStride = 8, + Conversion = DataConversion.Integer, + }; + + int ssboMesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, + rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, TwoPerVertex(), EnumDrawMode.Triangles, staticDraw: true, ssbo: true); + + VertexLayoutDescription ssbo = meshes.LayoutOf(meshes.LayoutIdOf(ssboMesh)); + + // Colours at 0, then the one surviving int at 1 - reading offset 0 + // on a four-byte stride, which is where the dropped member sat. + Assert.Equal(2, ssbo.Attributes.Length); + Assert.Equal(Format.R32Sint, ssbo.Attributes[1].Format); + Assert.Equal(0u, ssbo.Attributes[1].Offset); + Assert.Equal(4u, ssbo.Bindings[1].Stride); + + // Off the SSBO path the same part keeps both members and its stride. + int plainMesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, + rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, TwoPerVertex(), EnumDrawMode.Triangles, staticDraw: true, ssbo: false); + + VertexLayoutDescription plain = meshes.LayoutOf(meshes.LayoutIdOf(plainMesh)); + Assert.Equal(4, plain.Attributes.Length); + Assert.Equal(8u, plain.Bindings[^1].Stride); + } + } + + /// + /// A part that only ever had the colormap int has nothing left once it is + /// dropped, so GL binds it nowhere at all on the SSBO path. + /// + [SkippableFact] + public void TheSsboPathDropsASingleCustomIntPartEntirely() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new GlStateTracker(); + using var meshes = new MeshManager(context!, state); + + var customInts = new CustomMeshDataPartInt(4) + { + InterleaveSizes = new[] { 1 }, + InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, + Conversion = DataConversion.Integer, + }; + + int mesh = meshes.CreateEmpty( + xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, + rgbaSize: 4 * 4, flagsSize: 0, indicesSize: 6 * sizeof(int), + null, null, null, customInts, EnumDrawMode.Triangles, staticDraw: true, ssbo: true); + + VulkanMesh created = meshes.Get(mesh)!; + Assert.Null(created.Buffers[MeshManager.BufferCustomInt]); + Assert.Single(meshes.LayoutOf(created.LayoutId).Attributes); } } diff --git a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs index f2216ea7..a7bb9fbb 100644 --- a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs @@ -18,6 +18,18 @@ namespace Optimum.Render.Vulkan.Tests; /// public class TextureManagerTests { + [Theory] + [InlineData(short.MinValue, 0)] + [InlineData(-1, 0)] + [InlineData(0, 0)] + [InlineData(1, 2)] + [InlineData(16384, 32769)] + [InlineData(short.MaxValue, ushort.MaxValue)] + public void SignedShortTextureInputIsNormalizedBeforeUnsignedStorage(short source, int expected) + { + Assert.Equal((ushort)expected, TextureManager.ShortToUnorm16(source)); + } + private readonly ITestOutputHelper _output; public TextureManagerTests(ITestOutputHelper output) => _output = output; @@ -93,6 +105,51 @@ public void TextureParametersUpdateSamplerStateWithoutCreatingObjects() } } + /// + /// A GL min filter decides whether the mip chain is sampled at all, and the + /// sampler's LOD clamp is the only place Vulkan can say so. + /// + /// GL_LINEAR and GL_NEAREST read level 0 however many levels the image owns; + /// only the four MIPMAP filters descend the chain, and GL_TEXTURE_MAX_LEVEL + /// then caps how far. Vulkan has no non-mipmapping filter - a sampler always + /// picks a level out of [MinLod, MaxLod] - so leaving MaxLod unclamped lets + /// a texture that merely owns a chain be minified through it. On the block + /// atlas that puts unrelated block textures onto every surface that turns + /// away from the camera, while whatever is drawn flat stays correct. + /// + [SkippableFact] + public void OnlyAMipmappingFilterLetsTheSamplerLeaveLevelZero() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + + int id = textures.Create(16, 16, Format.R8G8B8A8Unorm, generateMipmaps: true); + Assert.True(textures.Get(id)!.MipLevels > 1, "the texture should own a chain to sample"); + + // What the atlas upload sets: linear, and so level 0 only. + textures.SetParameter(id, GlEnums.TextureMinFilter, 0x2601); // GL_LINEAR + SamplerState linear = textures.Get(id)!.State; + Assert.False(linear.Mipmapped); + Assert.True(linear.LodCeiling < 1f, + $"a non-mipmapping filter must confine sampling to level 0, got {linear.LodCeiling}"); + + // What BuildMipMaps sets once a chain exists, capped to the setting. + textures.SetParameter(id, GlEnums.TextureMinFilter, 0x2702); // NEAREST_MIPMAP_LINEAR + textures.SetParameter(id, GlEnums.TextureMaxLevel, 3); + SamplerState mipmapped = textures.Get(id)!.State; + Assert.True(mipmapped.Mipmapped); + Assert.Equal(3, mipmapped.MaxLevel); + Assert.Equal(4f, mipmapped.LodCeiling); + + // Uncapped stays uncapped. + textures.SetParameter(id, GlEnums.TextureMaxLevel, -1); + Assert.Equal(Vk.LodClampNone, textures.Get(id)!.State.LodCeiling); + } + } + /// /// Optimum's FSR path sets a negative LOD bias on the terrain samplers, so a /// bias change has to produce a genuinely different sampler rather than diff --git a/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs index 8eb0b7e7..1d1ab38f 100644 --- a/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs +++ b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs @@ -196,10 +196,10 @@ public void AnExplicitRendererChoiceIgnoresTheAutomaticAllowList() Assert.True(VulkanDevice.IsSupported(false, out _, out string driver)); Assert.False(string.IsNullOrWhiteSpace(driver)); - // Automatic: allowed too, because both development drivers are on the - // list. If this ever fails the machine grew a driver worth adding. - Assert.True(VulkanDevice.IsSupported(true, out string automaticReason, out _), - "automatic selection refused driver '" + driver + "': " + automaticReason); + // A supported software driver can be explicitly selected without being + // on the automatic allow-list (SwiftShader is one such driver). + Assert.Equal(VulkanDevice.IsAllowedForAutomaticSelection(driver), + VulkanDevice.IsSupported(true, out _, out _)); } /// diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index a2c474a1..ef63d8ef 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -38,6 +38,204 @@ private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? return false; } + [SkippableTheory] + [InlineData(EnumDrawMode.Lines)] + [InlineData(EnumDrawMode.LineStrip)] + public unsafe void IndexedLineMeshesDrawOnlyEdgesAndRestoreTriangleTopology(EnumDrawMode mode) + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 32; + int program = LinkProgram(seam, """ + #version 330 core + layout(location = 0) in vec3 position; + void main() { gl_Position = vec4(position, 1); } + """, """ + #version 330 core + out vec4 color; + void main() { color = vec4(1); } + """); + // Deliberately shuffled vertices: drawing without these indices + // introduces diagonals through the otherwise empty box interior. + var data = new MeshData(4, 8) { + xyz = new float[] { -.75f, -.75f, 0, .75f, .75f, 0, + .75f, -.75f, 0, -.75f, .75f, 0 }, + VerticesCount = 4, + Indices = mode == EnumDrawMode.Lines + ? new[] { 0, 2, 2, 1, 1, 3, 3, 0 } + : new[] { 0, 2, 1, 3, 0 }, + IndicesCount = mode == EnumDrawMode.Lines ? 8 : 5, + mode = mode + }; + int mesh = seam.CreateMesh(data, true); + int texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetLineWidth(1); + seam.ClearColor(0, 0, 0, 0, 1); + seam.DrawMeshInstanced(mesh, 1); + seam.Present(); + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + int lit = 0; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + byte red = pixels[(y * size + x) * 4]; + if (red != 0) lit++; + if (x >= 8 && x < 24 && y >= 8 && y < 24) Assert.Equal(0, red); + } + Assert.InRange(lit, 80, 112); + + // A following triangle mesh must change topology class again. + data.mode = EnumDrawMode.Triangles; + data.Indices = new[] { 0, 2, 1, 0, 1, 3 }; + data.IndicesCount = 6; + int triangles = seam.CreateMesh(data, true); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.DrawMesh(triangles); + seam.Present(); + fixed (byte* destination = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + Assert.Equal(255, pixels[(16 * size + 16) * 4]); + AssertClean(seam); + } + } + + [SkippableFact] + public unsafe void CloudMapShortUploadsKeepFullDensityAndBrightness() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int program = LinkProgram(seam, """ + #version 330 core + void main() { + gl_Position = vec4(-1 + ((gl_VertexID & 1) << 2), + -1 + ((gl_VertexID & 2) << 1), 0, 1); + } + """, """ + #version 330 core + uniform sampler2D cloudData; + out vec4 color; + void main() { + // Check all 16 bits before the UNORM8 render target can round + // half density to either 127 or 128 (both legal in Vulkan). + // A raw signed-short upload, wrong scale, or missing negative + // clamp must still fail its channel, independently of the GPU. + uvec4 stored = uvec4(round(texelFetch(cloudData, ivec2(0), 0) * 65535.0)); + color = vec4(equal(stored, uvec4(65535, 32769, 0, 0))); + } + """); + int texture = seam.CreateTexture2DRaw(1, 1, 0x805B, IntPtr.Zero, 0); // GL_RGBA16 + short[] source = { short.MaxValue, 16384, 0, short.MinValue }; + seam.UploadTexture2DNormalizedShorts(texture, 0, 0, 0, 1, 1, source); + Assert.Equal(new short[] { short.MaxValue, 16384, 0, short.MinValue }, source); + + int target = seam.CreateTexture2D(1, 1, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(1, 1); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 1); + seam.SetSamplerUnit(program, "cloudData", 0); + seam.BindTexture(0, texture); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetViewport(0, 0, 1, 1); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + seam.Present(); + var output = new byte[4]; + fixed (byte* destination = output) + seam.ReadDefaultFramebuffer(0, 0, 1, 1, (IntPtr)destination); + Assert.Equal(new byte[] { 255, 255, 255, 255 }, output); + AssertClean(seam); + } + } + + [SkippableFact] + public unsafe void AtlasCopiesWithinTheSameTextureReadTheContentsBeforeEachDraw() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int program = LinkProgram(seam, """ + #version 330 core + void main() { + gl_Position = vec4(-1 + ((gl_VertexID & 1) << 2), + -1 + ((gl_VertexID & 2) << 1), 0, 1); + } + """, """ + #version 330 core + uniform sampler2D atlas; + out vec4 color; + void main() { + color = texelFetch(atlas, ivec2(gl_FragCoord.x < 1.0 ? 1 : 0, 0), 0); + } + """, "atlas-self-copy"); + byte[] original = { 255, 0, 0, 255, 0, 255, 0, 255 }; + byte[] swapped = { 0, 255, 0, 255, 255, 0, 0, 255 }; + int texture; + fixed (byte* pixels = original) + texture = seam.CreateTexture2D(2, 1, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + int framebuffer = seam.CreateFramebuffer(2, 1); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetSamplerUnit(program, "atlas", 0); + seam.BindTexture(0, texture); + + // The first draw starts with a shader-readable upload; later draws + // start with a colour attachment. Refreshing the snapshot and leaving + // the client's texture binding intact must both hold across frames. + for (int frame = 0; frame < 4; frame++) + { + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + if (frame == 0) + { + var before = new byte[8]; + fixed (byte* destination = before) + seam.ReadDefaultFramebuffer(0, 0, 2, 1, (IntPtr)destination); + Assert.Equal(original, before); + } + seam.UseProgram(program); + seam.SetViewport(0, 0, 2, 1); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + seam.Present(); + var output = new byte[8]; + fixed (byte* destination = output) + seam.ReadDefaultFramebuffer(0, 0, 2, 1, (IntPtr)destination); + _output.WriteLine("frame " + frame + ": " + string.Join(", ", output)); + AssertClean(seam); + Assert.Equal(frame % 2 == 0 ? swapped : original, output); + } + seam.DeleteFramebuffer(framebuffer); + seam.DeleteTexture(texture); + AssertClean(seam); + } + } + /// /// A minimal shader stand-in. The client passes its own IShader and /// IShaderProgram implementations across the seam, so the device must work @@ -746,6 +944,76 @@ private static unsafe int SolidTexture(IOptimumGraphicsDevice seam, int size, by } } + /// + /// The pooled-chunk case. One mesh holds many chunks; each is written with + /// the byte offset of its own slice in every part, exactly as GL's + /// glBufferSubData destination offset works. + /// + /// Writing them all at zero is not a subtle corruption - every chunk in the + /// world lands on top of the first, which renders as no terrain at all. + /// + [SkippableFact] + public unsafe void AMeshUpdateWritesEachPartAtItsOwnDestinationOffset() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int verticesPerSlice = 3; + const int slices = 4; + + int meshId = seam.CreateEmptyMesh( + xyzSize: slices * verticesPerSlice * 3 * sizeof(float), + normalsSize: 0, + uvSize: slices * verticesPerSlice * 2 * sizeof(float), + rgbaSize: slices * verticesPerSlice * 4, + flagsSize: 0, + indicesSize: slices * verticesPerSlice * sizeof(int), + customFloats: null, customShorts: null, customBytes: null, customInts: null, + drawMode: EnumDrawMode.Triangles, staticDraw: false, ssbo: false); + Assert.True(meshId > 0); + + // Each slice writes a value identifying itself, at its own offset. + for (int slice = 0; slice < slices; slice++) + { + var xyz = new float[verticesPerSlice * 3]; + for (int i = 0; i < xyz.Length; i++) xyz[i] = slice * 100 + i; + + // XyzCount is derived from VerticesCount, so only the offset and + // the vertex count need setting. + var data = new MeshData(verticesPerSlice, verticesPerSlice) + { + xyz = xyz, + XyzOffset = slice * verticesPerSlice * 3 * sizeof(float), + VerticesCount = verticesPerSlice, + }; + + seam.UpdateMesh(meshId, data); + } + + // Read the whole buffer back and confirm each slice kept its place. + IntPtr mapped = seam.GetMappedPointer(meshId, EnumMeshBufferPart.Xyz); + Assert.NotEqual(IntPtr.Zero, mapped); + + var actual = new float[slices * verticesPerSlice * 3]; + fixed (float* destination = actual) + { + System.Buffer.MemoryCopy((void*)mapped, destination, + actual.Length * sizeof(float), actual.Length * sizeof(float)); + } + + for (int slice = 0; slice < slices; slice++) + { + int at = slice * verticesPerSlice * 3; + Assert.Equal(slice * 100f, actual[at]); + Assert.Equal(slice * 100f + 1, actual[at + 1]); + } + + seam.DeleteMesh(meshId); + AssertClean(seam); + } + } + private static void AssertClean(IOptimumGraphicsDevice device) { string? diagnostics = device.GetError(); diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs index c8c55c9e..775383a5 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorCache.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -14,7 +14,8 @@ namespace Optimum.Render.Vulkan.Core; /// handle. Zero means the resource is permanent and needs no tracking. /// internal readonly record struct SamplerBindingValue( - uint Binding, ImageView View, Sampler Sampler, ulong Resource = 0); + uint Binding, ImageView View, Sampler Sampler, ulong Resource = 0, + ImageLayout Layout = ImageLayout.ShaderReadOnlyOptimal); /// One buffer binding. as for samplers. internal readonly record struct BufferBindingValue( @@ -53,6 +54,7 @@ public DescriptorSetContents( hash.Add(sampler.View.Handle); hash.Add(sampler.Sampler.Handle); hash.Add(sampler.Resource); + hash.Add((int)sampler.Layout); } foreach (BufferBindingValue buffer in buffers) { @@ -378,7 +380,10 @@ private void Write(DescriptorSet set, DescriptorSetContents contents) { ImageView = sampler.View, Sampler = sampler.Sampler, - ImageLayout = ImageLayout.ShaderReadOnlyOptimal, + // Normally shader-read-only; a depth attachment sampled by + // the pass that has it bound is read through the read-only + // depth layout instead. + ImageLayout = sampler.Layout, }; writes[index++] = new WriteDescriptorSet { diff --git a/Optimum.Render.Vulkan/Core/GlEnums.cs b/Optimum.Render.Vulkan/Core/GlEnums.cs index cefdfb17..e124bb5b 100644 --- a/Optimum.Render.Vulkan/Core/GlEnums.cs +++ b/Optimum.Render.Vulkan/Core/GlEnums.cs @@ -119,6 +119,7 @@ internal static class GlEnums 0x822D => Format.R16Sfloat, // GL_R16F 0x8C3A => Format.B10G11R11UfloatPack32,// GL_R11F_G11F_B10F 0x8814 => Format.R32G32B32A32Sfloat, // GL_RGBA32F + 0x805B => Format.R16G16B16A16Unorm, // GL_RGBA16, the cloud map's tile data 0x8051 => Format.R8G8B8A8Unorm, // GL_RGB8, promoted: RGB is not a 0x1907 => Format.R8G8B8A8Unorm, // GL_RGB guaranteed attachment format 0x8DAB => Format.D32Sfloat, // GL_DEPTH_COMPONENT32F @@ -151,6 +152,14 @@ internal static class GlEnums _ => (Filter.Nearest, SamplerMipmapMode.Nearest), }; + /// + /// Whether a GL min filter samples the mip chain at all. Only the four + /// MIPMAP forms do; GL_NEAREST and GL_LINEAR read level 0 however many + /// levels the texture owns. + /// + public static bool MinFilterUsesMipmaps(int glFilter) => + glFilter is 0x2700 or 0x2701 or 0x2702 or 0x2703; + public static SamplerAddressMode AddressModeFrom(int glWrap) => glWrap switch { 0x2901 => SamplerAddressMode.Repeat, // GL_REPEAT @@ -167,6 +176,7 @@ internal static class GlEnums public const int TextureWrapT = 0x2803; public const int TextureCompareMode = 0x884C; public const int TextureLodBias = 0x8501; + public const int TextureMaxLevel = 0x813D; public const int TextureBorderColor = 0x1004; public const int TextureCompareModeNone = 0; public const int TextureCompareRefToTexture = 0x884E; diff --git a/Optimum.Render.Vulkan/Core/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs index 8238d430..3a66d61b 100644 --- a/Optimum.Render.Vulkan/Core/MeshManager.cs +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -132,7 +132,7 @@ public int CreateEmpty( int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat? customFloats, CustomMeshDataPartShort? customShorts, CustomMeshDataPartByte? customBytes, CustomMeshDataPartInt? customInts, - EnumDrawMode drawMode, bool staticDraw, bool ssbo) + EnumDrawMode drawMode, bool staticDraw, bool ssbo, bool signedCustomShorts = false) { var mesh = new VulkanMesh { @@ -144,27 +144,48 @@ public int CreateEmpty( var builder = new VertexLayoutBuilder(); // The order here is the order the GL allocator assigns attribute slots. - AddDedicated(mesh, builder, BufferXyz, xyzSize, 3, GlFloat, normalized: false, integer: false, ssbo); - AddDedicated(mesh, builder, BufferNormals, normalsSize, 4, GlInt2101010Rev, normalized: true, integer: false, ssbo); - AddDedicated(mesh, builder, BufferUv, uvSize, 2, GlFloat, normalized: false, integer: false, ssbo); + // With SSBO vertex fetch the xyz slot holds packed face records rather + // than positions: one 64-byte record per four vertices, so 16 bytes per + // vertex where a position is 12. GL sizes that buffer as xyzSize / 12 * 16 + // and so must this, or the last quarter of every pool is out of range - + // which robust buffer access reads back as zeros, collapsing those faces + // onto the origin and stretching their neighbours across the screen. + int xyzSlotSize = ssbo ? xyzSize / 12 * 16 : xyzSize; + AddDedicated(mesh, builder, BufferXyz, xyzSlotSize, 3, GlFloat, normalized: false, integer: false, ssbo); + + // Normals, uv and flags have no vertex binding on the SSBO path: their + // contents ride in the packed face records instead, and GL's SSBO + // allocator creates neither a buffer nor an attribute pointer for them. + // Adding one here would push rgba off location 0, so the shader's + // rgbaLightIn would read the uv stream - block light taken from atlas + // coordinates, which tints the terrain by texture position. + AddDedicated(mesh, builder, BufferNormals, ssbo ? 0 : normalsSize, 4, GlInt2101010Rev, normalized: true, integer: false, ssbo); + AddDedicated(mesh, builder, BufferUv, ssbo ? 0 : uvSize, 2, GlFloat, normalized: false, integer: false, ssbo); AddDedicated(mesh, builder, BufferRgba, rgbaSize, 4, GlUnsignedByte, normalized: true, integer: false, ssbo); - AddDedicated(mesh, builder, BufferFlags, flagsSize, 1, GlInt, normalized: false, integer: true, ssbo); + AddDedicated(mesh, builder, BufferFlags, ssbo ? 0 : flagsSize, 1, GlInt, normalized: false, integer: true, ssbo); AddCustom(mesh, builder, BufferCustomFloat, customFloats?.AllocationSize * 4 ?? 0, customFloats?.InterleaveSizes, customFloats?.InterleaveOffsets, customFloats?.InterleaveStride ?? 0, GlFloat, false, false, customFloats?.Instanced ?? false); + // AllocateEmptyMesh/AddCustoms uses GL_UNSIGNED_SHORT for float + // inputs, including the packed secondary UVs of topsoil. UploadMesh + // uses GL_SHORT instead (legacy clouds rely on signed offsets). + // Integer inputs use GL_SHORT on both paths. + int shortType = signedCustomShorts || customShorts?.Conversion == DataConversion.Integer + ? GlShort : GlUnsignedShort; AddCustom(mesh, builder, BufferCustomShort, customShorts?.AllocationSize * 2 ?? 0, customShorts?.InterleaveSizes, customShorts?.InterleaveOffsets, - customShorts?.InterleaveStride ?? 0, GlShort, + customShorts?.InterleaveStride ?? 0, shortType, customShorts?.Conversion == DataConversion.NormalizedFloat, customShorts?.Conversion == DataConversion.Integer, customShorts?.Instanced ?? false); - AddCustom(mesh, builder, BufferCustomInt, customInts?.AllocationSize * 4 ?? 0, - customInts?.InterleaveSizes, customInts?.InterleaveOffsets, - customInts?.InterleaveStride ?? 0, GlInt, + (int intBytes, int[]? intSizes, int[]? intOffsets, int intStride) = + PruneCustomInts(customInts, ssbo); + + AddCustom(mesh, builder, BufferCustomInt, intBytes, intSizes, intOffsets, intStride, GlInt, customInts?.Conversion == DataConversion.NormalizedFloat, customInts?.Conversion == DataConversion.Integer, customInts?.Instanced ?? false); @@ -180,6 +201,7 @@ public int CreateEmpty( { mesh.Indices = CreateBuffer(indicesSize, BufferUsageFlags.IndexBufferBit, mesh.Persistent); mesh.IndexCount = indicesSize / sizeof(int); + if (ssbo) FillQuadIndices(mesh.Indices); } mesh.Layout = builder.Build(); @@ -188,6 +210,32 @@ public int CreateEmpty( return Register(mesh); } + /// + /// The custom-int part as the SSBO path sees it. GL prunes it there: the + /// first interleaved member is the colormap data, which the face record now + /// carries, so it is dropped, the rest tighten onto half the stride and the + /// buffer halves with them. A part that only ever had that one member is + /// dropped entirely. Off the SSBO path the part passes through unchanged. + /// + private static (int Bytes, int[]? Sizes, int[]? Offsets, int Stride) PruneCustomInts( + CustomMeshDataPartInt? customInts, bool ssbo) + { + if (customInts == null) return (0, null, null, 0); + + int bytes = customInts.AllocationSize * 4; + int[]? sizes = customInts.InterleaveSizes; + int[]? offsets = customInts.InterleaveOffsets; + int stride = customInts.InterleaveStride; + + if (!ssbo) return (bytes, sizes, offsets, stride); + + if (stride <= 4 || sizes == null || sizes.Length < 2) return (0, null, null, 0); + + // Member k reads what member k - 1 used to, because dropping the first + // one shifts every remaining offset down a slot. + return (bytes / 2, sizes[1..], offsets?[..^1], stride / 2); + } + private void AddDedicated( VulkanMesh mesh, VertexLayoutBuilder builder, int slot, int byteSize, int components, int glType, bool normalized, bool integer, bool ssbo) @@ -281,6 +329,47 @@ private int Register(VulkanMesh mesh) } /// The persistently mapped pointer for a part, or zero. + /// + /// One of a mesh's buffers, for binding it as something other than a vertex + /// source - the SSBO chunk path reads the xyz slot as a storage buffer. + /// + /// Whether the mesh fetches its vertices through a storage buffer. + public bool IsSsbo(int meshId) => Get(meshId)?.Ssbo ?? false; + + /// + /// Fills an SSBO mesh's index buffer with the fixed quad pattern. + /// + /// GL keeps one shared static index buffer for every SSBO mesh, written once + /// with this pattern: each four consecutive vertices are a quad, drawn as the + /// triangles (0,1,2) and (0,2,3). Its chunk update path never uploads indices + /// on that route, so this fill is the only source of them here, as it is + /// there. + /// + private static void FillQuadIndices(VulkanBuffer indices) + { + if (indices.Mapped == IntPtr.Zero) return; + + int count = (int)(indices.Size / sizeof(int)); + int* destination = (int*)indices.Mapped; + for (int i = 0; i + 5 < count; i += 6) + { + int quad = i / 6 * 4; + destination[i] = quad; + destination[i + 1] = quad + 1; + destination[i + 2] = quad + 2; + destination[i + 3] = quad; + destination[i + 4] = quad + 2; + destination[i + 5] = quad + 3; + } + } + + public VulkanBuffer? BufferOf(int meshId, int slot) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null) return null; + return slot < 0 ? mesh.Indices : mesh.Buffers[slot]; + } + public IntPtr MappedPointer(int meshId, int slot) { VulkanMesh? mesh = Get(meshId); @@ -291,17 +380,44 @@ public IntPtr MappedPointer(int meshId, int slot) } /// Writes bytes into a mesh buffer through its mapping. + /// + /// Copies data into one of a mesh's buffers at a byte offset. + /// + /// A write that cannot land - no such buffer, not mapped, or past the end - + /// is counted and traced rather than dropped in silence. GL would raise + /// GL_INVALID_VALUE for the same glBufferSubData; a quiet return here turned + /// a sizing mistake into "the terrain is simply not there", with nothing in + /// any log to say why. + /// public void Write(int meshId, int slot, int byteOffset, IntPtr source, int byteCount) { - VulkanMesh? mesh = Get(meshId); - if (mesh == null || source == IntPtr.Zero || byteCount <= 0) return; + if (source == IntPtr.Zero || byteCount <= 0) return; - VulkanBuffer? buffer = slot < 0 ? mesh.Indices : mesh.Buffers[slot]; - if (buffer?.Mapped is null or 0) return; - if ((ulong)(byteOffset + byteCount) > buffer.Size) return; + VulkanMesh? mesh = Get(meshId); + VulkanBuffer? buffer = mesh == null ? null : slot < 0 ? mesh.Indices : mesh.Buffers[slot]; + + string? problem = + mesh == null ? "no such mesh" : + buffer == null ? "mesh has no buffer in that slot" : + buffer.Mapped == IntPtr.Zero ? "buffer is not host mapped" : + byteOffset < 0 ? "negative offset" : + (ulong)byteOffset + (ulong)byteCount > buffer.Size + ? "write ends past the buffer (" + buffer.Size + " bytes)" + : null; + + if (problem != null) + { + VulkanStats.NoteDroppedMeshWrite(); + if (RenderTrace.Enabled) + { + RenderTrace.Write("mesh write dropped: mesh " + meshId + " slot " + slot + + " offset " + byteOffset + " bytes " + byteCount + ": " + problem); + } + return; + } System.Buffer.MemoryCopy( - (void*)source, (void*)(buffer.Mapped + byteOffset), byteCount, byteCount); + (void*)source, (void*)(buffer!.Mapped + byteOffset), byteCount, byteCount); } public void Delete(int meshId, FrameRing? ring = null) @@ -361,34 +477,44 @@ public void Draw(CommandBuffer commandBuffer, int meshId, int instanceCount = 1) /// public void DrawMulti( CommandBuffer commandBuffer, int meshId, - int[] indicesStarts, int[] indicesSizes, int groupCount, VulkanBuffer indirectScratch) + int[] indicesStarts, int[] indicesSizes, int groupCount, VulkanBuffer indirectScratch, + ulong indirectOffset) { VulkanMesh? mesh = Get(meshId); if (mesh == null || groupCount <= 0) return; Bind(commandBuffer, mesh); - var commands = (DrawIndexedIndirectCommand*)indirectScratch.Mapped; - if (commands == null) return; + if (indirectScratch.Mapped == IntPtr.Zero || indirectOffset >= indirectScratch.Size) return; + var commands = (DrawIndexedIndirectCommand*)(indirectScratch.Mapped + (nint)indirectOffset); - int capacity = (int)(indirectScratch.Size / (ulong)sizeof(DrawIndexedIndirectCommand)); + int capacity = (int)((indirectScratch.Size - indirectOffset) / (ulong)sizeof(DrawIndexedIndirectCommand)); int count = Math.Min(groupCount, capacity); - for (int i = 0; i < count; i++) + WriteIndirectCommands(new Span(commands, count), indicesStarts, indicesSizes); + + _context.Api.CmdDrawIndexedIndirect(commandBuffer, indirectScratch.Handle, indirectOffset, (uint)count, + (uint)sizeof(DrawIndexedIndirectCommand)); + } + + internal static void WriteIndirectCommands( + Span commands, ReadOnlySpan indicesStarts, ReadOnlySpan indicesSizes) + { + for (int i = 0; i < commands.Length; i++) { + // MeshDataPool passes GL's 64-bit pointer array in an int[]. Each + // offset occupies two words, unlike the tightly packed counts. + ulong byteOffset = (uint)indicesStarts[i * 2] | ((ulong)(uint)indicesStarts[i * 2 + 1] << 32); commands[i] = new DrawIndexedIndirectCommand { IndexCount = (uint)indicesSizes[i], InstanceCount = 1, // GL takes a byte offset; Vulkan takes an index count. - FirstIndex = (uint)(indicesStarts[i] / sizeof(int)), + FirstIndex = checked((uint)(byteOffset / sizeof(int))), VertexOffset = 0, FirstInstance = 0, }; } - - _context.Api.CmdDrawIndexedIndirect(commandBuffer, indirectScratch.Handle, 0, (uint)count, - (uint)sizeof(DrawIndexedIndirectCommand)); } public int LayoutIdOf(int meshId) => Get(meshId)?.LayoutId ?? -1; diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 763f374c..a8de96d7 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -130,6 +130,29 @@ public void SetDrawBuffers(int framebufferId, uint mask) private bool _needsRestart; + /// + /// Whether the scope holds its depth attachment in the read-only layout. + /// + /// GL lets a pass sample the depth buffer it is drawing against as long as + /// depth writes are off - the liquid pass reads scene depth that way to fade + /// water at its edges. Vulkan allows the same only if the attachment is in + /// DEPTH_READ_ONLY_OPTIMAL for both the attachment and the descriptor, so + /// the scope switches layout for such draws and back for the next that + /// writes depth. + /// + public bool DepthReadOnly { get; private set; } + + public void SetDepthReadOnly(bool readOnly) + { + if (DepthReadOnly == readOnly) return; + DepthReadOnly = readOnly; + if (_renderingActive) _needsRestart = true; + } + + /// Whether a texture is the bound framebuffer's depth attachment. + public bool IsBoundDepth(int textureId) => + _bound != null && textureId > 0 && _bound.DepthTextureId == textureId; + /// /// Whether a texture takes part in the rendering scope the bound framebuffer /// is about to open, and so has to keep its attachment layout. @@ -266,12 +289,15 @@ public void EnsureRendering(CommandBuffer commandBuffer) VulkanTexture? depth = _textures.Get(framebuffer.DepthTextureId); if (depth != null) { - _textures.TransitionTexture(commandBuffer, depth, ImageLayout.DepthAttachmentOptimal); + ImageLayout depthLayout = DepthReadOnly + ? ImageLayout.DepthReadOnlyOptimal + : ImageLayout.DepthAttachmentOptimal; + _textures.TransitionTexture(commandBuffer, depth, depthLayout); depthAttachment = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo, ImageView = depth.View, - ImageLayout = ImageLayout.DepthAttachmentOptimal, + ImageLayout = depthLayout, LoadOp = AttachmentLoadOp.Load, StoreOp = AttachmentStoreOp.Store, }; @@ -315,6 +341,16 @@ public void EndRendering(CommandBuffer commandBuffer) public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) { if (_bound == null) return; + + // glClearBuffer names a draw buffer, and one that glDrawBuffers left out + // is simply not cleared. The scope only carries attachments up to the + // highest selected one, so a clear aimed past that - the game clears + // attachments 2 and 3 of the primary target while only 0 and 1 are + // selected - would name an attachment the scope does not have. + if ((uint)attachment >= (uint)_bound.Color.Length) return; + if (!_bound.Color[attachment].IsBound) return; + if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return; + EnsureRendering(commandBuffer); if (!_renderingActive) return; @@ -336,6 +372,9 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo public void ClearDepth(CommandBuffer commandBuffer, float depth) { if (_bound == null || _bound.DepthTextureId <= 0) return; + + // A read-only depth attachment cannot be cleared; a clear is a write. + SetDepthReadOnly(false); EnsureRendering(commandBuffer); if (!_renderingActive) return; diff --git a/Optimum.Render.Vulkan/Core/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs new file mode 100644 index 00000000..c9c13ccb --- /dev/null +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Writes a texture's contents, as they actually sit on the GPU, to a file. +/// +/// A wrong image and wrong texture coordinates look identical from the far end +/// of the pipeline, and the two can be argued about indefinitely. Reading the +/// image back settles it: whatever comes out is what every sample of that +/// texture saw, with no inference in between. +/// +/// Off unless OPTIMUM_DUMP_TEXTURES lists texture ids, comma separated. The +/// files land beside the render trace, or in OPTIMUM_DUMP_DIR when that is set, +/// as binary PPM - a five-line header and raw RGB, which needs no encoder here +/// and which every image tool reads. +/// +internal static class TextureDump +{ + private static readonly string? Requested = + Environment.GetEnvironmentVariable("OPTIMUM_DUMP_TEXTURES"); + + /// Texture ids still waiting to be written. + private static readonly HashSet Pending = Parse(Requested); + + /// + /// Set by OPTIMUM_DUMP_TEXTURES=terrain, which asks for whatever the chunk + /// pass binds rather than for an id. + /// + /// Atlas ids are only handed out once a world loads, and are not stable + /// between runs, so naming one up front means guessing. Latching onto the + /// first storage-buffer multi-draw instead catches the block atlas at the + /// one moment it is certainly the texture the terrain is being drawn with. + /// + private static bool _wantsTerrain = + string.Equals(Requested?.Trim(), "terrain", StringComparison.OrdinalIgnoreCase); + + public static bool WantsTerrain => _wantsTerrain; + + /// Records the textures a chunk draw is using, and stops asking. + public static void RequestTerrain(int baseTexture, int linearTexture) + { + _wantsTerrain = false; + if (baseTexture > 0) Pending.Add(baseTexture); + if (linearTexture > 0 && linearTexture != baseTexture) Pending.Add(linearTexture); + } + + /// True while any requested texture has not been written yet. + public static bool Wanted => Pending.Count > 0; + + private static HashSet Parse(string? value) + { + var ids = new HashSet(); + if (string.IsNullOrWhiteSpace(value)) return ids; + + foreach (string part in value.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(part.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int id)) + { + ids.Add(id); + } + } + return ids; + } + + /// The ids still to write, as a snapshot safe to iterate while removing. + public static int[] Take() + { + var ids = new int[Pending.Count]; + Pending.CopyTo(ids); + Pending.Clear(); + return ids; + } + + private static string Directory() + { + string? explicitDir = Environment.GetEnvironmentVariable("OPTIMUM_DUMP_DIR"); + if (!string.IsNullOrWhiteSpace(explicitDir)) return explicitDir; + + string? tracePath = Environment.GetEnvironmentVariable("OPTIMUM_RENDER_TRACE"); + string? beside = string.IsNullOrWhiteSpace(tracePath) + ? null + : Path.GetDirectoryName(Path.GetFullPath(tracePath)); + + return string.IsNullOrWhiteSpace(beside) ? "." : beside; + } + + /// + /// Writes RGBA or BGRA bytes as a binary PPM. + /// + /// The file written, or null if it could not be. + public static string? Write(int textureId, int width, int height, bool bgra, ReadOnlySpan rgba) + { + if (width <= 0 || height <= 0 || rgba.Length < width * height * 4) return null; + + try + { + string directory = Directory(); + System.IO.Directory.CreateDirectory(directory); + string path = Path.Combine(directory, $"texture-{textureId}-{width}x{height}.ppm"); + + using var file = new FileStream(path, FileMode.Create, FileAccess.Write); + using var writer = new BinaryWriter(file); + + foreach (char c in $"P6\n{width} {height}\n255\n") writer.Write((byte)c); + + int red = bgra ? 2 : 0; + int blue = bgra ? 0 : 2; + + var row = new byte[width * 3]; + for (int y = 0; y < height; y++) + { + int source = y * width * 4; + for (int x = 0; x < width; x++) + { + row[x * 3] = rgba[source + x * 4 + red]; + row[x * 3 + 1] = rgba[source + x * 4 + 1]; + row[x * 3 + 2] = rgba[source + x * 4 + blue]; + } + writer.Write(row); + } + + return path; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } +} diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index 9d58884d..967fd984 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -14,6 +14,19 @@ namespace Optimum.Render.Vulkan.Core; /// value and resolving to a cached sampler at bind time reproduces the GL /// behaviour without creating an object per texture. /// +/// +/// Whether the GL min filter is one of the four MIPMAP forms. GL treats +/// GL_NEAREST and GL_LINEAR as "level 0 only" however many levels the texture +/// has, and Vulkan has no such filter - it always picks a level from the range +/// the sampler allows. So this decides the sampler's LOD clamp, and without it +/// a texture that merely owns a mip chain gets minified through it on surfaces +/// GL would have sampled sharp. +/// +/// +/// GL_TEXTURE_MAX_LEVEL, the highest mip the texture is allowed to use, or a +/// negative value for no limit. The client clamps this to the mipmap quality +/// setting after building a chain. +/// internal readonly record struct SamplerState( Filter MagFilter, Filter MinFilter, @@ -23,12 +36,22 @@ internal readonly record struct SamplerState( float LodBias, bool CompareEnable, float MaxAnisotropy, - BorderColor BorderColor) + BorderColor BorderColor, + bool Mipmapped = false, + int MaxLevel = -1) { public static SamplerState Default => new( Filter.Nearest, Filter.Nearest, SamplerMipmapMode.Nearest, SamplerAddressMode.Repeat, SamplerAddressMode.Repeat, 0f, false, 1f, BorderColor.FloatOpaqueBlack); + + /// + /// The sampler's LOD ceiling. Anything under 1 confines sampling to level 0, + /// which is what a non-mipmapping GL filter means. + /// + public float LodCeiling => !Mipmapped ? 0.25f + : MaxLevel >= 0 ? MaxLevel + 1f + : Vk.LodClampNone; } /// A texture, its memory, its view, and the GL state attached to it. @@ -38,7 +61,7 @@ internal sealed unsafe class VulkanTexture : IDisposable private bool _disposed; public Image Image { get; init; } - public DeviceMemory Memory { get; init; } + public MemoryAllocation Allocation { get; init; } public ImageView View { get; init; } /// Never reused, unlike ; see . @@ -49,6 +72,9 @@ internal sealed unsafe class VulkanTexture : IDisposable public uint Height { get; init; } public uint MipLevels { get; init; } public uint Layers { get; init; } + + /// Whether the view is a cube rather than a six-layer array. + public bool Cube { get; init; } public ImageAspectFlags Aspect { get; init; } /// Mutable, as glTexParameter is. @@ -105,11 +131,7 @@ public void Dispose() _layerViews.Clear(); if (View.Handle != 0) api.DestroyImageView(_context.Device, View, null); if (Image.Handle != 0) api.DestroyImage(_context.Device, Image, null); - if (Memory.Handle != 0) - { - api.FreeMemory(_context.Device, Memory, null); - VulkanMemory.NoteFree(); - } + if (Allocation.IsValid) _context.Allocator.Free(Allocation); } } @@ -157,7 +179,7 @@ public Sampler Get(SamplerState state) // GL_COMPARE_REF_TO_TEXTURE mode the shadow passes enable. CompareOp = CompareOp.LessOrEqual, MinLod = 0f, - MaxLod = Vk.LodClampNone, + MaxLod = state.LodCeiling, BorderColor = state.BorderColor, UnnormalizedCoordinates = false, }; @@ -194,6 +216,23 @@ public void Dispose() /// internal sealed unsafe class TextureManager : IDisposable { + /// GL_SHORT source pixels converted to GL_RGBA16 storage. + internal static ushort ShortToUnorm16(short value) => + (ushort)((Math.Max(0, (int)value) * 65535L + 16383) / 32767); + + public void UploadNormalizedShorts(int id, int level, int x, int y, + int width, int height, ReadOnlySpan pixels) + { + int count = checked(width * height * 4); + var converted = new ushort[count]; + for (int i = 0; i < count; i++) converted[i] = ShortToUnorm16(pixels[i]); + + fixed (ushort* source = converted) + { + Upload(id, level, x, y, (uint)width, (uint)height, (IntPtr)source, 8); + } + } + private readonly VulkanContext _context; private readonly VulkanCommands _commands; private readonly List _textures = new(); @@ -293,16 +332,10 @@ public int Create( } api.GetImageMemoryRequirements(_context.Device, image, out MemoryRequirements requirements); - var allocateInfo = new MemoryAllocateInfo - { - SType = StructureType.MemoryAllocateInfo, - AllocationSize = requirements.Size, - MemoryTypeIndex = VulkanMemory.FindMemoryType( - _context, requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit), - }; - DeviceMemory memory = VulkanMemory.Allocate(_context, allocateInfo, + MemoryAllocation allocation = _context.Allocator.Allocate( + requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, $"a {width}x{height} {format} image"); - api.BindImageMemory(_context.Device, image, memory, 0); + api.BindImageMemory(_context.Device, image, allocation.Memory, allocation.Offset); uint viewLayers = cube ? 6 : layers; var viewInfo = new ImageViewCreateInfo @@ -320,13 +353,14 @@ public int Create( var texture = new VulkanTexture(_context) { Image = image, - Memory = memory, + Allocation = allocation, View = view, Format = format, Width = width, Height = height, MipLevels = mipLevels, Layers = viewLayers, + Cube = cube, Aspect = aspect, }; @@ -453,6 +487,7 @@ public void SetParameter(int textureId, int parameterName, float value) GlEnums.TextureWrapS => state with { AddressU = GlEnums.AddressModeFrom(integer) }, GlEnums.TextureWrapT => state with { AddressV = GlEnums.AddressModeFrom(integer) }, GlEnums.TextureLodBias => state with { LodBias = value }, + GlEnums.TextureMaxLevel => state with { MaxLevel = integer }, GlEnums.TextureCompareMode => state with { CompareEnable = integer == GlEnums.TextureCompareRefToTexture, @@ -464,7 +499,12 @@ public void SetParameter(int textureId, int parameterName, float value) private static SamplerState ApplyMinFilter(SamplerState state, int glFilter) { (Filter filter, SamplerMipmapMode mode) = GlEnums.MinFilterFrom(glFilter); - return state with { MinFilter = filter, MipmapMode = mode }; + return state with + { + MinFilter = filter, + MipmapMode = mode, + Mipmapped = GlEnums.MinFilterUsesMipmaps(glFilter), + }; } /// diff --git a/Optimum.Render.Vulkan/Core/VulkanAllocator.cs b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs new file mode 100644 index 00000000..4578f705 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// A region of a memory block handed to one resource. +internal readonly struct MemoryAllocation +{ + public DeviceMemory Memory { get; init; } + public ulong Offset { get; init; } + public ulong Size { get; init; } + + /// Host pointer to this region, or zero when the memory is not mapped. + public IntPtr Mapped { get; init; } + + internal MemoryBlock? Block { get; init; } + + public bool IsValid => Block != null; +} + +/// +/// One vkAllocateMemory, divided up among many resources. +/// +/// Free space is tracked as ranges in address order, so neighbouring frees merge +/// back into one range and the block does not fragment into confetti as chunk +/// meshes come and go. +/// +internal sealed unsafe class MemoryBlock : IDisposable +{ + private readonly struct FreeRange + { + public FreeRange(ulong offset, ulong size) + { + Offset = offset; + Size = size; + } + + public ulong Offset { get; } + public ulong Size { get; } + public ulong End => Offset + Size; + } + + private readonly VulkanContext _context; + private readonly List _free = new(); + private bool _disposed; + + public DeviceMemory Memory { get; } + public ulong Size { get; } + public uint TypeIndex { get; } + + /// + /// Whether this block holds linear resources (buffers) or optimally tiled + /// ones (images). They are never mixed, which is what makes + /// bufferImageGranularity irrelevant here: the spec only requires padding + /// between the two kinds, and there is never a boundary between them. + /// + public bool Linear { get; } + + /// Set when the block backs exactly one oversized resource. + public bool Dedicated { get; } + + /// Base host pointer when the memory type is host visible. + public IntPtr Mapped { get; private set; } + + public ulong Used { get; private set; } + + public bool IsEmpty => Used == 0; + + public MemoryBlock( + VulkanContext context, ulong size, uint typeIndex, bool linear, bool dedicated, bool hostVisible) + { + _context = context; + Size = size; + TypeIndex = typeIndex; + Linear = linear; + Dedicated = dedicated; + + var allocateInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = size, + MemoryTypeIndex = typeIndex, + }; + + Memory = VulkanMemory.Allocate(context, allocateInfo, + $"a {size} byte {(dedicated ? "dedicated" : "pooled")} memory block"); + + if (hostVisible) + { + void* mapped; + // Mapped once for the block's whole life. Mapping is not free and a + // resource may be written from any thread, so per-resource mapping + // would be both slower and harder to synchronise. + if (context.Api.MapMemory(context.Device, Memory, 0, size, 0, &mapped) == Result.Success) + { + Mapped = (IntPtr)mapped; + } + } + + _free.Add(new FreeRange(0, size)); + } + + public bool TryAllocate(ulong size, ulong alignment, out ulong offset) + { + offset = 0; + if (size == 0 || _disposed) return false; + + for (int i = 0; i < _free.Count; i++) + { + FreeRange range = _free[i]; + + ulong aligned = alignment <= 1 + ? range.Offset + : (range.Offset + alignment - 1) / alignment * alignment; + + ulong padding = aligned - range.Offset; + if (range.Size < padding || range.Size - padding < size) continue; + + ulong tail = range.Size - padding - size; + + // The alignment padding stays free rather than being lost, so a + // later smaller or less strictly aligned resource can use it. + _free.RemoveAt(i); + if (tail > 0) _free.Insert(i, new FreeRange(aligned + size, tail)); + if (padding > 0) _free.Insert(i, new FreeRange(range.Offset, padding)); + + Used += size; + offset = aligned; + return true; + } + + return false; + } + + public void Free(ulong offset, ulong size) + { + if (_disposed || size == 0) return; + + Used -= Math.Min(Used, size); + + int index = 0; + while (index < _free.Count && _free[index].Offset < offset) index++; + + ulong start = offset; + ulong end = offset + size; + + // Merge with the range before, if they touch. + if (index > 0 && _free[index - 1].End == start) + { + start = _free[index - 1].Offset; + _free.RemoveAt(index - 1); + index--; + } + + // And with the range after. + if (index < _free.Count && _free[index].Offset == end) + { + end = _free[index].End; + _free.RemoveAt(index); + } + + _free.Insert(index, new FreeRange(start, end - start)); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (Mapped != IntPtr.Zero) + { + _context.Api.UnmapMemory(_context.Device, Memory); + Mapped = IntPtr.Zero; + } + + _context.Api.FreeMemory(_context.Device, Memory, null); + VulkanMemory.NoteFree(); + _free.Clear(); + } +} + +/// +/// Hands resources memory out of a few large blocks instead of giving each its +/// own allocation. +/// +/// A device allocation is not a cheap object. The driver tracks every one of +/// them and builds a residency list over the whole set on each submit, so cost +/// grows with the count rather than with the bytes. Backing every buffer and +/// image individually put a loaded world at eighteen thousand live allocations, +/// where frames took 200 ms; the same world's memory in a few dozen blocks is +/// the difference between four frames a second and fifty. The allocation limit +/// the spec exposes - commonly 4096 - is the same problem stated as a hard cap, +/// and NVIDIA not enforcing one is why this degraded instead of failing. +/// +/// Buffers and images are kept in separate blocks so bufferImageGranularity +/// never applies, and anything large enough to waste a block gets its own. +/// +internal sealed unsafe class VulkanAllocator : IDisposable +{ + /// Size of a pooled block. + private const ulong BlockSize = 64UL * 1024 * 1024; + + /// Above this, a resource gets its own allocation rather than a slice. + private const ulong DedicatedThreshold = BlockSize / 4; + + /// + /// Set by OPTIMUM_VULKAN_DEDICATED_MEMORY=1 to give every resource its own + /// vkAllocateMemory, which is what this backend did before pooling existed. + /// + /// It is ruinously slow - that is the whole reason pooling is here - but it + /// removes every question of one resource landing on another's bytes, so a + /// rendering fault that survives it is not a suballocation fault. Keeping + /// the old behaviour reachable is what makes that a one-run experiment + /// rather than a bisect. + /// + private static readonly bool AlwaysDedicated = + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_DEDICATED_MEMORY") == "1"; + + private readonly VulkanContext _context; + private readonly object _gate = new(); + private readonly Dictionary<(uint TypeIndex, bool Linear), List> _pools = new(); + private readonly List _dedicated = new(); + private readonly PhysicalDeviceMemoryProperties _memoryProperties; + private bool _disposed; + + public VulkanAllocator(VulkanContext context) + { + _context = context; + context.Api.GetPhysicalDeviceMemoryProperties(context.PhysicalDevice, out _memoryProperties); + } + + /// Blocks currently held, which is the real vkAllocateMemory count. + public int BlockCount + { + get + { + lock (_gate) + { + int count = _dedicated.Count; + foreach (List blocks in _pools.Values) count += blocks.Count; + return count; + } + } + } + + public MemoryAllocation Allocate( + MemoryRequirements requirements, MemoryPropertyFlags properties, bool linear, string what) + { + uint typeIndex = FindMemoryType(requirements.MemoryTypeBits, properties); + bool hostVisible = (_memoryProperties.MemoryTypes[(int)typeIndex].PropertyFlags + & MemoryPropertyFlags.HostVisibleBit) != 0; + + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (AlwaysDedicated || requirements.Size >= DedicatedThreshold) + { + var block = new MemoryBlock( + _context, requirements.Size, typeIndex, linear, dedicated: true, hostVisible); + _dedicated.Add(block); + + if (!block.TryAllocate(requirements.Size, requirements.Alignment, out ulong dedicatedOffset)) + { + throw new InvalidOperationException("a dedicated block could not satisfy " + what); + } + return Describe(block, dedicatedOffset, requirements.Size); + } + + var key = (typeIndex, linear); + if (!_pools.TryGetValue(key, out List? pool)) + { + pool = new List(); + _pools[key] = pool; + } + + foreach (MemoryBlock candidate in pool) + { + if (candidate.TryAllocate(requirements.Size, requirements.Alignment, out ulong offset)) + { + return Describe(candidate, offset, requirements.Size); + } + } + + var fresh = new MemoryBlock( + _context, BlockSize, typeIndex, linear, dedicated: false, hostVisible); + pool.Add(fresh); + + if (!fresh.TryAllocate(requirements.Size, requirements.Alignment, out ulong freshOffset)) + { + throw new InvalidOperationException("a fresh block could not satisfy " + what); + } + return Describe(fresh, freshOffset, requirements.Size); + } + } + + private static MemoryAllocation Describe(MemoryBlock block, ulong offset, ulong size) => + new() + { + Memory = block.Memory, + Offset = offset, + Size = size, + Mapped = block.Mapped == IntPtr.Zero ? IntPtr.Zero : block.Mapped + (int)offset, + Block = block, + }; + + public void Free(in MemoryAllocation allocation) + { + MemoryBlock? block = allocation.Block; + if (block == null) return; + + lock (_gate) + { + if (_disposed) return; + + block.Free(allocation.Offset, allocation.Size); + + if (block.Dedicated) + { + _dedicated.Remove(block); + block.Dispose(); + return; + } + + // An emptied block is kept if it is its pool's last one, so a pool + // that is repeatedly drained and refilled - which chunk streaming + // does - is not paying for an allocation each time. + if (!block.IsEmpty) return; + + var key = (block.TypeIndex, block.Linear); + if (!_pools.TryGetValue(key, out List? pool) || pool.Count <= 1) return; + + pool.Remove(block); + block.Dispose(); + } + } + + private uint FindMemoryType(uint typeBits, MemoryPropertyFlags properties) + { + for (uint i = 0; i < _memoryProperties.MemoryTypeCount; i++) + { + if ((typeBits & (1u << (int)i)) == 0) continue; + + MemoryPropertyFlags flags = _memoryProperties.MemoryTypes[(int)i].PropertyFlags; + if ((flags & properties) == properties) return i; + } + + throw new InvalidOperationException($"no memory type with {properties}"); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + + foreach (List pool in _pools.Values) + { + foreach (MemoryBlock block in pool) block.Dispose(); + } + _pools.Clear(); + + foreach (MemoryBlock block in _dedicated) block.Dispose(); + _dedicated.Clear(); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 6fc06f01..0df9fa4a 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -82,6 +82,13 @@ internal sealed unsafe class VulkanContext : IDisposable /// enforced. /// public object QueueLock { get; } = new(); + + /// + /// Backs every buffer and image out of a few large blocks. See + /// for why one allocation per resource is not + /// an option. + /// + public VulkanAllocator Allocator { get; private set; } = null!; public VulkanCapabilities Capabilities { get; private set; } = new(); /// @@ -597,6 +604,7 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso GraphicsQueue = Api.GetDeviceQueue(Device, family, 0); LoadDiagnosticExtensions(wantCheckpoints, wantDeviceFault); Capabilities = ReadCapabilities(); + Allocator = new VulkanAllocator(this); return true; } @@ -774,6 +782,10 @@ public void Dispose() if (Device.Handle != 0) { Api.DeviceWaitIdle(Device); + + // Memory blocks are freed while the device still exists, and after + // the wait, so nothing is executing against them. + Allocator?.Dispose(); Api.DestroyDevice(Device, null); } diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index 47514d75..1710a9ce 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -31,11 +31,19 @@ internal sealed unsafe class VulkanBuffer : IDisposable private bool _disposed; public Buffer Handle { get; } - public DeviceMemory Memory { get; } public ulong Size { get; } /// Never reused, unlike ; see . public ulong Id { get; } = ResourceIds.Next(); + + private MemoryAllocation _allocation; + + /// Which block this buffer's memory came from. For tests. + internal ulong MemoryHandleForTest => _allocation.Memory.Handle; + + /// The block, offset and size this buffer occupies. For tests. + internal MemoryAllocation Allocation => _allocation; + /// Non-zero when the allocation is host visible and mapped. public IntPtr Mapped { get; private set; } @@ -61,23 +69,12 @@ public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, M api.GetBufferMemoryRequirements(context.Device, buffer, out MemoryRequirements requirements); - var allocateInfo = new MemoryAllocateInfo - { - SType = StructureType.MemoryAllocateInfo, - AllocationSize = requirements.Size, - MemoryTypeIndex = VulkanMemory.FindMemoryType(context, requirements.MemoryTypeBits, properties), - }; - - DeviceMemory memory = VulkanMemory.Allocate(context, allocateInfo, $"a {size} byte buffer"); - Memory = memory; - api.BindBufferMemory(context.Device, buffer, memory, 0); + // A buffer is linear, so it shares blocks only with other buffers. + _allocation = context.Allocator.Allocate( + requirements, properties, linear: true, $"a {size} byte buffer"); - if (properties.HasFlag(MemoryPropertyFlags.HostVisibleBit)) - { - void* mapped; - api.MapMemory(context.Device, memory, 0, size, 0, &mapped); - Mapped = (IntPtr)mapped; - } + api.BindBufferMemory(context.Device, buffer, _allocation.Memory, _allocation.Offset); + Mapped = _allocation.Mapped; } public void Dispose() @@ -85,15 +82,11 @@ public void Dispose() if (_disposed) return; _disposed = true; - Vk api = _context.Api; - if (Mapped != IntPtr.Zero) - { - api.UnmapMemory(_context.Device, Memory); - Mapped = IntPtr.Zero; - } - api.DestroyBuffer(_context.Device, Handle, null); - api.FreeMemory(_context.Device, Memory, null); - VulkanMemory.NoteFree(); + // The mapping belongs to the block, not to this buffer, so it is not + // unmapped here - the region simply goes back to the pool. + Mapped = IntPtr.Zero; + _context.Api.DestroyBuffer(_context.Device, Handle, null); + _context.Allocator.Free(_allocation); } } @@ -104,8 +97,9 @@ internal sealed unsafe class VulkanImage : IDisposable private bool _disposed; public Image Handle { get; } - public DeviceMemory Memory { get; } public ImageView View { get; } + + private MemoryAllocation _allocation; public Format Format { get; } public uint Width { get; } public uint Height { get; } @@ -148,16 +142,11 @@ public VulkanImage( Handle = image; api.GetImageMemoryRequirements(context.Device, image, out MemoryRequirements requirements); - var allocateInfo = new MemoryAllocateInfo - { - SType = StructureType.MemoryAllocateInfo, - AllocationSize = requirements.Size, - MemoryTypeIndex = VulkanMemory.FindMemoryType( - context, requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit), - }; - DeviceMemory memory = VulkanMemory.Allocate(context, allocateInfo, "an image"); - Memory = memory; - api.BindImageMemory(context.Device, image, memory, 0); + + // Optimally tiled, so it never shares a block with a buffer. + _allocation = context.Allocator.Allocate( + requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, "an image"); + api.BindImageMemory(context.Device, image, _allocation.Memory, _allocation.Offset); var viewInfo = new ImageViewCreateInfo { @@ -182,8 +171,7 @@ public void Dispose() Vk api = _context.Api; api.DestroyImageView(_context.Device, View, null); api.DestroyImage(_context.Device, Handle, null); - api.FreeMemory(_context.Device, Memory, null); - VulkanMemory.NoteFree(); + _context.Allocator.Free(_allocation); } } @@ -279,6 +267,7 @@ public static DeviceMemory Allocate(VulkanContext context, MemoryAllocateInfo al } NoteAllocation(); + VulkanStats.NoteAllocation(); return memory; } @@ -359,12 +348,27 @@ public CommandBuffer Allocate() /// uploads reach this from asset-loading worker threads while the render /// thread is submitting frames. /// + /// + /// Runs before every synchronous submit, outside the queue lock. The device + /// uses it to flush a frame it is in the middle of recording: a synchronous + /// submit executes before that frame does, so any layout transition the + /// frame has already recorded is not yet true on the GPU, and a setup + /// command that assumed it would corrupt the image or fail the submit. + /// + public Action? BeforeSynchronousSubmit; + public void SubmitAndWait(Action record) { + BeforeSynchronousSubmit?.Invoke(); + + // Timed from before the lock: waiting for the render thread to release + // the queue is as much a part of an upload's cost as the GPU work. + long start = System.Diagnostics.Stopwatch.GetTimestamp(); lock (_context.QueueLock) { SubmitAndWaitLocked(record); } + VulkanStats.NoteUpload(System.Diagnostics.Stopwatch.GetTimestamp() - start); } private void SubmitAndWaitLocked(Action record) diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs new file mode 100644 index 00000000..cc184d1f --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -0,0 +1,85 @@ +using System; +using System.Diagnostics; +using System.Threading; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Per-second counters for the work the backend does, so a slow phase can be +/// attributed rather than guessed at. +/// +/// A frame time on its own says the renderer is slow; it does not say whether +/// the cost is device allocations, blocking uploads waiting on the GPU, or +/// descriptor churn. These count each of those and report them together. +/// +/// Off unless OPTIMUM_VULKAN_STATS names a file. The counters themselves are +/// always live - they are interlocked increments on paths that already cost +/// microseconds apiece, so they do not need gating, and having them unconditional +/// means a report can be turned on for a session that is already misbehaving. +/// +internal static class VulkanStats +{ + private static long _allocations; + private static long _uploads; + private static long _uploadWaitTicks; + private static long _texturesCreated; + private static long _texturesDeleted; + private static long _frames; + private static long _droppedMeshWrites; + + /// A mesh write that could not land; see MeshManager.Write. + public static void NoteDroppedMeshWrite() => Interlocked.Increment(ref _droppedMeshWrites); + + public static long DroppedMeshWrites => Interlocked.Read(ref _droppedMeshWrites); + + public static void NoteAllocation() => Interlocked.Increment(ref _allocations); + public static void NoteTextureCreated() => Interlocked.Increment(ref _texturesCreated); + public static void NoteTextureDeleted() => Interlocked.Increment(ref _texturesDeleted); + public static void NoteFrame() => Interlocked.Increment(ref _frames); + + public static void NoteUpload(long elapsedTicks) + { + Interlocked.Increment(ref _uploads); + Interlocked.Add(ref _uploadWaitTicks, elapsedTicks); + } + + /// + /// Takes and clears the counters, formatted as one line, or null when the + /// interval has not elapsed. Called once per frame by the render thread. + /// + public static string? SampleIfDue(TimeSpan interval) + { + long now = Stopwatch.GetTimestamp(); + long last = Interlocked.Read(ref _lastSample); + if (last == 0) + { + Interlocked.CompareExchange(ref _lastSample, now, 0); + return null; + } + + double elapsed = (now - last) / (double)Stopwatch.Frequency; + if (elapsed < interval.TotalSeconds) return null; + if (Interlocked.CompareExchange(ref _lastSample, now, last) != last) return null; + + long frames = Interlocked.Exchange(ref _frames, 0); + long allocations = Interlocked.Exchange(ref _allocations, 0); + long uploads = Interlocked.Exchange(ref _uploads, 0); + long uploadTicks = Interlocked.Exchange(ref _uploadWaitTicks, 0); + long created = Interlocked.Exchange(ref _texturesCreated, 0); + long deleted = Interlocked.Exchange(ref _texturesDeleted, 0); + long dropped = Interlocked.Exchange(ref _droppedMeshWrites, 0); + + double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; + double frameMs = frames > 0 ? elapsed * 1000.0 / frames : 0; + + return string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "stats {0:F1}s: {1} frames ({2:F1} ms/frame), {3} allocations ({4} live), " + + "{5} blocking uploads costing {6:F0} ms ({7:F0}% of the interval), " + + "textures +{8}/-{9}, mesh writes dropped {10}", + elapsed, frames, frameMs, allocations, VulkanMemory.LiveAllocations, + uploads, uploadMs, uploadMs / (elapsed * 1000.0) * 100.0, created, deleted, dropped); + } + + private static long _lastSample; +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 491101aa..060500ac 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -44,8 +44,12 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice /// Pass names by program id, so a device-loss report can name the shader. private readonly Dictionary _programNames = new(); + /// Scratch for the SSBO path's pruned custom ints, grown as needed. + private int[] _prunedCustomInts = []; + private uint _frameCounter; private uint _uniformExhaustionReportedFrame = uint.MaxValue; + private int _renderThreadId = -1; private readonly Dictionary _stagedStages = new(); private readonly List _diagnostics = new(); @@ -66,12 +70,20 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice /// See the placeholder note in BindDescriptors. /// private int _placeholderTexture; + private int _placeholderArrayTexture; + private int _placeholderCubeTexture; + private int _placeholderDepthTexture; private VulkanBuffer? _placeholderUniforms; /// Texture bound to each unit, and any sampler overriding the texture's own state. private readonly int[] _boundTextures = new int[GlStateTracker.MaxTextureUnits]; private readonly Sampler[] _unitSamplerOverrides = new Sampler[GlStateTracker.MaxTextureUnits]; + // Atlas composition reads one tile while writing another in the same image. + // Reuse a snapshot image, but refresh its contents before each such draw. + private readonly Dictionary _feedbackCopies = new(); + private readonly Dictionary _sampledTextureOverrides = new(); + private int _nextProgramId = 1; private bool _frameActive; private bool _disposed; @@ -227,6 +239,9 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa { _diagnostics.Add(message); MirrorValidationMessage(message); + if (RenderTrace.Enabled) + RenderTrace.Write("validation: program=" + (_state?.CurrentProgram ?? 0) + + " target=" + (_targets?.Bound?.Id ?? -1) + " " + message); }, // Surface extensions have to be enabled at instance creation, before // any surface can exist, so the window system is asked first. @@ -257,6 +272,12 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; GPU checkpoints " + (_context.CheckpointsAvailable ? "ENABLED" : "NOT AVAILABLE") + "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE")); _setupCommands = new VulkanCommands(_context); + // Only the render thread records frames, so only its synchronous submits + // can race one; a worker's upload is ordered by the queue lock alone. + _setupCommands.BeforeSynchronousSubmit = () => + { + if (_frameActive && Environment.CurrentManagedThreadId == _renderThreadId) FlushFrame(); + }; _state = new GlStateTracker(); _textures = new TextureManager(_context, _setupCommands); _meshes = new MeshManager(_context, _state); @@ -372,7 +393,71 @@ private void CreatePlaceholderTexture() { _placeholderTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm); _textures.Upload(_placeholderTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4); + + // A descriptor's view type has to match the sampler's dimensionality + // - a 2D view in a sampler2DArray slot is invalid, not merely black - + // so an arrayed and a cube placeholder stand in for those samplers. + _placeholderArrayTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm, layers: 2); + for (uint layer = 0; layer < 2; layer++) + { + _textures.Upload(_placeholderArrayTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4, layer); + } + + _placeholderCubeTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm, layers: 6, cube: true); + for (uint face = 0; face < 6; face++) + { + _textures.Upload(_placeholderCubeTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4, face); + } + } + + // A shadow sampler compares against depth, so its placeholder is a depth + // texel at the far plane: every comparison passes and nothing is shadowed, + // which is what a missing shadow map looks like on GL. The state enables + // comparison so the sampler object matches the sampler declaration too. + float far = 1f; + _placeholderDepthTexture = _textures.Create(1, 1, Format.D32Sfloat); + _textures.Upload(_placeholderDepthTexture, 0, 0, 0, 1, 1, (IntPtr)(&far), 4); + VulkanTexture? depthPlaceholder = _textures.Get(_placeholderDepthTexture); + if (depthPlaceholder != null) + { + depthPlaceholder.State = depthPlaceholder.State with { CompareEnable = true }; + } + } + + /// + /// The placeholder that fits a sampler's declaration: a shadow sampler + /// compares against depth and needs a depth format, the others need the + /// matching view type. An arrayed shadow sampler gets the 2D depth + /// placeholder, which the trace will show should the game ever declare one. + /// + private int PlaceholderFor(string samplerType) => + samplerType.Contains("Shadow", StringComparison.Ordinal) ? _placeholderDepthTexture + : samplerType.Contains("Cube", StringComparison.Ordinal) ? _placeholderCubeTexture + : samplerType.Contains("Array", StringComparison.Ordinal) ? _placeholderArrayTexture + : _placeholderTexture; + + /// + /// Whether a texture can legally sit behind a sampler of the given type. A + /// shadow sampler on a colour texture is the case that matters: GL leaves the + /// comparison undefined, Vulkan rejects the descriptor, and the game reaches + /// it whenever a shadow map slot exists without a shadow map behind it. + /// + private static bool TextureSuitsSampler(VulkanTexture texture, string samplerType) + { + if (samplerType.Contains("Shadow", StringComparison.Ordinal) + && !TextureManager.IsDepthFormat(texture.Format)) + { + return false; } + + // The view type has to match the sampler's dimensionality, which GL + // enforces through its texture targets: a 2D texture cannot be bound + // where a sampler2DArray reads, nor an array where a sampler2D does. + bool wantsCube = samplerType.Contains("Cube", StringComparison.Ordinal); + bool wantsArray = samplerType.Contains("Array", StringComparison.Ordinal); + if (wantsCube) return texture.Cube; + if (wantsArray) return texture.Layers > 1 && !texture.Cube; + return texture.Layers == 1 && !texture.Cube; } /// @@ -464,7 +549,9 @@ public void BeginFrame() { _frames.BeginFrame(); _frameActive = true; + _renderThreadId = Environment.CurrentManagedThreadId; _frameCounter++; + _indirectFrameUsage = 0; Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); // Sets naming resources deleted since last frame leave the cache now and @@ -472,8 +559,24 @@ public void BeginFrame() // bound them. IDisposable? freedSets = _descriptors.CollectReleases(); if (freedSets != null) _frames.DeferDeletion(freedSets); + + VulkanStats.NoteFrame(); + if (StatsLogPath != null && + VulkanStats.SampleIfDue(TimeSpan.FromSeconds(1)) is { } sample) + { + try + { + System.IO.File.AppendAllText(StatsLogPath, sample + "\n"); + } + catch (System.IO.IOException) + { + } + } } + /// Where per-second backend counters go, when asked for. + private static readonly string? StatsLogPath = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_STATS"); + /// /// Leaves a marker the driver reports back if the GPU stops. Free when the /// extension is absent; one small command otherwise. @@ -548,6 +651,8 @@ public void Present() { if (!_frameActive) return; + if (TextureDump.Wanted) DumpRequestedTextures(); + CommandBuffer commandBuffer = _frames.Current.CommandBuffer; // Any open rendering scope has to close before the command buffer ends. @@ -1017,6 +1122,13 @@ public int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPt if (pixels != IntPtr.Zero && bytesPerPixel > 0) { _textures.Upload(id, 0, 0, 0, (uint)width, (uint)height, pixels, bytesPerPixel); + + // A chain that was asked for has to be filled here. GL's texture is + // complete the moment glGenerateMipmap runs, but an image created + // with levels and never blitted into keeps whatever its memory held, + // and every sample above level 0 reads that - which looks like other + // textures bleeding onto a surface as it turns away from the camera. + if (generateMipmaps) _textures.GenerateMipmaps(id); } RenderTrace.TextureCreated(id, width, height, format, pixels, bytesPerPixel); return id; @@ -1064,6 +1176,13 @@ public void UploadTexture2D( _textures.Upload(textureId, level, x, y, (uint)width, (uint)height, pixels, pixelFormat == EnumTexturePixelFormat.Red ? 1 : 4); + public void UploadTexture2DRaw( + int textureId, int level, int x, int y, int width, int height, IntPtr pixels, int bytesPerPixel) + { + if (bytesPerPixel <= 0) return; + _textures.Upload(textureId, level, x, y, (uint)width, (uint)height, pixels, bytesPerPixel); + } + public void GenerateMipmaps(int textureId) => _textures.GenerateMipmaps(textureId); public void DeleteTexture(int textureId) => ReleaseTexture(textureId); @@ -1079,9 +1198,12 @@ public void UploadTexture2D( /// private void ReleaseTexture(int textureId) { + if (_feedbackCopies.Remove(textureId, out int copy)) ReleaseTexture(copy); + _sampledTextureOverrides.Remove(textureId); VulkanTexture? texture = _textures.Get(textureId); if (texture != null) _descriptors.Release(texture.Id); _textures.Delete(textureId, _frames); + VulkanStats.NoteTextureDeleted(); } public void SetTextureParameter(int textureId, int parameterName, int value) => @@ -1104,8 +1226,13 @@ public void BindTexture(int unit, int textureId) { if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; _boundTextures[unit] = textureId; + if (RenderTrace.Enabled) RenderTrace.Write("bind unit=" + unit + " texture=" + textureId); } + public void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, + int width, int height, short[] pixels) => + _textures.UploadNormalizedShorts(textureId, level, x, y, width, height, pixels); + public void BindTextureCube(int unit, int textureId) => BindTexture(unit, textureId); private readonly Dictionary _standaloneSamplers = new(); @@ -1220,16 +1347,20 @@ public void ClearStencil() { } public int CreateMesh(MeshData data, bool staticDraw) { + // Sized as GL's UploadMesh sizes them. Every part follows the vertex + // count except flags, which GL allocates at the array's full length - + // a mesh that later grows within that capacity updates its flags in + // place there, and would overflow a vertex-count-sized buffer here. int vertices = data.VerticesCount; int id = _meshes.CreateEmpty( data.xyz != null ? vertices * 3 * sizeof(float) : 0, data.Normals != null ? vertices * sizeof(int) : 0, data.Uv != null ? vertices * 2 * sizeof(float) : 0, data.Rgba != null ? vertices * 4 : 0, - data.Flags != null ? vertices * sizeof(int) : 0, + data.Flags != null ? data.Flags.Length * sizeof(int) : 0, data.IndicesCount * sizeof(int), data.CustomFloats, data.CustomShorts, data.CustomBytes, data.CustomInts, - data.mode, staticDraw, ssbo: false); + data.mode, staticDraw, ssbo: false, signedCustomShorts: true); UpdateMesh(id, data); return id; @@ -1243,54 +1374,150 @@ public int CreateEmptyMesh( _meshes.CreateEmpty(xyzSize, normalsSize, uvSize, rgbaSize, flagsSize, indicesSize, customFloats, customShorts, customBytes, customInts, drawMode, staticDraw, ssbo); + /// + /// Writes a mesh's data, honouring the destination offset each part carries. + /// + /// Those offsets are the whole point. The game pools chunk meshes: one large + /// mesh holds many chunks, and each chunk is handed the same mesh with the + /// byte offset of its own slice in every part. GL's updateVAO takes that + /// offset as the destination for a glBufferSubData, so writing it at zero + /// instead stacks every chunk in the world on top of the first one - which + /// renders as no terrain at all. + /// + /// The counts are per part as well, not VerticesCount: a part can be absent + /// or shorter than the vertex count, and the custom buffers have no fixed + /// relationship to it. + /// public void UpdateMesh(int meshId, MeshData data) { - int vertices = data.VerticesCount; + // An SSBO mesh's xyz slot holds packed face records, written through + // UpdateMeshStorageBuffer; positions never belong there. The game hands + // the same MeshData to both calls, so without this the positions would + // land on top of the records - or, depending on order, under them. + bool ssbo = _meshes.IsSsbo(meshId); - if (data.xyz != null) + if (data.xyz != null && data.XyzCount > 0 && !ssbo) { fixed (float* source = data.xyz) { - _meshes.Write(meshId, MeshManager.BufferXyz, 0, (IntPtr)source, vertices * 3 * sizeof(float)); + _meshes.Write(meshId, MeshManager.BufferXyz, data.XyzOffset, + (IntPtr)source, data.XyzCount * sizeof(float)); } } - if (data.Uv != null) + // The normals, uv and flags streams have no buffer on an SSBO mesh - the + // face records carry what the shader needs from them - so GL's SSBO + // update path never writes them either. + if (data.Normals != null && data.VerticesCount > 0 && !ssbo) + { + fixed (int* source = data.Normals) + { + _meshes.Write(meshId, MeshManager.BufferNormals, data.NormalsOffset, + (IntPtr)source, data.VerticesCount * sizeof(int)); + } + } + if (data.Uv != null && data.UvCount > 0 && !ssbo) { fixed (float* source = data.Uv) { - _meshes.Write(meshId, MeshManager.BufferUv, 0, (IntPtr)source, vertices * 2 * sizeof(float)); + _meshes.Write(meshId, MeshManager.BufferUv, data.UvOffset, + (IntPtr)source, data.UvCount * sizeof(float)); } } - if (data.Rgba != null) + if (data.Rgba != null && data.RgbaCount > 0) { fixed (byte* source = data.Rgba) { - _meshes.Write(meshId, MeshManager.BufferRgba, 0, (IntPtr)source, vertices * 4); + _meshes.Write(meshId, MeshManager.BufferRgba, data.RgbaOffset, + (IntPtr)source, data.RgbaCount); } } - if (data.Flags != null) + if (data.Flags != null && data.FlagsCount > 0 && !ssbo) { fixed (int* source = data.Flags) { - _meshes.Write(meshId, MeshManager.BufferFlags, 0, (IntPtr)source, vertices * sizeof(int)); + _meshes.Write(meshId, MeshManager.BufferFlags, data.FlagsOffset, + (IntPtr)source, data.FlagsCount * sizeof(int)); } } - if (data.Normals != null) + if (data.CustomFloats != null && data.CustomFloats.Count > 0) { - fixed (int* source = data.Normals) + fixed (float* source = data.CustomFloats.Values) { - _meshes.Write(meshId, MeshManager.BufferNormals, 0, (IntPtr)source, vertices * sizeof(int)); + _meshes.Write(meshId, MeshManager.BufferCustomFloat, data.CustomFloats.BaseOffset, + (IntPtr)source, data.CustomFloats.Count * sizeof(float)); } } - if (data.Indices != null) + if (data.CustomShorts != null && data.CustomShorts.Count > 0) + { + fixed (short* source = data.CustomShorts.Values) + { + _meshes.Write(meshId, MeshManager.BufferCustomShort, data.CustomShorts.BaseOffset, + (IntPtr)source, data.CustomShorts.Count * sizeof(short)); + } + } + if (data.CustomInts != null && data.CustomInts.Count > 0) + { + if (ssbo) + { + WritePrunedCustomInts(meshId, data.CustomInts); + } + else + { + fixed (int* source = data.CustomInts.Values) + { + _meshes.Write(meshId, MeshManager.BufferCustomInt, data.CustomInts.BaseOffset, + (IntPtr)source, data.CustomInts.Count * sizeof(int)); + } + } + } + if (data.CustomBytes != null && data.CustomBytes.Count > 0) + { + fixed (byte* source = data.CustomBytes.Values) + { + _meshes.Write(meshId, MeshManager.BufferCustomByte, data.CustomBytes.BaseOffset, + (IntPtr)source, data.CustomBytes.Count); + } + } + // An SSBO mesh never takes indices from the data: GL draws every such + // mesh through one shared index buffer holding the fixed quad pattern, + // filled once at allocation, and its update path leaves indices alone. + // The mesh here got the same pattern when it was created. + if (data.Indices != null && data.IndicesCount > 0 && !ssbo) { fixed (int* source = data.Indices) { - _meshes.Write(meshId, -1, 0, (IntPtr)source, data.IndicesCount * sizeof(int)); + _meshes.Write(meshId, -1, data.IndicesOffset, + (IntPtr)source, data.IndicesCount * sizeof(int)); } } } + /// + /// Writes the custom ints as the SSBO path stores them: two per vertex go in + /// and only the second of each pair is kept, the first being the colormap + /// data that the face record already carries. The destination offset halves + /// with the stride. A part with a single int per vertex is not bound at all + /// on this path, so there is nothing to write. + /// + private void WritePrunedCustomInts(int meshId, CustomMeshDataPartInt customInts) + { + if (customInts.InterleaveStride <= 4) return; + + int kept = customInts.Count / 2; + if (kept <= 0) return; + + if (_prunedCustomInts.Length < kept) _prunedCustomInts = new int[kept]; + + int[] values = customInts.Values; + for (int i = 0; i < kept; i++) _prunedCustomInts[i] = values[i * 2 + 1]; + + fixed (int* source = _prunedCustomInts) + { + _meshes.Write(meshId, MeshManager.BufferCustomInt, customInts.BaseOffset / 2, + (IntPtr)source, kept * sizeof(int)); + } + } + /// /// The SSBO chunk path packs four vertices into one face record and stores /// them in the xyz slot, which CreateEmptyMesh gave StorageBufferBit usage @@ -1322,7 +1549,7 @@ public void UpdateMeshStorageBuffer(int meshId, IntPtr data, int byteOffset, int public void DrawMeshInstanced(int meshId, int instanceCount) { - if (!PrepareDraw(_meshes.LayoutIdOf(meshId), out CommandBuffer commandBuffer)) return; + if (!PrepareDraw(_meshes.LayoutIdOf(meshId), meshId, out CommandBuffer commandBuffer)) return; Checkpoint(commandBuffer, CheckpointMarker.Draw(CheckpointKind.Draw, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); if (RenderTrace.Enabled) @@ -1345,17 +1572,32 @@ public void DrawMeshInstanced(int meshId, int instanceCount) public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo) { - if (!PrepareDraw(_meshes.LayoutIdOf(meshId), out CommandBuffer commandBuffer)) return; + if (!PrepareDraw(_meshes.LayoutIdOf(meshId), meshId, out CommandBuffer commandBuffer)) return; Checkpoint(commandBuffer, CheckpointMarker.Draw(CheckpointKind.DrawMulti, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); - VulkanBuffer indirect = EnsureIndirectScratch(groupCount); - _meshes.DrawMulti(commandBuffer, meshId, indicesStarts, indicesSizes, groupCount, indirect); + VulkanBuffer indirect = AllocateIndirect(groupCount, out ulong indirectOffset); + + // The chunk pass is the only storage-buffer multi-draw, and units 0 and + // 1 are terrainTex and terrainTexLinear, so this is the block atlas. + if (ssbo && TextureDump.WantsTerrain) + { + TextureDump.RequestTerrain(_boundTextures[0], _boundTextures[1]); + } + if (RenderTrace.Enabled) + { + RenderTrace.Write("multidraw mesh=" + meshId + " program=" + _state.CurrentProgram + + " groups=" + groupCount + " first=" + (groupCount > 0 ? indicesStarts[0] + "/" + indicesSizes[0] : "-") + + " target=" + (_targets.Bound?.Id ?? -1) + " cull=" + _state.CullEnabled + "/" + _state.CullMode + + " depthTest=" + _state.DepthTest + " uniforms=" + _lastUniformAllocationOk + + " indirectOffset=" + indirectOffset); + } + _meshes.DrawMulti(commandBuffer, meshId, indicesStarts, indicesSizes, groupCount, indirect, indirectOffset); } public void DrawFullscreenTriangle() { - if (!PrepareDraw(MeshManager.EmptyLayoutId, out CommandBuffer commandBuffer)) return; + if (!PrepareDraw(MeshManager.EmptyLayoutId, 0, out CommandBuffer commandBuffer)) return; Checkpoint(commandBuffer, CheckpointMarker.Draw(CheckpointKind.Fullscreen, _state.CurrentProgram, _targets.Bound?.Id ?? 0, 0)); if (RenderTrace.Enabled) @@ -1372,7 +1614,7 @@ public void DrawFullscreenTriangle() /// upload, and the dynamic state. This is where the recorded GL state finally /// becomes Vulkan commands. /// - private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) + private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer commandBuffer) { commandBuffer = default; if (!_frameActive) @@ -1399,6 +1641,17 @@ private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) commandBuffer = Commands; + // Primitive mode belongs to the mesh, just as it does to GL's VAO. + // Apply it before both pipeline selection and dynamic state emission. + // Fullscreen draws have no mesh and must reset a preceding line draw. + _state.SetTopology(_meshes.Get(meshId)?.DrawMode ?? EnumDrawMode.Triangles); + + // A draw that samples the bound depth attachment with depth writes off is + // GL's way of reading scene depth mid-pass; the scope holds depth + // read-only for it, and returns to writable for the next draw that needs + // to write. Decided before the scope opens, since it decides the layout. + _targets.SetDepthReadOnly(SamplesBoundDepthWithoutWriting(program)); + // Before the scope opens, not after: a layout transition is illegal // inside one, so anything this draw samples has to be put right first. TransitionSampledTextures(commandBuffer, program); @@ -1454,7 +1707,7 @@ private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) VertexLayoutDescription.DefaultAttributeBinding, 1, &defaults, &offset); } - BindDescriptors(commandBuffer, program); + BindDescriptors(commandBuffer, program, meshId); ApplyDynamicState(commandBuffer, target); return true; } @@ -1469,13 +1722,29 @@ private bool PrepareDraw(int vertexLayoutId, out CommandBuffer commandBuffer) /// rendering scope - so a texture found in the wrong layout closes the scope, /// transitions, and the scope reopens around the draw. /// - /// An attachment of the framebuffer being drawn into is skipped: it has to - /// keep its attachment layout, EnsureRendering already transitions the ones - /// left out of the draw, and sampling what you are writing is a feedback loop - /// GL does not allow either. + /// A sampled colour attachment is snapshotted first: atlas composition reads + /// an existing tile while drawing into another tile of the same texture. + /// + /// + /// Whether any sampler this program reads through is bound to the depth + /// attachment of the current framebuffer while depth writes are off. /// + private bool SamplesBoundDepthWithoutWriting(ShaderProgramResources program) + { + if (_state.DepthWrite || program.Interface.Samplers.Count == 0) return false; + + foreach (SamplerBinding declared in program.Interface.Samplers) + { + int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Binding; + if ((uint)unit >= GlStateTracker.MaxTextureUnits) continue; + if (_targets.IsBoundDepth(_boundTextures[unit])) return true; + } + return false; + } + private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgramResources program) { + _sampledTextureOverrides.Clear(); if (program.Interface.Samplers.Count == 0) return; bool placeholderNeeded = false; @@ -1499,8 +1768,28 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra continue; } + // The bound depth attachment read with writes off: EnsureRendering + // puts it in the read-only layout, which serves both uses at once. + if (_targets.DepthReadOnly && _targets.IsBoundDepth(_boundTextures[unit])) continue; + + if (_targets.IsAttachmentOfBound(_boundTextures[unit])) + { + if (texture.Aspect == ImageAspectFlags.ColorBit) + { + SnapshotColorAttachment(commandBuffer, _boundTextures[unit], texture); + continue; + } + if (RenderTrace.Enabled) + { + RenderTrace.Write("feedback: program " + program.ProgramId + " '" + + ProgramNameOf(program.ProgramId) + "' samples texture " + _boundTextures[unit] + + " (layout " + texture.Layout + ") which is a written attachment of framebuffer " + + (_targets.Bound?.Id ?? -1)); + } + continue; + } + if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; - if (_targets.IsAttachmentOfBound(_boundTextures[unit])) continue; _targets.EndRendering(commandBuffer); _textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); @@ -1508,14 +1797,57 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra if (!placeholderNeeded) return; - VulkanTexture? placeholder = _textures.Get(_placeholderTexture); - if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) return; + foreach (int id in new[] + { + _placeholderTexture, _placeholderArrayTexture, _placeholderCubeTexture, _placeholderDepthTexture, + }) + { + VulkanTexture? placeholder = _textures.Get(id); + if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; + + _targets.EndRendering(commandBuffer); + _textures.TransitionTexture(commandBuffer, placeholder, ImageLayout.ShaderReadOnlyOptimal); + } + } + + private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, VulkanTexture source) + { + if (_sampledTextureOverrides.ContainsKey(textureId)) return; _targets.EndRendering(commandBuffer); - _textures.TransitionTexture(commandBuffer, placeholder, ImageLayout.ShaderReadOnlyOptimal); + if (!_feedbackCopies.TryGetValue(textureId, out int copyId)) + { + copyId = _textures.Create(source.Width, source.Height, source.Format, + layers: source.Layers, cube: source.Cube, + generateMipmaps: source.MipLevels > 1); + _feedbackCopies.Add(textureId, copyId); + } + VulkanTexture copy = _textures.Get(copyId)!; + copy.State = source.State; + + _textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal); + _textures.TransitionTexture(commandBuffer, copy, ImageLayout.TransferDstOptimal); + for (uint level = 0; level < source.MipLevels; level++) + { + var region = new ImageCopy + { + SrcSubresource = new ImageSubresourceLayers(source.Aspect, level, 0, source.Layers), + DstSubresource = new ImageSubresourceLayers(copy.Aspect, level, 0, copy.Layers), + Extent = new Extent3D(Math.Max(1u, source.Width >> (int)level), + Math.Max(1u, source.Height >> (int)level), 1), + }; + _context.Api.CmdCopyImage(commandBuffer, source.Image, ImageLayout.TransferSrcOptimal, + copy.Image, ImageLayout.TransferDstOptimal, 1, ®ion); + } + _textures.TransitionTexture(commandBuffer, copy, ImageLayout.ShaderReadOnlyOptimal); + _sampledTextureOverrides.Add(textureId, copyId); + if (RenderTrace.Enabled) + RenderTrace.Write("snapshot texture=" + textureId + " copy=" + copyId + + " size=" + source.Width + "x" + source.Height + " mips=" + source.MipLevels); + // EnsureRendering transitions the source back to its attachment layout. } - private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program) + private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) { Vk api = _context.Api; @@ -1611,7 +1943,21 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if ((uint)unit < GlStateTracker.MaxTextureUnits) { - VulkanTexture? texture = _textures.Get(_boundTextures[unit]); + int textureId = _sampledTextureOverrides.TryGetValue(_boundTextures[unit], out int copy) + ? copy : _boundTextures[unit]; + VulkanTexture? texture = _textures.Get(textureId); + if (texture != null && !TextureSuitsSampler(texture, declared.TypeName)) + { + // Left unbound on purpose, so the placeholder that suits + // the sampler takes the slot below. + if (RenderTrace.Enabled) + { + RenderTrace.Write("sampler '" + declared.Name + "' (" + declared.TypeName + + ") on program " + program.ProgramId + " has texture " + _boundTextures[unit] + + " of format " + texture.Format + " bound, which it cannot sample; using a placeholder"); + } + texture = null; + } if (texture != null) { view = texture.View; @@ -1624,7 +1970,14 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources } } - bindings[i] = new SamplerBindingValue((uint)declared.Binding, view, sampler, resource); + // The bound depth attachment, sampled with writes off, is read in + // the layout the scope holds it in rather than shader-read-only. + ImageLayout layout = view.Handle != 0 && _targets.DepthReadOnly + && _targets.IsBoundDepth(_boundTextures[unit]) + ? ImageLayout.DepthReadOnlyOptimal + : ImageLayout.ShaderReadOnlyOptimal; + + bindings[i] = new SamplerBindingValue((uint)declared.Binding, view, sampler, resource, layout); } // A sampler the client left unbound gets the placeholder rather than @@ -1633,11 +1986,13 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources // behaviour that costs the device rather than one texture. GL is // permissive here - sampling an unbound texture reads black and the // draw proceeds - so the placeholder is also the closer emulation. - VulkanTexture? placeholder = _textures.Get(_placeholderTexture); for (int i = 0; i < bindings.Length; i++) { if (bindings[i].View.Handle != 0 && bindings[i].Sampler.Handle != 0) continue; - if (placeholder == null) break; + + VulkanTexture? placeholder = + _textures.Get(PlaceholderFor(program.Interface.Samplers[i].TypeName)); + if (placeholder == null) continue; bindings[i] = new SamplerBindingValue( bindings[i].Binding, placeholder.View, _textures.Samplers.Get(placeholder.State), @@ -1665,6 +2020,42 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources RenderTrace.Write("draw with an incomplete sampler set on program " + program.ProgramId); } } + + // Set 2: the storage buffers a shader reads its own vertices from. + // + // The SSBO chunk path does not use vertex attributes at all - the chunk + // shaders declare `readonly buffer faceDataBuf` and index it by + // gl_VertexID, with the packed face records living in the mesh's xyz + // slot. Without this set bound the shader reads nothing and the terrain + // is simply absent, which is exactly how it presented. + if (program.Interface.StorageBlocks.Count > 0 && meshId > 0) + { + var storage = new List(program.Interface.StorageBlocks.Count); + foreach (BlockBinding block in program.Interface.StorageBlocks) + { + VulkanBuffer? buffer = _meshes.BufferOf(meshId, MeshManager.BufferXyz); + if (buffer == null) continue; + + storage.Add(new BufferBindingValue( + (uint)block.Binding, buffer.Handle, 0, buffer.Size, buffer.Id)); + } + + if (storage.Count == program.Interface.StorageBlocks.Count) + { + DescriptorSet storageSet = _descriptors.Get( + new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.StorageSet, + Array.Empty(), storage.ToArray()), + program.SetLayouts[ProgramInterfaceLayout.StorageSet]); + + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.StorageSet, 1, &storageSet, 0, null); + } + else if (RenderTrace.Enabled) + { + RenderTrace.Write("draw with an incomplete storage set on program " + program.ProgramId + + " mesh " + meshId); + } + } } private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target) @@ -1703,17 +2094,47 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta } private VulkanBuffer? _indirectScratch; + private ulong _indirectCursor; + private ulong _indirectFrameUsage; + private ulong _indirectPeakFrameUsage; - private VulkanBuffer EnsureIndirectScratch(int groupCount) + /// + /// Hands out a region of the indirect-command buffer for one multi-draw. + /// + /// The commands are written on the CPU when the draw is recorded and read by + /// the GPU when it executes, which is later - after every other draw of the + /// frame has been recorded too. So each draw needs its own region: writing + /// them all at offset zero meant every multi-draw in a frame executed with + /// the ranges of whichever was recorded last, and the chunk pass is hundreds + /// of them. + /// + /// The buffer is a ring that wraps, sized to hold four times the busiest + /// frame seen, so a wrap can never reach a region a frame still in flight is + /// reading. A buffer that has to grow is deferred rather than freed, because + /// draws already recorded this frame still name it. + /// + private VulkanBuffer AllocateIndirect(int groupCount, out ulong offset) { ulong needed = (ulong)Math.Max(groupCount, 1) * (ulong)sizeof(DrawIndexedIndirectCommand); - if (_indirectScratch != null && _indirectScratch.Size >= needed) return _indirectScratch; - if (_indirectScratch != null) _frames.DeferDeletion(_indirectScratch); + _indirectFrameUsage += needed; + if (_indirectFrameUsage > _indirectPeakFrameUsage) _indirectPeakFrameUsage = _indirectFrameUsage; - _indirectScratch = new VulkanBuffer(_context, Math.Max(needed * 2, 4096), - BufferUsageFlags.IndirectBufferBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + ulong required = Math.Max(Math.Max(_indirectPeakFrameUsage * 4, needed), 256UL * 1024); + if (_indirectScratch == null || _indirectScratch.Size < required) + { + if (_indirectScratch != null) _frames.DeferDeletion(_indirectScratch); + + _indirectScratch = new VulkanBuffer(_context, required, + BufferUsageFlags.IndirectBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + _indirectCursor = 0; + } + + if (_indirectCursor + needed > _indirectScratch.Size) _indirectCursor = 0; + + offset = _indirectCursor; + _indirectCursor += needed; return _indirectScratch; } @@ -1741,8 +2162,18 @@ public void BeginOcclusionQuery(int queryId) { if (!_frameActive || !_queries.TryGetValue(queryId, out QueryPool pool)) return; - _context.Api.CmdResetQueryPool(Commands, pool, 0, 1); - _context.Api.CmdBeginQuery(Commands, pool, 0, 0); + // A query pool can only be reset outside a rendering scope, and a query + // begun inside one has to end inside that same one. So the scope closes + // for the reset and reopens before the query begins; the draw the query + // covers then finds it already open. glBeginQuery resets implicitly, so + // the per-query reset is the same cost GL pays - the scope restart is + // the extra. Batching resets at frame start would be cheaper but would + // erase results the game may still be about to read. + CommandBuffer commandBuffer = Commands; + _targets.EndRendering(commandBuffer); + _context.Api.CmdResetQueryPool(commandBuffer, pool, 0, 1); + _targets.EnsureRendering(commandBuffer); + _context.Api.CmdBeginQuery(commandBuffer, pool, 0, 0); } public void EndOcclusionQuery(int queryId) @@ -1763,22 +2194,89 @@ public bool IsQueryResultAvailable(int queryId) return status == Result.Success; } + /// + /// The samples an occlusion query counted, waiting for it like + /// glGetQueryObject does - but never forever. + /// + /// A query recorded in the current frame has not been submitted yet, and a + /// wait on it would block the very thread that will submit it. The frame is + /// flushed first, so the wait is on work the GPU actually has. If the result + /// still does not arrive, the wait gives up, says so, and reports every + /// sample as passed: for a query that gates culling, "visible" is the + /// failure that costs a few draws, and "hidden" the one that removes the + /// world. + /// public int GetQueryResult(int queryId) { if (!_queries.TryGetValue(queryId, out QueryPool pool)) return 0; + FlushFrame(); + + long deadline = System.Diagnostics.Stopwatch.GetTimestamp() + System.Diagnostics.Stopwatch.Frequency * 2; ulong result = 0; - _context.Api.GetQueryPoolResults( - _context.Device, pool, 0, 1, sizeof(ulong), &result, sizeof(ulong), - QueryResultFlags.Result64Bit | QueryResultFlags.ResultWaitBit); - return (int)Math.Min(result, int.MaxValue); + while (true) + { + Result status = _context.Api.GetQueryPoolResults( + _context.Device, pool, 0, 1, sizeof(ulong), &result, sizeof(ulong), + QueryResultFlags.Result64Bit); + if (status == Result.Success) return (int)Math.Min(result, int.MaxValue); + if (status != Result.NotReady) VulkanResult.Check(status, "vkGetQueryPoolResults"); + + if (System.Diagnostics.Stopwatch.GetTimestamp() > deadline) + { + string message = VulkanContext.ErrorPrefix + "occlusion query " + queryId + + " produced no result within two seconds of being flushed; reporting it as visible"; + _diagnostics.Add(message); + MirrorValidationMessage(message); + return int.MaxValue; + } + System.Threading.Thread.Yield(); + } + } + + /// + /// Submits everything the frame has recorded so far and continues it in the + /// next slot, so a result read on the CPU - a query, a pixel readback - can + /// see work the frame already issued. Presenting instead would end the frame, + /// and every draw after the read would be dropped. + /// + private void FlushFrame() + { + if (!_frameActive) return; + + _targets.EndRendering(Commands); + _frames.EndFrame(); + _frames.BeginFrame(); + _frameCounter++; + Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); } public void DeleteQuery(int queryId) { if (_queries.Remove(queryId, out QueryPool pool)) { - _context.Api.DestroyQueryPool(_context.Device, pool, null); + // A frame still executing may be writing this pool; it goes through + // the ring like any other resource a recorded command can name. + _frames.DeferDeletion(new QueryPoolRelease(_context, pool)); + } + } + + private sealed class QueryPoolRelease : IDisposable + { + private readonly VulkanContext _context; + private QueryPool _pool; + + public QueryPoolRelease(VulkanContext context, QueryPool pool) + { + _context = context; + _pool = pool; + } + + public void Dispose() + { + if (_pool.Handle == 0) return; + _context.Api.DestroyQueryPool(_context.Device, _pool, null); + _pool = default; } } @@ -1793,6 +2291,62 @@ public void DeleteQuery(int queryId) /// backend never flips Y, the image in memory is laid out exactly as GL laid /// it out, so those paths keep working untouched. /// + /// + /// Reads back every texture OPTIMUM_DUMP_TEXTURES asked for. Debug only; see + /// for why it exists. + /// + private void DumpRequestedTextures() + { + foreach (int textureId in TextureDump.Take()) + { + VulkanTexture? texture = _textures.Get(textureId); + if (texture == null) + { + RenderTrace.Write("texture dump: no texture " + textureId); + continue; + } + + int width = (int)texture.Width; + int height = (int)texture.Height; + ulong bytes = (ulong)width * (ulong)height * 4; + + FlushFrame(); + _context.Api.DeviceWaitIdle(_context.Device); + + using var readback = new VulkanBuffer(_context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + ImageLayout restore = texture.Layout; + _setupCommands.SubmitAndWait(commandBuffer => + { + _textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D((uint)width, (uint)height, 1), + }; + _context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + bool bgra = texture.Format is Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb; + string? written = TextureDump.Write(textureId, width, height, bgra, + new ReadOnlySpan((void*)readback.Mapped, (int)bytes)); + + RenderTrace.Write("texture dump: " + textureId + " " + width + "x" + height + + " " + texture.Format + " mips=" + texture.MipLevels + " -> " + (written ?? "failed")); + + if (restore != ImageLayout.Undefined) + { + _setupCommands.SubmitAndWait(commandBuffer => + _textures.TransitionTexture(commandBuffer, texture, restore)); + } + } + } + public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) { if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; @@ -1803,9 +2357,11 @@ public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr d VulkanTexture? texture = _textures.Get(target.Color[0].TextureId); if (texture == null) return; - // Readback has to see finished work, so any recording frame is closed - // out first rather than racing it. - if (_frameActive) Present(); + // Readback has to see finished work, so what the frame has recorded is + // submitted first. Flushing rather than presenting keeps the frame open: + // the game reads pixels mid-frame and carries on drawing, and a present + // here silently dropped everything it drew afterwards. + FlushFrame(); _context.Api.DeviceWaitIdle(_context.Device); ulong bytes = (ulong)width * (ulong)height * 4; diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch new file mode 100644 index 00000000..def18840 --- /dev/null +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch @@ -0,0 +1,173 @@ +diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs +index 4252128..b43c07b 100644 +--- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs ++++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs +@@ -219,10 +219,22 @@ namespace FluffyClouds { + + return success; + } + + void FreeGlResources(){ ++ // Optimum: the device owns these on its own backend, where the GL ++ // binding does not exist and the raw calls throw. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.DeleteTexture(TextureData1); ++ optimumDevice.DeleteTexture(TextureData2); ++ optimumDevice.DeleteTexture(TextureMap); ++ optimumDevice.DeleteTexture(TextureCol); ++ optimumDevice.DeleteFramebuffer(Framebuffer); ++ return; ++ } + + GL.DeleteTexture(TextureData1); + GL.DeleteTexture(TextureData2); + GL.DeleteTexture(TextureMap); + GL.DeleteTexture(TextureCol); +@@ -336,15 +348,25 @@ namespace FluffyClouds { + (float)(((double)committedState.CenterTilePos.Z * CloudTileSize - capi.World.DefaultSpawnPosition.Z + windOffsetZ) / CloudTileSize) + ); + + matrix.Set(capi.Render.CameraMatrixOriginf); + +- int fb; +- GL.GetInteger(GetPName.FramebufferBinding, out fb); +- ++ // Optimum: the device keeps no queryable binding to read back, so the ++ // target the API considers current is what gets restored afterwards. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ FrameBufferRef optimumSavedTarget = null; ++ int fb = 0; + int[] vp = new int[4]; +- GL.GetInteger(GetPName.Viewport, vp); ++ if (optimumDevice != null) ++ { ++ optimumSavedTarget = capi.Render.CurrentFrameBuffer; ++ } ++ else ++ { ++ GL.GetInteger(GetPName.FramebufferBinding, out fb); ++ GL.GetInteger(GetPName.Viewport, vp); ++ } + + prog.Use(); + + prog.Uniform("dayLight", Math.Max(0, capi.World.Calendar.DayLightStrength - capi.World.Calendar.MoonLightStrength*0.95f)); + prog.Uniform("globalCloudBrightness", capi.Ambient.BlendedCloudBrightness); +@@ -362,12 +384,45 @@ namespace FluffyClouds { + prog.BindTexture2D("mapData2", TextureData2, 9); + prog.UniformMatrix("viewMatrix", matrix.Values); + + prog.Uniform("pointLightQuantity", capi.Render.ShaderUniforms.PointLightsCount); + if(capi.Render.ShaderUniforms.PointLightsCount > 0){ +- GL.Uniform3(GL.GetUniformLocation(programId, "pointLights"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLights3); +- GL.Uniform3(GL.GetUniformLocation(programId, "pointLightColors"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLightColors3); ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetUniformArray3(programId, optimumDevice.GetUniformLocation(programId, "pointLights"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLights3); ++ optimumDevice.SetUniformArray3(programId, optimumDevice.GetUniformLocation(programId, "pointLightColors"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLightColors3); ++ } ++ else ++ { ++ GL.Uniform3(GL.GetUniformLocation(programId, "pointLights"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLights3); ++ GL.Uniform3(GL.GetUniformLocation(programId, "pointLightColors"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLightColors3); ++ } ++ } ++ ++ if (optimumDevice != null) ++ { ++ optimumDevice.BindFramebuffer(Framebuffer); ++ optimumDevice.SetViewport(0, 0, CloudTileLength, CloudTileLength); ++ optimumDevice.SetBlend(false, EnumBlendMode.Standard); ++ optimumDevice.SetDepthTest(false); ++ ++ capi.Render.RenderMesh(quad); ++ ++ optimumDevice.SetDepthTest(true); ++ optimumDevice.SetBlend(true, EnumBlendMode.Standard); ++ if (optimumSavedTarget != null) ++ { ++ optimumDevice.BindFramebuffer(optimumSavedTarget.FboId); ++ optimumDevice.SetViewport(0, 0, optimumSavedTarget.Width, optimumSavedTarget.Height); ++ } ++ else ++ { ++ optimumDevice.BindDefaultFramebuffer(); ++ optimumDevice.SetViewport(0, 0, capi.Render.FrameWidth, capi.Render.FrameHeight); ++ } ++ prog.Stop(); ++ return; + } + + GL.BindFramebuffer(FramebufferTarget.Framebuffer, Framebuffer); + GL.Viewport(0, 0, CloudTileLength, CloudTileLength); + GL.Disable(EnableCap.Blend); +@@ -399,18 +454,41 @@ namespace FluffyClouds { + TextureDataBuffer1[j * 4 + 2] = tile.CloudOpaqueness; + TextureDataBuffer1[j * 4 + 3] = tile.Brightness; + TextureDataBuffer2[j * 4] = tile.UndulatingCloudMode; + } + ++ // GL converts the signed-short source to normalized RGBA16 storage. ++ // Copying the bits directly would turn full density (32767) into 0.5. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.UploadTexture2DNormalizedShorts(TextureData1, 0, 0, 0, CloudTileLength, CloudTileLength, TextureDataBuffer1); ++ optimumDevice.UploadTexture2DNormalizedShorts(TextureData2, 0, 0, 0, CloudTileLength, CloudTileLength, TextureDataBuffer2); ++ return; ++ } ++ + GL.BindTexture(TextureTarget.Texture2D, TextureData1); + GL.TexSubImage2D(TextureTarget.Texture2D, 0, 0, 0, CloudTileLength, CloudTileLength, PixelFormat.Rgba, PixelType.Short, TextureDataBuffer1); + GL.BindTexture(TextureTarget.Texture2D, TextureData2); + GL.TexSubImage2D(TextureTarget.Texture2D, 0, 0, 0, CloudTileLength, CloudTileLength, PixelFormat.Rgba, PixelType.Short, TextureDataBuffer2); + + } + + int makeTexture(int width, PixelInternalFormat internalFormat, PixelFormat format, PixelType type){ ++ // Optimum: the device takes the GL internal-format token directly and ++ // maps it; the sampling parameters are the GL tokens as well. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ int optimumTexture = optimumDevice.CreateTexture2DRaw(width, width, (int)internalFormat, IntPtr.Zero, 0); ++ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); ++ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); ++ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); ++ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); ++ return optimumTexture; ++ } ++ + int texture = GL.GenTexture(); + GL.BindTexture(TextureTarget.Texture2D, texture); + GL.TexImage2D(TextureTarget.Texture2D, 0, internalFormat, width, width, 0, format, type, 0); + GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); + GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); +@@ -453,10 +531,25 @@ namespace FluffyClouds { + TextureData2 = makeTexture(CloudTileLength, PixelInternalFormat.Rgba16, PixelFormat.Rgba, PixelType.Short); + + TextureDataBuffer1 = new short[CloudTileLength * CloudTileLength * 4]; + TextureDataBuffer2 = new short[CloudTileLength * CloudTileLength * 4]; + ++ // Optimum: the device's framebuffer objects are created and attached ++ // without binding anything, so nothing needs saving or restoring. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ TextureMap = makeTexture(CloudTileLength, PixelInternalFormat.Rgba32f, PixelFormat.Rgba, PixelType.Float); ++ TextureCol = makeTexture(CloudTileLength, PixelInternalFormat.Rgba32f, PixelFormat.Rgba, PixelType.Float); ++ ++ Framebuffer = optimumDevice.CreateFramebuffer(CloudTileLength, CloudTileLength); ++ optimumDevice.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment0, TextureMap, 0); ++ optimumDevice.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment1, TextureCol, 0); ++ optimumDevice.SetDrawBuffers(Framebuffer, 0b11); ++ return; ++ } ++ + int fb; + GL.GetInteger(GetPName.FramebufferBinding, out fb); + + TextureMap = makeTexture(CloudTileLength, PixelInternalFormat.Rgba32f, PixelFormat.Rgba, PixelType.Float); + diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch new file mode 100644 index 00000000..0b8eaad2 --- /dev/null +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch @@ -0,0 +1,30 @@ +diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs +index ce8dd81..7a0905b 100644 +--- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs ++++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs +@@ -73,10 +73,25 @@ namespace FluffyClouds { + program.Uniform("PerceptionEffectIntensity", capi.Render.ShaderUniforms.PerceptionEffectIntensity); + program.BindTexture2D("depthTex", capi.Render.FrameBuffers[(int)EnumFrameBuffer.Primary].DepthTextureId, 0); + program.BindTexture2D("cloudMap", map.TextureMap, 8); + program.BindTexture2D("cloudCol", map.TextureCol, 9); + ++ // Optimum: state goes through the device on its own backend, where ++ // the GL binding does not exist. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetDepthTest(false); ++ optimumDevice.SetBlend(true, EnumBlendMode.Standard); ++ ++ capi.Render.RenderMesh(quad); ++ ++ optimumDevice.SetDepthTest(true); ++ program.Stop(); ++ return; ++ } ++ + GL.Disable(EnableCap.DepthTest); + GL.Enable(EnableCap.Blend); + + capi.Render.RenderMesh(quad); + diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch new file mode 100644 index 00000000..83015110 --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -0,0 +1,27 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +index d6eb844..1482393 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +@@ -331,10 +331,21 @@ public abstract class ClientPlatformAbstract + + public static void DisposeIndexBuffer() + { + if (singleIndexBufferId != 0) + { +- GL.DeleteBuffer(singleIndexBufferId); ++ // Mono.Cecil transplant. ++ // Shutdown runs this after the GL binding is gone on the device path, ++ // where the raw call throws rather than freeing anything. ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.DeleteMesh(singleIndexBufferId); ++ } ++ else ++ { ++ GL.DeleteBuffer(singleIndexBufferId); ++ } + singleIndexBufferId = 0; + } + } + } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index ee8032aa..9ef9499c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..5500eda 100644 +index 6edf0c9..8824d24 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -209,7 +209,36 @@ index 6edf0c9..5500eda 100644 if (!supportsGlDebugMode) { throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -478,41 +628,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -335,11 +485,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + } + glDebugMode = value; + } + } + +- public override bool GlScissorFlagEnabled => GL.IsEnabled((EnableCap)3089); ++ // Mono.Cecil transplant (member injection + get_GlScissorFlagEnabled). ++ // The device takes the scissor flag as a call argument and keeps no ++ // queryable state, so the routed setter remembers it here for the getter, ++ // which the runtime atlas upload reads to restore the flag afterwards. ++ private bool optimumScissorEnabled; ++ ++ public override bool GlScissorFlagEnabled ++ { ++ get ++ { ++ if (Vintagestory.API.Config.OptimumRender.Device != null) ++ { ++ return optimumScissorEnabled; ++ } ++ return GL.IsEnabled((EnableCap)3089); ++ } ++ } + + public override string CurrentMouseCursor { get; protected set; } + + public override bool MouseGrabbed + { +@@ -478,41 +644,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -356,7 +385,7 @@ index 6edf0c9..5500eda 100644 public void LogAndTestHardwareInfosStage1() { -@@ -533,10 +784,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -533,10 +800,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } @@ -394,7 +423,7 @@ index 6edf0c9..5500eda 100644 logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +854,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -576,10 +870,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CheckGlError("testhwinfo"); } @@ -413,7 +442,7 @@ index 6edf0c9..5500eda 100644 public override string GetFrameworkInfos() { -@@ -702,10 +988,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,10 +1004,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -435,7 +464,7 @@ index 6edf0c9..5500eda 100644 SupportsThickLines = (int)error != 1281; cpuCoreCount = Environment.ProcessorCount; } -@@ -796,10 +1093,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1109,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -459,7 +488,7 @@ index 6edf0c9..5500eda 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1326,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1342,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -497,7 +526,7 @@ index 6edf0c9..5500eda 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1369,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1385,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -517,7 +546,7 @@ index 6edf0c9..5500eda 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1400,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1416,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -534,7 +563,7 @@ index 6edf0c9..5500eda 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1445,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1461,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -567,7 +596,7 @@ index 6edf0c9..5500eda 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1509,299 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1525,324 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -734,15 +763,22 @@ index 6edf0c9..5500eda 100644 + + list[5] = CreateOptimumDepthTarget(device, width / 4, height / 4); + ++ // Both shadow slots always hold a FrameBufferRef, exactly as the GL path ++ // does: vanilla constructs the objects unconditionally and only allocates ++ // their textures when the quality setting reaches each level. ++ // ++ // The distinction matters because ShaderProgramBase.Use dereferences both ++ // FrameBuffers[11] and FrameBuffers[12] whenever shadowmapQuality > 0, ++ // and every shader including fogandlight.fsh - sky.fsh among them - takes ++ // that branch. Leaving slot 12 null at quality 1 is a null reference on ++ // the first sky draw, which is what it was. + int shadowSize = Math.Max(4, ShadowMapQuality + 2) * 1024; -+ if (ShadowMapQuality > 0) -+ { -+ list[11] = CreateOptimumDepthTarget(device, shadowSize, shadowSize); -+ } -+ if (ShadowMapQuality > 1) -+ { -+ list[12] = CreateOptimumDepthTarget(device, shadowSize, shadowSize); -+ } ++ list[11] = ShadowMapQuality > 0 ++ ? CreateOptimumDepthTarget(device, shadowSize, shadowSize) ++ : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); ++ list[12] = ShadowMapQuality > 1 ++ ? CreateOptimumDepthTarget(device, shadowSize, shadowSize) ++ : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); + + // The fullscreen quad. The device generates its three vertices in the + // shader and binds nothing, but the field is public enough that other @@ -854,6 +890,24 @@ index 6edf0c9..5500eda 100644 + device.SetDrawBuffers(target.FboId, 0); + return target; + } ++ ++ /// ++ /// A framebuffer slot that exists but owns nothing, for a quality level whose ++ /// resources are not allocated. ++ /// ++ /// The GL path leaves such a slot holding a FrameBufferRef whose ids are zero; ++ /// callers read its Width and Height and bind its texture id, and binding zero ++ /// is a no-op there. The device treats texture 0 as unbound and substitutes ++ /// its placeholder, so the same read is equally harmless here. ++ /// ++ private FrameBufferRef CreateOptimumPlaceholderTarget(int width, int height) ++ { ++ FrameBufferRef target = new FrameBufferRef(); ++ target.Width = width; ++ target.Height = height; ++ target.ColorTextureIds = new int[0]; ++ return target; ++ } + public List SetupDefaultFrameBuffers() { @@ -867,7 +921,7 @@ index 6edf0c9..5500eda 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1210,11 +1856,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +1897,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -884,7 +938,7 @@ index 6edf0c9..5500eda 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1436,10 +2086,33 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2127,33 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -918,7 +972,7 @@ index 6edf0c9..5500eda 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1569,10 +2242,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1569,10 +2283,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); } @@ -948,7 +1002,7 @@ index 6edf0c9..5500eda 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2283,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2324,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -977,7 +1031,7 @@ index 6edf0c9..5500eda 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2317,74 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,21 +2358,74 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1052,7 +1106,7 @@ index 6edf0c9..5500eda 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1670,20 +2432,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2473,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1093,7 +1147,7 @@ index 6edf0c9..5500eda 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2475,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2516,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1160,7 +1214,7 @@ index 6edf0c9..5500eda 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2545,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2586,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1182,7 +1236,7 @@ index 6edf0c9..5500eda 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2569,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2610,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1209,7 +1263,7 @@ index 6edf0c9..5500eda 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,15 +2594,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,15 +2635,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1248,7 +1302,7 @@ index 6edf0c9..5500eda 100644 transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; -@@ -1823,10 +2643,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,10 +2684,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1264,7 +1318,7 @@ index 6edf0c9..5500eda 100644 if (RenderBloom) { GlToggleBlend(on: false); -@@ -1848,45 +2673,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +2714,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1324,7 +1378,7 @@ index 6edf0c9..5500eda 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,11 +2750,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,11 +2791,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1337,7 +1391,7 @@ index 6edf0c9..5500eda 100644 { LoadFrameBuffer(EnumFrameBuffer.Luma); ShaderProgramLuma luma = ShaderPrograms.Luma; -@@ -1935,11 +2770,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1935,11 +2811,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract blit.Use(); blit.Scene2D = frameBuffers[0].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); @@ -1359,7 +1413,7 @@ index 6edf0c9..5500eda 100644 } public override void RenderFinalComposition() -@@ -1953,13 +2797,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,13 +2838,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1388,7 +1442,7 @@ index 6edf0c9..5500eda 100644 final.Use(); final.PrimaryScene2D = primaryScene2D; final.BloomParts2D = bloomParts2D; -@@ -1987,23 +2844,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +2885,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1429,7 +1483,7 @@ index 6edf0c9..5500eda 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +2895,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,19 +2936,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -1508,7 +1562,7 @@ index 6edf0c9..5500eda 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3013,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3054,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -1534,7 +1588,7 @@ index 6edf0c9..5500eda 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3046,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3087,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -1556,7 +1610,7 @@ index 6edf0c9..5500eda 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3075,96 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3116,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -1645,6 +1699,7 @@ index 6edf0c9..5500eda 100644 + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) + { ++ optimumScissorEnabled = enable; + optimumDevice.SetScissorEnabled(enable); + return; + } @@ -1653,7 +1708,7 @@ index 6edf0c9..5500eda 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3173,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,36 +3215,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1742,7 +1797,7 @@ index 6edf0c9..5500eda 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3289,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2233,33 +3331,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1814,7 +1869,7 @@ index 6edf0c9..5500eda 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3367,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +3409,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1981,7 +2036,7 @@ index 6edf0c9..5500eda 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +3537,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +3579,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2005,7 +2060,7 @@ index 6edf0c9..5500eda 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +3566,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +3608,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2043,7 +2098,7 @@ index 6edf0c9..5500eda 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +3626,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +3668,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2086,7 +2141,7 @@ index 6edf0c9..5500eda 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +3679,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +3721,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2145,7 +2200,7 @@ index 6edf0c9..5500eda 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +3794,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +3836,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2174,10 +2229,15 @@ index 6edf0c9..5500eda 100644 + optimumDevice.DeleteTexture(intoTexture.TextureId); + } + // The mip chain has to be requested at creation; asking for -+ // mipmaps afterwards on a one-level image does nothing. ++ // mipmaps afterwards on a one-level image does nothing. GL ++ // can grow one at any time, which is what the deferred ++ // variant relies on: it uploads with makeMipMap false and ++ // the atlas manager calls BuildMipMaps later, in StageB. So ++ // the chain is sized whenever mipmapping is on at all, and ++ // makeMipMap only decides whether to fill it here. + intoTexture.TextureId = optimumDevice.CreateTexture2DRaw( + intoTexture.Width, intoTexture.Height, optimumGlFormat, -+ optimumPin.AddrOfPinnedObject(), 4, makeMipMap); ++ optimumPin.AddrOfPinnedObject(), 4, ENABLE_MIPMAPS); + + if (clampMode == 1) + { @@ -2214,22 +2274,29 @@ index 6edf0c9..5500eda 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +3887,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +3934,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } public override void BuildMipMaps(int textureId) { + // Mono.Cecil transplant. -+ // GL raises the max level, generates, then clamps back to the configured -+ // level. The device sizes the mip chain when the image is created, so -+ // only the generate itself carries over. ++ // The device sizes the mip chain when the image is created, so the ++ // generate carries over as it is. The two glTexParameter calls carry ++ // over as well, and they are not decoration: GL_LINEAR means "level 0 ++ // only" whatever the chain holds, so a texture that is never moved to a ++ // MIPMAP filter is never minified through one. Vulkan has no such ++ // filter, and the device turns these two into the sampler's LOD clamp. + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) + { + if (ENABLE_MIPMAPS) + { + optimumDevice.GenerateMipmaps(textureId); ++ optimumDevice.SetTextureParameter(textureId, ++ Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9986); ++ optimumDevice.SetTextureParameter(textureId, ++ Vintagestory.API.Config.OptimumGlConstants.TextureMaxLevel, ClientSettings.MipMapLevel); + } + return; + } @@ -2238,7 +2305,7 @@ index 6edf0c9..5500eda 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +3917,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +3971,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2273,7 +2340,7 @@ index 6edf0c9..5500eda 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +3964,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4018,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2302,7 +2369,7 @@ index 6edf0c9..5500eda 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +3995,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4049,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2326,7 +2393,7 @@ index 6edf0c9..5500eda 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4032,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4086,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -2343,7 +2410,7 @@ index 6edf0c9..5500eda 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4084,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4138,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2388,7 +2455,7 @@ index 6edf0c9..5500eda 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4121,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4175,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2409,7 +2476,7 @@ index 6edf0c9..5500eda 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4140,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4194,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2430,7 +2497,7 @@ index 6edf0c9..5500eda 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4159,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4213,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2451,7 +2518,7 @@ index 6edf0c9..5500eda 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4178,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4232,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2472,7 +2539,7 @@ index 6edf0c9..5500eda 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4201,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4255,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2493,7 +2560,7 @@ index 6edf0c9..5500eda 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4244,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4298,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -2519,7 +2586,7 @@ index 6edf0c9..5500eda 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +4486,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +4540,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -2541,7 +2608,7 @@ index 6edf0c9..5500eda 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +4684,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +4738,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -2564,7 +2631,7 @@ index 6edf0c9..5500eda 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +4758,35 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +4812,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -2581,6 +2648,15 @@ index 6edf0c9..5500eda 100644 + Vintagestory.API.Config.IOptimumGraphicsDevice optimumSsboDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumSsboDevice != null) + { ++ // Everything the mesh carries as ordinary vertex data goes first, the ++ // same call UpdateMesh makes. The packed face records follow, because ++ // they occupy the xyz slot and have to be what remains there. ++ // ++ // The device path returns here rather than falling through: the ++ // updateVAO calls below address VBO names directly and would reach ++ // OpenGL, which is not bound on this backend. ++ optimumSsboDevice.UpdateMesh(vAO.VaoId, data); ++ + GCHandle optimumFacePin = GCHandle.Alloc(facedataBuffer, GCHandleType.Pinned); + try + { @@ -2591,6 +2667,7 @@ index 6edf0c9..5500eda 100644 + { + optimumFacePin.Free(); + } ++ return; + } + else + { @@ -2603,7 +2680,7 @@ index 6edf0c9..5500eda 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +4818,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +4882,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -2635,7 +2712,7 @@ index 6edf0c9..5500eda 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +4848,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +4912,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -2661,7 +2738,7 @@ index 6edf0c9..5500eda 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5196,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5260,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -2691,7 +2768,7 @@ index 6edf0c9..5500eda 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5250,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5314,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index 6a4f68ff..9e71bb00 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -18,6 +18,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkTesselatorManager.cs.patc patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientChunk.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientCoreAPI.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSettings.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 94017510..274f290f 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -209,6 +209,14 @@ fi if [[ "$EXTRACTED_FRESH" == "1" ]]; then cp -f "$VANILLA_DIR/VintagestoryLib.dll" "$VANILLA_DIR/VintagestoryLib.vanilla.dll" fi +# The patcher reads symbols from .pdb and only rewrites them if it found +# them. The pristine copy is renamed, so without this its symbols are invisible, +# the patched library ships with the untouched vanilla .pdb beside it, and every +# stack trace from a patched method comes out with no line numbers - or worse, +# lines belonging to different code. +if [[ -f "$VANILLA_DIR/VintagestoryLib.pdb" && ! -f "$VANILLA_DIR/VintagestoryLib.vanilla.pdb" ]]; then + cp -f "$VANILLA_DIR/VintagestoryLib.pdb" "$VANILLA_DIR/VintagestoryLib.vanilla.pdb" +fi VANILLA_LIB="$VANILLA_DIR/VintagestoryLib.vanilla.dll" if [[ ! -f "$VANILLA_LIB" ]]; then echo "Error: pristine vanilla VintagestoryLib.vanilla.dll not found in $VANILLA_DIR. Delete the matching .vanilla cache and re-run packaging." >&2 @@ -259,6 +267,21 @@ cp -f "$BUILD_OUT/Vintagestory.dll" "$STAGE_DIR/" cp -f "$BUILD_OUT/Vintagestory.runtimeconfig.json" "$STAGE_DIR/Vintagestory.runtimeconfig.json" cp -f "$PATCHED_LIB" "$STAGE_DIR/VintagestoryLib.dll" cp -f "$PATCHED_API" "$STAGE_DIR/VintagestoryAPI.dll" + +# Symbols have to match the assembly they sit beside. The staged tree came from +# the vanilla install and still holds its .pdb files, which describe different +# IL; replacing them is what puts line numbers back into crash reports, and +# removing them is better than leaving ones that lie. +for symbols in "VintagestoryLib:$LIB_OUT/VintagestoryLib-patched.pdb" \ + "VintagestoryAPI:$LIB_OUT/VintagestoryAPI-patched.pdb"; do + name="${symbols%%:*}" + built="${symbols#*:}" + if [[ -f "$built" ]]; then + cp -f "$built" "$STAGE_DIR/$name.pdb" + else + rm -f "$STAGE_DIR/$name.pdb" + fi +done cp -f "$MOD_OUT/Optimum.Api.Contracts.dll" "$STAGE_DIR/" cp -f "$MOD_OUT/VSEssentials.dll" "$STAGE_DIR/Mods/" cp -f "$MOD_OUT/VSSurvivalMod.dll" "$STAGE_DIR/Mods/" From 55dd89e5ad4540842b4f9665991946ad6736509b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 13:44:11 +0200 Subject: [PATCH 005/226] fix(render): address PR #1 review findings and route the map page cache Review findings (CodeRabbit, VulkanStory PR #1): - FrameRing: round each per-slot uniform region to the device alignment - GlEnums: GL_DEPTH_COMPONENT32F is 0x8CAC - GlStateTracker: key the cached blend id on the attachment count - Swapchain: own the surface and destroy it on every early failure path - ProgramInterfaceLayout: move user blocks off set 0 binding 0; report unmodeled uniform types - ShaderRewriter: remap depth before every EmitVertex in geometry stages - ShaderCompiler: prepend the prefix when there is no #version - GlslReservedWords: rename member accesses like declarations - ShaderTranslator: error when no stage survives - RenderTargetManager: restart the scope when an attachment changes - VulkanResources: release fence and command buffer on throw - MeshManager: trace truncated indirect draws; VertexLayout: unsigned formats - TextureManager: check BindImageMemory / CreateImageView results - TextureDump: keep requests pending until the write succeeds - Seam: SetUniform(ivec3) instead of offset arithmetic in ShaderProgramBase; add the UploadTexture2DNormalizedShorts and TextureMaxLevel members the device and cloud patch already used - Patches: Arc advisory gated to OpenGL, NoGraphicsApiWindow reset on the GL fallback, cloud blend restore, ShaderRegistry helper, case-insensitive renderer config, duplicate patcher targets, InteropProbe WGL sentinels - Boat water surface: route GL.DrawBuffers through the device - Tests: shared validation filter, SSBO chunk path draws and reads back, new alignment / blend-count / geometry-remap / binding tests Optimum's world-map page cache now goes through the device as well: the page array is created and uploaded per layer via a new UploadTexture2DArrayLayer seam member, the instanced quad travels through the engine mesh API, and BC7 reports unsupported on Vulkan. --- Optimum.Patcher/Program.cs | 2 - .../ChunkRenderPathTests.cs | 73 +++++-- .../ChunkTerrainRenderTests.cs | 1 - Optimum.Render.Vulkan.Tests/FrameRingTests.cs | 29 +++ .../GlStateTrackerTests.cs | 31 ++- .../MeshManagerTests.cs | 14 +- .../PipelineCacheTests.cs | 21 +- .../RenderTargetTests.cs | 18 +- .../ShaderTranslationUnitTests.cs | 86 +++++++- .../TextureManagerTests.cs | 4 +- .../ValidationAssert.cs | 28 +++ .../VertexAttributeDefaultTests.cs | 12 +- .../WorldRenderPathTests.cs | 20 +- Optimum.Render.Vulkan/Core/FrameRing.cs | 5 +- Optimum.Render.Vulkan/Core/GlEnums.cs | 2 +- Optimum.Render.Vulkan/Core/GlStateTracker.cs | 11 +- Optimum.Render.Vulkan/Core/MeshManager.cs | 11 +- .../Core/RenderTargetManager.cs | 4 + .../Core/ShaderProgramResources.cs | 5 - Optimum.Render.Vulkan/Core/Swapchain.cs | 11 + Optimum.Render.Vulkan/Core/TextureDump.cs | 23 +- Optimum.Render.Vulkan/Core/TextureManager.cs | 14 +- Optimum.Render.Vulkan/Core/VertexLayout.cs | 12 +- Optimum.Render.Vulkan/Core/VulkanResources.cs | 72 ++++--- Optimum.Render.Vulkan/Core/WindowSurface.cs | 14 ++ .../Shaders/GlslReservedWords.cs | 14 -- .../Shaders/ProgramInterfaceLayout.cs | 7 + .../Shaders/ShaderCompiler.cs | 4 +- .../Shaders/ShaderRewriter.cs | 45 +++- .../Shaders/ShaderTranslator.cs | 6 + Optimum.Render.Vulkan/VulkanDevice.cs | 16 +- VULKAN-BACKEND-PLAN.md | 8 +- .../CloudRendererVolumetric.cs.patch | 5 +- .../BehaviorHideWaterSurface.cs.patch | 48 +++++ .../ClientSystemStartup.cs.patch | 8 +- .../ShaderProgramBase.cs.patch | 26 +-- .../ShaderRegistry.cs.patch | 58 +++-- .../ClientProgram.cs.patch | 5 +- .../WorldMap/ChunkLayer/OptimumBc7Support.cs | 13 ++ .../ChunkLayer/OptimumMapPageRenderer.cs | 203 +++++++++--------- .../ChunkLayer/OptimumMapTextureArray.cs | 32 ++- .../Client/optimum-render-device.cs | 20 +- .../VintagestoryApi/Config/OptimumConfig.cs | 11 +- tools/InteropProbe/Program.cs | 22 +- 44 files changed, 731 insertions(+), 343 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/ValidationAssert.cs create mode 100644 patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 842c0e01..dfcad5ab 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -595,7 +595,6 @@ // The scissor flag is read back by the runtime atlas upload; the device // keeps no queryable state, so the routed setter remembers it. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "get_GlScissorFlagEnabled", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlScissorFlag", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateFramebuffer", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffer", 2), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffers", 1), @@ -628,7 +627,6 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateTextureFromPixels", 6), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Load3DTextureCube", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGenerateTex2DMipmaps", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BindTexture2d", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UnBindTextureCubeMap", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlClearColorRgbaf", 4), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "SmoothLines", 1), diff --git a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs index 8024eb99..6370f514 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -158,7 +158,7 @@ public void TheRealChunkProgramTranslatesAndBuildsAPipeline() } Assert.Equal(4, built); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -256,7 +256,7 @@ public void AWorldProgramBuildsAPipelineAgainstItsMeshLayout(string programName) }); Assert.NotEqual((ulong)0, pipeline.Handle); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -300,7 +300,9 @@ public unsafe void TheSsboChunkPathUploadsFaceRecordsAndDraws() // The record the client writes: an origin and two edge offsets, in // the same layout FaceData uses. - float[] face = { -1f, -1f, 0f, 2f }; + // Quad from (-1,-1) to (0,0): covers the lower-left quadrant only, + // so the upper-right quadrant stays the clear colour. + float[] face = { -1f, -1f, 0f, 1f }; int[] indices = { 0, 1, 2, 0, 2, 3 }; fixed (float* f = face) @@ -369,7 +371,56 @@ void main(void) }); Assert.NotEqual((ulong)0, pipeline.Handle); - AssertNoValidationErrors(messages); + using var descriptors = new DescriptorCache(context!); + VulkanBuffer faceBuffer = meshes.BufferOf(mesh, MeshManager.BufferXyz)!; + BlockBinding storageBlock = Assert.Single(program.Interface.StorageBlocks); + DescriptorSet storageSet = descriptors.Get( + new DescriptorSetContents(1, ProgramInterfaceLayout.StorageSet, + Array.Empty(), + new[] + { + new BufferBindingValue( + (uint)storageBlock.Binding, faceBuffer.Handle, 0, faceBuffer.Size, faceBuffer.Id), + }), + program.SetLayouts[ProgramInterfaceLayout.StorageSet]); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.ClearColor(commandBuffer, 0, 0f, 0f, 0f, 1f); + targets.EnsureRendering(commandBuffer); + + Vk api = context!.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + DescriptorSet boundStorageSet = storageSet; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.StorageSet, 1, &boundStorageSet, 0, null); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + SetDynamicDefaults(api, commandBuffer); + + meshes.Draw(commandBuffer, mesh); + targets.EndRendering(commandBuffer); + }); + + byte[] pixels = ReadTexture(context!, commands, textures, target, size); + + // Covered by the quad, drawn green; the opposite corner is not, and + // must still show the black clear colour untouched. + byte[] covered = PixelAt(pixels, size, 2, 2); + Assert.Equal(0, covered[0]); + Assert.Equal(255, covered[1]); + Assert.Equal(0, covered[2]); + + byte[] uncovered = PixelAt(pixels, size, size - 3, size - 3); + Assert.Equal(0, uncovered[0]); + Assert.Equal(0, uncovered[1]); + Assert.Equal(0, uncovered[2]); + + ValidationAssert.NoErrors(messages); } } @@ -500,7 +551,7 @@ void main(void) Assert.Equal(255, PixelAt(pixels, size, 9, 9)[0]); Assert.Equal(255, PixelAt(pixels, size, 9, 9)[2]); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -553,16 +604,4 @@ private static unsafe byte[] ReadTexture( return result; } - private static void AssertNoValidationErrors(List messages) - { - // Only what the layers reported at error severity. Advisories - a - // fragment output with no attachment, say - are prefixed as warnings and - // are not failures; treating every message as one made these assertions - // fire on notes about correct frames. - var errors = messages - .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, - StringComparison.Ordinal)) - .ToList(); - Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); - } } diff --git a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs index b117db84..5218eb47 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs @@ -385,7 +385,6 @@ public void TheShadowMapProgramDrawsTerrainIntoADepthOnlyTarget() // ------------------------------------------------------------------ helpers - /// The magenta the target was cleared to, within 8-bit rounding. /// /// The path the world actually renders through: an SSBO pool, one packed /// face record in its storage slot, the fixed quad index pattern, the storage diff --git a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs index f7221ea2..20dd8139 100644 --- a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs @@ -161,6 +161,35 @@ public void UniformAllocationsRespectTheDeviceAlignmentAndTheRegionBound() } } + /// + /// Slot 0 starts at offset 0 and is aligned for free. Later slots start at + /// a multiple of the region size, so the region itself has to be rounded to + /// the uniform alignment or every dynamic offset from those slots is off. + /// Three frames in flight with a ring size that 3 * alignment does not + /// divide is exactly the case the third slot introduces. + /// + [SkippableFact] + public void LaterSlotsHandOutAlignedOffsetsWhenTheRingDoesNotDivideEvenly() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + ulong alignment = context!.Capabilities.MinUniformBufferOffsetAlignment; + using var ring = new FrameRing(context, framesInFlight: 3, uniformRingSize: 64 * 1024 + 1); + + for (int frame = 0; frame < 3; frame++) + { + FrameSlot slot = ring.BeginFrame(); + Assert.True(slot.TryAllocateUniforms(100, out RingAllocation allocation)); + Assert.True(allocation.Offset % alignment == 0, + $"frame {frame}: offset {allocation.Offset} is not aligned to {alignment}"); + ring.EndFrame(); + } + + context.Api.DeviceWaitIdle(context.Device); + } + } + /// /// Each slot bump-allocates inside its own slice of one shared buffer. The /// shared buffer is what lets descriptor sets be written once and reused, diff --git a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs index 29959a94..01494dd2 100644 --- a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs +++ b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs @@ -156,6 +156,35 @@ public void EveryMutatorInvalidatesTheCachedBlendId() } } + /// + /// The signature covers only the first attachmentCount attachments, + /// so the cache must key on the count too. Otherwise a one-attachment pass + /// followed by a six-attachment OIT pass hands the OIT draw the id of the + /// one-element signature, and two OIT blend sets that agree on attachment 0 + /// share a pipeline baked with the wrong factors. + /// + [Fact] + public void TheCachedBlendIdIsKeyedOnTheAttachmentCount() + { + var tracker = new GlStateTracker(); + tracker.SetBlend(true, EnumBlendMode.Standard); + tracker.SetAttachmentBlendFunc(1, 1, 1, 1, 1); + + int one = tracker.BlendId(1); + int six = tracker.BlendId(6); + Assert.NotEqual(one, six); + + // Attachment 1 changes; attachment 0 does not. The one-attachment id is + // re-cached first, and the six-attachment request must not inherit it. + tracker.SetAttachmentBlendFunc(1, 0, 0, 0, 0); + int oneAgain = tracker.BlendId(1); + int sixAgain = tracker.BlendId(6); + + Assert.Equal(one, oneAgain); + Assert.NotEqual(oneAgain, sixAgain); + Assert.NotEqual(six, sixAgain); + } + /// /// The packing squeezes eight fields into 32 bits. A collision there would /// silently merge two different blend states onto one pipeline. @@ -349,7 +378,7 @@ public void ThreeChannelFormatsArePromotedToFourChannels() [Fact] public void DepthAndFloatFormatsMapExactly() { - Assert.Equal(Format.D32Sfloat, GlEnums.TextureFormatFromGl(0x8DAB)); + Assert.Equal(Format.D32Sfloat, GlEnums.TextureFormatFromGl(0x8CAC)); Assert.Equal(Format.R16G16B16A16Sfloat, GlEnums.TextureFormatFromGl(0x881A)); Assert.Equal(Format.R16Sfloat, GlEnums.TextureFormatFromGl(0x822D)); Assert.Equal(Format.R32G32B32A32Sfloat, GlEnums.TextureFormatFromGl(0x8814)); diff --git a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs index 900b9f2f..99017bfd 100644 --- a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs @@ -496,7 +496,7 @@ void main(void) Assert.Equal(0, pixels[centre + 1]); Assert.Equal(255, pixels[centre + 3]); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -544,16 +544,4 @@ private static unsafe byte[] ReadTexture( return result; } - private static void AssertNoValidationErrors(List messages) - { - // Only what the layers reported at error severity. Advisories - a - // fragment output with no attachment, say - are prefixed as warnings and - // are not failures; treating every message as one made these assertions - // fire on notes about correct frames. - var errors = messages - .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, - StringComparison.Ordinal)) - .ToList(); - Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); - } } diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs index 6c9ef648..2ca538ea 100644 --- a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -88,7 +88,7 @@ public void AVanillaProgramProducesUsableDescriptorAndPipelineLayouts() Assert.True(translated.Layout.Samplers.Count >= 4); Assert.True(translated.Layout.BlockSize > 0); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -117,7 +117,7 @@ public void TheChunkProgramsStorageBufferLandsWhereTheShaderExpectsIt() using var program = new ShaderProgramResources(context!, programId: 2, translated); Assert.NotEqual(0ul, program.PipelineLayout.Handle); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -182,7 +182,7 @@ public void PipelinesAreReusedForIdenticalStateAndRebuiltForDifferentState() Assert.Equal(2, cache.Count); _output.WriteLine($"pipelines: {cache.Count}, hits: {cache.Hits}, misses: {cache.Misses}"); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -241,7 +241,7 @@ public void FullscreenPassesShareOnePipelinePerProgram() byte[] blob = cache.SerializeDriverCache(); _output.WriteLine($"{cache.Count} pipelines, driver cache blob {blob.Length} bytes"); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } finally { @@ -250,17 +250,4 @@ public void FullscreenPassesShareOnePipelinePerProgram() } } - private static void AssertNoValidationErrors(List messages) - { - // Only what the layers reported at error severity. Advisories - a - // fragment output with no attachment, say - are prefixed as warnings and - // are not failures; treating every message as one made these assertions - // fire on notes about correct frames. - var errors = messages - .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, - StringComparison.Ordinal)) - .ToList(); - - Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); - } } diff --git a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs index 8412019d..8191e36f 100644 --- a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs +++ b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs @@ -104,7 +104,7 @@ public unsafe void AnAttachmentLeftOutOfDrawBuffersIsNotWritten() // Attachment 1 kept every byte it started with. Assert.All(glow, b => Assert.Equal(0x77, b)); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -163,7 +163,7 @@ void main(void) Assert.Equal(0, glow[0]); Assert.Equal(255, glow[1]); // green - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -210,7 +210,7 @@ public unsafe void ChangingTheDrawBufferMaskRestartsTheRenderingScope() targets.EndRendering(commandBuffer); }); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -357,16 +357,4 @@ private static unsafe byte[] ReadTexture( return result; } - private static void AssertNoValidationErrors(List messages) - { - // Only what the layers reported at error severity. Advisories - a - // fragment output with no attachment, say - are prefixed as warnings and - // are not failures; treating every message as one made these assertions - // fire on notes about correct frames. - var errors = messages - .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, - StringComparison.Ordinal)) - .ToList(); - Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); - } } diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs index d939caae..6d270db9 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs @@ -197,6 +197,65 @@ void main() {} private static string RewriteVertex(string source, ProgramInterfaceLayout layout) => ShaderRewriter.Rewrite(Parse(source), layout, EnumShaderType.VertexShader, emitDepthRemap: true).Code; + /// + /// EmitVertex() snapshots gl_Position, so a geometry stage cannot be fixed + /// up by a wrapper after main returns: the remap has to precede every emit. + /// + [Fact] + public void RewritingAGeometryStageRemapsDepthBeforeEveryEmitVertex() + { + const string source = """ + #version 330 core + layout(triangles) in; + layout(triangle_strip, max_vertices = 3) out; + void main() { + for (int i = 0; i < 3; i++) { + gl_Position = gl_in[i].gl_Position; + EmitVertex(); + } + EmitVertex (); + EndPrimitive(); + } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.GeometryShader, source)); + RewrittenShader rewritten = ShaderRewriter.Rewrite( + Parse(source), layout, EnumShaderType.GeometryShader, emitDepthRemap: true); + string code = rewritten.Code; + + Assert.Empty(rewritten.Errors); + Assert.DoesNotContain("_optimum_main", code); + Assert.Equal(2, CountOf(code, "gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; EmitVertex")); + } + + private static int CountOf(string text, string needle) + { + int count = 0; + for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; + at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) count++; + return count; + } + + /// + /// Set 0, binding 0 belongs to the generated OptimumUniforms block. A shader + /// that claims it for its own block would double-register that descriptor. + /// + [Fact] + public void AUserBlockAtBindingZeroMovesOffTheOptimumUniformsBinding() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ + #version 330 core + layout(std140, binding = 0) uniform Lights { vec4 pos; }; + layout(std140, binding = 2) uniform Fog { vec4 colour; }; + void main() {} + """)); + + Assert.Equal(2, layout.UniformBlocks.Count); + Assert.NotEqual(ProgramInterfaceLayout.DefaultBlockBinding, layout.UniformBlocks[0].Binding); + Assert.NotEqual(layout.UniformBlocks[1].Binding, layout.UniformBlocks[0].Binding); + Assert.Equal(2, layout.UniformBlocks[1].Binding); + } + [Fact] public void RewritingBumpsTheVersionAndWrapsMainForTheVulkanDepthRange() { @@ -366,14 +425,31 @@ public void VulkanSpellingsOfBuiltInsAreSubstituted() } /// - /// A word after a dot is a field or a swizzle, never a declaration. Renaming - /// it would rewrite a member of somebody else's struct. + /// A word after a dot can be a struct member declared elsewhere in this + /// class with a reserved name (see + /// ), so it is + /// renamed exactly like a declaration would be - leaving it alone would + /// desync the access from the member it is meant to reach. + /// + [Fact] + public void FieldsAndSwizzlesAreRenamedLikeDeclarations() + { + Assert.Equal("value._optimum_kw_sample = 1.0;", GlslReservedWords.Rename("value.sample = 1.0;")); + Assert.Equal("a._optimum_kw_filter", GlslReservedWords.Rename("a.filter")); + } + + /// + /// A struct member declared with a reserved name and an access to that + /// member must end up with the same renamed identifier, or the access no + /// longer resolves to the declaration. /// [Fact] - public void FieldsAndSwizzlesKeepTheirNames() + public void MemberAccessOfAReservedNameMatchesItsDeclaration() { - Assert.Equal("value.sample = 1.0;", GlslReservedWords.Rename("value.sample = 1.0;")); - Assert.Equal("a.filter", GlslReservedWords.Rename("a.filter")); + string renamed = GlslReservedWords.Rename("struct S { float filter; }; void main() { S t; t.filter = 1.0; }"); + + Assert.Contains("float _optimum_kw_filter;", renamed); + Assert.Contains("t._optimum_kw_filter = 1.0;", renamed); } /// diff --git a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs index a7bb9fbb..0f3c990a 100644 --- a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs @@ -268,9 +268,7 @@ public unsafe void MipmapGenerationBuildsTheWholeChainCleanly() textures.GenerateMipmaps(id); Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, texture.Layout); - var errors = messages.FindAll(m => - m.Contains("Error", StringComparison.OrdinalIgnoreCase) || m.Contains("VUID", StringComparison.Ordinal)); - Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + ValidationAssert.NoErrors(messages); } } diff --git a/Optimum.Render.Vulkan.Tests/ValidationAssert.cs b/Optimum.Render.Vulkan.Tests/ValidationAssert.cs new file mode 100644 index 00000000..48e1535a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ValidationAssert.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Core; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Shared check that a captured validation-layer log holds no errors. +/// +internal static class ValidationAssert +{ + /// + /// Fails when any message carries the error prefix. Only what the layers + /// reported at error severity counts: advisories - a fragment output with + /// no attachment, say - are prefixed as warnings and are not failures; + /// treating every message as one made these assertions fire on notes + /// about correct frames. + /// + public static void NoErrors(IReadOnlyCollection messages) + { + var errors = messages + .Where(m => m.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) + .ToList(); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs index 1d1ab38f..7a7648a1 100644 --- a/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs +++ b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs @@ -94,7 +94,7 @@ public void SuppliedAttributesAreLeftOnTheirOwnBinding() [InlineData("vec4", Format.R32G32B32A32Sfloat, 0u)] [InlineData("int", Format.R32Sint, 16u)] [InlineData("ivec4", Format.R32G32B32A32Sint, 16u)] - [InlineData("uint", Format.R32Sint, 16u)] + [InlineData("uint", Format.R32Uint, 16u)] public void DefaultsUseTheFormatAndHalfMatchingTheDeclaredType( string type, Format expectedFormat, uint expectedOffset) { @@ -107,6 +107,16 @@ public void DefaultsUseTheFormatAndHalfMatchingTheDeclaredType( Assert.Equal(expectedOffset, attribute.Offset); } + [Fact] + public void UnsignedVectorTypesUseUnsignedFormats() + { + VertexLayoutDescription merged = + VertexLayoutDescription.Empty.WithDefaultsFor(new[] { Slot("x", 7, "uvec3") }); + + VertexAttribute attribute = Assert.Single(merged.Attributes); + Assert.Equal(Format.R32G32B32Uint, attribute.Format); + } + /// /// A layout that already covers everything must come back untouched, so no /// pipeline gains a binding it will never have a buffer for. diff --git a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs index 63e444f8..9045c897 100644 --- a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs @@ -111,7 +111,7 @@ void main(void) Assert.Equal(new byte[] { 0, 255, 0 }, FirstPixel(context!, commands, textures, accumulation, size, 1)); Assert.Equal(new byte[] { 0, 0, 255 }, FirstPixel(context!, commands, textures, accumulation, size, 2)); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -182,7 +182,7 @@ void main(void) // 0 + 0.25 = 0.25, so accumulation added rather than multiplying. Assert.InRange(accumPixels[0], 56, 72); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -254,7 +254,7 @@ void main(void) { } // rather than assumed. Assert.InRange(stored, 0.74f, 0.76f); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -322,7 +322,7 @@ public unsafe void AnOcclusionQueryCountsTheSamplesThatPassed() // The triangle covers the whole 8x8 target. Assert.Equal((ulong)(size * size), passed); - AssertNoValidationErrors(messages); + ValidationAssert.NoErrors(messages); } } @@ -479,16 +479,4 @@ private static unsafe float ReadDepth( return result[0]; } - private static void AssertNoValidationErrors(List messages) - { - // Only what the layers reported at error severity. Advisories - a - // fragment output with no attachment, say - are prefixed as warnings and - // are not failures; treating every message as one made these assertions - // fire on notes about correct frames. - var errors = messages - .Where(m => m.StartsWith(Optimum.Render.Vulkan.Core.VulkanContext.ErrorPrefix, - StringComparison.Ordinal)) - .ToList(); - Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); - } } diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 1a9a3518..53efaf4e 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -222,7 +222,10 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin BufferUsageFlags.UniformBufferBit, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); - ulong regionSize = uniformRingSize / (ulong)framesInFlight; + // Each region must start on a uniform-offset boundary, otherwise every + // dynamic offset handed out from slot 1 onwards inherits the misalignment. + ulong alignment = Math.Max(1UL, context.Capabilities.MinUniformBufferOffsetAlignment); + ulong regionSize = uniformRingSize / (ulong)framesInFlight / alignment * alignment; _slots = new FrameSlot[framesInFlight]; for (int i = 0; i < framesInFlight; i++) { diff --git a/Optimum.Render.Vulkan/Core/GlEnums.cs b/Optimum.Render.Vulkan/Core/GlEnums.cs index e124bb5b..f5a01554 100644 --- a/Optimum.Render.Vulkan/Core/GlEnums.cs +++ b/Optimum.Render.Vulkan/Core/GlEnums.cs @@ -122,7 +122,7 @@ internal static class GlEnums 0x805B => Format.R16G16B16A16Unorm, // GL_RGBA16, the cloud map's tile data 0x8051 => Format.R8G8B8A8Unorm, // GL_RGB8, promoted: RGB is not a 0x1907 => Format.R8G8B8A8Unorm, // GL_RGB guaranteed attachment format - 0x8DAB => Format.D32Sfloat, // GL_DEPTH_COMPONENT32F + 0x8CAC => Format.D32Sfloat, // GL_DEPTH_COMPONENT32F 0x81A5 => Format.D16Unorm, // GL_DEPTH_COMPONENT16 // GL_BGRA. The GL bodies use it as a source pixel format against an // RGBA8 internal format; here it names a BGRA-ordered image, so Cairo diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs index 144de767..2a259a76 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -189,6 +189,7 @@ internal sealed class GlStateTracker private readonly Interner _targetFormats = new(); private int _cachedBlendId = -1; + private int _cachedBlendCount = -1; private ColorComponentFlags _colorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; @@ -309,6 +310,7 @@ public void SetColorMask(bool r, bool g, bool b, bool a) for (int i = 0; i < _blend.Length; i++) _blend[i].WriteMask = mask; _cachedBlendId = -1; + _cachedBlendCount = -1; } /// @@ -344,6 +346,7 @@ public void SetBlend(bool enabled, EnumBlendMode mode) _blend[i].AlphaOp = BlendOp.Add; } _cachedBlendId = -1; + _cachedBlendCount = -1; } /// @@ -359,6 +362,7 @@ public void SetAttachmentBlendFunc(int attachment, int srcColor, int dstColor, i _blend[attachment].SrcAlpha = GlEnums.BlendFactorFrom(srcAlpha); _blend[attachment].DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); _cachedBlendId = -1; + _cachedBlendCount = -1; } public void SetAttachmentBlendEquation(int attachment, int equation) @@ -369,6 +373,7 @@ public void SetAttachmentBlendEquation(int attachment, int equation) _blend[attachment].ColorOp = op; _blend[attachment].AlphaOp = op; _cachedBlendId = -1; + _cachedBlendCount = -1; } // ---------------------------------------------------------------------- keys @@ -379,9 +384,10 @@ public void SetAttachmentBlendEquation(int attachment, int equation) /// public int BlendId(int attachmentCount) { - if (_cachedBlendId >= 0) return _cachedBlendId; - int count = Math.Clamp(attachmentCount, 0, MaxColorAttachments); + if (_cachedBlendId >= 0 && _cachedBlendCount == count) return _cachedBlendId; + + _cachedBlendCount = count; _cachedBlendId = _blendSignatures.Intern(new BlendSignature(_blend.AsSpan(0, count))); return _cachedBlendId; } @@ -408,6 +414,7 @@ public void Reset() { for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; _cachedBlendId = -1; + _cachedBlendCount = -1; _colorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; diff --git a/Optimum.Render.Vulkan/Core/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs index 3a66d61b..c253a48a 100644 --- a/Optimum.Render.Vulkan/Core/MeshManager.cs +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -328,11 +328,6 @@ private int Register(VulkanMesh mesh) return _meshes.Count - 1; } - /// The persistently mapped pointer for a part, or zero. - /// - /// One of a mesh's buffers, for binding it as something other than a vertex - /// source - the SSBO chunk path reads the xyz slot as a storage buffer. - /// /// Whether the mesh fetches its vertices through a storage buffer. public bool IsSsbo(int meshId) => Get(meshId)?.Ssbo ?? false; @@ -491,6 +486,12 @@ public void DrawMulti( int capacity = (int)((indirectScratch.Size - indirectOffset) / (ulong)sizeof(DrawIndexedIndirectCommand)); int count = Math.Min(groupCount, capacity); + if (count < groupCount && RenderTrace.Enabled) + { + RenderTrace.Write("mesh indirect draw clamped: mesh " + meshId + " groupCount " + groupCount + + " capacity " + capacity); + } + WriteIndirectCommands(new Span(commands, count), indicesStarts, indicesSizes); _context.Api.CmdDrawIndexedIndirect(commandBuffer, indirectScratch.Handle, indirectOffset, (uint)count, diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index a8de96d7..b188e000 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -113,6 +113,10 @@ public void Attach(int framebufferId, int attachmentIndex, int textureId, uint l } framebuffer.FormatsId = -1; + + // GL attaches to the bound framebuffer, so an open scope no longer + // describes the target: the next draw must reopen on the new views. + if (_bound == framebuffer) _needsRestart = true; } public void SetDrawBuffers(int framebufferId, uint mask) diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs index f4f01f26..813f4e38 100644 --- a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -196,11 +196,6 @@ private void CreatePipelineLayout() // ------------------------------------------------------------------ uniforms - /// - /// Resolves a uniform name to its byte offset in the block, or -1 when the - /// program does not use it. Callers treat this as opaque, exactly as they - /// treat a GL uniform location. - /// /// /// The first sampler location. Sampler locations run downwards from here so /// they can never collide with a uniform block offset, which is always zero diff --git a/Optimum.Render.Vulkan/Core/Swapchain.cs b/Optimum.Render.Vulkan/Core/Swapchain.cs index cd0f72ff..4d1bc2ea 100644 --- a/Optimum.Render.Vulkan/Core/Swapchain.cs +++ b/Optimum.Render.Vulkan/Core/Swapchain.cs @@ -47,6 +47,11 @@ private Swapchain(VulkanContext context, KhrSurface surfaceApi, KhrSwapchain swa _surface = surface; } + /// + /// Takes ownership of on entry: on every failure + /// return the surface is destroyed here, and on success the swapchain + /// destroys it in . The caller never destroys it. + /// public static bool TryCreate( VulkanContext context, SurfaceKHR surface, uint width, uint height, bool vsync, out Swapchain? swapchain, out string? failureReason) @@ -57,11 +62,14 @@ public static bool TryCreate( if (!context.Api.TryGetInstanceExtension(context.Instance, out KhrSurface surfaceApi)) { failureReason = "VK_KHR_surface unavailable"; + WindowSurface.Destroy(context, surface); return false; } if (!context.Api.TryGetDeviceExtension(context.Instance, context.Device, out KhrSwapchain swapchainApi)) { failureReason = "VK_KHR_swapchain unavailable"; + surfaceApi.DestroySurface(context.Instance, surface, null); + surfaceApi.Dispose(); return false; } @@ -74,6 +82,9 @@ public static bool TryCreate( if (!supported) { failureReason = "the graphics queue family cannot present to this surface"; + surfaceApi.DestroySurface(context.Instance, surface, null); + surfaceApi.Dispose(); + swapchainApi.Dispose(); return false; } diff --git a/Optimum.Render.Vulkan/Core/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs index c9c13ccb..eecf5c23 100644 --- a/Optimum.Render.Vulkan/Core/TextureDump.cs +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -66,15 +66,22 @@ private static HashSet Parse(string? value) return ids; } - /// The ids still to write, as a snapshot safe to iterate while removing. + /// + /// The ids still to write, as a snapshot safe to iterate while removing. + /// Ids stay pending until reports a successful write, + /// so a texture that does not exist yet or whose write fails is retried on a + /// later frame instead of being silently dropped. + /// public static int[] Take() { var ids = new int[Pending.Count]; Pending.CopyTo(ids); - Pending.Clear(); return ids; } + /// Removes an id from the pending set once it has been written successfully. + public static void Complete(int textureId) => Pending.Remove(textureId); + private static string Directory() { string? explicitDir = Environment.GetEnvironmentVariable("OPTIMUM_DUMP_DIR"); @@ -91,10 +98,10 @@ private static string Directory() /// /// Writes RGBA or BGRA bytes as a binary PPM. /// - /// The file written, or null if it could not be. - public static string? Write(int textureId, int width, int height, bool bgra, ReadOnlySpan rgba) + /// True if the file was written. + public static bool Write(int textureId, int width, int height, bool bgra, ReadOnlySpan rgba) { - if (width <= 0 || height <= 0 || rgba.Length < width * height * 4) return null; + if (width <= 0 || height <= 0 || rgba.Length < width * height * 4) return false; try { @@ -123,15 +130,15 @@ private static string Directory() writer.Write(row); } - return path; + return true; } catch (IOException) { - return null; + return false; } catch (UnauthorizedAccessException) { - return null; + return false; } } } diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index 967fd984..a7d9a418 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -335,7 +335,12 @@ public int Create( MemoryAllocation allocation = _context.Allocator.Allocate( requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, $"a {width}x{height} {format} image"); - api.BindImageMemory(_context.Device, image, allocation.Memory, allocation.Offset); + if (api.BindImageMemory(_context.Device, image, allocation.Memory, allocation.Offset) != Result.Success) + { + api.DestroyImage(_context.Device, image, null); + _context.Allocator.Free(allocation); + throw new InvalidOperationException("vkBindImageMemory failed"); + } uint viewLayers = cube ? 6 : layers; var viewInfo = new ImageViewCreateInfo @@ -348,7 +353,12 @@ public int Create( Format = format, SubresourceRange = new ImageSubresourceRange(aspect, 0, mipLevels, 0, viewLayers), }; - api.CreateImageView(_context.Device, &viewInfo, null, out ImageView view); + if (api.CreateImageView(_context.Device, &viewInfo, null, out ImageView view) != Result.Success) + { + api.DestroyImage(_context.Device, image, null); + _context.Allocator.Free(allocation); + throw new InvalidOperationException("vkCreateImageView failed"); + } var texture = new VulkanTexture(_context) { diff --git a/Optimum.Render.Vulkan/Core/VertexLayout.cs b/Optimum.Render.Vulkan/Core/VertexLayout.cs index 9be6a952..67d5d0f4 100644 --- a/Optimum.Render.Vulkan/Core/VertexLayout.cs +++ b/Optimum.Render.Vulkan/Core/VertexLayout.cs @@ -105,15 +105,19 @@ public VertexLayoutDescription WithDefaultsFor(IReadOnlyList de private static Format DefaultFormatFor(GlslType type) { bool integer = IsIntegerType(type); + bool unsigned = IsUnsignedType(type); return type.ComponentCount switch { - 1 => integer ? Format.R32Sint : Format.R32Sfloat, - 2 => integer ? Format.R32G32Sint : Format.R32G32Sfloat, - 3 => integer ? Format.R32G32B32Sint : Format.R32G32B32Sfloat, - _ => integer ? Format.R32G32B32A32Sint : Format.R32G32B32A32Sfloat, + 1 => integer ? (unsigned ? Format.R32Uint : Format.R32Sint) : Format.R32Sfloat, + 2 => integer ? (unsigned ? Format.R32G32Uint : Format.R32G32Sint) : Format.R32G32Sfloat, + 3 => integer ? (unsigned ? Format.R32G32B32Uint : Format.R32G32B32Sint) : Format.R32G32B32Sfloat, + _ => integer ? (unsigned ? Format.R32G32B32A32Uint : Format.R32G32B32A32Sint) : Format.R32G32B32A32Sfloat, }; } + private static bool IsUnsignedType(GlslType type) => + type.Name.StartsWith("u", StringComparison.Ordinal) || type.Name == "uint"; + private static bool IsIntegerType(GlslType type) => type.Name.StartsWith("i", StringComparison.Ordinal) || type.Name.StartsWith("u", StringComparison.Ordinal) || diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index 1710a9ce..5415370c 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -339,15 +339,6 @@ public CommandBuffer Allocate() return buffer; } - /// - /// Records, submits and waits. For setup and readback, not frames. - /// - /// The whole body is serialised, not just the submit: the command pool is - /// shared, and Vulkan requires external synchronisation for allocating from - /// and freeing to a pool as much as for submitting to a queue. Texture - /// uploads reach this from asset-loading worker threads while the render - /// thread is submitting frames. - /// /// /// Runs before every synchronous submit, outside the queue lock. The device /// uses it to flush a frame it is in the middle of recording: a synchronous @@ -357,6 +348,15 @@ public CommandBuffer Allocate() /// public Action? BeforeSynchronousSubmit; + /// + /// Records, submits and waits. For setup and readback, not frames. + /// + /// The whole body is serialised, not just the submit: the command pool is + /// shared, and Vulkan requires external synchronisation for allocating from + /// and freeing to a pool as much as for submitting to a queue. Texture + /// uploads reach this from asset-loading worker threads while the render + /// thread is submitting frames. + /// public void SubmitAndWait(Action record) { BeforeSynchronousSubmit?.Invoke(); @@ -376,31 +376,39 @@ private void SubmitAndWaitLocked(Action record) Vk api = _context.Api; CommandBuffer commandBuffer = Allocate(); - var begin = new CommandBufferBeginInfo + Fence fence = default; + bool fenceCreated = false; + try { - SType = StructureType.CommandBufferBeginInfo, - Flags = CommandBufferUsageFlags.OneTimeSubmitBit, - }; - api.BeginCommandBuffer(commandBuffer, &begin); - record(commandBuffer); - api.EndCommandBuffer(commandBuffer); - - var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo }; - api.CreateFence(_context.Device, &fenceInfo, null, out Fence fence); - - var submit = new SubmitInfo + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + api.BeginCommandBuffer(commandBuffer, &begin); + record(commandBuffer); + api.EndCommandBuffer(commandBuffer); + + var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo }; + api.CreateFence(_context.Device, &fenceInfo, null, out fence); + fenceCreated = true; + + var submit = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer, + }; + VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, fence), + "vkQueueSubmit for a setup command buffer"); + VulkanResult.Check(api.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue), + "vkWaitForFences for a setup command buffer"); + } + finally { - SType = StructureType.SubmitInfo, - CommandBufferCount = 1, - PCommandBuffers = &commandBuffer, - }; - VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, fence), - "vkQueueSubmit for a setup command buffer"); - VulkanResult.Check(api.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue), - "vkWaitForFences for a setup command buffer"); - - api.DestroyFence(_context.Device, fence, null); - api.FreeCommandBuffers(_context.Device, Pool, 1, &commandBuffer); + if (fenceCreated) api.DestroyFence(_context.Device, fence, null); + api.FreeCommandBuffers(_context.Device, Pool, 1, &commandBuffer); + } } /// diff --git a/Optimum.Render.Vulkan/Core/WindowSurface.cs b/Optimum.Render.Vulkan/Core/WindowSurface.cs index a63b7185..dbf5c8e9 100644 --- a/Optimum.Render.Vulkan/Core/WindowSurface.cs +++ b/Optimum.Render.Vulkan/Core/WindowSurface.cs @@ -50,6 +50,20 @@ public static string[] RequiredInstanceExtensions() /// Creates the surface. The window pointer is the GLFW handle the client /// already holds. /// + /// + /// Destroys a surface that never reached a . The + /// swapchain owns the surface once it exists, so this is only for the + /// failure paths between creation and hand-over; the instance must not be + /// destroyed with a surface still alive under it. + /// + public static void Destroy(VulkanContext context, SurfaceKHR surface) + { + if (surface.Handle == 0) return; + if (!context.Api.TryGetInstanceExtension(context.Instance, out KhrSurface surfaceApi)) return; + surfaceApi.DestroySurface(context.Instance, surface, null); + surfaceApi.Dispose(); + } + public static bool TryCreate( VulkanContext context, IntPtr windowHandle, out SurfaceKHR surface, out string? failureReason) { diff --git a/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs b/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs index cf9b75f5..84b167f9 100644 --- a/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs +++ b/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs @@ -116,13 +116,6 @@ public static string Rename(string source) int start = position; while (position < length && IsIdentifierPart(source[position])) position++; - // A word preceded by '.' is a struct field or a swizzle, never a - // declaration, and renaming it would break the member it names. - if (IsMemberAccess(source, start)) - { - continue; - } - int wordLength = position - start; if (wordLength > LongestRename) continue; @@ -141,13 +134,6 @@ public static string Rename(string source) return builder.ToString(); } - private static bool IsMemberAccess(string source, int identifierStart) - { - int i = identifierStart - 1; - while (i >= 0 && (source[i] == ' ' || source[i] == '\t')) i--; - return i >= 0 && source[i] == '.'; - } - private static bool IsIdentifierStart(char c) => char.IsLetter(c) || c == '_'; private static bool IsIdentifierPart(char c) => char.IsLetterOrDigit(c) || c == '_'; } diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs index 94203a6e..2149035f 100644 --- a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -300,6 +300,13 @@ private static void AddBlock( // "layout(binding = 3, std430) readonly buffer faceDataBuf", and the mesh // path binds the vertex buffer to that exact index. int declared = ReadQualifierInt(declaration.LayoutQualifiers, "binding"); + + // Set 0, binding 0 is where the generated OptimumUniforms block lives. + // A shader that names that binding itself would register two blocks at + // one descriptor binding, so it is treated as unnumbered and moves to + // the next free binding; the rewriter re-emits the qualifier from here. + if (set == DefaultBlockSet && declared == DefaultBlockBinding) declared = -1; + blocks.Add(new BlockBinding { BlockName = declaration.Name, diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs index 911307b2..9b43bb18 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -132,7 +132,9 @@ internal static string SplicePrefix(string code, string prefixCode) if (string.IsNullOrEmpty(prefixCode)) return code; int versionIndex = code.IndexOf("#version", StringComparison.Ordinal); - int insertAt = code.IndexOf('\n', Math.Max(0, versionIndex)) + 1; + if (versionIndex < 0) return prefixCode + code; + + int insertAt = code.IndexOf('\n', versionIndex) + 1; if (insertAt <= 0) return prefixCode + code; return code.Insert(insertAt, prefixCode); diff --git a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs index aecf674a..fc0d1dc9 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs @@ -103,7 +103,7 @@ public static RewrittenShader Rewrite( if (emitDepthRemap) { - AddDepthRemapEdits(parsed, edits, result); + AddDepthRemapEdits(parsed, stage, edits, result); } result.Code = ApplyEdits(source, edits); @@ -268,8 +268,17 @@ private static Edit LayoutEdit(GlslDeclaration declaration, (string Key, string /// shadow orthographic projections and any matrix a mod builds all keep /// working, and the CPU-side code never has to know which backend is running. /// - private static void AddDepthRemapEdits(ParsedShader parsed, List edits, RewrittenShader result) + private const string DepthRemapStatement = "gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;"; + + private static void AddDepthRemapEdits( + ParsedShader parsed, EnumShaderType stage, List edits, RewrittenShader result) { + if (stage == EnumShaderType.GeometryShader) + { + AddGeometryDepthRemapEdits(parsed, edits, result); + return; + } + if (!parsed.HasMain) { result.Errors.Add("stage has no main() to wrap for the Vulkan depth range"); @@ -281,10 +290,40 @@ private static void AddDepthRemapEdits(ParsedShader parsed, List edits, Re edits.Add(new Edit(parsed.Source.Length, 0, "\n\nvoid main()\n{\n" + " " + MainReplacementName + "();\n" + - " gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;\n" + + " " + DepthRemapStatement + "\n" + "}\n")); } + /// + /// A geometry stage snapshots gl_Position at every EmitVertex(), + /// so a wrapper around main would run after every vertex has already + /// left. The remap goes immediately before each emit instead. + /// + private static void AddGeometryDepthRemapEdits(ParsedShader parsed, List edits, RewrittenShader result) + { + string source = parsed.Source; + const string call = "EmitVertex"; + int found = 0; + + for (int at = source.IndexOf(call, StringComparison.Ordinal); at >= 0; + at = source.IndexOf(call, at + call.Length, StringComparison.Ordinal)) + { + bool startsWord = at == 0 || !(char.IsLetterOrDigit(source[at - 1]) || source[at - 1] == '_'); + int after = at + call.Length; + while (after < source.Length && char.IsWhiteSpace(source[after])) after++; + bool isCall = after < source.Length && source[after] == '('; + if (!startsWord || !isCall) continue; + + edits.Add(new Edit(at, 0, DepthRemapStatement + " ")); + found++; + } + + if (found == 0) + { + result.Errors.Add("geometry stage never calls EmitVertex(), so no vertex gets the Vulkan depth range"); + } + } + // --------------------------------------------------------------------- edits /// diff --git a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs index eb025501..3a6c0eb6 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs @@ -86,6 +86,12 @@ public static TranslatedProgram Translate( if (program.Errors.Count > 0) return program; + if (parsed.Count == 0) + { + program.Errors.Add("no shader stage survived translation"); + return program; + } + program.Layout = ProgramInterfaceLayout.Build(parsed, declaredAttributes); foreach (string error in program.Layout.Errors) { diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 060500ac..c3266ff2 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -937,6 +937,13 @@ public void SetUniform(int programId, int location, int value) Write(programId, location, new ReadOnlySpan(&value, sizeof(int))); } + public void SetUniform(int programId, int location, int x, int y, int z) + { + // Scalar block layout stores an ivec3 as three consecutive 32-bit ints. + int* values = stackalloc int[3] { x, y, z }; + Write(programId, location, new ReadOnlySpan(values, 3 * sizeof(int))); + } + public void SetUniform(int programId, int location, float x, float y) { float* values = stackalloc float[2] { x, y }; @@ -1229,6 +1236,10 @@ public void BindTexture(int unit, int textureId) if (RenderTrace.Enabled) RenderTrace.Write("bind unit=" + unit + " texture=" + textureId); } + public void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, + int width, int height, IntPtr pixels) => + _textures.Upload(textureId, 0, x, y, (uint)width, (uint)height, pixels, 4, (uint)layer); + public void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, int width, int height, short[] pixels) => _textures.UploadNormalizedShorts(textureId, level, x, y, width, height, pixels); @@ -2333,11 +2344,12 @@ private void DumpRequestedTextures() }); bool bgra = texture.Format is Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb; - string? written = TextureDump.Write(textureId, width, height, bgra, + bool written = TextureDump.Write(textureId, width, height, bgra, new ReadOnlySpan((void*)readback.Mapped, (int)bytes)); + if (written) TextureDump.Complete(textureId); RenderTrace.Write("texture dump: " + textureId + " " + width + "x" + height + - " " + texture.Format + " mips=" + texture.MipLevels + " -> " + (written ?? "failed")); + " " + texture.Format + " mips=" + texture.MipLevels + " -> " + (written ? "ok" : "failed")); if (restore != ImageLayout.Undefined) { diff --git a/VULKAN-BACKEND-PLAN.md b/VULKAN-BACKEND-PLAN.md index 03799f4e..41b8ba1e 100644 --- a/VULKAN-BACKEND-PLAN.md +++ b/VULKAN-BACKEND-PLAN.md @@ -60,7 +60,13 @@ device — `ChunkRenderer` (atlas LOD bias, sampler unbinding), `SystemRenderOIT (the layered accumulation target, six-attachment blending, its own textures), `SystemRenderSunMoon` (occlusion queries, colour mask), `SystemRenderFrameBufferDebug` (shadow-map compare mode), `SvgLoader`, `ShaderRegistry` (terrain sampler bias), -`ClientMain`, `InventoryItemRenderer`, `ClientSystemStartup` and `Screenshot`. +`ClientMain`, `InventoryItemRenderer`, `ClientSystemStartup`, `Screenshot` and +`EntityBehaviorHideWaterSurface` (the boat's depth-only `DrawBuffers(0)` scope +and its six-attachment restore). +Optimum's own world-map page cache (`OptimumMapTextureArray`, +`OptimumMapPageRenderer`, `OptimumBc7Support`) now goes through the device too: +the page array is created and uploaded per layer through the seam, the instanced +quad travels through the engine's mesh API, and BC7 reports unsupported on Vulkan. 242/242 Cecil methods, 192 members injected, 126 patches, 0 conflicts. Two more defects came out of it, both silent: diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch index 0b8eaad2..ad6387b6 100644 --- a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch @@ -1,8 +1,8 @@ diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs -index ce8dd81..7a0905b 100644 +index ce8dd81..18d41f0 100644 --- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs +++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs -@@ -73,10 +73,25 @@ namespace FluffyClouds { +@@ -73,10 +73,26 @@ namespace FluffyClouds { program.Uniform("PerceptionEffectIntensity", capi.Render.ShaderUniforms.PerceptionEffectIntensity); program.BindTexture2D("depthTex", capi.Render.FrameBuffers[(int)EnumFrameBuffer.Primary].DepthTextureId, 0); program.BindTexture2D("cloudMap", map.TextureMap, 8); @@ -19,6 +19,7 @@ index ce8dd81..7a0905b 100644 + capi.Render.RenderMesh(quad); + + optimumDevice.SetDepthTest(true); ++ optimumDevice.SetBlend(false, EnumBlendMode.Standard); + program.Stop(); + return; + } diff --git a/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch b/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch new file mode 100644 index 00000000..4ddcd1c6 --- /dev/null +++ b/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch @@ -0,0 +1,48 @@ +diff --git a/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs b/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs +index 1c83a88..a33ef42 100644 +--- a/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs ++++ b/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs +@@ -109,11 +109,22 @@ namespace Vintagestory.GameContent + if (esr == null) return; + + // Slightly fugly hack, needs clean up + // This clears the drawbuffers set up by RenderOITLayers and then restores them after the render + // Otherwise we render a big black surface inside the boat +- OpenTK.Graphics.OpenGL.GL.DrawBuffers(0, new OpenTK.Graphics.OpenGL.DrawBuffersEnum[0]); ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ int oitFboId = capi.Render.FrameBuffers[(int)EnumFrameBuffer.Transparent].FboId; ++ if (optimumDevice != null) ++ { ++ // Vulkan path: the window has no GL context, so the draw-buffer ++ // mask has to change through the device. ++ optimumDevice.SetDrawBuffers(oitFboId, 0); ++ } ++ else ++ { ++ OpenTK.Graphics.OpenGL.GL.DrawBuffers(0, new OpenTK.Graphics.OpenGL.DrawBuffersEnum[0]); ++ } + + capi.Render.GLDepthMask(true); + + // We only render into the depth texture + // We can abuse the shadow map shader for this +@@ -143,11 +154,18 @@ namespace Vintagestory.GameContent + OpenTK.Graphics.OpenGL.DrawBuffersEnum.ColorAttachment3, // accumulation bin 1 (rgba16) + OpenTK.Graphics.OpenGL.DrawBuffersEnum.ColorAttachment4, // accumulation bin 2 (rgba16) + OpenTK.Graphics.OpenGL.DrawBuffersEnum.ColorAttachment5 // accumulation bin 3 (rgba16) + }; + +- OpenTK.Graphics.OpenGL.GL.DrawBuffers(buffers.Length, buffers); ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetDrawBuffers(oitFboId, (1 << buffers.Length) - 1); ++ } ++ else ++ { ++ OpenTK.Graphics.OpenGL.GL.DrawBuffers(buffers.Length, buffers); ++ } + } + + public override string PropertyName() => "hidewatersurface"; + } + diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch index d4c0b2c0..533dbf84 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs -index f437458..1810589 100644 +index f437458..db565ba 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs @@ -738,11 +738,14 @@ public class ClientSystemStartup : ClientSystem @@ -48,11 +48,11 @@ index f437458..1810589 100644 logger.VerboseDebug("Done level finalize"); game.AmbientManager.LateInit(); - if (GL.GetString((StringName)7937).Contains("Arc(TM)") && ClientSettings.AllowSSBOs) -+ // The advisory is about an Intel Arc OpenGL driver bug, so it reads the -+ // renderer name from whichever backend is live rather than from GL. ++ // The advisory is about an Intel Arc OpenGL driver bug, so it only applies ++ // when the OpenGL backend is actually live; a non-OpenGL device skips it. + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + string optimumRendererName = optimumDevice != null -+ ? optimumDevice.RendererString ++ ? null + : GL.GetString((StringName)7937); + if (optimumRendererName != null && optimumRendererName.Contains("Arc(TM)") && ClientSettings.AllowSSBOs) { diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch index 5b1738a2..63473dc1 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs -index 815363d..b06f2fd 100644 +index 815363d..0b21d87 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs -@@ -109,102 +109,224 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -109,102 +109,220 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable { int value = ScreenManager.Platform.GenSampler(isLinear); customSamplers.Add(uniformName, value); @@ -130,13 +130,9 @@ index 815363d..b06f2fd 100644 + if (optimumDevice != null) + { + // Unlike the Vec2i overload this one keeps integers, so the shader -+ // declares an ivec3. Scalar block layout stores that as three -+ // consecutive 32-bit ints, so writing each component at its own -+ // offset lands them exactly where the shader reads them. -+ int optimumOffset = uniformLocations[uniformName]; -+ optimumDevice.SetUniform(ProgramId, optimumOffset, value.X); -+ optimumDevice.SetUniform(ProgramId, optimumOffset + 4, value.Y); -+ optimumDevice.SetUniform(ProgramId, optimumOffset + 8, value.Z); ++ // declares an ivec3. The location is opaque to this side, so the ++ // device lays the three components out itself. ++ optimumDevice.SetUniform(ProgramId, uniformLocations[uniformName], value.X, value.Y, value.Z); + return; + } GL.Uniform3(uniformLocations[uniformName], value.X, value.Y, value.Z); @@ -227,7 +223,7 @@ index 815363d..b06f2fd 100644 [MethodImpl(MethodImplOptions.AggressiveInlining)] protected void CheckShaderIsActive() -@@ -220,10 +342,37 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -220,10 +338,37 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable return uniformLocations.ContainsKey(uniformName); } @@ -265,7 +261,7 @@ index 815363d..b06f2fd 100644 GL.BindTexture((TextureTarget)3553, textureId); if (customSamplers.TryGetValue(samplerName, out var value)) { -@@ -240,10 +389,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -240,10 +385,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable BindTexture2D(samplerName, textureId, textureLocations[samplerName]); } @@ -289,7 +285,7 @@ index 815363d..b06f2fd 100644 GL.BindTexture((TextureTarget)34067, textureId); if (clampTToEdge) { -@@ -251,15 +413,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -251,15 +409,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable } } @@ -317,7 +313,7 @@ index 815363d..b06f2fd 100644 public void Use() { -@@ -269,11 +443,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -269,11 +439,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable } if (disposed) { @@ -342,7 +338,7 @@ index 815363d..b06f2fd 100644 if (includes.Contains("fogandlight.fsh")) { Uniform("zNear", shaderUniforms.ZNear); -@@ -369,14 +555,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -369,14 +551,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable } } @@ -373,7 +369,7 @@ index 815363d..b06f2fd 100644 { ubo.Value.Unbind(); } -@@ -388,10 +587,24 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -388,10 +583,24 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable if (disposed) { return; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index c2342d9e..dee0123b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..f57b7b1 100644 +index 4a24e75..e278e46 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..f57b7b1 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,160 @@ public class ShaderRegistry +@@ -181,39 +183,154 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -83,20 +83,7 @@ index 4a24e75..f57b7b1 100644 + ShaderProgram shaderProgram = shaderPrograms[i]; + if (shaderProgram != null) + { -+ bool compiled = shaderProgram.Compile(); -+ if (shaderProgram == ShaderPrograms.Chunkopaque) -+ { -+ bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); -+ OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); -+ } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas) -+ { -+ shaderProgram.LoadError |= !compiled; -+ } -+ else -+ { -+ flag = compiled && flag; -+ } ++ CompileAndTrackShaderProgram(shaderProgram, ref flag); + } + } } @@ -121,20 +108,7 @@ index 4a24e75..f57b7b1 100644 + if (shaderProgram != null) + { + LoadShaderProgram(shaderProgram, ScreenManager.Platform.UseSSBOs); -+ bool compiled = shaderProgram.Compile(); -+ if (shaderProgram == ShaderPrograms.Chunkopaque) -+ { -+ bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); -+ OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); -+ } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas) -+ { -+ shaderProgram.LoadError |= !compiled; -+ } -+ else -+ { -+ flag = compiled && flag; -+ } ++ CompileAndTrackShaderProgram(shaderProgram, ref flag); + } } } @@ -166,6 +140,26 @@ index 4a24e75..f57b7b1 100644 return flag; } ++ // Optimum: shared per-program post-compile handling used by both the ++ // parallel-preprocess and vanilla single-threaded load paths. ++ private static void CompileAndTrackShaderProgram(ShaderProgram shaderProgram, ref bool flag) ++ { ++ bool compiled = shaderProgram.Compile(); ++ if (shaderProgram == ShaderPrograms.Chunkopaque) ++ { ++ bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); ++ OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); ++ } ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas) ++ { ++ shaderProgram.LoadError |= !compiled; ++ } ++ else ++ { ++ flag = compiled && flag; ++ } ++ } ++ + private static bool HasOptimumGreedyMeshContract(ShaderProgram program) + { + return HasOptimumGreedyMeshContract(program.VertexShader) && HasOptimumGreedyMeshContract(program.FragmentShader); @@ -184,7 +178,7 @@ index 4a24e75..f57b7b1 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +419,11 @@ public class ShaderRegistry +@@ -296,11 +413,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -197,7 +191,7 @@ index 4a24e75..f57b7b1 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +456,27 @@ public class ShaderRegistry +@@ -333,10 +450,27 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch b/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch index 3f1a2d21..c1cdaeb1 100644 --- a/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client/ClientProgram.cs b/VintagestoryLib/Vintagestory.Client/ClientProgram.cs -index afa4d5f..56cf7b7 100644 +index afa4d5f..2d8144a 100644 --- a/VintagestoryLib/Vintagestory.Client/ClientProgram.cs +++ b/VintagestoryLib/Vintagestory.Client/ClientProgram.cs @@ -46,10 +46,45 @@ public class ClientProgram @@ -48,7 +48,7 @@ index afa4d5f..56cf7b7 100644 new ClientProgram(rawArgs); } -@@ -295,12 +330,76 @@ public class ClientProgram +@@ -295,12 +330,77 @@ public class ClientProgram }; if (RuntimeEnv.OS == OS.Mac) { @@ -116,6 +116,7 @@ index afa4d5f..56cf7b7 100644 + OptimumRender.FallBackToOpenGL(optimumInstallReason); + Console.WriteLine("[Optimum] Vulkan unavailable, reopening for OpenGL: " + optimumInstallReason); + ((NativeWindow)gameWindowNative).Close(); ++ OptimumRender.NoGraphicsApiWindow = false; + val2.API = ContextAPI.OpenGL; + gameWindowNative = AttemptToOpenWindow(gameWindowSettings, val2, num3, num4, 3); + } diff --git a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs index ebece34a..638ec00c 100644 --- a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs +++ b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs @@ -28,6 +28,14 @@ public static class OptimumBc7Support /// public static void DetectSupport() { + // The Vulkan backend uploads pages as RGBA8 through the device and has + // no compressed upload route, so BC7 stays off there. + if (OptimumRender.Device != null) + { + OptimumConfig.MapPageCacheBc7Supported = false; + return; + } + bool supported = false; try { @@ -111,6 +119,11 @@ public static void DetectSupport() /// public static void UploadCompressedLayer(int textureId, int layer, byte[] compressedData, int width, int height) { + if (OptimumRender.Device != null) + { + throw new InvalidOperationException("BC7 map page upload is not available on the Vulkan backend"); + } + GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, textureId); unsafe { diff --git a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs index cdc3b8e8..d7b0ae44 100644 --- a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs +++ b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs @@ -18,20 +18,18 @@ namespace Vintagestory.GameContent; public sealed class OptimumMapPageRenderer : IDisposable { private const int GL_TEXTURE_2D_ARRAY = 35866; - private const int GL_ARRAY_BUFFER = 34962; - private const int GL_DYNAMIC_DRAW = 35048; - private const int GL_FLOAT = 5126; - private const int GL_TRIANGLES = 4; - private const int GL_DEPTH_TEST = 2929; + private const int FloatsPerInstance = 5; private readonly ICoreClientAPI _capi; private readonly OptimumMapTextureArray _texArray; private IShaderProgram _shader; - // GL resources for the instanced quad - private int _vaoId; - private int _quadVboId; - private int _instanceVboId; + // The quad and its per-instance stream travel as one mesh through the + // engine's own mesh API, so the platform routes it to whichever backend + // is live. Position is a vec3 at location 0; the custom floats are one + // interleaved, instanced part: vec4 rect at 1 and float layer at 2. + private MeshRef _mesh; + private readonly MeshData _instanceUpdate; private bool _disposed; // Instance data buffer (reused each frame) @@ -39,21 +37,17 @@ public sealed class OptimumMapPageRenderer : IDisposable private float[] _instanceData; private int _instanceCount; - // Shader uniform locations - private int _locScreenSize; - private int _locMapPages; - private int _locZValue; - - public bool Ready => _shader != null && !_shader.Disposed && _vaoId != 0 && _texArray.TextureId != 0; + public bool Ready => _shader != null && !_shader.Disposed && _mesh != null && _texArray.TextureId != 0; public OptimumMapPageRenderer(ICoreClientAPI capi, OptimumMapTextureArray texArray) { _capi = capi; _texArray = texArray; - _instanceData = new float[OptimumConfig.MapPageCacheMaxLayers * 5]; + _instanceData = new float[OptimumConfig.MapPageCacheMaxLayers * FloatsPerInstance]; + _instanceUpdate = new MeshData(0, 0, false, false, false, false); CreateShader(); - CreateQuadVao(); + CreateQuadMesh(); _capi.Event.ReloadShader += OnReloadShader; } @@ -72,11 +66,12 @@ public void AddPage(float screenX, float screenY, float screenW, float screenH, { if (layer < 0) return; - int offset = _instanceCount * 5; - if (offset + 5 > _instanceData.Length) + int offset = _instanceCount * FloatsPerInstance; + if (offset + FloatsPerInstance > _instanceData.Length) { - // Grow the buffer + // Grow the CPU buffer; the GPU buffer is re-created to match. Array.Resize(ref _instanceData, _instanceData.Length * 2); + RecreateQuadMesh(); } _instanceData[offset + 0] = screenX; @@ -94,21 +89,24 @@ public void EndFrame(float viewportWidth, float viewportHeight) { if (_instanceCount == 0 || !Ready) return; + IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; + // The map renders inside the GUI pass which uses the 'gui' engine shader. // Entity/player/waypoint layers call GetEngineShader(Gui) and set uniforms - // WITHOUT calling Use() - they assume it's already the active GL program. + // WITHOUT calling Use() - they assume it's already the active program. // We must stop it, run our shader, then explicitly re-bind GUI afterward. IShaderProgram guiShader = _capi.Render.GetEngineShader(EnumShaderProgram.Gui); IShaderProgram currentShader = _capi.Render.CurrentActiveShader; - bool depthTestWasOn = GL.IsEnabled(EnableCap.DepthTest); + // The GUI pass runs with depth testing on; only GL can be asked, so the + // device path restores that known state rather than querying it. + bool depthTestWasOn = optimumDevice != null || GL.IsEnabled(EnableCap.DepthTest); currentShader?.Stop(); _shader.Use(); - // Set uniforms - GL.Uniform2(_locScreenSize, viewportWidth, viewportHeight); + _shader.Uniform("screenSize", viewportWidth, viewportHeight); // Z depth: vanilla terrain tiles render at Z=50 via GlTranslate. // Ortho projection: near=0.4 (NDC -1, front), far=20001 (NDC +1, back). @@ -122,23 +120,29 @@ public void EndFrame(float viewportWidth, float viewportHeight) const float orthoFar = 20001.0f; const float renderZ = 50.01f; float ndcZ = (2.0f * renderZ - orthoNear - orthoFar) / (orthoFar - orthoNear); - GL.Uniform1(_locZValue, ndcZ); + _shader.Uniform("zValue", ndcZ); - // Bind texture array to unit 0 - GL.ActiveTexture(TextureUnit.Texture0); - GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, _texArray.TextureId); - GL.Uniform1(_locMapPages, 0); + // Bind the texture array to unit 0. The engine's BindTexture2D helper + // binds GL_TEXTURE_2D, so the array target needs its own bind here. + if (optimumDevice != null) + { + optimumDevice.BindTexture(0, _texArray.TextureId); + } + else + { + GL.ActiveTexture(TextureUnit.Texture0); + GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, _texArray.TextureId); + } + _shader.Uniform("mapPages", 0); - // Upload instance data - GL.BindBuffer((BufferTarget)GL_ARRAY_BUFFER, _instanceVboId); - int byteSize = _instanceCount * 5 * sizeof(float); - GL.BufferData((BufferTarget)GL_ARRAY_BUFFER, byteSize, _instanceData, (BufferUsageHint)GL_DYNAMIC_DRAW); - GL.BindBuffer((BufferTarget)GL_ARRAY_BUFFER, 0); + // Upload this frame's instances. + _instanceUpdate.CustomFloats.Count = _instanceCount * FloatsPerInstance; + _capi.Render.UpdateMesh(_mesh, _instanceUpdate); // Disable depth writes: the page terrain sits behind everything else // on the map (icons, waypoints, player markers). Writing to the depth // buffer would reject those layers when they render at the same Z. - GL.DepthMask(false); + _capi.Render.GLDepthMask(false); // Disable depth test: the map uses painter's algorithm (render order). // ChunkMapLayer draws first (position 0), then player/entity icons @@ -146,29 +150,33 @@ public void EndFrame(float viewportWidth, float viewportHeight) // by virtue of rendering later. Depth test interferes because icons use // Z=60 while pages use Z=50.01 (closer in VS ortho = lower Z wins), // causing pages to occlude icons. - GL.Disable((EnableCap)GL_DEPTH_TEST); + _capi.Render.GLDisableDepthTest(); - // Draw instanced - GL.BindVertexArray(_vaoId); - GL.DrawArraysInstanced((PrimitiveType)GL_TRIANGLES, 0, 6, _instanceCount); - GL.BindVertexArray(0); + _capi.Render.RenderMeshInstanced(_mesh, _instanceCount); // Restore the depth-test enable state we found on entry rather than // forcing it on: the GUI pass owns this state and later map layers // (player/entity/waypoint icons) render under whatever it was. - if (depthTestWasOn) GL.Enable((EnableCap)GL_DEPTH_TEST); - GL.DepthMask(true); + if (depthTestWasOn) _capi.Render.GLEnableDepthTest(); + _capi.Render.GLDepthMask(true); _shader.Stop(); - // Restore GL state: unbind the texture array from unit 0 so the GUI - // shader finds its expected 2D texture on that unit. - GL.ActiveTexture(TextureUnit.Texture0); - GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, 0); + // Unbind the texture array from unit 0 so the GUI shader finds its + // expected 2D texture on that unit. + if (optimumDevice != null) + { + optimumDevice.BindTexture(0, 0); + } + else + { + GL.ActiveTexture(TextureUnit.Texture0); + GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, 0); + } // Re-bind the GUI shader. Entity/player/waypoint layers call // GetEngineShader(Gui).Uniform(...) without Use() - they rely on - // the GUI program being the active GL program when their Render runs. + // the GUI program being the active program when their Render runs. guiShader?.Use(); } @@ -179,21 +187,8 @@ public void Dispose() _capi.Event.ReloadShader -= OnReloadShader; - if (_vaoId != 0) - { - GL.DeleteVertexArray(_vaoId); - _vaoId = 0; - } - if (_quadVboId != 0) - { - GL.DeleteBuffer(_quadVboId); - _quadVboId = 0; - } - if (_instanceVboId != 0) - { - GL.DeleteBuffer(_instanceVboId); - _instanceVboId = 0; - } + _mesh?.Dispose(); + _mesh = null; _shader?.Dispose(); _shader = null; } @@ -219,58 +214,60 @@ private void CreateShader() _capi.Shader.RegisterMemoryShaderProgram("optimum-map", _shader); _shader.Compile(); + } - _locScreenSize = GL.GetUniformLocation(_shader.ProgramId, "screenSize"); - _locMapPages = GL.GetUniformLocation(_shader.ProgramId, "mapPages"); - _locZValue = GL.GetUniformLocation(_shader.ProgramId, "zValue"); + private void RecreateQuadMesh() + { + _mesh?.Dispose(); + _mesh = null; + CreateQuadMesh(); } - private void CreateQuadVao() + private void CreateQuadMesh() { - _vaoId = GL.GenVertexArray(); - GL.BindVertexArray(_vaoId); - - // Quad vertices: two triangles forming a unit square [0,1]x[0,1] - float[] quadVerts = { - 0f, 0f, - 1f, 0f, - 1f, 1f, - 0f, 0f, - 1f, 1f, - 0f, 1f + // Quad vertices: two triangles forming a unit square [0,1]x[0,1]. + var mesh = new MeshData(6, 6, false, false, false, false); + mesh.xyz = new float[] + { + 0f, 0f, 0f, + 1f, 0f, 0f, + 1f, 1f, 0f, + 0f, 0f, 0f, + 1f, 1f, 0f, + 0f, 1f, 0f, }; + mesh.VerticesCount = 6; + mesh.Indices = new int[] { 0, 1, 2, 3, 4, 5 }; + mesh.IndicesCount = 6; - _quadVboId = GL.GenBuffer(); - GL.BindBuffer((BufferTarget)GL_ARRAY_BUFFER, _quadVboId); - GL.BufferData((BufferTarget)GL_ARRAY_BUFFER, quadVerts.Length * sizeof(float), quadVerts, (BufferUsageHint)35044); // GL_STATIC_DRAW - GL.EnableVertexAttribArray(0); - GL.VertexAttribPointer(0, 2, (VertexAttribPointerType)GL_FLOAT, false, 2 * sizeof(float), 0); + // Per-instance stream, sized to the CPU buffer and filled each frame. + mesh.CustomFloats = NewInstancePart(); + mesh.CustomFloats.Values = new float[_instanceData.Length]; + // Upload the full, zeroed stream so the buffer is sized for every layer. + mesh.CustomFloats.Count = _instanceData.Length; - // Instance VBO (dynamic, uploaded each frame) - _instanceVboId = GL.GenBuffer(); - GL.BindBuffer((BufferTarget)GL_ARRAY_BUFFER, _instanceVboId); - // Pre-allocate with null data - GL.BufferData((BufferTarget)GL_ARRAY_BUFFER, _instanceData.Length * sizeof(float), IntPtr.Zero, (BufferUsageHint)GL_DYNAMIC_DRAW); + _mesh = _capi.Render.UploadMesh(mesh); - int stride = 5 * sizeof(float); - - // Attribute 1: instanceRect (vec4: posX, posY, sizeX, sizeY) - GL.EnableVertexAttribArray(1); - GL.VertexAttribPointer(1, 4, (VertexAttribPointerType)GL_FLOAT, false, stride, 0); - GL.VertexAttribDivisor(1, 1); - - // Attribute 2: instanceLayer (float) - GL.EnableVertexAttribArray(2); - GL.VertexAttribPointer(2, 1, (VertexAttribPointerType)GL_FLOAT, false, stride, 4 * sizeof(float)); - GL.VertexAttribDivisor(2, 1); + // The frame update shares the instance array so no copy is needed. + _instanceUpdate.CustomFloats = NewInstancePart(); + _instanceUpdate.CustomFloats.Values = _instanceData; + } - GL.BindVertexArray(0); - GL.BindBuffer((BufferTarget)GL_ARRAY_BUFFER, 0); + private static CustomMeshDataPartFloat NewInstancePart() + { + return new CustomMeshDataPartFloat + { + InterleaveSizes = new[] { 4, 1 }, + InterleaveStride = FloatsPerInstance * sizeof(float), + InterleaveOffsets = new[] { 0, 4 * sizeof(float) }, + Instanced = true, + StaticDraw = false, + }; } // Embedded shader source (avoids asset-path dependency at this stage) private const string VertexShaderSource = @"#version 330 core -layout(location = 0) in vec2 vertexPos; +layout(location = 0) in vec3 vertexPos; layout(location = 1) in vec4 instanceRect; layout(location = 2) in float instanceLayer; @@ -282,11 +279,11 @@ private void CreateQuadVao() void main(void) { - vec2 screenPos = instanceRect.xy + vertexPos * instanceRect.zw; + vec2 screenPos = instanceRect.xy + vertexPos.xy * instanceRect.zw; vec2 ndc = (screenPos / screenSize) * 2.0 - 1.0; ndc.y = -ndc.y; gl_Position = vec4(ndc, zValue, 1.0); - texCoord = vertexPos; + texCoord = vertexPos.xy; layerIndex = instanceLayer; }"; diff --git a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs index 7fd23780..47387658 100644 --- a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs +++ b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs @@ -53,7 +53,20 @@ public OptimumMapTextureArray(int maxLayers) _freeList.Push(i); } - // Create the GL texture array + // Create the texture array. On the Vulkan backend the window has no GL + // context, so the device owns the array; the GL body stays for OpenGL. + IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; + if (optimumDevice != null) + { + TextureId = optimumDevice.CreateTexture2DArray(PageSize, PageSize, maxLayers, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba); + optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + return; + } + TextureId = GL.GenTexture(); GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, TextureId); GL.TexImage3D( @@ -151,7 +164,9 @@ public void Dispose() if (TextureId != 0) { - GL.DeleteTexture(TextureId); + IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; + if (optimumDevice != null) optimumDevice.DeleteTexture(TextureId); + else GL.DeleteTexture(TextureId); TextureId = 0; } @@ -163,6 +178,19 @@ public void Dispose() private void UploadToLayer(int layer, int[] pixels) { + IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; + if (optimumDevice != null) + { + unsafe + { + fixed (int* ptr = pixels) + { + optimumDevice.UploadTexture2DArrayLayer(TextureId, layer, 0, 0, PageSize, PageSize, (IntPtr)ptr); + } + } + return; + } + GL.BindTexture((TextureTarget)GL_TEXTURE_2D_ARRAY, TextureId); GL.TexSubImage3D( (TextureTarget)GL_TEXTURE_2D_ARRAY, diff --git a/sources/VintagestoryApi/Client/optimum-render-device.cs b/sources/VintagestoryApi/Client/optimum-render-device.cs index d5032331..1cf30a30 100644 --- a/sources/VintagestoryApi/Client/optimum-render-device.cs +++ b/sources/VintagestoryApi/Client/optimum-render-device.cs @@ -160,6 +160,11 @@ public interface IOptimumGraphicsDevice : IDisposable void SetUniform(int programId, int location, float value); void SetUniform(int programId, int location, int value); + /// + /// Writes an ivec3. The location is opaque, so the implementation, + /// not the caller, knows where the second and third components land. + /// + void SetUniform(int programId, int location, int x, int y, int z); void SetUniform(int programId, int location, float x, float y); void SetUniform(int programId, int location, float x, float y, float z); void SetUniform(int programId, int location, float x, float y, float z, float w); @@ -210,7 +215,6 @@ int CreateTexture2D(int width, int height, EnumTextureInternalFormat internalFor int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPtr pixels, int bytesPerPixel, bool generateMipmaps = false); - /// Six-layer cube map, faces in GL's +X -X +Y -Y +Z -Z order. /// /// Creates a cubemap from a raw GL internal format, the cube counterpart of /// . The skybox faces arrive as BGRA bytes, @@ -227,6 +231,19 @@ int CreateTexture2DArray(int width, int height, int layers, void UploadTexture2D(int textureId, int level, int x, int y, int width, int height, EnumTexturePixelFormat pixelFormat, IntPtr pixels); + /// + /// Uploads signed 16-bit normalised pixels (GL_SHORT into a normalised + /// format), as the cloud map's tile data does. + /// + void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, + int width, int height, short[] pixels); + + /// + /// Uploads one layer of a texture, as + /// glTexSubImage3D with depth 1 does. Pixels are RGBA8. + /// + void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, + int width, int height, IntPtr pixels); void GenerateMipmaps(int textureId); void DeleteTexture(int textureId); @@ -348,6 +365,7 @@ public static class OptimumGlConstants public const int TextureWrapT = 0x2803; public const int TextureCompareMode = 0x884C; public const int TextureLodBias = 0x8501; + public const int TextureMaxLevel = 0x813D; public const int Nearest = 0x2600; public const int Linear = 0x2601; diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index a4c81d90..5a4a480a 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -769,12 +769,11 @@ public static void Load() RenderScale = Math.Clamp(data.RenderScale, 0.5f, 1.0f); // An unrecognised value means OpenGL rather than a parse failure, so // a hand-edited config cannot leave the client unable to start. - Renderer = data.Renderer switch - { - "vulkan" => "vulkan", - "auto" => "auto", - _ => "opengl", - }; + string requestedRenderer = data.Renderer?.Trim() ?? ""; + Renderer = + string.Equals(requestedRenderer, "vulkan", StringComparison.OrdinalIgnoreCase) ? "vulkan" : + string.Equals(requestedRenderer, "auto", StringComparison.OrdinalIgnoreCase) ? "auto" : + "opengl"; GodRaysSampleCapEnabled = data.GodRaysSampleCap; MapPageCacheEnabled = data.MapPageCache; MapPageCacheMaxLayers = Math.Clamp(data.MapPageCacheMaxLayers, 16, 512); diff --git a/tools/InteropProbe/Program.cs b/tools/InteropProbe/Program.cs index eec06a12..09adad78 100644 --- a/tools/InteropProbe/Program.cs +++ b/tools/InteropProbe/Program.cs @@ -105,6 +105,7 @@ private static void ReportDirect3DRoute(HashSet glExtensions, HashSet glExtensions, HashSet glExtensions, HashSet Viable. Share via WGL_NV_DX_interop2 into D3D11, then reuse the"); Console.WriteLine(" existing D3D11/D3D12 proxy design unchanged."); @@ -219,10 +222,10 @@ private static HashSet ReadWglExtensions() } IntPtr address = WglGetProcAddress("wglGetExtensionsStringARB"); - if (address == IntPtr.Zero) + if (!IsValidProc(address)) { address = WglGetProcAddress("wglGetExtensionsStringEXT"); - if (address == IntPtr.Zero) + if (!IsValidProc(address)) { return extensions; } @@ -261,4 +264,15 @@ private static void AddAll(HashSet target, string? spaceSeparated) [DllImport("opengl32.dll", EntryPoint = "wglGetCurrentDC")] private static extern IntPtr WglGetCurrentDC(); + + /// + /// wglGetProcAddress can return NULL, but also 1, 2, 3 or -1 for an + /// unsupported entry point - those small sentinel values must not be + /// treated as valid function pointers. + /// + private static bool IsValidProc(IntPtr p) + { + long value = p.ToInt64(); + return value != 0 && value != 1 && value != 2 && value != 3 && value != -1; + } } From 2c296fbc2c92b5e61d21807b148768d54ef65e59 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 13:47:48 +0200 Subject: [PATCH 006/226] fix(render): harden renderer install, shader intake and texture dumps - Bootstrap: Install is idempotent and never displaces a published device; the crash marker is written before the driver is touched and cleared on a clean failure; Shutdown resets ActiveBackend and NoGraphicsApiWindow. - ClientProgram calls OptimumRenderBootstrap.Shutdown on exit. Nothing did, so the crash marker survived every clean exit and the next start fell back to OpenGL. - CompileShader rejects stage sources over 2 MiB or containing NUL bytes before they reach shaderc, with a diagnostic. - TextureDump requires an absolute OPTIMUM_DUMP_DIR, never writes into the working directory, prefixes files per run and opens them CreateNew so no existing file is truncated. --- Optimum.Render.Vulkan/Core/TextureDump.cs | 44 ++++++++++++++----- Optimum.Render.Vulkan/VulkanDevice.cs | 19 ++++++++ .../ClientProgram.cs.patch | 16 ++++++- .../Client/optimum-render-bootstrap.cs | 24 +++++++++- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/Optimum.Render.Vulkan/Core/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs index eecf5c23..e7fa12b0 100644 --- a/Optimum.Render.Vulkan/Core/TextureDump.cs +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -14,8 +14,9 @@ namespace Optimum.Render.Vulkan.Core; /// texture saw, with no inference in between. /// /// Off unless OPTIMUM_DUMP_TEXTURES lists texture ids, comma separated. The -/// files land beside the render trace, or in OPTIMUM_DUMP_DIR when that is set, -/// as binary PPM - a five-line header and raw RGB, which needs no encoder here +/// files land in OPTIMUM_DUMP_DIR when that names an absolute path, else beside +/// the render trace, else under the temp directory - never the working +/// directory - as binary PPM - a five-line header and raw RGB, which needs no encoder here /// and which every image tool reads. /// internal static class TextureDump @@ -82,19 +83,38 @@ public static int[] Take() /// Removes an id from the pending set once it has been written successfully. public static void Complete(int textureId) => Pending.Remove(textureId); - private static string Directory() + /// + /// Where the files go. An explicit OPTIMUM_DUMP_DIR must be absolute so + /// the launching environment names the location outright rather than + /// relative to whatever the working directory happens to be; otherwise + /// the files sit beside the render trace, and failing both, under a + /// dedicated folder in the temp directory. Never the working directory. + /// + private static string? Directory() { string? explicitDir = Environment.GetEnvironmentVariable("OPTIMUM_DUMP_DIR"); - if (!string.IsNullOrWhiteSpace(explicitDir)) return explicitDir; + if (!string.IsNullOrWhiteSpace(explicitDir)) + { + return Path.IsPathRooted(explicitDir) ? Path.GetFullPath(explicitDir) : null; + } string? tracePath = Environment.GetEnvironmentVariable("OPTIMUM_RENDER_TRACE"); - string? beside = string.IsNullOrWhiteSpace(tracePath) - ? null - : Path.GetDirectoryName(Path.GetFullPath(tracePath)); + if (!string.IsNullOrWhiteSpace(tracePath) && Path.IsPathRooted(tracePath)) + { + string? beside = Path.GetDirectoryName(Path.GetFullPath(tracePath)); + if (!string.IsNullOrWhiteSpace(beside)) return beside; + } - return string.IsNullOrWhiteSpace(beside) ? "." : beside; + return Path.Combine(Path.GetTempPath(), "optimum-texture-dumps"); } + /// + /// One prefix per process, so two runs into the same directory never + /// overwrite each other's files and a run never overwrites its own. + /// + private static readonly string RunPrefix = + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + "-" + Environment.ProcessId; + /// /// Writes RGBA or BGRA bytes as a binary PPM. /// @@ -105,11 +125,13 @@ public static bool Write(int textureId, int width, int height, bool bgra, ReadOn try { - string directory = Directory(); + string? directory = Directory(); + if (directory == null) return false; System.IO.Directory.CreateDirectory(directory); - string path = Path.Combine(directory, $"texture-{textureId}-{width}x{height}.ppm"); + string path = Path.Combine(directory, $"{RunPrefix}-texture-{textureId}-{width}x{height}.ppm"); - using var file = new FileStream(path, FileMode.Create, FileAccess.Write); + // CreateNew: an existing file is never truncated, whatever named it. + using var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write); using var writer = new BinaryWriter(file); foreach (char c in $"P6\n{width} {height}\n255\n") writer.Write((byte)c); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index c3266ff2..8a2a4664 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -822,10 +822,29 @@ public void SetBlendEquation(int attachment, int mode) => /// and varyings by name across the whole program, so nothing about a stage is /// final until its siblings are known. /// + /// + /// Largest shader source accepted per stage. Vanilla's biggest stage is + /// well under 100 KiB; the cap keeps a broken or hostile mod shader from + /// handing the native compiler an unbounded input. + /// + internal const int MaxShaderSourceBytes = 2 * 1024 * 1024; + public bool CompileShader(IShader shader) { if (shader?.Code == null) return false; + string stageName = shader.Type.ToString(); + if (shader.Code.Length + (shader.PrefixCode?.Length ?? 0) > MaxShaderSourceBytes) + { + _diagnostics.Add($"{stageName}: shader source exceeds {MaxShaderSourceBytes} bytes and was rejected"); + return false; + } + if (shader.Code.IndexOf('\0') >= 0 || (shader.PrefixCode?.IndexOf('\0') ?? -1) >= 0) + { + _diagnostics.Add($"{stageName}: shader source contains a NUL byte and was rejected"); + return false; + } + _stagedStages[shader] = new StagedStage { Stage = shader.Type, diff --git a/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch b/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch index c1cdaeb1..5bff3f6f 100644 --- a/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client/ClientProgram.cs b/VintagestoryLib/Vintagestory.Client/ClientProgram.cs -index afa4d5f..2d8144a 100644 +index afa4d5f..8adc77a 100644 --- a/VintagestoryLib/Vintagestory.Client/ClientProgram.cs +++ b/VintagestoryLib/Vintagestory.Client/ClientProgram.cs @@ -46,10 +46,45 @@ public class ClientProgram @@ -126,3 +126,17 @@ index afa4d5f..2d8144a 100644 try { ((NativeWindow)gameWindowNative).CenterWindow(); +@@ -337,10 +437,13 @@ public class ClientProgram + } + Thread.CurrentThread.Priority = ThreadPriority.Normal; + ScreenManager.Platform.Logger.Debug("After gamewindow.Run()"); + clientPlatformWindows.DisposeFrameBuffers(clientPlatformWindows.FrameBuffers); + clientPlatformWindows.StopAudio(); ++ // Tear the Vulkan device down before its window goes, and clear the ++ // crash marker so the next start does not read this exit as a crash. ++ OptimumRenderBootstrap.Shutdown(); + ((NativeWindow)gameWindowNative).Dispose(); + } + } + + private GameWindowNative AttemptToOpenWindow(GameWindowSettings gameWindowSettings, NativeWindowSettings nativeWindowSettings, int openGlMajor, int openGlMinor, int tries) diff --git a/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs b/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs index cc4998b5..28ad9fc7 100644 --- a/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs +++ b/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs @@ -109,6 +109,14 @@ public static bool Install(IntPtr windowHandle, int width, int height, string da { reason = null; + // Installing is a single transition: a device that is already published + // stays, so a second call cannot displace and leak the one the client + // is drawing with. + if (OptimumRender.Device != null) + { + return true; + } + try { if (!TryLoadBackend(out reason)) return false; @@ -128,27 +136,37 @@ public static bool Install(IntPtr windowHandle, int width, int height, string da return false; } + // The marker goes down before the driver is touched: a crash inside + // device creation is exactly the kind the next start must see. A + // clean failure clears it again, since the caller falls back to + // OpenGL on its own. + WriteCrashMarker(dataPath); + string failureReason; if (!device.Initialize(windowHandle, width, height, out failureReason)) { device.Dispose(); + ClearCrashMarker(); reason = failureReason; return false; } OptimumRender.Device = device; OptimumRender.ActiveBackend = EnumRenderBackend.Vulkan; - WriteCrashMarker(dataPath); return true; } catch (Exception error) { + ClearCrashMarker(); reason = error.Message; return false; } } - /// Shuts the device down and clears the crash marker. + /// + /// Shuts the device down, returns the backend state to OpenGL and clears the + /// crash marker. Safe to call more than once and on the OpenGL path. + /// public static void Shutdown() { try @@ -164,6 +182,8 @@ public static void Shutdown() } OptimumRender.Device = null; + OptimumRender.ActiveBackend = EnumRenderBackend.OpenGL; + OptimumRender.NoGraphicsApiWindow = false; ClearCrashMarker(); } From 0dabacab90d6d9f35bf7e4b70df43c7be201b46d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 14:03:48 +0200 Subject: [PATCH 007/226] fix(render): address second review round - Geometry depth remap redirects EmitVertex() to a header helper that remaps then emits, so unbraced if/else and loop bodies keep their scope. - SplicePrefix keeps a #version line with no trailing newline first. - SubmitAndWaitLocked checks BeginCommandBuffer, EndCommandBuffer and CreateFence results; the fence is only destroyed if it was created. - The FXAA define uses EffectiveRenderScale, matching the LOD-bias decision. --- .../ShaderTranslationUnitTests.cs | 33 +++++++++++++++---- Optimum.Render.Vulkan/Core/VulkanResources.cs | 9 +++-- .../Shaders/ShaderCompiler.cs | 8 +++-- .../Shaders/ShaderRewriter.cs | 21 +++++++++--- .../ShaderRegistry.cs.patch | 4 +-- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs index 6d270db9..cf74d798 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs @@ -207,13 +207,11 @@ public void RewritingAGeometryStageRemapsDepthBeforeEveryEmitVertex() const string source = """ #version 330 core layout(triangles) in; - layout(triangle_strip, max_vertices = 3) out; + layout(triangle_strip, max_vertices = 4) out; + uniform bool visible; void main() { - for (int i = 0; i < 3; i++) { - gl_Position = gl_in[i].gl_Position; - EmitVertex(); - } - EmitVertex (); + for (int i = 0; i < 3; i++) gl_Position = gl_in[i].gl_Position, EmitVertex(); + if (visible) EmitVertex (); else EndPrimitive(); EndPrimitive(); } """; @@ -225,7 +223,28 @@ void main() { Assert.Empty(rewritten.Errors); Assert.DoesNotContain("_optimum_main", code); - Assert.Equal(2, CountOf(code, "gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; EmitVertex")); + + // One helper, defined before any user code, that remaps then emits. + Assert.Contains("void _optimum_emit_vertex() { gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; EmitVertex(); }", code); + Assert.True(code.IndexOf("_optimum_emit_vertex()", StringComparison.Ordinal) + < code.IndexOf("void main()", StringComparison.Ordinal)); + + // Every call site is redirected as a single statement, so the unbraced + // loop body and the if/else keep their shape. + Assert.Equal(1, CountOf(code, "EmitVertex();")); // only inside the helper + Assert.Contains("gl_Position = gl_in[i].gl_Position, _optimum_emit_vertex();", code); + Assert.Contains("if (visible) _optimum_emit_vertex (); else EndPrimitive();", code); + } + + [Fact] + public void ThePrefixFollowsAVersionLineThatHasNoNewline() + { + string spliced = ShaderCompiler.SplicePrefix("#version 330 core", "#define A 1\n"); + Assert.StartsWith("#version 330 core\n#define A 1\n", spliced); + + Assert.Equal("#define A 1\nvoid main() {}", ShaderCompiler.SplicePrefix("void main() {}", "#define A 1\n")); + Assert.Equal("#version 330\n#define A 1\nvoid main() {}", + ShaderCompiler.SplicePrefix("#version 330\nvoid main() {}", "#define A 1\n")); } private static int CountOf(string text, string needle) diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index 5415370c..3a934a30 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -385,12 +385,15 @@ private void SubmitAndWaitLocked(Action record) SType = StructureType.CommandBufferBeginInfo, Flags = CommandBufferUsageFlags.OneTimeSubmitBit, }; - api.BeginCommandBuffer(commandBuffer, &begin); + VulkanResult.Check(api.BeginCommandBuffer(commandBuffer, &begin), + "vkBeginCommandBuffer for a setup command buffer"); record(commandBuffer); - api.EndCommandBuffer(commandBuffer); + VulkanResult.Check(api.EndCommandBuffer(commandBuffer), + "vkEndCommandBuffer for a setup command buffer"); var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo }; - api.CreateFence(_context.Device, &fenceInfo, null, out fence); + VulkanResult.Check(api.CreateFence(_context.Device, &fenceInfo, null, out fence), + "vkCreateFence for a setup command buffer"); fenceCreated = true; var submit = new SubmitInfo diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs index 9b43bb18..d17f5d44 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -134,10 +134,12 @@ internal static string SplicePrefix(string code, string prefixCode) int versionIndex = code.IndexOf("#version", StringComparison.Ordinal); if (versionIndex < 0) return prefixCode + code; - int insertAt = code.IndexOf('\n', versionIndex) + 1; - if (insertAt <= 0) return prefixCode + code; + // A #version with nothing after it is a complete first line; the + // prefix follows it rather than displacing it. + int lineEnd = code.IndexOf('\n', versionIndex); + if (lineEnd < 0) return code + "\n" + prefixCode; - return code.Insert(insertAt, prefixCode); + return code.Insert(lineEnd + 1, prefixCode); } /// diff --git a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs index fc0d1dc9..ff86e98d 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs @@ -60,7 +60,7 @@ public static RewrittenShader Rewrite( string source = parsed.Source; var edits = new List(); - AddHeaderEdits(parsed, layout, stage, edits); + AddHeaderEdits(parsed, layout, stage, emitDepthRemap, edits); foreach (GlslDeclaration declaration in parsed.Declarations) { @@ -113,7 +113,8 @@ public static RewrittenShader Rewrite( // -------------------------------------------------------------------- header private static void AddHeaderEdits( - ParsedShader parsed, ProgramInterfaceLayout layout, EnumShaderType stage, List edits) + ParsedShader parsed, ProgramInterfaceLayout layout, EnumShaderType stage, bool emitDepthRemap, + List edits) { string block = BuildUniformBlock(layout, stage); @@ -125,6 +126,13 @@ private static void AddHeaderEdits( } header.Append(block); + // The geometry stage's EmitVertex() replacement lives in the header so + // it precedes every function that may call it. + if (emitDepthRemap && stage == EnumShaderType.GeometryShader) + { + header.Append("void " + EmitVertexReplacementName + "() { " + DepthRemapStatement + " EmitVertex(); }\n"); + } + if (parsed.VersionStart >= 0) { edits.Add(new Edit(parsed.VersionStart, parsed.VersionLength, header.ToString().TrimEnd('\n'))); @@ -294,10 +302,15 @@ private static void AddDepthRemapEdits( "}\n")); } + private const string EmitVertexReplacementName = "_optimum_emit_vertex"; + /// /// A geometry stage snapshots gl_Position at every EmitVertex(), /// so a wrapper around main would run after every vertex has already - /// left. The remap goes immediately before each emit instead. + /// left. Each call is redirected to a helper that remaps and then emits, + /// which keeps the call a single statement: an unbraced if or loop + /// body around it keeps its scope, where an inserted extra statement would + /// not. /// private static void AddGeometryDepthRemapEdits(ParsedShader parsed, List edits, RewrittenShader result) { @@ -314,7 +327,7 @@ private static void AddGeometryDepthRemapEdits(ParsedShader parsed, List e bool isCall = after < source.Length && source[after] == '('; if (!startsWord || !isCall) continue; - edits.Add(new Edit(at, 0, DepthRemapStatement + " ")); + edits.Add(new Edit(at, call.Length, EmitVertexReplacementName)); found++; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index dee0123b..7601bf86 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..e278e46 100644 +index 4a24e75..a45ac57 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -185,7 +185,7 @@ index 4a24e75..e278e46 100644 { Shader fragmentShader = program.FragmentShader; - fragmentShader.PrefixCode = fragmentShader.PrefixCode + "#define FXAA " + (ClientSettings.FXAA ? 1 : 0) + "\r\n"; -+ fragmentShader.PrefixCode = fragmentShader.PrefixCode + "#define FXAA " + (ClientSettings.FXAA && ClientSettings.OptimumRenderScale >= 1.0f ? 1 : 0) + "\r\n"; ++ fragmentShader.PrefixCode = fragmentShader.PrefixCode + "#define FXAA " + (ClientSettings.FXAA && OptimumConfig.EffectiveRenderScale >= 1.0f ? 1 : 0) + "\r\n"; Shader fragmentShader2 = program.FragmentShader; fragmentShader2.PrefixCode = fragmentShader2.PrefixCode + "#define SSAOLEVEL " + ClientSettings.SSAOQuality + "\r\n"; Shader fragmentShader3 = program.FragmentShader; From 02268a815eb85fa4171a1568ac73aa7f66c9fbf2 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 15:40:33 +0200 Subject: [PATCH 008/226] feat(render): TAA P0 prerequisites - Vulkan named uniform blocks (client UBOs such as Animation) are now snapshotted per draw into the frame ring and bound through dynamic offsets, like the generated block. Before this every draw recorded in a frame read the last upload, so all animated entities shared one pose. On ring exhaustion a draw gets a transient copy instead of aliasing the shared buffer; the ring grows to 32 MiB and overflows are counted in the stats line. Identical re-uploads no longer bump the version. - Descriptor pools budget eight dynamic uniform descriptors per set. - Texture dumps are format-aware (RGBA16F, R32F, R8) instead of assuming four bytes per pixel. - New OptimumTemporalMath (Halton jitter, projection shear, motion-vector adapters) with convention tests, a render-system inventory test that pins the plan's table to the source, device tests for sparse multi-attachment writes, per-attachment blending and read-only depth sampling, and UBO tests that fail under the old behaviour. - Two stale source-string tests updated for the ivec3 seam member and the case-insensitive renderer setting. --- .../AttachmentSemanticsTests.cs | 549 ++++++++++++++++++ .../TextureDumpTests.cs | 84 +++ .../VulkanDeviceIntegrationTests.cs | 237 ++++++++ Optimum.Render.Vulkan/Core/DescriptorCache.cs | 38 +- Optimum.Render.Vulkan/Core/FrameRing.cs | 2 +- .../Core/ShaderProgramResources.cs | 6 +- Optimum.Render.Vulkan/Core/TextureDump.cs | 116 +++- Optimum.Render.Vulkan/Core/VulkanStats.cs | 11 +- Optimum.Render.Vulkan/VulkanDevice.cs | 316 ++++++++-- Optimum.Tests/temporal-conventions-tests.cs | 197 +++++++ .../temporal-render-inventory-tests.cs | 147 +++++ .../vulkan-backend-integration-tests.cs | 9 +- .../optimum-api-contracts.csproj | 1 + .../Client/Render/OptimumTemporalMath.cs | 86 +++ .../VintagestoryApi/VintagestoryAPI.csproj | 1 + 15 files changed, 1706 insertions(+), 94 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/TextureDumpTests.cs create mode 100644 Optimum.Tests/temporal-conventions-tests.cs create mode 100644 Optimum.Tests/temporal-render-inventory-tests.cs create mode 100644 sources/VintagestoryApi/Client/Render/OptimumTemporalMath.cs diff --git a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs new file mode 100644 index 00000000..70a6f79d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -0,0 +1,549 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Device-level proof of the multi-attachment behaviour a TAA motion attachment +/// will lean on: a fifth colour attachment carried alongside colour, glow and OIT +/// that a resolve pass writes selectively and reads back read-only, without +/// disturbing its neighbours. +/// +/// Every test here runs with validation on and asserts a clean log, the same way +/// RenderTargetTests and WorldRenderPathTests do - the failures this guards +/// against render a legal, silently wrong frame rather than throwing. +/// +public class AttachmentSemanticsTests +{ + private readonly ITestOutputHelper _output; + + public AttachmentSemanticsTests(ITestOutputHelper output) => _output = output; + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + /// + /// Five colour attachments, a shader that declares outputs only at locations + /// 0 and 4, and a draw-buffer mask that enables only 0 and 4. A motion + /// attachment sitting at index 4 alongside colour, glow and two OIT layers + /// must receive exactly its own write and leave 1-3 alone, the same way + /// glow does at index 1 today. + /// + [SkippableFact] + public unsafe void OnlyTheDeclaredLocationsAmongFiveAttachmentsAreWritten() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + var attachment = new int[5]; + byte[] seeds = { 0x10, 0x30, 0x50, 0x70, 0x90 }; + for (int i = 0; i < 5; i++) + { + attachment[i] = textures.Create(size, size, Format.R8G8B8A8Unorm); + FillTexture(textures, attachment[i], size, seeds[i]); + } + + int framebuffer = targets.Create(size, size); + for (int i = 0; i < 5; i++) targets.Attach(framebuffer, i, attachment[i]); + targets.SetDrawBuffers(framebuffer, 0b10001); // 0 and 4 only + + TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + layout(location = 4) out vec4 outMotion; + void main(void) + { + outColor = vec4(1.0, 0.0, 0.0, 1.0); + outMotion = vec4(0.0, 0.0, 1.0, 1.0); + } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + byte[] color = ReadTexture(context!, commands, textures, attachment[0], size); + byte[] motion = ReadTexture(context!, commands, textures, attachment[4], size); + + Assert.Equal(255, color[0]); + Assert.Equal(0, color[2]); + Assert.Equal(0, motion[0]); + Assert.Equal(255, motion[2]); + + for (int i = 1; i <= 3; i++) + { + byte[] untouched = ReadTexture(context!, commands, textures, attachment[i], size); + Assert.All(untouched, b => Assert.Equal(seeds[i], b)); + } + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// Same five-attachment framebuffer, but the mask enables all five while the + /// shader statically writes only location 0. The Vulkan spec leaves the + /// unwritten locations' contents undefined rather than promising they are + /// preserved, so this documents what this driver actually does rather than + /// asserting a guarantee the TAA design may not lean on: measured on this + /// device, an attachment the shader never writes keeps its prior contents, + /// the same as if it had been masked out of glDrawBuffers. + /// + [SkippableFact] + public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContentsOnThisDriver() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + var attachment = new int[5]; + byte[] seeds = { 0x10, 0x30, 0x50, 0x70, 0x90 }; + for (int i = 0; i < 5; i++) + { + attachment[i] = textures.Create(size, size, Format.R8G8B8A8Unorm); + FillTexture(textures, attachment[i], size, seeds[i]); + } + + int framebuffer = targets.Create(size, size); + for (int i = 0; i < 5; i++) targets.Attach(framebuffer, i, attachment[i]); + targets.SetDrawBuffers(framebuffer, 0b11111); // all five enabled + + TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(1.0, 0.0, 0.0, 1.0); } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + byte[] color = ReadTexture(context!, commands, textures, attachment[0], size); + Assert.Equal(255, color[0]); + Assert.Equal(0, color[2]); + + // Observed reality on this driver, not a Vulkan guarantee: locations + // the shader never wrote came through unchanged, exactly like the + // masked-out case above. The TAA resolve pass must not be written to + // depend on this - it has to name every attachment it touches in + // both the shader and the draw-buffer mask, as the tests above do. + for (int i = 1; i <= 4; i++) + { + byte[] untouched = ReadTexture(context!, commands, textures, attachment[i], size); + Assert.All(untouched, b => Assert.Equal(seeds[i], b)); + } + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// Attachment 0 blends normally (standard alpha) while attachment 4 - where + /// a motion attachment would sit - is switched to replace blending via + /// SetBlendFuncSeparate, and the shader writes alpha 0 to both. If the two + /// attachments shared one blend state, alpha-0 would leave both at their + /// destination colour; independent state must let attachment 4 come through + /// as the plain source value regardless of the alpha the draw wrote. + /// + [SkippableFact] + public unsafe void EachAttachmentBlendsWithItsOwnFactorsRegardlessOfSharedAlpha() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int colorTexture = textures.Create(size, size, Format.R8G8B8A8Unorm); + int motionTexture = textures.Create(size, size, Format.R8G8B8A8Unorm); + FillTexture(textures, colorTexture, size, 0x33); + FillTexture(textures, motionTexture, size, 0x33); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, colorTexture); + targets.Attach(framebuffer, 4, motionTexture); + targets.SetDrawBuffers(framebuffer, 0b10001); // 0 and 4, 1-3 unattached + + // Attachment 0: ordinary alpha blending. + state.SetBlend(true, EnumBlendMode.Standard); + // Attachment 4: GL_ONE, GL_ZERO on both channels - a plain replace, + // independent of the alpha the fragment writes. + state.SetAttachmentBlendFunc(4, 1, 0, 1, 0); + + TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + layout(location = 4) out vec4 outMotion; + void main(void) + { + outColor = vec4(0.8, 0.8, 0.8, 0.0); + outMotion = vec4(0.8, 0.8, 0.8, 0.0); + } + """); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + using var program = new ShaderProgramResources(context!, 1, translated); + state.SetProgram(1); + + RenderFullscreen(context!, commands, targets, pipelines, state, program, framebuffer, size); + + byte[] color = ReadTexture(context!, commands, textures, colorTexture, size); + byte[] motion = ReadTexture(context!, commands, textures, motionTexture, size); + + // Standard blend, source alpha 0: dst * 1 + src * 0, so the + // destination colour (0x33 = 51) survives. + Assert.InRange(color[0], 43, 59); + // Replace blend ignores alpha entirely: the source value (0.8 * 255 + // = 204) lands regardless. + Assert.InRange(motion[0], 196, 212); + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// A resolve pass samples the depth attachment it is itself bound against, + /// with depth writes off - exactly what a TAA resolve does to reconstruct + /// world position. The scope has to drop the depth attachment into + /// DEPTH_READ_ONLY_OPTIMAL rather than the write layout, or this is either a + /// validation error (reading an attachment layout as a sampled image) or a + /// feedback loop. + /// + [SkippableFact] + public unsafe void TheBoundFramebuffersDepthCanBeSampledWithWritesOff() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + const uint size = 8; + using var commands = new VulkanCommands(context!); + using var textures = new TextureManager(context!, commands); + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + + int depth = textures.Create(size, size, Format.D32Sfloat); + int color = textures.Create(size, size, Format.R8G8B8A8Unorm); + + // A depth-only framebuffer to draw the known depth into, the same + // way the shadow / opaque passes do. + int depthPass = targets.Create(size, size); + targets.Attach(depthPass, -1, depth); + targets.SetDrawBuffers(depthPass, 0); + + TranslatedProgram depthOnly = Translate(compiler, """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.5, 1.0); + } + """, """ + #version 330 core + void main(void) { } + """); + Assert.True(depthOnly.Success, string.Join("; ", depthOnly.Errors)); + + using var depthProgram = new ShaderProgramResources(context!, 1, depthOnly); + state.SetProgram(1); + state.SetDepthTest(true); + state.SetDepthWrite(true); + state.SetDepthFunc(0x203); // GL_LEQUAL + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, depthPass); + targets.ClearDepth(commandBuffer, 1f); + targets.EndRendering(commandBuffer); + }); + + RenderFullscreen(context!, commands, targets, pipelines, state, depthProgram, depthPass, size, + depthTest: true); + + // The resolve target: the same depth texture attached read-only, + // plus a colour attachment the sampled value is written into. + int resolvePass = targets.Create(size, size); + targets.Attach(resolvePass, -1, depth); + targets.Attach(resolvePass, 0, color); + targets.SetDrawBuffers(resolvePass, 0b1); + + TranslatedProgram resolveTranslated = Translate(compiler, FullscreenVertex, """ + #version 330 core + uniform sampler2D depthTex; + layout(location = 0) out vec4 outColor; + void main(void) + { + float d = texelFetch(depthTex, ivec2(gl_FragCoord.xy), 0).r; + outColor = vec4(d, 0.0, 0.0, 1.0); + } + """); + Assert.True(resolveTranslated.Success, string.Join("; ", resolveTranslated.Errors)); + + using var resolveProgram = new ShaderProgramResources(context!, 2, resolveTranslated); + state.SetProgram(2); + + RenderFullscreenSamplingDepth( + context!, commands, textures, targets, pipelines, state, resolveProgram, resolvePass, depth, size); + + byte[] resolved = ReadTexture(context!, commands, textures, color, size); + + // (0.5 + 1.0) * 0.5 = 0.75, the GL-to-Vulkan depth remap - measured + // the same way ADepthOnlyTargetStoresWhatWasDrawn does, then read + // back through the sampled copy rather than a direct depth readback. + Assert.InRange(resolved[0], (byte)185, (byte)198); + + ValidationAssert.NoErrors(messages); + } + } + + // ------------------------------------------------------------------ helpers + + private static TranslatedProgram Translate(ShaderCompiler compiler, string vertex, string fragment) => + ShaderTranslator.Translate(new[] + { + new ShaderStageSource { Stage = EnumShaderType.VertexShader, Code = vertex, Filename = "t.vsh" }, + new ShaderStageSource { Stage = EnumShaderType.FragmentShader, Code = fragment, Filename = "t.fsh" }, + }, compiler); + + private static unsafe void RenderFullscreen( + VulkanContext context, VulkanCommands commands, RenderTargetManager targets, + GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + int framebuffer, uint size, bool depthTest = false) + { + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + int attachmentCount = targets.EnabledAttachmentCount(bound); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(0, formatsId, attachmentCount), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + + Vk api = context.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, depthTest); + api.CmdSetDepthWriteEnable(commandBuffer, depthTest); + api.CmdSetDepthCompareOp(commandBuffer, depthTest ? CompareOp.LessOrEqual : CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + targets.EndRendering(commandBuffer); + }); + } + + /// + /// Like , but binds one combined-image-sampler + /// descriptor referring to at set 1, + /// binding 0 - the shape a resolve pass reading its own depth attachment + /// needs. The framebuffer's depth attachment is put in + /// DEPTH_READ_ONLY_OPTIMAL for the scope rather than the write layout, and + /// depth test/write stay off throughout. + /// + private static unsafe void RenderFullscreenSamplingDepth( + VulkanContext context, VulkanCommands commands, TextureManager textures, RenderTargetManager targets, + GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + int framebuffer, int sampledDepthTextureId, uint size) + { + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + int attachmentCount = targets.EnabledAttachmentCount(bound); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(0, formatsId, attachmentCount), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + using var descriptors = new DescriptorCache(context); + + commands.SubmitAndWait(commandBuffer => + { + Vk api = context.Api; + + targets.Bind(commandBuffer, framebuffer); + targets.SetDepthReadOnly(true); + targets.EnsureRendering(commandBuffer); + + VulkanTexture depthTexture = textures.Get(sampledDepthTextureId)!; + var samplerBinding = new SamplerBindingValue( + (uint)program.Interface.Samplers[0].Binding, + depthTexture.View, + textures.Samplers.Get(depthTexture.State), + depthTexture.Id, + ImageLayout.DepthReadOnlyOptimal); + + DescriptorSet samplerSet = descriptors.Get( + new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.SamplerSet, + new[] { samplerBinding }, Array.Empty()), + program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); + + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); + + var viewport = new Viewport(0, 0, size, size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(size, size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, false); + api.CmdSetDepthWriteEnable(commandBuffer, false); + api.CmdSetDepthCompareOp(commandBuffer, CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + targets.EndRendering(commandBuffer); + }); + + targets.SetDepthReadOnly(false); + } + + private static unsafe void FillTexture(TextureManager textures, int textureId, uint size, byte value) + { + var pixels = new byte[size * size * 4]; + Array.Fill(pixels, value); + fixed (byte* data = pixels) + { + textures.Upload(textureId, 0, 0, 0, size, size, (IntPtr)data, 4); + } + } + + private static unsafe byte[] ReadTexture( + VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * 4; + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[(int)bytes]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } +} diff --git a/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs b/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs new file mode 100644 index 00000000..18c22cbf --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Covers the format-aware conversion in - in +/// particular that a 16-bit float attachment (the shape a TAA motion vector +/// target takes) is accepted and converted rather than read as 8-bit RGBA and +/// either overrun or garbled. +/// +public class TextureDumpTests +{ + [Fact] + public void WritesRgba16FloatTextureAsPpm() + { + const int width = 4; + const int height = 3; + + string directory = Path.Combine(Path.GetTempPath(), "optimum-texture-dump-tests-" + Guid.NewGuid()); + string? previousDir = Environment.GetEnvironmentVariable("OPTIMUM_DUMP_DIR"); + string? previousTrace = Environment.GetEnvironmentVariable("OPTIMUM_RENDER_TRACE"); + try + { + Environment.SetEnvironmentVariable("OPTIMUM_DUMP_DIR", directory); + Environment.SetEnvironmentVariable("OPTIMUM_RENDER_TRACE", null); + + var texels = new Half[width * height * 4]; + for (int i = 0; i < texels.Length; i++) + { + // Cycle through channel values so every component participates. + texels[i] = (Half)((i % 4) switch + { + 0 => 1f, + 1 => 0.5f, + 2 => 0f, + _ => 1f, // alpha, ignored by the PPM + }); + } + byte[] data = MemoryMarshal.AsBytes(texels).ToArray(); + + bool written = TextureDump.Write( + textureId: 1234, + width: width, + height: height, + bgra: false, + format: Format.R16G16B16A16Sfloat, + data: data); + + Assert.True(written); + + string path = Directory.GetFiles(directory, "*-texture-1234-*.ppm").SingleOrDefault() + ?? throw new Xunit.Sdk.XunitException("No dump file was written."); + + byte[] file = File.ReadAllBytes(path); + string header = $"P6\n{width} {height}\n255\n"; + string actualHeader = System.Text.Encoding.ASCII.GetString(file, 0, header.Length); + Assert.Equal(header, actualHeader); + + int expectedPixelBytes = width * height * 3; + Assert.Equal(header.Length + expectedPixelBytes, file.Length); + + // First texel is (1, 0.5, 0) -> full red, half green, zero blue. + int pixelStart = header.Length; + Assert.Equal(255, file[pixelStart]); + Assert.InRange(file[pixelStart + 1], 126, 128); + Assert.Equal(0, file[pixelStart + 2]); + } + finally + { + Environment.SetEnvironmentVariable("OPTIMUM_DUMP_DIR", previousDir); + Environment.SetEnvironmentVariable("OPTIMUM_RENDER_TRACE", previousTrace); + if (Directory.Exists(directory)) + { + try { Directory.Delete(directory, recursive: true); } catch (IOException) { } + } + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index ef63d8ef..0b09b16d 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -832,6 +832,243 @@ void main(void) } } + /// + /// The per-entity uniform bug. The client keeps one UBO per named block and + /// re-uploads it immediately before each draw - EntityShapeRenderer does this + /// with the "Animation" block, once per entity - but a draw is only recorded + /// when it is issued, not executed. A backend that wrote the client's buffer + /// in place and bound that buffer would give every entity in the frame the + /// last entity's transforms, because all of those draws execute after the + /// last upload. + /// + /// Two quads, two uploads, one frame: each quad has to come out the colour + /// that was in the block when it was drawn. + /// + [SkippableFact] + public unsafe void TwoDrawsInOneFrameEachSeeTheBlockContentsTheyWereGiven() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 16; + + int program = LinkProgram(seam, """ + #version 330 core + layout(location = 0) in vec3 position; + void main(void) { gl_Position = vec4(position, 1.0); } + """, """ + #version 330 core + layout(std140) uniform Tint { vec4 tint; }; + out vec4 outColor; + void main(void) { outColor = tint; } + """); + + int left = HalfScreenQuad(seam, -1f, 0f); + int right = HalfScreenQuad(seam, 0f, 1f); + + int target = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + int ubo = seam.CreateUniformBuffer(program, 0, "Tint", sizeof(float) * 4); + Assert.True(ubo > 0); + seam.BindUniformBuffer(ubo); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.ClearColor(0, 0, 0, 0, 1); + + SetTint(seam, ubo, 60, 120, 180); + seam.DrawMesh(left); + + // The same block, rewritten between two draws of the same frame. + SetTint(seam, ubo, 200, 40, 90); + seam.DrawMesh(right); + seam.Present(); + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + + int leftPixel = (size / 2 * size + size / 4) * 4; + int rightPixel = (size / 2 * size + size * 3 / 4) * 4; + + Assert.Equal(60, pixels[leftPixel + 0]); + Assert.Equal(120, pixels[leftPixel + 1]); + Assert.Equal(180, pixels[leftPixel + 2]); + + Assert.Equal(200, pixels[rightPixel + 0]); + Assert.Equal(40, pixels[rightPixel + 1]); + Assert.Equal(90, pixels[rightPixel + 2]); + + AssertNoValidationErrors(seam); + } + } + + /// + /// The same hazard across the frames-in-flight boundary. The frame the GPU is + /// still executing must not see the block the frame being recorded uploaded, + /// and the descriptor set has to stay the same set: a per-frame snapshot that + /// changed the set contents would grow the cache without bound. + /// + [SkippableFact] + public unsafe void ConsecutiveFramesEachSeeTheirOwnBlockContents() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + // Big enough, with a long enough fragment loop, that the first frame + // is still running on the GPU while the second is recorded: that is + // the window the buffer-per-block design got wrong. + const int size = 512; + + int program = LinkProgram(seam, """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """, """ + #version 330 core + layout(std140) uniform Tint { vec4 tint; }; + out vec4 outColor; + void main(void) + { + // Busywork whose result is never actually reached, but which + // the compiler cannot drop: the trip count and the branch both + // depend on the fragment. The colour written is the tint, + // untouched, so the assertion stays exact. + float busy = 0.0; + int n = 8192 + int(gl_FragCoord.x); + for (int i = 0; i < n; i++) busy += sin(float(i) + gl_FragCoord.y); + outColor = busy > 1e30 ? vec4(0.0) : tint; + } + """); + + var framebuffers = new int[2]; + for (int i = 0; i < framebuffers.Length; i++) + { + int texture = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + framebuffers[i] = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffers[i], EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffers[i], 0b1); + } + + int ubo = seam.CreateUniformBuffer(program, 0, "Tint", sizeof(float) * 4); + seam.BindUniformBuffer(ubo); + + var colours = new[] + { + new byte[] { 25, 75, 125 }, + new byte[] { 210, 15, 45 }, + }; + + // Neither frame is read back between the two, so the first is still + // submitted - and with two frames in flight, possibly still running - + // when the second overwrites the block. + for (int frame = 0; frame < framebuffers.Length; frame++) + { + SetTint(seam, ubo, colours[frame][0], colours[frame][1], colours[frame][2]); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffers[frame]); + seam.UseProgram(program); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + for (int i = 0; i < 8; i++) seam.DrawFullscreenTriangle(); + seam.Present(); + } + + int cachedAfterTwoFrames = device!.CachedDescriptorSets; + + var pixels = new byte[size * size * 4]; + for (int frame = 0; frame < framebuffers.Length; frame++) + { + // Inside a frame: binding a target is what a frame records, so a + // read between frames would report whatever was bound last. + seam.BeginFrame(); + seam.BindFramebuffer(framebuffers[frame]); + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + seam.Present(); + + int centre = (size / 2 * size + size / 2) * 4; + Assert.Equal(colours[frame][0], pixels[centre + 0]); + Assert.Equal(colours[frame][1], pixels[centre + 1]); + Assert.Equal(colours[frame][2], pixels[centre + 2]); + } + + // The snapshot travels as a dynamic offset, so the set naming the + // ring is written once and reused; a set per frame would mean the + // cache grew with every one of these. + for (int i = 0; i < 4; i++) + { + SetTint(seam, ubo, (byte)(10 + i), 20, 30); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffers[0]); + seam.UseProgram(program); + seam.SetViewport(0, 0, size, size); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + Assert.Equal(cachedAfterTwoFrames, device.CachedDescriptorSets); + + AssertNoValidationErrors(seam); + } + } + + /// A quad spanning the full height between two x coordinates. + private static int HalfScreenQuad(IOptimumGraphicsDevice device, float x0, float x1) + { + var data = new MeshData(4, 6) + { + xyz = new[] { x0, -1f, 0f, x1, -1f, 0f, x1, 1f, 0f, x0, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + mode = EnumDrawMode.Triangles, + }; + return device.CreateMesh(data, true); + } + + private static unsafe void SetTint(IOptimumGraphicsDevice device, int ubo, byte r, byte g, byte b) + { + var tint = new[] { r / 255f, g / 255f, b / 255f, 1f }; + fixed (float* values = tint) + { + device.UpdateUniformBuffer(ubo, (IntPtr)values, 0, sizeof(float) * 4); + } + } + + /// + /// Drains the device's diagnostics and fails on anything the layers reported + /// at error severity. + /// + private static void AssertNoValidationErrors(IOptimumGraphicsDevice device) + { + string? diagnostics = device.GetError(); + ValidationAssert.NoErrors(diagnostics == null + ? Array.Empty() + : diagnostics.Split('\n')); + } + /// /// The loading-screen crash. A texture is deleted and a new one takes its /// place; the driver may give the new image view the very handle value the diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs index 775383a5..23c3a8b2 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorCache.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -323,16 +323,19 @@ private Result AllocateFrom(PoolSlot slot, DescriptorSetLayout layout, out Descr private PoolSlot GrowPool() { - // A pool can only satisfy the descriptor types it was sized for. The - // generated block is a dynamic uniform buffer, but the game also declares - // uniform blocks of its own - entityanimated's ElementTransforms is one - - // and those are plain uniform buffers. Without a size for that type the - // allocation fails, the set is never written, and the first draw that - // uses it takes the device down. - var sizes = stackalloc DescriptorPoolSize[4] + // A pool can only satisfy the descriptor types it was sized for. Set 0 + // holds the generated block plus every block the shader declares for + // itself - entityanimated's ElementTransforms is one - and all of them + // are dynamic uniform buffers, so that budget covers several per set. + // Without a size for a type the allocation fails, the set is never + // written, and the first draw that uses it takes the device down. + var sizes = stackalloc DescriptorPoolSize[3] { - new DescriptorPoolSize(DescriptorType.UniformBufferDynamic, SetsPerPool), - new DescriptorPoolSize(DescriptorType.UniformBuffer, SetsPerPool * 2), + // Set 0 holds the generated block plus every named block, all dynamic. + // Eight per set is Vulkan's guaranteed minimum for + // maxDescriptorSetUniformBuffersDynamic, so a set that fits the + // device limit always fits the pool. + new DescriptorPoolSize(DescriptorType.UniformBufferDynamic, SetsPerPool * 8), new DescriptorPoolSize(DescriptorType.CombinedImageSampler, SetsPerPool * 8), new DescriptorPoolSize(DescriptorType.StorageBuffer, SetsPerPool * 2), }; @@ -342,7 +345,7 @@ private PoolSlot GrowPool() SType = StructureType.DescriptorPoolCreateInfo, // Evicted sets are freed individually, which a pool has to allow. Flags = DescriptorPoolCreateFlags.FreeDescriptorSetBit, - PoolSizeCount = 4, + PoolSizeCount = 3, PPoolSizes = sizes, MaxSets = SetsPerPool, }; @@ -406,13 +409,10 @@ private void Write(DescriptorSet set, DescriptorSetContents contents) Range = buffer.Range, }; - // Set 0 binding 0 is the generated uniform block, bound as a - // dynamic descriptor so the per-draw ring offset travels - // separately and the set itself never has to change. - bool isDynamicUniform = - contents.SetIndex == ProgramInterfaceLayoutBindings.DefaultBlockSet - && buffer.Binding == ProgramInterfaceLayoutBindings.DefaultBlockBinding; - + // Every buffer in set 0 is a uniform block - the generated one at + // binding 0 and the shader's own after it - and every one of them + // is dynamic, so the per-draw ring offset travels separately and + // the set itself never has to change. writes[index++] = new WriteDescriptorSet { SType = StructureType.WriteDescriptorSet, @@ -421,9 +421,7 @@ private void Write(DescriptorSet set, DescriptorSetContents contents) DescriptorCount = 1, DescriptorType = contents.SetIndex == ProgramInterfaceLayoutBindings.StorageSet ? DescriptorType.StorageBuffer - : isDynamicUniform - ? DescriptorType.UniformBufferDynamic - : DescriptorType.UniformBuffer, + : DescriptorType.UniformBufferDynamic, PBufferInfo = bufferPtr + i, }; } diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 53efaf4e..0498fa57 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -216,7 +216,7 @@ internal sealed class FrameRing : IDisposable private int _index = -1; private bool _disposed; - public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRingSize = 16 * 1024 * 1024) + public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRingSize = 32 * 1024 * 1024) { _uniformRing = new VulkanBuffer(context, uniformRingSize, BufferUsageFlags.UniformBufferBit, diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs index 813f4e38..c03c40b8 100644 --- a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -110,12 +110,16 @@ private void CreateSetLayouts() StageFlags = allGraphics, }); } + // A block the shader declares for itself is dynamic for the same reason + // the generated one is: the client re-uploads it between draws that are + // only recorded, so each draw needs its own slice of the frame's uniform + // ring, reached through an offset rather than through a set of its own. foreach (BlockBinding block in Interface.UniformBlocks) { uniformBindings.Add(new DescriptorSetLayoutBinding { Binding = (uint)block.Binding, - DescriptorType = DescriptorType.UniformBuffer, + DescriptorType = DescriptorType.UniformBufferDynamic, DescriptorCount = 1, StageFlags = allGraphics, }); diff --git a/Optimum.Render.Vulkan/Core/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs index e7fa12b0..82fad2a4 100644 --- a/Optimum.Render.Vulkan/Core/TextureDump.cs +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; +using Silk.NET.Vulkan; namespace Optimum.Render.Vulkan.Core; @@ -116,12 +118,35 @@ public static int[] Take() DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture) + "-" + Environment.ProcessId; /// - /// Writes RGBA or BGRA bytes as a binary PPM. + /// Writes a texture's raw GPU bytes as a binary PPM, converting whatever + /// format the texture actually carries into 8-bit RGB. + /// + /// R16G16B16A16Sfloat and R32Sfloat are readback formats an attachment can + /// legitimately be dumped in (TAA motion, a depth-like target) rather than + /// the 8-bit RGBA/BGRA every other texture uses, so each gets its own + /// normalisation: + /// - Colour-shaped float data (R16G16B16A16Sfloat) is clamped to [0,1] and + /// scaled to a byte, same as any other colour channel. + /// - Single-channel float data (R32Sfloat) is treated as motion-like and + /// mapped from [-64,64] pixels to [0,255], with 128 standing for zero + /// displacement - there is no separate "depth" convention to distinguish + /// it from motion at this format, so callers dumping true depth should + /// expect the same [-64,64]-centred-at-128 mapping. /// /// True if the file was written. - public static bool Write(int textureId, int width, int height, bool bgra, ReadOnlySpan rgba) + public static bool Write(int textureId, int width, int height, bool bgra, Format format, + ReadOnlySpan data) { - if (width <= 0 || height <= 0 || rgba.Length < width * height * 4) return false; + if (width <= 0 || height <= 0) return false; + + int bytesPerPixel = format switch + { + Format.R16G16B16A16Sfloat => 8, + Format.R32Sfloat => 4, + Format.R8Unorm or Format.R8Uint or Format.R8Srgb => 1, + _ => 4, + }; + if (data.Length < width * height * bytesPerPixel) return false; try { @@ -136,20 +161,78 @@ public static bool Write(int textureId, int width, int height, bool bgra, ReadOn foreach (char c in $"P6\n{width} {height}\n255\n") writer.Write((byte)c); - int red = bgra ? 2 : 0; - int blue = bgra ? 0 : 2; - var row = new byte[width * 3]; - for (int y = 0; y < height; y++) + int stride = width * bytesPerPixel; + + switch (format) { - int source = y * width * 4; - for (int x = 0; x < width; x++) + case Format.R16G16B16A16Sfloat: + { + var floats = MemoryMarshal.Cast(data); + int floatsPerRow = width * 4; + for (int y = 0; y < height; y++) + { + var source = floats.Slice(y * floatsPerRow, floatsPerRow); + for (int x = 0; x < width; x++) + { + row[x * 3] = ColorByte((float)source[x * 4]); + row[x * 3 + 1] = ColorByte((float)source[x * 4 + 1]); + row[x * 3 + 2] = ColorByte((float)source[x * 4 + 2]); + } + writer.Write(row); + } + break; + } + case Format.R32Sfloat: { - row[x * 3] = rgba[source + x * 4 + red]; - row[x * 3 + 1] = rgba[source + x * 4 + 1]; - row[x * 3 + 2] = rgba[source + x * 4 + blue]; + var floats = MemoryMarshal.Cast(data); + for (int y = 0; y < height; y++) + { + var source = floats.Slice(y * width, width); + for (int x = 0; x < width; x++) + { + byte value = MotionByte(source[x]); + row[x * 3] = value; + row[x * 3 + 1] = value; + row[x * 3 + 2] = value; + } + writer.Write(row); + } + break; + } + case Format.R8Unorm or Format.R8Uint or Format.R8Srgb: + { + for (int y = 0; y < height; y++) + { + var source = data.Slice(y * stride, width); + for (int x = 0; x < width; x++) + { + byte value = source[x]; + row[x * 3] = value; + row[x * 3 + 1] = value; + row[x * 3 + 2] = value; + } + writer.Write(row); + } + break; + } + default: + { + int red = bgra ? 2 : 0; + int blue = bgra ? 0 : 2; + for (int y = 0; y < height; y++) + { + int source = y * stride; + for (int x = 0; x < width; x++) + { + row[x * 3] = data[source + x * 4 + red]; + row[x * 3 + 1] = data[source + x * 4 + 1]; + row[x * 3 + 2] = data[source + x * 4 + blue]; + } + writer.Write(row); + } + break; } - writer.Write(row); } return true; @@ -163,4 +246,11 @@ public static bool Write(int textureId, int width, int height, bool bgra, ReadOn return false; } } + + /// Clamps [0,1] colour data to a byte. + private static byte ColorByte(float value) => (byte)(Math.Clamp(value, 0f, 1f) * 255f); + + /// Maps [-64,64] px of motion-like data to [0,255], 128 = zero. + private static byte MotionByte(float value) => + (byte)Math.Clamp((value / 64f) * 127f + 128f, 0f, 255f); } diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index cc184d1f..9bc6f733 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -26,12 +26,18 @@ internal static class VulkanStats private static long _texturesDeleted; private static long _frames; private static long _droppedMeshWrites; + private static long _uniformOverflows; /// A mesh write that could not land; see MeshManager.Write. public static void NoteDroppedMeshWrite() => Interlocked.Increment(ref _droppedMeshWrites); public static long DroppedMeshWrites => Interlocked.Read(ref _droppedMeshWrites); + /// A named uniform block that did not fit the frame ring and took a transient buffer instead. + public static void NoteUniformOverflow() => Interlocked.Increment(ref _uniformOverflows); + + public static long UniformOverflows => Interlocked.Read(ref _uniformOverflows); + public static void NoteAllocation() => Interlocked.Increment(ref _allocations); public static void NoteTextureCreated() => Interlocked.Increment(ref _texturesCreated); public static void NoteTextureDeleted() => Interlocked.Increment(ref _texturesDeleted); @@ -68,6 +74,7 @@ public static void NoteUpload(long elapsedTicks) long created = Interlocked.Exchange(ref _texturesCreated, 0); long deleted = Interlocked.Exchange(ref _texturesDeleted, 0); long dropped = Interlocked.Exchange(ref _droppedMeshWrites, 0); + long overflows = Interlocked.Exchange(ref _uniformOverflows, 0); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; double frameMs = frames > 0 ? elapsed * 1000.0 / frames : 0; @@ -76,9 +83,9 @@ public static void NoteUpload(long elapsedTicks) System.Globalization.CultureInfo.InvariantCulture, "stats {0:F1}s: {1} frames ({2:F1} ms/frame), {3} allocations ({4} live), " + "{5} blocking uploads costing {6:F0} ms ({7:F0}% of the interval), " + - "textures +{8}/-{9}, mesh writes dropped {10}", + "textures +{8}/-{9}, mesh writes dropped {10}, uniform overflows {11}", elapsed, frames, frameMs, allocations, VulkanMemory.LiveAllocations, - uploads, uploadMs, uploadMs / (elapsed * 1000.0) * 100.0, created, deleted, dropped); + uploads, uploadMs, uploadMs / (elapsed * 1000.0) * 100.0, created, deleted, dropped, overflows); } private static long _lastSample; diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 8a2a4664..dfb75eb2 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -1044,10 +1044,82 @@ public void SetSamplerUnit(int programId, string samplerName, int unit) // ------------------------------------------------------------ uniform buffers - private readonly Dictionary _uniformBuffers = new(); + /// + /// One uniform buffer object the client created for a named block. + /// + /// The CPU shadow is the source of truth, not the GPU buffer. The client + /// updates one UBO per block and re-updates it between draws - the entity + /// renderer uploads the "Animation" block once per entity, immediately before + /// that entity's draw - but a draw is only recorded here, not executed, so a + /// buffer written in place would give every entity in the frame the last + /// entity's transforms. Writes therefore land in ordinary memory and a draw + /// snapshots them into the frame's uniform ring, exactly as the generated + /// block does. + /// + /// is only reached when the ring has no room left, and + /// carries the shadow's contents from that moment on. + /// + private sealed class ClientUniformBuffer : IDisposable + { + public ClientUniformBuffer(VulkanBuffer buffer, byte[] shadow, string blockName) + { + Buffer = buffer; + Shadow = shadow; + BlockName = blockName; + } + + public VulkanBuffer Buffer { get; } + public byte[] Shadow { get; } + public string BlockName { get; } + + /// Bumped by every write, so an unchanged block reuses its snapshot. + public uint Version { get; private set; } = 1; + + /// The version holds, for the fallback path. + private uint _uploadedVersion; + + /// Which frame's ring the snapshot below lives in, and what it holds. + public uint SnapshotFrame { get; private set; } + public uint SnapshotVersion { get; private set; } + public uint SnapshotOffset { get; private set; } + + public void Write(IntPtr data, int offset, int size) + { + // A client that re-uploads identical bytes before every draw would + // otherwise cost a fresh ring slice per draw; comparing is cheaper. + var incoming = new ReadOnlySpan((void*)data, size); + Span target = Shadow.AsSpan(offset, size); + if (incoming.SequenceEqual(target)) return; + incoming.CopyTo(target); + Version++; + } + + public void NoteSnapshot(uint frame, uint offset) + { + SnapshotFrame = frame; + SnapshotVersion = Version; + SnapshotOffset = offset; + } + + public bool HasSnapshotFor(uint frame) => SnapshotFrame == frame && SnapshotVersion == Version; + + /// + /// Brings the persistent buffer up to date for the ring-exhausted path. + /// Deliberately lazy: in a healthy frame it never runs, so the common + /// path writes host memory once and never touches the GPU. + /// + public void SyncBuffer() + { + if (_uploadedVersion == Version || Buffer.Mapped == IntPtr.Zero) return; + + Shadow.AsSpan().CopyTo(new Span((void*)Buffer.Mapped, Shadow.Length)); + _uploadedVersion = Version; + } + + public void Dispose() => Buffer.Dispose(); + } - /// Block name each uniform buffer was created for. - private readonly Dictionary _uniformBufferBlocks = new(); + private readonly Dictionary _uniformBuffers = new(); /// /// The buffer currently supplying each named block. @@ -1064,13 +1136,13 @@ public void SetSamplerUnit(int programId, string samplerName, int unit) public int CreateUniformBuffer(int programId, int bindingPoint, string blockName, int size) { - var buffer = new VulkanBuffer(_context, (ulong)Math.Max(size, 4), + int bytes = Math.Max(size, 4); + var buffer = new VulkanBuffer(_context, (ulong)bytes, BufferUsageFlags.UniformBufferBit, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); int id = _nextUniformBufferId++; - _uniformBuffers[id] = buffer; - _uniformBufferBlocks[id] = blockName ?? ""; + _uniformBuffers[id] = new ClientUniformBuffer(buffer, new byte[bytes], blockName ?? ""); // GL's glBindBufferBase in the client's constructor takes effect at once, // and a buffer is only ever created to be used. @@ -1080,18 +1152,18 @@ public int CreateUniformBuffer(int programId, int bindingPoint, string blockName public void UpdateUniformBuffer(int handle, IntPtr data, int offset, int size) { - if (!_uniformBuffers.TryGetValue(handle, out VulkanBuffer? buffer)) return; - if (buffer.Mapped == IntPtr.Zero || data == IntPtr.Zero) return; - if ((ulong)(offset + size) > buffer.Size) return; + if (!_uniformBuffers.TryGetValue(handle, out ClientUniformBuffer? ubo)) return; + if (data == IntPtr.Zero || offset < 0 || size < 0) return; + if ((long)offset + size > ubo.Shadow.Length) return; - System.Buffer.MemoryCopy((void*)data, (void*)(buffer.Mapped + offset), size, size); + ubo.Write(data, offset, size); } public void BindUniformBuffer(int handle) { - if (_uniformBufferBlocks.TryGetValue(handle, out string? blockName) && blockName.Length > 0) + if (_uniformBuffers.TryGetValue(handle, out ClientUniformBuffer? ubo) && ubo.BlockName.Length > 0) { - _boundUniformBuffers[blockName] = handle; + _boundUniformBuffers[ubo.BlockName] = handle; } } @@ -1107,19 +1179,19 @@ public void UnbindUniformBuffer(int handle) { } public void DeleteUniformBuffer(int handle) { - if (_uniformBufferBlocks.Remove(handle, out string? blockName) && - _boundUniformBuffers.TryGetValue(blockName, out int bound) && bound == handle) - { - _boundUniformBuffers.Remove(blockName); - } + if (!_uniformBuffers.Remove(handle, out ClientUniformBuffer? ubo)) return; - if (_uniformBuffers.Remove(handle, out VulkanBuffer? buffer)) + if (ubo.BlockName.Length > 0 && + _boundUniformBuffers.TryGetValue(ubo.BlockName, out int bound) && bound == handle) { - // Same hazard as a texture: a set naming this buffer must not - // survive to be served for a successor with the same handle. - _descriptors.Release(buffer.Id); - _frames.DeferDeletion(buffer); + _boundUniformBuffers.Remove(ubo.BlockName); } + + // Same hazard as a texture: a set naming this buffer must not survive to + // be served for a successor with the same handle. Only the ring-exhausted + // fallback ever names it, but that set is cached like any other. + _descriptors.Release(ubo.Buffer.Id); + _frames.DeferDeletion(ubo); } // -------------------------------------------------------------------- textures @@ -1877,52 +1949,111 @@ private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, // EnsureRendering transitions the source back to its attachment layout. } + /// + /// Copies a client UBO's shadow into this frame's uniform ring, so the draw + /// about to be recorded reads the contents the client uploaded for it rather + /// than whatever the last upload of the frame left behind. + /// + /// One snapshot serves every draw that follows with the block unchanged: the + /// pairing of frame and version is what makes a thousand chunk draws sharing + /// one block cost one copy rather than a thousand. A new frame invalidates it + /// because the ring's cursor is reset, and so does a mid-frame flush, which + /// bumps the frame counter for exactly that reason. + /// + private bool TrySnapshotClientBlock( + ClientUniformBuffer ubo, ShaderProgramResources program, out uint offset) + { + if (ubo.HasSnapshotFor(_frameCounter)) + { + offset = ubo.SnapshotOffset; + return true; + } + + if (!_frames.Current.TryAllocateUniforms(ubo.Shadow.Length, out RingAllocation allocation)) + { + ReportUniformExhaustion(program, "block '" + ubo.BlockName + "'"); + offset = 0; + return false; + } + + fixed (byte* source = ubo.Shadow) + { + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, + ubo.Shadow.Length, ubo.Shadow.Length); + } + ubo.NoteSnapshot(_frameCounter, allocation.Offset); + offset = allocation.Offset; + return true; + } + + /// + /// Reports that the frame's uniform ring ran out. Said once per frame so a + /// long frame does not flood the log. + /// + private void ReportUniformExhaustion(ShaderProgramResources program, string what) + { + if (_uniformExhaustionReportedFrame == _frameCounter) return; + + _uniformExhaustionReportedFrame = _frameCounter; + string message = VulkanContext.ErrorPrefix + "uniform ring exhausted in frame " + _frameCounter + + " (" + _frames.Current.UniformBytesUsed + " of " + _frames.Current.UniformCapacity + + " bytes used) at a draw with program " + program.ProgramId + + " '" + ProgramNameOf(program.ProgramId) + "' for " + what; + _diagnostics.Add(message); + MirrorValidationMessage(message); + } + private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) { Vk api = _context.Api; - // Set 0: the generated uniform block, uploaded into this frame's ring and - // reached through a dynamic offset so the set itself never changes, plus - // one entry for every block the shader declared for itself. - uint dynamicOffset = 0; + // Set 0: the generated uniform block plus one entry for every block the + // shader declared for itself. Every one of them is a dynamic descriptor + // pointing at this frame's uniform ring, so the set itself never changes + // - the per-draw offset travels alongside it instead. bool hasGeneratedBlock = program.Interface.HasUniformBlock; + int dynamicCount = (hasGeneratedBlock ? 1 : 0) + program.Interface.UniformBlocks.Count; - if (hasGeneratedBlock || program.Interface.UniformBlocks.Count > 0) + if (dynamicCount > 0) { - var buffers = new List(1 + program.Interface.UniformBlocks.Count); + var buffers = new List(dynamicCount); + + // Dynamic offsets are consumed in increasing order of binding number, + // not in the order the bindings were written, so each one is carried + // with its binding and sorted below. + uint* offsetBindings = stackalloc uint[dynamicCount]; + uint* offsetValues = stackalloc uint[dynamicCount]; + int offsetCount = 0; + bool allocationOk = true; if (hasGeneratedBlock) { - _lastUniformAllocationOk = - _frames.Current.TryAllocateUniforms(program.UniformShadow.Length, out RingAllocation allocation); - if (_lastUniformAllocationOk) + uint generatedOffset = 0; + if (_frames.Current.TryAllocateUniforms( + program.UniformShadow.Length, out RingAllocation allocation)) { fixed (byte* source = program.UniformShadow) { System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, program.UniformShadow.Length, program.UniformShadow.Length); } - dynamicOffset = allocation.Offset; + generatedOffset = allocation.Offset; program.MarkUniformsClean(); } - else if (_uniformExhaustionReportedFrame != _frameCounter) + else { // The draw goes ahead reading offset zero of the ring, which // is some other draw's block: wrong, and for a shader that - // loops on a uniform count, possibly fatal. Said once per - // frame so a long frame does not flood the log. - _uniformExhaustionReportedFrame = _frameCounter; - string message = VulkanContext.ErrorPrefix + "uniform ring exhausted in frame " + _frameCounter + - " (" + _frames.Current.UniformBytesUsed + " of " + _frames.Current.UniformCapacity + - " bytes used) at a draw with program " + program.ProgramId + - " '" + ProgramNameOf(program.ProgramId) + "'"; - _diagnostics.Add(message); - MirrorValidationMessage(message); + // loops on a uniform count, possibly fatal. + allocationOk = false; + ReportUniformExhaustion(program, "its generated uniform block"); } buffers.Add(new BufferBindingValue( ProgramInterfaceLayout.DefaultBlockBinding, _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length)); + offsetBindings[offsetCount] = ProgramInterfaceLayout.DefaultBlockBinding; + offsetValues[offsetCount++] = generatedOffset; } // A block the shader declares is fed by whichever UBO the client @@ -1930,16 +2061,82 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources // rather than leaving the descriptor undefined. foreach (BlockBinding block in program.Interface.UniformBlocks) { - VulkanBuffer? blockBuffer = null; + ClientUniformBuffer? ubo = null; if (_boundUniformBuffers.TryGetValue(block.BlockName, out int handle)) { - _uniformBuffers.TryGetValue(handle, out blockBuffer); + _uniformBuffers.TryGetValue(handle, out ubo); } - blockBuffer ??= _placeholderUniforms; - if (blockBuffer == null) continue; - buffers.Add(new BufferBindingValue( - (uint)block.Binding, blockBuffer.Handle, 0, blockBuffer.Size, blockBuffer.Id)); + if (ubo == null) + { + // Zeroes, at dynamic offset zero. The placeholder has existed + // since the device came up; should it somehow not, the ring + // stands in, because a set with a hole in it - or a dynamic + // offset count that disagrees with the layout - is an invalid + // draw rather than merely a wrong colour. + buffers.Add(_placeholderUniforms != null + ? new BufferBindingValue((uint)block.Binding, _placeholderUniforms.Handle, + 0, _placeholderUniforms.Size, _placeholderUniforms.Id) + : new BufferBindingValue((uint)block.Binding, _frames.UniformBuffer, + 0, Math.Min(16384UL, _context.Capabilities.MaxUniformBufferRange))); + offsetBindings[offsetCount] = (uint)block.Binding; + offsetValues[offsetCount++] = 0; + continue; + } + + if (TrySnapshotClientBlock(ubo, program, out uint blockOffset)) + { + buffers.Add(new BufferBindingValue((uint)block.Binding, + _frames.UniformBuffer, 0, (ulong)ubo.Shadow.Length)); + offsetBindings[offsetCount] = (uint)block.Binding; + offsetValues[offsetCount++] = blockOffset; + } + else + { + // No room left in the ring. Rather than aliasing the block's + // persistent buffer - which would hand every remaining draw + // in the frame the last upload, the exact bug the ring + // exists to fix - this draw gets its own transient copy. + // Slower, but still correct; the overflow is counted so a + // scene that lives in this path shows up in the stats. + allocationOk = false; + VulkanStats.NoteUniformOverflow(); + var overflow = new VulkanBuffer(_context, (ulong)ubo.Shadow.Length, + BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + fixed (byte* shadow = ubo.Shadow) + { + System.Buffer.MemoryCopy(shadow, (void*)overflow.Mapped, + ubo.Shadow.Length, ubo.Shadow.Length); + } + buffers.Add(new BufferBindingValue((uint)block.Binding, + overflow.Handle, 0, overflow.Size, overflow.Id)); + offsetBindings[offsetCount] = (uint)block.Binding; + offsetValues[offsetCount++] = 0; + // Same order as DeleteUniformBuffer: the set naming this + // buffer must not outlive it under a reused handle. + _descriptors.Release(overflow.Id); + _frames.DeferDeletion(overflow); + } + } + + _lastUniformAllocationOk = allocationOk; + + // Insertion sort by binding: at most a handful of entries, and the + // generated block is already the lowest of them. + for (int i = 1; i < offsetCount; i++) + { + uint binding = offsetBindings[i]; + uint value = offsetValues[i]; + int j = i - 1; + while (j >= 0 && offsetBindings[j] > binding) + { + offsetBindings[j + 1] = offsetBindings[j]; + offsetValues[j + 1] = offsetValues[j]; + j--; + } + offsetBindings[j + 1] = binding; + offsetValues[j + 1] = value; } var uniformContents = new DescriptorSetContents( @@ -1949,10 +2146,9 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources DescriptorSet uniformSet = _descriptors.Get( uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); - uint offset = dynamicOffset; api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, - hasGeneratedBlock ? 1u : 0u, hasGeneratedBlock ? &offset : null); + (uint)offsetCount, offsetCount == 0 ? null : offsetValues); } // Set 1: one combined image sampler per declared sampler, resolved through @@ -2338,7 +2534,8 @@ private void DumpRequestedTextures() int width = (int)texture.Width; int height = (int)texture.Height; - ulong bytes = (ulong)width * (ulong)height * 4; + int bytesPerPixel = BytesPerPixel(texture.Format); + ulong bytes = (ulong)width * (ulong)height * (ulong)bytesPerPixel; FlushFrame(); _context.Api.DeviceWaitIdle(_context.Device); @@ -2363,7 +2560,7 @@ private void DumpRequestedTextures() }); bool bgra = texture.Format is Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb; - bool written = TextureDump.Write(textureId, width, height, bgra, + bool written = TextureDump.Write(textureId, width, height, bgra, texture.Format, new ReadOnlySpan((void*)readback.Mapped, (int)bytes)); if (written) TextureDump.Complete(textureId); @@ -2378,6 +2575,19 @@ private void DumpRequestedTextures() } } + /// + /// Bytes per texel for the formats the dump path is expected to see. + /// Anything unrecognised falls back to 4 (8-bit RGBA), the previous + /// blanket assumption, rather than guessing wrong in either direction. + /// + private static int BytesPerPixel(Format format) => format switch + { + Format.R16G16B16A16Sfloat => 8, + Format.R32Sfloat => 4, + Format.R8Unorm or Format.R8Uint or Format.R8Srgb => 1, + _ => 4, + }; + public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) { if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; @@ -2439,7 +2649,7 @@ public void Dispose() foreach (ShaderProgramResources program in _programs.Values) program.Dispose(); _programs.Clear(); - foreach (VulkanBuffer buffer in _uniformBuffers.Values) buffer.Dispose(); + foreach (ClientUniformBuffer ubo in _uniformBuffers.Values) ubo.Dispose(); _uniformBuffers.Clear(); foreach (QueryPool pool in _queries.Values) diff --git a/Optimum.Tests/temporal-conventions-tests.cs b/Optimum.Tests/temporal-conventions-tests.cs new file mode 100644 index 00000000..385a0ae5 --- /dev/null +++ b/Optimum.Tests/temporal-conventions-tests.cs @@ -0,0 +1,197 @@ +using System; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Pure-math coverage for Optimum's TAA conventions: the Halton jitter sequence, +/// the projection-shear jitter applied to Mat4d.Perspective output, and the +/// per-upscaler motion vector unit conventions. All of it lives in +/// OptimumTemporalMath so it can be tested without a render context. +/// +public class TemporalConventionsTests +{ + // --- Halton(2,3) sequence ------------------------------------------------- + + [Theory] + [InlineData(1, 0.5)] + [InlineData(2, 0.25)] + [InlineData(3, 0.75)] + [InlineData(4, 0.125)] + [InlineData(5, 0.625)] + [InlineData(6, 0.375)] + [InlineData(7, 0.875)] + [InlineData(8, 0.0625)] + public void HaltonBase2MatchesKnownValues(int index, double expected) + { + Assert.Equal(expected, OptimumTemporalMath.Halton(index, 2), 12); + } + + [Theory] + [InlineData(1, 1.0 / 3.0)] + [InlineData(2, 2.0 / 3.0)] + [InlineData(3, 1.0 / 9.0)] + [InlineData(4, 4.0 / 9.0)] + [InlineData(5, 7.0 / 9.0)] + [InlineData(6, 2.0 / 9.0)] + [InlineData(7, 5.0 / 9.0)] + [InlineData(8, 8.0 / 9.0)] + public void HaltonBase3MatchesKnownValues(int index, double expected) + { + Assert.Equal(expected, OptimumTemporalMath.Halton(index, 3), 12); + } + + // --- Jitter phase count ----------------------------------------------------- + + [Theory] + [InlineData(1.0f, 8)] + [InlineData(0.5f, 2)] + [InlineData(0.75f, 5)] // ceil(8 * 0.5625) = ceil(4.5) = 5 + [InlineData(2.0f, 32)] + public void JitterPhaseCountIsCeilingOfEightScaleSquared(float scale, int expected) + { + Assert.Equal(expected, OptimumTemporalMath.JitterPhaseCount(scale)); + } + + // --- Projection shear -------------------------------------------------------- + + [Theory] + [InlineData(1.5)] + [InlineData(-2.0)] + [InlineData(0.0)] + public void JitterShearMovesStaticPointByExactlyJPixelsX(double jitterX) + { + const double width = 1920.0; + const double height = 1080.0; + + double[] unjittered = Perspective(width, height); + double[] jittered = (double[])unjittered.Clone(); + OptimumTemporalMath.ApplyProjectionJitter(jittered, jitterX, 0, width, height); + + // A static point somewhere in front of the camera. + double viewX = 3.2; + double viewY = -1.7; + double viewZ = -10.0; // negative: in front of the camera + + (double px0, double py0) = ProjectToPixel(unjittered, viewX, viewY, viewZ, width, height); + (double px1, double py1) = ProjectToPixel(jittered, viewX, viewY, viewZ, width, height); + + Assert.Equal(jitterX, px1 - px0, 9); + Assert.Equal(0.0, py1 - py0, 9); + } + + [Theory] + [InlineData(2.25)] + [InlineData(-0.6)] + [InlineData(0.0)] + public void JitterShearMovesStaticPointByExactlyJPixelsY(double jitterY) + { + const double width = 1920.0; + const double height = 1080.0; + + double[] unjittered = Perspective(width, height); + double[] jittered = (double[])unjittered.Clone(); + OptimumTemporalMath.ApplyProjectionJitter(jittered, 0, jitterY, width, height); + + double viewX = -0.4; + double viewY = 2.1; + double viewZ = -25.0; + + (double px0, double py0) = ProjectToPixel(unjittered, viewX, viewY, viewZ, width, height); + (double px1, double py1) = ProjectToPixel(jittered, viewX, viewY, viewZ, width, height); + + Assert.Equal(0.0, px1 - px0, 9); + Assert.Equal(jitterY, py1 - py0, 9); + } + + /// + /// Builds a column-major perspective matrix exactly as Mat4d.Perspective does + /// (float[16]/double[16] layout, clip.w = -z_view via row 3 = (0,0,-1,0)). + /// + private static double[] Perspective(double width, double height) + { + double fovy = 70.0 * Math.PI / 180.0; + double aspect = width / height; + double near = 0.1; + double far = 1000.0; + + double f = 1.0 / Math.Tan(fovy / 2.0); + double nf = 1.0 / (near - far); + + double[] output = new double[16]; + output[0] = f / aspect; + output[5] = f; + output[10] = (far + near) * nf; + output[11] = -1; + output[14] = (2 * far * near) * nf; + return output; + } + + /// + /// Projects a view-space point through a column-major clip matrix (Mat4d.Perspective + /// layout) to raster pixel coordinates, matching OpenGL's NDC-to-viewport mapping. + /// + private static (double X, double Y) ProjectToPixel(double[] m, double x, double y, double z, double width, double height) + { + double clipX = m[0] * x + m[4] * y + m[8] * z + m[12]; + double clipY = m[1] * x + m[5] * y + m[9] * z + m[13]; + double clipW = m[3] * x + m[7] * y + m[11] * z + m[15]; + + double ndcX = clipX / clipW; + double ndcY = clipY / clipW; + + double pixelX = (ndcX * 0.5 + 0.5) * width; + double pixelY = (ndcY * 0.5 + 0.5) * height; + return (pixelX, pixelY); + } + + // --- Motion vector adapters ----------------------------------------------- + + [Fact] + public void FsrAdapterLeavesRenderPixelVectorUnchanged() + { + (float x, float y) = OptimumTemporalMath.AdaptMotionVector(1f, 0f, 1920, 1080, OptimumTemporalMath.MotionVectorAdapter.Fsr); + Assert.Equal(1f, x); + Assert.Equal(0f, y); + } + + [Fact] + public void XessAdapterLeavesRenderPixelVectorUnchanged() + { + (float x, float y) = OptimumTemporalMath.AdaptMotionVector(0f, 1f, 1920, 1080, OptimumTemporalMath.MotionVectorAdapter.Xess); + Assert.Equal(0f, x); + Assert.Equal(1f, y); + } + + [Fact] + public void DlssAdapterNormalizesByRenderTargetSize() + { + (float x, float y) = OptimumTemporalMath.AdaptMotionVector(1f, 1f, 1920, 1080, OptimumTemporalMath.MotionVectorAdapter.Dlss); + Assert.Equal(1f / 1920f, x); + Assert.Equal(1f / 1080f, y); + } + + [Theory] + [InlineData(OptimumTemporalMath.MotionVectorAdapter.Fsr)] + [InlineData(OptimumTemporalMath.MotionVectorAdapter.Dlss)] + [InlineData(OptimumTemporalMath.MotionVectorAdapter.Xess)] + public void OnePixelDisplacementRoundTripsThroughEachAdapter(OptimumTemporalMath.MotionVectorAdapter adapter) + { + const int width = 1920; + const int height = 1080; + + // A one render-pixel displacement, stored as previousPixel - currentPixel. + float storedX = 1f; + float storedY = 1f; + + (float adaptedX, float adaptedY) = OptimumTemporalMath.AdaptMotionVector(storedX, storedY, width, height, adapter); + + // Round trip back to render pixels using each adapter's own scale. + float scaleX = adapter == OptimumTemporalMath.MotionVectorAdapter.Dlss ? width : 1; + float scaleY = adapter == OptimumTemporalMath.MotionVectorAdapter.Dlss ? height : 1; + + Assert.Equal(storedX, adaptedX * scaleX, 5); + Assert.Equal(storedY, adaptedY * scaleY, 5); + } +} diff --git a/Optimum.Tests/temporal-render-inventory-tests.cs b/Optimum.Tests/temporal-render-inventory-tests.cs new file mode 100644 index 00000000..650aa8e5 --- /dev/null +++ b/Optimum.Tests/temporal-render-inventory-tests.cs @@ -0,0 +1,147 @@ +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// A checked inventory of the game's render systems relevant to motion vectors and +/// jittered projection. Each assertion pins down a string in the actual source tree +/// (donor decompile or hand-maintained mod source) so the TAA plan's render-system +/// table stays true as the game and Optimum's patches evolve. This is not behavior +/// coverage - it is a tripwire: if any of these registrations move or are renamed, +/// the TAA plan needs to be revisited before it is trusted. +/// +public class TemporalRenderInventoryTests +{ + [Fact] + public void ChunkRendererDrawsOpaqueAndTopsoilWithCameraMatrixOriginAndLiquidInRenderOIT() + { + string chunkRenderer = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + Assert.Contains("public void RenderOpaque(float dt)", chunkRenderer); + Assert.Contains("internal void RenderOIT(float deltaTime)", chunkRenderer); + Assert.Contains("ShaderProgramChunkopaque chunkopaque = ShaderPrograms.Chunkopaque;", chunkRenderer); + Assert.Contains("ShaderProgramChunktopsoil chunktopsoil = ShaderPrograms.Chunktopsoil;", chunkRenderer); + Assert.Contains("ShaderProgramChunkliquid chunkliquid = ShaderPrograms.Chunkliquid;", chunkRenderer); + + int renderOpaqueStart = chunkRenderer.IndexOf("public void RenderOpaque(float dt)"); + int renderOitStart = chunkRenderer.IndexOf("internal void RenderOIT(float deltaTime)"); + Assert.True(renderOpaqueStart >= 0 && renderOitStart > renderOpaqueStart); + + string renderOpaqueBody = chunkRenderer.Substring(renderOpaqueStart, renderOitStart - renderOpaqueStart); + Assert.Contains("game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin);", renderOpaqueBody); + Assert.Contains("chunkopaque.TerrainTex2D", renderOpaqueBody); + Assert.Contains("chunktopsoil.TerrainTex2D", renderOpaqueBody); + + string renderOitBody = chunkRenderer.Substring(renderOitStart, 400); + Assert.Contains("game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin);", renderOitBody); + } + + [Fact] + public void SystemRenderParticlesRegistersOpaqueAndOitRenderers() + { + string particles = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs"); + + Assert.Contains( + "game.eventManager.RegisterRenderer(OnRenderFrame3D, EnumRenderStage.Opaque, \"rep-opa\", 0.6);", + particles); + Assert.Contains( + "game.eventManager.RegisterRenderer(OnRenderFrame3DOIT, EnumRenderStage.OIT, \"rep-oit\", 0.6);", + particles); + } + + [Fact] + public void SystemRenderOITLayersUsesSixDrawBuffers() + { + string oitLayers = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); + + Assert.Contains("new DrawBuffersEnum[6]", oitLayers); + Assert.Contains("DrawBuffersEnum.ColorAttachment0", oitLayers); + Assert.Contains("DrawBuffersEnum.ColorAttachment5", oitLayers); + } + + [Fact] + public void EntityShapeRendererUploadsAnimationUboPerEntity() + { + string entityShapeRenderer = Read("VSEssentials/EntityRenderer/EntityShapeRenderer.cs"); + + Assert.Contains( + "prog.UBOs[\"Animation\"].Update(entity.AnimManager.Animator.Matrices, 0, entity.AnimManager.Animator.MaxJointId * 16 * 4);", + entityShapeRenderer); + } + + [Fact] + public void ModSystemFpHandsCreatesItsOwnAnimationUbo() + { + string fpHands = Read("VSEssentials/EntityRenderer/ModSystemFpHands.cs"); + + Assert.Contains( + "fpModeHandShader.UBOs[\"Animation\"] = capi.Render.CreateUBO(fpModeHandShader, 0, \"Animation\", GlobalConstants.MaxAnimatedElements * 16 * 4);", + fpHands); + } + + [Fact] + public void EntityItemRendererUsesTheStandardShader() + { + string entityItemRenderer = Read("VSEssentials/EntityRenderer/EntityItemRenderer.cs"); + + Assert.Contains("IStandardShaderProgram prog = null;", entityItemRenderer); + Assert.Contains("prog = rapi.StandardShader;", entityItemRenderer); + } + + [Fact] + public void QuernTopRendererUsesTheStandardShader() + { + string quernTopRenderer = Read("VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs"); + + Assert.Contains("IStandardShaderProgram prog = rpi.PreparedStandardShader(pos.X, pos.Y, pos.Z);", quernTopRenderer); + } + + [Fact] + public void MechNetworkRendererIsInstanced() + { + string mechNetworkRenderer = Read("VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs"); + + Assert.Contains("1. Use instanced rendering to issue one draw call for all mech.power blocks of one type.", mechNetworkRenderer); + Assert.Contains("prog = capi.Shader.GetProgramByName(\"instanced\");", mechNetworkRenderer); + } + + [Fact] + public void RiftRendererRendersAtAfterBlit() + { + string riftRenderer = Read("VSSurvivalMod/Systems/Rifts/RiftRenderer.cs"); + + Assert.Contains("capi.Event.RegisterRenderer(this, EnumRenderStage.AfterBlit, \"riftrenderer\");", riftRenderer); + } + + [Fact] + public void KnappingClayFormAndAnvilRenderersRenderAtAfterFinalComposition() + { + string knapping = Read("VSSurvivalMod/BlockEntityRenderer/KnappingRenderer.cs"); + string clayForm = Read("VSSurvivalMod/BlockEntityRenderer/ClayFormRenderer.cs"); + string anvil = Read("VSSurvivalMod/BlockEntityRenderer/AnvilWorkItemRenderer.cs"); + + Assert.Contains("capi.Event.RegisterRenderer(this, EnumRenderStage.AfterFinalComposition, \"knappingsurface\");", knapping); + Assert.Contains("if (stage == EnumRenderStage.AfterFinalComposition)", knapping); + + Assert.Contains("if (stage == EnumRenderStage.AfterFinalComposition)", clayForm); + Assert.Contains("api.Event.UnregisterRenderer(this, EnumRenderStage.AfterFinalComposition);", clayForm); + + Assert.Contains("if (stage == EnumRenderStage.AfterFinalComposition)", anvil); + Assert.Contains("api.Event.UnregisterRenderer(this, EnumRenderStage.AfterFinalComposition);", anvil); + } + + [Fact] + public void VulkanPresentBlitIsTheOnlyYFlip() + { + string vulkanDevice = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + + Assert.Contains("This inverted blit is the entire Y-flip story for the backend.", vulkanDevice); + Assert.Contains("// Source Y runs backwards: this is the flip.", vulkanDevice); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } +} diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 3998bf72..2942d964 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -125,8 +125,9 @@ public void OpenGlRemainsTheDefaultRenderer() Assert.Contains("public static string Renderer = \"opengl\";", config); Assert.Contains("public string Renderer { get; set; } = \"opengl\";", config); - // The switch falls through to opengl for anything it does not recognise. - Assert.Contains("_ => \"opengl\",", config); + // Anything the normaliser does not recognise falls back to opengl. + Assert.Contains("StringComparison.OrdinalIgnoreCase) ? \"auto\" :", config); + Assert.Contains("\"opengl\";", config); } /// @@ -275,8 +276,8 @@ public void IntegerVectorUniformsKeepTheirIntegerRepresentation() { string added = AddedLines(Read(ShaderProgramBasePatch)); - Assert.Contains("optimumOffset + 4", added); - Assert.Contains("optimumOffset + 8", added); + // The device lays the three components out itself; the location is opaque here. + Assert.Contains("value.X, value.Y, value.Z)", added); // The Vec2i overload keeps the cast the GL body performs. Assert.Contains("(float)value.X, (float)value.Y", added); } diff --git a/optimum-api-contracts/optimum-api-contracts.csproj b/optimum-api-contracts/optimum-api-contracts.csproj index 37970dd5..fa49a995 100644 --- a/optimum-api-contracts/optimum-api-contracts.csproj +++ b/optimum-api-contracts/optimum-api-contracts.csproj @@ -26,6 +26,7 @@ + diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalMath.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalMath.cs new file mode 100644 index 00000000..0bee673a --- /dev/null +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalMath.cs @@ -0,0 +1,86 @@ +using System; + +#nullable disable + +namespace Vintagestory.API.Client +{ + /// + /// Small, dependency-free conventions shared by Optimum's temporal anti-aliasing + /// and its upscaler motion-vector adapters. Kept as pure functions so they can be + /// unit tested without a render context. + /// + public static class OptimumTemporalMath + { + /// + /// The Halton low-discrepancy sequence, one-indexed (Halton(0, base) is never + /// requested - jitter sequences start at index 1). + /// + public static double Halton(int index, int radix) + { + double result = 0; + double fraction = 1.0 / radix; + int i = index; + while (i > 0) + { + result += (i % radix) * fraction; + i /= radix; + fraction /= radix; + } + return result; + } + + /// + /// Number of distinct jitter offsets in the TAA jitter sequence for a given + /// render scale: more upscaling needs more sub-pixel samples to converge. + /// + public static int JitterPhaseCount(float renderScale) + { + return (int)Math.Ceiling(8.0 * renderScale * renderScale); + } + + /// + /// Applies a sub-pixel projection jitter (in render pixels) to a column-major + /// perspective matrix produced by Mat4d.Perspective, in place. Matches the + /// convention used by the TAA jitter pass: P[8]/P[9] are the matrix's x/y + /// oblique terms, so nudging them shifts every clip-space x/y by a fixed + /// fraction of clip.w = -z_view, i.e. a constant pixel offset on screen. + /// + public static void ApplyProjectionJitter(double[] projection, double jitterX, double jitterY, double renderWidth, double renderHeight) + { + projection[8] -= 2.0 * jitterX / renderWidth; + projection[9] -= 2.0 * jitterY / renderHeight; + } + + /// + /// The upscalers Optimum drives disagree on the units a stored motion vector + /// (previousPixel - currentPixel, in render pixels) should be handed over in. + /// + public enum MotionVectorAdapter + { + /// FSR2/FSR3: render pixels, unchanged. + Fsr, + /// DLSS: normalized by render target size. + Dlss, + /// XeSS pixel mode: render pixels, unchanged. + Xess + } + + /// + /// Rescales a stored motion vector (previousPixel - currentPixel, in render + /// pixels) into the units the given upscaler adapter expects. + /// + public static (float X, float Y) AdaptMotionVector(float motionPixelsX, float motionPixelsY, int renderWidth, int renderHeight, MotionVectorAdapter adapter) + { + switch (adapter) + { + case MotionVectorAdapter.Fsr: + case MotionVectorAdapter.Xess: + return (motionPixelsX, motionPixelsY); + case MotionVectorAdapter.Dlss: + return (motionPixelsX / renderWidth, motionPixelsY / renderHeight); + default: + throw new ArgumentOutOfRangeException(nameof(adapter), adapter, null); + } + } + } +} diff --git a/sources/VintagestoryApi/VintagestoryAPI.csproj b/sources/VintagestoryApi/VintagestoryAPI.csproj index ee8e2448..6aa37510 100644 --- a/sources/VintagestoryApi/VintagestoryAPI.csproj +++ b/sources/VintagestoryApi/VintagestoryAPI.csproj @@ -51,6 +51,7 @@ + From e1cd96a8e363088302d498d70041b4ac9c3b8925 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 15:56:55 +0200 Subject: [PATCH 009/226] build: deploy the Vulkan backend and its dependencies with make deploy make deploy only copied the patched game DLLs, so the backend beside the client went stale, the Vulkan probe threw on a missing seam member and the client silently fell back to OpenGL. --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 346c650e..a26032dc 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,11 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client @cp $(MOD_OUT)/VSSurvivalMod.dll $(VANILLA_DIR)/Mods/ @cp $(MOD_OUT)/VSCreativeMod.dll $(VANILLA_DIR)/Mods/ @cp $(MOD_OUT)/cairo-sharp.dll $(VANILLA_DIR)/Lib/ + @# The Vulkan renderer and its dependencies, loaded by name at startup; a + @# stale copy here makes the probe throw and the client fall back to OpenGL. + @cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(VANILLA_DIR)/ + @cp $(MOD_OUT)/Silk.NET.*.dll $(VANILLA_DIR)/ + @if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(VANILLA_DIR)/Lib/; fi @cp sources/shaders/*.fsh sources/shaders/*.vsh $(VANILLA_DIR)/assets/game/shaders/ @if [ -d "sources/lang" ]; then for f in sources/lang/*.json; do [ -f "$$f" ] || continue; dst="$(VANILLA_DIR)/assets/game/lang/$$(basename $$f)"; [ -f "$$dst" ] || continue; python3 -c "import json,sys; s=json.load(open(sys.argv[1],encoding='utf-8-sig')); d=json.load(open(sys.argv[2],encoding='utf-8-sig')); d.update(s); json.dump(d,open(sys.argv[2],'w',encoding='utf-8'),ensure_ascii=False,indent='\t')" "$$f" "$$dst"; done; fi @if [ -d "$(INSTALL_DIR)" ]; then \ @@ -115,6 +120,7 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client cp $(MOD_OUT)/VSSurvivalMod.dll $(INSTALL_DIR)/Mods/; \ cp $(MOD_OUT)/VSCreativeMod.dll $(INSTALL_DIR)/Mods/; \ cp $(MOD_OUT)/cairo-sharp.dll $(INSTALL_DIR)/Lib/; \ + cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(INSTALL_DIR)/; cp $(MOD_OUT)/Silk.NET.*.dll $(INSTALL_DIR)/; \ cp sources/shaders/*.fsh sources/shaders/*.vsh $(INSTALL_DIR)/assets/game/shaders/; \ if [ -d "sources/lang" ]; then for f in sources/lang/*.json; do [ -f "$$f" ] || continue; dst="$(INSTALL_DIR)/assets/game/lang/$$(basename $$f)"; [ -f "$$dst" ] || continue; python3 -c "import json,sys; s=json.load(open(sys.argv[1],encoding='utf-8-sig')); d=json.load(open(sys.argv[2],encoding='utf-8-sig')); d.update(s); json.dump(d,open(sys.argv[2],'w',encoding='utf-8'),ensure_ascii=False,indent='\t')" "$$f" "$$dst"; done; fi; \ fi From d95fd982e42791219cc9907d33b42df9bdc7c9f8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:15:46 +0200 Subject: [PATCH 010/226] debug(render): let texture dumps wait for a world (OPTIMUM_DUMP_AFTER_FRAMES / _SECONDS) --- Optimum.Render.Vulkan/Core/TextureDump.cs | 30 ++++++++++++++++++++++- Optimum.Render.Vulkan/VulkanDevice.cs | 1 + 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Optimum.Render.Vulkan/Core/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs index 82fad2a4..76201588 100644 --- a/Optimum.Render.Vulkan/Core/TextureDump.cs +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -52,7 +52,35 @@ public static void RequestTerrain(int baseTexture, int linearTexture) } /// True while any requested texture has not been written yet. - public static bool Wanted => Pending.Count > 0; + /// + /// Frames to let pass before writing anything. OPTIMUM_DUMP_AFTER_FRAMES + /// (default 0) lets a dump of a frame target wait until a world is on + /// screen instead of capturing the menu's black first frame. + /// + private static readonly long StartAfterFrames = + long.TryParse(Environment.GetEnvironmentVariable("OPTIMUM_DUMP_AFTER_FRAMES"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out long frames) ? frames : 0; + + /// + /// Seconds to wait before writing, OPTIMUM_DUMP_AFTER_SECONDS (default 0). + /// The menu runs uncapped, so a frame count alone can expire before a world + /// is on screen; wall time is what a person setting this reasons in. + /// + private static readonly double StartAfterSeconds = + double.TryParse(Environment.GetEnvironmentVariable("OPTIMUM_DUMP_AFTER_SECONDS"), NumberStyles.Float, + CultureInfo.InvariantCulture, out double seconds) ? seconds : 0; + + private static long _framesSeen; + private static readonly long StartedAt = System.Diagnostics.Stopwatch.GetTimestamp(); + + /// Counts a presented frame; the dump waits out the configured delays. + public static void NoteFrame() => _framesSeen++; + + private static double SecondsSinceStart => + (System.Diagnostics.Stopwatch.GetTimestamp() - StartedAt) / (double)System.Diagnostics.Stopwatch.Frequency; + + public static bool Wanted => + Pending.Count > 0 && _framesSeen >= StartAfterFrames && SecondsSinceStart >= StartAfterSeconds; private static HashSet Parse(string? value) { diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index dfb75eb2..7a7bd4c9 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -651,6 +651,7 @@ public void Present() { if (!_frameActive) return; + TextureDump.NoteFrame(); if (TextureDump.Wanted) DumpRequestedTextures(); CommandBuffer commandBuffer = _frames.Current.CommandBuffer; From 7962a8477da9174314b116e6316b167245ba3d4e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:17:58 +0200 Subject: [PATCH 011/226] wip(taa): partial P1 workflow output (frame contract, motion/history targets, Cecil entries) - not wired, TAA off by default --- Optimum.Patcher/Program.cs | 38 ++ Optimum.Tests/taa-pipeline-coverage-tests.cs | 181 +++++++ Optimum.Tests/temporal-frame-tests.cs | 353 ++++++++++++++ .../optimum-api-contracts.csproj | 1 + .../VintagestoryAPI.csproj.patch | 16 + .../ClientEventManager.cs.patch | 20 + .../ClientMain.cs.patch | 168 ++++++- .../ClientPlatformWindows.cs.patch | 446 +++++++++++++++--- .../RenderAPIGame.cs.patch | 30 ++ .../ShaderPrograms.cs.patch | 6 +- .../ShaderRegistry.cs.patch | 11 +- patches/cecil-owned.list | 2 + .../Client/Render/OptimumTemporalFrame.cs | 446 ++++++++++++++++++ .../VintagestoryApi/Config/OptimumConfig.cs | 57 +++ .../VintagestoryApi/VintagestoryAPI.csproj | 1 + sources/shaders/taa-debug.fsh | 69 +++ sources/shaders/taa-debug.vsh | 11 + 17 files changed, 1778 insertions(+), 78 deletions(-) create mode 100644 Optimum.Tests/taa-pipeline-coverage-tests.cs create mode 100644 Optimum.Tests/temporal-frame-tests.cs create mode 100644 patches/VintagestoryApi/VintagestoryAPI.csproj.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs.patch create mode 100644 sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs create mode 100644 sources/shaders/taa-debug.fsh create mode 100644 sources/shaders/taa-debug.vsh diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index dfcad5ab..e827d8e7 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -131,15 +131,33 @@ "optimumClearA", "optimumBoundTexture2d", "optimumScissorEnabled", + // TAA: motion attachment, history/aux/prev-depth targets, and the + // debug-view blit path (P1). + "OptimumTaaHistoryIndexA", + "OptimumTaaHistoryIndexB", + "OptimumGlR32f", + "MotionAttachmentIndex", + "TaaTargetsReady", + "optimumTaaDisabled", + "TaaHistory", + "CreateOptimumHistoryTarget", + "CreateOptimumHistoryTargetGl", + "DisableOptimumTaa", }, ["Vintagestory.Client.NoObf.ShaderPrograms"] = new() { "FsrEasu", "FsrRcas", + "TaaDebug", }, ["Vintagestory.Client.NoObf.ShaderRegistry"] = new() { "RegisterOptimumShaderProgram", + // TAA: shared per-program post-compile handling extracted out of + // loadRegisteredShaderPrograms (both the parallel-preprocess and + // vanilla single-threaded paths call it); treats taa-debug as + // optional exactly like the two FSR programs. + "CompileAndTrackShaderProgram", }, ["Vintagestory.Client.NoObf.SystemRenderOITLayers"] = new() { @@ -281,6 +299,15 @@ "RegisterTesselationThread", "GetTesselationWorkerSlot", "ChunkTesselatorManager", + // TAA P1: unjittered projection companion to CurrentProjectionMatrix + // (new property; the jittered getter itself is an existing transplant + // target below). + "CurrentProjectionMatrixUnjittered", + }, + ["Vintagestory.Client.NoObf.RenderAPIGame"] = new() + { + "CurrentProjectionMatrixUnjittered", + "TemporalContext", }, // Load-bearing dependency, wire before ServerSystemSupplyChunks: dispatchClaim's @@ -445,6 +472,17 @@ // FSR mip bias: refresh block atlas texture state after scale or atlas changes. new("Vintagestory.Client.NoObf.ChunkRenderer", "OnBeforeRenderOpaque", 1), new("Vintagestory.Client.NoObf.ChunkRenderer", "RuntimeAddBlockTextureAtlas", 1), + // TAA P1: temporal frame contract - Advance()/JitterActive wiring in the + // render loop, the jittered projection getter, its capture at both + // Set3DProjection call sites, and the resets (FOV change, resize, world + // load already listed below as Start, shader reload). + new("Vintagestory.Client.NoObf.ClientMain", "MainRenderLoop", 1), + new("Vintagestory.Client.NoObf.ClientMain", "Set3DProjection", 2), + new("Vintagestory.Client.NoObf.ClientMain", "get_CurrentProjectionMatrix", 0), + new("Vintagestory.Client.NoObf.ClientMain", "OnFowChanged", 1), + new("Vintagestory.Client.NoObf.ClientMain", "OnResize", 0), + new("Vintagestory.Client.NoObf.ClientMain", "RenderAfterPostProcessing", 1), + new("Vintagestory.Client.NoObf.ClientEventManager", "TriggerReloadShaders", 0), // ClientMain: mouse wheel fix (vanilla fields only) new("Vintagestory.Client.NoObf.ClientMain", "OnMouseWheel", 1), // ClientMain: single-pass OpenedGuis scan instead of two LINQ calls (vanilla fields only) diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs new file mode 100644 index 00000000..b3355f3b --- /dev/null +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -0,0 +1,181 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +public class TaaPipelineCoverageTests +{ + [Fact] + public void CecilPatcherShipsEveryTaaMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + // Transplant targets. + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"MainRenderLoop\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"Set3DProjection\", 2", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"get_CurrentProjectionMatrix\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"OnFowChanged\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"OnResize\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"Start\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"RenderAfterPostProcessing\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientEventManager\", \"TriggerReloadShaders\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"BlitPrimaryToDefault\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"SetupDefaultFrameBuffers\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ShaderRegistry\", \"registerDefaultShaderProgramsPre\", 0", patcher); + + // New members needing injection (CompileAndTrackShaderProgram is a + // genuinely new private helper extracted from + // loadRegisteredShaderPrograms, not a transplant of a pre-existing + // vanilla method - it has no vanilla counterpart to transplant onto). + Assert.Contains("\"CompileAndTrackShaderProgram\"", patcher); + Assert.Contains("\"CurrentProjectionMatrixUnjittered\"", patcher); + Assert.Contains("\"TemporalContext\"", patcher); + Assert.Contains("\"TaaDebug\"", patcher); + Assert.Contains("\"MotionAttachmentIndex\"", patcher); + Assert.Contains("\"TaaHistory\"", patcher); + Assert.Contains("\"DisableOptimumTaa\"", patcher); + Assert.Contains("\"CreateOptimumHistoryTarget\"", patcher); + Assert.Contains("\"CreateOptimumHistoryTargetGl\"", patcher); + + // Both new vanilla-type member-injection dictionaries exist. + Assert.Contains("[\"Vintagestory.Client.NoObf.RenderAPIGame\"]", patcher); + } + + [Fact] + public void MotionAttachmentIndexIsTwoWithoutSsaoAndFourWithIt() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // The motion attachment index logic (2 without SSAO, 4 with) - the + // attachment is appended after the existing 2/4 slots, matching the + // property doc comment. + Assert.Contains( + "the Primary colour-attachment index that holds per-pixel motion", + platform); + Assert.Contains("enabled - 2 without", platform); + Assert.Contains("SSAO G-buffer, 4 with it", platform); + } + + [Fact] + public void DefaultDrawBufferMasksAreUnchangedByTaa() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // Primary's draw-buffer mask is still derived only from + // primaryAttachments (2 or 4 colour targets), never including the new + // motion attachment - it is enabled per-pass by writers, not by + // default. + Assert.Contains("device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", platform); + // Transparent (OIT) keeps its untouched six/three-output mask. + Assert.Contains("device.SetDrawBuffers(transparent.FboId, 7);", platform); + } + + [Fact] + public void ClearFrameBufferClearsTheMotionAttachmentOnBothPaths() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // Device path. + Assert.Contains("optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", platform); + // GL path. + Assert.Contains("GL.ClearBuffer((ClearBuffer)6144, MotionAttachmentIndex, new float[4]);", platform); + // Both are guarded so a failed/absent motion attachment leaves the + // clear untouched (MotionAttachmentIndex stays -1 via DisableOptimumTaa). + Assert.Equal(2, Count(platform, "if (MotionAttachmentIndex >= 0)")); + } + + [Fact] + public void CurrentProjectionMatrixOnlyJittersWhenJitterActive() + { + // The transplant patch's diff hunks are not contiguous with the rest + // of the file (context lines get truncated at hunk boundaries), so + // read the full Cecil-transplanted source directly rather than via + // ReadPatchedOrSource here - this test needs to walk from one member + // declaration to the next. + string clientMain = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + int getterStart = clientMain.IndexOf("public float[] CurrentProjectionMatrix", StringComparison.Ordinal); + Assert.True(getterStart >= 0); + int getterEnd = clientMain.IndexOf("public float[] CurrentProjectionMatrixUnjittered", getterStart, StringComparison.Ordinal); + Assert.True(getterEnd > getterStart); + string getter = clientMain.Substring(getterStart, getterEnd - getterStart); + + Assert.Contains("OptimumTemporal.Frame.JitterActive", getter); + // Only shears when JitterActive AND the matrix on top of PMatrix is the + // exact one Set3DProjection last recorded - anything else (ortho, HUD, + // shadow, pushed matrices) falls through to the vanilla tmpMatrix copy. + Assert.Contains("set3DProjectionTempMat4", getter); + Assert.Contains("OptimumTemporal.Frame.ApplyJitterCopy(top)", getter); + + // The unjittered companion never shears at all. + int unjitteredStart = getterEnd; + int unjitteredEnd = clientMain.IndexOf("public float[] CurrentModelViewMatrix", unjitteredStart, StringComparison.Ordinal); + Assert.True(unjitteredEnd > unjitteredStart); + string unjittered = clientMain.Substring(unjitteredStart, unjitteredEnd - unjitteredStart); + Assert.Contains("PMatrix.Top", unjittered); + Assert.DoesNotContain("JitterActive", unjittered); + Assert.DoesNotContain("ApplyJitterCopy", unjittered); + } + + [Fact] + public void TaaAndJitterDefaultsAreOff() + { + string config = Read("sources/VintagestoryApi/Config/OptimumConfig.cs"); + + Assert.Contains("Taa = false", config); + Assert.Contains("TaaJitterDev = false", config); + Assert.Contains("EffectiveTaa", config); + } + + [Fact] + public void TaaDebugViewIsGatedBehindMotionAttachmentAndFallsThroughOtherwise() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.Contains("OptimumConfig.TaaDebugView != 0 && MotionAttachmentIndex >= 0", platform); + } + + private static int Count(string source, string value) + { + int count = 0; + int offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/Optimum.Tests/temporal-frame-tests.cs b/Optimum.Tests/temporal-frame-tests.cs new file mode 100644 index 00000000..d6258798 --- /dev/null +++ b/Optimum.Tests/temporal-frame-tests.cs @@ -0,0 +1,353 @@ +using System; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The per-frame temporal contract: rotation, the jitter sequence, the reset +/// detectors and the one-frame lifetime of the reset flag. All of it runs off a +/// bare OptimumTemporalFrame instance, so none of it needs a render context - the +/// static OptimumTemporal.Frame is deliberately left alone here. +/// +public class TemporalFrameTests +{ + private const int Width = 1920; + private const int Height = 1080; + + private static OptimumTemporalFrame NewFrame(bool jitterActive = true) + { + var frame = new OptimumTemporalFrame(); + frame.JitterActive = jitterActive; + return frame; + } + + private static DefaultShaderUniforms Uniforms(double refX = 0, double refZ = 0) + { + return new DefaultShaderUniforms + { + playerReferencePos = new Vec3d(refX, 0, refZ), + TimeCounter = 1f, + WindWaveCounter = 2f, + WindWaveCounterHighFreq = 3f, + WaterWaveCounter = 4f, + WindSpeed = 5f, + GlobalWorldWarp = 6f, + GlitchWaviness = 7f, + WindWaveIntensity = 8f, + WaterWaveIntensity = 9f, + PerceptionEffectId = 3, + PerceptionEffectIntensity = 0.5f + }; + } + + private static void Advance(OptimumTemporalFrame frame, Vec3d cameraPos, DefaultShaderUniforms uniforms, float renderScale = 1f) + { + frame.Advance(16.6f, Width, Height, renderScale, 0.1f, 3000f, 1.2f, cameraPos, uniforms); + } + + // --- rotation ------------------------------------------------------------ + + [Fact] + public void AdvanceIncrementsFrameIndex() + { + var frame = NewFrame(); + Assert.Equal(0L, frame.FrameIndex); + + Advance(frame, new Vec3d(0, 0, 0), Uniforms()); + Assert.Equal(1L, frame.FrameIndex); + + Advance(frame, new Vec3d(0, 0, 0), Uniforms()); + Assert.Equal(2L, frame.FrameIndex); + } + + [Fact] + public void AdvanceRotatesProjectionsCameraMatricesAndWarpState() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var cameraPos = new Vec3d(10, 64, 10); + + Advance(frame, cameraPos, uniforms); + frame.RecordProjection(EnumTemporalView.World, Diagonal(1.0)); + frame.RecordProjection(EnumTemporalView.Hand, Diagonal(2.0)); + frame.CaptureCamera(Diagonal(3.0), Diagonal(4.0)); + + Assert.True(frame.IsViewCaptured(EnumTemporalView.World)); + Assert.True(frame.IsViewCaptured(EnumTemporalView.Hand)); + + // Second frame: the first frame's values must have moved into the previous slots. + var uniforms2 = Uniforms(); + uniforms2.TimeCounter = 42f; + Advance(frame, cameraPos, uniforms2); + + Assert.Equal(1f, frame.GetPrevProjection(EnumTemporalView.World)[0]); + Assert.Equal(2f, frame.GetPrevProjection(EnumTemporalView.Hand)[0]); + Assert.Equal(3f, frame.PrevCameraMatrix[0]); + Assert.Equal(4f, frame.PrevCameraMatrixOrigin[0]); + Assert.Equal(1f, frame.PrevWarp.TimeCounter); + Assert.Equal(42f, frame.Warp.TimeCounter); + + // The capture flags rotate too: nothing was recorded in the second frame yet. + Assert.False(frame.IsViewCaptured(EnumTemporalView.World)); + Assert.True(frame.WasViewCaptured(EnumTemporalView.World)); + } + + [Fact] + public void WarpStateReadsEveryUniformTheVertexWarpConsumes() + { + var frame = NewFrame(); + Advance(frame, new Vec3d(0, 0, 0), Uniforms()); + + OptimumWarpState warp = frame.Warp; + Assert.Equal(1f, warp.TimeCounter); + Assert.Equal(2f, warp.WindWaveCounter); + Assert.Equal(3f, warp.WindWaveCounterHighFreq); + Assert.Equal(4f, warp.WaterWaveCounter); + Assert.Equal(5f, warp.WindSpeed); + Assert.Equal(6f, warp.GlobalWarpIntensity); + Assert.Equal(7f, warp.GlitchWaviness); + Assert.Equal(8f, warp.WindWaveIntensity); + Assert.Equal(9f, warp.WaterWaveIntensity); + Assert.Equal(3, warp.PerceptionEffectId); + Assert.Equal(0.5f, warp.PerceptionEffectIntensity); + } + + [Fact] + public void PlayerposRotatesFromTheShaderUniforms() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + + uniforms.PlayerPos.Set(1f, 2f, 3f); + Advance(frame, new Vec3d(0, 0, 0), uniforms); + uniforms.PlayerPos.Set(4f, 5f, 6f); + Advance(frame, new Vec3d(0, 0, 0), uniforms); + + Assert.Equal(4f, frame.Playerpos.X); + Assert.Equal(1f, frame.PrevPlayerpos.X); + Assert.Equal(3f, frame.PrevPlayerpos.Z); + } + + // --- jitter --------------------------------------------------------------- + + [Fact] + public void JitterPhaseCyclesWithPeriodEightAtNativeResolution() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var pos = new Vec3d(0, 0, 0); + + var first = new (float X, float Y)[8]; + for (int i = 0; i < 8; i++) + { + Advance(frame, pos, uniforms); + first[i] = (frame.JitterPx.X, frame.JitterPx.Y); + } + + for (int i = 0; i < 8; i++) + { + Advance(frame, pos, uniforms); + Assert.Equal(first[i].X, frame.JitterPx.X, 6); + Assert.Equal(first[i].Y, frame.JitterPx.Y, 6); + } + + // Eight distinct offsets, not one value repeated. + for (int i = 1; i < 8; i++) + { + Assert.NotEqual((first[0].X, first[0].Y), (first[i].X, first[i].Y)); + } + } + + [Fact] + public void JitterIsNeverZeroAndStaysWithinHalfAPixel() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var pos = new Vec3d(0, 0, 0); + + for (int i = 0; i < 64; i++) + { + Advance(frame, pos, uniforms); + Assert.False(frame.JitterPx.X == 0f && frame.JitterPx.Y == 0f); + Assert.InRange(frame.JitterPx.X, -0.5f, 0.5f); + Assert.InRange(frame.JitterPx.Y, -0.5f, 0.5f); + } + } + + [Fact] + public void ClosedJitterWindowReportsZeroButKeepsTheSequence() + { + var frame = NewFrame(jitterActive: false); + Advance(frame, new Vec3d(0, 0, 0), Uniforms()); + + Assert.Equal(0f, frame.JitterPx.X); + Assert.Equal(0f, frame.JitterPx.Y); + Assert.False(frame.JitterSequencePx.X == 0f && frame.JitterSequencePx.Y == 0f); + + // Opening the window mid-frame publishes the sequence offset. + frame.JitterActive = true; + Assert.Equal(frame.JitterSequencePx.X, frame.JitterPx.X); + } + + [Fact] + public void PreviousJitterIsTheOffsetTheLastFrameApplied() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var pos = new Vec3d(0, 0, 0); + + Advance(frame, pos, uniforms); + float firstX = frame.JitterPx.X; + float firstY = frame.JitterPx.Y; + + Advance(frame, pos, uniforms); + Assert.Equal(firstX, frame.PrevJitterPx.X); + Assert.Equal(firstY, frame.PrevJitterPx.Y); + } + + [Fact] + public void ApplyJitterCopyShearsAndDoesNotTouchTheInput() + { + var frame = NewFrame(); + Advance(frame, new Vec3d(0, 0, 0), Uniforms()); + + double[] projection = Diagonal(1.0); + float[] jittered = frame.ApplyJitterCopy(projection); + + Assert.Equal(0.0, projection[8]); + Assert.Equal((float)(-2.0 * frame.JitterPx.X / Width), jittered[8], 6); + Assert.Equal((float)(-2.0 * frame.JitterPx.Y / Height), jittered[9], 6); + } + + // --- reset detection ------------------------------------------------------ + + [Fact] + public void CameraDeltaAboveEightBlocksIsATeleport() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + + Advance(frame, new Vec3d(100, 64, 100), uniforms); + Advance(frame, new Vec3d(100.5, 64, 100), uniforms); + Assert.False(frame.Reset); + Assert.Equal(0.5f, frame.CameraPosDelta.X, 4); + + Advance(frame, new Vec3d(400, 64, 100), uniforms); + Assert.True(frame.Reset); + Assert.Equal(EnumTemporalResetReason.Teleport, frame.ResetReason); + // A reset frame has no meaningful camera delta to reproject with. + Assert.Equal(0f, frame.CameraPosDelta.X); + } + + [Fact] + public void CameraDeltaAtTheThresholdIsStillMotion() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + + Advance(frame, new Vec3d(0, 0, 0), uniforms); + Advance(frame, new Vec3d(7.9, 0, 0), uniforms); + + Assert.False(frame.Reset); + Assert.Equal(7.9f, frame.CameraPosDelta.X, 4); + } + + [Fact] + public void ReferencePositionRebaseIsAReset() + { + var frame = NewFrame(); + var pos = new Vec3d(0, 0, 0); + + Advance(frame, pos, Uniforms(0, 0)); + Advance(frame, pos, Uniforms(0, 0)); + Assert.False(frame.Reset); + + Advance(frame, pos, Uniforms(512000, 0)); + Assert.True(frame.Reset); + Assert.Equal(EnumTemporalResetReason.Rebase, frame.ResetReason); + } + + [Fact] + public void RenderSizeChangeIsAResizeReset() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var pos = new Vec3d(0, 0, 0); + + Advance(frame, pos, uniforms); + frame.Advance(16.6f, 1280, 720, 1f, 0.1f, 3000f, 1.2f, pos, uniforms); + + Assert.True(frame.Reset); + Assert.Equal(EnumTemporalResetReason.Resize, frame.ResetReason); + Assert.Equal(1280, frame.RenderWidth); + } + + // --- reset flag lifetime --------------------------------------------------- + + [Fact] + public void RequestedResetLastsExactlyOneFrame() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var pos = new Vec3d(0, 0, 0); + + Advance(frame, pos, uniforms); + Assert.False(frame.Reset); + + frame.RequestReset(EnumTemporalResetReason.FovChange); + Assert.False(frame.Reset); // not visible until the next Advance + + Advance(frame, pos, uniforms); + Assert.True(frame.Reset); + Assert.Equal(EnumTemporalResetReason.FovChange, frame.ResetReason); + + Advance(frame, pos, uniforms); + Assert.False(frame.Reset); + Assert.Equal(EnumTemporalResetReason.None, frame.ResetReason); + } + + [Fact] + public void TheEarliestRequestedReasonWins() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + var pos = new Vec3d(0, 0, 0); + + frame.RequestReset(EnumTemporalResetReason.WorldLoad); + frame.RequestReset(EnumTemporalResetReason.ShaderReload); + Advance(frame, pos, uniforms); + + Assert.Equal(EnumTemporalResetReason.WorldLoad, frame.ResetReason); + } + + [Fact] + public void NoneIsNotARequestableReason() + { + var frame = NewFrame(); + frame.RequestReset(EnumTemporalResetReason.None); + Advance(frame, new Vec3d(0, 0, 0), Uniforms()); + + Assert.False(frame.Reset); + } + + // --- config ---------------------------------------------------------------- + + [Fact] + public void TaaDefaultsToOff() + { + Assert.False(Vintagestory.API.Config.OptimumConfig.Taa); + Assert.False(Vintagestory.API.Config.OptimumConfig.TaaJitterDev); + } + + private static double[] Diagonal(double value) + { + double[] m = new double[16]; + m[0] = value; + m[5] = value; + m[10] = value; + m[15] = value; + return m; + } +} diff --git a/optimum-api-contracts/optimum-api-contracts.csproj b/optimum-api-contracts/optimum-api-contracts.csproj index fa49a995..3086ede7 100644 --- a/optimum-api-contracts/optimum-api-contracts.csproj +++ b/optimum-api-contracts/optimum-api-contracts.csproj @@ -27,6 +27,7 @@ + diff --git a/patches/VintagestoryApi/VintagestoryAPI.csproj.patch b/patches/VintagestoryApi/VintagestoryAPI.csproj.patch new file mode 100644 index 00000000..f89ebdd0 --- /dev/null +++ b/patches/VintagestoryApi/VintagestoryAPI.csproj.patch @@ -0,0 +1,16 @@ +diff --git a/VintagestoryApi/VintagestoryAPI.csproj b/VintagestoryApi/VintagestoryAPI.csproj +index e25adce..6aa3751 100644 +--- a/VintagestoryApi/VintagestoryAPI.csproj ++++ b/VintagestoryApi/VintagestoryAPI.csproj +@@ -50,11 +50,10 @@ + + + + + +- + + + + + diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs.patch new file mode 100644 index 00000000..c04ce2c6 --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs.patch @@ -0,0 +1,20 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs +index 5e61808..a7b3f2f 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs +@@ -483,10 +483,15 @@ public class ClientEventManager : EventManager + { + if (ShaderRegistry.SupressShaderAndBufferReloads) + { + return true; + } ++ // Optimum TAA: every shader reload path funnels through here (escape menu, ++ // hotkey, .debug commands, ShaderAPI, the ssaoQuality/minbrightness watchers). ++ // Programs and often the framebuffers are rebuilt, so the temporal history no ++ // longer matches what the next frame writes. ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.ShaderReload); + bool flag = true; + foreach (ActionBoolReturn onReloadShader in OnReloadShaders) + { + try + { diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index cc852b94..1a910925 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..14ebeb0 100644 +index 67feafa..9253b0f 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -129,7 +129,80 @@ index 67feafa..14ebeb0 100644 internal ServerInformation ServerInfo; -@@ -874,14 +902,19 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -511,10 +539,52 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + public long[] LoadedChunkIndices => WorldMap.chunks.Keys.ToArray(); + + public long[] LoadedMapChunkIndices => WorldMap.MapChunks.Keys.ToArray(); + + public float[] CurrentProjectionMatrix ++ { ++ get ++ { ++ // Optimum TAA: inside the temporal window every perspective draw gets the ++ // sub-pixel shear, and nothing else does. The guard is an exact comparison ++ // against the matrix Set3DProjection last loaded, so an ortho stack, a ++ // shadow ortho matrix or any matrix a caller pushed itself is handed out ++ // unchanged - that is what keeps the HUD and the shadow passes unjittered ++ // without a per-call-site opt-in. With TAA off JitterActive is never true ++ // and this reduces to the vanilla body. ++ double[] top = PMatrix.Top; ++ if (OptimumTemporal.Frame.JitterActive && set3DProjectionTempMat4 != null) ++ { ++ bool matches = true; ++ for (int j = 0; j < 16; j++) ++ { ++ if (top[j] != set3DProjectionTempMat4[j]) ++ { ++ matches = false; ++ break; ++ } ++ } ++ if (matches) ++ { ++ return OptimumTemporal.Frame.ApplyJitterCopy(top); ++ } ++ } ++ for (int i = 0; i < 16; i++) ++ { ++ tmpMatrix[i] = (float)top[i]; ++ } ++ return tmpMatrix; ++ } ++ } ++ ++ /// ++ /// Optimum TAA: the projection as loaded, never sheared. Motion-vector writers ++ /// and any consumer that reprojects a position must use this - a motion vector ++ /// computed through the jittered matrix carries the jitter difference of two ++ /// frames instead of the surface's own movement. ++ /// ++ public float[] CurrentProjectionMatrixUnjittered + { + get + { + for (int i = 0; i < 16; i++) + { +@@ -859,29 +929,39 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + } + } + + private void OnFowChanged(int newValue) + { ++ // Optimum TAA: the projection changes, so every reprojection into the history ++ // is wrong for one frame. ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.FovChange); + MainCamera.Fov = (float)ClientSettings.FieldOfView * ((float)Math.PI / 180f); + MainCamera.ZNear = GameMath.Clamp(0.1f - (float)ClientSettings.FieldOfView / 90f / 25f, 0.025f, 0.1f); + Reset3DProjection(); + } + + public void Start() + { ++ // Optimum TAA: a fresh world session has no history worth keeping. ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.WorldLoad); + Compression.Reset(); + Platform.ResetGamePauseAndUptimeState(); + disconnectAction = null; disconnectMissingMods = null; quadModel = Platform.UploadMesh(QuadMeshUtilExt.GetQuadModelData()); FrustumCulling frustumCulling = new FrustumCulling(); @@ -152,7 +225,7 @@ index 67feafa..14ebeb0 100644 _clientThreadsCts = new CancellationTokenSource(); ClientSystem clientSystem = new SystemCompressChunks(this); Thread thread = new Thread(new ClientThread(this, "compresschunks", new ClientSystem[1] { clientSystem }, _clientThreadsCts.Token).Process); -@@ -899,17 +932,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -899,17 +979,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo Thread thread3 = new Thread(new ClientThread(this, "relight", new ClientSystem[1] { clientSystem3 }, _clientThreadsCts.Token).Process); thread3.IsBackground = true; thread3.Start(); @@ -176,7 +249,7 @@ index 67feafa..14ebeb0 100644 thread5.IsBackground = true; thread5.Start(); thread5.Name = "chunkvis"; -@@ -1032,11 +1069,32 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1032,11 +1116,32 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void ExecuteMainThreadTasks(float deltaTime) { ScreenManager.FrameProfiler.Mark("beginMTT"); @@ -210,7 +283,74 @@ index 67feafa..14ebeb0 100644 { if (SuspendMainThreadTasks) { -@@ -1565,21 +1623,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1122,10 +1227,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + Platform.ThreadSpinWait(10000000); + } + shUniforms.Update(dt, api); + shUniforms.ZNear = MainCamera.ZNear; + shUniforms.ZFar = MainCamera.ZFar; ++ // Optimum TAA: rotate the temporal frame contract here, after the shader ++ // uniforms are final for the frame and before any render stage runs, so every ++ // pass - including the LiquidDepth prepass in the Before stage - sees one ++ // consistent snapshot and the same jitter. ++ FrameBufferRef primaryFb = ((Platform.FrameBuffers != null && Platform.FrameBuffers.Count > 0) ? Platform.FrameBuffers[0] : null); ++ OptimumTemporal.Frame.Advance(dt * 1000f, (primaryFb != null) ? primaryFb.Width : Width, (primaryFb != null) ? primaryFb.Height : Height, OptimumConfig.EffectiveRenderScale, MainCamera.ZNear, MainCamera.ZFar, MainCamera.Fov, EntityPlayer?.CameraPos, shUniforms); ++ OptimumTemporal.Frame.JitterActive = OptimumConfig.EffectiveTaa || OptimumConfig.TaaJitterDev; + TriggerRenderStage(EnumRenderStage.Before, dt); + Platform.GlEnableDepthTest(); + Platform.GlDepthMask(flag: true); + ScreenManager.FrameProfiler.Mark("rendOpaque-12before"); + if (AmbientManager.ShadowQuality > 0 && (double)AmbientManager.DropShadowIntensity > 0.01) +@@ -1140,10 +1252,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + } + ScreenManager.FrameProfiler.Mark("rendOpaque-3shadows"); + GlMatrixModeModelView(); + GlLoadMatrix(MainCamera.CameraMatrix); + double[] top = api.Render.PMatrix.Top; ++ // Optimum TAA: freeze the world view for the frame - CameraMatrix is the entity ++ // view, CameraMatrixOrigin the terrain view drawn relative to the rebased ++ // origin, and PMatrix.Top here is the unjittered world perspective the frame ++ // culls and draws with. Recorded here rather than relying on Set3DProjection ++ // alone, which vanilla happens to call once per frame from the sky renderer. ++ OptimumTemporal.Frame.CaptureCamera(MainCamera.CameraMatrix, MainCamera.CameraMatrixOrigin); ++ OptimumTemporal.Frame.RecordProjection(EnumTemporalView.World, top); + double[] top2 = api.Render.MvMatrix.Top; + for (int i = 0; i < 16; i++) + { + PerspectiveProjectionMat[i] = top[i]; + PerspectiveViewMat[i] = top2[i]; +@@ -1180,10 +1299,15 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + TriggerRenderStage(EnumRenderStage.AfterOIT, dt); + } + + public void RenderAfterPostProcessing(float dt) + { ++ // Optimum TAA: closes the jitter window. The resolve will eventually run at the ++ // end of post-processing and close it there instead; until it exists this is the ++ // first point after every jittered pass (scene geometry and SSAO), so late ++ // overlays, the HUD and the blit stay unjittered. ++ OptimumTemporal.Frame.JitterActive = false; + if (DeltaTimeLimiter > 0f) + { + dt = DeltaTimeLimiter; + } + TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); +@@ -1420,10 +1544,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + { + float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; + Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); + GlMatrixModeProjection(); + GlLoadMatrix(set3DProjectionTempMat4); ++ // Optimum TAA: record the unjittered matrix for the view it belongs to. A fov ++ // that is not the main camera's is the first-person hand pass (HandRenderFov in ++ // EntityPlayerShapeRenderer), which reprojects through its own previous matrix. ++ OptimumTemporal.Frame.RecordProjection((fov != MainCamera.Fov) ? EnumTemporalView.Hand : EnumTemporalView.World, set3DProjectionTempMat4); + shUniforms.ZNear = MainCamera.ZNear; + shUniforms.ZFar = MainCamera.ZFar; + GlMatrixModeModelView(); + } + +@@ -1565,21 +1693,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -243,7 +383,7 @@ index 67feafa..14ebeb0 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2191,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2261,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -268,7 +408,21 @@ index 67feafa..14ebeb0 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -3531,6 +3608,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2690,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + ShouldRedrawAllBlocks = true; + } + + public void OnResize() + { ++ // Optimum TAA: the history targets are reallocated at the new size, so nothing ++ // in them reprojects. ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.Resize); + Platform.GlViewport(0, 0, Platform.WindowSize.Width, Platform.WindowSize.Height); + Reset3DProjection(); + } + + public void DoReconnect() +@@ -3531,6 +3681,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 9ef9499c..fca4759b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..8824d24 100644 +index 6edf0c9..3289533 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -102,7 +102,7 @@ index 6edf0c9..8824d24 100644 private Logger logger; private int doResize; -@@ -93,10 +182,33 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,57 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -112,6 +112,30 @@ index 6edf0c9..8824d24 100644 + + private bool optimumFsrDisabled; + ++ // Optimum: TAA history targets. Two full-resolution slots (current/previous ++ // parity), each colour (RGBA16F) + aux (RGBA8: glow.rg, ssao.b) + linear ++ // depth (R32F). R32F has no EnumTextureInternalFormat entry, so it is ++ // created via CreateTexture2DRaw / a raw GL token like the other raw-GL ++ // spots in this file. ++ private const int OptimumTaaHistoryIndexA = 19; ++ ++ private const int OptimumTaaHistoryIndexB = 20; ++ ++ private const int OptimumGlR32f = 0x822E; ++ ++ /// ++ /// Optimum: the Primary colour-attachment index that holds per-pixel motion ++ /// (rg), reactive (b) and writer depth (a) once TAA is enabled - 2 without ++ /// the SSAO G-buffer, 4 with it. -1 when TAA is off or its targets failed ++ /// to allocate. Never part of the default draw-buffer mask: only a writer ++ /// that has been ported to emit it (P3+) enables it explicitly. ++ /// ++ public int MotionAttachmentIndex { get; private set; } = -1; ++ ++ private bool TaaTargetsReady; ++ ++ private bool optimumTaaDisabled; ++ + // Optimum: GL keeps the clear colour in driver state and applies it at + // glClear; the device takes it as an argument, so GlClearColorRgbaf records + // it here and ClearFrameBuffer passes it on. Deliberately left at the @@ -136,7 +160,31 @@ index 6edf0c9..8824d24 100644 private bool serverRunning; private bool gamepause; -@@ -278,11 +390,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +392,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + } + } + + public override List FrameBuffers => frameBuffers; + ++ /// ++ /// Optimum: one of the two TAA history slots, selected by frame parity ++ /// (typically frameIndex & 1). Null when TAA is off or its targets ++ /// failed to allocate - callers should check ++ /// (via the setup/disable paths) before writing or reading through it. ++ /// Binding for writing uses the existing LoadFrameBuffer(FrameBufferRef, int) ++ /// overload; this method never binds anything itself. ++ /// ++ public FrameBufferRef TaaHistory(int parity) ++ { ++ return frameBuffers[(parity & 1) == 0 ? OptimumTaaHistoryIndexA : OptimumTaaHistoryIndexB]; ++ } ++ + public override bool IsServerRunning + { + get + { + return serverRunning; +@@ -278,11 +427,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -164,7 +212,7 @@ index 6edf0c9..8824d24 100644 GL.BindFramebuffer((FramebufferTarget)36160, 0); return; } -@@ -297,11 +425,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -297,11 +462,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -188,7 +236,7 @@ index 6edf0c9..8824d24 100644 } public override bool GlErrorChecking { get; set; } -@@ -314,10 +454,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -314,10 +491,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } set { @@ -209,7 +257,7 @@ index 6edf0c9..8824d24 100644 if (!supportsGlDebugMode) { throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -335,11 +485,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -335,11 +522,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } glDebugMode = value; } @@ -238,7 +286,7 @@ index 6edf0c9..8824d24 100644 public override bool MouseGrabbed { -@@ -478,41 +644,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,41 +681,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -385,7 +433,7 @@ index 6edf0c9..8824d24 100644 public void LogAndTestHardwareInfosStage1() { -@@ -533,10 +800,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -533,10 +837,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } @@ -423,7 +471,7 @@ index 6edf0c9..8824d24 100644 logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +870,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -576,10 +907,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CheckGlError("testhwinfo"); } @@ -442,7 +490,7 @@ index 6edf0c9..8824d24 100644 public override string GetFrameworkInfos() { -@@ -702,10 +1004,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,10 +1041,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -464,7 +512,7 @@ index 6edf0c9..8824d24 100644 SupportsThickLines = (int)error != 1281; cpuCoreCount = Environment.ProcessorCount; } -@@ -796,10 +1109,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1146,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -488,7 +536,7 @@ index 6edf0c9..8824d24 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1342,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1379,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -526,7 +574,7 @@ index 6edf0c9..8824d24 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1385,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1422,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -546,7 +594,7 @@ index 6edf0c9..8824d24 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1416,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1453,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -563,7 +611,7 @@ index 6edf0c9..8824d24 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1461,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1498,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -596,7 +644,7 @@ index 6edf0c9..8824d24 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1525,324 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1562,406 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -630,6 +678,11 @@ index 6edf0c9..8824d24 100644 + return list; + } + ++ // Optimum: TAA. Read once per (re-)build; a mid-session config change ++ // only takes effect on the next RebuildFrameBuffers. ++ bool taaRequested = Vintagestory.API.Config.OptimumConfig.EffectiveTaa; ++ int motionAttachmentIndex = -1; ++ + // Primary: depth, colour, glow, and the SSAO position/normal G-buffer. + FrameBufferRef primary = new FrameBufferRef(); + primary.Width = width; @@ -652,7 +705,29 @@ index 6edf0c9..8824d24 100644 + primary.ColorTextureIds[3] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + } -+ for (int attachment = 0; attachment < primaryAttachments; attachment++) ++ if (taaRequested) ++ { ++ // Optimum: TAA motion attachment, appended after the SSAO G-buffer ++ // so every existing attachment index is unchanged. Deliberately not ++ // folded into the draw-buffer mask below - it stays out of every ++ // pass's output set until a writer opts in (P3+). ++ try ++ { ++ int motionTextureId = device.CreateTexture2D(width, height, ++ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); ++ int[] extendedColorIds = new int[primary.ColorTextureIds.Length + 1]; ++ Array.Copy(primary.ColorTextureIds, extendedColorIds, primary.ColorTextureIds.Length); ++ motionAttachmentIndex = primary.ColorTextureIds.Length; ++ extendedColorIds[motionAttachmentIndex] = motionTextureId; ++ primary.ColorTextureIds = extendedColorIds; ++ } ++ catch (Exception error) ++ { ++ DisableOptimumTaa("Primary motion attachment (device): " + error.Message); ++ motionAttachmentIndex = -1; ++ } ++ } ++ for (int attachment = 0; attachment < primary.ColorTextureIds.Length; attachment++) + { + device.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), @@ -660,6 +735,7 @@ index 6edf0c9..8824d24 100644 + } + device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1); + list[0] = primary; ++ MotionAttachmentIndex = motionAttachmentIndex; + + // Transparent: OIT accumulation, revealage, glow. Shares Primary's depth. + FrameBufferRef transparent = new FrameBufferRef(); @@ -753,6 +829,26 @@ index 6edf0c9..8824d24 100644 + list[7] = CreateOptimumColorTarget(device, width / 2, height / 2, EnumTextureInternalFormat.Rgba16f); + list[10] = CreateOptimumColorTarget(device, width, height, EnumTextureInternalFormat.Rgba16f); + ++ // Optimum: TAA history, render-resolution like Primary. Two slots so the ++ // resolve reads last frame's parity while writing this frame's; never ++ // cleared per frame (ClearFrameBuffer(Primary) only touches Primary). ++ if (taaRequested) ++ { ++ try ++ { ++ list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTarget(device, width, height); ++ list[OptimumTaaHistoryIndexB] = CreateOptimumHistoryTarget(device, width, height); ++ } ++ catch (Exception error) ++ { ++ DisableOptimumTaa("history targets (device): " + error.Message); ++ list[OptimumTaaHistoryIndexA] = null; ++ list[OptimumTaaHistoryIndexB] = null; ++ } ++ } ++ TaaTargetsReady = taaRequested && MotionAttachmentIndex >= 0 ++ && list[OptimumTaaHistoryIndexA] != null && list[OptimumTaaHistoryIndexB] != null; ++ + // FSR renders at a reduced scale and resolves into a native-sized target. + if (ClientSettings.OptimumRenderScale < 1.0f) + { @@ -892,6 +988,40 @@ index 6edf0c9..8824d24 100644 + } + + /// ++ /// Optimum: a TAA history slot - colour (RGBA16F), aux (RGBA8: glow.rg, ++ /// ssao.b) and linear depth (R32F), MRT-written by the resolve pass and ++ /// read back next frame. R32F has no ++ /// entry, so it goes through CreateTexture2DRaw with the raw GL token, ++ /// the same way the SSAO noise texture does above. ++ /// ++ private FrameBufferRef CreateOptimumHistoryTarget( ++ Vintagestory.API.Config.IOptimumGraphicsDevice device, int width, int height) ++ { ++ FrameBufferRef target = new FrameBufferRef(); ++ target.Width = width; ++ target.Height = height; ++ target.FboId = device.CreateFramebuffer(width, height); ++ target.ColorTextureIds = new int[3]; ++ target.ColorTextureIds[0] = device.CreateTexture2D(width, height, ++ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); ++ target.ColorTextureIds[1] = device.CreateTexture2D(width, height, ++ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); ++ target.ColorTextureIds[2] = device.CreateTexture2DRaw(width, height, OptimumGlR32f, IntPtr.Zero, 4); ++ for (int attachment = 0; attachment < 3; attachment++) ++ { ++ device.AttachTexture(target.FboId, ++ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), ++ target.ColorTextureIds[attachment], 0); ++ } ++ device.SetDrawBuffers(target.FboId, 7); ++ if (!device.CheckFramebufferComplete(target.FboId, out string status)) ++ { ++ throw new Exception("Optimum TAA history FBO: " + status); ++ } ++ return target; ++ } ++ ++ /// + /// A framebuffer slot that exists but owns nothing, for a quality level whose + /// resources are not allocated. + /// @@ -921,7 +1051,22 @@ index 6edf0c9..8824d24 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1210,11 +1897,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +1993,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); + if (num == 0 || num2 == 0) + { + return list; + } ++ // Optimum: TAA. Read once per (re-)build; a mid-session config change ++ // only takes effect on the next RebuildFrameBuffers. ++ bool taaRequested = Vintagestory.API.Config.OptimumConfig.EffectiveTaa; ++ int motionAttachmentIndex = -1; + PixelFormat val = (PixelFormat)6408; + CheckGlError("sdfb-begin"); + FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), +@@ -1210,11 +2020,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -938,12 +1083,70 @@ index 6edf0c9..8824d24 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1436,10 +2127,33 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2065,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + (DrawBuffersEnum)36064, + (DrawBuffersEnum)36065 + }; + GL.DrawBuffers(2, array3); + } ++ if (taaRequested) ++ { ++ // Optimum: TAA motion attachment, appended after the SSAO G-buffer ++ // so every existing attachment index is unchanged. Deliberately not ++ // folded into the DrawBuffers calls above - it stays out of every ++ // pass's output set until a writer opts in (P3+). ++ try ++ { ++ int motionTextureId = GL.GenTexture(); ++ GL.BindTexture((TextureTarget)3553, motionTextureId); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ motionAttachmentIndex = frameBufferRef3.ColorTextureIds.Length; ++ int motionAttachmentEnum = 36064 + motionAttachmentIndex; ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)motionAttachmentEnum, (TextureTarget)3553, motionTextureId, 0); ++ int[] extendedColorIds = new int[frameBufferRef3.ColorTextureIds.Length + 1]; ++ Array.Copy(frameBufferRef3.ColorTextureIds, extendedColorIds, frameBufferRef3.ColorTextureIds.Length); ++ extendedColorIds[motionAttachmentIndex] = motionTextureId; ++ frameBufferRef3.ColorTextureIds = extendedColorIds; ++ } ++ catch (Exception error) ++ { ++ DisableOptimumTaa("Primary motion attachment (GL): " + error.Message); ++ motionAttachmentIndex = -1; ++ } ++ } ++ MotionAttachmentIndex = motionAttachmentIndex; + CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); + frameBufferRef = (list[1] = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), + Width = num, +@@ -1436,10 +2278,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); GL.DrawBuffer((DrawBufferMode)36064); CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Luma); ++ // Optimum: TAA history, render-resolution like Primary. Two slots so the ++ // resolve reads last frame's parity while writing this frame's; never ++ // cleared per frame (ClearFrameBuffer(Primary) only touches Primary). ++ if (taaRequested) ++ { ++ try ++ { ++ list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTargetGl(num, num2); ++ list[OptimumTaaHistoryIndexB] = CreateOptimumHistoryTargetGl(num, num2); ++ } ++ catch (Exception error) ++ { ++ DisableOptimumTaa("history targets (GL): " + error.Message); ++ list[OptimumTaaHistoryIndexA] = null; ++ list[OptimumTaaHistoryIndexB] = null; ++ } ++ } ++ TaaTargetsReady = taaRequested && MotionAttachmentIndex >= 0 ++ && list[OptimumTaaHistoryIndexA] != null && list[OptimumTaaHistoryIndexB] != null; + if (ClientSettings.OptimumRenderScale < 1.0f) + { + int nativeWidth = ((NativeWindow)window).ClientSize.X; @@ -972,10 +1175,64 @@ index 6edf0c9..8824d24 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1569,10 +2283,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2451,83 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); } ++ /// ++ /// Optimum: the GL-path body of - ++ /// a TAA history slot with colour (RGBA16F), aux (RGBA8: glow.rg, ssao.b) ++ /// and linear depth (R32F, raw GL token - no PixelInternalFormat ++ /// member for it) attachments. ++ /// ++ private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) ++ { ++ FrameBufferRef target = new FrameBufferRef ++ { ++ FboId = GL.GenFramebuffer(), ++ Width = width, ++ Height = height ++ }; ++ CurrentFrameBufferKeepVw = target; ++ target.ColorTextureIds = new int[3] { GL.GenTexture(), GL.GenTexture(), GL.GenTexture() }; ++ ++ GL.BindTexture((TextureTarget)3553, target.ColorTextureIds[0]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, width, height, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, target.ColorTextureIds[0], 0); ++ ++ GL.BindTexture((TextureTarget)3553, target.ColorTextureIds[1]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, width, height, 0, (PixelFormat)6408, (PixelType)5121, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, target.ColorTextureIds[1], 0); ++ ++ GL.BindTexture((TextureTarget)3553, target.ColorTextureIds[2]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)OptimumGlR32f, width, height, 0, (PixelFormat)6403, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, target.ColorTextureIds[2], 0); ++ ++ DrawBuffersEnum[] drawBuffers = new DrawBuffersEnum[3] ++ { ++ DrawBuffersEnum.ColorAttachment0, ++ DrawBuffersEnum.ColorAttachment1, ++ DrawBuffersEnum.ColorAttachment2 ++ }; ++ GL.DrawBuffers(3, drawBuffers); ++ CheckFboStatus((FramebufferTarget)36160, "OptimumTaaHistory"); ++ return target; ++ } ++ public void DisposeFrameBuffers(List buffers) { + // Mono.Cecil transplant. @@ -1002,7 +1259,7 @@ index 6edf0c9..8824d24 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2324,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2546,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1031,7 +1288,7 @@ index 6edf0c9..8824d24 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2358,74 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,21 +2580,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1080,6 +1337,10 @@ index 6edf0c9..8824d24 100644 + optimumDevice.ClearColor(2, 0f, 0f, 0f, 1f); + optimumDevice.ClearColor(3, 0f, 0f, 0f, 1f); + } ++ if (MotionAttachmentIndex >= 0) ++ { ++ optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f); ++ } + optimumDevice.ClearDepth(1f); + break; + case EnumFrameBuffer.LiquidDepth: @@ -1106,7 +1367,22 @@ index 6edf0c9..8824d24 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1670,20 +2473,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1636,10 +2665,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + if (RenderSSAO) + { + GL.ClearBuffer((ClearBuffer)6144, 2, new float[4] { 0f, 0f, 0f, 1f }); + GL.ClearBuffer((ClearBuffer)6144, 3, new float[4] { 0f, 0f, 0f, 1f }); + } ++ if (MotionAttachmentIndex >= 0) ++ { ++ GL.ClearBuffer((ClearBuffer)6144, MotionAttachmentIndex, new float[4]); ++ } + float num2 = 1f; + GL.ClearBuffer((ClearBuffer)6145, 0, ref num2); + break; + } + case EnumFrameBuffer.LiquidDepth: +@@ -1670,20 +2703,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1147,7 +1423,7 @@ index 6edf0c9..8824d24 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2516,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2746,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1214,7 +1490,7 @@ index 6edf0c9..8824d24 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2586,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2816,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1236,7 +1512,7 @@ index 6edf0c9..8824d24 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2610,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2840,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1263,7 +1539,7 @@ index 6edf0c9..8824d24 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,15 +2635,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,15 +2865,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1302,7 +1578,7 @@ index 6edf0c9..8824d24 100644 transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; -@@ -1823,10 +2684,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,10 +2914,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1318,7 +1594,7 @@ index 6edf0c9..8824d24 100644 if (RenderBloom) { GlToggleBlend(on: false); -@@ -1848,45 +2714,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +2944,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1378,7 +1654,7 @@ index 6edf0c9..8824d24 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,11 +2791,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,11 +3021,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1391,7 +1667,7 @@ index 6edf0c9..8824d24 100644 { LoadFrameBuffer(EnumFrameBuffer.Luma); ShaderProgramLuma luma = ShaderPrograms.Luma; -@@ -1935,11 +2811,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1935,11 +3041,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract blit.Use(); blit.Scene2D = frameBuffers[0].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); @@ -1413,7 +1689,7 @@ index 6edf0c9..8824d24 100644 } public override void RenderFinalComposition() -@@ -1953,13 +2838,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,13 +3068,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1442,7 +1718,7 @@ index 6edf0c9..8824d24 100644 final.Use(); final.PrimaryScene2D = primaryScene2D; final.BloomParts2D = bloomParts2D; -@@ -1987,23 +2885,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3115,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1483,7 +1759,7 @@ index 6edf0c9..8824d24 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +2936,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,19 +3166,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -1497,6 +1773,24 @@ index 6edf0c9..8824d24 100644 + } + optimumFsrDisabled = true; + } ++ ++ /// ++ /// Optimum: marks TAA unavailable for the rest of the session after its ++ /// frame buffers failed to allocate. Modelled on : ++ /// logs once, then every later (re-)build sees OptimumConfig.EffectiveTaa ++ /// still true but skips relying on these targets via ++ /// and (reset to -1 here too). ++ /// ++ public void DisableOptimumTaa(string reason) ++ { ++ if (!optimumTaaDisabled) ++ { ++ logger.Error("Optimum TAA disabled after frame buffer setup failure: {0}", reason); ++ } ++ optimumTaaDisabled = true; ++ TaaTargetsReady = false; ++ MotionAttachmentIndex = -1; ++ } + public override void BlitPrimaryToDefault() { @@ -1505,6 +1799,30 @@ index 6edf0c9..8824d24 100644 if (OffscreenBuffer) { int scene2D = frameBuffers[0].ColorTextureIds[0]; ++ // Optimum P1: TAA debug views. Bypasses the normal FSR/blit path ++ // entirely and draws a fullscreen triangle visualising the Primary ++ // motion attachment, depth buffer or scene colour. Only reached when ++ // a debug mode is selected and the motion attachment actually exists ++ // (i.e. the TAA render targets were requested this frame); falls ++ // through to the normal path otherwise so TAA-off stays unaffected. ++ if (OptimumConfig.TaaDebugView != 0 && MotionAttachmentIndex >= 0) ++ { ++ ShaderProgram taaDebug = ShaderPrograms.TaaDebug; ++ if (taaDebug != null && !taaDebug.LoadError) ++ { ++ LoadFrameBuffer(EnumFrameBuffer.Default); ++ GlViewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); ++ taaDebug.Use(); ++ taaDebug.BindTexture2D("motionTex", frameBuffers[0].ColorTextureIds[MotionAttachmentIndex], 0); ++ taaDebug.BindTexture2D("depthTex", frameBuffers[0].DepthTextureId, 1); ++ taaDebug.BindTexture2D("sceneTex", scene2D, 2); ++ taaDebug.Uniform("mode", OptimumConfig.TaaDebugView); ++ taaDebug.Uniform("renderSize", (float)frameBuffers[0].Width, (float)frameBuffers[0].Height); ++ RenderFullscreenTriangle(screenQuad); ++ taaDebug.Stop(); ++ return; ++ } ++ } + FrameBufferRef optimumFsrFramebuffer = frameBuffers[OptimumFsrFramebufferIndex]; + ShaderProgram fsrEasu = ShaderPrograms.FsrEasu; + ShaderProgram fsrRcas = ShaderPrograms.FsrRcas; @@ -1562,7 +1880,7 @@ index 6edf0c9..8824d24 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3054,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3326,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -1588,7 +1906,7 @@ index 6edf0c9..8824d24 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3087,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3359,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -1610,7 +1928,7 @@ index 6edf0c9..8824d24 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3116,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3388,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -1708,7 +2026,7 @@ index 6edf0c9..8824d24 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3215,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,36 +3487,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1797,7 +2115,7 @@ index 6edf0c9..8824d24 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3331,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2233,33 +3603,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1869,7 +2187,7 @@ index 6edf0c9..8824d24 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3409,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +3681,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2036,7 +2354,7 @@ index 6edf0c9..8824d24 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +3579,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +3851,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2060,7 +2378,7 @@ index 6edf0c9..8824d24 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +3608,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +3880,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2098,7 +2416,7 @@ index 6edf0c9..8824d24 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +3668,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +3940,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2141,7 +2459,7 @@ index 6edf0c9..8824d24 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +3721,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +3993,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2200,7 +2518,7 @@ index 6edf0c9..8824d24 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +3836,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4108,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2274,7 +2592,7 @@ index 6edf0c9..8824d24 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +3934,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4206,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2305,7 +2623,7 @@ index 6edf0c9..8824d24 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +3971,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4243,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2340,7 +2658,7 @@ index 6edf0c9..8824d24 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4018,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4290,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2369,7 +2687,7 @@ index 6edf0c9..8824d24 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4049,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4321,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2393,7 +2711,7 @@ index 6edf0c9..8824d24 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4086,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4358,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -2410,7 +2728,7 @@ index 6edf0c9..8824d24 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4138,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4410,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2455,7 +2773,7 @@ index 6edf0c9..8824d24 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4175,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4447,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2476,7 +2794,7 @@ index 6edf0c9..8824d24 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4194,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4466,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2497,7 +2815,7 @@ index 6edf0c9..8824d24 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4213,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4485,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2518,7 +2836,7 @@ index 6edf0c9..8824d24 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4232,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4504,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2539,7 +2857,7 @@ index 6edf0c9..8824d24 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4255,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4527,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2560,7 +2878,7 @@ index 6edf0c9..8824d24 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4298,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4570,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -2586,7 +2904,7 @@ index 6edf0c9..8824d24 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +4540,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +4812,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -2608,7 +2926,7 @@ index 6edf0c9..8824d24 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +4738,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5010,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -2631,7 +2949,7 @@ index 6edf0c9..8824d24 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +4812,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5084,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -2680,7 +2998,7 @@ index 6edf0c9..8824d24 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +4882,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5154,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -2712,7 +3030,7 @@ index 6edf0c9..8824d24 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +4912,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5184,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -2738,7 +3056,7 @@ index 6edf0c9..8824d24 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5260,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5532,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -2768,7 +3086,7 @@ index 6edf0c9..8824d24 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5314,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5586,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs.patch new file mode 100644 index 00000000..58518450 --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs.patch @@ -0,0 +1,30 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs b/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs +index 142286d..acf8f17 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs +@@ -51,10 +51,25 @@ public class RenderAPIGame : RenderAPIBase + + public override float[] CurrentModelviewMatrix => game.CurrentModelViewMatrix; + + public override float[] CurrentProjectionMatrix => game.CurrentProjectionMatrix; + ++ /// ++ /// Optimum TAA: the projection as loaded, without the temporal jitter shear. ++ /// New member rather than an IRenderAPI change - the interface is implemented by ++ /// mods, so it cannot grow. ++ /// ++ public float[] CurrentProjectionMatrixUnjittered => game.CurrentProjectionMatrixUnjittered; ++ ++ /// ++ /// Optimum TAA: read-only access to this frame's temporal contract (jitter, the ++ /// per-view current and previous camera constants, warp state, reset reason). ++ /// Reached from a mod through (capi.Render as RenderAPIGame)?.TemporalContext ++ /// or, without a cast, through the static OptimumTemporal.Context. ++ /// ++ public IOptimumTemporalContext TemporalContext => OptimumTemporal.Context; ++ + public override EnumRenderStage CurrentRenderStage => game.currentRenderStage; + + public override float[] CurrentShadowProjectionMatrix => game.shadowMvpMatrix; + + public override FrustumCulling DefaultFrustumCuller => game.frustumCuller; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch index 2a76e0c6..0ca56f10 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -index f19d524..64a9dd4 100644 +index f19d524..5bda201 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -@@ -40,10 +40,14 @@ public static class ShaderPrograms +@@ -40,10 +40,16 @@ public static class ShaderPrograms public static ShaderProgramEntityanimated Entityanimated; @@ -11,6 +11,8 @@ index f19d524..64a9dd4 100644 + public static ShaderProgram FsrEasu; + + public static ShaderProgram FsrRcas; ++ ++ public static ShaderProgram TaaDebug; + public static ShaderProgramFindbright Findbright; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index 7601bf86..04e92436 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..a45ac57 100644 +index 4a24e75..b2ad007 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..a45ac57 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,154 @@ public class ShaderRegistry +@@ -181,39 +183,155 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -21,6 +21,7 @@ index 4a24e75..a45ac57 100644 }); + RegisterOptimumShaderProgram("fsr-easu", ShaderPrograms.FsrEasu = new ShaderProgram()); + RegisterOptimumShaderProgram("fsr-rcas", ShaderPrograms.FsrRcas = new ShaderProgram()); ++ RegisterOptimumShaderProgram("taa-debug", ShaderPrograms.TaaDebug = new ShaderProgram()); + } + + private static void RegisterOptimumShaderProgram(string name, ShaderProgram program) @@ -150,7 +151,7 @@ index 4a24e75..a45ac57 100644 + bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); + OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); + } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas) ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug) + { + shaderProgram.LoadError |= !compiled; + } @@ -178,7 +179,7 @@ index 4a24e75..a45ac57 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +413,11 @@ public class ShaderRegistry +@@ -296,11 +414,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -191,7 +192,7 @@ index 4a24e75..a45ac57 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +450,27 @@ public class ShaderRegistry +@@ -333,10 +451,27 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index 9e71bb00..5b02490c 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -20,6 +20,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientCoreAPI.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientEventManager.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSettings.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientWorldMap.cs.patch @@ -29,6 +30,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiManager.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/HudEntityNameTags.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ParticlePoolQuads.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs new file mode 100644 index 00000000..dda1e845 --- /dev/null +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -0,0 +1,446 @@ +using System; +using Vintagestory.API.MathTools; + +#nullable disable + +namespace Vintagestory.API.Client +{ + /// + /// Why the temporal history is invalid for a frame. Every consumer of the + /// temporal frame contract (the in-house TAA resolve first, vendor upscalers + /// and frame generators later) needs the same "throw the history away" signal, + /// and needs to know why, because the remedies differ: a resize reallocates + /// targets, a teleport only clears colour. + /// + public enum EnumTemporalResetReason + { + None = 0, + WorldLoad, + Dimension, + Teleport, + Rebase, + Resize, + ShaderReload, + FovChange, + RenderScale, + Toggle, + Screenshot + } + + /// + /// The camera views the temporal contract captures. Each one has its own + /// projection matrix and its own previous projection, because a motion vector + /// written by a draw under the hand FOV must be reprojected through the hand + /// FOV of the previous frame, not the world one. + /// + public enum EnumTemporalView + { + /// The world FOV set by Reset3DProjection. + World = 0, + /// The first-person hand FOV (EntityPlayerShapeRenderer's HandRenderFov). + Hand = 1 + } + + /// + /// Every uniform the vertex-warp functions read, snapshotted so a motion-vector + /// writer can evaluate the warp twice: once with this frame's state and once + /// with the previous frame's. The counters wrap (DefaultShaderUniforms.Update + /// takes them modulo 6000), so the previous value must be stored rather than + /// derived from the current value minus a delta. + /// + public struct OptimumWarpState + { + public float TimeCounter; + public float WindWaveCounter; + public float WindWaveCounterHighFreq; + public float WaterWaveCounter; + public float WindSpeed; + /// DefaultShaderUniforms.GlobalWorldWarp. + public float GlobalWarpIntensity; + public float GlitchWaviness; + public float WindWaveIntensity; + public float WaterWaveIntensity; + public int PerceptionEffectId; + public float PerceptionEffectIntensity; + + public static OptimumWarpState FromUniforms(DefaultShaderUniforms u) + { + OptimumWarpState state = default(OptimumWarpState); + if (u == null) return state; + + state.TimeCounter = u.TimeCounter; + state.WindWaveCounter = u.WindWaveCounter; + state.WindWaveCounterHighFreq = u.WindWaveCounterHighFreq; + state.WaterWaveCounter = u.WaterWaveCounter; + state.WindSpeed = u.WindSpeed; + state.GlobalWarpIntensity = u.GlobalWorldWarp; + state.GlitchWaviness = u.GlitchWaviness; + state.WindWaveIntensity = u.WindWaveIntensity; + state.WaterWaveIntensity = u.WaterWaveIntensity; + state.PerceptionEffectId = u.PerceptionEffectId; + state.PerceptionEffectIntensity = u.PerceptionEffectIntensity; + return state; + } + } + + /// + /// Read-only view of the current temporal frame. + /// + /// Deliberately a companion interface rather than new members on IRenderAPI: + /// every mod that implements IRenderAPI would break if the interface grew, and + /// the contract is expected to keep growing as upscalers and frame generation + /// land. Consumers reach it through . + /// + /// The float[16] matrices are the live per-frame arrays, not copies; treat them + /// as read-only and copy before keeping them past the frame. + /// + public interface IOptimumTemporalContext + { + /// Increments once per real rendered frame. Generated frames get their own id later. + long FrameIndex { get; } + + /// True while the jitter window is open: from Advance() until the resolve has run. + bool JitterActive { get; } + + /// The sub-pixel offset actually applied this frame, in render pixels. Zero while the window is closed. + Vec2f JitterPx { get; } + + /// The previous frame's applied jitter, in render pixels. + Vec2f PrevJitterPx { get; } + + /// The Halton offset for this frame index, whether or not it is applied. + Vec2f JitterSequencePx { get; } + + int RenderWidth { get; } + int RenderHeight { get; } + + /// The unjittered projection last loaded for the given view this frame. + float[] GetProjection(EnumTemporalView view); + /// The unjittered projection that view had in the previous frame. + float[] GetPrevProjection(EnumTemporalView view); + /// Whether the view was actually set up this frame (the hand view is absent in third person). + bool IsViewCaptured(EnumTemporalView view); + + float[] CameraMatrix { get; } + float[] PrevCameraMatrix { get; } + float[] CameraMatrixOrigin { get; } + float[] PrevCameraMatrixOrigin { get; } + + /// Current minus previous EntityPlayer.CameraPos, differenced in double precision. + Vec3f CameraPosDelta { get; } + + /// DefaultShaderUniforms.PlayerPos: the camera relative to the slowly rebased reference position. + Vec3f Playerpos { get; } + Vec3f PrevPlayerpos { get; } + + OptimumWarpState Warp { get; } + OptimumWarpState PrevWarp { get; } + + bool Reset { get; } + EnumTemporalResetReason ResetReason { get; } + + float ZNear { get; } + float ZFar { get; } + float Fov { get; } + float DeltaTimeMs { get; } + } + + /// + /// The per-frame temporal contract: one engine-owned superset of the data every + /// temporal consumer needs (jitter, camera constants and their previous values, + /// warp state, reset reason), rotated once per frame by . + /// + /// A single mutable instance rather than a fresh record per frame: it lives on + /// the render thread only and is read by shader-uniform setters in the hot path, + /// so it must not allocate per frame. + /// + public sealed class OptimumTemporalFrame : IOptimumTemporalContext + { + /// Camera movement above this many blocks in one frame is a teleport, not motion. + public const double TeleportThresholdBlocks = 8.0; + + private const int ViewCount = 2; + + private readonly float[][] projection = new float[ViewCount][]; + private readonly float[][] projectionPrev = new float[ViewCount][]; + private readonly bool[] viewCaptured = new bool[ViewCount]; + private readonly bool[] viewCapturedPrev = new bool[ViewCount]; + + private readonly float[] cameraMatrix = new float[16]; + private readonly float[] cameraMatrixPrev = new float[16]; + private readonly float[] cameraMatrixOrigin = new float[16]; + private readonly float[] cameraMatrixOriginPrev = new float[16]; + + /// + /// Scratch for the jittered projection handed out by + /// ClientMain.CurrentProjectionMatrix. Owned here so ClientMain needs no + /// injected field, and separate from ClientMain's shared tmpMatrix so a + /// caller that reads the modelview matrix in between does not overwrite it. + /// + private readonly float[] jitteredScratch = new float[16]; + + private readonly Vec3d cameraPos = new Vec3d(); + private readonly Vec3d cameraPosPrev = new Vec3d(); + private readonly Vec3d referencePos = new Vec3d(); + private bool hasCameraPos; + private bool hasReferencePos; + + private EnumTemporalResetReason pendingReset; + private bool jitterActive; + + public OptimumTemporalFrame() + { + for (int i = 0; i < ViewCount; i++) + { + projection[i] = new float[16]; + projectionPrev[i] = new float[16]; + } + } + + public long FrameIndex { get; private set; } + + public Vec2f JitterPx { get; } = new Vec2f(); + public Vec2f PrevJitterPx { get; } = new Vec2f(); + public Vec2f JitterSequencePx { get; } = new Vec2f(); + + /// + /// Opens and closes the jitter window. Setting it also updates + /// , so the contract always reports the offset that + /// was really applied: closed window means (0,0), which is what a motion + /// vector or a reprojection has to assume. + /// + public bool JitterActive + { + get { return jitterActive; } + set + { + jitterActive = value; + JitterPx.X = value ? JitterSequencePx.X : 0f; + JitterPx.Y = value ? JitterSequencePx.Y : 0f; + } + } + + public int RenderWidth { get; private set; } + public int RenderHeight { get; private set; } + + public float[] CameraMatrix => cameraMatrix; + public float[] PrevCameraMatrix => cameraMatrixPrev; + public float[] CameraMatrixOrigin => cameraMatrixOrigin; + public float[] PrevCameraMatrixOrigin => cameraMatrixOriginPrev; + + public Vec3f CameraPosDelta { get; } = new Vec3f(); + + public Vec3f Playerpos { get; } = new Vec3f(); + public Vec3f PrevPlayerpos { get; } = new Vec3f(); + + public OptimumWarpState Warp { get; private set; } + public OptimumWarpState PrevWarp { get; private set; } + + public bool Reset { get; private set; } + public EnumTemporalResetReason ResetReason { get; private set; } + + public float ZNear { get; private set; } + public float ZFar { get; private set; } + public float Fov { get; private set; } + public float DeltaTimeMs { get; private set; } + + public float[] GetProjection(EnumTemporalView view) => projection[(int)view]; + public float[] GetPrevProjection(EnumTemporalView view) => projectionPrev[(int)view]; + public bool IsViewCaptured(EnumTemporalView view) => viewCaptured[(int)view]; + public bool WasViewCaptured(EnumTemporalView view) => viewCapturedPrev[(int)view]; + + /// + /// Marks the history invalid for the next frame. Safe to call several times + /// before the next ; the first non-None reason wins, + /// so the earliest cause is the one reported. + /// + public void RequestReset(EnumTemporalResetReason reason) + { + if (reason == EnumTemporalResetReason.None) return; + if (pendingReset == EnumTemporalResetReason.None) pendingReset = reason; + } + + /// + /// Rotates current-to-previous, increments the frame index, computes this + /// frame's Halton jitter and decides whether the history survives. Called + /// once per frame, immediately after DefaultShaderUniforms.Update and before + /// the Before render stage, so every pass in the frame sees one consistent + /// snapshot. + /// + /// Optimum's render scale (1 = native). The jitter + /// sequence gets more phases the more the image is upscaled. + /// EntityPlayer.CameraPos, differenced in double + /// precision. May be null before a world is loaded. + public void Advance( + float deltaTimeMs, + int renderWidth, + int renderHeight, + float renderScale, + float zNear, + float zFar, + float fov, + Vec3d cameraPosIn, + DefaultShaderUniforms uniforms) + { + // --- rotate current -> previous ------------------------------------- + PrevJitterPx.X = JitterPx.X; + PrevJitterPx.Y = JitterPx.Y; + for (int i = 0; i < ViewCount; i++) + { + Array.Copy(projection[i], projectionPrev[i], 16); + viewCapturedPrev[i] = viewCaptured[i]; + viewCaptured[i] = false; + } + Array.Copy(cameraMatrix, cameraMatrixPrev, 16); + Array.Copy(cameraMatrixOrigin, cameraMatrixOriginPrev, 16); + PrevPlayerpos.Set(Playerpos.X, Playerpos.Y, Playerpos.Z); + PrevWarp = Warp; + + FrameIndex++; + + // --- this frame's constants ----------------------------------------- + DeltaTimeMs = deltaTimeMs; + ZNear = zNear; + ZFar = zFar; + Fov = fov; + Warp = OptimumWarpState.FromUniforms(uniforms); + if (uniforms != null && uniforms.PlayerPos != null) + { + Playerpos.Set(uniforms.PlayerPos.X, uniforms.PlayerPos.Y, uniforms.PlayerPos.Z); + } + + EnumTemporalResetReason reason = pendingReset; + pendingReset = EnumTemporalResetReason.None; + + int prevWidth = RenderWidth; + int prevHeight = RenderHeight; + RenderWidth = Math.Max(1, renderWidth); + RenderHeight = Math.Max(1, renderHeight); + if (prevWidth != 0 && (prevWidth != RenderWidth || prevHeight != RenderHeight) && + reason == EnumTemporalResetReason.None) + { + reason = EnumTemporalResetReason.Resize; + } + + // --- camera position delta, teleport detection ----------------------- + if (cameraPosIn != null) + { + if (hasCameraPos) + { + cameraPosPrev.Set(cameraPos); + double dx = cameraPosIn.X - cameraPosPrev.X; + double dy = cameraPosIn.Y - cameraPosPrev.Y; + double dz = cameraPosIn.Z - cameraPosPrev.Z; + CameraPosDelta.Set((float)dx, (float)dy, (float)dz); + if (dx * dx + dy * dy + dz * dz > TeleportThresholdBlocks * TeleportThresholdBlocks) + { + reason = EnumTemporalResetReason.Teleport; + } + } + else + { + cameraPosPrev.Set(cameraPosIn); + CameraPosDelta.Set(0f, 0f, 0f); + hasCameraPos = true; + } + cameraPos.Set(cameraPosIn); + } + else + { + CameraPosDelta.Set(0f, 0f, 0f); + hasCameraPos = false; + } + + // --- reference-position rebase --------------------------------------- + Vec3d reference = uniforms?.playerReferencePos; + if (reference != null) + { + if (hasReferencePos) + { + if (reference.X != referencePos.X || reference.Y != referencePos.Y || reference.Z != referencePos.Z) + { + if (reason == EnumTemporalResetReason.None) reason = EnumTemporalResetReason.Rebase; + } + } + else hasReferencePos = true; + referencePos.Set(reference); + } + else hasReferencePos = false; + + ResetReason = reason; + Reset = reason != EnumTemporalResetReason.None; + if (Reset) CameraPosDelta.Set(0f, 0f, 0f); + + // --- jitter ---------------------------------------------------------- + int phaseCount = Math.Max(1, OptimumTemporalMath.JitterPhaseCount(renderScale > 0f ? 1f / renderScale : 1f)); + int phase = (int)(FrameIndex % phaseCount); + double jx = OptimumTemporalMath.Halton(phase + 1, 2) - 0.5; + double jy = OptimumTemporalMath.Halton(phase + 1, 3) - 0.5; + // Halton(2,3) never lands on (0.5, 0.5), but a zero offset would make a + // frame contribute no new sub-pixel sample at all, so it is excluded by + // construction rather than by luck. + if (jx == 0.0 && jy == 0.0) jx = 0.25; + JitterSequencePx.X = (float)jx; + JitterSequencePx.Y = (float)jy; + JitterPx.X = jitterActive ? JitterSequencePx.X : 0f; + JitterPx.Y = jitterActive ? JitterSequencePx.Y : 0f; + } + + /// + /// Records the unjittered projection matrix a Set3DProjection call just + /// loaded, for the view it belongs to. + /// + public void RecordProjection(EnumTemporalView view, double[] matrix) + { + if (matrix == null) return; + float[] dest = projection[(int)view]; + for (int i = 0; i < 16; i++) dest[i] = (float)matrix[i]; + viewCaptured[(int)view] = true; + } + + /// + /// Freezes the camera matrices for the frame: the entity view (camera at the + /// player) and the terrain view (camera at the chunk-relative origin). + /// + public void CaptureCamera(double[] cameraMatrixIn, double[] cameraMatrixOriginIn) + { + if (cameraMatrixIn != null) + { + for (int i = 0; i < 16; i++) cameraMatrix[i] = (float)cameraMatrixIn[i]; + } + if (cameraMatrixOriginIn != null) + { + for (int i = 0; i < 16; i++) cameraMatrixOrigin[i] = (float)cameraMatrixOriginIn[i]; + } + } + + /// + /// Copies a projection matrix into this frame's scratch array and shears it + /// by the current jitter. The shear matches Mat4d.Perspective's convention + /// (clip.w = -z_view), so a static point moves by exactly JitterPx pixels. + /// + public float[] ApplyJitterCopy(double[] matrix) + { + for (int i = 0; i < 16; i++) jitteredScratch[i] = (float)matrix[i]; + jitteredScratch[8] -= (float)(2.0 * JitterPx.X / RenderWidth); + jitteredScratch[9] -= (float)(2.0 * JitterPx.Y / RenderHeight); + return jitteredScratch; + } + } + + /// + /// The process-wide holder of the temporal frame contract. Static because the + /// producers are scattered across the render loop, the platform layer and the + /// mod forks, and none of them share an object that already reaches all of them. + /// Render-thread only. + /// + public static class OptimumTemporal + { + public static readonly OptimumTemporalFrame Frame = new OptimumTemporalFrame(); + + /// Read-only access for consumers that must not mutate the frame. + public static IOptimumTemporalContext Context => Frame; + + public static void RequestReset(EnumTemporalResetReason reason) => Frame.RequestReset(reason); + } +} diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index 5a4a480a..da9e3009 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -403,6 +403,40 @@ public static class OptimumConfig /// public static float RenderScale = 1.0f; + /// + /// Temporal anti-aliasing. Off by default: with Taa false the render chain + /// must stay byte-identical to the pre-TAA one, so nothing here may change a + /// matrix, a target or a shader define unless it is on. + /// + public static bool Taa = false; + + /// + /// Post-resolve sharpening strength, 0 (none) to 1. TAA trades sharpness for + /// stability; the sharpen pass buys some of it back. Kept separate from the + /// FSR1 RCAS strength so the two are never applied at full force together. + /// + public static float TaaSharpness = 0.2f; + + /// + /// LOD bias applied to sampled textures while TAA is on. Jitter gives the + /// resolve sub-pixel samples, so mip selection can afford to be sharper than + /// the unjittered frame would allow. Negative sharpens. + /// + public static float TaaMipBias = -0.5f; + + /// + /// Debug visualisation of the temporal pipeline: 0 off, higher values select + /// motion, reactive, validity and rejection views. + /// + public static int TaaDebugView = 0; + + /// + /// Applies the jitter window without running a resolve. A developer switch for + /// isolating "is the shear correct" from "is the resolve correct": with it on, + /// a static scene must visibly shimmer by exactly one pixel. Not in the GUI. + /// + public static bool TaaJitterDev = false; + /// /// Which renderer the client runs: "opengl", "vulkan", or "auto". /// @@ -464,6 +498,9 @@ public static class OptimumConfig public static bool EffectiveGodRaysSampleCap => GodRaysSampleCapEnabled && !IsShaderFeatureDisabled("GodRaysSampleCap"); + public static bool EffectiveTaa => Taa && + !IsShaderFeatureDisabled("Taa"); + public static bool EffectiveEntityLightBatch => EntityLightBatchEnabled && !IsShaderFeatureDisabled("EntityLightBatch"); @@ -677,6 +714,11 @@ public static int ResolveWorldgenWorkerCount( (nameof(OptimumConfigData.RenderScale), RenderScale.ToString("F2")), (nameof(OptimumConfigData.Renderer), Renderer), (nameof(OptimumConfigData.GodRaysSampleCap), GodRaysSampleCapEnabled.ToString()), + (nameof(OptimumConfigData.Taa), Taa.ToString()), + (nameof(OptimumConfigData.TaaSharpness), TaaSharpness.ToString("F2")), + (nameof(OptimumConfigData.TaaMipBias), TaaMipBias.ToString("F2")), + (nameof(OptimumConfigData.TaaDebugView), TaaDebugView.ToString()), + (nameof(OptimumConfigData.TaaJitterDev), TaaJitterDev.ToString()), (nameof(OptimumConfigData.MapPageCache), MapPageCacheEnabled.ToString()), (nameof(OptimumConfigData.MapPageCacheMaxLayers), MapPageCacheMaxLayers.ToString()), (nameof(OptimumConfigData.MapPageCacheBc7), MapPageCacheBc7.ToString()), @@ -775,6 +817,11 @@ public static void Load() string.Equals(requestedRenderer, "auto", StringComparison.OrdinalIgnoreCase) ? "auto" : "opengl"; GodRaysSampleCapEnabled = data.GodRaysSampleCap; + Taa = data.Taa; + TaaSharpness = Math.Clamp(data.TaaSharpness, 0f, 1f); + TaaMipBias = Math.Clamp(data.TaaMipBias, -2f, 1f); + TaaDebugView = Math.Max(0, data.TaaDebugView); + TaaJitterDev = data.TaaJitterDev; MapPageCacheEnabled = data.MapPageCache; MapPageCacheMaxLayers = Math.Clamp(data.MapPageCacheMaxLayers, 16, 512); MapPageCacheBc7 = data.MapPageCacheBc7; @@ -844,6 +891,11 @@ public static void Save() RenderScale = RenderScale, Renderer = Renderer, GodRaysSampleCap = GodRaysSampleCapEnabled, + Taa = Taa, + TaaSharpness = TaaSharpness, + TaaMipBias = TaaMipBias, + TaaDebugView = TaaDebugView, + TaaJitterDev = TaaJitterDev, MapPageCache = MapPageCacheEnabled, MapPageCacheMaxLayers = MapPageCacheMaxLayers, MapPageCacheBc7 = MapPageCacheBc7, @@ -921,6 +973,11 @@ internal sealed class OptimumConfigData public float RenderScale { get; set; } = 1.0f; public string Renderer { get; set; } = "opengl"; public bool GodRaysSampleCap { get; set; } = false; + public bool Taa { get; set; } = false; + public float TaaSharpness { get; set; } = 0.2f; + public float TaaMipBias { get; set; } = -0.5f; + public int TaaDebugView { get; set; } = 0; + public bool TaaJitterDev { get; set; } = false; public bool MapPageCache { get; set; } = true; public int MapPageCacheMaxLayers { get; set; } = 128; public bool MapPageCacheBc7 { get; set; } = true; diff --git a/sources/VintagestoryApi/VintagestoryAPI.csproj b/sources/VintagestoryApi/VintagestoryAPI.csproj index 6aa37510..e25adced 100644 --- a/sources/VintagestoryApi/VintagestoryAPI.csproj +++ b/sources/VintagestoryApi/VintagestoryAPI.csproj @@ -52,6 +52,7 @@ + diff --git a/sources/shaders/taa-debug.fsh b/sources/shaders/taa-debug.fsh new file mode 100644 index 00000000..a07ea3d6 --- /dev/null +++ b/sources/shaders/taa-debug.fsh @@ -0,0 +1,69 @@ +#version 330 core +// Optimum TAA debug views (P1). Reads the Primary motion attachment, depth +// buffer and resolved/raw scene colour and visualises them for the developer +// debug switch OptimumConfig.TaaDebugView. Does not affect the normal blit +// path; only reached when a debug mode is selected and the motion +// attachment exists. + +uniform sampler2D motionTex; +uniform sampler2D depthTex; +uniform sampler2D sceneTex; +uniform int mode; +uniform vec2 renderSize; + +in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +void main(void) +{ + ivec2 pixel = ivec2(clamp(texCoord * renderSize, vec2(0.0), renderSize - vec2(1.0))); + + // Motion attachment: rg = mv (render-resolution px, jitter excluded), + // b = reactive, a = writerDepth (NDC depth at write time, 0 when unwritten). + vec4 motion = texelFetch(motionTex, pixel, 0); + float sceneDepth = texelFetch(depthTex, pixel, 0).r; + + if (mode == 1) + { + // Motion as colour: map +/-16px to the full 0..1 range per channel. + vec2 mapped = clamp(motion.rg / 16.0, vec2(-1.0), vec2(1.0)) * 0.5 + 0.5; + outColor = vec4(mapped, 0.0, 1.0); + } + else if (mode == 2) + { + // Reactive mask (b channel) as greyscale. + outColor = vec4(vec3(motion.b), 1.0); + } + else if (mode == 3) + { + // Validity: green where the writer's recorded depth still matches the + // final depth buffer, red where it does not (occluded/overwritten, + // falls back to camera-motion reprojection), black where nothing wrote + // motion for this pixel at all. + if (motion.a == 0.0) + { + outColor = vec4(0.0, 0.0, 0.0, 1.0); + } + else if (abs(motion.a - sceneDepth) < 1e-4) + { + outColor = vec4(0.0, 1.0, 0.0, 1.0); + } + else + { + outColor = vec4(1.0, 0.0, 0.0, 1.0); + } + } + else if (mode == 4) + { + // Scene colour with a motion-vector colour overlay blended on top. + vec3 scene = texture(sceneTex, texCoord).rgb; + vec2 mapped = clamp(motion.rg / 16.0, vec2(-1.0), vec2(1.0)) * 0.5 + 0.5; + vec3 overlay = vec3(mapped, 0.0); + outColor = vec4(mix(scene, overlay, 0.5), 1.0); + } + else + { + outColor = texture(sceneTex, texCoord); + } +} diff --git a/sources/shaders/taa-debug.vsh b/sources/shaders/taa-debug.vsh new file mode 100644 index 00000000..252d666d --- /dev/null +++ b/sources/shaders/taa-debug.vsh @@ -0,0 +1,11 @@ +#version 330 core + +out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); +} From 0cb6f0b27813fc638b64eda6b43650ebee25eef5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:19:26 +0200 Subject: [PATCH 012/226] chore: drop redundant VintagestoryAPI.csproj patch (baseline carries the exclusion) --- .../VintagestoryApi/VintagestoryAPI.csproj.patch | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 patches/VintagestoryApi/VintagestoryAPI.csproj.patch diff --git a/patches/VintagestoryApi/VintagestoryAPI.csproj.patch b/patches/VintagestoryApi/VintagestoryAPI.csproj.patch deleted file mode 100644 index f89ebdd0..00000000 --- a/patches/VintagestoryApi/VintagestoryAPI.csproj.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/VintagestoryApi/VintagestoryAPI.csproj b/VintagestoryApi/VintagestoryAPI.csproj -index e25adce..6aa3751 100644 ---- a/VintagestoryApi/VintagestoryAPI.csproj -+++ b/VintagestoryApi/VintagestoryAPI.csproj -@@ -50,11 +50,10 @@ - - - - - -- - - - - - From c830fb1059e942f75296df2519c09d528fcbf84e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:31:40 +0200 Subject: [PATCH 013/226] fix(vulkan): match OpenGL post-processing and sampler state Restore framebuffer filtering, shadow comparison, and terrain mip sampling including texture mip limits. Preserve indexed OIT blend factors across cloud blend-enable toggles. Cover sampler output and blend-state preservation with regressions. --- Optimum.Patcher/Program.cs | 1 + .../GlStateTrackerTests.cs | 21 +++ .../TextureManagerTests.cs | 2 +- .../VulkanDeviceIntegrationTests.cs | 88 +++++++++++ Optimum.Render.Vulkan/Core/GlStateTracker.cs | 8 + Optimum.Render.Vulkan/Core/TextureManager.cs | 2 +- Optimum.Render.Vulkan/VulkanDevice.cs | 29 ++-- .../vulkan-backend-integration-tests.cs | 1 + .../Newclouds/CloudRendererMap.cs.patch | 6 +- .../CloudRendererVolumetric.cs.patch | 6 +- .../ClientPlatformWindows.cs.patch | 142 +++++++++++------- .../Client/optimum-render-device.cs | 4 + 12 files changed, 239 insertions(+), 71 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index e827d8e7..b4bc3ff5 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -120,6 +120,7 @@ // Vulkan backend: the device-path framebuffer setup and its helpers. "SetupOptimumFrameBuffers", "CreateOptimumColorTarget", + "SetupOptimumTextureSampler", "CreateOptimumDepthTarget", "CreateOptimumPlaceholderTarget", "CreateOptimumFramebuffer", diff --git a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs index 01494dd2..e59cc9b9 100644 --- a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs +++ b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs @@ -100,6 +100,27 @@ public void PerAttachmentBlendLeavesOtherAttachmentsAlone() Assert.Equal(BlendFactor.OneMinusSrcAlpha, tracker.BlendFor(0).DstColor); } + [Fact] + public void BlendEnableTogglePreservesOitFactorsAndEquations() + { + var tracker = new GlStateTracker(); + tracker.SetBlend(true, EnumBlendMode.Standard); + tracker.SetAttachmentBlendFunc(0, 774, 0, 774, 0); + tracker.SetAttachmentBlendFunc(3, 1, 1, 1, 1); + tracker.SetAttachmentBlendEquation(3, 32779); // GL_FUNC_REVERSE_SUBTRACT + var reveal = tracker.BlendFor(0); + var accumulation = tracker.BlendFor(3); + int enabledId = tracker.BlendId(6); + tracker.SetBlendEnabled(false); + Assert.False(tracker.BlendFor(0).Enabled); + Assert.False(tracker.BlendFor(3).Enabled); + Assert.NotEqual(enabledId, tracker.BlendId(6)); + tracker.SetBlendEnabled(true); + Assert.Equal(reveal, tracker.BlendFor(0)); + Assert.Equal(accumulation, tracker.BlendFor(3)); + Assert.Equal(enabledId, tracker.BlendId(6)); + } + // -------------------------------------------------------------- interning [Fact] diff --git a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs index 0f3c990a..f5a6ebb2 100644 --- a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs @@ -142,7 +142,7 @@ public void OnlyAMipmappingFilterLetsTheSamplerLeaveLevelZero() SamplerState mipmapped = textures.Get(id)!.State; Assert.True(mipmapped.Mipmapped); Assert.Equal(3, mipmapped.MaxLevel); - Assert.Equal(4f, mipmapped.LodCeiling); + Assert.Equal(3f, mipmapped.LodCeiling); // Uncapped stays uncapped. textures.SetParameter(id, GlEnums.TextureMaxLevel, -1); diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index 0b09b16d..93c16e56 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -38,6 +38,94 @@ private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? return false; } + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public unsafe void TerrainSamplerUsesNearestTexelsAndBlendsMipLevels(bool linear) + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int program = LinkProgram(seam, """ + #version 330 core + void main() { + gl_Position = vec4(-1 + ((gl_VertexID & 1) << 2), + -1 + ((gl_VertexID & 2) << 1), 0, 1); + } + """, """ + #version 330 core + uniform sampler2D source; + out vec4 color; + void main() { + float lod = gl_FragCoord.x < 1.0 ? 1.0 : 1.5; + color = textureLod(source, vec2(0.625, 0.25), lod); + } + """); + int source = seam.CreateTexture2D(4, 4, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, true); + // Base level red; mip 1 alternates green/blue, mip 2 is white. + // Sampling base level, filtering within mip 1, or rounding the LOD + // produces a different colour from the GL_NEAREST_MIPMAP_LINEAR result. + byte[] basePixels = new byte[64]; + for (int i = 0; i < basePixels.Length; i += 4) + { + basePixels[i] = 255; + basePixels[i + 3] = 255; + } + byte[] mip1 = { 0,255,0,255, 0,0,255,255, 0,255,0,255, 0,0,255,255 }; + byte[] mip2 = { 255,255,255,255 }; + fixed (byte* data = basePixels) + seam.UploadTexture2D(source, 0, 0, 0, 4, 4, EnumTexturePixelFormat.Rgba, (IntPtr)data); + fixed (byte* data = mip1) + seam.UploadTexture2D(source, 1, 0, 0, 2, 2, EnumTexturePixelFormat.Rgba, (IntPtr)data); + fixed (byte* data = mip2) + seam.UploadTexture2D(source, 2, 0, 0, 1, 1, EnumTexturePixelFormat.Rgba, (IntPtr)data); + int target = seam.CreateTexture2D(2, 1, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(2, 1); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 1); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, 2, 1); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, source); + seam.BindSampler(0, seam.CreateSampler(linear)); + seam.DrawFullscreenTriangle(); + seam.Present(); + byte[] pixels = new byte[8]; + fixed (byte* data = pixels) + seam.ReadDefaultFramebuffer(0, 0, 2, 1, (IntPtr)data); + Assert.Equal(new byte[] { 0, 0, 255, 255 }, pixels[..4]); + Assert.InRange(pixels[4], 127, 128); + Assert.InRange(pixels[5], 127, 128); + Assert.Equal(255, pixels[6]); + Assert.Equal(255, pixels[7]); + + // GL_TEXTURE_MAX_LEVEL is a texture property, not sampler state. + // It must still clamp an override, and must not blend in level 2. + seam.SetTextureParameter(source, OptimumGlConstants.TextureMaxLevel, 1); + seam.SetTextureParameter(source, OptimumGlConstants.TextureMinFilter, 0x2702); + for (int pass = 0; pass < 2; pass++) + { + if (pass == 1) seam.BindSampler(0, 0); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.DrawFullscreenTriangle(); + seam.Present(); + fixed (byte* data = pixels) + seam.ReadDefaultFramebuffer(0, 0, 2, 1, (IntPtr)data); + Assert.Equal(new byte[] { 0, 0, 255, 255, 0, 0, 255, 255 }, pixels); + } + AssertClean(seam); + } + } + [SkippableTheory] [InlineData(EnumDrawMode.Lines)] [InlineData(EnumDrawMode.LineStrip)] diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs index 2a259a76..3245e5df 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -349,6 +349,14 @@ public void SetBlend(bool enabled, EnumBlendMode mode) _cachedBlendCount = -1; } + /// glEnable/glDisable(GL_BLEND) preserve the indexed blend functions. + public void SetBlendEnabled(bool enabled) + { + for (int i = 0; i < _blend.Length; i++) _blend[i].Enabled = enabled; + _cachedBlendId = -1; + _cachedBlendCount = -1; + } + /// /// Per-attachment blend, which the OIT and SSAO passes use through /// glBlendFunci and glBlendEquationi. diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index a7d9a418..b5b2e298 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -50,7 +50,7 @@ internal readonly record struct SamplerState( /// which is what a non-mipmapping GL filter means. /// public float LodCeiling => !Mipmapped ? 0.25f - : MaxLevel >= 0 ? MaxLevel + 1f + : MaxLevel >= 0 ? MaxLevel : Vk.LodClampNone; } diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 7a7bd4c9..1e04708f 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -77,7 +77,7 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice /// Texture bound to each unit, and any sampler overriding the texture's own state. private readonly int[] _boundTextures = new int[GlStateTracker.MaxTextureUnits]; - private readonly Sampler[] _unitSamplerOverrides = new Sampler[GlStateTracker.MaxTextureUnits]; + private readonly int[] _unitSamplerOverrides = new int[GlStateTracker.MaxTextureUnits]; // Atlas composition reads one tile while writing another in the same image. // Reuse a snapshot image, but refresh its contents before each such draw. @@ -800,6 +800,8 @@ public void SetVSync(bool enabled) public void SetBlend(bool enabled, EnumBlendMode mode) => _state.SetBlend(enabled, mode); + public void SetBlendEnabled(bool enabled) => _state.SetBlendEnabled(enabled); + public void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) => _state.SetAttachmentBlendFunc(attachment, srcColor, dstColor, srcAlpha, dstAlpha); @@ -1311,6 +1313,9 @@ public void SetTextureParameter(int textureId, int parameterName, int value) => public void SetTextureParameter(int textureId, int parameterName, float value) => _textures.SetParameter(textureId, parameterName, value); + public void SetTextureBorderColor(int textureId, float r, float g, float b, float a) => + _textures.SetBorderColor(textureId, r, g, b, a); + public int GetTextureParameter(int textureId, int parameterName) { VulkanTexture? texture = _textures.Get(textureId); @@ -1347,8 +1352,12 @@ public int CreateSampler(bool linear) _standaloneSamplers[id] = SamplerState.Default with { MagFilter = linear ? Filter.Linear : Filter.Nearest, - MinFilter = linear ? Filter.Linear : Filter.Nearest, - MipmapMode = linear ? SamplerMipmapMode.Linear : SamplerMipmapMode.Nearest, + // GenSampler uses GL_NEAREST_MIPMAP_LINEAR for both variants; + // the flag changes magnification only. Terrain relies on this + // override retaining the atlas mip chain at a distance. + MinFilter = Filter.Nearest, + MipmapMode = SamplerMipmapMode.Linear, + Mipmapped = true, }; return id; } @@ -1366,9 +1375,7 @@ public void BindSampler(int unit, int samplerId) { if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; - _unitSamplerOverrides[unit] = samplerId > 0 && _standaloneSamplers.TryGetValue(samplerId, out SamplerState state) - ? _textures.Samplers.Get(state) - : default; + _unitSamplerOverrides[unit] = _standaloneSamplers.ContainsKey(samplerId) ? samplerId : 0; } public void DeleteSampler(int samplerId) => _standaloneSamplers.Remove(samplerId); @@ -2191,9 +2198,13 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources resource = texture.Id; // A sampler bound to the unit overrides the texture's own // state, which is what glBindSampler means. - sampler = _unitSamplerOverrides[unit].Handle != 0 - ? _unitSamplerOverrides[unit] - : _textures.Samplers.Get(texture.State); + // MAX_LEVEL belongs to the texture, even when a sampler + // overrides its filters. Resolve at draw time so changes + // to either object also affect an already-bound unit. + SamplerState sampling = _standaloneSamplers.TryGetValue(_unitSamplerOverrides[unit], out SamplerState custom) + ? custom with { MaxLevel = texture.State.MaxLevel } + : texture.State; + sampler = _textures.Samplers.Get(sampling); } } diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 2942d964..697ddb5b 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -415,6 +415,7 @@ public void TheFramebufferHelpersAreInjectedMembers() Assert.Contains("\"SetupOptimumFrameBuffers\"", patcher); Assert.Contains("\"CreateOptimumColorTarget\"", patcher); + Assert.Contains("\"SetupOptimumTextureSampler\"", patcher); Assert.Contains("\"CreateOptimumDepthTarget\"", patcher); } diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch index def18840..76417309 100644 --- a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch @@ -1,5 +1,5 @@ diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs -index 4252128..b43c07b 100644 +index 4252128..120824f 100644 --- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs +++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs @@ -219,10 +219,22 @@ namespace FluffyClouds { @@ -79,13 +79,13 @@ index 4252128..b43c07b 100644 + { + optimumDevice.BindFramebuffer(Framebuffer); + optimumDevice.SetViewport(0, 0, CloudTileLength, CloudTileLength); -+ optimumDevice.SetBlend(false, EnumBlendMode.Standard); ++ optimumDevice.SetBlendEnabled(false); + optimumDevice.SetDepthTest(false); + + capi.Render.RenderMesh(quad); + + optimumDevice.SetDepthTest(true); -+ optimumDevice.SetBlend(true, EnumBlendMode.Standard); ++ optimumDevice.SetBlendEnabled(true); + if (optimumSavedTarget != null) + { + optimumDevice.BindFramebuffer(optimumSavedTarget.FboId); diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch index ad6387b6..c059dcfd 100644 --- a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch @@ -1,5 +1,5 @@ diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs -index ce8dd81..18d41f0 100644 +index ce8dd81..8ab1d9c 100644 --- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs +++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs @@ -73,10 +73,26 @@ namespace FluffyClouds { @@ -14,12 +14,12 @@ index ce8dd81..18d41f0 100644 + if (optimumDevice != null) + { + optimumDevice.SetDepthTest(false); -+ optimumDevice.SetBlend(true, EnumBlendMode.Standard); ++ optimumDevice.SetBlendEnabled(true); + + capi.Render.RenderMesh(quad); + ++ // GL leaves blending enabled for the remaining OIT draws. + optimumDevice.SetDepthTest(true); -+ optimumDevice.SetBlend(false, EnumBlendMode.Standard); + program.Stop(); + return; + } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index fca4759b..b6e61d69 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..3289533 100644 +index 6edf0c9..52f76d4 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -644,7 +644,7 @@ index 6edf0c9..3289533 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1562,406 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1562,440 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -690,6 +690,7 @@ index 6edf0c9..3289533 100644 + primary.FboId = device.CreateFramebuffer(width, height); + primary.DepthTextureId = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); ++ SetupOptimumTextureSampler(device, primary.DepthTextureId, 9728, 33071); + device.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + + int primaryAttachments = (SetupSSAO ? 4 : 2); @@ -705,6 +706,15 @@ index 6edf0c9..3289533 100644 + primary.ColorTextureIds[3] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + } ++ // Match the GL Primary filters, including linear G-buffer sampling and ++ // the white border used when SSAO projects a sample off screen. ++ for (int attachment = 0; attachment < primaryAttachments; attachment++) ++ { ++ int textureId = primary.ColorTextureIds[attachment]; ++ SetupOptimumTextureSampler(device, textureId, ++ attachment >= 2 || ssaaLevel > 1f ? 9729 : 9728, attachment >= 2 ? 33069 : 10497); ++ if (attachment >= 2) device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); ++ } + if (taaRequested) + { + // Optimum: TAA motion attachment, appended after the SSAO G-buffer @@ -751,6 +761,7 @@ index 6edf0c9..3289533 100644 + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + for (int attachment = 0; attachment < 3; attachment++) + { ++ SetupOptimumTextureSampler(device, transparent.ColorTextureIds[attachment], 9729, 10497); + device.AttachTexture(transparent.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), + transparent.ColorTextureIds[attachment], 0); @@ -876,6 +887,15 @@ index 6edf0c9..3289533 100644 + ? CreateOptimumDepthTarget(device, shadowSize, shadowSize) + : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); + ++ for (int shadow = 11; shadow <= 12; shadow++) ++ { ++ int textureId = list[shadow].DepthTextureId; ++ if (textureId == 0) continue; ++ SetupOptimumTextureSampler(device, textureId, 9729, 33069); ++ device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); ++ device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, OptimumGlConstants.CompareRefToTexture); ++ } ++ + // The fullscreen quad. The device generates its three vertices in the + // shader and binds nothing, but the field is public enough that other + // code holds it, so it is kept in step with the GL path. @@ -955,6 +975,16 @@ index 6edf0c9..3289533 100644 + return target; + } + ++ /// Mirror the GL framebuffer texture's filtering and edge policy. ++ private void SetupOptimumTextureSampler( ++ Vintagestory.API.Config.IOptimumGraphicsDevice device, int textureId, int filter, int wrap) ++ { ++ device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, filter); ++ device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, filter); ++ device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapS, wrap); ++ device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, wrap); ++ } ++ + private FrameBufferRef CreateOptimumColorTarget( + Vintagestory.API.Config.IOptimumGraphicsDevice device, int width, int height, + EnumTextureInternalFormat format) @@ -966,6 +996,9 @@ index 6edf0c9..3289533 100644 + target.ColorTextureIds = new int[1]; + target.ColorTextureIds[0] = device.CreateTexture2D(width, height, format, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); ++ // setupAttachment uses linear filtering and edge clamping. FXAA and ++ // the reduced-resolution blur passes require fractional texel samples. ++ SetupOptimumTextureSampler(device, target.ColorTextureIds[0], 9729, 33071); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + device.SetDrawBuffers(target.FboId, 1); + return target; @@ -982,6 +1015,7 @@ index 6edf0c9..3289533 100644 + target.ColorTextureIds = new int[0]; + target.DepthTextureId = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); ++ SetupOptimumTextureSampler(device, target.DepthTextureId, 9729, 33071); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0); + device.SetDrawBuffers(target.FboId, 0); + return target; @@ -1051,7 +1085,7 @@ index 6edf0c9..3289533 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +1993,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2027,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1066,7 +1100,7 @@ index 6edf0c9..3289533 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2020,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2054,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1083,7 +1117,7 @@ index 6edf0c9..3289533 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2065,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2099,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1122,7 +1156,7 @@ index 6edf0c9..3289533 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2278,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2312,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1175,7 +1209,7 @@ index 6edf0c9..3289533 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1567,12 +2451,83 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2485,83 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1259,7 +1293,7 @@ index 6edf0c9..3289533 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2546,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2580,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1288,7 +1322,7 @@ index 6edf0c9..3289533 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2580,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,21 +2614,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1367,7 +1401,7 @@ index 6edf0c9..3289533 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1636,10 +2665,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1636,10 +2699,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (RenderSSAO) { GL.ClearBuffer((ClearBuffer)6144, 2, new float[4] { 0f, 0f, 0f, 1f }); @@ -1382,7 +1416,7 @@ index 6edf0c9..3289533 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2703,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2737,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1423,7 +1457,7 @@ index 6edf0c9..3289533 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2746,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2780,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1472,7 +1506,7 @@ index 6edf0c9..3289533 100644 - GL.Disable((EnableCap)3042); + if (optimumDevice != null) + { -+ optimumDevice.SetBlend(false, EnumBlendMode.Standard); ++ optimumDevice.SetBlendEnabled(false); + } + else + { @@ -1490,7 +1524,7 @@ index 6edf0c9..3289533 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2816,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2850,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1512,7 +1546,7 @@ index 6edf0c9..3289533 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2840,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2874,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1539,7 +1573,7 @@ index 6edf0c9..3289533 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,15 +2865,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,15 +2899,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1578,7 +1612,7 @@ index 6edf0c9..3289533 100644 transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; -@@ -1823,10 +2914,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,10 +2948,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1594,7 +1628,7 @@ index 6edf0c9..3289533 100644 if (RenderBloom) { GlToggleBlend(on: false); -@@ -1848,45 +2944,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +2978,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1654,7 +1688,7 @@ index 6edf0c9..3289533 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,11 +3021,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,11 +3055,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1667,7 +1701,7 @@ index 6edf0c9..3289533 100644 { LoadFrameBuffer(EnumFrameBuffer.Luma); ShaderProgramLuma luma = ShaderPrograms.Luma; -@@ -1935,11 +3041,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1935,11 +3075,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract blit.Use(); blit.Scene2D = frameBuffers[0].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); @@ -1678,7 +1712,7 @@ index 6edf0c9..3289533 100644 + { + // Re-enabling blend only; the mode is whatever the last GlToggleBlend + // left, which is what glEnable(GL_BLEND) does here too. -+ optimumPostDevice.SetBlend(true, EnumBlendMode.Standard); ++ optimumPostDevice.SetBlendEnabled(true); + } + else + { @@ -1689,7 +1723,7 @@ index 6edf0c9..3289533 100644 } public override void RenderFinalComposition() -@@ -1953,13 +3068,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,13 +3102,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1718,7 +1752,7 @@ index 6edf0c9..3289533 100644 final.Use(); final.PrimaryScene2D = primaryScene2D; final.BloomParts2D = bloomParts2D; -@@ -1987,23 +3115,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3149,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1759,7 +1793,7 @@ index 6edf0c9..3289533 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +3166,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,19 +3200,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -1880,7 +1914,7 @@ index 6edf0c9..3289533 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3326,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3360,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -1906,7 +1940,7 @@ index 6edf0c9..3289533 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3359,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3393,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -1928,7 +1962,7 @@ index 6edf0c9..3289533 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3388,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3422,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2026,7 +2060,7 @@ index 6edf0c9..3289533 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3487,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,36 +3521,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2115,7 +2149,7 @@ index 6edf0c9..3289533 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3603,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2233,33 +3637,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2187,7 +2221,7 @@ index 6edf0c9..3289533 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3681,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +3715,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2354,7 +2388,7 @@ index 6edf0c9..3289533 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +3851,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +3885,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2378,7 +2412,7 @@ index 6edf0c9..3289533 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +3880,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +3914,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2416,7 +2450,7 @@ index 6edf0c9..3289533 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +3940,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +3974,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2459,7 +2493,7 @@ index 6edf0c9..3289533 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +3993,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4027,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2518,7 +2552,7 @@ index 6edf0c9..3289533 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4108,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4142,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2592,7 +2626,7 @@ index 6edf0c9..3289533 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4206,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4240,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2623,7 +2657,7 @@ index 6edf0c9..3289533 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4243,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4277,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2658,7 +2692,7 @@ index 6edf0c9..3289533 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4290,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4324,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2687,7 +2721,7 @@ index 6edf0c9..3289533 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4321,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4355,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2711,7 +2745,7 @@ index 6edf0c9..3289533 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4358,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4392,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -2728,7 +2762,7 @@ index 6edf0c9..3289533 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4410,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4444,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2773,7 +2807,7 @@ index 6edf0c9..3289533 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4447,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4481,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2794,7 +2828,7 @@ index 6edf0c9..3289533 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4466,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4500,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2815,7 +2849,7 @@ index 6edf0c9..3289533 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4485,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4519,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2836,7 +2870,7 @@ index 6edf0c9..3289533 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4504,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4538,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2857,7 +2891,7 @@ index 6edf0c9..3289533 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4527,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4561,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2878,7 +2912,7 @@ index 6edf0c9..3289533 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4570,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4604,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -2904,7 +2938,7 @@ index 6edf0c9..3289533 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +4812,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +4846,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -2926,7 +2960,7 @@ index 6edf0c9..3289533 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5010,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5044,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -2949,7 +2983,7 @@ index 6edf0c9..3289533 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5084,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5118,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -2998,7 +3032,7 @@ index 6edf0c9..3289533 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5154,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5188,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3030,7 +3064,7 @@ index 6edf0c9..3289533 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5184,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5218,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3056,7 +3090,7 @@ index 6edf0c9..3289533 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5532,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5566,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3086,7 +3120,7 @@ index 6edf0c9..3289533 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5586,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5620,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/sources/VintagestoryApi/Client/optimum-render-device.cs b/sources/VintagestoryApi/Client/optimum-render-device.cs index 1cf30a30..ab303610 100644 --- a/sources/VintagestoryApi/Client/optimum-render-device.cs +++ b/sources/VintagestoryApi/Client/optimum-render-device.cs @@ -113,6 +113,8 @@ public interface IOptimumGraphicsDevice : IDisposable void SetCullFaceMode(bool back); void SetBlend(bool enabled, EnumBlendMode mode); + /// Toggle blending without replacing per-attachment factors or equations. + void SetBlendEnabled(bool enabled); /// Per-attachment blend, as used by the OIT and SSAO passes. void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha); void SetBlendEquation(int attachment, int mode); @@ -255,6 +257,8 @@ void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, /// void SetTextureParameter(int textureId, int parameterName, int value); void SetTextureParameter(int textureId, int parameterName, float value); + /// Texture border colour for clamped G-buffers and shadow maps. + void SetTextureBorderColor(int textureId, float r, float g, float b, float a); int GetTextureParameter(int textureId, int parameterName); void BindTexture(int unit, int textureId); From b0d37f2eecb0ae49244363fc32f065695599a53d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:35:51 +0200 Subject: [PATCH 014/226] docs: CLAUDE.md, dev scripts and skills distilled from the Codex sessions Rules and procedures that the last two days' failures produced: sources of truth per tree, the patch/Cecil/csproj workflow, launch/stop/verify scripts (renderer confirmed from the log, clean window close, no pattern kills), the GL-vs-device parity debugging protocol, the Codex handoff recipe, and the agent model policy. AGENTS.md links to CLAUDE.md so Codex reads the same file. --- .claude/skills/codex-handoff/SKILL.md | 26 +++++++ .claude/skills/patch-workflow/SKILL.md | 30 ++++++++ .claude/skills/run-optimum/SKILL.md | 24 +++++++ .claude/skills/vulkan-parity-debug/SKILL.md | 42 +++++++++++ AGENTS.md | 1 + CLAUDE.md | 80 +++++++++++++++++++++ scripts/dev/client-renderer.sh | 7 ++ scripts/dev/kill-client.sh | 11 +++ scripts/dev/run-client.sh | 24 +++++++ scripts/dev/screenshot.sh | 5 ++ 10 files changed, 250 insertions(+) create mode 100644 .claude/skills/codex-handoff/SKILL.md create mode 100644 .claude/skills/patch-workflow/SKILL.md create mode 100644 .claude/skills/run-optimum/SKILL.md create mode 100644 .claude/skills/vulkan-parity-debug/SKILL.md create mode 120000 AGENTS.md create mode 100644 CLAUDE.md create mode 100755 scripts/dev/client-renderer.sh create mode 100755 scripts/dev/kill-client.sh create mode 100755 scripts/dev/run-client.sh create mode 100755 scripts/dev/screenshot.sh diff --git a/.claude/skills/codex-handoff/SKILL.md b/.claude/skills/codex-handoff/SKILL.md new file mode 100644 index 00000000..9454d2ef --- /dev/null +++ b/.claude/skills/codex-handoff/SKILL.md @@ -0,0 +1,26 @@ +--- +name: codex-handoff +description: Hand a stuck rendering bug or a plan review to the local Codex CLI (gpt-6-astra) with a neutral brief, full machine access, detached launch and a completion monitor; then read its report and transcript. Use when the user says "give it to codex/astra" or after two failed fix attempts. +--- + +# Codex handoff + +Brief = symptom + reproduction + where things live. No theories, no ruled-out lists; that poisons it. + +1. Stop other writers: pause workflows/agents, commit WIP (`wip:` prefix, never stash), clean tree. +2. Write `/codex--brief.md`: repo path and branch; the user's words verbatim; + screenshot paths; how to build (`make deploy`), launch (`scripts/dev/run-client.sh`), stop, switch + renderer, read the renderer log line; diagnostics env vars; the test commands; the patch workflow + rule (edit build/ + fork, run extract, never patches/sources); ask for a report file and a commit + on the branch, no push. +3. Wrapper script (the tool timeout cannot kill it): + ``` + cat brief.md | codex exec -m gpt-6-astra -c model_reasoning_effort="high" \ + --dangerously-bypass-approvals-and-sandbox -i shot1.png -i shot2.png > codex.log 2>&1 + echo "CODEX_EXIT $?" >> codex.log + ``` + `setsid wrapper.sh &` then a Monitor that greps for `CODEX_EXIT`. Effort: `high` for reviews and + rendering bugs (~15-40 min), `low` for small tasks; it is on a weekly quota. +4. When done: read the report, `git log`, and the transcript + `~/.codex/sessions//rollout-*.jsonl` (condense `response_item` messages + + `custom_tool_call` inputs). Verify its claims yourself in-game before relaying them. diff --git a/.claude/skills/patch-workflow/SKILL.md b/.claude/skills/patch-workflow/SKILL.md new file mode 100644 index 00000000..a632d905 --- /dev/null +++ b/.claude/skills/patch-workflow/SKILL.md @@ -0,0 +1,30 @@ +--- +name: patch-workflow +description: How to change game-lib, API-fork, mod-fork and shader code in Optimum so it actually ships - edit the right tree, regenerate patches, list Cecil targets, wire csproj overlays, run the checks. Use before editing anything under build/, VintagestoryApi/, VSEssentials/, VSSurvivalMod/, sources/shaders/. +--- + +# Patch workflow + +1. Edit the source of truth (see CLAUDE.md table): `build/VintagestoryLib/**` for the client lib, + `VintagestoryApi/**` for the API, the mod fork dirs, `sources/shaders/` for shaders. + Never edit `patches/*.patch` or `sources/VintagestoryApi/**`. +2. New API file: add `` to + `optimum-api-contracts/optimum-api-contracts.csproj`, and `` to + `VintagestoryApi/VintagestoryAPI.csproj`, `sources/VintagestoryApi/VintagestoryAPI.csproj` and + `.baseline/VintagestoryApi/VintagestoryAPI.csproj` (mirrors what bootstrap folds in). +3. New seam member on `IOptimumGraphicsDevice`: implement in `Optimum.Render.Vulkan/VulkanDevice.cs`; + the GL path keeps its own body in the lib method's `else` branch. +4. Lib change: every changed or added method/property/field in `ClientMain`, `ClientPlatformWindows`, + `ChunkRenderer`, `ShaderRegistry`, `ShaderProgram*`, `ScreenManager`, ... goes into + `Optimum.Patcher/Program.cs` (transplant tuple `new("Type", "Method", paramCount)`; injected + members in the per-type member lists). The patcher only checks references, not omissions, so + grep your diff for every signature. +5. Mod-fork change: rebuild ships it locally; the installed-runtime path needs the + `Optimum.Patcher/mod-patcher.cs` manifest entry for the type/member. +6. New shader include: `sources/shaderincludes/` + add the copy to `make deploy` and every + `scripts/package-*` script; the Vulkan test corpus (`ShaderCorpus.cs`) must overlay it too. +7. `bash scripts/extract-patches.sh` then `bash scripts/check-patches.sh` (expect 0 conflicts, 0 pending; + a stray `patches/VintagestoryApi/*.csproj.patch` means step 2's baseline line is missing). +8. `dotnet build VintageStory.slnx -c Release`, both test suites, `make deploy`, run the game. +9. If a build of the lib fails on a member missing from the API, the fork and `sources/` have drifted: + diff `VintagestoryApi/` against `sources/VintagestoryApi/` and fix the fork, then extract. diff --git a/.claude/skills/run-optimum/SKILL.md b/.claude/skills/run-optimum/SKILL.md new file mode 100644 index 00000000..ff6623d0 --- /dev/null +++ b/.claude/skills/run-optimum/SKILL.md @@ -0,0 +1,24 @@ +--- +name: run-optimum +description: Build, deploy, launch, stop and screenshot the Optimum Vintage Story client on Vulkan or OpenGL, and confirm from the log which renderer actually started. Use for any "run it", "check in game", "compare backends" request. +--- + +# Run Optimum and verify what is on screen + +1. Deploy: `make deploy` (Cecil patch, copies DLLs, shaders and the Vulkan backend into + `.vanilla/win-x64/vintagestory`). If only the backend changed: `dotnet build Optimum.Render.Vulkan -c Release && cp bin/Release/net10.0/Optimum.Render.Vulkan.dll .vanilla/win-x64/vintagestory/`. +2. Stop any running client first: `scripts/dev/kill-client.sh` (its own call; no launch text in the same command). +3. Launch: `RENDERER=vulkan scripts/dev/run-client.sh "serene cave world"` (or `RENDERER=opengl`). + Diagnostics go in the environment: `OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/t.log`. +4. Wait for the world: poll the log for `Savegame .* loaded` and `Received level finalize` + (about 25 s), never blind-sleep. +5. **Confirm the renderer:** `scripts/dev/client-renderer.sh`. If it says `OpenGL renderer: `, + the Vulkan probe failed; read the reason (stale `Optimum.Render.Vulkan.dll` beside the client is the + classic one) and fix that before judging pixels. +6. Screenshot: `scripts/dev/screenshot.sh /tmp/vulkan.png`, then Read the PNG and describe what you see. + For a backend comparison take both shots from the same save and camera. +7. Stop: `scripts/dev/kill-client.sh`. Restore `ModConfig/optimum.json` `Renderer` to what the user had. + +Gotchas: `ssaa` 0.5 in clientsettings halves the render resolution on both backends; the random +`--rndWorld -p creativebuilding` world is superflat and has no animals; passing `world.vcdbs` to `-o` +creates a new world named `world.vcdbs.vcdbs`. diff --git a/.claude/skills/vulkan-parity-debug/SKILL.md b/.claude/skills/vulkan-parity-debug/SKILL.md new file mode 100644 index 00000000..a8cc128f --- /dev/null +++ b/.claude/skills/vulkan-parity-debug/SKILL.md @@ -0,0 +1,42 @@ +--- +name: vulkan-parity-debug +description: Debug a rendering difference between the OpenGL path and the Vulkan backend (missing post-processing, wrong filtering, transparency, colours). Baseline capture, trace and dump analysis, GL-vs-device state diff, GPU regression test, in-game verification. +--- + +# Vulkan rendering parity debugging + +The Vulkan backend reproduces GL state through `IOptimumGraphicsDevice`; every bug so far was a +state difference between a method's GL branch and its device branch, not shader maths. + +## 1. Baseline before touching code +- `RENDERER=vulkan OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/before.trace scripts/dev/run-client.sh` +- confirm `scripts/dev/client-renderer.sh` says Vulkan; screenshot to `/tmp/vulkan-before.png` +- same scene on `RENDERER=opengl`, screenshot `/tmp/opengl.png`; Read both and write down the differences in words. +- Trace summary (python): map `program N 'name'` lines to ids, count `fullscreen program=` per name, + list `validation:` lines with `[error]`. Passes that never run are one class; passes that run but + produce nothing are the other. +- Dump the intermediates from a live frame: `OPTIMUM_DUMP_TEXTURES= + OPTIMUM_DUMP_DIR=/abs/dir OPTIMUM_DUMP_AFTER_SECONDS=60`; build a contact sheet with PIL and Read it. + Texture ids: `bind unit=U texture=T` lines right before a pass's `fullscreen` line. + +## 2. Diff the two paths, do not theorise +For the pass that is wrong, open the method in `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs` +(or the mod renderer) and read the `if (optimumDevice != null) {...}` branch next to the GL branch, +plus the framebuffer setup pair `SetupOptimumFrameBuffers` / `SetupDefaultFrameBuffers`. Check every +item in this list on both sides: +- texture create: format, mip levels, `TexParameter` min/mag filter, mipmap mode, wrap S/T, border colour, compare mode +- samplers: `GenSampler`/`BindSampler` semantics (the "linear" flag changes magnification only; min is NEAREST_MIPMAP_LINEAR) +- blend: `glEnable(BLEND)` vs `SetBlend(enabled, mode)` (the latter rewrites per-attachment factors; use `SetBlendEnabled` to toggle only), `glBlendFunci` per attachment +- draw buffers: `glDrawBuffers` vs `SetDrawBuffers(fbo, mask)`; an enabled-but-unwritten attachment is undefined +- clears per attachment, depth mask/test/func, cull, viewport for sub-resolution targets, scissor +- attachment indices and texture-id bookkeeping (`FrameBufferRef.ColorTextureIds`) +Write the list of mismatches first; then fix them all, not the first one. + +## 3. Fix, test, verify +- Backend changes in `Optimum.Render.Vulkan/`, seam additions in `VintagestoryApi/Client/optimum-render-device.cs` + (then contracts csproj), lib changes in `build/` + Cecil list (see patch-workflow skill). +- Add a GPU readback test per fix in `Optimum.Render.Vulkan.Tests` (draw with a translated shader, + read the pixel, assert; readbacks must happen inside a frame). +- `make deploy`, run Vulkan with validation, screenshot after; run OpenGL; compare live. Then + `dotnet test Optimum.Render.Vulkan.Tests`, `dotnet test Optimum.Tests -c Release`, `bash scripts/check-patches.sh`. +- Keep evidence (before/after PNGs, logs) in the scratchpad and cite it in the report and commit. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..147c1ac9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# Optimum: working rules for agents + +Optimum is a performance mod for Vintage Story: a patched client (OpenGL path) plus a Vulkan +backend behind the `IOptimumGraphicsDevice` seam. Read this before touching anything. The +skills in `.claude/skills/` hold the step-by-step procedures; this file holds the rules. + +## Where the truth lives (edit these, never the generated copies) + +| What | Edit here | Generated from it | Ships as | +|---|---|---|---| +| Game client code | `build/VintagestoryLib/**` (decompiled + patched) | `patches/VintagestoryLib/*.patch` via `scripts/extract-patches.sh` | Cecil transplant into vanilla DLL; every changed/new method or member MUST be listed in `Optimum.Patcher/Program.cs` | +| Game API | `VintagestoryApi/**` (hand-maintained fork, git-ignored) | `sources/VintagestoryApi/**` via extract | `VintagestoryAPI-patched.dll`; new files also go in `optimum-api-contracts/optimum-api-contracts.csproj` (path `..\sources\VintagestoryApi\...`) and get a `` in both `VintagestoryApi/VintagestoryAPI.csproj` and `sources/VintagestoryApi/VintagestoryAPI.csproj` | +| Mods | `VSEssentials/`, `VSSurvivalMod/`, `VSCreativeMod/` (forks) | `patches//*.patch` via extract | recompiled mod DLLs plus `Optimum.Patcher/mod-patcher.cs` manifests for the installed-runtime path | +| Shaders | `sources/shaders/*.vsh/.fsh` (override vanilla by file name) | shipped by `make deploy` and `scripts/package-*` | includes: `sources/shaderincludes/` (add to deploy and packagers when first used) | +| Vulkan backend | `Optimum.Render.Vulkan/**` | - | `Optimum.Render.Vulkan.dll` + `Silk.NET.*.dll` beside the client (`make deploy` copies them) | +| Vanilla reference | `_ref/**` and `.vanilla/**/assets` | read-only | - | + +Never edit `patches/*.patch` or `sources/VintagestoryApi/**` by hand; extract overwrites them. +`.baseline/` is the decompiled vanilla; csproj overlays are folded into it by bootstrap, so a new +`` must also be added to `.baseline/VintagestoryApi/VintagestoryAPI.csproj` locally +or extract will keep emitting a stray csproj patch. + +## Build, deploy, run, verify + +``` +dotnet build VintageStory.slnx -c Release # everything +dotnet test Optimum.Render.Vulkan.Tests # GPU tests, validation layers on (needs a GPU) +dotnet test Optimum.Tests -c Release # source/patch coverage tests +bash scripts/extract-patches.sh && bash scripts/check-patches.sh # after editing build/, forks, API +make deploy # Cecil patch + copy into .vanilla/win-x64/vintagestory +scripts/dev/run-client.sh ["world name"] # detached launch; RENDERER=vulkan|opengl env switches +scripts/dev/client-renderer.sh # which renderer ACTUALLY started (read this every time) +scripts/dev/screenshot.sh /tmp/x.png # then look at the image with Read +scripts/dev/kill-client.sh # clean close; never pkill -f from a shell that mentions the process +``` + +Data dir: `~/.config/OptimumVintagestoryData` (`clientsettings.json`, `ModConfig/optimum.json` with +`"Renderer"`). Saves: `Saves/*.vcdbs`; pass the bare world name to `-o`, not the file name. +Settings that change what you see: `ssaa` (0.5 renders at half res on BOTH backends), `fxaa`, +`ssaoQuality`, `bloom`, `godRays`, `mipMapLevel`. + +Backend diagnostics: `OPTIMUM_VULKAN_VALIDATION=1`, `OPTIMUM_RENDER_TRACE=` (per-draw +trace: `program N 'name'`, `fullscreen program= tex0= target=`, `bind unit= texture=`, +`validation:` lines), `OPTIMUM_DUMP_TEXTURES= OPTIMUM_DUMP_DIR= OPTIMUM_DUMP_AFTER_SECONDS=60` +(PPM dumps of live textures; without the delay you dump the menu), `OPTIMUM_VULKAN_STATS=`. + +## Rules that came from real failures + +1. **A launch is not a verification.** The bootstrap falls back to OpenGL silently; MangoHud only + shows on Vulkan. Grep the log for `[Optimum] Vulkan renderer` / `[Optimum] OpenGL renderer:` + before saying anything about rendering. A PR was merged on an OpenGL run because this was skipped. +2. **Look at pixels, then diff the two paths.** For any "X looks wrong on Vulkan": capture a baseline + (screenshot + trace + validation log) first, then read the GL branch and the device branch of the + same method side by side and list every state difference (sampler filter/wrap/mip/border/compare, + blend enable vs per-attachment factors, draw-buffer masks, clears, viewports, formats). The bugs + have all been parity gaps, never shader maths. Do not theorise from symptoms. +3. **Verify in the game, both backends, before claiming done.** Deploy, run, screenshot, compare with + OpenGL live. Component tests passing is not evidence for the screen. +4. **Every fix gets a GPU readback test** in `Optimum.Render.Vulkan.Tests` (pattern: + `VulkanDeviceIntegrationTests`, `AttachmentSemanticsTests`) and, for lib/patch changes, a + source-coverage test in `Optimum.Tests` (pattern: `fsr-pipeline-coverage-tests.cs`). +5. **Process hygiene.** Launch through `scripts/dev/*.sh` (setsid wrappers). Never put `pkill -f` or + `pgrep -f` in a command that also contains the process name in a heredoc or string: it matches the + calling shell and the tool dies with exit 144. Close the game with the kill script (window close + first) to avoid shutdown-race crash reports. +6. **Git.** Never `git stash`. Commit WIP on the branch with a `wip:` prefix instead. Branch from + `main` (tracks `origin/main` = NightHammer1000/VulkanStory; `upstream` = StratumServer/Optimum). + Commit only when asked or when a phase is verified; say what was verified in the message. +7. **Batch reads.** Read whole methods and both paths in one command (`sed -n` ranges + `rg`), not + ten single greps. Codex found in one pass what took an afternoon of small probes. +8. **Agents cost money.** The session model is Fable. Only launch subagents with an explicit + cheaper `model` ("sonnet", "haiku") and low effort for mechanical work; Fable does the hard parts + itself. For plan reviews and stuck rendering bugs, hand off to Codex (`.claude/skills/codex-handoff`) + with symptom + repro only, no theories. + +## Testing notes +- `Optimum.Render.Vulkan.Tests` GPU tests must read back inside a frame; `BindFramebuffer`/`ClearColor` + are no-ops between frames. +- Vulkan named UBOs are per-draw snapshots (fixed 2026-09-10); the uniform ring is 32 MiB. +- Shader pairs dropped in `sources/shaders/` are auto-translated by `ShaderTranslationTests`. diff --git a/scripts/dev/client-renderer.sh b/scripts/dev/client-renderer.sh new file mode 100755 index 00000000..af056741 --- /dev/null +++ b/scripts/dev/client-renderer.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Prints which renderer the running/last client actually selected. A launch is +# not a verification: the bootstrap falls back to OpenGL silently. +LOG="${1:-/tmp/optimum-client.log}" +grep -m1 -E "\[Optimum\] (Vulkan|OpenGL) renderer" "$LOG" || echo "no renderer line yet in $LOG" +grep -m1 "Graphics Card Renderer" "$LOG" +grep -m1 "Savegame .* loaded" "$LOG" diff --git a/scripts/dev/kill-client.sh b/scripts/dev/kill-client.sh new file mode 100755 index 00000000..4632ae9a --- /dev/null +++ b/scripts/dev/kill-client.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Close the client. Prefers a clean window close (no shutdown race), falls back to +# SIGTERM by PID. Never pattern-kill from a shell that also contains the launch +# text: the pattern matches the calling shell and kills it (exit 144). +if command -v xdotool >/dev/null; then + for w in $(xdotool search --name '^Vintage Story$' 2>/dev/null); do xdotool windowactivate --sync "$w" 2>/dev/null; xdotool key --window "$w" alt+F4; done + sleep 3 +fi +for p in $(ps -eo pid,cmd | grep "dotnet [V]intagestory.dll" | awk '{print $1}'); do kill "$p" 2>/dev/null; done +sleep 1 +ps -eo pid,cmd | grep -c "dotnet [V]intagestory.dll" | sed 's/^/remaining: /' diff --git a/scripts/dev/run-client.sh b/scripts/dev/run-client.sh new file mode 100755 index 00000000..94d81f85 --- /dev/null +++ b/scripts/dev/run-client.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Launch the deployed client (after `make deploy`) into a save, detached from the +# calling shell so tool timeouts cannot kill it. Usage: +# scripts/dev/run-client.sh [world-name] [extra env...] +# Env: RENDERER=vulkan|opengl (rewrites ModConfig/optimum.json), DATA_PATH, +# OPTIMUM_VULKAN_VALIDATION=1, OPTIMUM_RENDER_TRACE=, +# OPTIMUM_DUMP_TEXTURES= OPTIMUM_DUMP_DIR= OPTIMUM_DUMP_AFTER_SECONDS= +# Verify the renderer from the log afterwards: scripts/dev/client-renderer.sh +set -u +WORLD="${1:-serene cave world}" +DATA_PATH="${DATA_PATH:-$HOME/.config/OptimumVintagestoryData}" +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +CLIENT="$REPO/.vanilla/win-x64/vintagestory" +LOG="${CLIENT_LOG:-/tmp/optimum-client.log}" +if [[ -n "${RENDERER:-}" ]]; then + python3 - "$DATA_PATH/ModConfig/optimum.json" "$RENDERER" <<'PY' +import json,sys +p,r=sys.argv[1],sys.argv[2]; d=json.load(open(p)); d['Renderer']=r; json.dump(d,open(p,'w'),indent=2) +PY +fi +cd "$CLIENT" || exit 1 +setsid prime-run dotnet Vintagestory.dll --dataPath "$DATA_PATH" -o "$WORLD" > "$LOG" 2>&1 < /dev/null & +disown +echo "launched; log: $LOG" diff --git a/scripts/dev/screenshot.sh b/scripts/dev/screenshot.sh new file mode 100755 index 00000000..2bee6561 --- /dev/null +++ b/scripts/dev/screenshot.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Screenshot the active window (the game) to a file, then look at it with the Read tool. +OUT="${1:-/tmp/optimum-shot.png}" +if command -v spectacle >/dev/null; then spectacle -b -n -a -o "$OUT"; else import -window "$(xdotool getactivewindow)" "$OUT"; fi +echo "$OUT" From cea367fb2a6af3171fb80d999b2518f5a21a3690 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:38:05 +0200 Subject: [PATCH 015/226] docs: add the TAA plan (reviewed by Codex, P0 done) --- TAA-PLAN.md | 315 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 TAA-PLAN.md diff --git a/TAA-PLAN.md b/TAA-PLAN.md new file mode 100644 index 00000000..91bd72d6 --- /dev/null +++ b/TAA-PLAN.md @@ -0,0 +1,315 @@ +# TAA for Optimum: plan (revised after Codex review, 2026-09-10) + +## Context + +Optimum renders on OpenGL and on the new Vulkan backend behind the `IOptimumGraphicsDevice` +seam. The roadmap is TAA first, then FSR/XeSS/DLSS upscaling, then frame generation, and possibly +path tracing with ray reconstruction. Target hardware includes an Arc 140V handheld. Today the game +has FXAA plus a spatial FSR 1 render-scale path (EASU + RCAS). + +Every temporal upscaler, frame generator and neural denoiser consumes an overlapping set of +per-frame data: jittered colour, depth, motion vectors, jitter offsets, camera constants, exposure, +reactive/transparency masks, a reset flag, and (denoisers) material guides. Their exact resource +requirements, resolutions and colour stages differ, so this plan builds one engine-owned superset +("temporal frame contract") with explicit adapters per consumer, and treats the resolve as a +replaceable stage. The in-house GLSL resolve is the first implementation and the permanent fallback +on OpenGL. + +The plan was reviewed by Codex (gpt-6-astra, high effort) against `build/`, `_ref/`, the mod forks +and the Vulkan backend; its review is at +`/tmp/claude-1000/-home-n1ght-Projekte-Optimum/73ffc57c-d773-4311-8bb2-b42cbc432943/scratchpad/codex-taa-review.md`. +Its major corrections were verified against the code and are folded in below. + +## Decisions (agreed with the user) + +1. **Placement**: the resolve runs after all scene geometry (including the AfterOIT decals/terrain + overlay pass) and after SSAO, and before bloom, god rays, Final composition and the HUD. Verified + against AMD's FSR "Placement in the frame" (SSAO/SSR/denoisers before, bloom/DOF/tonemap/grain + after) and Unreal's temporal-upscaler position. Bloom, god rays and Final read *resolved* colour, + glow and SSAO, so the resolve outputs those three signals, not only colour. +2. **Milestone = everything**: chunks, topsoil, liquid, standard-shaded items and block entities, + instanced mechanical renderers, skinned entities, held items and first-person hands write motion + vectors; OIT transparents, particles, clouds/aurora, sky and late overlays have explicit + rejection or mask policies. The plan states per class whether motion is exact or a fallback. +3. **In-house resolve only** in this plan; vendor upscalers, frame generation and ray reconstruction + are separate consumers in follow-up plans, each with its own capability requirements. +4. **Backend-neutral producers**: GLSL 330 through the existing translator and existing seam members + (`SetDrawBuffers`, `SetBlendFuncSeparate(attachment, ...)`, `LoadFrameBuffer(FrameBufferRef, int)`, + UBO members). Backend-native capabilities (device handles, extensions) are reserved for the + vendor-library plan. +5. **Resolver behind an interface** (`ITemporalResolver` taking an immutable per-frame input record: + resources, formats, rects, jitter, camera constants, exposure, reset reason, frame/view ids). + +## Architecture + +``` +ClientMain.MainRenderLoop (ClientMain.cs:1154) + shUniforms.Update (1182) -> [P1] TemporalFrame.Advance(): frame index, jitter N, rotate + cur->prev for every captured view, reset detection + Before stage: LiquidDepth prepass (quarter res, ChunkRenderer.OnRenderBefore) - jittered NDC shear + shadows (own ortho matrices, never jittered) + GlLoadMatrix(CameraMatrix) (1201): [P1] freeze "entity view" (CameraMatrix) and + "terrain view" (CameraMatrixOrigin) for this frame + Opaque stage: chunk (CameraMatrixOrigin), entity, standard, instanced, particle-cube draws with the + JITTERED projection; motion-writing passes enable the Primary motion attachment via the + draw-buffer mask and write (mv.xy px, reactive, writerDepth) + OIT stage into Transparent (liquid, quad particles, OIT entities, clouds) - unchanged outputs + MergeTransparentRenderPass, AfterOIT (decals, terrain pass 7, AfterOIT entities) - motion enabled + [P4] liquid velocity pass: liquid geometry re-drawn into motion attachment only (depth test on, + depth write off) so the water surface's motion and depth win where water is in front +RenderPostprocessingEffects (jittered projection for SSAO, as today) + SSAO (unchanged) -> ssao texture + [P2] taa-resolve: colour*1, glow, ssao, motion, depth, prev-depth, history -> MRT + history colour RGBA16F | aux RGBA8 (glow.rg, ssao.b) | linear depth R32F + camera-motion fallback computed inside the resolve for pixels whose motion is invalid + [P5] optional RCAS (new uniform-driven variant) -> Luma; else Luma aliases the resolved colour + bloom (Findbright) reads resolved colour + resolved glow; god rays read resolved glow +RenderFinalComposition: unchanged maths; primaryScene/glow/ssao inputs rebound to resolved textures +BlitPrimaryToDefault: unchanged (FSR1 EASU/RCAS or blit; RCAS not doubled when TAA sharpen is on) +AfterFinalComposition (work-item guides), Ortho HUD, AfterBlit (rifts): outside the temporal window, + never jittered, never in history +``` + +Temporal window: jitter is applied only while `TemporalFrame.JitterActive` is true, from `Advance()` +until the resolve has run, and only to the perspective matrix last loaded by `Set3DProjection` +(world FOV or hand FOV, each with its own previous). Shadow, ortho, offscreen and late passes are +never jittered. + +## Conventions (fixed, tested by a one-pixel adapter test) + +- Motion vector `mv = previousPixel - currentPixel` (current pixel to where it was), render-resolution + pixels, RG16F, jitter excluded (both positions via unjittered projections), undilated. + History lookup: `historyUV = uv + mv / renderSize`. Adapters: FSR `motionVectorScale=(1,1)` for + pixel input, XeSS pixel mode, DLSS `mvecScale = 1/renderSize`. +- Jitter `j` (px) is the raster displacement of a static point. With `Mat4d.Perspective` + (`clip.w = -z_view`, `Mat4d.cs:923-944`) the shear is `P[8] -= 2*jx/W; P[9] -= 2*jy/H` in + column-major float[16]. Halton(2,3), 8 phases at native (`ceil(8*scale^2)` when scaled), offsets + in [-0.5,0.5], never (0,0). One NDC shear per frame; auxiliary targets of other sizes (LiquidDepth + at quarter res) inherit the same NDC shift, never a per-target pixel offset. +- Current pixel in a writer: `gl_FragCoord.xy - j` (both backends: the Vulkan device renders + offscreen unflipped and flips only in the present blit, `VulkanDevice.cs:690-715`). Previous pixel: + interpolated previous clip position divided by its own w, then `(ndc*0.5+0.5)*renderSize`. +- Depth: Primary depth is `GL_DEPTH_COMPONENT32` on GL and `D32_SFLOAT` on Vulkan + (`ClientPlatformWindows.cs:1571,1895`, `GlEnums.cs:107`), sampled as [0,1] via `sampler2D`; the + contract records format and "0 = near" convention. Previous linear depth is an R32F resolve output. +- Motion attachment: RGBA16F on Primary at `MV_LOCATION` (2 without SSAO, 4 with; the OIT layer pass + uses six outputs on Transparent, which is untouched). `rg = mv`, `b = reactive`, `a = writerDepth` + (NDC depth at write time). The resolve treats a pixel as validly written only when `a` matches the + final depth buffer within tolerance; otherwise it uses the camera-motion fallback (static-surface + reprojection from depth, infinite-direction reprojection where depth == 1). This defines behaviour + for unknown writers, mod geometry and sky without relying on undefined unwritten-output contents. +- Blend state: passes that blend colour (particle cubes, `SystemRenderParticles.cs:132`) set the + motion attachment to replace blending via `SetBlendFuncSeparate(MV_LOCATION, 1, 0, 1, 0)` (the + seam already exposes per-attachment blend; OIT uses it). Fullscreen resolve/sharpen passes set + blend off, depth test/write off, viewport and target explicitly, then restore. +- Reactive: opaque writers 0; alpha-tested foliage 0; OIT transparents `1 - revealage` from the merge; + particles 1 via their own writer (quad particles in OIT are covered by revealage only, cube + particles write directly); animated liquid textures 0.3 initial, tuned by measurement; the value + lowers history weight in the resolve and maps to the FSR reactive / XeSS responsive mask later. +- Reset: world load, dimension change, teleport (camera delta above a threshold), reference-position + rebase (`PlayerCamera.cs:74-76`), window resize or zero size, SSAO/shader reload, render-scale or + FOV change, TAA toggle, mega-screenshot capture. Reset clears both history sets and marks the + frame in the contract with a reason. + +## Motion vectors: accuracy rules (all consumers depend on these) + +1. Compute in the same draw as the colour, per pixel, perspective-correct (previous clip position + interpolated, divided in the fragment shader). Apply the `chunkopaque.vsh` z-fighting w-offset + identically to the previous clip position. +2. Previous position uses the same code path with previous inputs: previous model matrix, previous + per-view view matrix, previous unjittered projection (world or hand FOV), previous warp state, + previous bone matrices. Keep the previous *rendered* state, not the previous simulation tick; + add history-valid flags for spawn, mesh/animator change, reappearance, first/third person switch. +3. Vertex animation: evaluate the `vertexwarp.vsh` functions twice through a `WarpState` struct that + carries every uniform they read: `timeCounter`, `windWaveCounter`, `windWaveCounterHighFreq`, + `waterWaveCounter`, `windSpeed`, `playerpos`, `globalWarpIntensity`, `glitchWaviness`, + `windWaveIntensity`, `waterWaveIntensity`, `perceptionEffectId`, `perceptionEffectIntensity`. + Some vary per entity or per pass (`EntityShapeRenderer.cs:641`, cloud perception multiplier), so + previous values are captured per draw class, not only globally. Counters wrap + (`DefaultShaderUniforms.cs:153-168`): store actual previous values, never current minus dt. +4. Terrain: `prevRel = truePos + (cameraPos_cur - cameraPos_prev)` using the exact + `EntityPlayer.CameraPos` delta in double precision, then `prevAbsForWarp = prevRel + prevPlayerpos` + (position relative to the slowly rebased reference, which is what the warp noise consumes), then + `prevClip = prevProjUnjittered * prevCameraMatrixOrigin * warpPrev(prevRel)`. +5. Skinned entities: previous model matrix and previous bone matrices per renderer, skinned twice. + Held items (standard shader), first-person hands (own program and FOV, `EntityPlayerShapeRenderer.cs:255-308`, + `ModSystemFpHands.cs:24-38`), EchoChamber, dropped items (`EntityItemRenderer.cs`), quern/gears + (`QuernTopRenderer.cs`, `MechNetworkRenderer.cs` instanced) each get their own previous-transform + store and shader writer; instanced renderers keep previous instance transforms with stable identity. +6. Static world under camera motion is exact through rule 4; the resolve's camera fallback covers + only depth-writing static surfaces and sky, and is labelled a fallback. +7. Liquid: the OIT liquid draw cannot write Primary's motion attachment (six OIT outputs already), so + a dedicated liquid velocity pass re-draws liquid into the motion attachment with depth test against + Primary depth and writes the surface's motion and depth. Foam/flow UV animation stays reactive. +8. Screen-space effects: SSAO uses the jittered projection matching its jittered G-buffer + (`ssao.fsh:119-127` projects samples and reads gPosition); bloom/god rays/Final read resolved signals. +9. Validation must use frozen scenes with tolerances plus known directional displacements; "zero + everywhere with wind blowing" is wrong (swaying leaves have real motion) and "zero" cannot + distinguish a sign or axis error. + +## Render-system inventory (maintained in the plan and as a test table) + +| Class | Stage / target | Shader | Motion policy | +|---|---|---|---| +| Chunk opaque / topsoil / pass-7 overlay | Opaque, AfterOIT / Primary | chunkopaque, chunktopsoil | exact (P3) | +| Liquid | OIT / Transparent | chunkliquid | exact via liquid velocity pass (P4) + reactive foam | +| LiquidDepth prepass | Before / LiquidDepth quarter res | chunkliquiddepth | jittered NDC shear, no motion | +| Entities (skinned) | Opaque, OIT, AfterOIT / Primary, Transparent | entityanimated(_oit) | exact opaque (P3); OIT reactive | +| Held items, dropped items, block-entity models | Opaque / Primary | standard | exact (P3, standard writer) | +| First-person hands | Opaque / Primary, hand FOV, depthOffset | fp hands program | exact with hand-FOV previous (P3) | +| Instanced mechanical power | Opaque / Primary | instanced | exact with previous instance transforms (P3) | +| Particles cube | Opaque / Primary, blend on | particlescube | reactive 1, replace-blend on motion (P4) | +| Particles quad | OIT / Transparent | particlesquad | reactive via revealage (P4) | +| Clouds (volumetric, map), aurora, night sky, sun/moon, sky colour | OIT/Opaque | dedicated | fallback + reactive; sky uses infinite-direction reprojection (P4) | +| Decals | AfterOIT / Primary | decal shader | inherits surface motion; crack progress rejected by colour clipping (P4) | +| Work-item guides, selection boxes, wireframes | AfterFinalComposition / Primary | various | outside window, unjittered (P1) | +| Rifts | AfterBlit / Default | rift | outside window; noted as FG gap | +| Mod geometry via `IRenderAPI` | any | any | fallback via writerDepth mismatch; opt-in writer API later | + +## Frame-generation and ray-reconstruction readiness (constraints, not built here) +- HUD-less colour exists today: Primary after Final, before the blit; the HUD is drawn into the + default framebuffer afterwards, window-sized. Rifts (AfterBlit) and late guides are world content + outside that image; FG needs them moved before the boundary or composited after. The UI later needs + its own alpha target with defined premultiplication. +- Camera constants captured per frame: unjittered view/projection and previous, camera-relative + origin, near/far, vertical FOV, position/forward/up/right, jitter px, frame id (+1 per real frame, + distinct from generated/present ids), delta time ms, reset reason, render/display rects. +- Vendor libraries need native handles, negotiated extensions at device creation (`VulkanContext.cs:64,570-573`), + completion-based resource lifetimes past Present, and a replaceable present path; that is a separate + backend-native capability interface in the vendor plan. XeSS-FG and AMD Ray Regeneration are + D3D12-only today; DLSS SR/FG/RR and FSR 3.1 have Vulkan paths. +- Ray reconstruction needs linear HDR noisy colour, separate diffuse and specular albedo, normals + + roughness, specular motion or hit distance; Optimum's SSAO gnormal/gposition are not those guides. + This plan only keeps the attachment scheme and the resolver input record extensible; an HDR path + and PBR guides are a later renderer change. + +## Implementation phases + +Delivery rules for every phase: methods changed in `ClientMain`, `ClientPlatformWindows`, `ChunkRenderer`, +`ShaderRegistry`, `ShaderProgramEntityanimated`, `ScreenManager` go into `Optimum.Patcher/Program.cs` +(new members need injection entries, not only transplants); the patcher's check verifies references, +not behaviour, so each phase also runs `make deploy` and the game. New API files go into +`optimum-api-contracts.csproj` and the api-patcher export list. Mod-fork changes (VSEssentials, +VSSurvivalMod) need `mod-patcher` manifests for the installed-runtime path. New shader includes ship +via `sources/shaderincludes/` plus `make deploy` and all `scripts/package-*` copies, and get +`ShaderCompatibilityScanner` rules (`Optimum.Launcher/ShaderCompatibilityScanner.cs:22-26,294-309`). + +**P0. Prerequisites (no TAA yet).** +- Fix Vulkan named uniform blocks: `UpdateUniformBuffer` overwrites one mapped allocation and every + draw binds that allocation (`VulkanDevice.cs:1081-1087,1931-1942`), so all entities in a frame read + the last uploaded bones. Snapshot named blocks per draw into the frame ring with dynamic offsets, + as ordinary uniform blocks already are (`:1894-1925`); fix ring exhaustion (`:1908-1918`) to fail + loudly. Tests: two entities with different poses in one command buffer; consecutive frames. +- Deterministic attachment writes: enable the motion attachment only for passes that write it + (draw-buffer mask), define replace-blend for it, and add a device test for sparse outputs with five + attachments, omitted fragment outputs and independent blending on both backends. +- Typed texture readback: `DumpRequestedTextures` allocates `w*h*4` bytes regardless of format + (`VulkanDevice.cs:2341-2367`); make readback format-aware so RGBA16F/R32F targets can be dumped. +- Build the render-system inventory above as a checked test (renderer, stage, target, shader), and + the sign/adapter unit tests (shear, mv, one-pixel adapter for FSR/XeSS/DLSS scales). +- Verify: Vulkan tests, `make deploy`, game runs identically with several animals in view on Vulkan + (the UBO fix is visible), texture dump of an RGBA16F target round-trips. + +P0 status (2026-09-10): done in commit on `feat/taa`. Findings to carry: (a) `VulkanDevice.BindFramebuffer`, +`BindDefaultFramebuffer` and `ClearColor` are no-ops outside an active frame, so between-frame readbacks +silently read the last bound target; readback code and tests must run inside a frame until that is +fixed. (b) `maxDescriptorSetUniformBuffersDynamic` is not read; more than seven named blocks in one +program would fail on a minimum-spec device (none exist). (c) `_uniformBuffers` is mutated from the +finalizer thread by `DeleteUniformBuffer` while the render thread reads it; pre-existing. + +**P1. Frame contract, jitter (dev switch), camera reprojection, debug views. TAA default off.** +- `VintagestoryApi/Client/Render/OptimumTemporalFrame.cs` (immutable per-frame record + `Advance()`), + captured in `MainRenderLoop` after `shUniforms.Update` and per view at the matrix loads; previous + values for world and hand projections, `CameraMatrix`, `CameraMatrixOrigin`, camera position delta + (double), `playerpos`, full `WarpState`. `OptimumConfig`: `Taa`, `TaaSharpness`, `TaaMipBias`, + `TaaDebugView`, `EffectiveTaa`; `TaaJitterDev` hidden switch. +- Jitter through the `CurrentProjectionMatrix` getter within the temporal window only; separate + jittered array; `CurrentProjectionMatrixUnjittered`; read-only temporal context exposed through a + companion interface so mods and `RenderAPIGame` can read it without breaking `IRenderAPI` + implementers. +- Primary motion attachment (both setup paths, clear, disposal, draw-buffer masks) and history/aux/ + prev-depth targets bound via `LoadFrameBuffer(FrameBufferRef, int)` with a dispose/recreate + lifecycle; no enum dispatch changes. +- Debug: motion/reactive/validity/rejection views in `BlitPrimaryToDefault`; test scene protocol: + frozen scene tolerance, +X/+Y/rotation/near-plane displacements on GL and Vulkan. + +**P2. Minimal resolve early.** +- `taa-resolve` MRT (colour, glow+ssao aux, linear depth) with reset, validity via writerDepth, + camera fallback, depth/velocity rejection, YCoCg variance clipping, luma weighting, Catmull-Rom for + colour and nearest for depth/validity; explicit blend/depth/viewport state. Luma aliases the resolved + colour; bloom/god rays/Final rebound to resolved textures. This makes every later producer's + failure visible instead of being confused with raw jitter differences. +- Tests: GPU harness on `WorldRenderPathTests` with synthetic inputs (static convergence, known + offset reprojection, outlier clip, reset); coverage test for ordering and FXAA-off; in-game the + whole scene converges with camera-only motion vectors. + +**P3. Opaque coverage.** +- `sources/shaderincludes/vertexwarp.vsh` with `WarpState`; `chunkopaque`, `chunktopsoil` writers; + `standard.vsh/.fsh` writer (items, block entities, dropped items, quern) with previous transforms + in `EntityShapeRenderer` (item and body), `EntityItemRenderer`, `QuernTopRenderer`; `entityanimated` + writer with previous bones and model matrix (`AnimationPrev` UBO from `initUbos`, FP hands' own + UBO in `ModSystemFpHands`, `EchoChamberRenderer`); instanced mech-network previous instance + transforms (`MechNetworkRenderer`, `GenericMechBlockRenderer`); hand-FOV previous projection and + depthOffset handling. Uniforms set in `ChunkRenderer.RenderOpaque/RenderAfterOIT` and the + LiquidDepth prepass. +- Verify per class with the debug views and directional tests; measure vertex-warp cost on dense + foliage and the UBO snapshot cost with crowds. + +**P4. Transparency, particles, volumetrics, sky, decals, late overlays.** +- Liquid velocity pass; OIT revealage reactive; particle writers; cloud/aurora/sky policies with the + infinite-direction reprojection; decals inherit motion; AfterFinalComposition/AfterBlit content + verified outside the window. State per class exact vs fallback in the inventory test. + +**P5. Integration, sharpen, settings, fallback, acceptance.** +- RCAS variant with a sharpness uniform and true bypass; no double sharpening with FSR1 render + scale; `TaaMipBias` optional and measured; settings rows in `GuiCompositeSettings.cs.patch`; + runtime fallback to FXAA when compile/FBO creation fails; scanner rules; packaging. +- Acceptance matrix on both backends: moving silhouettes on contrast, transparent foreground and + background motion, thin fences, hand/world FOV, quern/gear, dropped items, fire, rain, clouds, + aurora, underwater transitions, camera modes (shake, third person, mounted), reference rebase, + chunk replacement, shader reload, missing-resource fallback, normal/scaled/mega screenshots (mega + capture uses warm-up or spatial-only). +- Performance on the Arc 140V: total frame delta, GPU pass timestamps, CPU frame time, 1% lows, + with renderer name, power mode and thermals recorded. Memory at 1080p: motion 15.8 MiB + two colour + histories 31.6 MiB + aux 7.9 MiB + prev-depth 15.8 MiB. +- Decide default-on only after the matrix passes. + +**P6. Freeze the contract.** +- Document the immutable frame input record, resource formats/conventions, per-class motion status + and the adapter tests; reserve backend-native execution, presentation lifetime and extra ray + signals for the vendor plan. + +## Verification (end to end) +1. `dotnet build VintageStory.slnx -c Release`; `dotnet test Optimum.Tests`; + `dotnet test Optimum.Render.Vulkan.Tests`; `scripts/check-patches.sh`; patcher run. +2. `make deploy`; run on Vulkan and OpenGL (`Renderer` in + `~/.config/OptimumVintagestoryData/ModConfig/optimum.json`) with `prime-run`, confirming the + selected GPU in the log. +3. Debug views and the directional/frozen-scene protocol on both backends after each phase. +4. Acceptance matrix in P5; TAA off must be byte-identical to today's chain. + +## Sources +- https://alextardif.com/TAA.html +- https://www.elopezr.com/temporal-aa-and-the-quest-for-the-holy-trail/ +- https://interplayoflight.wordpress.com/2020/05/30/a-survey-of-temporal-antialiasing-techniques-presentation-notes/ +- https://github.com/playdeadgames/temporal +- https://github.com/GameTechDev/TAA +- https://github.com/DiligentGraphics/DiligentFX/tree/master/PostProcess/TemporalAntiAliasing +- https://gpuopen.com/manuals/fsr_sdk/techniques/super-resolution-upscaler/ +- https://github.com/GPUOpen-Effects/FidelityFX-FSR2 (README "Placement in the frame") +- https://gpuopen.com/manuals/fidelityfx_sdk/techniques/super-resolution-interpolation/ +- https://gpuopen.com/amd-fsr-rayregeneration/ +- https://github.com/GPUOpen-LibrariesAndSDKs/Capsaicin +- https://gpuopen.com/learn/fsr-2-1-unreal-engine-plugin-part1/ +- https://juandiegomontoya.github.io/porting_fsr2.html +- https://github.com/BoyBaykiller/FidelityFX-FSR2-CSharpBindings +- https://github.com/intel/xess/blob/main/doc/xess_sr_developer_guide_english.md +- https://github.com/intel/xess/blob/main/doc/xess_fg_developer_guide_english.md +- https://github.com/NVIDIA-RTX/Streamline/blob/main/docs/ProgrammingGuideDLSS.md +- https://github.com/NVIDIA-RTX/Streamline/blob/main/docs/ProgrammingGuideDLSS_G.md +- https://github.com/NVIDIA-RTX/Streamline/blob/main/docs/ProgrammingGuideDLSS_RR.md +- https://dev.epicgames.com/documentation/unreal-engine/temporal-upscalers-in-unreal-engine +- https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@16.0/manual/features/motion-vectors.html +- https://ogldev.org/www/tutorial41/tutorial41.html +- https://github.com/godotengine/godot/pull/61319 +- https://mods.vintagestory.at/show/mod/35005 From 82ce77c305b0f97c904fdbb015a5685aedf83cf0 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 16:42:50 +0200 Subject: [PATCH 016/226] feat(taa): P1 temporal frame contract, jitter window, motion/history targets, debug views Gate EffectiveTaa on the explicit compatibility verdict like the Vulkan renderer does; a missing launcher scan reports every feature disabled and silently left TAA off. Verified in-game on Vulkan and OpenGL: debug view 1 shows zero motion everywhere, view 3 shows nothing written yet, the jitter dev switch visibly jitters the raw image. --- sources/VintagestoryApi/Config/OptimumConfig.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index da9e3009..5551bd5e 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -498,8 +498,11 @@ public static class OptimumConfig public static bool EffectiveGodRaysSampleCap => GodRaysSampleCapEnabled && !IsShaderFeatureDisabled("GodRaysSampleCap"); + // Like the Vulkan renderer selection, TAA is a renderer-level feature: a + // missing launcher scan must not disable it (IsShaderFeatureDisabled reports + // everything disabled without a scan), only an explicit scan verdict does. public static bool EffectiveTaa => Taa && - !IsShaderFeatureDisabled("Taa"); + !IsFeatureExplicitlyDisabled("Taa"); public static bool EffectiveEntityLightBatch => EntityLightBatchEnabled && !IsShaderFeatureDisabled("EntityLightBatch"); From fce1343e5da32f2240a88bf0da23f7203d359b03 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 17:22:17 +0200 Subject: [PATCH 017/226] wip(taa): P2 resolve pass, GPU + coverage tests (review defects pending) --- .claude/skills/run-optimum/SKILL.md | 2 +- CLAUDE.md | 2 +- Optimum.Patcher/Program.cs | 7 + .../TaaResolveTests.cs | 682 ++++++++++++++++++ Optimum.Tests/taa-pipeline-coverage-tests.cs | 123 ++++ .../ClientPlatformWindows.cs.patch | 287 ++++++-- .../ShaderPrograms.cs.patch | 6 +- .../ShaderRegistry.cs.patch | 13 +- sources/shaders/taa-resolve.fsh | 177 +++++ sources/shaders/taa-resolve.vsh | 11 + 10 files changed, 1229 insertions(+), 81 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaResolveTests.cs create mode 100644 sources/shaders/taa-resolve.fsh create mode 100644 sources/shaders/taa-resolve.vsh diff --git a/.claude/skills/run-optimum/SKILL.md b/.claude/skills/run-optimum/SKILL.md index ff6623d0..5457e808 100644 --- a/.claude/skills/run-optimum/SKILL.md +++ b/.claude/skills/run-optimum/SKILL.md @@ -17,7 +17,7 @@ description: Build, deploy, launch, stop and screenshot the Optimum Vintage Stor classic one) and fix that before judging pixels. 6. Screenshot: `scripts/dev/screenshot.sh /tmp/vulkan.png`, then Read the PNG and describe what you see. For a backend comparison take both shots from the same save and camera. -7. Stop: `scripts/dev/kill-client.sh`. Restore `ModConfig/optimum.json` `Renderer` to what the user had. +7. Stop: `scripts/dev/kill-client.sh` immediately after the check; the user does not want it left running. Restore `ModConfig/optimum.json` `Renderer` to what the user had. Gotchas: `ssaa` 0.5 in clientsettings halves the render resolution on both backends; the random `--rndWorld -p creativebuilding` world is superflat and has no animals; passing `world.vcdbs` to `-o` diff --git a/CLAUDE.md b/CLAUDE.md index 147c1ac9..ba2b8c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ trace: `program N 'name'`, `fullscreen program= tex0= target=`, `bind unit= text 5. **Process hygiene.** Launch through `scripts/dev/*.sh` (setsid wrappers). Never put `pkill -f` or `pgrep -f` in a command that also contains the process name in a heredoc or string: it matches the calling shell and the tool dies with exit 144. Close the game with the kill script (window close - first) to avoid shutdown-race crash reports. + first) to avoid shutdown-race crash reports. Close the game as soon as a check is done; never leave it running. 6. **Git.** Never `git stash`. Commit WIP on the branch with a `wip:` prefix instead. Branch from `main` (tracks `origin/main` = NightHammer1000/VulkanStory; `upstream` = StratumServer/Optimum). Commit only when asked or when a phase is verified; say what was verified in the message. diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index b4bc3ff5..3abb28d0 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -144,12 +144,19 @@ "CreateOptimumHistoryTarget", "CreateOptimumHistoryTargetGl", "DisableOptimumTaa", + "_taaFrameParity", + "_taaHistoryValid", + "taaResolvedColorTexture", + "taaResolvedGlowTexture", + "TaaResolvedThisFrame", + "RenderOptimumTaaResolve", }, ["Vintagestory.Client.NoObf.ShaderPrograms"] = new() { "FsrEasu", "FsrRcas", "TaaDebug", + "TaaResolve", }, ["Vintagestory.Client.NoObf.ShaderRegistry"] = new() { diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs new file mode 100644 index 00000000..e71dd407 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -0,0 +1,682 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Drives the real taa-resolve shader pair (see TAA-PLAN.md P2) on the +/// Vulkan backend with synthetic inputs, the way +/// and drive the world programs: load through +/// , build a real pipeline against a three-attachment +/// MRT framebuffer (colour history RGBA16F, aux/glow RGBA8, linear depth R32F), +/// draw the fullscreen triangle and read back inside the frame. +/// +/// This goes one level lower than the seam (IOptimumGraphicsDevice): the +/// public seam's EnumTextureInternalFormat has no R32F, and +/// ReadDefaultFramebuffer always assumes 4 bytes per pixel, neither of +/// which fits an HDR history or a float depth target. So this talks to +/// , and +/// directly and builds descriptor sets by +/// hand with a private , mirroring what +/// VulkanDevice does per draw but scoped to one fullscreen pass with named +/// uniforms and named samplers. +/// +public class TaaResolveTests +{ + private readonly ITestOutputHelper _output; + + public TaaResolveTests(ITestOutputHelper output) => _output = output; + + private const uint Size = 32; + + private static readonly float[] Identity4 = + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 1f, 0f, + 0f, 0f, 0f, 1f, + }; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) + { + var options = new VulkanContextOptions + { + Headless = true, + EnableValidation = true, + DebugCallback = messages.Add, + }; + + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) + { + output.WriteLine("Vulkan unavailable: " + failureReason); + } + return created; + } + + // ------------------------------------------------------------------ tests + + /// + /// With resetHistory=1 the resolve must ignore whatever the history + /// holds entirely: the output is the current frame's colour, unmodified. + /// + [SkippableFact] + public unsafe void ResetHistoryIgnoresTheHistoryEntirely() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const float currentR = 0.7f, currentG = 0.3f, currentB = 0.2f; + var inputs = CreateInputSet(textures); + UploadFlatRgba16F(textures, inputs.SceneTex, currentR, currentG, currentB, 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + // Motion says "written, no displacement" so the resolve does not + // fall back to a camera-reprojection path that would need a valid + // matrix; irrelevant here since reset overrides alpha regardless. + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + // History: a completely different colour. If this leaks into the + // output at all, reset is not doing its job. + UploadFlatRgba16F(textures, inputs.HistoryColor, 0.1f, 0.9f, 0.1f, 1f); + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.HistoryDepth, 0.5f); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + + var uniforms = new TaaUniforms { ResetHistory = 1 }; + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + Assert.InRange(ReadHalf(colorBytes, x, y, 0, 8), currentR - 0.02f, currentR + 0.02f); + Assert.InRange(ReadHalf(colorBytes, x, y, 1, 8), currentG - 0.02f, currentG + 0.02f); + Assert.InRange(ReadHalf(colorBytes, x, y, 2, 8), currentB - 0.02f, currentB + 0.02f); + } + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// A perfectly static scene (identity camera, zero jitter, zero motion) + /// converges to the constant current colour as the two history sets are + /// ping-ponged across many resolves. + /// + [SkippableFact] + public unsafe void StaticSceneConvergesToTheCurrentColour() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const float currentValue = 0.6f; + var inputs = CreateInputSet(textures); + UploadFlatRgba16F(textures, inputs.SceneTex, currentValue, currentValue, currentValue, 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + + TaaAttachmentSet setA = CreateAttachmentSet(textures, targets); + TaaAttachmentSet setB = CreateAttachmentSet(textures, targets); + // Seed A far from the current colour so twenty resolves are a real + // convergence, not a no-op. + UploadFlatRgba16F(textures, setA.Color, 0f, 0f, 0f, 1f); + UploadFlatRgba8(textures, setA.Glow, 0, 0, 0, 255); + UploadFlatR32F(textures, setA.Depth, 0.5f); + + var uniforms = new TaaUniforms { ResetHistory = 0, BlendAlpha = 0.1f }; + + TaaAttachmentSet history = setA, current = setB; + for (int i = 0; i < 20; i++) + { + inputs.HistoryColor = history.Color; + inputs.HistoryGlow = history.Glow; + inputs.HistoryDepth = history.Depth; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, current); + + (history, current) = (current, history); + } + + // 'history' now holds the last write, since the pair swapped once + // more than it resolved. + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, history.Color, 8); + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + Assert.InRange(ReadHalf(colorBytes, x, y, 0, 8), currentValue - 0.02f, currentValue + 0.02f); + Assert.InRange(ReadHalf(colorBytes, x, y, 1, 8), currentValue - 0.02f, currentValue + 0.02f); + Assert.InRange(ReadHalf(colorBytes, x, y, 2, 8), currentValue - 0.02f, currentValue + 0.02f); + } + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// A uniform +2px motion field reprojects the history two pixels: a bright + /// band in the history shows up two columns earlier in the output. + /// + /// The current frame cannot be perfectly flat here. The resolve rectifies + /// history against the current frame's own 3x3 neighbourhood before + /// blending it in (see clipToBox in taa-resolve.fsh) - against a + /// genuinely flat current, that neighbourhood box has zero width and any + /// history value that disagrees with it, however it got there, is clipped + /// back to (effectively) the current colour. That is the clip working as + /// designed, not a test bug, so the current frame here carries a fine + /// per-column checker instead of a flat fill: it keeps the local box open + /// (both a low and a high value are always present in every 3x3 window) + /// without giving the resolve any large-scale feature of its own, so a + /// reprojected history feature is what a regional average actually shows. + /// + [SkippableFact] + public unsafe void UniformMotionReprojectsTheHistoryByThatOffset() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + var inputs = CreateInputSet(textures); + // Per-column checker: every 3-wide window has both 0.3 and 0.7, so + // clipToBox never degenerates to a point, but no column carries a + // feature of its own for the assertions to confuse with history's. + UploadRgba16F(textures, inputs.SceneTex, (x, _) => (x % 2 == 0) ? 0.3f : 0.7f, + (x, _) => (x % 2 == 0) ? 0.3f : 0.7f, (x, _) => (x % 2 == 0) ? 0.3f : 0.7f, (_, _) => 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + // Uniform +2px motion, written (matches depth) everywhere. + UploadFlatRgba16F(textures, inputs.MotionTex, 2f, 0f, 0f, 0.5f); + + const int stripeStart = 14, stripeWidth = 4; + const float background = 0.5f, stripe = 1.0f; + UploadRgba16F(textures, inputs.HistoryColor, + (x, _) => x is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (x, _) => x is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (x, _) => x is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (_, _) => 1f); + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.HistoryDepth, 0.5f); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + // Mostly history, so the reprojected band dominates the blend. + var uniforms = new TaaUniforms { ResetHistory = 0, BlendAlpha = 0.05f }; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + + // Reading history at (pixelCentre + mv) means an output column c + // sees history column c + 2: the bright band at history columns + // [14,18) should appear at output columns [12,16). + float expectedBandAverage = AverageRed(colorBytes, stripeStart - 2, stripeStart - 2 + stripeWidth); + // A control window far from both the source and destination bands, + // still reading flat history background wherever it samples. + float controlAverage = AverageRed(colorBytes, 24, 28); + + _output.WriteLine($"reprojected band average={expectedBandAverage}, control average={controlAverage}"); + Assert.True(expectedBandAverage > controlAverage + 0.1f, + $"expected the reprojected band (avg {expectedBandAverage}) to read clearly brighter " + + $"than the control window (avg {controlAverage})"); + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// A history pixel far outside the current frame's neighbourhood range - + /// a stale ghost, a lighting spike - is clipped back toward that + /// neighbourhood rather than blended in at full strength. + /// + [SkippableFact] + public unsafe void AnOutlierHistoryValueIsClippedTowardTheNeighbourhood() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const float currentValue = 0.5f; + var inputs = CreateInputSet(textures); + UploadFlatRgba16F(textures, inputs.SceneTex, currentValue, currentValue, currentValue, 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + + // A magenta outlier, nothing like the current frame's flat grey. + const float outlierR = 1.0f, outlierG = 0.0f, outlierB = 1.0f; + UploadFlatRgba16F(textures, inputs.HistoryColor, outlierR, outlierG, outlierB, 1f); + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.HistoryDepth, 0.5f); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + // Even with heavy history weight, the clip should dominate. + var uniforms = new TaaUniforms { ResetHistory = 0, BlendAlpha = 0.5f }; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + float r = ReadHalf(colorBytes, (int)Size / 2, (int)Size / 2, 0, 8); + float g = ReadHalf(colorBytes, (int)Size / 2, (int)Size / 2, 1, 8); + float b = ReadHalf(colorBytes, (int)Size / 2, (int)Size / 2, 2, 8); + _output.WriteLine($"resolved=({r},{g},{b}) current=({currentValue}) outlier=({outlierR},{outlierG},{outlierB})"); + + Assert.InRange(r, currentValue - 0.1f, currentValue + 0.1f); + Assert.InRange(g, currentValue - 0.1f, currentValue + 0.1f); + Assert.InRange(b, currentValue - 0.1f, currentValue + 0.1f); + // Clearly not the raw outlier, in at least the channel it disagrees + // with the current frame the most. + Assert.True(MathF.Abs(g - outlierG) > 0.3f, "the outlier's green channel should have been clipped away"); + + ValidationAssert.NoErrors(messages); + } + } + + // ------------------------------------------------------------------ setup + + private static ShaderProgramResources LoadProgram( + VulkanContext context, ShaderCompiler compiler, GlStateTracker state) + { + Dictionary files = ShaderCorpus.LoadShaderFiles(); + Dictionary includes = ShaderCorpus.LoadIncludes(); + List stages = ShaderCorpus.BuildProgram( + "taa-resolve", files, includes, ShaderCorpus.Variants().First()); + Assert.NotEmpty(stages); + + TranslatedProgram translated = ShaderTranslator.Translate(stages, compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + var program = new ShaderProgramResources(context, 1, translated); + state.SetProgram(1); + return program; + } + + /// The seven sampler inputs the resolve declares. + private sealed class TaaInputSet + { + public int SceneTex; + public int GlowTex; + public int MotionTex; + public int DepthTex; + public int HistoryColor; + public int HistoryGlow; + public int HistoryDepth; + } + + /// One MRT write target: colour history, aux/glow, linear depth. + private sealed class TaaAttachmentSet + { + public int Color; + public int Glow; + public int Depth; + public int Framebuffer; + } + + private sealed class TaaUniforms + { + public float[] RenderSize = { Size, Size }; + public float[] JitterPx = { 0f, 0f }; + public float[] InvViewProjJittered = Identity4; + public float[] PrevViewProj = Identity4; + public float[] ViewMatrix = Identity4; + public float[] CameraDelta = { 0f, 0f, 0f }; + public int ResetHistory; + public float BlendAlpha = 0.1f; + public float VarianceGamma = 1.25f; + } + + private static TaaInputSet CreateInputSet(TextureManager textures) => new() + { + SceneTex = textures.Create(Size, Size, Format.R16G16B16A16Sfloat), + GlowTex = textures.Create(Size, Size, Format.R8G8B8A8Unorm), + MotionTex = textures.Create(Size, Size, Format.R16G16B16A16Sfloat), + DepthTex = textures.Create(Size, Size, Format.R32Sfloat), + HistoryColor = textures.Create(Size, Size, Format.R16G16B16A16Sfloat), + HistoryGlow = textures.Create(Size, Size, Format.R8G8B8A8Unorm), + HistoryDepth = textures.Create(Size, Size, Format.R32Sfloat), + }; + + private static TaaAttachmentSet CreateAttachmentSet(TextureManager textures, RenderTargetManager targets) + { + var set = new TaaAttachmentSet + { + Color = textures.Create(Size, Size, Format.R16G16B16A16Sfloat), + Glow = textures.Create(Size, Size, Format.R8G8B8A8Unorm), + Depth = textures.Create(Size, Size, Format.R32Sfloat), + }; + set.Framebuffer = targets.Create(Size, Size); + targets.Attach(set.Framebuffer, 0, set.Color); + targets.Attach(set.Framebuffer, 1, set.Glow); + targets.Attach(set.Framebuffer, 2, set.Depth); + targets.SetDrawBuffers(set.Framebuffer, 0b111); + return set; + } + + // ------------------------------------------------------------------- draw + + /// + /// One resolve pass: writes the shadow buffer, uploads it to a dedicated + /// dynamic-uniform-buffer, builds set 0 (uniforms) and set 1 (samplers) by + /// hand through a private , and draws the + /// fullscreen triangle - the same three-step shape VulkanDevice's own + /// draw path follows, scoped to a single named-uniform, named-sampler pass. + /// + private static unsafe void ResolveOnce( + VulkanContext context, VulkanCommands commands, TextureManager textures, GlStateTracker state, + RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, + DescriptorCache descriptors, TaaInputSet inputs, TaaUniforms uniforms, TaaAttachmentSet output) + { + SetUniformFloats(program, "renderSize", uniforms.RenderSize); + SetUniformFloats(program, "jitterPx", uniforms.JitterPx); + SetUniformFloats(program, "invViewProjJittered", uniforms.InvViewProjJittered); + SetUniformFloats(program, "prevViewProj", uniforms.PrevViewProj); + SetUniformFloats(program, "viewMatrix", uniforms.ViewMatrix); + SetUniformFloats(program, "cameraDelta", uniforms.CameraDelta); + SetUniformInt(program, "resetHistory", uniforms.ResetHistory); + SetUniformFloats(program, "blendAlpha", new[] { uniforms.BlendAlpha }); + SetUniformFloats(program, "varianceGamma", new[] { uniforms.VarianceGamma }); + + ulong shadowSize = (ulong)Math.Max(program.UniformShadow.Length, 16); + using var uniformBuffer = new VulkanBuffer(context, shadowSize, + BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + fixed (byte* source = program.UniformShadow) + { + System.Buffer.MemoryCopy(source, (void*)uniformBuffer.Mapped, + (long)shadowSize, program.UniformShadow.Length); + } + + var textureByName = new Dictionary(StringComparer.Ordinal) + { + ["sceneTex"] = inputs.SceneTex, + ["glowTex"] = inputs.GlowTex, + ["motionTex"] = inputs.MotionTex, + ["depthTex"] = inputs.DepthTex, + ["historyColor"] = inputs.HistoryColor, + ["historyGlow"] = inputs.HistoryGlow, + ["historyDepth"] = inputs.HistoryDepth, + }; + + var samplerState = SamplerState.Default with + { + MagFilter = Filter.Linear, + MinFilter = Filter.Linear, + AddressU = SamplerAddressMode.ClampToEdge, + AddressV = SamplerAddressMode.ClampToEdge, + }; + + var samplerBindings = new SamplerBindingValue[program.Interface.Samplers.Count]; + var sampledTextures = new VulkanTexture[samplerBindings.Length]; + for (int i = 0; i < samplerBindings.Length; i++) + { + SamplerBinding declared = program.Interface.Samplers[i]; + VulkanTexture texture = textures.Get(textureByName[declared.Name]) + ?? throw new InvalidOperationException("no texture bound for sampler '" + declared.Name + "'"); + sampledTextures[i] = texture; + Sampler samplerHandle = textures.Samplers.Get(samplerState); + samplerBindings[i] = new SamplerBindingValue((uint)declared.Binding, texture.View, samplerHandle, texture.Id); + } + + VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = state.TargetFormats(formatsId); + int attachmentCount = targets.EnabledAttachmentCount(bound); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); + + Pipeline pipeline = pipelines.Get( + state.BuildKey(0, formatsId, attachmentCount), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = formats, + Blend = blend, + PolygonMode = state.PolygonMode, + Topology = state.Topology, + }); + + commands.SubmitAndWait(commandBuffer => + { + Vk api = context.Api; + + // Layout transitions cannot happen inside a rendering scope, so + // every sampled texture - including a previous iteration's output, + // still in ColorAttachmentOptimal - is put right before it opens. + foreach (VulkanTexture texture in sampledTextures) + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + } + + targets.Bind(commandBuffer, output.Framebuffer); + targets.EnsureRendering(commandBuffer); + + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + + var viewport = new Viewport(0, 0, Size, Size, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(Size, Size)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); + api.CmdSetDepthTestEnable(commandBuffer, false); + api.CmdSetDepthWriteEnable(commandBuffer, false); + api.CmdSetDepthCompareOp(commandBuffer, CompareOp.Always); + api.CmdSetStencilTestEnable(commandBuffer, false); + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + StencilOp.Keep, StencilOp.Keep, StencilOp.Keep, CompareOp.Always); + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0xFF); + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); + api.CmdSetLineWidth(commandBuffer, 1.0f); + + if (program.Interface.HasUniformBlock) + { + var uniformContents = new DescriptorSetContents( + program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, + Array.Empty(), + new[] + { + new BufferBindingValue(ProgramInterfaceLayout.DefaultBlockBinding, + uniformBuffer.Handle, 0, (ulong)program.UniformShadow.Length, uniformBuffer.Id), + }); + DescriptorSet uniformSet = descriptors.Get( + uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); + uint dynamicOffset = 0; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, 1, &dynamicOffset); + } + + if (samplerBindings.Length > 0) + { + var samplerContents = new DescriptorSetContents( + program.ProgramId, ProgramInterfaceLayout.SamplerSet, + samplerBindings, Array.Empty()); + DescriptorSet samplerSet = descriptors.Get( + samplerContents, program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); + } + + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + targets.EndRendering(commandBuffer); + }); + } + + private static void SetUniformFloats(ShaderProgramResources program, string name, float[] values) + { + int location = program.LocationOf(name); + if (location < 0) return; + + var bytes = new byte[values.Length * sizeof(float)]; + for (int i = 0; i < values.Length; i++) + { + BitConverter.TryWriteBytes(bytes.AsSpan(i * sizeof(float), sizeof(float)), values[i]); + } + program.SetUniform(location, bytes); + } + + private static void SetUniformInt(ShaderProgramResources program, string name, int value) + { + int location = program.LocationOf(name); + if (location < 0) return; + program.SetUniform(location, BitConverter.GetBytes(value)); + } + + // --------------------------------------------------------------- textures + + private static unsafe void UploadFlatRgba16F( + TextureManager textures, int textureId, float r, float g, float b, float a) => + UploadRgba16F(textures, textureId, (_, _) => r, (_, _) => g, (_, _) => b, (_, _) => a); + + private static unsafe void UploadRgba16F( + TextureManager textures, int textureId, + Func r, Func g, Func b, Func a) + { + var data = new Half[Size * Size * 4]; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + int i = (y * (int)Size + x) * 4; + data[i] = (Half)r(x, y); + data[i + 1] = (Half)g(x, y); + data[i + 2] = (Half)b(x, y); + data[i + 3] = (Half)a(x, y); + } + fixed (Half* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 8); + } + } + + private static unsafe void UploadFlatRgba8( + TextureManager textures, int textureId, byte r, byte g, byte b, byte a) + { + var data = new byte[Size * Size * 4]; + for (int i = 0; i < data.Length; i += 4) + { + data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = a; + } + fixed (byte* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 4); + } + } + + private static unsafe void UploadFlatR32F(TextureManager textures, int textureId, float value) + { + var data = new float[Size * Size]; + Array.Fill(data, value); + fixed (float* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 4); + } + } + + // --------------------------------------------------------------- readback + + private static unsafe byte[] ReadTextureBytes( + VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, int bytesPerPixel) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)Size * Size * (ulong)bytesPerPixel; + + using var readback = new VulkanBuffer(context, bytes, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(texture.Aspect, 0, 0, 1), + ImageExtent = new Extent3D(Size, Size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + }); + + var result = new byte[(int)bytes]; + Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } + + private static float ReadHalf(byte[] data, int x, int y, int channel, int bytesPerPixel) + { + int offset = (y * (int)Size + x) * bytesPerPixel + channel * 2; + return (float)BitConverter.ToHalf(data, offset); + } + + /// Average red channel over columns [startX, endX) across every row. + private static float AverageRed(byte[] colorBytes, int startX, int endX) + { + float sum = 0f; + int count = 0; + for (int y = 0; y < Size; y++) + for (int x = startX; x < endX; x++) + { + sum += ReadHalf(colorBytes, x, y, 0, 8); + count++; + } + return sum / count; + } +} diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index b3355f3b..9f99d98e 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -144,6 +144,129 @@ public void TaaDebugViewIsGatedBehindMotionAttachmentAndFallsThroughOtherwise() Assert.Contains("OptimumConfig.TaaDebugView != 0 && MotionAttachmentIndex >= 0", platform); } + [Fact] + public void RenderPostprocessingEffectsResolvesTaaBeforeBloomAndReadsTheResolvedTextures() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + int postEffectsStart = platform.IndexOf( + "public override void RenderPostprocessingEffects(float[] projectMatrix)", + StringComparison.Ordinal); + Assert.True(postEffectsStart >= 0); + int resolveCall = platform.IndexOf("RenderOptimumTaaResolve();", postEffectsStart, StringComparison.Ordinal); + Assert.True(resolveCall > postEffectsStart); + + // postSceneTexture/postGlowTexture are derived from the resolve result + // right after the call, before the bloom block reads them. + int postSceneDecl = platform.IndexOf( + "int postSceneTexture = TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0];", + resolveCall, + StringComparison.Ordinal); + int postGlowDecl = platform.IndexOf( + "int postGlowTexture = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1];", + resolveCall, + StringComparison.Ordinal); + Assert.True(postSceneDecl > resolveCall); + Assert.True(postGlowDecl > postSceneDecl); + + int bloomBlock = platform.IndexOf("if (RenderBloom)", postGlowDecl, StringComparison.Ordinal); + Assert.True(bloomBlock > postGlowDecl); + + // Bloom's findbright pass reads the resolved colour+glow, not the raw + // primary attachments. + int findbrightColor = platform.IndexOf("findbright.ColorTex2D = postSceneTexture;", bloomBlock, StringComparison.Ordinal); + int findbrightGlow = platform.IndexOf("findbright.GlowTex2D = postGlowTexture;", bloomBlock, StringComparison.Ordinal); + Assert.True(findbrightColor > bloomBlock); + Assert.True(findbrightGlow > findbrightColor); + + // God rays read the same resolved pair. + int godRaysBlock = platform.IndexOf("if (RenderGodRays)", findbrightGlow, StringComparison.Ordinal); + Assert.True(godRaysBlock > findbrightGlow); + int godraysInput = platform.IndexOf("godrays.InputTexture2D = postSceneTexture;", godRaysBlock, StringComparison.Ordinal); + int godraysGlow = platform.IndexOf("godrays.GlowParts2D = postGlowTexture;", godRaysBlock, StringComparison.Ordinal); + Assert.True(godraysInput > godRaysBlock); + Assert.True(godraysGlow > godraysInput); + + // The Luma blit target reads postSceneTexture through Blit.Scene2D on + // the TAA-resolved path (the FXAA branch instead reads the raw primary + // colour attachment, since FXAA and TAA are mutually exclusive). + Assert.Contains("if (RenderFXAA && !TaaResolvedThisFrame)", platform); + Assert.Contains("blit.Scene2D = postSceneTexture;", platform); + } + + [Fact] + public void FinalReadsTheResolvedGlowTextureOnlyWhenTaaResolvedThisFrame() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.Contains( + "final.GlowParts2D = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1];", + platform); + } + + [Fact] + public void FxaaDefineIsOffWhenEffectiveTaaIsOn() + { + string shaderRegistry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + + Assert.Contains( + "#define FXAA \" + (ClientSettings.FXAA && OptimumConfig.EffectiveRenderScale >= 1.0f && !OptimumConfig.EffectiveTaa ? 1 : 0)", + shaderRegistry); + } + + [Fact] + public void TaaResolveIsRegisteredAndOptional() + { + string shaderRegistry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + + Assert.Contains( + "RegisterOptimumShaderProgram(\"taa-resolve\", ShaderPrograms.TaaResolve = new ShaderProgram());", + shaderRegistry); + + // taa-resolve is optional: a failed compile only marks LoadError on the + // program itself, it never flips the global shader-load-succeeded flag + // (same treatment as FsrEasu/FsrRcas/TaaDebug). + int compileHelperStart = shaderRegistry.IndexOf( + "private static void CompileAndTrackShaderProgram", + StringComparison.Ordinal); + Assert.True(compileHelperStart >= 0); + Assert.Contains( + "shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve", + shaderRegistry.Substring(compileHelperStart)); + } + + [Fact] + public void CecilPatcherShipsEveryTaaResolveMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"TaaResolve\"", patcher); + Assert.Contains("\"RenderOptimumTaaResolve\"", patcher); + Assert.Contains("\"_taaFrameParity\"", patcher); + Assert.Contains("\"_taaHistoryValid\"", patcher); + Assert.Contains("\"taaResolvedColorTexture\"", patcher); + Assert.Contains("\"taaResolvedGlowTexture\"", patcher); + Assert.Contains("\"TaaResolvedThisFrame\"", patcher); + } + + [Fact] + public void TaaResolveShaderPairExistsAndReadsHistoryAndCurrentColour() + { + string vsh = Read("sources/shaders/taa-resolve.vsh"); + string fsh = Read("sources/shaders/taa-resolve.fsh"); + + Assert.NotEmpty(vsh); + Assert.NotEmpty(fsh); + } + private static int Count(string source, string value) { int count = 0; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index b6e61d69..7bc0724a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..52f76d4 100644 +index 6edf0c9..777af61 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -102,7 +102,7 @@ index 6edf0c9..52f76d4 100644 private Logger logger; private int doResize; -@@ -93,10 +182,57 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -134,6 +134,20 @@ index 6edf0c9..52f76d4 100644 + + private bool TaaTargetsReady; + ++ // Optimum TAA resolve state (P2): which history slot is written this frame, ++ // whether the other slot holds a frame worth reading, and the textures the ++ // rest of the post chain reads instead of the jittered Primary attachments. ++ private int _taaFrameParity; ++ ++ private bool _taaHistoryValid; ++ ++ private int taaResolvedColorTexture; ++ ++ private int taaResolvedGlowTexture; ++ ++ /// True once this frame's TAA resolve ran; the post chain then reads the resolved textures. ++ public bool TaaResolvedThisFrame { get; private set; } ++ + private bool optimumTaaDisabled; + + // Optimum: GL keeps the clear colour in driver state and applies it at @@ -160,7 +174,7 @@ index 6edf0c9..52f76d4 100644 private bool serverRunning; private bool gamepause; -@@ -256,10 +392,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +406,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -184,7 +198,7 @@ index 6edf0c9..52f76d4 100644 get { return serverRunning; -@@ -278,11 +427,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,11 +441,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -212,7 +226,7 @@ index 6edf0c9..52f76d4 100644 GL.BindFramebuffer((FramebufferTarget)36160, 0); return; } -@@ -297,11 +462,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -297,11 +476,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -236,7 +250,7 @@ index 6edf0c9..52f76d4 100644 } public override bool GlErrorChecking { get; set; } -@@ -314,10 +491,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -314,10 +505,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } set { @@ -257,7 +271,7 @@ index 6edf0c9..52f76d4 100644 if (!supportsGlDebugMode) { throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -335,11 +522,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -335,11 +536,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } glDebugMode = value; } @@ -286,7 +300,7 @@ index 6edf0c9..52f76d4 100644 public override bool MouseGrabbed { -@@ -478,41 +681,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,41 +695,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -433,7 +447,7 @@ index 6edf0c9..52f76d4 100644 public void LogAndTestHardwareInfosStage1() { -@@ -533,10 +837,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -533,10 +851,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } @@ -471,7 +485,7 @@ index 6edf0c9..52f76d4 100644 logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +907,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -576,10 +921,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CheckGlError("testhwinfo"); } @@ -490,7 +504,7 @@ index 6edf0c9..52f76d4 100644 public override string GetFrameworkInfos() { -@@ -702,10 +1041,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,10 +1055,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -512,7 +526,7 @@ index 6edf0c9..52f76d4 100644 SupportsThickLines = (int)error != 1281; cpuCoreCount = Environment.ProcessorCount; } -@@ -796,10 +1146,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1160,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -536,7 +550,7 @@ index 6edf0c9..52f76d4 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1379,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1393,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -574,7 +588,7 @@ index 6edf0c9..52f76d4 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1422,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1436,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -594,7 +608,7 @@ index 6edf0c9..52f76d4 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1453,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1467,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -611,7 +625,7 @@ index 6edf0c9..52f76d4 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1498,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1512,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -644,7 +658,7 @@ index 6edf0c9..52f76d4 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1562,440 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1576,440 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -1085,7 +1099,7 @@ index 6edf0c9..52f76d4 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2027,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2041,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1100,7 +1114,7 @@ index 6edf0c9..52f76d4 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2054,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2068,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1117,7 +1131,7 @@ index 6edf0c9..52f76d4 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2099,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2113,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1156,7 +1170,7 @@ index 6edf0c9..52f76d4 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2312,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2326,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1209,7 +1223,7 @@ index 6edf0c9..52f76d4 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1567,12 +2485,83 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2499,83 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1293,7 +1307,7 @@ index 6edf0c9..52f76d4 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2580,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2594,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1322,7 +1336,7 @@ index 6edf0c9..52f76d4 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2614,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,21 +2628,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1401,7 +1415,7 @@ index 6edf0c9..52f76d4 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1636,10 +2699,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1636,10 +2713,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (RenderSSAO) { GL.ClearBuffer((ClearBuffer)6144, 2, new float[4] { 0f, 0f, 0f, 1f }); @@ -1416,7 +1430,7 @@ index 6edf0c9..52f76d4 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2737,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2751,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1457,7 +1471,7 @@ index 6edf0c9..52f76d4 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2780,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2794,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1524,7 +1538,7 @@ index 6edf0c9..52f76d4 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2850,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2864,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1546,7 +1560,7 @@ index 6edf0c9..52f76d4 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2874,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2888,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1573,7 +1587,7 @@ index 6edf0c9..52f76d4 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,15 +2899,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +2913,127 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1612,7 +1626,100 @@ index 6edf0c9..52f76d4 100644 transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; -@@ -1823,10 +2948,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + RenderFullscreenTriangle(screenQuad); + transparentcompose.Stop(); + } + ++ /// ++ /// Optimum TAA (P2): reprojects and blends the history into this frame's ++ /// history slot from the jittered Primary colour/glow, the motion attachment ++ /// and depth. Returns false when TAA is off or not ready, in which case the ++ /// post chain runs exactly as it does today. ++ /// ++ private bool RenderOptimumTaaResolve() ++ { ++ TaaResolvedThisFrame = false; ++ if (!TaaTargetsReady || !Vintagestory.API.Config.OptimumConfig.EffectiveTaa) ++ { ++ _taaHistoryValid = false; ++ return false; ++ } ++ ShaderProgram resolve = ShaderPrograms.TaaResolve; ++ if (resolve == null || resolve.LoadError) ++ { ++ _taaHistoryValid = false; ++ return false; ++ } ++ FrameBufferRef write = TaaHistory(_taaFrameParity); ++ FrameBufferRef read = TaaHistory(_taaFrameParity + 1); ++ if (write == null || read == null) ++ { ++ _taaHistoryValid = false; ++ return false; ++ } ++ ++ OptimumTemporalFrame frame = OptimumTemporal.Frame; ++ float[] projection = frame.GetProjection(EnumTemporalView.World); ++ double[] jittered = new double[16]; ++ for (int i = 0; i < 16; i++) ++ { ++ jittered[i] = projection[i]; ++ } ++ OptimumTemporalMath.ApplyProjectionJitter(jittered, frame.JitterPx.X, frame.JitterPx.Y, write.Width, write.Height); ++ float[] projectionJittered = new float[16]; ++ for (int i = 0; i < 16; i++) ++ { ++ projectionJittered[i] = (float)jittered[i]; ++ } ++ float[] viewProj = Mat4f.Mul(new float[16], projectionJittered, frame.CameraMatrixOrigin); ++ float[] invViewProj = Mat4f.Invert(new float[16], viewProj); ++ float[] prevViewProj = Mat4f.Mul(new float[16], frame.GetPrevProjection(EnumTemporalView.World), frame.PrevCameraMatrixOrigin); ++ bool reset = frame.Reset || !_taaHistoryValid || !frame.WasViewCaptured(EnumTemporalView.World) || invViewProj == null; ++ if (invViewProj == null) ++ { ++ invViewProj = Mat4f.Identity(new float[16]); ++ } ++ ++ GlToggleBlend(on: false); ++ GlDisableDepthTest(); ++ LoadFrameBuffer(write, write.ColorTextureIds[0]); ++ GlViewport(0, 0, write.Width, write.Height); ++ ++ resolve.Use(); ++ resolve.BindTexture2D("sceneTex", frameBuffers[0].ColorTextureIds[0], 0); ++ resolve.BindTexture2D("glowTex", frameBuffers[0].ColorTextureIds[1], 1); ++ resolve.BindTexture2D("motionTex", frameBuffers[0].ColorTextureIds[MotionAttachmentIndex], 2); ++ resolve.BindTexture2D("depthTex", frameBuffers[0].DepthTextureId, 3); ++ resolve.BindTexture2D("historyColor", read.ColorTextureIds[0], 4); ++ resolve.BindTexture2D("historyGlow", read.ColorTextureIds[1], 5); ++ resolve.BindTexture2D("historyDepth", read.ColorTextureIds[2], 6); ++ resolve.Uniform("renderSize", (float)write.Width, (float)write.Height); ++ resolve.Uniform("jitterPx", frame.JitterPx.X, frame.JitterPx.Y); ++ resolve.UniformMatrix("invViewProjJittered", invViewProj); ++ resolve.UniformMatrix("prevViewProj", prevViewProj); ++ resolve.UniformMatrix("viewMatrix", frame.CameraMatrixOrigin); ++ resolve.Uniform("cameraDelta", frame.CameraPosDelta); ++ resolve.Uniform("resetHistory", reset ? 1 : 0); ++ resolve.Uniform("blendAlpha", 0.1f); ++ resolve.Uniform("varianceGamma", 1.25f); ++ RenderFullscreenTriangle(screenQuad); ++ resolve.Stop(); ++ ++ taaResolvedColorTexture = write.ColorTextureIds[0]; ++ taaResolvedGlowTexture = write.ColorTextureIds[1]; ++ _taaHistoryValid = true; ++ _taaFrameParity ^= 1; ++ TaaResolvedThisFrame = true; ++ GlToggleBlend(on: true); ++ return true; ++ } ++ + public override void RenderPostprocessingEffects(float[] projectMatrix) + { + //IL_000f: Unknown result type (might be due to invalid IL or missing references) + //IL_0020: Unknown result type (might be due to invalid IL or missing references) + //IL_0189: Unknown result type (might be due to invalid IL or missing references) +@@ -1823,20 +3046,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1625,10 +1732,27 @@ index 6edf0c9..52f76d4 100644 + Vintagestory.API.Config.IOptimumGraphicsDevice optimumPostDevice = Vintagestory.API.Config.OptimumRender.Device; int x = ((NativeWindow)window).ClientSize.X; int y = ((NativeWindow)window).ClientSize.Y; ++ // Optimum TAA: resolve first, so bloom, god rays and the final input read ++ // the temporally stable image instead of the jittered one. ++ RenderOptimumTaaResolve(); ++ int postSceneTexture = TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0]; ++ int postGlowTexture = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1]; if (RenderBloom) { GlToggleBlend(on: false); -@@ -1848,45 +2978,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + LoadFrameBuffer(EnumFrameBuffer.FindBright); + ShaderProgramFindbright findbright = ShaderPrograms.Findbright; + findbright.Use(); +- findbright.ColorTex2D = frameBuffers[0].ColorTextureIds[0]; +- findbright.GlowTex2D = frameBuffers[0].ColorTextureIds[1]; ++ findbright.ColorTex2D = postSceneTexture; ++ findbright.GlowTex2D = postGlowTexture; + findbright.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; + findbright.ExtraBloom = ShaderUniforms.ExtraBloom; + RenderFullscreenTriangle(screenQuad); + findbright.Stop(); + ShaderProgramBlur blur = ShaderPrograms.Blur; +@@ -1848,45 +3081,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1663,8 +1787,10 @@ index 6edf0c9..52f76d4 100644 godrays.PlayerViewVector = ShaderUniforms.PlayerViewVector; godrays.Dusk = ShaderUniforms.Dusk; godrays.IGlobalTimeIn = (float)EllapsedMs / 1000f; - godrays.InputTexture2D = frameBuffers[0].ColorTextureIds[0]; - godrays.GlowParts2D = frameBuffers[0].ColorTextureIds[1]; +- godrays.InputTexture2D = frameBuffers[0].ColorTextureIds[0]; +- godrays.GlowParts2D = frameBuffers[0].ColorTextureIds[1]; ++ godrays.InputTexture2D = postSceneTexture; ++ godrays.GlowParts2D = postGlowTexture; RenderFullscreenTriangle(screenQuad); godrays.Stop(); - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); @@ -1688,7 +1814,7 @@ index 6edf0c9..52f76d4 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,11 +3055,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3158,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1697,13 +1823,25 @@ index 6edf0c9..52f76d4 100644 - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); } - if (RenderFXAA) +- if (RenderFXAA) ++ if (RenderFXAA && !TaaResolvedThisFrame) { LoadFrameBuffer(EnumFrameBuffer.Luma); ShaderProgramLuma luma = ShaderPrograms.Luma; -@@ -1935,11 +3075,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + luma.Use(); + luma.Scene2D = frameBuffers[0].ColorTextureIds[0]; + RenderFullscreenTriangle(screenQuad); + luma.Stop(); + } + else + { ++ // With TAA the FXAA define is off and Final reads the resolved image ++ // through the Luma target; the copy goes away once P5 sharpens here. + LoadFrameBuffer(EnumFrameBuffer.Luma); + ShaderProgramBlit blit = ShaderPrograms.Blit; blit.Use(); - blit.Scene2D = frameBuffers[0].ColorTextureIds[0]; +- blit.Scene2D = frameBuffers[0].ColorTextureIds[0]; ++ blit.Scene2D = postSceneTexture; RenderFullscreenTriangle(screenQuad); blit.Stop(); } @@ -1723,7 +1861,7 @@ index 6edf0c9..52f76d4 100644 } public override void RenderFinalComposition() -@@ -1953,13 +3102,26 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3207,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1752,7 +1890,14 @@ index 6edf0c9..52f76d4 100644 final.Use(); final.PrimaryScene2D = primaryScene2D; final.BloomParts2D = bloomParts2D; -@@ -1987,23 +3149,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +- final.GlowParts2D = frameBuffers[0].ColorTextureIds[1]; ++ final.GlowParts2D = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1]; + final.GodrayParts2D = godrayParts2D; + final.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; + if (RenderSSAO) + { + final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; +@@ -1987,23 +3254,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1793,7 +1938,7 @@ index 6edf0c9..52f76d4 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +3200,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,19 +3305,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -1914,7 +2059,7 @@ index 6edf0c9..52f76d4 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3360,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3465,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -1940,7 +2085,7 @@ index 6edf0c9..52f76d4 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3393,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3498,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -1962,7 +2107,7 @@ index 6edf0c9..52f76d4 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3422,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3527,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2060,7 +2205,7 @@ index 6edf0c9..52f76d4 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3521,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,36 +3626,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2149,7 +2294,7 @@ index 6edf0c9..52f76d4 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3637,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2233,33 +3742,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2221,7 +2366,7 @@ index 6edf0c9..52f76d4 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3715,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +3820,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2388,7 +2533,7 @@ index 6edf0c9..52f76d4 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +3885,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +3990,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2412,7 +2557,7 @@ index 6edf0c9..52f76d4 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +3914,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4019,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2450,7 +2595,7 @@ index 6edf0c9..52f76d4 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +3974,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4079,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2493,7 +2638,7 @@ index 6edf0c9..52f76d4 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4027,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4132,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2552,7 +2697,7 @@ index 6edf0c9..52f76d4 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4142,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4247,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2626,7 +2771,7 @@ index 6edf0c9..52f76d4 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4240,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4345,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2657,7 +2802,7 @@ index 6edf0c9..52f76d4 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4277,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4382,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2692,7 +2837,7 @@ index 6edf0c9..52f76d4 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4324,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4429,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2721,7 +2866,7 @@ index 6edf0c9..52f76d4 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4355,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4460,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2745,7 +2890,7 @@ index 6edf0c9..52f76d4 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4392,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4497,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -2762,7 +2907,7 @@ index 6edf0c9..52f76d4 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4444,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4549,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2807,7 +2952,7 @@ index 6edf0c9..52f76d4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4481,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4586,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2828,7 +2973,7 @@ index 6edf0c9..52f76d4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4500,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4605,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2849,7 +2994,7 @@ index 6edf0c9..52f76d4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4519,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4624,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2870,7 +3015,7 @@ index 6edf0c9..52f76d4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4538,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4643,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2891,7 +3036,7 @@ index 6edf0c9..52f76d4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4561,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4666,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2912,7 +3057,7 @@ index 6edf0c9..52f76d4 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4604,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4709,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -2938,7 +3083,7 @@ index 6edf0c9..52f76d4 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +4846,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +4951,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -2960,7 +3105,7 @@ index 6edf0c9..52f76d4 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5044,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5149,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -2983,7 +3128,7 @@ index 6edf0c9..52f76d4 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5118,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5223,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3032,7 +3177,7 @@ index 6edf0c9..52f76d4 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5188,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5293,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3064,7 +3209,7 @@ index 6edf0c9..52f76d4 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5218,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5323,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3090,7 +3235,7 @@ index 6edf0c9..52f76d4 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5566,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5671,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3120,7 +3265,7 @@ index 6edf0c9..52f76d4 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5620,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5725,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch index 0ca56f10..a8db90bc 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -index f19d524..5bda201 100644 +index f19d524..5fad806 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -@@ -40,10 +40,16 @@ public static class ShaderPrograms +@@ -40,10 +40,18 @@ public static class ShaderPrograms public static ShaderProgramEntityanimated Entityanimated; @@ -13,6 +13,8 @@ index f19d524..5bda201 100644 + public static ShaderProgram FsrRcas; + + public static ShaderProgram TaaDebug; ++ ++ public static ShaderProgram TaaResolve; + public static ShaderProgramFindbright Findbright; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index 04e92436..8d8f367b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..b2ad007 100644 +index 4a24e75..ff92e49 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..b2ad007 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,155 @@ public class ShaderRegistry +@@ -181,39 +183,156 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -22,6 +22,7 @@ index 4a24e75..b2ad007 100644 + RegisterOptimumShaderProgram("fsr-easu", ShaderPrograms.FsrEasu = new ShaderProgram()); + RegisterOptimumShaderProgram("fsr-rcas", ShaderPrograms.FsrRcas = new ShaderProgram()); + RegisterOptimumShaderProgram("taa-debug", ShaderPrograms.TaaDebug = new ShaderProgram()); ++ RegisterOptimumShaderProgram("taa-resolve", ShaderPrograms.TaaResolve = new ShaderProgram()); + } + + private static void RegisterOptimumShaderProgram(string name, ShaderProgram program) @@ -151,7 +152,7 @@ index 4a24e75..b2ad007 100644 + bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); + OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); + } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug) ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve) + { + shaderProgram.LoadError |= !compiled; + } @@ -179,20 +180,20 @@ index 4a24e75..b2ad007 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +414,11 @@ public class ShaderRegistry +@@ -296,11 +415,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) { Shader fragmentShader = program.FragmentShader; - fragmentShader.PrefixCode = fragmentShader.PrefixCode + "#define FXAA " + (ClientSettings.FXAA ? 1 : 0) + "\r\n"; -+ fragmentShader.PrefixCode = fragmentShader.PrefixCode + "#define FXAA " + (ClientSettings.FXAA && OptimumConfig.EffectiveRenderScale >= 1.0f ? 1 : 0) + "\r\n"; ++ fragmentShader.PrefixCode = fragmentShader.PrefixCode + "#define FXAA " + (ClientSettings.FXAA && OptimumConfig.EffectiveRenderScale >= 1.0f && !OptimumConfig.EffectiveTaa ? 1 : 0) + "\r\n"; Shader fragmentShader2 = program.FragmentShader; fragmentShader2.PrefixCode = fragmentShader2.PrefixCode + "#define SSAOLEVEL " + ClientSettings.SSAOQuality + "\r\n"; Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +451,27 @@ public class ShaderRegistry +@@ -333,10 +452,27 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/sources/shaders/taa-resolve.fsh b/sources/shaders/taa-resolve.fsh new file mode 100644 index 00000000..91f2b57f --- /dev/null +++ b/sources/shaders/taa-resolve.fsh @@ -0,0 +1,177 @@ +#version 330 core +// Optimum TAA resolve (P2). One fullscreen pass per frame after all scene +// geometry: reprojects last frame's history by the motion attachment (or by +// camera motion from depth where nothing wrote a vector), rectifies it +// against the current 3x3 neighbourhood in YCoCg, and blends. Writes the new +// history: colour (RGBA16F, alpha = scene alpha), glow (RGBA8) and linear +// view depth (R32F) for next frame's disocclusion test. +// +// Conventions (see TAA-PLAN.md): motion = previousPixel - currentUnjitteredPixel +// in render pixels; a raster pixel centre sits at unjittered position +// centre - jitterPx; history is stored at unjittered pixel centres. + +uniform sampler2D sceneTex; // Primary colour 0, jittered +uniform sampler2D glowTex; // Primary colour 1, jittered +uniform sampler2D motionTex; // rg mv px, b reactive, a writerDepth (0 = unwritten) +uniform sampler2D depthTex; // Primary depth, [0,1], 0 = near +uniform sampler2D historyColor; // previous resolve colour +uniform sampler2D historyGlow; // previous resolve glow +uniform sampler2D historyDepth; // previous resolve linear depth + +uniform vec2 renderSize; +uniform vec2 jitterPx; // this frame's raster displacement +uniform mat4 invViewProjJittered;// raster NDC -> camera-relative world (this frame) +uniform mat4 prevViewProj; // camera-relative world (previous camera) -> previous unjittered clip +uniform mat4 viewMatrix; // camera-relative world -> view (for linear depth) +uniform vec3 cameraDelta; // currentCameraPos - previousCameraPos +uniform int resetHistory; +uniform float blendAlpha; // 0.1 default +uniform float varianceGamma; // 1.25 default + +in vec2 texCoord; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +layout(location = 2) out vec4 outDepth; + +vec3 rgbToYCoCg(vec3 c) { + return vec3(0.25 * c.r + 0.5 * c.g + 0.25 * c.b, + 0.5 * c.r - 0.5 * c.b, + -0.25 * c.r + 0.5 * c.g - 0.25 * c.b); +} + +vec3 yCoCgToRgb(vec3 c) { + return vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z); +} + +// Intersects the history colour with the neighbourhood box (clip, not clamp). +vec3 clipToBox(vec3 boxMin, vec3 boxMax, vec3 history) { + vec3 centre = 0.5 * (boxMax + boxMin); + vec3 extent = 0.5 * (boxMax - boxMin) + 1e-5; + vec3 offset = history - centre; + vec3 unit = abs(offset / extent); + float maxUnit = max(unit.x, max(unit.y, unit.z)); + return maxUnit > 1.0 ? centre + offset / maxUnit : history; +} + +// 9-tap Catmull-Rom on a bilinear sampler (the usual 5-tap optimisation would +// drop corners; keep the full quality for history colour). +vec4 sampleCatmullRom(sampler2D tex, vec2 uv) { + vec2 samplePos = uv * renderSize; + vec2 texPos1 = floor(samplePos - 0.5) + 0.5; + vec2 f = samplePos - texPos1; + vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f)); + vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f); + vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f)); + vec2 w3 = f * f * (-0.5 + 0.5 * f); + vec2 w12 = w1 + w2; + vec2 offset12 = w2 / w12; + vec2 texPos0 = (texPos1 - 1.0) / renderSize; + vec2 texPos3 = (texPos1 + 2.0) / renderSize; + vec2 texPos12 = (texPos1 + offset12) / renderSize; + vec4 result = vec4(0.0); + result += texture(tex, vec2(texPos0.x, texPos0.y)) * w0.x * w0.y; + result += texture(tex, vec2(texPos12.x, texPos0.y)) * w12.x * w0.y; + result += texture(tex, vec2(texPos3.x, texPos0.y)) * w3.x * w0.y; + result += texture(tex, vec2(texPos0.x, texPos12.y)) * w0.x * w12.y; + result += texture(tex, vec2(texPos12.x, texPos12.y)) * w12.x * w12.y; + result += texture(tex, vec2(texPos3.x, texPos12.y)) * w3.x * w12.y; + result += texture(tex, vec2(texPos0.x, texPos3.y)) * w0.x * w3.y; + result += texture(tex, vec2(texPos12.x, texPos3.y)) * w12.x * w3.y; + result += texture(tex, vec2(texPos3.x, texPos3.y)) * w3.x * w3.y; + return max(result, vec4(0.0)); +} + +float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } + +void main(void) +{ + vec2 invSize = 1.0 / renderSize; + ivec2 pixel = ivec2(clamp(texCoord * renderSize, vec2(0.0), renderSize - vec2(1.0))); + vec2 pixelCentre = vec2(pixel) + 0.5; + + // ---- current frame: 3x3 neighbourhood, un-jittered reconstruction and statistics + vec4 centreSample = texelFetch(sceneTex, pixel, 0); + vec4 filtered = vec4(0.0); + float filteredWeight = 0.0; + vec3 m1 = vec3(0.0), m2 = vec3(0.0); + vec3 boxMin = vec3(1e9), boxMax = vec3(-1e9); + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + { + ivec2 p = clamp(pixel + ivec2(x, y), ivec2(0), ivec2(renderSize) - ivec2(1)); + vec4 c = texelFetch(sceneTex, p, 0); + vec3 ycc = rgbToYCoCg(c.rgb); + m1 += ycc; m2 += ycc * ycc; + boxMin = min(boxMin, ycc); boxMax = max(boxMax, ycc); + // Reconstruct at the unjittered pixel centre: this tap sits at + // (x, y) + jitter relative to it. Blackman-Harris over radius ~1. + vec2 d = vec2(x, y) + jitterPx; + float r = length(d); + float w = r < 1.0 ? (0.35875 + 0.48829 * cos(3.14159265 * r) + 0.14128 * cos(2.0 * 3.14159265 * r) + 0.01168 * cos(3.0 * 3.14159265 * r)) : 0.0; + filtered += c * w; filteredWeight += w; + } + vec4 current = filteredWeight > 1e-4 ? filtered / filteredWeight : centreSample; + current = max(current, vec4(0.0)); + vec3 mu = m1 / 9.0; + vec3 sigma = sqrt(max(m2 / 9.0 - mu * mu, vec3(0.0))); + vec3 clipMin = max(boxMin, mu - varianceGamma * sigma); + vec3 clipMax = min(boxMax, mu + varianceGamma * sigma); + + // ---- depth and linear view depth of this pixel + float depth = texelFetch(depthTex, pixel, 0).r; + vec2 ndc = pixelCentre * invSize * 2.0 - 1.0; + vec4 worldH = invViewProjJittered * vec4(ndc, depth * 2.0 - 1.0, 1.0); + vec3 world = worldH.xyz / max(abs(worldH.w), 1e-6) * sign(worldH.w); + float linearDepth = -(viewMatrix * vec4(world, 1.0)).z; + + // ---- motion: written vector when its depth matches, else camera reprojection + vec4 motion = texelFetch(motionTex, pixel, 0); + float reactive = clamp(motion.b, 0.0, 1.0); + vec2 currentUnjittered = pixelCentre - jitterPx; + vec2 mv; + bool written = motion.a > 0.0 && abs(motion.a - depth) < 1e-4; + if (written) + { + mv = motion.rg; + } + else + { + vec4 prevClip = prevViewProj * vec4(world + cameraDelta, 1.0); + if (prevClip.w <= 1e-6) { outColor = current; outGlow = texelFetch(glowTex, pixel, 0); outDepth = vec4(linearDepth); return; } + vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize; + mv = prevPixel - currentUnjittered; + } + vec2 historyUv = (currentUnjittered + mv) * invSize; + + // ---- history sample and rejection + float alpha = blendAlpha; + bool offscreen = any(lessThan(historyUv, vec2(0.0))) || any(greaterThan(historyUv, vec2(1.0))); + if (resetHistory != 0 || offscreen) alpha = 1.0; + + vec4 history = sampleCatmullRom(historyColor, historyUv); + vec4 historyGlowSample = texture(historyGlow, historyUv); + float historyLinear = texture(historyDepth, historyUv).r; + // Disocclusion: the surface seen last frame at that location must be at a + // comparable distance. Tolerance grows with distance; camera translation + // along the view axis is covered by the relative term. + float depthTolerance = 0.5 + 0.08 * linearDepth; + if (abs(historyLinear - linearDepth) > depthTolerance) alpha = max(alpha, 0.5); + alpha = max(alpha, reactive); + + // ---- rectify and blend in YCoCg with luminance weighting + vec3 histYcc = clipToBox(clipMin, clipMax, rgbToYCoCg(history.rgb)); + vec3 curYcc = rgbToYCoCg(current.rgb); + float wCur = alpha / (1.0 + curYcc.x); + float wHist = (1.0 - alpha) / (1.0 + histYcc.x); + vec3 resolvedYcc = (curYcc * wCur + histYcc * wHist) / max(wCur + wHist, 1e-5); + vec3 resolved = max(yCoCgToRgb(resolvedYcc), vec3(0.0)); + float resolvedAlpha = mix(history.a, current.a, alpha); + + vec4 glow = texelFetch(glowTex, pixel, 0); + vec4 resolvedGlow = mix(historyGlowSample, glow, max(alpha, 0.2)); + + outColor = vec4(resolved, resolvedAlpha); + outGlow = resolvedGlow; + outDepth = vec4(linearDepth); +} diff --git a/sources/shaders/taa-resolve.vsh b/sources/shaders/taa-resolve.vsh new file mode 100644 index 00000000..252d666d --- /dev/null +++ b/sources/shaders/taa-resolve.vsh @@ -0,0 +1,11 @@ +#version 330 core + +out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); +} From 85a710973fcff2ff1ea634a76a2fe4cea8030e35 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 17:43:40 +0200 Subject: [PATCH 018/226] fix(taa): close the P2 review defects and anchor the history lookup at the pixel centre Unjitter kernel sign, linear history filtering on both paths, half-float aware writer validity in window depth, GL motion-attachment clear, history invalidation on framebuffer rebuilds, state restore after the resolve, no per-frame re-attach, full disocclusion rejection, glow blended with the colour alpha. The remaining per-frame wobble was the history lookup anchored at the jittered sample point instead of the pixel centre. --- .claude/skills/run-optimum/SKILL.md | 1 + Optimum.Patcher/Program.cs | 3 + .../TaaResolveTests.cs | 276 ++++++++++++++++++ TAA-PLAN.md | 28 +- .../ClientPlatformWindows.cs.patch | 217 ++++++++++---- sources/shaders/taa-resolve.fsh | 74 ++++- 6 files changed, 516 insertions(+), 83 deletions(-) diff --git a/.claude/skills/run-optimum/SKILL.md b/.claude/skills/run-optimum/SKILL.md index 5457e808..ed6322ad 100644 --- a/.claude/skills/run-optimum/SKILL.md +++ b/.claude/skills/run-optimum/SKILL.md @@ -17,6 +17,7 @@ description: Build, deploy, launch, stop and screenshot the Optimum Vintage Stor classic one) and fix that before judging pixels. 6. Screenshot: `scripts/dev/screenshot.sh /tmp/vulkan.png`, then Read the PNG and describe what you see. For a backend comparison take both shots from the same save and camera. +6b. Daylight for comparable screenshots: focus the window, `xdotool key t`, `xdotool type '/time set 12:00'`, `xdotool key Return` (chat opens with T, sends with Enter); for fog-free comparisons also send `/weather set clearsky` and `/weather setprecip -1` the same way; wait 3 s before the screenshot. 7. Stop: `scripts/dev/kill-client.sh` immediately after the check; the user does not want it left running. Restore `ModConfig/optimum.json` `Renderer` to what the user had. Gotchas: `ssaa` 0.5 in clientsettings halves the render resolution on both backends; the random diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 3abb28d0..a145948e 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -534,6 +534,9 @@ new("Vintagestory.Client.NoObf.ClientPlatformAbstract", "DisposeIndexBuffer", 0), // FSR: allocate the native intermediate and replace the final bilinear blit. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "SetupDefaultFrameBuffers", 0), + // TAA P2: a framebuffer rebuild throws the history away - the flag and the + // temporal contract's reset reason are both set where the swap completes. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RebuildFrameBuffers", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BlitPrimaryToDefault", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisableOptimumFsr", 1), // R4: pass the configured god-rays sample limit to the post-process shader. diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index e71dd407..87a72970 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -328,6 +328,238 @@ public unsafe void AnOutlierHistoryValueIsClippedTowardTheNeighbourhood() } } + /// + /// With non-zero jitter, the per-pixel Blackman-Harris reconstruction in + /// taa-resolve.fsh (the filtered/filteredWeight loop) is + /// supposed to undo the raster displacement: a scene that was rendered + /// with jitterPx=(+0.5,0) - meaning its texel at raster column x holds + /// the unjittered scene's value at (x + 0.5) - jitterPx, per the file's + /// own convention comment - should reconstruct to the same edge position + /// as a scene rendered with zero jitter that already holds the unjittered + /// values directly. Both runs use resetHistory=1 so the output is exactly + /// current, isolating the reconstruction step from history/blend. + /// + /// The edge is encoded as one-pixel-wide linear coverage ramp (not a hard + /// step) so a subpixel shift is representable in a texel grid at all; + /// the crossing point is then recovered from the *output* by a linear + /// interpolation against the 0.5 threshold - a "column-average centroid" + /// since the scene is flat in y. + /// + [SkippableFact] + public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const float edgeCentre = 16f; + // One-pixel-wide linear coverage ramp around `pos`, standing in + // for a hard edge at edgeCentre that a discrete texel grid can + // still represent a subpixel shift of. + float EdgeAt(float pos) => Math.Clamp(pos - edgeCentre + 0.5f, 0f, 1f); + + float centroidJitterZero = ResolveEdgeCentroid( + context!, commands, textures, state, targets, pipelines, program, descriptors, + jitterPx: (0f, 0f), + // Baseline: texel at column x already holds the unjittered + // value at its own pixel centre. + sceneAt: x => EdgeAt(x + 0.5f)); + + float centroidJitteredHalfPx = ResolveEdgeCentroid( + context!, commands, textures, state, targets, pipelines, program, descriptors, + jitterPx: (0.5f, 0f), + // Jittered render: texel at column x holds the unjittered + // value at (x + 0.5) - jitterPx, per the file's convention. + sceneAt: x => EdgeAt(x + 0.5f - 0.5f)); + + _output.WriteLine($"centroid jitter=0: {centroidJitterZero}, centroid jitter=+0.5px: {centroidJitteredHalfPx}"); + Assert.True(Math.Abs(centroidJitteredHalfPx - centroidJitterZero) < 0.25f, + $"reconstructed edge moved by {Math.Abs(centroidJitteredHalfPx - centroidJitterZero)}px " + + "with the jitter; it should not move at all"); + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// One resolve, reading back the reconstructed edge's crossing column + /// (0.5-threshold linear interpolation across the column averages) for + /// . + /// + private static unsafe float ResolveEdgeCentroid( + VulkanContext context, VulkanCommands commands, TextureManager textures, GlStateTracker state, + RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, + DescriptorCache descriptors, (float x, float y) jitterPx, Func sceneAt) + { + var inputs = CreateInputSet(textures); + UploadRgba16F(textures, inputs.SceneTex, + (x, _) => sceneAt(x), (x, _) => sceneAt(x), (x, _) => sceneAt(x), (_, _) => 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + UploadFlatRgba16F(textures, inputs.HistoryColor, 0f, 0f, 0f, 1f); + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.HistoryDepth, 0.5f); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + var uniforms = new TaaUniforms { ResetHistory = 1, JitterPx = { [0] = jitterPx.x, [1] = jitterPx.y } }; + + ResolveOnce(context, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context, commands, textures, output.Color, 8); + var columnAverage = new float[Size]; + for (int x = 0; x < Size; x++) + { + float sum = 0f; + for (int y = 0; y < Size; y++) sum += ReadHalf(colorBytes, x, y, 0, 8); + columnAverage[x] = sum / Size; + } + return FindThresholdCrossing(columnAverage, 0.5f); + } + + /// + /// With LINEAR history sampling and a uniform +0.5px motion, a + /// one-texel-wide bright column in historyGlow lands, in texel + /// space, exactly on the boundary between two texels for two adjacent + /// output columns: raster column x0 samples 50% texel x0 / 50% texel + /// x0+1, and column x0-1 samples 50% texel x0-1 / 50% texel x0. Both + /// should read half the bright value if - and only if - the device is + /// actually doing bilinear filtering on that sampler, not point + /// sampling. glowTex's resolve path (mix(historyGlowSample, + /// glow, alpha)) has no neighbourhood clip of its own, unlike colour, + /// so it isolates the sampler behaviour cleanly. + /// + [SkippableFact] + public unsafe void LinearHistorySamplingSpreadsAOnePixelLineOverTwoColumns() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const int brightColumn = 16; + var inputs = CreateInputSet(textures); + UploadFlatRgba16F(textures, inputs.SceneTex, 0f, 0f, 0f, 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + // Written +0.5px motion (matches depth), so historyUv reads + // (pixelCentre + 0.5) * invSize - a half-texel shift. + UploadFlatRgba16F(textures, inputs.MotionTex, 0.5f, 0f, 0f, 0.5f); + UploadFlatRgba16F(textures, inputs.HistoryColor, 0f, 0f, 0f, 1f); + UploadRgba8(textures, inputs.HistoryGlow, + (x, _) => x == brightColumn ? (byte)255 : (byte)0, + (x, _) => x == brightColumn ? (byte)255 : (byte)0, + (x, _) => x == brightColumn ? (byte)255 : (byte)0, + (_, _) => (byte)255); + UploadFlatR32F(textures, inputs.HistoryDepth, 0.5f); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + // Heavy history weight so resolvedGlow ~= historyGlowSample. + var uniforms = new TaaUniforms { ResetHistory = 0, BlendAlpha = 0.02f }; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] glowBytes = ReadTextureBytes(context!, commands, textures, output.Glow, 4); + float below = ReadByteChannel(glowBytes, brightColumn - 1, (int)Size / 2, 0); + float at = ReadByteChannel(glowBytes, brightColumn, (int)Size / 2, 0); + float farBackground = ReadByteChannel(glowBytes, brightColumn - 8, (int)Size / 2, 0); + + _output.WriteLine($"column {brightColumn - 1}={below}, column {brightColumn}={at}, background={farBackground}"); + + // Both straddling columns should read roughly half the bright + // value - not one at full brightness and the other at zero, + // which is what point/nearest sampling would produce. + Assert.InRange(below, 0.30f, 0.70f); + Assert.InRange(at, 0.30f, 0.70f); + Assert.True(Math.Abs(below - at) < 0.15f, + $"the two straddling columns should read close to equal (bilinear midpoint), got {below} vs {at}"); + Assert.True(farBackground < 0.1f, "a column away from the line should stay near background"); + + ValidationAssert.NoErrors(messages); + } + } + + /// + /// A NaN anywhere in the history colour sample must be treated exactly + /// like resetHistory=1: the shader's own comment says NaN "survives any + /// weighted blend, poisoning the pixel forever", so it is detected and + /// swapped for the current frame's values with full current weight. Here + /// resetHistory stays 0 and blendAlpha is a normal 0.1 - only the NaN + /// planted in the history colour texture should force the reset. + /// + [SkippableFact] + public unsafe void NanInHistoryIsTreatedAsAReset() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const float currentR = 0.65f, currentG = 0.4f, currentB = 0.25f; + var inputs = CreateInputSet(textures); + UploadFlatRgba16F(textures, inputs.SceneTex, currentR, currentG, currentB, 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + // NaN in history colour - nothing else in the history is broken. + UploadFlatRgba16F(textures, inputs.HistoryColor, float.NaN, float.NaN, float.NaN, 1f); + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.HistoryDepth, 0.5f); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + var uniforms = new TaaUniforms { ResetHistory = 0, BlendAlpha = 0.1f }; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + float r = ReadHalf(colorBytes, x, y, 0, 8); + float g = ReadHalf(colorBytes, x, y, 1, 8); + float b = ReadHalf(colorBytes, x, y, 2, 8); + Assert.False(float.IsNaN(r) || float.IsNaN(g) || float.IsNaN(b), + $"NaN leaked into the output at ({x},{y})"); + Assert.InRange(r, currentR - 0.02f, currentR + 0.02f); + Assert.InRange(g, currentG - 0.02f, currentG + 0.02f); + Assert.InRange(b, currentB - 0.02f, currentB + 0.02f); + } + + ValidationAssert.NoErrors(messages); + } + } + // ------------------------------------------------------------------ setup private static ShaderProgramResources LoadProgram( @@ -621,6 +853,26 @@ private static unsafe void UploadFlatRgba8( } } + private static unsafe void UploadRgba8( + TextureManager textures, int textureId, + Func r, Func g, Func b, Func a) + { + var data = new byte[Size * Size * 4]; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + int i = (y * (int)Size + x) * 4; + data[i] = r(x, y); + data[i + 1] = g(x, y); + data[i + 2] = b(x, y); + data[i + 3] = a(x, y); + } + fixed (byte* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 4); + } + } + private static unsafe void UploadFlatR32F(TextureManager textures, int textureId, float value) { var data = new float[Size * Size]; @@ -666,6 +918,30 @@ private static float ReadHalf(byte[] data, int x, int y, int channel, int bytesP return (float)BitConverter.ToHalf(data, offset); } + private static float ReadByteChannel(byte[] data, int x, int y, int channel) => + data[(y * (int)Size + x) * 4 + channel] / 255f; + + /// + /// Linear interpolation between the two samples of a monotonic-ish + /// array that straddle , returning the + /// fractional index where the crossing happens. + /// + private static float FindThresholdCrossing(float[] values, float threshold) + { + for (int i = 1; i < values.Length; i++) + { + bool crosses = (values[i - 1] < threshold && values[i] >= threshold) + || (values[i - 1] > threshold && values[i] <= threshold); + if (crosses) + { + float denom = values[i] - values[i - 1]; + float t = Math.Abs(denom) > 1e-6f ? (threshold - values[i - 1]) / denom : 0.5f; + return (i - 1) + t; + } + } + throw new InvalidOperationException("no threshold crossing found"); + } + /// Average red channel over columns [startX, endX) across every row. private static float AverageRed(byte[] colorBytes, int startX, int endX) { diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 91bd72d6..1eb7b15a 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -94,10 +94,16 @@ never jittered. contract records format and "0 = near" convention. Previous linear depth is an R32F resolve output. - Motion attachment: RGBA16F on Primary at `MV_LOCATION` (2 without SSAO, 4 with; the OIT layer pass uses six outputs on Transparent, which is untouched). `rg = mv`, `b = reactive`, `a = writerDepth` - (NDC depth at write time). The resolve treats a pixel as validly written only when `a` matches the - final depth buffer within tolerance; otherwise it uses the camera-motion fallback (static-surface - reprojection from depth, infinite-direction reprojection where depth == 1). This defines behaviour - for unknown writers, mod geometry and sky without relying on undefined unwritten-output contents. + = **window depth in [0,1]** at write time - the same space as the depth attachment the resolve + compares it against (`gl_FragCoord.z`), never NDC depth. The resolve treats a pixel as validly + written only when `a` matches the final depth buffer within + `abs(motion.a - depth) <= max(2e-4, 8e-4 * depth)`. The tolerance is half-float aware: the + attachment is RGBA16F, whose ULP near 1.0 is already ~5e-4, so a fixed absolute epsilon rejects + every legitimate distant writer; the relative term covers precision and the floor covers depths + near the near plane. Where the test fails the resolve uses the camera-motion fallback + (static-surface reprojection from depth, infinite-direction reprojection where depth == 1). This + defines behaviour for unknown writers, mod geometry and sky without relying on undefined + unwritten-output contents. - Blend state: passes that blend colour (particle cubes, `SystemRenderParticles.cs:132`) set the motion attachment to replace blending via `SetBlendFuncSeparate(MV_LOCATION, 1, 0, 1, 0)` (the seam already exposes per-attachment blend; OIT uses it). Fullscreen resolve/sharpen passes set @@ -239,6 +245,20 @@ finalizer thread by `DeleteUniformBuffer` while the render thread reads it; pre- colour and nearest for depth/validity; explicit blend/depth/viewport state. Luma aliases the resolved colour; bloom/god rays/Final rebound to resolved textures. This makes every later producer's failure visible instead of being confused with raw jitter differences. +- Ordering in P2 only: the resolve runs at the top of `RenderPostprocessingEffects`, which is + *before* the SSAO pass, not after it as Decision 1 requires. SSAO therefore stays exactly where it + is - computed from the jittered G-buffer and consumed by Final from `frameBuffers[14]` - and the + aux target's `b` (SSAO) channel is allocated but written as zero and read by nobody. Resolving + SSAO temporally, and with it moving the SSAO pass in front of the resolve so Final reads a + resolved occlusion term, is deferred: it needs the SSAO output routed through the resolve's MRT + and Final rebound, which is a separate change from getting colour and glow stable. P2 rebinds only + colour and glow (bloom, god rays, Final's `PrimaryScene2D`/`GlowParts2D`); Final's `SsaoScene2D` + is untouched. +- History targets are LINEAR-filtered on colour and glow (the reprojected read is fractional) and + NEAREST on linear depth (interpolating across a silhouette invents a depth on neither surface), + on both the GL and the device path. A framebuffer rebuild invalidates the history and raises + `EnumTemporalResetReason.Resize`; the resolve additionally treats a NaN/Inf history sample as a + reset, because freshly allocated slots hold undefined contents and NaN survives any blend. - Tests: GPU harness on `WorldRenderPathTests` with synthetic inputs (static convergence, known offset reprojection, outlier clip, reset); coverage test for ordering and FXAA-off; in-game the whole scene converges with camera-only motion vectors. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 7bc0724a..38cf84d2 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..777af61 100644 +index 6edf0c9..25f5a1b 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -504,7 +504,7 @@ index 6edf0c9..777af61 100644 public override string GetFrameworkInfos() { -@@ -702,10 +1055,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1055,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -526,7 +526,28 @@ index 6edf0c9..777af61 100644 SupportsThickLines = (int)error != 1281; cpuCoreCount = Environment.ProcessorCount; } -@@ -796,10 +1160,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + + public override void RebuildFrameBuffers() + { ++ // Mono.Cecil transplant. + if (!ShaderRegistry.SupressShaderAndBufferReloads) + { + List buffers = frameBuffers; + List list = SetupDefaultFrameBuffers(); + frameBuffers = list; + DisposeFrameBuffers(buffers); ++ // Optimum TAA: the setup bodies already do this, but the textures the ++ // old history slots owned are only released here - state this at the ++ // point the swap completes so no path can reach a resolve holding a ++ // disposed read slot. ++ _taaHistoryValid = false; ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.Resize); + } + } + + private void Window_FileDrop(FileDropEventArgs e) + { +@@ -796,10 +1167,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -550,7 +571,7 @@ index 6edf0c9..777af61 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1393,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1400,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -588,7 +609,7 @@ index 6edf0c9..777af61 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1436,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1443,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -608,7 +629,7 @@ index 6edf0c9..777af61 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1467,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1474,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -625,7 +646,7 @@ index 6edf0c9..777af61 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1512,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1519,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -658,7 +679,7 @@ index 6edf0c9..777af61 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1576,440 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1583,457 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -924,6 +945,13 @@ index 6edf0c9..777af61 100644 + screenQuad = UploadMesh(quadData); + + CurrentFrameBufferKeepVw = (OffscreenBuffer ? list[0] : null); ++ // Optimum TAA: the history slots this build just allocated hold ++ // undefined contents, and the old ones are about to be disposed. Both ++ // the local flag and the temporal contract have to know, because the ++ // flag only guards our own resolve while the reset reason is what every ++ // other temporal consumer (vendor upscalers later) reads. ++ _taaHistoryValid = false; ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.Resize); + logger.Notification("(Re-)loaded frame buffers on the Optimum device"); + return list; + } @@ -1055,6 +1083,16 @@ index 6edf0c9..777af61 100644 + target.ColorTextureIds[1] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + target.ColorTextureIds[2] = device.CreateTexture2DRaw(width, height, OptimumGlR32f, IntPtr.Zero, 4); ++ // Optimum TAA: the resolve reprojects the history by a fractional pixel ++ // offset, so colour (Catmull-Rom taps) and glow (a plain bilinear fetch) ++ // must filter LINEAR; sampling them NEAREST snaps the reprojection to ++ // whole pixels and the history never converges. Linear depth stays ++ // NEAREST - interpolating across a silhouette invents a depth that is on ++ // neither surface and defeats the disocclusion test. Clamp to edge on ++ // all three, matching CreateOptimumHistoryTargetGl. ++ SetupOptimumTextureSampler(device, target.ColorTextureIds[0], 9729, 33071); ++ SetupOptimumTextureSampler(device, target.ColorTextureIds[1], 9729, 33071); ++ SetupOptimumTextureSampler(device, target.ColorTextureIds[2], 9728, 33071); + for (int attachment = 0; attachment < 3; attachment++) + { + device.AttachTexture(target.FboId, @@ -1099,7 +1137,7 @@ index 6edf0c9..777af61 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2041,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2065,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1114,7 +1152,7 @@ index 6edf0c9..777af61 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2068,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2092,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1131,7 +1169,7 @@ index 6edf0c9..777af61 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2113,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2137,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1170,7 +1208,7 @@ index 6edf0c9..777af61 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2326,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2350,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1223,7 +1261,22 @@ index 6edf0c9..777af61 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1567,12 +2499,83 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2504,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + else + { + CurrentFrameBufferKeepVw = null; + GL.DrawBuffer((DrawBufferMode)1029); + } ++ // Optimum TAA: freshly allocated history slots hold undefined contents. ++ // See the same pair in SetupOptimumFrameBuffers. ++ _taaHistoryValid = false; ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.Resize); + logger.Notification("(Re-)loaded frame buffers"); + return list; + } + + private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) +@@ -1567,12 +2527,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1248,22 +1301,28 @@ index 6edf0c9..777af61 100644 + + GL.BindTexture((TextureTarget)3553, target.ColorTextureIds[0]); + GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, width, height, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ // LINEAR: the resolve reads this history at a fractional reprojected ++ // offset (Catmull-Rom over a bilinear sampler). See the device path. ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, target.ColorTextureIds[0], 0); + + GL.BindTexture((TextureTarget)3553, target.ColorTextureIds[1]); + GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, width, height, 0, (PixelFormat)6408, (PixelType)5121, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ // LINEAR for the same reason as attachment 0; the glow history is read ++ // with a plain bilinear texture() at the reprojected uv. ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, target.ColorTextureIds[1], 0); + + GL.BindTexture((TextureTarget)3553, target.ColorTextureIds[2]); + GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)OptimumGlR32f, width, height, 0, (PixelFormat)6403, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ // NEAREST: an interpolated linear depth across a silhouette belongs to ++ // neither surface and would defeat the disocclusion test. + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); @@ -1307,7 +1366,7 @@ index 6edf0c9..777af61 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2594,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2628,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1336,7 +1395,7 @@ index 6edf0c9..777af61 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2628,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,21 +2662,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1415,7 +1474,7 @@ index 6edf0c9..777af61 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1636,10 +2713,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1636,10 +2747,36 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (RenderSSAO) { GL.ClearBuffer((ClearBuffer)6144, 2, new float[4] { 0f, 0f, 0f, 1f }); @@ -1423,14 +1482,36 @@ index 6edf0c9..777af61 100644 } + if (MotionAttachmentIndex >= 0) + { ++ // Optimum TAA: glClearBufferfv's second argument is a DRAW BUFFER ++ // index, not an attachment index, and the motion attachment is ++ // deliberately outside Primary's default draw-buffer set (only a ++ // writer that opts in enables it - see SetupDefaultFrameBuffers). ++ // Clearing it therefore needs a draw-buffer set that contains it, ++ // otherwise the call is an INVALID_VALUE no-op and the motion ++ // attachment keeps last frame's vectors while the device path ++ // clears it to 0. MotionAttachmentIndex is also the size of the ++ // default set (2 without the SSAO G-buffer, 4 with it), so the ++ // set restored below is exactly the one the setup left bound. ++ DrawBuffersEnum[] optimumMotionDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1]; ++ for (int optimumDb = 0; optimumDb <= MotionAttachmentIndex; optimumDb++) ++ { ++ optimumMotionDrawBuffers[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); ++ } ++ GL.DrawBuffers(optimumMotionDrawBuffers.Length, optimumMotionDrawBuffers); + GL.ClearBuffer((ClearBuffer)6144, MotionAttachmentIndex, new float[4]); ++ DrawBuffersEnum[] optimumRestoreDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex]; ++ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) ++ { ++ optimumRestoreDrawBuffers[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); ++ } ++ GL.DrawBuffers(optimumRestoreDrawBuffers.Length, optimumRestoreDrawBuffers); + } float num2 = 1f; GL.ClearBuffer((ClearBuffer)6145, 0, ref num2); break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2751,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2807,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1471,7 +1552,7 @@ index 6edf0c9..777af61 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2794,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2850,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1538,7 +1619,7 @@ index 6edf0c9..777af61 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2864,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2920,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1560,7 +1641,7 @@ index 6edf0c9..777af61 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2888,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2944,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1587,7 +1668,7 @@ index 6edf0c9..777af61 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +2913,127 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +2969,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1682,8 +1763,12 @@ index 6edf0c9..777af61 100644 + + GlToggleBlend(on: false); + GlDisableDepthTest(); -+ LoadFrameBuffer(write, write.ColorTextureIds[0]); -+ GlViewport(0, 0, write.Width, write.Height); ++ // The history slot already owns all three of its attachments, so bind it ++ // as it is: LoadFrameBuffer(write, texture) would re-attach colour 0 to ++ // the framebuffer every single frame, which is a pipeline/render-pass ++ // invalidation on the device path for no change at all. The ++ // CurrentFrameBuffer setter binds the target and sets the viewport. ++ CurrentFrameBuffer = write; + + resolve.Use(); + resolve.BindTexture2D("sceneTex", frameBuffers[0].ColorTextureIds[0], 0); @@ -1710,7 +1795,13 @@ index 6edf0c9..777af61 100644 + _taaHistoryValid = true; + _taaFrameParity ^= 1; + TaaResolvedThisFrame = true; ++ // Restore everything this pass changed, symmetrically: the rest of ++ // RenderPostprocessingEffects runs with blend on, the depth test on and ++ // Primary bound, and it is this pass's job to hand that back rather than ++ // leave the next pass to discover a history slot still bound. + GlToggleBlend(on: true); ++ GlEnableDepthTest(); ++ LoadFrameBuffer(EnumFrameBuffer.Primary); + return true; + } + @@ -1719,7 +1810,7 @@ index 6edf0c9..777af61 100644 //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3046,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3112,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1752,7 +1843,7 @@ index 6edf0c9..777af61 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3081,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3147,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1814,7 +1905,7 @@ index 6edf0c9..777af61 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3158,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3224,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1861,7 +1952,7 @@ index 6edf0c9..777af61 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3207,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3273,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1897,7 +1988,7 @@ index 6edf0c9..777af61 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3254,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3320,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1938,7 +2029,7 @@ index 6edf0c9..777af61 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +3305,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,19 +3371,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2059,7 +2150,7 @@ index 6edf0c9..777af61 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3465,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3531,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2085,7 +2176,7 @@ index 6edf0c9..777af61 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3498,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3564,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2107,7 +2198,7 @@ index 6edf0c9..777af61 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3527,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3593,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2205,7 +2296,7 @@ index 6edf0c9..777af61 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3626,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,36 +3692,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2294,7 +2385,7 @@ index 6edf0c9..777af61 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3742,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2233,33 +3808,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2366,7 +2457,7 @@ index 6edf0c9..777af61 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3820,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +3886,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2533,7 +2624,7 @@ index 6edf0c9..777af61 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +3990,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4056,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2557,7 +2648,7 @@ index 6edf0c9..777af61 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4019,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4085,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2595,7 +2686,7 @@ index 6edf0c9..777af61 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4079,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4145,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2638,7 +2729,7 @@ index 6edf0c9..777af61 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4132,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4198,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2697,7 +2788,7 @@ index 6edf0c9..777af61 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4247,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4313,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2771,7 +2862,7 @@ index 6edf0c9..777af61 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4345,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4411,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2802,7 +2893,7 @@ index 6edf0c9..777af61 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4382,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4448,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2837,7 +2928,7 @@ index 6edf0c9..777af61 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4429,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4495,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2866,7 +2957,7 @@ index 6edf0c9..777af61 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4460,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4526,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2890,7 +2981,7 @@ index 6edf0c9..777af61 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4497,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4563,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -2907,7 +2998,7 @@ index 6edf0c9..777af61 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4549,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4615,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2952,7 +3043,7 @@ index 6edf0c9..777af61 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4586,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4652,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2973,7 +3064,7 @@ index 6edf0c9..777af61 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4605,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4671,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2994,7 +3085,7 @@ index 6edf0c9..777af61 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4624,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4690,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3015,7 +3106,7 @@ index 6edf0c9..777af61 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4643,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4709,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3036,7 +3127,7 @@ index 6edf0c9..777af61 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4666,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4732,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3057,7 +3148,7 @@ index 6edf0c9..777af61 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4709,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4775,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3083,7 +3174,7 @@ index 6edf0c9..777af61 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +4951,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5017,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3105,7 +3196,7 @@ index 6edf0c9..777af61 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5149,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5215,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3128,7 +3219,7 @@ index 6edf0c9..777af61 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5223,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5289,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3177,7 +3268,7 @@ index 6edf0c9..777af61 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5293,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5359,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3209,7 +3300,7 @@ index 6edf0c9..777af61 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5323,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5389,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3235,7 +3326,7 @@ index 6edf0c9..777af61 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5671,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5737,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3265,7 +3356,7 @@ index 6edf0c9..777af61 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5725,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5791,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/sources/shaders/taa-resolve.fsh b/sources/shaders/taa-resolve.fsh index 91f2b57f..eb9cb8fb 100644 --- a/sources/shaders/taa-resolve.fsh +++ b/sources/shaders/taa-resolve.fsh @@ -8,11 +8,13 @@ // // Conventions (see TAA-PLAN.md): motion = previousPixel - currentUnjitteredPixel // in render pixels; a raster pixel centre sits at unjittered position -// centre - jitterPx; history is stored at unjittered pixel centres. +// centre - jitterPx; history is stored at unjittered pixel centres; the +// motion attachment's alpha is the writer's WINDOW depth in [0,1] (the same +// space as the depth attachment), not NDC depth. uniform sampler2D sceneTex; // Primary colour 0, jittered uniform sampler2D glowTex; // Primary colour 1, jittered -uniform sampler2D motionTex; // rg mv px, b reactive, a writerDepth (0 = unwritten) +uniform sampler2D motionTex; // rg mv px, b reactive, a writerDepth [0,1] (0 = unwritten) uniform sampler2D depthTex; // Primary depth, [0,1], 0 = near uniform sampler2D historyColor; // previous resolve colour uniform sampler2D historyGlow; // previous resolve glow @@ -45,12 +47,17 @@ vec3 yCoCgToRgb(vec3 c) { } // Intersects the history colour with the neighbourhood box (clip, not clamp). -vec3 clipToBox(vec3 boxMin, vec3 boxMax, vec3 history) { +// `keep` reports how much of the history survived the clip: 1 when it was +// already inside the box, 1/maxUnit when it had to be pulled in. The alpha +// channel has no neighbourhood box of its own, so it is rectified toward the +// current alpha by this same factor instead of drifting unchecked. +vec3 clipToBox(vec3 boxMin, vec3 boxMax, vec3 history, out float keep) { vec3 centre = 0.5 * (boxMax + boxMin); vec3 extent = 0.5 * (boxMax - boxMin) + 1e-5; vec3 offset = history - centre; vec3 unit = abs(offset / extent); float maxUnit = max(unit.x, max(unit.y, unit.z)); + keep = maxUnit > 1.0 ? 1.0 / maxUnit : 1.0; return maxUnit > 1.0 ? centre + offset / maxUnit : history; } @@ -104,9 +111,11 @@ void main(void) vec3 ycc = rgbToYCoCg(c.rgb); m1 += ycc; m2 += ycc * ycc; boxMin = min(boxMin, ycc); boxMax = max(boxMax, ycc); - // Reconstruct at the unjittered pixel centre: this tap sits at - // (x, y) + jitter relative to it. Blackman-Harris over radius ~1. - vec2 d = vec2(x, y) + jitterPx; + // Reconstruct at this pixel's unjittered centre. The tap's raster + // centre (pixel + (x,y) + 0.5) sits at unjittered position + // pixelCentre + (x,y) - jitterPx, so its offset from the + // reconstruction point is (x, y) - jitterPx. Blackman-Harris, radius ~1. + vec2 d = vec2(x, y) - jitterPx; float r = length(d); float w = r < 1.0 ? (0.35875 + 0.48829 * cos(3.14159265 * r) + 0.14128 * cos(2.0 * 3.14159265 * r) + 0.01168 * cos(3.0 * 3.14159265 * r)) : 0.0; filtered += c * w; filteredWeight += w; @@ -125,12 +134,18 @@ void main(void) vec3 world = worldH.xyz / max(abs(worldH.w), 1e-6) * sign(worldH.w); float linearDepth = -(viewMatrix * vec4(world, 1.0)).z; + vec4 glow = texelFetch(glowTex, pixel, 0); + // ---- motion: written vector when its depth matches, else camera reprojection vec4 motion = texelFetch(motionTex, pixel, 0); float reactive = clamp(motion.b, 0.0, 1.0); vec2 currentUnjittered = pixelCentre - jitterPx; vec2 mv; - bool written = motion.a > 0.0 && abs(motion.a - depth) < 1e-4; + // motion.a is the writer's window depth in [0,1], stored in an RGBA16F + // attachment: half precision alone costs ~5e-4 near 1.0, so the tolerance + // has to scale with the value and keep a floor for depths near the near + // plane. A fixed 1e-4 rejected every legitimate writer past mid-range. + bool written = motion.a > 0.0 && abs(motion.a - depth) <= max(2e-4, 8e-4 * depth); if (written) { mv = motion.rg; @@ -138,11 +153,16 @@ void main(void) else { vec4 prevClip = prevViewProj * vec4(world + cameraDelta, 1.0); - if (prevClip.w <= 1e-6) { outColor = current; outGlow = texelFetch(glowTex, pixel, 0); outDepth = vec4(linearDepth); return; } + if (prevClip.w <= 1e-6) { outColor = current; outGlow = glow; outDepth = vec4(linearDepth); return; } vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize; mv = prevPixel - currentUnjittered; } - vec2 historyUv = (currentUnjittered + mv) * invSize; + // The history grid is the unjittered pixel-centre grid (see the + // reconstruction kernel above), so the lookup anchor is pixelCentre; mv is + // a displacement field, and subtracting the jitter here would re-sample the + // converged history at a different sub-pixel offset every frame - exactly + // the wobble jitter is supposed to remove. + vec2 historyUv = (pixelCentre + mv) * invSize; // ---- history sample and rejection float alpha = blendAlpha; @@ -152,24 +172,46 @@ void main(void) vec4 history = sampleCatmullRom(historyColor, historyUv); vec4 historyGlowSample = texture(historyGlow, historyUv); float historyLinear = texture(historyDepth, historyUv).r; + // A history slot that was never written (freshly allocated after a + // framebuffer rebuild) or that caught a division blow-up holds NaN/Inf, + // and NaN survives any weighted blend, poisoning the pixel forever. Treat + // it exactly like a reset: this frame's own values, full current weight. + if (any(isnan(history)) || any(isinf(history)) + || any(isnan(historyGlowSample)) || any(isinf(historyGlowSample)) + || isnan(historyLinear) || isinf(historyLinear)) + { + history = current; + historyGlowSample = glow; + historyLinear = linearDepth; + alpha = 1.0; + } // Disocclusion: the surface seen last frame at that location must be at a // comparable distance. Tolerance grows with distance; camera translation - // along the view axis is covered by the relative term. + // along the view axis is covered by the relative term. A disoccluded pixel + // has no valid history at all, so it is rejected outright - half-rejecting + // it just blends in whatever surface used to be in front. float depthTolerance = 0.5 + 0.08 * linearDepth; - if (abs(historyLinear - linearDepth) > depthTolerance) alpha = max(alpha, 0.5); + if (abs(historyLinear - linearDepth) > depthTolerance) alpha = 1.0; alpha = max(alpha, reactive); // ---- rectify and blend in YCoCg with luminance weighting - vec3 histYcc = clipToBox(clipMin, clipMax, rgbToYCoCg(history.rgb)); + float clipKeep = 1.0; + vec3 histYcc = clipToBox(clipMin, clipMax, rgbToYCoCg(history.rgb), clipKeep); vec3 curYcc = rgbToYCoCg(current.rgb); float wCur = alpha / (1.0 + curYcc.x); float wHist = (1.0 - alpha) / (1.0 + histYcc.x); vec3 resolvedYcc = (curYcc * wCur + histYcc * wHist) / max(wCur + wHist, 1e-5); vec3 resolved = max(yCoCgToRgb(resolvedYcc), vec3(0.0)); - float resolvedAlpha = mix(history.a, current.a, alpha); - - vec4 glow = texelFetch(glowTex, pixel, 0); - vec4 resolvedGlow = mix(historyGlowSample, glow, max(alpha, 0.2)); + // Rectify the history alpha by the same factor the colour clip applied, + // then blend it with the same weight, so scene alpha cannot drift away + // from the colour it belongs to. + float histAlpha = mix(current.a, history.a, clipKeep); + float resolvedAlpha = mix(histAlpha, current.a, alpha); + + // Glow blends with the same alpha as colour: a separate 0.2 floor made the + // two signals converge at different rates, so bloom lagged or led the image + // it is derived from. + vec4 resolvedGlow = mix(historyGlowSample, glow, alpha); outColor = vec4(resolved, resolvedAlpha); outGlow = resolvedGlow; From 5738160b16df2449d08a23decfdf4db9496f21b3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 17:56:30 +0200 Subject: [PATCH 019/226] fix(vulkan): preserve R32F TAA history depth Map GL_R32F to R32Sfloat instead of the RGBA8 fallback. Clamping linear history depth to 1 made distant surfaces reject history every frame, leaving visible jitter and aliasing on Vulkan. Add GPU seam regression cases using the real resolve over three frames: retain history at 12/128 blocks through changing jitter, preserve depth, and reject disocclusion. Removing the mapping makes all three fail. Verified: Release solution build and make deploy; 249 Vulkan tests; 787 source tests passed (34 skipped); extract/check patches with zero pending/conflicts; still-camera screenshots a second apart on Vulkan and OpenGL in serene cave world at noon/clear sky. Corrected Vulkan trace has zero validation errors and R32Sfloat history-depth dumps. --- .../VulkanDeviceIntegrationTests.cs | 150 ++++++++++++++++++ Optimum.Render.Vulkan/Core/GlEnums.cs | 3 + 2 files changed, 153 insertions(+) diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index 93c16e56..ca6a467f 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -38,6 +38,156 @@ private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? return false; } + // Unlike the lower-level TaaResolveTests, allocate through the same raw GL + // format API as ClientPlatformWindows.CreateOptimumHistoryTarget. A missing + // GL_R32F mapping used to clamp linear history depth to 1 in an RGBA8 target, + // so every world surface rejected history even though the shader tests passed. + [SkippableTheory] + [InlineData(12f, false)] + [InlineData(128f, false)] + [InlineData(12f, true)] + public unsafe void TaaRetainsDistantHistoryAndRejectsDisocclusionThroughTheSeam( + float distance, bool disoccluded) + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 8; + var files = ShaderCorpus.LoadShaderFiles(); + int resolve = LinkProgram(seam, files["taa-resolve.vsh"], files["taa-resolve.fsh"], "taa-resolve"); + int inspect = LinkProgram(seam, files["taa-resolve.vsh"], """ + #version 330 core + uniform sampler2D colorTex; + uniform sampler2D linearDepthTex; + out vec4 color; + void main() { + ivec2 p = ivec2(gl_FragCoord.xy); + color = vec4(texelFetch(linearDepthTex, p, 0).r / 256.0, + texelFetch(colorTex, p, 0).r, 0.0, 1.0); + } + """); + + int Texture(EnumTextureInternalFormat format) => seam.CreateTexture2D( + size, size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + void Filter(int texture, int filter) + { + seam.SetTextureParameter(texture, OptimumGlConstants.TextureMinFilter, filter); + seam.SetTextureParameter(texture, OptimumGlConstants.TextureMagFilter, filter); + seam.SetTextureParameter(texture, OptimumGlConstants.TextureWrapS, 33071); + seam.SetTextureParameter(texture, OptimumGlConstants.TextureWrapT, 33071); + } + int Target(params int[] colors) + { + int fbo = seam.CreateFramebuffer(size, size); + for (int i = 0; i < colors.Length; i++) + seam.AttachTexture(fbo, (EnumFramebufferAttachment)(36064 + i), colors[i], 0); + seam.SetDrawBuffers(fbo, (1 << colors.Length) - 1); + Assert.True(seam.CheckFramebufferComplete(fbo, out string status), status); + return fbo; + } + void Bind(int program, string name, int texture, int unit) + { + seam.SetSamplerUnit(program, name, unit); + seam.BindTexture(unit, texture); + } + int Loc(string name) => seam.GetUniformLocation(resolve, name); + float[] Identity() => new float[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; + + int scene = Texture(EnumTextureInternalFormat.Rgba8); + int glow = Texture(EnumTextureInternalFormat.Rgba8); + int motion = Texture(EnumTextureInternalFormat.Rgba16f); + int depth = Texture(EnumTextureInternalFormat.DepthComponent32); + int primary = Target(scene, glow, motion); + seam.AttachTexture(primary, EnumFramebufferAttachment.DepthAttachment, depth, 0); + Filter(depth, 9728); + var history = new int[2][]; + var framebuffers = new int[2]; + for (int i = 0; i < 2; i++) + { + history[i] = new[] { Texture(EnumTextureInternalFormat.Rgba16f), + Texture(EnumTextureInternalFormat.Rgba8), + seam.CreateTexture2DRaw(size, size, 0x822E, IntPtr.Zero, 4) }; + for (int j = 0; j < 3; j++) Filter(history[i][j], j == 2 ? 9728 : 9729); + framebuffers[i] = Target(history[i]); + } + int readback = Target(Texture(EnumTextureInternalFormat.Rgba8)); + var pixels = new byte[size * size * 4]; + + // First frame seeds history. Later frames invert the checkerboard; + // both colours remain in the neighbourhood clipping box. Retention + // must blend across the two slots, not simply return current colour. + for (int frame = 0; frame < 3; frame++) + { + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + byte value = (byte)(((x + y + frame) % 2 == 0) ? 64 : 192); + int at = (y * size + x) * 4; + pixels[at] = pixels[at + 1] = pixels[at + 2] = value; + pixels[at + 3] = 255; + } + fixed (byte* data = pixels) + seam.UploadTexture2D(scene, 0, 0, 0, size, size, EnumTexturePixelFormat.Rgba, (IntPtr)data); + + seam.BeginFrame(); + seam.BindFramebuffer(primary); + seam.SetViewport(0, 0, size, size); + seam.SetDepthMask(true); + seam.ClearDepth(0.5f); + seam.ClearColor(1, 0, 0, 0, 0); + seam.ClearColor(2, 0, 0, 0, 0); // no motion writer: camera fallback + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.BindFramebuffer(framebuffers[frame & 1]); + seam.UseProgram(resolve); + Bind(resolve, "sceneTex", scene, 0); + Bind(resolve, "glowTex", glow, 1); + Bind(resolve, "motionTex", motion, 2); + Bind(resolve, "depthTex", depth, 3); + int[] previous = history[(frame + 1) & 1]; + Bind(resolve, "historyColor", previous[0], 4); + Bind(resolve, "historyGlow", previous[1], 5); + Bind(resolve, "historyDepth", previous[2], 6); + seam.SetUniform(resolve, Loc("renderSize"), (float)size, (float)size); + // Orthographic reprojection with a known linear depth, and a + // changing subpixel jitter that cancels in static camera motion. + float jitter = frame % 2 == 0 ? 0.25f : -0.25f; + seam.SetUniform(resolve, Loc("jitterPx"), jitter, 0f); + float currentDistance = disoccluded ? distance * (frame + 1) : distance; + float[] inverse = Identity(); + inverse[12] = -2 * jitter / size; + inverse[14] = -currentDistance; + seam.SetUniformMatrix(resolve, Loc("invViewProjJittered"), inverse); + seam.SetUniformMatrix(resolve, Loc("prevViewProj"), Identity()); + seam.SetUniformMatrix(resolve, Loc("viewMatrix"), Identity()); + seam.SetUniform(resolve, Loc("cameraDelta"), 0f, 0f, 0f); + seam.SetUniform(resolve, Loc("resetHistory"), frame == 0 ? 1 : 0); + seam.SetUniform(resolve, Loc("blendAlpha"), 0.1f); + seam.SetUniform(resolve, Loc("varianceGamma"), 1.25f); + seam.DrawFullscreenTriangle(); + + seam.BindFramebuffer(readback); + seam.UseProgram(inspect); + Bind(inspect, "colorTex", history[frame & 1][0], 0); + Bind(inspect, "linearDepthTex", history[frame & 1][2], 1); + seam.DrawFullscreenTriangle(); + fixed (byte* data = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)data); + seam.Present(); + int centre = (4 * size + 4) * 4; // raw RGBA8 attachment readback + Assert.InRange(pixels[centre], (int)(currentDistance * 255 / 256) - 1, + (int)(currentDistance * 255 / 256) + 1); + if (frame == 1) + Assert.InRange(pixels[centre + 1], disoccluded ? 175 : 60, disoccluded ? 195 : 100); + if (frame == 2) + Assert.InRange(pixels[centre + 1], 60, 100); + } + AssertClean(seam); + } + } + [SkippableTheory] [InlineData(false)] [InlineData(true)] diff --git a/Optimum.Render.Vulkan/Core/GlEnums.cs b/Optimum.Render.Vulkan/Core/GlEnums.cs index f5a01554..9e653acf 100644 --- a/Optimum.Render.Vulkan/Core/GlEnums.cs +++ b/Optimum.Render.Vulkan/Core/GlEnums.cs @@ -117,6 +117,9 @@ internal static class GlEnums 0x8058 => Format.R8G8B8A8Unorm, // GL_RGBA8 0x881A => Format.R16G16B16A16Sfloat, // GL_RGBA16F 0x822D => Format.R16Sfloat, // GL_R16F + // TAA stores linear view depth here. Falling back to RGBA8 clamps every + // distance above 1, making the next resolve reject otherwise valid history. + 0x822E => Format.R32Sfloat, // GL_R32F 0x8C3A => Format.B10G11R11UfloatPack32,// GL_R11F_G11F_B10F 0x8814 => Format.R32G32B32A32Sfloat, // GL_RGBA32F 0x805B => Format.R16G16B16A16Unorm, // GL_RGBA16, the cloud map's tile data From 8fb0a3a25d00df92ac924775cc0040a5b37945d8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 18:23:41 +0200 Subject: [PATCH 020/226] docs(skill): shader instrumentation and controlled-input method for parity debugging --- .claude/skills/vulkan-parity-debug/SKILL.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.claude/skills/vulkan-parity-debug/SKILL.md b/.claude/skills/vulkan-parity-debug/SKILL.md index a8cc128f..5a0b1a8d 100644 --- a/.claude/skills/vulkan-parity-debug/SKILL.md +++ b/.claude/skills/vulkan-parity-debug/SKILL.md @@ -32,6 +32,24 @@ item in this list on both sides: - attachment indices and texture-id bookkeeping (`FrameBufferRef.ColorTextureIds`) Write the list of mismatches first; then fix them all, not the first one. +## 2b. Instrument the shader instead of guessing (Codex's method, 2026-09-10) +When a pass "does nothing" or "wobbles" and the inputs are hard to inspect, temporarily rewrite the +pass's fragment shader to OUTPUT ITS INTERNAL SIGNALS AS COLOUR and look at the screen: +- Save the original: `cp sources/shaders/.fsh /tmp/-original.fsh`. +- Patch the deployed copy directly (no rebuild needed): edit `sources/shaders/.fsh` and copy it to + `.vanilla/win-x64/vintagestory/assets/game/shaders/.fsh`; the game loads it at start. + Example for the TAA resolve: `outColor = vec4(alpha, clamp(length(mv)/4.0, 0, 1), resetHistory != 0 ? 1 : 0, 1)` + shows blend weight, motion magnitude and reset per pixel; early-out branches get a fixed colour + (`vec4(0,0,1,1)`) so you can see which path ran. +- Replace real inputs with CONTROLLED ones to split the chain: a checkerboard or diagonal pattern as + "current" proves the resolve+display copy are identical on both backends; a static pattern under the + live jitter proves accumulation on its own, independent of wind, lighting and foliage. +- Freeze the world for comparisons: `/time set 12:00`, `/weather set clearsky`, `/weather setprecip -1`, + still camera, screenshot pairs 1 s apart, numeric diff of a crop. +- Test allocator luck explicitly: fill a suspect texture with deliberately non-zero data before the pass + (cold-start dumps that happen to read zero hide a missing clear). +- Restore the original shader afterwards and re-deploy; never commit the instrumented version. + ## 3. Fix, test, verify - Backend changes in `Optimum.Render.Vulkan/`, seam additions in `VintagestoryApi/Client/optimum-render-device.cs` (then contracts csproj), lib changes in `build/` + Cecil list (see patch-workflow skill). From 07030ee65d36385ff7549e54d968e7d877b91216 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 18:35:32 +0200 Subject: [PATCH 021/226] fix(taa): clear Vulkan motion while its draw buffer is enabled ClearFrameBuffer(Primary) called ClearColor on an excluded motion attachment. The device honours the draw-buffer mask, so this silently preserved undefined/recycled motion and reactivity. Nonzero reactivity can suppress history even when the writer-depth check rejects the vector. Match OpenGL: enable the attachment for the clear, then restore the normal world draw-buffer mask. ClearFrameBuffer(EnumFrameBuffer) is already in the Cecil transplant manifest. Add GPU cases with deliberately dirty masked motion at attachment 2/4, checking all four cleared channels and accumulation through the real TAA resolve. The old sequence fails both cases (current-only colour 188). Strengthen source coverage to require enable/clear/restore ordering. Verified: Release solution build; make deploy; 251 Vulkan tests passed; 787 source tests passed (34 skipped); extract/check patches with no pending/conflicts; dirty-motion game repro before/after and final normal Vulkan/OpenGL still-camera screenshot pairs at noon/clear sky. Motion dumps are zero after the fix; Vulkan traces have no validation errors. Temporary shader/dirty-memory diagnostics removed from the final build. --- .../VulkanDeviceIntegrationTests.cs | 40 +++++++- Optimum.Tests/taa-pipeline-coverage-tests.cs | 7 ++ .../ClientPlatformWindows.cs.patch | 94 ++++++++++--------- 3 files changed, 93 insertions(+), 48 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index ca6a467f..738681ee 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -47,7 +47,16 @@ private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? [InlineData(128f, false)] [InlineData(12f, true)] public unsafe void TaaRetainsDistantHistoryAndRejectsDisocclusionThroughTheSeam( - float distance, bool disoccluded) + float distance, bool disoccluded) => RunTaaResolve(distance, disoccluded, 2, false); + + [SkippableTheory] + [InlineData(2)] + [InlineData(4)] + public void TaaAccumulatesAfterClearingDirtyMaskedMotion(int motionAttachmentIndex) => + RunTaaResolve(12f, false, motionAttachmentIndex, true); + + private unsafe void RunTaaResolve(float distance, bool disoccluded, + int motionAttachmentIndex, bool poisonMotion) { Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) @@ -60,11 +69,13 @@ public unsafe void TaaRetainsDistantHistoryAndRejectsDisocclusionThroughTheSeam( #version 330 core uniform sampler2D colorTex; uniform sampler2D linearDepthTex; + uniform sampler2D motionTex; out vec4 color; void main() { ivec2 p = ivec2(gl_FragCoord.xy); color = vec4(texelFetch(linearDepthTex, p, 0).r / 256.0, - texelFetch(colorTex, p, 0).r, 0.0, 1.0); + texelFetch(colorTex, p, 0).r, + any(notEqual(texelFetch(motionTex, p, 0), vec4(0))) ? 1.0 : 0.0, 1.0); } """); @@ -98,7 +109,13 @@ void Bind(int program, string name, int texture, int unit) int glow = Texture(EnumTextureInternalFormat.Rgba8); int motion = Texture(EnumTextureInternalFormat.Rgba16f); int depth = Texture(EnumTextureInternalFormat.DepthComponent32); - int primary = Target(scene, glow, motion); + var primaryColors = new int[motionAttachmentIndex + 1]; + primaryColors[0] = scene; + primaryColors[1] = glow; + for (int i = 2; i < motionAttachmentIndex; i++) + primaryColors[i] = Texture(EnumTextureInternalFormat.Rgba16f); + primaryColors[motionAttachmentIndex] = motion; + int primary = Target(primaryColors); seam.AttachTexture(primary, EnumFramebufferAttachment.DepthAttachment, depth, 0); Filter(depth, 9728); var history = new int[2][]; @@ -136,7 +153,20 @@ void Bind(int program, string name, int texture, int unit) seam.SetDepthMask(true); seam.ClearDepth(0.5f); seam.ClearColor(1, 0, 0, 0, 0); - seam.ClearColor(2, 0, 0, 0, 0); // no motion writer: camera fallback + if (poisonMotion) + { + // Recycled GPU memory may contain plausible motion/depth and + // full reactivity. Never let zero-filled fresh allocations + // hide a skipped clear. P2 keeps this attachment masked out. + seam.SetDrawBuffers(primary, (1 << (motionAttachmentIndex + 1)) - 1); + seam.ClearColor(motionAttachmentIndex, 16, 8, 1, 1); + seam.SetDrawBuffers(primary, (1 << motionAttachmentIndex) - 1); + } + // Match ClearFrameBuffer(Primary): the clear obeys the mask, + // then world draws must again exclude unwritten motion output. + seam.SetDrawBuffers(primary, (1 << (motionAttachmentIndex + 1)) - 1); + seam.ClearColor(motionAttachmentIndex, 0, 0, 0, 0); + seam.SetDrawBuffers(primary, (1 << motionAttachmentIndex) - 1); seam.SetDepthTest(false); seam.SetCullFace(false); seam.SetBlend(false, EnumBlendMode.Standard); @@ -172,11 +202,13 @@ void Bind(int program, string name, int texture, int unit) seam.UseProgram(inspect); Bind(inspect, "colorTex", history[frame & 1][0], 0); Bind(inspect, "linearDepthTex", history[frame & 1][2], 1); + Bind(inspect, "motionTex", motion, 2); seam.DrawFullscreenTriangle(); fixed (byte* data = pixels) seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)data); seam.Present(); int centre = (4 * size + 4) * 4; // raw RGBA8 attachment readback + Assert.Equal(0, pixels[centre + 2]); // all four motion channels cleared Assert.InRange(pixels[centre], (int)(currentDistance * 255 / 256) - 1, (int)(currentDistance * 255 / 256) + 1); if (frame == 1) diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index 9f99d98e..8259529d 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -84,6 +84,13 @@ public void ClearFrameBufferClearsTheMotionAttachmentOnBothPaths() // Device path. Assert.Contains("optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", platform); + // An excluded attachment is not cleared on either backend. Checking + // only that ClearColor exists missed Vulkan's silent masked-out no-op. + int enable = platform.IndexOf("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", StringComparison.Ordinal); + int clear = platform.IndexOf("optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", StringComparison.Ordinal); + int restore = platform.IndexOf("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", clear, StringComparison.Ordinal); + Assert.True(enable >= 0 && enable < clear && restore > clear); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"ClearFrameBuffer\", 1", Read("Optimum.Patcher/Program.cs")); // GL path. Assert.Contains("GL.ClearBuffer((ClearBuffer)6144, MotionAttachmentIndex, new float[4]);", platform); // Both are guarded so a failed/absent motion attachment leaves the diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 38cf84d2..3b7cc553 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..25f5a1b 100644 +index 6edf0c9..da5e80b 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -1395,7 +1395,7 @@ index 6edf0c9..25f5a1b 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2662,78 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,21 +2662,84 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1446,7 +1446,13 @@ index 6edf0c9..25f5a1b 100644 + } + if (MotionAttachmentIndex >= 0) + { ++ // ClearColor honours the draw-buffer mask on the device too. ++ // Motion is excluded until a writer opts in, so temporarily ++ // enable it just as the GL branch does below. Otherwise stale ++ // motion/reactivity survives and can reject all TAA history. ++ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); + optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f); ++ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + } + optimumDevice.ClearDepth(1f); + break; @@ -1474,7 +1480,7 @@ index 6edf0c9..25f5a1b 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1636,10 +2747,36 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1636,10 +2753,36 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (RenderSSAO) { GL.ClearBuffer((ClearBuffer)6144, 2, new float[4] { 0f, 0f, 0f, 1f }); @@ -1511,7 +1517,7 @@ index 6edf0c9..25f5a1b 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2807,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2813,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1552,7 +1558,7 @@ index 6edf0c9..25f5a1b 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2850,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2856,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1619,7 +1625,7 @@ index 6edf0c9..25f5a1b 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2920,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2926,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1641,7 +1647,7 @@ index 6edf0c9..25f5a1b 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2944,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2950,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1668,7 +1674,7 @@ index 6edf0c9..25f5a1b 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +2969,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +2975,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1810,7 +1816,7 @@ index 6edf0c9..25f5a1b 100644 //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3112,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3118,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1843,7 +1849,7 @@ index 6edf0c9..25f5a1b 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3147,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3153,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1905,7 +1911,7 @@ index 6edf0c9..25f5a1b 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3224,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3230,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1952,7 +1958,7 @@ index 6edf0c9..25f5a1b 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3273,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3279,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1988,7 +1994,7 @@ index 6edf0c9..25f5a1b 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3320,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3326,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2029,7 +2035,7 @@ index 6edf0c9..25f5a1b 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +3371,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,19 +3377,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2150,7 +2156,7 @@ index 6edf0c9..25f5a1b 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3531,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3537,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2176,7 +2182,7 @@ index 6edf0c9..25f5a1b 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3564,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3570,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2198,7 +2204,7 @@ index 6edf0c9..25f5a1b 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3593,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3599,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2296,7 +2302,7 @@ index 6edf0c9..25f5a1b 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3692,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,36 +3698,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2385,7 +2391,7 @@ index 6edf0c9..25f5a1b 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3808,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2233,33 +3814,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2457,7 +2463,7 @@ index 6edf0c9..25f5a1b 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3886,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +3892,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2624,7 +2630,7 @@ index 6edf0c9..25f5a1b 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4056,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4062,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2648,7 +2654,7 @@ index 6edf0c9..25f5a1b 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4085,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4091,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2686,7 +2692,7 @@ index 6edf0c9..25f5a1b 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4145,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4151,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2729,7 +2735,7 @@ index 6edf0c9..25f5a1b 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4198,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4204,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2788,7 +2794,7 @@ index 6edf0c9..25f5a1b 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4313,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4319,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2862,7 +2868,7 @@ index 6edf0c9..25f5a1b 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4411,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4417,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2893,7 +2899,7 @@ index 6edf0c9..25f5a1b 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4448,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4454,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2928,7 +2934,7 @@ index 6edf0c9..25f5a1b 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4495,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4501,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2957,7 +2963,7 @@ index 6edf0c9..25f5a1b 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4526,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4532,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2981,7 +2987,7 @@ index 6edf0c9..25f5a1b 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4563,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4569,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -2998,7 +3004,7 @@ index 6edf0c9..25f5a1b 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4615,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4621,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3043,7 +3049,7 @@ index 6edf0c9..25f5a1b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4652,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4658,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3064,7 +3070,7 @@ index 6edf0c9..25f5a1b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4671,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4677,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3085,7 +3091,7 @@ index 6edf0c9..25f5a1b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4690,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4696,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3106,7 +3112,7 @@ index 6edf0c9..25f5a1b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4709,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4715,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3127,7 +3133,7 @@ index 6edf0c9..25f5a1b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4732,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4738,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3148,7 +3154,7 @@ index 6edf0c9..25f5a1b 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4775,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4781,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3174,7 +3180,7 @@ index 6edf0c9..25f5a1b 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5017,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5023,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3196,7 +3202,7 @@ index 6edf0c9..25f5a1b 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5215,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5221,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3219,7 +3225,7 @@ index 6edf0c9..25f5a1b 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5289,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5295,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3268,7 +3274,7 @@ index 6edf0c9..25f5a1b 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5359,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5365,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3300,7 +3306,7 @@ index 6edf0c9..25f5a1b 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5389,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5395,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3326,7 +3332,7 @@ index 6edf0c9..25f5a1b 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5737,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5743,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3356,7 +3362,7 @@ index 6edf0c9..25f5a1b 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5791,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5797,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } From a149245650e875b2b69a69d9cd733bb6fc9c6768 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 19:15:01 +0200 Subject: [PATCH 022/226] test(vulkan): verify temporal history across frames in flight Exercise RGBA16F/RGBA8/R32F ping-pong targets over 16 submitted frames, with changing per-frame uniforms and cached image descriptors. Generate all inputs on the GPU and read back only after the final frame so host uploads/readbacks cannot hide a history synchronization defect. Verified with synchronization validation: the history test passes and the game has no synchronization errors with MangoHud disabled (the legacy-render-pass warnings were isolated to that overlay). Controlled live comparison after 8e4a970: still camera, noon/clear/still wind, centre-60% luma difference, seven adjacent ~1.03-second pairs: Vulkan mean 1.837 / median 1.743; OpenGL mean 1.871 / median 1.715. Creative mode temporarily prevented hunger damage/respawn; Survival restored and clients closed. No further renderer change was warranted. Release solution build passed; 252 GPU tests passed; 787 source tests passed (34 skipped); patch check has zero pending/conflicts. --- .../VulkanDeviceIntegrationTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index 738681ee..d1d8b37c 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -55,6 +55,129 @@ public unsafe void TaaRetainsDistantHistoryAndRejectsDisocclusionThroughTheSeam( public void TaaAccumulatesAfterClearingDirtyMaskedMotion(int motionAttachmentIndex) => RunTaaResolve(12f, false, motionAttachmentIndex, true); + [SkippableFact] + public unsafe void TemporalHistorySurvivesFramesInFlightWithoutIntermediateReadbacks() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 64, frames = 16; + string vertex = ShaderCorpus.LoadShaderFiles()["taa-resolve.vsh"]; + int accumulate = LinkProgram(seam, vertex, """ + #version 330 core + uniform sampler2D historyColor; + uniform sampler2D historyAux; + uniform sampler2D historyDepth; + uniform float increment; + layout(location = 0) out vec4 color; + layout(location = 1) out vec4 aux; + layout(location = 2) out vec4 depth; + void main() { + ivec2 p = ivec2(gl_FragCoord.xy); + color = texelFetch(historyColor, p, 0) + vec4(increment); + aux = texelFetch(historyAux, p, 0) + vec4(8.0 / 255.0); + depth = vec4(texelFetch(historyDepth, p, 0).r + increment); + // Keep work in flight while the CPU submits the next frame. + // The bound is deliberately data dependent, preventing the + // compiler from precomputing the loop for the whole draw. + float busy = 0; + for (int i = 0; i < 4096 + int(gl_FragCoord.y); ++i) + busy += sin(float(i) + gl_FragCoord.x); + if (busy > 1e30) color = vec4(busy); + } + """); + int inspect = LinkProgram(seam, vertex, """ + #version 330 core + uniform sampler2D historyColor; + uniform sampler2D historyAux; + uniform sampler2D historyDepth; + out vec4 color; + void main() { + ivec2 p = ivec2(gl_FragCoord.xy); + color = vec4(texelFetch(historyColor, p, 0).r / 8.0, + texelFetch(historyDepth, p, 0).r / 8.0, + texelFetch(historyAux, p, 0).r, 1.0); + } + """); + + int Texture(EnumTextureInternalFormat format) => seam.CreateTexture2D( + size, size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int Target(int[] colors) + { + int target = seam.CreateFramebuffer(size, size); + for (int i = 0; i < colors.Length; ++i) + seam.AttachTexture(target, (EnumFramebufferAttachment)(36064 + i), colors[i], 0); + seam.SetDrawBuffers(target, (1 << colors.Length) - 1); + return target; + } + void BindHistory(int program, int[] textures) + { + string[] names = { "historyColor", "historyAux", "historyDepth" }; + for (int i = 0; i < names.Length; ++i) + { + seam.SetSamplerUnit(program, names[i], i + 4); + seam.BindTexture(i + 4, textures[i]); + } + } + + var histories = new int[2][]; + var targets = new int[2]; + for (int i = 0; i < 2; ++i) + { + histories[i] = new[] { Texture(EnumTextureInternalFormat.Rgba16f), + Texture(EnumTextureInternalFormat.Rgba8), + seam.CreateTexture2DRaw(size, size, 0x822E, IntPtr.Zero, 4) }; + targets[i] = Target(histories[i]); + } + int readbackTarget = Target(new[] { Texture(EnumTextureInternalFormat.Rgba8) }); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + for (int frame = 0; frame < frames; ++frame) + { + seam.BeginFrame(); + if (frame == 0) + { + seam.BindFramebuffer(targets[0]); + for (int attachment = 0; attachment < 3; ++attachment) + seam.ClearColor(attachment, 0, 0, 0, 0); + } + seam.BindFramebuffer(targets[(frame + 1) & 1]); + seam.UseProgram(accumulate); + BindHistory(accumulate, histories[frame & 1]); + seam.SetUniform(accumulate, seam.GetUniformLocation(accumulate, "increment"), (frame + 1) / 32f); + seam.DrawFullscreenTriangle(); + seam.Present(); + // No readback, upload or explicit wait here: these would flush + // the graphics queue and mask broken history synchronization. + } + + seam.BeginFrame(); + seam.BindFramebuffer(readbackTarget); + seam.UseProgram(inspect); + BindHistory(inspect, histories[frames & 1]); + seam.DrawFullscreenTriangle(); + byte[] pixels = new byte[size * size * 4]; + fixed (byte* data = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)data); + seam.Present(); + // Sum(1..16)/32 = 4.25 in both float histories; the RGBA8 aux + // accumulates exactly eight byte values per frame. A stale cached + // descriptor, missing frame, or overwritten uniform changes these. + for (int i = 0; i < pixels.Length; i += 4) + { + Assert.InRange(pixels[i], 135, 136); + Assert.InRange(pixels[i + 1], 135, 136); + Assert.Equal(128, pixels[i + 2]); + Assert.Equal(255, pixels[i + 3]); + } + AssertClean(seam); + } + } + private unsafe void RunTaaResolve(float distance, bool disoccluded, int motionAttachmentIndex, bool poisonMotion) { From ab1ba53edd5f0c6bf06a986657cbce9b951bc575 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 19:23:05 +0200 Subject: [PATCH 023/226] docs(taa): record P2 acceptance and findings --- TAA-PLAN.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 1eb7b15a..8b2c82d2 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -263,6 +263,13 @@ finalizer thread by `DeleteUniformBuffer` while the render thread reads it; pre- offset reprojection, outlier clip, reset); coverage test for ordering and FXAA-off; in-game the whole scene converges with camera-only motion vectors. +P2 status (2026-09-10): done and accepted in game by the user on both backends ("TAA is CHEFSKISS now"). +Commits 1257117..9c32acb on `feat/taa`. Findings to carry: (a) Vulkan `GlEnums` lacked GL_R32F, so the +history depth target silently became RGBA8 (b4d58a2); (b) `ClearColor` on a masked-out attachment is a +no-op on Vulkan, so the motion clear must enable the attachment first (8e4a970); (c) the history lookup +must be anchored at the pixel centre plus mv, not at the unjittered current position (7e1b9bd); +(d) matched-camera luminance-diff measurements (Codex) are the acceptance tool for "jitter" reports. + **P3. Opaque coverage.** - `sources/shaderincludes/vertexwarp.vsh` with `WarpState`; `chunkopaque`, `chunktopsoil` writers; `standard.vsh/.fsh` writer (items, block entities, dropped items, quern) with previous transforms From d81238bf122183058d889d71bdf2a2a4f5181a00 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 19:26:41 +0200 Subject: [PATCH 024/226] chore: stop tracking local agent docs (CLAUDE.md, AGENTS.md, .claude/) --- .claude/skills/codex-handoff/SKILL.md | 26 ------- .claude/skills/patch-workflow/SKILL.md | 30 -------- .claude/skills/run-optimum/SKILL.md | 25 ------- .claude/skills/vulkan-parity-debug/SKILL.md | 60 ---------------- AGENTS.md | 1 - CLAUDE.md | 80 --------------------- 6 files changed, 222 deletions(-) delete mode 100644 .claude/skills/codex-handoff/SKILL.md delete mode 100644 .claude/skills/patch-workflow/SKILL.md delete mode 100644 .claude/skills/run-optimum/SKILL.md delete mode 100644 .claude/skills/vulkan-parity-debug/SKILL.md delete mode 120000 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/.claude/skills/codex-handoff/SKILL.md b/.claude/skills/codex-handoff/SKILL.md deleted file mode 100644 index 9454d2ef..00000000 --- a/.claude/skills/codex-handoff/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: codex-handoff -description: Hand a stuck rendering bug or a plan review to the local Codex CLI (gpt-6-astra) with a neutral brief, full machine access, detached launch and a completion monitor; then read its report and transcript. Use when the user says "give it to codex/astra" or after two failed fix attempts. ---- - -# Codex handoff - -Brief = symptom + reproduction + where things live. No theories, no ruled-out lists; that poisons it. - -1. Stop other writers: pause workflows/agents, commit WIP (`wip:` prefix, never stash), clean tree. -2. Write `/codex--brief.md`: repo path and branch; the user's words verbatim; - screenshot paths; how to build (`make deploy`), launch (`scripts/dev/run-client.sh`), stop, switch - renderer, read the renderer log line; diagnostics env vars; the test commands; the patch workflow - rule (edit build/ + fork, run extract, never patches/sources); ask for a report file and a commit - on the branch, no push. -3. Wrapper script (the tool timeout cannot kill it): - ``` - cat brief.md | codex exec -m gpt-6-astra -c model_reasoning_effort="high" \ - --dangerously-bypass-approvals-and-sandbox -i shot1.png -i shot2.png > codex.log 2>&1 - echo "CODEX_EXIT $?" >> codex.log - ``` - `setsid wrapper.sh &` then a Monitor that greps for `CODEX_EXIT`. Effort: `high` for reviews and - rendering bugs (~15-40 min), `low` for small tasks; it is on a weekly quota. -4. When done: read the report, `git log`, and the transcript - `~/.codex/sessions//rollout-*.jsonl` (condense `response_item` messages + - `custom_tool_call` inputs). Verify its claims yourself in-game before relaying them. diff --git a/.claude/skills/patch-workflow/SKILL.md b/.claude/skills/patch-workflow/SKILL.md deleted file mode 100644 index a632d905..00000000 --- a/.claude/skills/patch-workflow/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: patch-workflow -description: How to change game-lib, API-fork, mod-fork and shader code in Optimum so it actually ships - edit the right tree, regenerate patches, list Cecil targets, wire csproj overlays, run the checks. Use before editing anything under build/, VintagestoryApi/, VSEssentials/, VSSurvivalMod/, sources/shaders/. ---- - -# Patch workflow - -1. Edit the source of truth (see CLAUDE.md table): `build/VintagestoryLib/**` for the client lib, - `VintagestoryApi/**` for the API, the mod fork dirs, `sources/shaders/` for shaders. - Never edit `patches/*.patch` or `sources/VintagestoryApi/**`. -2. New API file: add `` to - `optimum-api-contracts/optimum-api-contracts.csproj`, and `` to - `VintagestoryApi/VintagestoryAPI.csproj`, `sources/VintagestoryApi/VintagestoryAPI.csproj` and - `.baseline/VintagestoryApi/VintagestoryAPI.csproj` (mirrors what bootstrap folds in). -3. New seam member on `IOptimumGraphicsDevice`: implement in `Optimum.Render.Vulkan/VulkanDevice.cs`; - the GL path keeps its own body in the lib method's `else` branch. -4. Lib change: every changed or added method/property/field in `ClientMain`, `ClientPlatformWindows`, - `ChunkRenderer`, `ShaderRegistry`, `ShaderProgram*`, `ScreenManager`, ... goes into - `Optimum.Patcher/Program.cs` (transplant tuple `new("Type", "Method", paramCount)`; injected - members in the per-type member lists). The patcher only checks references, not omissions, so - grep your diff for every signature. -5. Mod-fork change: rebuild ships it locally; the installed-runtime path needs the - `Optimum.Patcher/mod-patcher.cs` manifest entry for the type/member. -6. New shader include: `sources/shaderincludes/` + add the copy to `make deploy` and every - `scripts/package-*` script; the Vulkan test corpus (`ShaderCorpus.cs`) must overlay it too. -7. `bash scripts/extract-patches.sh` then `bash scripts/check-patches.sh` (expect 0 conflicts, 0 pending; - a stray `patches/VintagestoryApi/*.csproj.patch` means step 2's baseline line is missing). -8. `dotnet build VintageStory.slnx -c Release`, both test suites, `make deploy`, run the game. -9. If a build of the lib fails on a member missing from the API, the fork and `sources/` have drifted: - diff `VintagestoryApi/` against `sources/VintagestoryApi/` and fix the fork, then extract. diff --git a/.claude/skills/run-optimum/SKILL.md b/.claude/skills/run-optimum/SKILL.md deleted file mode 100644 index ed6322ad..00000000 --- a/.claude/skills/run-optimum/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: run-optimum -description: Build, deploy, launch, stop and screenshot the Optimum Vintage Story client on Vulkan or OpenGL, and confirm from the log which renderer actually started. Use for any "run it", "check in game", "compare backends" request. ---- - -# Run Optimum and verify what is on screen - -1. Deploy: `make deploy` (Cecil patch, copies DLLs, shaders and the Vulkan backend into - `.vanilla/win-x64/vintagestory`). If only the backend changed: `dotnet build Optimum.Render.Vulkan -c Release && cp bin/Release/net10.0/Optimum.Render.Vulkan.dll .vanilla/win-x64/vintagestory/`. -2. Stop any running client first: `scripts/dev/kill-client.sh` (its own call; no launch text in the same command). -3. Launch: `RENDERER=vulkan scripts/dev/run-client.sh "serene cave world"` (or `RENDERER=opengl`). - Diagnostics go in the environment: `OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/t.log`. -4. Wait for the world: poll the log for `Savegame .* loaded` and `Received level finalize` - (about 25 s), never blind-sleep. -5. **Confirm the renderer:** `scripts/dev/client-renderer.sh`. If it says `OpenGL renderer: `, - the Vulkan probe failed; read the reason (stale `Optimum.Render.Vulkan.dll` beside the client is the - classic one) and fix that before judging pixels. -6. Screenshot: `scripts/dev/screenshot.sh /tmp/vulkan.png`, then Read the PNG and describe what you see. - For a backend comparison take both shots from the same save and camera. -6b. Daylight for comparable screenshots: focus the window, `xdotool key t`, `xdotool type '/time set 12:00'`, `xdotool key Return` (chat opens with T, sends with Enter); for fog-free comparisons also send `/weather set clearsky` and `/weather setprecip -1` the same way; wait 3 s before the screenshot. -7. Stop: `scripts/dev/kill-client.sh` immediately after the check; the user does not want it left running. Restore `ModConfig/optimum.json` `Renderer` to what the user had. - -Gotchas: `ssaa` 0.5 in clientsettings halves the render resolution on both backends; the random -`--rndWorld -p creativebuilding` world is superflat and has no animals; passing `world.vcdbs` to `-o` -creates a new world named `world.vcdbs.vcdbs`. diff --git a/.claude/skills/vulkan-parity-debug/SKILL.md b/.claude/skills/vulkan-parity-debug/SKILL.md deleted file mode 100644 index 5a0b1a8d..00000000 --- a/.claude/skills/vulkan-parity-debug/SKILL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: vulkan-parity-debug -description: Debug a rendering difference between the OpenGL path and the Vulkan backend (missing post-processing, wrong filtering, transparency, colours). Baseline capture, trace and dump analysis, GL-vs-device state diff, GPU regression test, in-game verification. ---- - -# Vulkan rendering parity debugging - -The Vulkan backend reproduces GL state through `IOptimumGraphicsDevice`; every bug so far was a -state difference between a method's GL branch and its device branch, not shader maths. - -## 1. Baseline before touching code -- `RENDERER=vulkan OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/before.trace scripts/dev/run-client.sh` -- confirm `scripts/dev/client-renderer.sh` says Vulkan; screenshot to `/tmp/vulkan-before.png` -- same scene on `RENDERER=opengl`, screenshot `/tmp/opengl.png`; Read both and write down the differences in words. -- Trace summary (python): map `program N 'name'` lines to ids, count `fullscreen program=` per name, - list `validation:` lines with `[error]`. Passes that never run are one class; passes that run but - produce nothing are the other. -- Dump the intermediates from a live frame: `OPTIMUM_DUMP_TEXTURES= - OPTIMUM_DUMP_DIR=/abs/dir OPTIMUM_DUMP_AFTER_SECONDS=60`; build a contact sheet with PIL and Read it. - Texture ids: `bind unit=U texture=T` lines right before a pass's `fullscreen` line. - -## 2. Diff the two paths, do not theorise -For the pass that is wrong, open the method in `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs` -(or the mod renderer) and read the `if (optimumDevice != null) {...}` branch next to the GL branch, -plus the framebuffer setup pair `SetupOptimumFrameBuffers` / `SetupDefaultFrameBuffers`. Check every -item in this list on both sides: -- texture create: format, mip levels, `TexParameter` min/mag filter, mipmap mode, wrap S/T, border colour, compare mode -- samplers: `GenSampler`/`BindSampler` semantics (the "linear" flag changes magnification only; min is NEAREST_MIPMAP_LINEAR) -- blend: `glEnable(BLEND)` vs `SetBlend(enabled, mode)` (the latter rewrites per-attachment factors; use `SetBlendEnabled` to toggle only), `glBlendFunci` per attachment -- draw buffers: `glDrawBuffers` vs `SetDrawBuffers(fbo, mask)`; an enabled-but-unwritten attachment is undefined -- clears per attachment, depth mask/test/func, cull, viewport for sub-resolution targets, scissor -- attachment indices and texture-id bookkeeping (`FrameBufferRef.ColorTextureIds`) -Write the list of mismatches first; then fix them all, not the first one. - -## 2b. Instrument the shader instead of guessing (Codex's method, 2026-09-10) -When a pass "does nothing" or "wobbles" and the inputs are hard to inspect, temporarily rewrite the -pass's fragment shader to OUTPUT ITS INTERNAL SIGNALS AS COLOUR and look at the screen: -- Save the original: `cp sources/shaders/.fsh /tmp/-original.fsh`. -- Patch the deployed copy directly (no rebuild needed): edit `sources/shaders/.fsh` and copy it to - `.vanilla/win-x64/vintagestory/assets/game/shaders/.fsh`; the game loads it at start. - Example for the TAA resolve: `outColor = vec4(alpha, clamp(length(mv)/4.0, 0, 1), resetHistory != 0 ? 1 : 0, 1)` - shows blend weight, motion magnitude and reset per pixel; early-out branches get a fixed colour - (`vec4(0,0,1,1)`) so you can see which path ran. -- Replace real inputs with CONTROLLED ones to split the chain: a checkerboard or diagonal pattern as - "current" proves the resolve+display copy are identical on both backends; a static pattern under the - live jitter proves accumulation on its own, independent of wind, lighting and foliage. -- Freeze the world for comparisons: `/time set 12:00`, `/weather set clearsky`, `/weather setprecip -1`, - still camera, screenshot pairs 1 s apart, numeric diff of a crop. -- Test allocator luck explicitly: fill a suspect texture with deliberately non-zero data before the pass - (cold-start dumps that happen to read zero hide a missing clear). -- Restore the original shader afterwards and re-deploy; never commit the instrumented version. - -## 3. Fix, test, verify -- Backend changes in `Optimum.Render.Vulkan/`, seam additions in `VintagestoryApi/Client/optimum-render-device.cs` - (then contracts csproj), lib changes in `build/` + Cecil list (see patch-workflow skill). -- Add a GPU readback test per fix in `Optimum.Render.Vulkan.Tests` (draw with a translated shader, - read the pixel, assert; readbacks must happen inside a frame). -- `make deploy`, run Vulkan with validation, screenshot after; run OpenGL; compare live. Then - `dotnet test Optimum.Render.Vulkan.Tests`, `dotnet test Optimum.Tests -c Release`, `bash scripts/check-patches.sh`. -- Keep evidence (before/after PNGs, logs) in the scratchpad and cite it in the report and commit. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index 681311eb..00000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ba2b8c44..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,80 +0,0 @@ -# Optimum: working rules for agents - -Optimum is a performance mod for Vintage Story: a patched client (OpenGL path) plus a Vulkan -backend behind the `IOptimumGraphicsDevice` seam. Read this before touching anything. The -skills in `.claude/skills/` hold the step-by-step procedures; this file holds the rules. - -## Where the truth lives (edit these, never the generated copies) - -| What | Edit here | Generated from it | Ships as | -|---|---|---|---| -| Game client code | `build/VintagestoryLib/**` (decompiled + patched) | `patches/VintagestoryLib/*.patch` via `scripts/extract-patches.sh` | Cecil transplant into vanilla DLL; every changed/new method or member MUST be listed in `Optimum.Patcher/Program.cs` | -| Game API | `VintagestoryApi/**` (hand-maintained fork, git-ignored) | `sources/VintagestoryApi/**` via extract | `VintagestoryAPI-patched.dll`; new files also go in `optimum-api-contracts/optimum-api-contracts.csproj` (path `..\sources\VintagestoryApi\...`) and get a `` in both `VintagestoryApi/VintagestoryAPI.csproj` and `sources/VintagestoryApi/VintagestoryAPI.csproj` | -| Mods | `VSEssentials/`, `VSSurvivalMod/`, `VSCreativeMod/` (forks) | `patches//*.patch` via extract | recompiled mod DLLs plus `Optimum.Patcher/mod-patcher.cs` manifests for the installed-runtime path | -| Shaders | `sources/shaders/*.vsh/.fsh` (override vanilla by file name) | shipped by `make deploy` and `scripts/package-*` | includes: `sources/shaderincludes/` (add to deploy and packagers when first used) | -| Vulkan backend | `Optimum.Render.Vulkan/**` | - | `Optimum.Render.Vulkan.dll` + `Silk.NET.*.dll` beside the client (`make deploy` copies them) | -| Vanilla reference | `_ref/**` and `.vanilla/**/assets` | read-only | - | - -Never edit `patches/*.patch` or `sources/VintagestoryApi/**` by hand; extract overwrites them. -`.baseline/` is the decompiled vanilla; csproj overlays are folded into it by bootstrap, so a new -`` must also be added to `.baseline/VintagestoryApi/VintagestoryAPI.csproj` locally -or extract will keep emitting a stray csproj patch. - -## Build, deploy, run, verify - -``` -dotnet build VintageStory.slnx -c Release # everything -dotnet test Optimum.Render.Vulkan.Tests # GPU tests, validation layers on (needs a GPU) -dotnet test Optimum.Tests -c Release # source/patch coverage tests -bash scripts/extract-patches.sh && bash scripts/check-patches.sh # after editing build/, forks, API -make deploy # Cecil patch + copy into .vanilla/win-x64/vintagestory -scripts/dev/run-client.sh ["world name"] # detached launch; RENDERER=vulkan|opengl env switches -scripts/dev/client-renderer.sh # which renderer ACTUALLY started (read this every time) -scripts/dev/screenshot.sh /tmp/x.png # then look at the image with Read -scripts/dev/kill-client.sh # clean close; never pkill -f from a shell that mentions the process -``` - -Data dir: `~/.config/OptimumVintagestoryData` (`clientsettings.json`, `ModConfig/optimum.json` with -`"Renderer"`). Saves: `Saves/*.vcdbs`; pass the bare world name to `-o`, not the file name. -Settings that change what you see: `ssaa` (0.5 renders at half res on BOTH backends), `fxaa`, -`ssaoQuality`, `bloom`, `godRays`, `mipMapLevel`. - -Backend diagnostics: `OPTIMUM_VULKAN_VALIDATION=1`, `OPTIMUM_RENDER_TRACE=` (per-draw -trace: `program N 'name'`, `fullscreen program= tex0= target=`, `bind unit= texture=`, -`validation:` lines), `OPTIMUM_DUMP_TEXTURES= OPTIMUM_DUMP_DIR= OPTIMUM_DUMP_AFTER_SECONDS=60` -(PPM dumps of live textures; without the delay you dump the menu), `OPTIMUM_VULKAN_STATS=`. - -## Rules that came from real failures - -1. **A launch is not a verification.** The bootstrap falls back to OpenGL silently; MangoHud only - shows on Vulkan. Grep the log for `[Optimum] Vulkan renderer` / `[Optimum] OpenGL renderer:` - before saying anything about rendering. A PR was merged on an OpenGL run because this was skipped. -2. **Look at pixels, then diff the two paths.** For any "X looks wrong on Vulkan": capture a baseline - (screenshot + trace + validation log) first, then read the GL branch and the device branch of the - same method side by side and list every state difference (sampler filter/wrap/mip/border/compare, - blend enable vs per-attachment factors, draw-buffer masks, clears, viewports, formats). The bugs - have all been parity gaps, never shader maths. Do not theorise from symptoms. -3. **Verify in the game, both backends, before claiming done.** Deploy, run, screenshot, compare with - OpenGL live. Component tests passing is not evidence for the screen. -4. **Every fix gets a GPU readback test** in `Optimum.Render.Vulkan.Tests` (pattern: - `VulkanDeviceIntegrationTests`, `AttachmentSemanticsTests`) and, for lib/patch changes, a - source-coverage test in `Optimum.Tests` (pattern: `fsr-pipeline-coverage-tests.cs`). -5. **Process hygiene.** Launch through `scripts/dev/*.sh` (setsid wrappers). Never put `pkill -f` or - `pgrep -f` in a command that also contains the process name in a heredoc or string: it matches the - calling shell and the tool dies with exit 144. Close the game with the kill script (window close - first) to avoid shutdown-race crash reports. Close the game as soon as a check is done; never leave it running. -6. **Git.** Never `git stash`. Commit WIP on the branch with a `wip:` prefix instead. Branch from - `main` (tracks `origin/main` = NightHammer1000/VulkanStory; `upstream` = StratumServer/Optimum). - Commit only when asked or when a phase is verified; say what was verified in the message. -7. **Batch reads.** Read whole methods and both paths in one command (`sed -n` ranges + `rg`), not - ten single greps. Codex found in one pass what took an afternoon of small probes. -8. **Agents cost money.** The session model is Fable. Only launch subagents with an explicit - cheaper `model` ("sonnet", "haiku") and low effort for mechanical work; Fable does the hard parts - itself. For plan reviews and stuck rendering bugs, hand off to Codex (`.claude/skills/codex-handoff`) - with symptom + repro only, no theories. - -## Testing notes -- `Optimum.Render.Vulkan.Tests` GPU tests must read back inside a frame; `BindFramebuffer`/`ClearColor` - are no-ops between frames. -- Vulkan named UBOs are per-draw snapshots (fixed 2026-09-10); the uniform ring is 32 MiB. -- Shader pairs dropped in `sources/shaders/` are auto-translated by `ShaderTranslationTests`. From a5b511a2cdff62f07ef2f7219f9aa4fa82ac1d6b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 19:55:12 +0200 Subject: [PATCH 025/226] wip(taa): P3 terrain motion writers - chunkopaque/chunktopsoil verified on GPU The vertexwarp include now evaluates through an explicit WarpState, so a motion-vector writer can run the warp twice - this frame's counters and the previous frame's - through the same code. The vanilla entry points became one-line wrappers around currentWarpState(), and a test compares each state-taking body against the vanilla include so the maths cannot drift. chunkopaque and chunktopsoil compute the previous clip position from prevRel = truePos + cameraPosDelta through the previous unjittered projection and the previous CameraMatrixOrigin, apply chunkopaque's z-fighting w-offset to it as well, and write (mv px, reactive 0, gl_FragCoord.z) to the motion attachment. Topsoil keeps vanilla's asymmetry: global warp only, no vertex warp. ClientPlatformWindows.BeginMotionWrite/EndMotionWrite put the attachment into Primary's draw-buffer mask only around the passes that write it, on both the GL and the device path, and force replace-blending on it after every blend change. ChunkRenderer wraps RenderOpaque and the AfterOIT terrain overlay; the LiquidDepth prepass stays jittered and writes nothing. Verified: GPU readback through the real programs on the Vulkan backend (TaaMotionWriterTests) - still camera = (0,0) with writerDepth 0.5, four known camera translations matching delta*0.5*renderSize to within the decode quantisation, a previous-warp-only case matching the closed-form global-warp offset, and uncovered pixels keeping writerDepth 0. Plus source coverage in Optimum.Tests, extract/check-patches clean, both suites green. Not yet verified in game. --- Makefile | 5 + .../ShaderCompatibilityScanner.cs | 39 +- Optimum.Patcher/Program.cs | 13 + .../ChunkRenderPathTests.cs | 5 +- Optimum.Render.Vulkan.Tests/ShaderCorpus.cs | 52 +- .../TaaMotionWriterTests.cs | 601 ++++++++++++++++++ .../taa-terrain-motion-coverage-tests.cs | 525 +++++++++++++++ .../ChunkRenderer.cs.patch | 159 ++++- .../ClientPlatformWindows.cs.patch | 338 +++++++--- .../ShaderRegistry.cs.patch | 21 +- scripts/package-linux.ps1 | 7 + scripts/package-linux.sh | 8 + scripts/package-macos.sh | 8 + .../Client/Render/OptimumTemporalFrame.cs | 40 ++ sources/shaderincludes/vertexwarp.vsh | 291 +++++++++ sources/shaders/chunkopaque.fsh | 32 + sources/shaders/chunkopaque.vsh | 34 + sources/shaders/chunktopsoil.fsh | 127 ++++ sources/shaders/chunktopsoil.vsh | 135 ++++ 19 files changed, 2348 insertions(+), 92 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs create mode 100644 Optimum.Tests/taa-terrain-motion-coverage-tests.cs create mode 100644 sources/shaderincludes/vertexwarp.vsh create mode 100644 sources/shaders/chunktopsoil.fsh create mode 100644 sources/shaders/chunktopsoil.vsh diff --git a/Makefile b/Makefile index a26032dc..9dd7c4de 100644 --- a/Makefile +++ b/Makefile @@ -108,6 +108,10 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client @cp $(MOD_OUT)/Silk.NET.*.dll $(VANILLA_DIR)/ @if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(VANILLA_DIR)/Lib/; fi @cp sources/shaders/*.fsh sources/shaders/*.vsh $(VANILLA_DIR)/assets/game/shaders/ + @# Shader includes (TAA P3: the WarpState vertexwarp.vsh). Same override + @# mechanism as shaders - ShaderRegistry merges both asset categories into one + @# include dictionary - but a separate directory, so it needs its own copy. + @if [ -d "sources/shaderincludes" ]; then cp sources/shaderincludes/* $(VANILLA_DIR)/assets/game/shaderincludes/; fi @if [ -d "sources/lang" ]; then for f in sources/lang/*.json; do [ -f "$$f" ] || continue; dst="$(VANILLA_DIR)/assets/game/lang/$$(basename $$f)"; [ -f "$$dst" ] || continue; python3 -c "import json,sys; s=json.load(open(sys.argv[1],encoding='utf-8-sig')); d=json.load(open(sys.argv[2],encoding='utf-8-sig')); d.update(s); json.dump(d,open(sys.argv[2],'w',encoding='utf-8'),ensure_ascii=False,indent='\t')" "$$f" "$$dst"; done; fi @if [ -d "$(INSTALL_DIR)" ]; then \ echo "Deploying to $(INSTALL_DIR)..."; \ @@ -122,6 +126,7 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client cp $(MOD_OUT)/cairo-sharp.dll $(INSTALL_DIR)/Lib/; \ cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(INSTALL_DIR)/; cp $(MOD_OUT)/Silk.NET.*.dll $(INSTALL_DIR)/; \ cp sources/shaders/*.fsh sources/shaders/*.vsh $(INSTALL_DIR)/assets/game/shaders/; \ + if [ -d "sources/shaderincludes" ]; then cp sources/shaderincludes/* $(INSTALL_DIR)/assets/game/shaderincludes/; fi; \ if [ -d "sources/lang" ]; then for f in sources/lang/*.json; do [ -f "$$f" ] || continue; dst="$(INSTALL_DIR)/assets/game/lang/$$(basename $$f)"; [ -f "$$dst" ] || continue; python3 -c "import json,sys; s=json.load(open(sys.argv[1],encoding='utf-8-sig')); d=json.load(open(sys.argv[2],encoding='utf-8-sig')); d.update(s); json.dump(d,open(sys.argv[2],'w',encoding='utf-8'),ensure_ascii=False,indent='\t')" "$$f" "$$dst"; done; fi; \ fi @echo "Deploy complete." diff --git a/Optimum.Launcher/ShaderCompatibilityScanner.cs b/Optimum.Launcher/ShaderCompatibilityScanner.cs index 65ab2b52..c56f7f2d 100644 --- a/Optimum.Launcher/ShaderCompatibilityScanner.cs +++ b/Optimum.Launcher/ShaderCompatibilityScanner.cs @@ -319,6 +319,26 @@ private static void FinalizeReport(ShaderCompatibilityReport report) AddFeatureDecision(report, "Vulkan", rawOpenGl, "a mod calls OpenGL directly, which the Vulkan backend cannot serve"); + // "Taa" is deliberately absent from ShaderFeatures for the same reason as + // "Vulkan": OptimumConfig.EffectiveTaa consults IsFeatureExplicitlyDisabled, + // so a missing scan must not silently veto a renderer feature the user + // asked for - only an explicit verdict does. + // + // An external copy of any shader Optimum's motion-vector writers live in, + // or of the vertexwarp include they evaluate twice, replaces the writer + // with one that emits nothing to the motion attachment. The resolve would + // then reproject those pixels by camera motion alone while everything + // around them used real vectors, which is worse than not running TAA. + bool externalMotionShader = + HasExternalShader(report, "chunkopaque.vsh") || HasExternalShader(report, "chunkopaque.fsh") || + HasExternalShader(report, "chunktopsoil.vsh") || HasExternalShader(report, "chunktopsoil.fsh") || + HasExternalShader(report, "entityanimated.vsh") || HasExternalShader(report, "entityanimated.fsh") || + HasExternalShader(report, "standard.vsh") || HasExternalShader(report, "standard.fsh") || + HasExternalShader(report, "instanced.vsh") || HasExternalShader(report, "instanced.fsh") || + HasExternalShader(report, "vertexwarp.vsh"); + AddFeatureDecision(report, "Taa", externalMotionShader, + "external shader owns a motion-vector writer contract"); + if (report.ScanFailed) { foreach (string feature in ShaderFeatures) @@ -402,7 +422,24 @@ private static bool IsOptimumSource(string path, string gameDir) } else { - return null; + // Optimum: shaderincludes is a first-class asset category that + // ShaderRegistry merges into the same include dictionary as + // shaders, so an external vertexwarp.vsh replaces Optimum's copy + // exactly the way an external chunkopaque.vsh would - and with it + // the WarpState overloads the motion-vector writers evaluate. + marker = normalized.IndexOf("/shaderincludes/", StringComparison.OrdinalIgnoreCase); + if (marker >= 0) + { + shader = normalized[(marker + 1)..]; + } + else if (normalized.StartsWith("shaderincludes/", StringComparison.OrdinalIgnoreCase)) + { + shader = normalized; + } + else + { + return null; + } } } diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index a145948e..c871eacc 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -150,6 +150,12 @@ "taaResolvedGlowTexture", "TaaResolvedThisFrame", "RenderOptimumTaaResolve", + // TAA P3: the motion-attachment draw-buffer window the terrain (and + // later entity/standard/instanced) writers open around their draws. + "OptimumMotionWriteActive", + "BeginMotionWrite", + "EndMotionWrite", + "ApplyOptimumMotionBlendState", }, ["Vintagestory.Client.NoObf.ShaderPrograms"] = new() { @@ -256,6 +262,8 @@ "optimumTextureLodBias", "ApplyOptimumTextureLodBias", "SetOptimumTextureLodBias", + // TAA P3: previous-frame transforms for the terrain motion writers. + "SetOptimumMotionUniforms", }, // ChunkTesselatorManager: skip RecalcPriority+Sort when the player hasn't moved // (_lastSortPlayerPos/_lastSortYaw), plus the multi-tesselator worker pool and @@ -480,6 +488,11 @@ // FSR mip bias: refresh block atlas texture state after scale or atlas changes. new("Vintagestory.Client.NoObf.ChunkRenderer", "OnBeforeRenderOpaque", 1), new("Vintagestory.Client.NoObf.ChunkRenderer", "RuntimeAddBlockTextureAtlas", 1), + // TAA P3: terrain motion-vector writers - the opaque pass, the AfterOIT + // terrain overlay (pass 7) and the LiquidDepth prepass comment that records + // why it stays jittered but writes no motion. + new("Vintagestory.Client.NoObf.ChunkRenderer", "RenderAfterOIT", 1), + new("Vintagestory.Client.NoObf.ChunkRenderer", "OnRenderBefore", 1), // TAA P1: temporal frame contract - Advance()/JitterActive wiring in the // render loop, the jittered projection getter, its capture at both // Set3DProjection call sites, and the resets (FOV change, resize, world diff --git a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs index 6370f514..ac5a6fa1 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -157,7 +157,10 @@ public void TheRealChunkProgramTranslatesAndBuildsAPipeline() textures.Delete(target); } - Assert.Equal(4, built); + // One pipeline per corpus variant; the count follows the variant table + // rather than a literal, so adding a define row (TAA on/off) does not + // silently turn this into a weaker assertion. + Assert.Equal(ShaderCorpus.Variants().Count(), built); ValidationAssert.NoErrors(messages); } } diff --git a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs index 11e0f261..c9b0f145 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs @@ -100,18 +100,37 @@ public static Dictionary LoadShaderFiles() return files; } + /// + /// The shader includes, with Optimum's own overlays replacing their vanilla + /// counterparts - the same relationship has, + /// and the same one `make deploy` and the package scripts produce on disk. + /// Without the overlay the corpus would translate the vanilla vertexwarp.vsh + /// while the client runs Optimum's WarpState one. + /// public static Dictionary LoadIncludes() { var includes = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (AssetRoot == null) return includes; - - string directory = Path.Combine(AssetRoot, "shaderincludes"); - if (!Directory.Exists(directory)) return includes; + if (AssetRoot != null) + { + string directory = Path.Combine(AssetRoot, "shaderincludes"); + if (Directory.Exists(directory)) + { + foreach (string path in Directory.EnumerateFiles(directory)) + { + includes[Path.GetFileName(path)] = File.ReadAllText(path); + } + } + } - foreach (string path in Directory.EnumerateFiles(directory)) + string overlays = Path.Combine(RepositoryRoot, "sources", "shaderincludes"); + if (Directory.Exists(overlays)) { - includes[Path.GetFileName(path)] = File.ReadAllText(path); + foreach (string path in Directory.EnumerateFiles(overlays)) + { + includes[Path.GetFileName(path)] = File.ReadAllText(path); + } } + return includes; } @@ -170,6 +189,10 @@ public sealed class ShaderVariant public int GreedyMesh; public float MinBright; public int MaxAnimatedElements = 35; + /// TAA motion-vector writers compiled in (OptimumConfig.EffectiveTaa). + public int TaaMotion; + /// Primary colour attachment the motion texture occupies: 4 with the SSAO G-buffer, 2 without. + public int TaaMotionLocation = 2; public override string ToString() => Name; } @@ -207,6 +230,19 @@ public static IEnumerable Variants() Name = "shadows-and-ssbo", ShadowQuality = 2, DynLights = 8, UseSsbo = 1, }; + // TAA on, without the SSAO G-buffer (motion at attachment 2) and with it + // (attachment 4): the motion-vector writers only exist in these, and the + // two rows move the output location the same way the client does. + yield return new ShaderVariant + { + Name = "taa-no-ssao", + TaaMotion = 1, TaaMotionLocation = 2, + }; + yield return new ShaderVariant + { + Name = "taa-with-ssao", + SsaoLevel = 2, DynLights = 4, TaaMotion = 1, TaaMotionLocation = 4, + }; } /// @@ -231,6 +267,8 @@ public static string PrefixFor(EnumShaderType stage, ShaderVariant variant) lines.Add($"#define USEOIT {variant.UseOit}"); lines.Add($"#define GREEDYMESH {variant.GreedyMesh}"); lines.Add($"#define GREEDYMESH_GRAD 0"); + lines.Add($"#define TAAMOTION {variant.TaaMotion}"); + lines.Add($"#define TAAMOTIONLOCATION {variant.TaaMotionLocation}"); } else { @@ -246,6 +284,8 @@ public static string PrefixFor(EnumShaderType stage, ShaderVariant variant) lines.Add($"#define DYNLIGHTS {variant.DynLights}"); lines.Add($"#define MAXANIMATEDELEMENTS {variant.MaxAnimatedElements}"); lines.Add($"#define GREEDYMESH {variant.GreedyMesh}"); + lines.Add($"#define TAAMOTION {variant.TaaMotion}"); + lines.Add($"#define TAAMOTIONLOCATION {variant.TaaMotionLocation}"); } return string.Join("\r\n", lines) + "\r\n"; diff --git a/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs new file mode 100644 index 00000000..9eb17f5c --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs @@ -0,0 +1,601 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The terrain motion-vector writers (TAA P3), driven through the seam with the +/// real chunkopaque and chunktopsoil programs and read back as pixels. +/// +/// The contract under test is the one taa-resolve.fsh consumes: the motion +/// attachment carries rg = previousPixel - currentUnjitteredPixel in +/// render pixels, b = reactive, a = the writer's window depth. A +/// sign flip, an axis swap or a forgotten 0.5 in the NDC-to-pixel conversion all +/// look the same in a "motion is zero when nothing moves" test, so every case +/// here is a known displacement with an exact expected magnitude, and the still +/// case is only the baseline. +/// +/// The motion attachment is RGBA16F and ReadDefaultFramebuffer reads four +/// bytes per pixel from colour attachment 0, so the values come back through a +/// second fullscreen pass that decodes them into an RGBA8 target. That is a +/// readback detail, not part of the contract: the decode is a plain +/// texelFetch with a fixed scale. +/// +public class TaaMotionWriterTests +{ + private readonly ITestOutputHelper _output; + + public TaaMotionWriterTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Normal pointing up, no glow, no z-offset and - crucially - no wind mode bits. + private const int UpNormalFlags = 7 << 18; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + // ------------------------------------------------------------------ tests + + /// + /// A camera that did not move produces no motion at all, and the writer + /// still stamps its own depth so the resolve accepts the pixel rather than + /// silently falling back to camera reprojection. + /// + [SkippableTheory] + [InlineData("chunkopaque")] + [InlineData("chunktopsoil")] + public void AStillCameraWritesZeroMotionAndTheFragmentDepth(string programName) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderMotion(device!, programName, 0f, 0f, previousGlobalWarp: 0f); + + _output.WriteLine($"{programName} still: mv = ({centre.MotionX}, {centre.MotionY}), writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + // Identity matrices put the quad on the near-ish middle of the + // depth range: 0 in NDC, which is 0.5 as a window depth on both + // backends (the translator's (z+w)*0.5 remap lands on the same + // value Vulkan's [0,1] clip range expects). + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The camera translating by a known amount moves every static surface by + /// exactly that amount, converted into pixels: with identity matrices a + /// camera-relative displacement of d in NDC is d * 0.5 * renderSize pixels, + /// and the sign is "where the pixel was", not "where it went". + /// + [SkippableTheory] + [InlineData("chunkopaque", 0.25f, 0f)] + [InlineData("chunkopaque", 0f, -0.125f)] + [InlineData("chunkopaque", -0.1875f, 0.0625f)] + [InlineData("chunktopsoil", 0.25f, -0.125f)] + public void ACameraTranslationShowsUpAsTheExactPixelDisplacement( + string programName, float deltaX, float deltaY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderMotion(device!, programName, deltaX, deltaY, previousGlobalWarp: 0f); + + // prevRel = truePos + cameraPosDelta, both matrices identity, so the + // previous clip position differs from the current one by exactly the + // delta and the pixel difference is delta * 0.5 * Size. + float expectedX = deltaX * 0.5f * Size; + float expectedY = deltaY * 0.5f * Size; + + _output.WriteLine($"{programName} delta ({deltaX}, {deltaY}): mv = " + + $"({centre.MotionX}, {centre.MotionY}), expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// Vertex animation that differed last frame is motion too. + /// + /// The previous warp state is fed through the same code as the current one + /// (the WarpState overloads in the vertexwarp include), so a previous global + /// warp intensity that this frame no longer has must displace the previous + /// position and nothing else. The values are chosen so applyGlobalWarping's + /// phase argument saturates at zero over the whole quad, which makes the warp + /// a constant offset and the expected motion exactly computable rather than a + /// "not zero" assertion. + /// + [SkippableTheory] + [InlineData("chunkopaque")] + [InlineData("chunktopsoil")] + public void APreviousWarpStateThatDiffersFromThisFrameProducesItsOwnMotion(string programName) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float previousWarp = 8f; + Decoded centre = RenderMotion(device!, programName, 0f, 0f, previousWarp); + + // applyGlobalWarpingState with a phase of zero: + // worldPos.x += (sin(0) + sin(0.5) + sin(1)/3) / 30 * intensity + // and nothing on y, so the whole quad shifts by a constant in x only. + double offsetX = (Math.Sin(0.0) + Math.Sin(0.5) + Math.Sin(1.0) / 3.0) / 30.0 * previousWarp; + float expectedX = (float)(offsetX * 0.5 * Size); + + _output.WriteLine($"{programName} warp-only: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, 0)"); + + // The check only means something if the displacement is well clear of + // the decode quantisation and of zero. + Assert.True(Math.Abs(expectedX) > 1f, "the warp displacement chosen is too small to test"); + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + } + } + + /// + /// A pixel no writer covered keeps a zero alpha, which is what makes the + /// resolve's writer-depth test reject it and use the camera fallback. If the + /// motion attachment were in the default draw-buffer set, or the clear were + /// skipped, this would hold another surface's vector instead. + /// + [SkippableFact] + public void PixelsNoWriterCoveredKeepAZeroWriterDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded corner = RenderMotion(device!, "chunkopaque", 0.25f, 0f, 0f, sampleCorner: true); + + _output.WriteLine($"corner: mv = ({corner.MotionX}, {corner.MotionY}), writerDepth = {corner.WriterDepth}"); + Assert.InRange(corner.WriterDepth, 0f, 0.01f); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float WriterDepth { get; } + } + + /// + /// Draws one block face with the given terrain program compiled as a motion + /// writer, then decodes the motion attachment and returns the centre (or + /// corner) pixel. + /// + private unsafe Decoded RenderMotion( + VulkanDevice device, + string programName, + float cameraDeltaX, + float cameraDeltaY, + float previousGlobalWarp, + bool sampleCorner = false) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(v => v.Name == "taa-no-ssao"); + Assert.Equal(1, variant.TaaMotion); + Assert.Equal(2, variant.TaaMotionLocation); + + List stages = ShaderCorpus.BuildProgram(programName, files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, programName); + + // The writer only exists if the shader really declares it; without this + // the test would pass on a shader that dropped the output entirely. + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + programName + " declares no taaRenderSize, so it is not a motion writer"); + + int nextUnit = BindEveryDeclaredSampler(device, seam, program); + int atlas = CreateWhiteTexture(seam); + foreach (string samplerName in new[] { "terrainTex", "terrainTexLinear" }) + { + seam.SetSamplerUnit(program, samplerName, nextUnit); + seam.BindTexture(nextUnit, atlas); + nextUnit++; + } + + // Primary stand-in: colour, glow and the motion attachment at index 2, + // which is where SetupDefaultFrameBuffers puts it without the SSAO + // G-buffer and what TAAMOTIONLOCATION was stamped with above. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + // The motion attachment enabled for the duration of the writing pass - + // exactly what ClientPlatformWindows.BeginMotionWrite does to Primary. + seam.SetDrawBuffers(scene, 0b111); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + int mesh = seam.CreateMesh(BuildBlockFace(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.UseProgram(program); + SetMatrix(seam, program, "projectionMatrix", Identity); + SetMatrix(seam, program, "modelViewMatrix", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixFar", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); + SetViewUniforms(seam, program); + SetWarpUniforms(seam, program, previousGlobalWarp); + SetMatrix(seam, program, "prevProjectionMatrix", Identity); + SetMatrix(seam, program, "prevModelViewMatrix", Identity); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", 0f, 0f); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(mesh); + + byte[] decoded = DecodeMotion(seam, motion); + seam.Present(); + + int x = sampleCorner ? 2 : Size / 2; + int y = sampleCorner ? 2 : Size / 2; + int offset = (y * Size + x) * 4; + + AssertClean(seam); + + return new Decoded( + (decoded[offset] / 255f * 2f - 1f) * DecodeScale, + (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, + decoded[offset + 2] / 255f); + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-motion-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-motion-decode.fsh" }, + }, "taa-motion-decode"); + + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int quadMesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + private static MeshData BuildBlockFace() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + Vintagestory.API.MathTools.ColorUtil.WhiteArgb, + flags: UpNormalFlags); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + return mesh; + } + + /// + /// Both halves of the warp state, pinned so the current frame's warp is a + /// no-op and only the previous one moves. Set explicitly rather than left at + /// zero: an unset uniform is a defined zero in GL but the value that happens + /// to be in the block on the device path, and this test's whole point is the + /// difference between the two states. + /// + private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, float previousGlobalWarp) + { + SetFloat(seam, program, "timeCounter", 0f); + SetFloat(seam, program, "windWaveCounter", 0f); + SetFloat(seam, program, "windWaveCounterHighFreq", 0f); + SetFloat(seam, program, "waterWaveCounter", 0f); + SetFloat(seam, program, "windSpeed", 0f); + SetFloat(seam, program, "globalWarpIntensity", 0f); + SetFloat(seam, program, "glitchWaviness", 0f); + SetFloat(seam, program, "windWaveIntensity", 1f); + SetFloat(seam, program, "waterWaveIntensity", 1f); + SetInt(seam, program, "perceptionEffectId", 1); + SetFloat(seam, program, "perceptionEffectIntensity", 0f); + SetFloat3(seam, program, "playerpos", 0f, 0f, 0f); + SetFloat3(seam, program, "origin", 0f, 0f, 0f); + + SetFloat(seam, program, "prevTimeCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounterHighFreq", 0f); + SetFloat(seam, program, "prevWaterWaveCounter", 0f); + SetFloat(seam, program, "prevWindSpeed", 0f); + SetFloat(seam, program, "prevGlobalWarpIntensity", previousGlobalWarp); + SetFloat(seam, program, "prevGlitchWaviness", 0f); + SetFloat(seam, program, "prevWindWaveIntensity", 1f); + SetFloat(seam, program, "prevWaterWaveIntensity", 1f); + SetInt(seam, program, "prevPerceptionEffectId", 1); + SetFloat(seam, program, "prevPerceptionEffectIntensity", 0f); + SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); + } + + private static void SetViewUniforms(IOptimumGraphicsDevice seam, int program) + { + SetFloat(seam, program, "viewDistance", 1024f); + SetFloat(seam, program, "viewDistanceLod0", 1024f); + SetFloat(seam, program, "alphaTest", 0.001f); + SetFloat(seam, program, "zNear", 0.1f); + SetFloat(seam, program, "zFar", 1024f); + SetFloat(seam, program, "shadowRangeFar", 1024f); + SetFloat(seam, program, "shadowRangeNear", 64f); + SetFloat(seam, program, "shadowMapWidthInv", 1f); + SetFloat(seam, program, "shadowMapHeightInv", 1f); + SetFloat(seam, program, "subpixelPaddingX", 0f); + SetFloat(seam, program, "subpixelPaddingY", 0f); + SetFloat2(seam, program, "blockTextureSize", 1f, 1f); + SetFloat3(seam, program, "rgbaAmbientIn", 1f, 1f, 1f); + SetFloat2(seam, program, "frameSize", Size, Size); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + { + var white = new byte[] { 255, 255, 255, 255 }; + fixed (byte* pixels = white) + { + return seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + } + + private static unsafe int BindEveryDeclaredSampler( + VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + { + int unit = 0; + foreach (string samplerName in device.SamplerNamesOf(programId)) + { + int texture = CreateWhiteTexture(seam); + seam.SetSamplerUnit(programId, samplerName, unit); + seam.BindTexture(unit, texture); + unit++; + } + return unit; + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs new file mode 100644 index 00000000..0124b727 --- /dev/null +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -0,0 +1,525 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P3 terrain motion-vector writers: the shared +/// vertexwarp WarpState include, the chunkopaque/chunktopsoil writers, the +/// draw-buffer window on both backends, and the plumbing that has to ship them +/// (patcher entries, deploy and package copies, the compatibility scanner). +/// +/// These are text assertions, which only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaMotionWriterTests) proves the numbers. What +/// they catch is the failure this project keeps hitting: a change that works in +/// the build tree and never reaches the installed runtime because a patcher +/// entry or a packaging copy was missed. +/// +public class TaaTerrainMotionCoverageTests +{ + // ------------------------------------------------------------- the include + + [Fact] + public void TheVertexWarpOverrideCarriesAWarpStateAndKeepsTheVanillaEntryPoints() + { + string warp = Read("sources/shaderincludes/vertexwarp.vsh"); + + // Every uniform the warp functions read, in one struct, so a writer can + // evaluate them twice. + Assert.Contains("struct WarpState", warp); + foreach (string member in new[] + { + "float timeCounter;", "float windWaveCounter;", "float windWaveCounterHighFreq;", + "float waterWaveCounter;", "float windSpeed;", "vec3 playerpos;", + "float globalWarpIntensity;", "float glitchWaviness;", "float windWaveIntensity;", + "float waterWaveIntensity;", "int perceptionEffectId;", "float perceptionEffectIntensity;", + }) + { + Assert.Contains(member, warp); + } + + // The previous-frame uniforms and the accessor pair. + foreach (string uniform in new[] + { + "prevTimeCounter", "prevWindWaveCounter", "prevWindWaveCounterHighFreq", + "prevWaterWaveCounter", "prevWindSpeed", "prevPlayerpos", "prevGlobalWarpIntensity", + "prevGlitchWaviness", "prevWindWaveIntensity", "prevWaterWaveIntensity", + "prevPerceptionEffectId", "prevPerceptionEffectIntensity", + }) + { + Assert.Contains("uniform ", warp); + Assert.Contains(uniform, warp); + } + Assert.Contains("WarpState currentWarpState()", warp); + Assert.Contains("WarpState previousWarpState()", warp); + + // The state-taking forms every writer calls. + Assert.Contains("vec3 applyPerceptionWarpingState(WarpState st, vec3 worldPos)", warp); + Assert.Contains("vec4 applyLiquidWarpingState(WarpState st, bool windAffected, vec4 worldPos, float div)", warp); + Assert.Contains("vec4 applyVertexWarpingState(WarpState st, int renderFlags, vec4 worldPos)", warp); + Assert.Contains("vec4 applyGlobalWarpingState(WarpState st, vec4 worldPos)", warp); + + // And the vanilla entry points, unchanged in signature, delegating to the + // current state - every other shader in the game includes this file. + Assert.Contains("vec3 applyPerceptionWarping(vec3 worldPos) {\n\treturn applyPerceptionWarpingState(currentWarpState(), worldPos);", warp.Replace("\r\n", "\n")); + Assert.Contains("vec4 applyLiquidWarping(bool windAffected, vec4 worldPos, float div) {\n\treturn applyLiquidWarpingState(currentWarpState(), windAffected, worldPos, div);", warp.Replace("\r\n", "\n")); + Assert.Contains("vec4 applyVertexWarping(int renderFlags, vec4 worldPos) {\n\treturn applyVertexWarpingState(currentWarpState(), renderFlags, worldPos);", warp.Replace("\r\n", "\n")); + Assert.Contains("vec4 applyGlobalWarping(vec4 worldPos) {\n\treturn applyGlobalWarpingState(currentWarpState(), worldPos);", warp.Replace("\r\n", "\n")); + } + + /// + /// The maths inside the state-taking functions is the vanilla maths, with the + /// uniform reads replaced by struct reads and nothing else. Compared against + /// the vanilla include when the checkout has been bootstrapped; skipped + /// rather than failed when the proprietary assets are absent. + /// + [Fact] + public void TheStateOverloadsAreVanillaMathsWithTheUniformsReadFromTheStruct() + { + string? vanillaPath = TryFind(".vanilla/win-x64/vintagestory/assets/game/shaderincludes/vertexwarp.vsh"); + // The vanilla shaders are proprietary and never committed; a checkout + // that has not bootstrapped has nothing to compare against. + if (vanillaPath == null) return; + + string vanilla = File.ReadAllText(vanillaPath!); + string ours = Read("sources/shaderincludes/vertexwarp.vsh"); + + foreach ((string vanillaSignature, string ourSignature) in new[] + { + ("vec3 applyPerceptionWarping(vec3", "vec3 applyPerceptionWarpingState(WarpState st, vec3"), + ("vec4 applyLiquidWarping(bool", "vec4 applyLiquidWarpingState(WarpState st, bool"), + ("vec4 applyVertexWarping(int", "vec4 applyVertexWarpingState(WarpState st, int"), + ("vec4 applyGlobalWarping(vec4", "vec4 applyGlobalWarpingState(WarpState st, vec4"), + }) + { + string expected = Normalize(BodyOf(vanilla, vanillaSignature)); + // The only permitted differences: the uniform reads became struct + // reads, and the two internal calls reach the state-taking form. + string actual = Normalize(BodyOf(ours, ourSignature)) + .Replace("applyPerceptionWarpingState(st, ", "applyPerceptionWarping(") + .Replace("applyLiquidWarpingState(st, ", "applyLiquidWarping(") + .Replace("st.", ""); + Assert.Equal(expected, actual); + } + } + + // ------------------------------------------------------------- the writers + + [Theory] + [InlineData("chunkopaque")] + [InlineData("chunktopsoil")] + public void TheTerrainWritersEmitTheMotionContract(string program) + { + string vertex = Read("sources/shaders/" + program + ".vsh"); + string fragment = Read("sources/shaders/" + program + ".fsh"); + + // Compiled in only while TAA is on, so TAA off preprocesses to vanilla. + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("#if TAAMOTION > 0", fragment); + + // Previous transforms and the camera's own movement. + Assert.Contains("uniform mat4 prevProjectionMatrix;", vertex); + Assert.Contains("uniform mat4 prevModelViewMatrix;", vertex); + Assert.Contains("uniform vec3 cameraPosDelta;", vertex); + Assert.Contains("out vec4 taaPrevClip;", vertex); + + // prevRel = truePos + cameraPosDelta, warped with the previous state, + // through the previous unjittered projection and view. + Assert.Contains("WarpState taaPrev = previousWarpState();", vertex); + Assert.Contains("vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0);", vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos);", vertex); + Assert.Contains("taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos);", vertex); + + // The output goes to the attachment index the C# side stamps. + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + Assert.Contains("uniform vec2 taaRenderSize;", fragment); + Assert.Contains("uniform vec2 taaJitterPx;", fragment); + + // rg = previousPixel - currentUnjitteredPixel, b = reactive, a = window depth. + Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + Assert.Contains("return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z);", fragment); + // A previous position behind the previous camera is not a motion vector; + // a zero alpha routes the pixel to the resolve's camera fallback. + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + // Opaque terrain is never reactive. + Assert.Contains("taaMotionVector(0.0)", fragment); + } + + /// + /// chunkopaque applies a w-offset to beat z-fighting, which moves where the + /// fragment lands. Leaving it off the previous position would report that + /// offset as motion on every z-offset block. + /// + [Fact] + public void ChunkopaquesZFightingOffsetIsAppliedToThePreviousClipPositionToo() + { + string vertex = Read("sources/shaders/chunkopaque.vsh"); + + Assert.Contains("gl_Position.w += zOffset * 0.00025 / ((gl_Position.z + 3) * 0.05);", vertex); + Assert.Contains("taaPrevClip.w += taaPrevZOffset * 0.00025 / ((taaPrevClip.z + 3) * 0.05);", vertex); + Assert.Contains("int taaPrevZOffset = (renderFlags & ZOffsetBitMask) >> 8;", vertex); + } + + /// + /// Vanilla topsoil has applyVertexWarping commented out and only applies the + /// global warp. The previous position must reproduce that asymmetry, or every + /// grass-topped block reports a warp it never had. + /// + [Fact] + public void TopsoilsPreviousPositionSkipsTheVertexWarpJustAsVanillaDoes() + { + string vertex = Read("sources/shaders/chunktopsoil.vsh"); + + Assert.Contains("//worldPos = applyVertexWarping(renderFlags, worldPos);", vertex); + Assert.Contains("worldPos = applyGlobalWarping(worldPos);", vertex); + Assert.DoesNotContain("applyVertexWarpingState(", vertex); + Assert.Contains("applyGlobalWarpingState(taaPrev, taaPrevPos);", vertex); + // And no z-offset: vanilla topsoil has none either. + Assert.DoesNotContain("ZOffsetBitMask", vertex); + } + + // ------------------------------------------------------------ the defines + + [Fact] + public void ShaderRegistryStampsTheMotionDefinesOnBothStages() + { + string registry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + + Assert.Contains("bool taaMotion = OptimumConfig.EffectiveTaa;", registry); + // The location must follow the same condition SetupDefaultFrameBuffers + // sizes Primary's colour attachments with (SetupSSAO), or the writer + // emits into a slot the framebuffer does not have. + Assert.Contains("int taaMotionLocation = ((ClientSettings.SSAOQuality > 0) ? 4 : 2);", registry); + Assert.Contains("#define TAAMOTION \" + (taaMotion ? 1 : 0) + \"\\r\\n#define TAAMOTIONLOCATION \" + taaMotionLocation", registry); + Assert.Contains("taaFrag.PrefixCode = taaFrag.PrefixCode + taaDefines;", registry); + Assert.Contains("taaVert.PrefixCode = taaVert.PrefixCode + taaDefines;", registry); + } + + // ------------------------------------------------- the draw-buffer window + + [Fact] + public void ThePlatformOpensAndClosesTheMotionDrawBufferOnBothBackends() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.Contains("public bool BeginMotionWrite()", platform); + Assert.Contains("public void EndMotionWrite()", platform); + Assert.Contains("public bool OptimumMotionWriteActive { get; private set; }", platform); + + // Guards: no motion attachment, no TAA targets, TAA switched off, or a + // window already open - all no-ops, so callers can wrap unconditionally. + Assert.Contains("if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false;", platform); + Assert.Contains("if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false;", platform); + Assert.Contains("if (OptimumMotionWriteActive) return false;", platform); + + // Device path: mask including the motion attachment, then back to the + // default set (whose size is the attachment's own index). + Assert.Contains( + "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", + platform); + Assert.Contains( + "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", + platform); + + // GL path: the same two sets, built as DrawBuffers arrays. + Assert.Contains("DrawBuffersEnum[] optimumMotionDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1];", platform); + Assert.Contains("DrawBuffersEnum[] optimumRestoreDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex];", platform); + } + + /// + /// Terrain passes 2 and 8 draw with blending on. A blended motion vector is a + /// weighted average of two surfaces' displacements and belongs to neither, so + /// the attachment gets replace-blending re-applied after every global blend + /// change - the same treatment the SSAO G-buffer already gets. + /// + [Fact] + public void TheMotionAttachmentNeverBlends() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.Contains("private void ApplyOptimumMotionBlendState()", platform); + Assert.Contains("optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0);", platform); + Assert.Contains("GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0);", platform); + + // GlToggleBlend re-applies it, including on the early-returning blend + // modes, because glBlendFunc resets every attachment's function. + int toggle = platform.IndexOf("public override void GlToggleBlend(bool on, EnumBlendMode blendMode", StringComparison.Ordinal); + Assert.True(toggle >= 0); + string body = platform.Substring(toggle); + Assert.True(Count(body, "ApplyOptimumMotionBlendState();") >= 6, + "every blend-mode branch has to re-apply the motion attachment's replace blending"); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"GlToggleBlend\", 2", Read("Optimum.Patcher/Program.cs")); + } + + // ------------------------------------------------------------- the caller + + [Fact] + public void ChunkRendererWrapsTheOpaqueAndAfterOitPassesAndSetsThePreviousTransforms() + { + string chunk = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + // The previous transforms come from the frame contract, and the + // projection is the UNJITTERED one - a jittered matrix would put two + // frames' jitter difference into every vector. + Assert.Contains("private void SetOptimumMotionUniforms(ShaderProgram program)", chunk); + Assert.Contains("program.UniformMatrix(\"prevProjectionMatrix\", frame.GetPrevProjection(EnumTemporalView.World));", chunk); + Assert.Contains("program.UniformMatrix(\"prevModelViewMatrix\", frame.PrevCameraMatrixOrigin);", chunk); + Assert.Contains("frame.ApplyMotionUniforms(program);", chunk); + + // Both passes open and close the window, and both terrain programs get + // the uniforms. + Assert.Equal(2, Count(chunk, "bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite();")); + Assert.Equal(2, Count(chunk, "optimumPlatform.EndMotionWrite();")); + Assert.Contains("SetOptimumMotionUniforms(chunkopaque);", chunk); + Assert.Contains("SetOptimumMotionUniforms(chunktopsoil);", chunk); + } + + /// + /// The LiquidDepth prepass keeps the jittered projection (its depth is + /// compared against the jittered scene) and writes no motion: it renders into + /// its own framebuffer and never opens the window. + /// + [Fact] + public void TheLiquidDepthPrepassStaysJitteredAndWritesNoMotion() + { + string chunk = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + string prepass = MethodBodyAfter(chunk, "public void OnRenderBefore(float dt)"); + + Assert.Contains("chunkliquiddepth.ProjectionMatrix = game.CurrentProjectionMatrix;", prepass); + Assert.DoesNotContain("BeginMotionWrite", prepass); + Assert.DoesNotContain("SetOptimumMotionUniforms", prepass); + } + + // ------------------------------------------------------ contract uniforms + + /// + /// The shared uniform names the writers declare and the ones the frame + /// contract sets have to be the same strings; a typo on either side is a + /// silent zero, which looks exactly like "the surface did not move". + /// + [Fact] + public void TheFrameContractSetsExactlyTheUniformNamesTheWritersDeclare() + { + string frame = Read("sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + string warp = Read("sources/shaderincludes/vertexwarp.vsh"); + string fragment = Read("sources/shaders/chunkopaque.fsh"); + string vertex = Read("sources/shaders/chunkopaque.vsh"); + + Assert.Contains("public void ApplyMotionUniforms(IShaderProgram program)", frame); + + // The previous warp state lives in the include both writers pull in. + foreach (string name in new[] + { + "prevTimeCounter", "prevWindWaveCounter", "prevWindWaveCounterHighFreq", + "prevWaterWaveCounter", "prevWindSpeed", "prevGlobalWarpIntensity", + "prevGlitchWaviness", "prevWindWaveIntensity", "prevWaterWaveIntensity", + "prevPerceptionEffectId", "prevPerceptionEffectIntensity", "prevPlayerpos", + }) + { + // Guarded by HasUniform, because ShaderProgram.Uniform throws on a + // name the program does not declare and the writers vanish with TAA off. + Assert.Contains("if (program.HasUniform(\"" + name + "\")) program.Uniform(\"" + name + "\"", frame); + Assert.True(DeclaresUniform(warp, name), name + " is set by the frame contract but declared by no shader"); + } + + // The screen-space pair is declared by the fragment writer, the camera + // delta by the vertex writer. + foreach (string name in new[] { "taaRenderSize", "taaJitterPx" }) + { + Assert.Contains("if (program.HasUniform(\"" + name + "\")) program.Uniform(\"" + name + "\"", frame); + Assert.True(DeclaresUniform(fragment, name), name + " is set by the frame contract but declared by no shader"); + } + Assert.Contains("if (program.HasUniform(\"cameraPosDelta\")) program.Uniform(\"cameraPosDelta\"", frame); + Assert.True(DeclaresUniform(vertex, "cameraPosDelta")); + } + + // -------------------------------------------------------------- the ship + + [Fact] + public void CecilPatcherShipsEveryTerrainMotionMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"BeginMotionWrite\"", patcher); + Assert.Contains("\"EndMotionWrite\"", patcher); + Assert.Contains("\"OptimumMotionWriteActive\"", patcher); + Assert.Contains("\"ApplyOptimumMotionBlendState\"", patcher); + Assert.Contains("\"SetOptimumMotionUniforms\"", patcher); + + Assert.Contains("\"Vintagestory.Client.NoObf.ChunkRenderer\", \"RenderOpaque\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ChunkRenderer\", \"RenderAfterOIT\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ChunkRenderer\", \"OnRenderBefore\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ShaderRegistry\", \"registerDefaultShaderCodePrefixes\", 2", patcher); + } + + /// + /// Shader includes are a separate asset directory from shaders, so they need + /// their own copy in the deploy target and in every packaging script - + /// without it the WarpState vertexwarp.vsh never reaches a running client and + /// the writers silently compile against the vanilla one. + /// + [Fact] + public void DeployAndEveryPackagerShipTheShaderIncludes() + { + Assert.Contains("sources/shaderincludes", Read("Makefile")); + Assert.Equal(2, Count(Read("Makefile"), "assets/game/shaderincludes")); + + foreach (string script in new[] + { + "scripts/package-linux.sh", "scripts/package-macos.sh", "scripts/package-linux.ps1", + }) + { + string text = Read(script); + Assert.Contains("sources/shaderincludes", text); + Assert.Contains("assets/game/shaderincludes", text); + } + } + + /// + /// A mod that ships its own copy of a shader the writers live in - or of the + /// vertexwarp include they evaluate twice - replaces the writer with one that + /// emits nothing. The resolve would then reproject those pixels by camera + /// motion alone while their neighbours use real vectors. + /// + [Fact] + public void TheCompatibilityScannerDisablesTaaForAnExternalMotionWriterShader() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + + Assert.Contains("AddFeatureDecision(report, \"Taa\", externalMotionShader,", scanner); + foreach (string shader in new[] + { + "chunkopaque.vsh", "chunktopsoil.vsh", "entityanimated.vsh", + "standard.vsh", "instanced.vsh", "vertexwarp.vsh", + }) + { + Assert.Contains("HasExternalShader(report, \"" + shader + "\")", scanner); + } + + // shaderincludes has to be a recognised shader path at all, or an + // external vertexwarp.vsh is invisible to the scan. + Assert.Contains("normalized.IndexOf(\"/shaderincludes/\", StringComparison.OrdinalIgnoreCase)", scanner); + Assert.Contains("normalized.StartsWith(\"shaderincludes/\", StringComparison.OrdinalIgnoreCase)", scanner); + } + + /// + /// The Vulkan translation corpus has to overlay Optimum's shader includes the + /// way `make deploy` does, or it keeps translating the vanilla vertexwarp.vsh + /// while the client runs the WarpState one. + /// + [Fact] + public void TheVulkanShaderCorpusOverlaysOptimumsIncludesAndCoversTaaOn() + { + string corpus = Read("Optimum.Render.Vulkan.Tests/ShaderCorpus.cs"); + + Assert.Contains("Path.Combine(RepositoryRoot, \"sources\", \"shaderincludes\")", corpus); + Assert.Contains("#define TAAMOTION {variant.TaaMotion}", corpus); + Assert.Contains("#define TAAMOTIONLOCATION {variant.TaaMotionLocation}", corpus); + Assert.Contains("Name = \"taa-no-ssao\"", corpus); + Assert.Contains("Name = \"taa-with-ssao\"", corpus); + } + + // ----------------------------------------------------------------- helpers + + /// + /// The text of one method: from its signature to the matching closing brace, + /// so a helper declared after it cannot leak into the assertions. + /// + private static string MethodBodyAfter(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such method: " + signature); + return signature + BodyOf(source, signature); + } + + private static bool DeclaresUniform(string shader, string name) + { + foreach (string line in shader.Replace("\r\n", "\n").Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("uniform ", StringComparison.Ordinal)) continue; + string declaration = trimmed.Substring("uniform ".Length); + int semicolon = declaration.IndexOf(';'); + if (semicolon < 0) continue; + declaration = declaration.Substring(0, semicolon); + int assign = declaration.IndexOf('='); + if (assign >= 0) declaration = declaration.Substring(0, assign); + int space = declaration.TrimEnd().LastIndexOf(' '); + if (space < 0) continue; + if (declaration.TrimEnd().Substring(space + 1) == name) return true; + } + return false; + } + + private static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + private static string Normalize(string body) => body.Replace("\r\n", "\n").Trim(); + + private static int Count(string source, string value) + { + int count = 0; + int offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch index 88e8ad35..770f0222 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs -index 431e51a..46d578c 100644 +index 431e51a..dfb4654 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs @@ -1,9 +1,11 @@ @@ -103,7 +103,24 @@ index 431e51a..46d578c 100644 private void AddPoolsForAtlasAndPass(int atlas, EnumChunkRenderPass pass, int maxVertices, int maxIndices, int maxPartsPerPool) { switch (pass) -@@ -170,10 +239,11 @@ public class ChunkRenderer +@@ -142,10 +211,16 @@ public class ChunkRenderer + culler.CullInvisibleChunks(); + } + + public void OnRenderBefore(float dt) + { ++ // Optimum TAA (P3): the LiquidDepth prepass keeps drawing with the same ++ // jittered projection as the main pass (game.CurrentProjectionMatrix), so ++ // its quarter-resolution depth stays consistent with the jittered scene it ++ // is compared against. It deliberately writes no motion: it renders into ++ // its own framebuffer, chunkliquiddepth declares no motion output, and the ++ // motion attachment is never in this target's draw-buffer mask. + game.Platform.LoadFrameBuffer(EnumFrameBuffer.LiquidDepth); + game.Platform.ClearFrameBuffer(EnumFrameBuffer.LiquidDepth); + subPixelPaddingX = game.BlockAtlasManager.SubPixelPaddingX; + subPixelPaddingY = game.BlockAtlasManager.SubPixelPaddingY; + Vec3d cameraPos = game.EntityPlayer.CameraPos; +@@ -170,10 +245,11 @@ public class ChunkRenderer game.Platform.LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -115,7 +132,7 @@ index 431e51a..46d578c 100644 RuntimeStats.availableTriangles = 0; accum += dt; if (accum > 5f) -@@ -208,14 +278,25 @@ public class ChunkRenderer +@@ -208,23 +284,59 @@ public class ChunkRenderer chunkshadowmap.Tex2d2D = textureIds[j]; poolsByRenderPass[5][j].Render(cameraPos, "origin", frustumCullMode); } @@ -144,8 +161,98 @@ index 431e51a..46d578c 100644 { chunkshadowmap.Tex2d2D = textureIds[l]; poolsByRenderPass[1][l].Render(cameraPos, "origin", frustumCullMode); -@@ -318,19 +399,32 @@ public class ChunkRenderer + } + platform.GlToggleBlend(on: true); + } + ++ /// ++ /// Optimum TAA (P3): the previous-frame state the terrain motion-vector ++ /// writers need. The projection is the previous frame's UNJITTERED world ++ /// projection - a motion vector computed through a jittered matrix carries ++ /// two frames' jitter difference instead of the surface's own movement - and ++ /// the view is the previous CameraMatrixOrigin, because terrain draws ++ /// relative to the rebased chunk origin, not the camera-at-player matrix. ++ /// ++ /// Everything else (render size, jitter, camera delta, previous warp state) ++ /// comes from the shared contract helper, which every later writer reuses. ++ /// ++ private void SetOptimumMotionUniforms(ShaderProgram program) ++ { ++ OptimumTemporalFrame frame = OptimumTemporal.Frame; ++ if (program.HasUniform("prevProjectionMatrix")) ++ { ++ program.UniformMatrix("prevProjectionMatrix", frame.GetPrevProjection(EnumTemporalView.World)); ++ } ++ if (program.HasUniform("prevModelViewMatrix")) ++ { ++ program.UniformMatrix("prevModelViewMatrix", frame.PrevCameraMatrixOrigin); ++ } ++ frame.ApplyMotionUniforms(program); ++ } ++ + public void RenderOpaque(float dt) + { + Vec3d cameraPos = game.EntityPlayer.CameraPos; + ScreenManager.FrameProfiler.Mark("rend3D-ret-begin"); + platform.GlDepthMask(flag: true); +@@ -232,10 +344,15 @@ public class ChunkRenderer + platform.GlToggleBlend(on: true); + platform.GlEnableCullFace(); + game.GlMatrixModeModelView(); + game.GlPushMatrix(); + game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); ++ // Optimum TAA (P3): every chunk draw in this method writes motion ++ // vectors, so the motion attachment joins Primary's draw-buffer mask for ++ // the whole pass and leaves it again below. A no-op when TAA is off. ++ ClientPlatformWindows optimumPlatform = platform as ClientPlatformWindows; ++ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); + ShaderProgramChunkopaque chunkopaque = ShaderPrograms.Chunkopaque; + chunkopaque.Use(); + chunkopaque.CameraUnderwater = game.shUniforms.CameraUnderwater; + chunkopaque.RgbaFogIn = game.AmbientManager.BlendedFogColor; + chunkopaque.RgbaAmbientIn = game.AmbientManager.BlendedAmbientColor; +@@ -249,10 +366,14 @@ public class ChunkRenderer + chunkopaque.Uniform("subpixelPaddingX", subPixelPaddingX); + chunkopaque.Uniform("subpixelPaddingY", subPixelPaddingY); + chunkopaque.SunPosition = game.GameWorldCalendar.SunPositionNormalized; + chunkopaque.DayLight = game.shUniforms.SkyDaylight; + chunkopaque.HorizonFog = game.AmbientManager.BlendedCloudDensity; ++ if (optimumMotionWrite) ++ { ++ SetOptimumMotionUniforms(chunkopaque); ++ } + for (int i = 0; i < textureIds.Length; i++) + { + chunkopaque.TerrainTex2D = textureIds[i]; + chunkopaque.TerrainTexLinear2D = textureIds[i]; + poolsByRenderPass[0][i].Render(cameraPos, "origin"); +@@ -268,10 +389,14 @@ public class ChunkRenderer + chunktopsoil.ProjectionMatrix = game.CurrentProjectionMatrix; + chunktopsoil.ModelViewMatrix = game.CurrentModelViewMatrix; + chunktopsoil.BlockTextureSize = blockTextureSize; + chunktopsoil.Uniform("subpixelPaddingX", subPixelPaddingX); + chunktopsoil.Uniform("subpixelPaddingY", subPixelPaddingY); ++ if (optimumMotionWrite) ++ { ++ SetOptimumMotionUniforms(chunktopsoil); ++ } + for (int j = 0; j < textureIds.Length; j++) + { + chunktopsoil.TerrainTex2D = textureIds[j]; + chunktopsoil.TerrainTexLinear2D = textureIds[j]; + poolsByRenderPass[5][j].Render(cameraPos, "origin"); +@@ -314,23 +439,42 @@ public class ChunkRenderer + chunkopaque.TerrainTexLinear2D = textureIds[m]; + poolsByRenderPass[8][m].Render(cameraPos, "origin"); + } + platform.GlToggleBlend(on: false); chunkopaque.Stop(); ++ // Optimum TAA (P3): the motion attachment leaves the draw-buffer mask ++ // again, so no later pass can write it by accident. ++ if (optimumMotionWrite) ++ { ++ optimumPlatform.EndMotionWrite(); ++ } ScreenManager.FrameProfiler.Mark("rend3D-ret-opnc"); game.GlPopMatrix(); if (game.unbindSamplers) @@ -186,7 +293,49 @@ index 431e51a..46d578c 100644 internal void RenderOIT(float deltaTime) { -@@ -442,11 +536,11 @@ public class ChunkRenderer +@@ -407,10 +551,16 @@ public class ChunkRenderer + internal void RenderAfterOIT(float deltaTime) + { + game.GlPushMatrix(); + game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); + Vec3d cameraPos = game.EntityPlayer.CameraPos; ++ // Optimum TAA (P3): the terrain overlay pass (render pass 7) is inside the ++ // temporal window and draws the same chunk geometry, so it writes motion ++ // vectors too - otherwise every decal-covered surface would fall back to ++ // the camera-only reprojection. ++ ClientPlatformWindows optimumPlatform = platform as ClientPlatformWindows; ++ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); + ShaderProgramChunkopaque chunkopaque = ShaderPrograms.Chunkopaque; + platform.GlDisableCullFace(); + platform.GlToggleBlend(on: false); + platform.GlEnableDepthTest(); + chunkopaque.Use(); +@@ -425,28 +575,36 @@ public class ChunkRenderer + chunkopaque.DayLight = game.shUniforms.SkyDaylight; + chunkopaque.HorizonFog = game.AmbientManager.BlendedCloudDensity; + chunkopaque.HaxyFade = 1; + chunkopaque.Uniform("subpixelPaddingX", subPixelPaddingX); + chunkopaque.Uniform("subpixelPaddingY", subPixelPaddingY); ++ if (optimumMotionWrite) ++ { ++ SetOptimumMotionUniforms(chunkopaque); ++ } + for (int i = 0; i < textureIds.Length; i++) + { + chunkopaque.TerrainTex2D = textureIds[i]; + chunkopaque.TerrainTexLinear2D = textureIds[i]; + poolsByRenderPass[7][i].Render(cameraPos, "origin"); + } + chunkopaque.Stop(); ++ if (optimumMotionWrite) ++ { ++ optimumPlatform.EndMotionWrite(); ++ } + game.GlPopMatrix(); + } + + public void Dispose() + { masterPool.DisposeAllPools(game.api); } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 3b7cc553..16f9b50a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..da5e80b 100644 +index 6edf0c9..43ccc85 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -102,7 +102,7 @@ index 6edf0c9..da5e80b 100644 private Logger logger; private int doResize; -@@ -93,10 +182,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -132,6 +132,12 @@ index 6edf0c9..da5e80b 100644 + /// + public int MotionAttachmentIndex { get; private set; } = -1; + ++ /// ++ /// Optimum TAA (P3): whether a pass currently has the motion attachment in ++ /// Primary's draw-buffer mask. See . ++ /// ++ public bool OptimumMotionWriteActive { get; private set; } ++ + private bool TaaTargetsReady; + + // Optimum TAA resolve state (P2): which history slot is written this frame, @@ -174,7 +180,7 @@ index 6edf0c9..da5e80b 100644 private bool serverRunning; private bool gamepause; -@@ -256,10 +406,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +412,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -198,7 +204,7 @@ index 6edf0c9..da5e80b 100644 get { return serverRunning; -@@ -278,11 +441,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,11 +447,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -226,7 +232,7 @@ index 6edf0c9..da5e80b 100644 GL.BindFramebuffer((FramebufferTarget)36160, 0); return; } -@@ -297,11 +476,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -297,11 +482,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -250,7 +256,7 @@ index 6edf0c9..da5e80b 100644 } public override bool GlErrorChecking { get; set; } -@@ -314,10 +505,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -314,10 +511,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } set { @@ -271,7 +277,7 @@ index 6edf0c9..da5e80b 100644 if (!supportsGlDebugMode) { throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -335,11 +536,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -335,11 +542,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } glDebugMode = value; } @@ -300,7 +306,7 @@ index 6edf0c9..da5e80b 100644 public override bool MouseGrabbed { -@@ -478,41 +695,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,41 +701,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -447,7 +453,7 @@ index 6edf0c9..da5e80b 100644 public void LogAndTestHardwareInfosStage1() { -@@ -533,10 +851,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -533,10 +857,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } @@ -485,7 +491,7 @@ index 6edf0c9..da5e80b 100644 logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +921,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -576,10 +927,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CheckGlError("testhwinfo"); } @@ -504,7 +510,7 @@ index 6edf0c9..da5e80b 100644 public override string GetFrameworkInfos() { -@@ -702,24 +1055,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1061,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -547,7 +553,7 @@ index 6edf0c9..da5e80b 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1167,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1173,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -571,7 +577,7 @@ index 6edf0c9..da5e80b 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1400,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1406,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -609,7 +615,7 @@ index 6edf0c9..da5e80b 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1443,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1449,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -629,7 +635,7 @@ index 6edf0c9..da5e80b 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1474,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1480,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -646,7 +652,7 @@ index 6edf0c9..da5e80b 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1519,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1525,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -679,7 +685,7 @@ index 6edf0c9..da5e80b 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1583,457 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1589,457 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -1137,7 +1143,7 @@ index 6edf0c9..da5e80b 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2065,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2071,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1152,7 +1158,7 @@ index 6edf0c9..da5e80b 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2092,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2098,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1169,7 +1175,7 @@ index 6edf0c9..da5e80b 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2137,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2143,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1208,7 +1214,7 @@ index 6edf0c9..da5e80b 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2350,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2356,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1261,7 +1267,7 @@ index 6edf0c9..da5e80b 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2504,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2510,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1276,7 +1282,7 @@ index 6edf0c9..da5e80b 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,12 +2527,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2533,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1366,7 +1372,7 @@ index 6edf0c9..da5e80b 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2628,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2634,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1395,7 +1401,7 @@ index 6edf0c9..da5e80b 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,21 +2662,84 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,38 +2668,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1437,6 +1443,12 @@ index 6edf0c9..da5e80b 100644 + CurrentFrameBufferKeepVw = frameBuffers[0]; + break; + case EnumFrameBuffer.Primary: ++ // Optimum TAA (P3): the frame starts with no motion window open. ++ // Primary is cleared exactly once per frame (ScreenManager), and ++ // the draw-buffer set is restored below, so this makes a pass that ++ // failed to reach EndMotionWrite heal at the next frame instead of ++ // leaving the attachment enabled for every later pass. ++ OptimumMotionWriteActive = false; + optimumDevice.ClearColor(0, 0f, 0f, 0f, 1f); + optimumDevice.ClearColor(1, 0f, 0f, 0f, 1f); + if (RenderSSAO) @@ -1480,7 +1492,15 @@ index 6edf0c9..da5e80b 100644 case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; GL.DrawBuffer((DrawBufferMode)1029); -@@ -1636,10 +2753,36 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + GL.Clear((ClearBufferMask)16640); + CurrentFrameBufferKeepVw = frameBuffers[0]; + break; + case EnumFrameBuffer.Primary: + { ++ // Optimum TAA (P3): see the device branch above. ++ OptimumMotionWriteActive = false; + GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 0f, 0f, 0f, 1f }); + GL.ClearBuffer((ClearBuffer)6144, 1, new float[4] { 0f, 0f, 0f, 1f }); if (RenderSSAO) { GL.ClearBuffer((ClearBuffer)6144, 2, new float[4] { 0f, 0f, 0f, 1f }); @@ -1517,7 +1537,7 @@ index 6edf0c9..da5e80b 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2813,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2827,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1558,7 +1578,7 @@ index 6edf0c9..da5e80b 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2856,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2870,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1625,7 +1645,7 @@ index 6edf0c9..da5e80b 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2926,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2940,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1647,7 +1667,7 @@ index 6edf0c9..da5e80b 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2950,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2964,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1674,7 +1694,7 @@ index 6edf0c9..da5e80b 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +2975,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +2989,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1816,7 +1836,7 @@ index 6edf0c9..da5e80b 100644 //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3118,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3132,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1849,7 +1869,7 @@ index 6edf0c9..da5e80b 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3153,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3167,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1911,7 +1931,7 @@ index 6edf0c9..da5e80b 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3230,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3244,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1958,7 +1978,7 @@ index 6edf0c9..da5e80b 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3279,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3293,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1994,7 +2014,7 @@ index 6edf0c9..da5e80b 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3326,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,55 +3340,266 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2030,17 +2050,29 @@ index 6edf0c9..da5e80b 100644 + { + GL.DrawBuffers(2, array); + } - } - } - } - - private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +3377,119 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - throw new Exception(text); - } - } - } - ++ } ++ } ++ } ++ ++ private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) ++ { ++ //IL_0000: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0006: Invalid comparison between Unknown and I4 ++ //IL_0026: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0030: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0040: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0046: Invalid comparison between Unknown and I4 ++ if ((int)type != 33361) ++ { ++ string text = Marshal.PtrToStringAnsi(message, length); ++ Logger.Notification("{0} {1} | {2}", severity, type, text); ++ if ((int)type == 33356) ++ { ++ throw new Exception(text); ++ } ++ } ++ } ++ + private void DisableOptimumFsr(Exception error) + { + if (!optimumFsrDisabled) @@ -2068,6 +2100,116 @@ index 6edf0c9..da5e80b 100644 + MotionAttachmentIndex = -1; + } + ++ /// ++ /// Optimum TAA (P3): adds the motion attachment to Primary's draw-buffer mask ++ /// for the duration of one motion-writing pass, and ++ /// takes it back out. ++ /// ++ /// The attachment is deliberately outside Primary's default set (see ++ /// SetupDefaultFrameBuffers): a fragment shader that does not declare the ++ /// output would leave whatever the last writer put there, and the resolve ++ /// would happily reproject a pixel by another surface's vector. Enabling it ++ /// only around passes that really write it makes "nobody wrote here" the ++ /// default, which the resolve detects through the writer-depth mismatch and ++ /// answers with the camera-motion fallback. ++ /// ++ /// A no-op that returns false when TAA is off, its targets failed to ++ /// allocate, or a pass already opened the window - so callers can wrap ++ /// unconditionally. ++ /// ++ /// Whether the motion attachment is now enabled. ++ public bool BeginMotionWrite() ++ { ++ if (OptimumMotionWriteActive) return false; ++ if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false; ++ if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false; ++ if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return false; ++ ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); ++ } ++ else ++ { ++ DrawBuffersEnum[] optimumMotionDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1]; ++ for (int optimumDb = 0; optimumDb <= MotionAttachmentIndex; optimumDb++) ++ { ++ optimumMotionDrawBuffers[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); + } ++ GL.DrawBuffers(optimumMotionDrawBuffers.Length, optimumMotionDrawBuffers); + } ++ OptimumMotionWriteActive = true; ++ // Blending is per-attachment state that GlToggleBlend re-applies whenever ++ // a pass turns blending on; motion must never blend, so the mask change ++ // has to be paired with it right away for a pass that is already blending. ++ ApplyOptimumMotionBlendState(); ++ return true; + } + +- private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) ++ /// ++ /// Optimum TAA (P3): restores Primary's default draw-buffer set (the 2 or 4 ++ /// colour attachments the setup left bound), taking the motion attachment ++ /// back out. Safe to call when returned false. ++ /// ++ public void EndMotionWrite() + { +- //IL_0000: Unknown result type (might be due to invalid IL or missing references) +- //IL_0006: Invalid comparison between Unknown and I4 +- //IL_0026: Unknown result type (might be due to invalid IL or missing references) +- //IL_0030: Unknown result type (might be due to invalid IL or missing references) +- //IL_0040: Unknown result type (might be due to invalid IL or missing references) +- //IL_0046: Invalid comparison between Unknown and I4 +- if ((int)type != 33361) ++ if (!OptimumMotionWriteActive) return; ++ OptimumMotionWriteActive = false; ++ if (MotionAttachmentIndex < 0) return; ++ if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return; ++ ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) + { +- string text = Marshal.PtrToStringAnsi(message, length); +- Logger.Notification("{0} {1} | {2}", severity, type, text); +- if ((int)type == 33356) +- { +- throw new Exception(text); +- } ++ // MotionAttachmentIndex is also the size of the default set (2 without ++ // the SSAO G-buffer, 4 with it), because the attachment was appended ++ // after it. ++ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); ++ return; ++ } ++ DrawBuffersEnum[] optimumRestoreDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex]; ++ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) ++ { ++ optimumRestoreDrawBuffers[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); ++ } ++ GL.DrawBuffers(optimumRestoreDrawBuffers.Length, optimumRestoreDrawBuffers); ++ } ++ ++ /// ++ /// Optimum TAA (P3): forces replace-blending on the motion attachment. ++ /// Terrain passes 2 and 8 draw with blending on, and a blended motion vector ++ /// is a weighted average of two surfaces' displacements, which belongs to ++ /// neither. Mirrors what the GL path already does for the SSAO G-buffer. ++ /// ++ private void ApplyOptimumMotionBlendState() ++ { ++ if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); ++ optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0); ++ return; + } ++ GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774); ++ GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0); + } + public override void BlitPrimaryToDefault() { //IL_002c: Unknown result type (might be due to invalid IL or missing references) @@ -2156,7 +2298,7 @@ index 6edf0c9..da5e80b 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3537,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3647,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2182,7 +2324,7 @@ index 6edf0c9..da5e80b 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3570,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3680,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2204,7 +2346,7 @@ index 6edf0c9..da5e80b 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3599,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3709,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2302,7 +2444,7 @@ index 6edf0c9..da5e80b 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,36 +3698,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +3808,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2384,6 +2526,14 @@ index 6edf0c9..da5e80b 100644 + optimumDevice.SetBlendEquation(3, 32774); + optimumDevice.SetBlendFuncSeparate(3, 1, 0, 1, 0); + } ++ // Optimum TAA (P3): the motion attachment never blends. A blended ++ // motion vector averages two surfaces' displacements and belongs to ++ // neither; the per-attachment override has to be re-applied after ++ // every global blend change, exactly like the SSAO one above. ++ if (on) ++ { ++ ApplyOptimumMotionBlendState(); ++ } + return; + } if (on) @@ -2391,7 +2541,41 @@ index 6edf0c9..da5e80b 100644 GL.Enable((EnableCap)3042); switch (blendMode) { -@@ -2233,33 +3814,71 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + case EnumBlendMode.Brighten: + GL.BlendFunc((BlendingFactor)774, (BlendingFactor)1); ++ ApplyOptimumMotionBlendState(); + return; + case EnumBlendMode.Multiply: + GL.BlendFuncSeparate((BlendingFactorSrc)0, (BlendingFactorDest)771, (BlendingFactorSrc)1, (BlendingFactorDest)771); ++ ApplyOptimumMotionBlendState(); + return; + case EnumBlendMode.PremultipliedAlpha: + GL.BlendFunc((BlendingFactor)1, (BlendingFactor)771); ++ ApplyOptimumMotionBlendState(); + return; + case EnumBlendMode.Glow: + GL.BlendFuncSeparate((BlendingFactorSrc)770, (BlendingFactorDest)1, (BlendingFactorSrc)1, (BlendingFactorDest)0); ++ ApplyOptimumMotionBlendState(); + return; + case EnumBlendMode.Overlay: + GL.BlendFuncSeparate((BlendingFactorSrc)770, (BlendingFactorDest)771, (BlendingFactorSrc)1, (BlendingFactorDest)1); ++ ApplyOptimumMotionBlendState(); + return; + } + GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); + if (RenderSSAO) + { + GL.BlendEquation(2, (BlendEquationMode)32774); + GL.BlendFunc(2, (BlendingFactorSrc)1, (BlendingFactorDest)0); + GL.BlendEquation(3, (BlendEquationMode)32774); + GL.BlendFunc(3, (BlendingFactorSrc)1, (BlendingFactorDest)0); + } ++ // Optimum TAA (P3): see the device branch above. ++ ApplyOptimumMotionBlendState(); + } + else + { + GL.Disable((EnableCap)3042); } } @@ -2463,7 +2647,7 @@ index 6edf0c9..da5e80b 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +3892,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4017,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2630,7 +2814,7 @@ index 6edf0c9..da5e80b 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4062,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4187,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2654,7 +2838,7 @@ index 6edf0c9..da5e80b 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4091,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4216,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2692,7 +2876,7 @@ index 6edf0c9..da5e80b 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4151,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4276,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2735,7 +2919,7 @@ index 6edf0c9..da5e80b 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4204,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4329,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2794,7 +2978,7 @@ index 6edf0c9..da5e80b 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4319,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4444,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2868,7 +3052,7 @@ index 6edf0c9..da5e80b 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4417,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4542,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2899,7 +3083,7 @@ index 6edf0c9..da5e80b 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4454,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4579,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2934,7 +3118,7 @@ index 6edf0c9..da5e80b 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4501,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4626,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -2963,7 +3147,7 @@ index 6edf0c9..da5e80b 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4532,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4657,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2987,7 +3171,7 @@ index 6edf0c9..da5e80b 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4569,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4694,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3004,7 +3188,7 @@ index 6edf0c9..da5e80b 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4621,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4746,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3049,7 +3233,7 @@ index 6edf0c9..da5e80b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4658,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4783,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3070,7 +3254,7 @@ index 6edf0c9..da5e80b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4677,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4802,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3091,7 +3275,7 @@ index 6edf0c9..da5e80b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4696,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4821,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3112,7 +3296,7 @@ index 6edf0c9..da5e80b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4715,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4840,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3133,7 +3317,7 @@ index 6edf0c9..da5e80b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4738,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4863,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3154,7 +3338,7 @@ index 6edf0c9..da5e80b 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4781,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4906,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3180,7 +3364,7 @@ index 6edf0c9..da5e80b 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5023,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5148,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3202,7 +3386,7 @@ index 6edf0c9..da5e80b 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5221,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5346,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3225,7 +3409,7 @@ index 6edf0c9..da5e80b 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5295,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5420,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3274,7 +3458,7 @@ index 6edf0c9..da5e80b 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5365,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5490,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3306,7 +3490,7 @@ index 6edf0c9..da5e80b 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5395,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5520,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3332,7 +3516,7 @@ index 6edf0c9..da5e80b 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5743,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5868,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3362,7 +3546,7 @@ index 6edf0c9..da5e80b 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5797,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5922,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index 8d8f367b..90b3e2dc 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..ff92e49 100644 +index 4a24e75..ad4177e 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -193,7 +193,7 @@ index 4a24e75..ff92e49 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +452,27 @@ public class ShaderRegistry +@@ -333,10 +452,44 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; @@ -216,6 +216,23 @@ index 4a24e75..ff92e49 100644 + optimumFrag.PrefixCode = optimumFrag.PrefixCode + "#define GREEDYMESH_GRAD " + (OptimumConfig.GreedyMeshTextureGrad ? 1 : 0) + "\r\n"; + Shader optimumVert = program.VertexShader; + optimumVert.PrefixCode = optimumVert.PrefixCode + "#define GREEDYMESH " + (greedy ? 1 : 0) + "\r\n"; ++ // Optimum TAA (P3): the motion-vector writers compile in only while TAA ++ // is on, so with TAA off chunkopaque/chunktopsoil preprocess back to ++ // vanilla and the vertexwarp include's WarpState overloads collapse to ++ // the vanilla entry points. ++ // ++ // TAAMOTIONLOCATION is the Primary colour attachment the motion texture ++ // occupies. SetupDefaultFrameBuffers appends it after the existing set, ++ // whose size is (SetupSSAO ? 4 : 2) with SetupSSAO = ClientSettings.SSAOQuality > 0 ++ // - the same condition SSAOLEVEL above is stamped from, so the two can ++ // never disagree. ++ bool taaMotion = OptimumConfig.EffectiveTaa; ++ int taaMotionLocation = ((ClientSettings.SSAOQuality > 0) ? 4 : 2); ++ string taaDefines = "#define TAAMOTION " + (taaMotion ? 1 : 0) + "\r\n#define TAAMOTIONLOCATION " + taaMotionLocation + "\r\n"; ++ Shader taaFrag = program.FragmentShader; ++ taaFrag.PrefixCode = taaFrag.PrefixCode + taaDefines; ++ Shader taaVert = program.VertexShader; ++ taaVert.PrefixCode = taaVert.PrefixCode + taaDefines; } private static string HandleIncludes(ShaderProgram program, string shaderCode, HashSet filenames = null) diff --git a/scripts/package-linux.ps1 b/scripts/package-linux.ps1 index 610cd845..c706cb43 100644 --- a/scripts/package-linux.ps1 +++ b/scripts/package-linux.ps1 @@ -122,6 +122,13 @@ try { Get-ChildItem $shaderSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderDst } } + # 5b-2. Overlay optimized shader includes (TAA P3). + $shaderIncSrc = Join-Path $repoRoot 'sources/shaderincludes' + $shaderIncDst = Join-Path $stageDir 'assets/game/shaderincludes' + if (Test-Path $shaderIncSrc) { + Get-ChildItem $shaderIncSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderIncDst } + } + # Merge translation strings (text-based; vanilla JSON has case-duplicate keys that break ConvertFrom-Json). # Read/write explicitly as UTF-8 via .NET, not Get-Content/Set-Content: # on Windows PowerShell 5.1 those cmdlets default to the system codepage diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 274f290f..9847f50d 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -332,6 +332,14 @@ if [[ -d "$SHADER_SRC" ]]; then find "$SHADER_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADER_DST/" \; fi +# 5b-2. Overlay optimized shader includes (TAA P3). Same asset-name override +# mechanism as shaders, separate directory. +SHADER_INC_SRC="$REPO_ROOT/sources/shaderincludes" +SHADER_INC_DST="$STAGE_DIR/assets/game/shaderincludes" +if [[ -d "$SHADER_INC_SRC" ]]; then + find "$SHADER_INC_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADER_INC_DST/" \; +fi + # 5c. Merge translation strings. LANG_SRC="$REPO_ROOT/sources/lang" LANG_DST="$STAGE_DIR/assets/game/lang" diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index a65ace5e..ab33927b 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -202,6 +202,14 @@ if [[ -d "$SHADER_SRC" ]]; then find "$SHADER_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADER_DST/" \; fi +# 5b-2. Overlay optimized shader includes (TAA P3). Same asset-name override +# mechanism as shaders, separate directory. +SHADER_INC_SRC="$REPO_ROOT/sources/shaderincludes" +SHADER_INC_DST="$APP_DIR/assets/game/shaderincludes" +if [[ -d "$SHADER_INC_SRC" ]]; then + find "$SHADER_INC_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADER_INC_DST/" \; +fi + # 5c. Merge translation strings. LANG_SRC="$REPO_ROOT/sources/lang" LANG_DST="$APP_DIR/assets/game/lang" diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs index dda1e845..4b0fc1c8 100644 --- a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -426,6 +426,46 @@ public float[] ApplyJitterCopy(double[] matrix) jitteredScratch[9] -= (float)(2.0 * JitterPx.Y / RenderHeight); return jitteredScratch; } + + /// + /// Sets the uniforms every motion-vector writer shares (TAA P3): the render + /// size and this frame's jitter, which turn a clip position into the pixel + /// grid the resolve works in; the camera's own movement in double-differenced + /// form; and the previous frame's complete warp state, so the writer can + /// evaluate the vertex warp twice through the same code. + /// + /// The previous view and projection matrices are deliberately not set here: + /// their uniform names differ per program (terrain has one modelViewMatrix, + /// entities a separate view and model matrix) and so does the view they + /// belong to (world FOV vs hand FOV). Each writer sets those two itself and + /// calls this for the rest. + /// + /// Every uniform is guarded by : + /// with TAA off the writers preprocess away, the names are not active, and + /// setting one by name would throw. + /// + public void ApplyMotionUniforms(IShaderProgram program) + { + if (program == null) return; + + if (program.HasUniform("taaRenderSize")) program.Uniform("taaRenderSize", RenderWidth, RenderHeight); + if (program.HasUniform("taaJitterPx")) program.Uniform("taaJitterPx", JitterPx.X, JitterPx.Y); + if (program.HasUniform("cameraPosDelta")) program.Uniform("cameraPosDelta", CameraPosDelta.X, CameraPosDelta.Y, CameraPosDelta.Z); + + OptimumWarpState previous = PrevWarp; + if (program.HasUniform("prevTimeCounter")) program.Uniform("prevTimeCounter", previous.TimeCounter); + if (program.HasUniform("prevWindWaveCounter")) program.Uniform("prevWindWaveCounter", previous.WindWaveCounter); + if (program.HasUniform("prevWindWaveCounterHighFreq")) program.Uniform("prevWindWaveCounterHighFreq", previous.WindWaveCounterHighFreq); + if (program.HasUniform("prevWaterWaveCounter")) program.Uniform("prevWaterWaveCounter", previous.WaterWaveCounter); + if (program.HasUniform("prevWindSpeed")) program.Uniform("prevWindSpeed", previous.WindSpeed); + if (program.HasUniform("prevGlobalWarpIntensity")) program.Uniform("prevGlobalWarpIntensity", previous.GlobalWarpIntensity); + if (program.HasUniform("prevGlitchWaviness")) program.Uniform("prevGlitchWaviness", previous.GlitchWaviness); + if (program.HasUniform("prevWindWaveIntensity")) program.Uniform("prevWindWaveIntensity", previous.WindWaveIntensity); + if (program.HasUniform("prevWaterWaveIntensity")) program.Uniform("prevWaterWaveIntensity", previous.WaterWaveIntensity); + if (program.HasUniform("prevPerceptionEffectId")) program.Uniform("prevPerceptionEffectId", previous.PerceptionEffectId); + if (program.HasUniform("prevPerceptionEffectIntensity")) program.Uniform("prevPerceptionEffectIntensity", previous.PerceptionEffectIntensity); + if (program.HasUniform("prevPlayerpos")) program.Uniform("prevPlayerpos", PrevPlayerpos.X, PrevPlayerpos.Y, PrevPlayerpos.Z); + } } /// diff --git a/sources/shaderincludes/vertexwarp.vsh b/sources/shaderincludes/vertexwarp.vsh new file mode 100644 index 00000000..6d3ef255 --- /dev/null +++ b/sources/shaderincludes/vertexwarp.vsh @@ -0,0 +1,291 @@ +// Optimum override of the vanilla vertexwarp.vsh (TAA P3). +// +// Motion-vector writers have to evaluate the vertex warp twice: once with this +// frame's animation state and once with the previous frame's, through the very +// same code. So every warp function here takes an explicit WarpState carrying +// every uniform it reads, and the vanilla entry points became one-line wrappers +// that pass currentWarpState(). The maths inside is unchanged, line for line, +// which is what keeps every other shader that includes this file - liquids, +// particles, clouds, decals, wireframe, the shadow map - byte-for-byte +// identical to vanilla for current values. +// +// The prev* uniforms default to zero and are set only by a pass that actually +// writes motion (ChunkRenderer sets them from OptimumTemporal.Frame). A shader +// that never calls previousWarpState() drops them at compile time. +// +// The counters wrap (DefaultShaderUniforms.Update takes them modulo 6000), so +// the previous values are snapshotted values, never "current minus dt". + +uniform float timeCounter; +uniform float windWaveCounter; +uniform float windWaveCounterHighFreq; +uniform float waterWaveCounter; +uniform float windSpeed; +uniform vec3 playerpos; +uniform float globalWarpIntensity; + +uniform float glitchWaviness = 0; +uniform float windWaveIntensity = 1; +uniform float waterWaveIntensity = 1; + +uniform int perceptionEffectId = 1; +uniform float perceptionEffectIntensity = 1; + +// Previous frame's values of exactly the same set (TAA P3). +uniform float prevTimeCounter = 0; +uniform float prevWindWaveCounter = 0; +uniform float prevWindWaveCounterHighFreq = 0; +uniform float prevWaterWaveCounter = 0; +uniform float prevWindSpeed = 0; +uniform vec3 prevPlayerpos = vec3(0.0, 0.0, 0.0); +uniform float prevGlobalWarpIntensity = 0; +uniform float prevGlitchWaviness = 0; +uniform float prevWindWaveIntensity = 1; +uniform float prevWaterWaveIntensity = 1; +uniform int prevPerceptionEffectId = 1; +uniform float prevPerceptionEffectIntensity = 1; + + + +#include noise3d.ash + + + +// Every uniform the warp functions read, so one call site can evaluate them for +// this frame and the previous frame without any hidden global state. +struct WarpState { + float timeCounter; + float windWaveCounter; + float windWaveCounterHighFreq; + float waterWaveCounter; + float windSpeed; + vec3 playerpos; + float globalWarpIntensity; + float glitchWaviness; + float windWaveIntensity; + float waterWaveIntensity; + int perceptionEffectId; + float perceptionEffectIntensity; +}; + +WarpState currentWarpState() { + return WarpState( + timeCounter, windWaveCounter, windWaveCounterHighFreq, waterWaveCounter, + windSpeed, playerpos, globalWarpIntensity, glitchWaviness, + windWaveIntensity, waterWaveIntensity, perceptionEffectId, perceptionEffectIntensity); +} + +WarpState previousWarpState() { + return WarpState( + prevTimeCounter, prevWindWaveCounter, prevWindWaveCounterHighFreq, prevWaterWaveCounter, + prevWindSpeed, prevPlayerpos, prevGlobalWarpIntensity, prevGlitchWaviness, + prevWindWaveIntensity, prevWaterWaveIntensity, prevPerceptionEffectId, prevPerceptionEffectIntensity); +} + + +vec3 applyPerceptionWarpingState(WarpState st, vec3 worldPos) { + + if (st.perceptionEffectId == 2 && st.perceptionEffectIntensity > 0) { // Drunk + float pci = st.perceptionEffectIntensity * clamp(length(worldPos)/2 - 2, 0.0, 2.0); + float xf = (worldPos.x + st.playerpos.x) / 10; + float zf = (worldPos.z + st.playerpos.z) / 10; + worldPos.x += pci * gnoise(vec3(xf, zf, st.timeCounter/6)) / 2; + worldPos.y += pci * gnoise(vec3(xf, zf, st.timeCounter/10)) / 2; + worldPos.z += pci * gnoise(vec3(xf, zf, st.timeCounter/3.5)) / 2; + } + + return worldPos; +} + + +vec4 applyLiquidWarpingState(WarpState st, bool windAffected, vec4 worldPos, float div) { + #if WAVINGSTUFF == 1 + vec3 noisepos = vec3((worldPos.x + st.playerpos.x) / 3, (worldPos.z + st.playerpos.z) / 3, st.waterWaveCounter / 8 + (windAffected ? st.windWaveCounter / 4 : 0)); + worldPos.y += st.waterWaveIntensity * gnoise(noisepos) / div; + + if (windAffected) worldPos.y += st.windWaveIntensity * gnoise(noisepos * 3.5) / (div * 4); + + worldPos.xyz = applyPerceptionWarpingState(st, worldPos.xyz); + + #endif + + return worldPos; +} + +vec4 applyVertexWarpingState(WarpState st, int renderFlags, vec4 worldPos) { + #if WAVINGSTUFF == 1 + + if ((renderFlags & WindModeBitMask) > 0) { + + int windMode = (renderFlags >> WindModePosition) & 0xF; + + if (windMode==12) { + return applyLiquidWarpingState(st, true, worldPos, 5); + } + + int windData = (renderFlags >> WindDataPosition) & 0x7; + + float x = worldPos.x + st.playerpos.x; // See also code in PlayerCamera.cs how this is derived from ShaderUniforms.playerReferencePos + float z = worldPos.z + st.playerpos.z; + + if (windMode != 6) { + float y = worldPos.y + st.playerpos.y; + + // Fixes jitter due to float rounding errors + y = ceil(y * 10000) / 10000.0; + + float heightBend = 0; + + float strength = st.windWaveIntensity * (1 + st.windSpeed) / 30.0; + float bendCounter = st.windWaveCounter; + float vbendMul = 1.3/5.0; + float wwaveHighFreq = st.windWaveCounterHighFreq * 1.2; + float strengthFactorY = 1; + float bendNoiseFactor = 1.4; + float bendConstant = 0.8; + + int windwaveConfig = 0; + + switch (windMode) { + case 1: // Weak Wind + case 13: // Weak Wind + reduced AlphaTest + strength = 0.005 + 0.015 * st.windSpeed; + heightBend = (fract(y) + windData) / 7.0 * 1.3; + break; + case 2: // Normal wind + strength = 0.005 + 0.015 * st.windSpeed; + heightBend = (fract(y) + windData) / 4 * 1.3; + break; + case 3: // Leaves + strength *= 0.5; + heightBend = (fract(y) + windData) / 12.0 * 1.3; + heightBend = heightBend / 2 + pow(heightBend, 1.5) / 2; // the pow makes the bend neatly rounded + break; + case 4: // Bend (for small stems) + strength = 0; + heightBend = (fract(y) + windData) / 7.0 * 1.3; + break; + case 5: // Tall Bend (for thick and/or tall stems) + strength = 0; + heightBend = (fract(y) + windData) / 14.0 * 1.3; + heightBend = heightBend / 2 + pow(heightBend, 1.5) / 2; // the pow makes the bend neatly rounded + vbendMul = 0.0; + break; + // case 6: Water + case 7: // Extra Weak Wind + strength = 0.01; + heightBend = (fract(y) + windData) / 7.0 * 0.6; + break; + case 8: // Fruit + strength *= 0.15; + if (windData == 0) windData = -1; // Slight fudge for very tall fruit such as pears + y += (windData + 4) / 32.0; // All vertices on the whole fruit should have the same y - or close to it - if windData was set correctly + strengthFactorY = 3; + break; + case 9: // Weak Wind No Bend (for foliage with non bending stems) + strength *= 0.2; + heightBend = 0; + break; + case 10: // Weak Wind, Inverse Bend (for vines) + strength *= 0.5; + //strength = 0.02; // Not sure actually why this looks better and seems to scale just fine with the windspeed + heightBend = ((1 - fract(y)) + windData) / 14.0 * 1.5; + break; + case 11: // WaterPlant for Seaweed + strength = windData * (0.013 + 0.002 * st.windSpeed); + wwaveHighFreq /= 5; + heightBend = windData / 7.0 * 1.3; + bendNoiseFactor = 2.4; + bendConstant = 0.1; + bendCounter /= 1.8; + break; + } + + + // 1. Determine bend + float bend = st.windSpeed * heightBend * st.windWaveIntensity; + if (bend != 0) + { + float bendNoise = st.windSpeed * 0.2 + bendNoiseFactor * gnoise(vec3(x * 0.1, z * 0.1, mod(bendCounter, 1024.0) * 0.25)); + bend *= (bendConstant + bendNoise); + bend = min(4, bend); + } + + // 2. Add more noise + + x += wwaveHighFreq; + y += wwaveHighFreq; + z += wwaveHighFreq; + + // 3. Generate wiggle from a set of curves + // Visualized: https://pfortuny.net/fooplot.com/#W3sidHlwZSI6MCwiZXEiOiIyKnNpbih4LzgpK3Npbih4LzIpK3NpbigwLjUrMip4KStzaW4oMSszKngpIiwiY29sb3IiOiIjMDAwMDAwIn0seyJ0eXBlIjoxMDAwLCJ3aW5kb3ciOlsiLTI0Ljc5NTUzMjIyNjU2MjQ4NiIsIjI0Ljc5NTUzMjIyNjU2MjQ4NiIsIi0xNS4yNTg3ODkwNjI0OTk5OTEiLCIxNS4yNTg3ODkwNjI0OTk5OTEiXX1d + worldPos.x += bend + strength * (2 * sin(x * 0.5) + sin(x + y) + sin(0.5 + 4*x + 2*y) + sin(1 + 6*x + 3*y)/3); + + + // This might need to be a new mode. It makes sunflower leaves nicely wiggly + if (windMode == 1) worldPos.x += sin(x*20)*strength * 0.2 * st.windSpeed; + + worldPos.y += -bend * vbendMul + strength * strengthFactorY * (sin(5*y)/15 + cos(10*x/strengthFactorY) / 10 + sin(3*z/strengthFactorY)/2 + cos(x/strengthFactorY*2)/2.2); + worldPos.z += strength * (2 * sin(z * 0.25) + sin(z + 3 * y) + sin(0.5 + 4*z + 2*y) + sin(1 + 6*z + y)/3); + + } + else { + // Water wave + vec3 noisepos = vec3(x / 3, z / 3, st.waterWaveCounter / 8 + st.windWaveCounter / 4); + worldPos.y += gnoise(noisepos) / 10; + } + } + + #endif + + return worldPos; +} + +vec4 applyGlobalWarpingState(WarpState st, vec4 worldPos) { + #if WAVINGSTUFF == 1 + + if (st.glitchWaviness > 0.1) { + float str = max(0.0, st.glitchWaviness - 0.1); + str *= clamp(1.5 * length(worldPos) * st.glitchWaviness - 1, 0.0, 250.0); + + float xf = (worldPos.x + st.playerpos.x) / 10; + float zf = (worldPos.z + st.playerpos.z) / 10; + worldPos.x += str * gnoise(vec3(xf, zf, st.windWaveCounter/6)) / 5; + worldPos.y += str * gnoise(vec3(xf, zf, st.windWaveCounter/10)) / 5; + worldPos.z += str * gnoise(vec3(xf, zf, st.windWaveCounter/3.5)) / 5; + } + + if (st.globalWarpIntensity > 0) { + float x = max(0.0, (mod(20*st.windWaveCounter, 30)) + (worldPos.x + st.playerpos.x) * 0.2 + (worldPos.y + st.playerpos.y) * 0.125 - 40); + worldPos.x += (sin(x / 2) + sin(0.5 + 2*x) + sin(1 + 3*x)/3) / 30.0 * st.globalWarpIntensity; + worldPos.z += (cos(x / 3) + cos(0.2 + 2.2*x) + cos(1 + 4*x)/3) / 30.0 * st.globalWarpIntensity; + } + + + worldPos.xyz = applyPerceptionWarpingState(st, worldPos.xyz); + + #endif + + return worldPos; +} + + +// ---- vanilla entry points, unchanged behaviour ---------------------------- +// Evaluated with this frame's uniforms, so every existing caller sees exactly +// what vanilla produced. + +vec3 applyPerceptionWarping(vec3 worldPos) { + return applyPerceptionWarpingState(currentWarpState(), worldPos); +} + +vec4 applyLiquidWarping(bool windAffected, vec4 worldPos, float div) { + return applyLiquidWarpingState(currentWarpState(), windAffected, worldPos, div); +} + +vec4 applyVertexWarping(int renderFlags, vec4 worldPos) { + return applyVertexWarpingState(currentWarpState(), renderFlags, worldPos); +} + +vec4 applyGlobalWarping(vec4 worldPos) { + return applyGlobalWarpingState(currentWarpState(), worldPos); +} diff --git a/sources/shaders/chunkopaque.fsh b/sources/shaders/chunkopaque.fsh index cf2c3285..3d1f29ad 100644 --- a/sources/shaders/chunkopaque.fsh +++ b/sources/shaders/chunkopaque.fsh @@ -44,6 +44,31 @@ layout(location = 2) out vec4 outGNormal; layout(location = 3) out vec4 outGPosition; #endif +// TAA motion vectors (Optimum P3). TAAMOTION and TAAMOTIONLOCATION are stamped +// by ShaderRegistry.registerDefaultShaderCodePrefixes; the location is the +// Primary colour attachment the motion texture occupies (2 without the SSAO +// G-buffer, 4 with it), which is the same index ClientPlatformWindows appends +// it at. With TAA off this preprocesses back to vanilla. +#if TAAMOTION > 0 +in vec4 taaPrevClip; +uniform vec2 taaRenderSize; // render-target size in pixels +uniform vec2 taaJitterPx; // this frame's sub-pixel shear, in pixels +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; + +// rg = previousPixel - currentUnjitteredPixel in render pixels, b = reactive, +// a = this fragment's window depth. The resolve accepts the vector only when +// a matches the depth buffer, so a zero alpha means "nobody wrote here" and +// sends the pixel to the camera-motion fallback - which is exactly what a +// previous position behind the previous camera deserves. +vec4 taaMotionVector(float reactive) +{ + if (taaPrevClip.w <= 1e-6) return vec4(0.0); + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z); +} +#endif + #include vertexflagbits.ash #include fogandlight.fsh #include dither.fsh @@ -129,6 +154,10 @@ void main() outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); #endif outGlow = vec4(glowLevel + glow, godrayLevel, 0, min(1, fogAmount + outColor.a)); +#if TAAMOTION > 0 + // Opaque terrain is not reactive. + outMotion = taaMotionVector(0.0); +#endif return; } #endif @@ -189,4 +218,7 @@ void main() #endif outGlow = vec4(glowLevel + glow, godrayLevel, 0, min(1, fogAmount + outColor.a)); +#if TAAMOTION > 0 + outMotion = taaMotionVector(0.0); +#endif } diff --git a/sources/shaders/chunkopaque.vsh b/sources/shaders/chunkopaque.vsh index b941b67a..620eaea2 100644 --- a/sources/shaders/chunkopaque.vsh +++ b/sources/shaders/chunkopaque.vsh @@ -56,6 +56,16 @@ out vec4 gnormal; flat out int renderFlags; +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED world projection +uniform mat4 prevModelViewMatrix; // previous frame's CameraMatrixOrigin +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +out vec4 taaPrevClip; +#endif + #include vertexflagbits.ash #include shadowcoords.vsh #include fogandlight.vsh @@ -180,6 +190,30 @@ void main(void) } +#if TAAMOTION > 0 + // The same vertex, one frame ago, through the same code path: the chunk's + // camera-relative position moved by exactly the camera's own motion + // (accuracy rule 4), the warp is re-evaluated with the previous frame's + // counters, and the previous unjittered projection replaces this frame's + // jittered one. The warp noise consumes an absolute-ish position, which is + // prevRel + prevPlayerpos - that is what previousWarpState() carries. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0); + taaPrevPos = applyVertexWarpingState(taaPrev, renderFlags, taaPrevPos); + taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + + // The z-fighting w-offset shifts where the fragment lands on screen, so + // leaving it off the previous position would report that shift as motion. + if (taaPrevClip.z > -1) { + int taaPrevZOffset = (renderFlags & ZOffsetBitMask) >> 8; + taaPrevClip.w += taaPrevZOffset * 0.00025 / ((taaPrevClip.z + 3) * 0.05); + } + } +#endif + + if ((renderFlags & Lod0BitMask) != 0) { float b = clamp(10 * (1.05 - length(worldPos.xz) / viewDistanceLod0) - 2.5, 0.0, 1.0); lod0Fade = 1 - b; diff --git a/sources/shaders/chunktopsoil.fsh b/sources/shaders/chunktopsoil.fsh new file mode 100644 index 00000000..b7561383 --- /dev/null +++ b/sources/shaders/chunktopsoil.fsh @@ -0,0 +1,127 @@ +#version 330 core +// Optimum override of the vanilla chunktopsoil.fsh: adds the TAA motion-vector +// output (P3). Everything else is vanilla, line for line. + +uniform sampler2D terrainTex; +uniform sampler2D terrainTexLinear; + +uniform float alphaTest = 0.01; +uniform vec2 blockTextureSize; + +in vec4 rgba; +in vec4 rgbaFog; +in float fogAmount; +in vec2 uv; +in vec2 uv2; +in float glowLevel; +in vec3 blockLight; +in vec4 worldPos; +in vec3 vertexPosition; + +flat in int renderFlags; +in vec3 normal; +in vec4 gnormal; + + + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if SSAOLEVEL > 0 +in vec4 fragPosition; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +// TAA motion vectors (Optimum P3); see chunkopaque.fsh for the contract. +#if TAAMOTION > 0 +in vec4 taaPrevClip; +uniform vec2 taaRenderSize; +uniform vec2 taaJitterPx; +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; + +vec4 taaMotionVector(float reactive) +{ + if (taaPrevClip.w <= 1e-6) return vec4(0.0); + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z); +} +#endif + +#include vertexflagbits.ash +#include fogandlight.fsh +#include colormap.fsh +#include noise3d.ash +#include underwatereffects.fsh + +void main() +{ + vec4 brownSoilColor = texture(terrainTex, uv) * rgba; + + if (normal.y >= 0) { + // Top (normal.y == 1) or Sides (normal.y == 0) + vec4 grassColor = getColorMapped(terrainTexLinear, texture(terrainTex, uv2 + vec2(blockTextureSize.x * normal.y, 0))) * rgba; + outColor = brownSoilColor * (1 - grassColor.a) + grassColor * grassColor.a; + } else { + // Bottom + outColor = applyFog(brownSoilColor, fogAmount); + } + + if (psychedelicStrength > Epsilon) outColor = applyPsychedelicEffect(outColor, vertexPosition*2, 0); + if (glitchStrength > Epsilon) outColor = applyRustEffect(outColor, normal, vertexPosition, 1); + + + #if SHADOWQUALITY > 0 + float intensity = 0.34 + (1 - shadowIntensity)/8.0; // this was 0.45, which makes shadow acne visible on blocks + #else + float intensity = 0.45; + #endif + + + + float murkiness=getUnderwaterMurkiness(); + outColor = applyFogAndShadowWithNormal(outColor, clamp(fogAmount - 50*murkiness, 0, 1), normal, 1, intensity, worldPos.xyz); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + + outColor.a = rgbaFog.a; + + float aTest = outColor.a; + aTest += max(0.0, 1 - rgba.a) * min(1, outColor.a * 10); +#if NORMALVIEW == 0 + // Fade to sky color + // Also, when looking through tinted glass you can clearly see the edges where we fade to sky color; using the outColor.a < 0.005 discard seems to completely fix that + if (aTest < alphaTest || outColor.a < 0.005) discard; +#endif + + + float glow = 0; + +#if SHINYEFFECT > 0 + if ((renderFlags & ReflectiveBitMask) > 0) { + vec3 worldVec = normalize(worldPos.xyz); + + float angle = 2 * dot(normalize(normal), worldVec); + angle += gnoise(vec3(uv.x*500, uv.y*500, worldVec.z/10)) / 7.5; + outColor.rgb *= max(vec3(1), vec3(1) + 3*blockLight * gnoise(vec3(worldVec.x/10 + angle, worldVec.y/10 + angle, worldVec.z/10 + angle))); + } + + glow = pow(max(0.0, dot(normal, lightPosition)), 6) * 0.1 * shadowIntensity * (1 - fogAmount); +#endif + + + +#if SSAOLEVEL > 0 + outGPosition = vec4(fragPosition.xyz, fogAmount * 2 + glowLevel); + outGNormal = gnormal; +#endif + +#if NORMALVIEW > 0 + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); +#endif + + outGlow = vec4(glowLevel + glow, 0, 0, outColor.a); +#if TAAMOTION > 0 + // Opaque terrain is not reactive. + outMotion = taaMotionVector(0.0); +#endif +} diff --git a/sources/shaders/chunktopsoil.vsh b/sources/shaders/chunktopsoil.vsh new file mode 100644 index 00000000..52b56fdc --- /dev/null +++ b/sources/shaders/chunktopsoil.vsh @@ -0,0 +1,135 @@ +#version 330 core +// Optimum override of the vanilla chunktopsoil.vsh: adds the TAA motion-vector +// writer (P3). Everything else is vanilla, line for line. +// +// Topsoil deliberately does NOT call applyVertexWarping - vanilla has that call +// commented out and only applies the global warp - so the previous position +// must reproduce exactly that asymmetry, not the chunkopaque path. +// code will change the version to 430 if USESSBO > 0 +#extension GL_ARB_explicit_attrib_location: enable + + #if USESSBO > 0 +// rgb = block light, a=sun light level +layout(location = 0) in vec4 rgbaLightIn; +layout(location = 1) in vec2 uv2In; + #else +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; // Check out vertexflagbits.ash for understanding the contents of this data +layout(location = 4) in vec2 uv2In; +layout(location = 5) in int colormapData; + #endif + + +uniform vec4 rgbaFogIn; +uniform vec3 rgbaAmbientIn; +uniform float fogDensityIn; +uniform float fogMinIn; +uniform vec3 origin; +uniform mat4 projectionMatrix; +uniform mat4 modelViewMatrix; +uniform float subpixelPaddingX; +uniform float subpixelPaddingY; + + +out vec4 rgba; +out vec4 rgbaFog; +out float fogAmount; +out vec2 uv; +out vec2 uv2; +out vec3 normal; + + #if SSAOLEVEL > 0 +out vec4 fragPosition; +out vec4 gnormal; + #endif + +out vec3 vertexPosition; +out vec4 worldPos; + +flat out int renderFlags; + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED world projection +uniform mat4 prevModelViewMatrix; // previous frame's CameraMatrixOrigin +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +out vec4 taaPrevClip; +#endif + + +#include vertexflagbits.ash +#include shadowcoords.vsh +#include fogandlight.vsh +#include vertexwarp.vsh +#include colormap.vsh + + #if USESSBO > 0 +layout(binding = 3, std430) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + +const float uvEpsilon = 1.0 / 32768.0; + +void main(void) +{ + #if USESSBO > 0 + FaceData vdata = faces[gl_VertexID / 4]; + int vIndex = gl_VertexID & 0x03; + renderFlags = vdata.flags[vIndex]; + vertexPosition = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #else + renderFlags = renderFlagsIn; + vertexPosition = xyz; + #endif + + vec4 truePos = vec4(vertexPosition + origin, 1.0); + worldPos = truePos; + //worldPos = applyVertexWarping(renderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + +#if TAAMOTION > 0 + // The same vertex, one frame ago: camera-relative position displaced by the + // camera's own motion (accuracy rule 4), the global warp re-evaluated with + // the previous frame's counters - and no vertex warp, matching the vanilla + // path above - through the previous unjittered projection. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0); + taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + } +#endif + + calcShadowMapCoords(modelViewMatrix, worldPos); + + #if USESSBO > 0 + calcColorMapUvs(vdata.colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + uv = UnpackUv(vdata, vIndex, subpixelPaddingX, subpixelPaddingY); + #else + calcColorMapUvs(colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + uv = uvIn; + #endif + uv2 = uv2In * 2.0 - vec2((int(uv2In.x * 0x10000) & 1) * (uvEpsilon + subpixelPaddingX * 2.0), (int(uv2In.y * 0x10000) & 1) * (uvEpsilon + subpixelPaddingY * 2.0)); // uv2In least significant bit is a flag which tells whether this coordinate is (for .x) u1 or u2, or (for .y) v1 or v2 + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + rgba = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos); + rgbaFog = rgbaFogIn; + + rgbaFog.a = clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5 + max(0.0, worldPos.y * 0.02), 0.0, 1.0); + + normal = unpackNormal(renderFlags); + +#if SSAOLEVEL > 0 + fragPosition = cameraPos; + gnormal = modelViewMatrix * vec4(normal, 0); + gnormal.w=0; +#endif +} From 7a20b5711387c0fbaf7cd21871977d45f9387c90 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 20:14:48 +0200 Subject: [PATCH 026/226] docs(taa): note the shader patch system follow-up --- TAA-PLAN.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 8b2c82d2..a896b81c 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -340,3 +340,11 @@ must be anchored at the pixel centre plus mv, not at the unjittered current posi - https://ogldev.org/www/tutorial41/tutorial41.html - https://github.com/godotengine/godot/pull/61319 - https://mods.vintagestory.at/show/mod/35005 + +## Follow-up (not part of this plan): shader patch system +Shaders ship as whole-file overrides (`sources/shaders/*` copied over vanilla by name, since v0.1.0; +P3 adds chunktopsoil, entityanimated and the vertexwarp include). A game update that changes a vanilla +shader is silently shadowed. Needed later: emit `patches/shaders/*.patch` against the vanilla archive +(`.vanilla/archives/vs_client_*.tar.gz`) from `scripts/extract-patches.sh`, verify in +`scripts/check-patches.sh`, and keep overrides additive (vanilla functions untouched, Optimum twins +beside them) so patches stay small. Raised by the user on 2026-09-10 during P3. From 445f64804da3e9aad4359ff8a7889ca75dfca24f Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 20:22:49 +0200 Subject: [PATCH 027/226] wip(taa): P3 skinned entities - entityanimated motion writer, verified by GPU readback The previous position is the same skinning run twice: prevModelMatrix x AnimationPrev.values[jointId], then the same warp branch with the previous frame's WarpState, then the previous unjittered projection of whichever view the draw is under (world FOV, or the hand FOV for the first-person hands). Per-entity history is keyed on the animator's own Matrices array, so a spawn, a re-tesselation, a changed animator, an entity that was off-screen last frame or a first/third-person switch all lose their history by construction; those draws write camera-only motion with reactive = 1. The bone upload every entity renderer already makes into the "Animation" block is the single gate this hangs off, which is why the first-person hands, the echo chamber and any mod entity renderer are covered without each keeping its own bookkeeping. Also fixes UBO.Bind, which hard-coded binding point 0 - with a second block beside "Animation" that would have fed both declarations the same buffer. Verified: Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests draws the real entityanimated program and reads the motion attachment back - two bone sets a known translation apart give exactly that translation in pixels, so do a previous model matrix and a previous warp state, identical inputs give (0,0) with the fragment's own window depth, and an invalidated history gives camera motion only. Full suites: 848 Optimum.Tests, 268 Optimum.Render.Vulkan.Tests, check-patches 0 pending / 0 conflict. NOT verified in game on either backend. --- Optimum.Patcher/Program.cs | 13 + Optimum.Patcher/mod-patcher.cs | 10 + .../TaaEntityMotionWriterTests.cs | 733 ++++++++++++++++++ .../taa-entity-motion-coverage-tests.cs | 438 +++++++++++ .../EntityPlayerShapeRenderer.cs.patch | 31 + .../EntityRenderer/ModSystemFpHands.cs.patch | 26 + .../ResoArchives/EchoChamberRenderer.cs.patch | 43 + .../ClientPlatformWindows.cs.patch | 131 ++-- .../ShaderProgramBase.cs.patch | 27 +- .../ShaderProgramEntityanimated.cs.patch | 29 + .../ShaderRegistry.cs.patch | 8 +- .../SystemRenderEntities.cs.patch | 31 +- .../Vintagestory.Client.NoObf/UBO.cs.patch | 35 +- patches/cecil-owned.list | 1 + .../Client/Render/OptimumTemporalFrame.cs | 236 ++++++ sources/shaders/entityanimated.fsh | 184 +++++ sources/shaders/entityanimated.vsh | 160 ++++ 17 files changed, 2064 insertions(+), 72 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs create mode 100644 Optimum.Tests/taa-entity-motion-coverage-tests.cs create mode 100644 patches/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs.patch create mode 100644 patches/VSEssentials/EntityRenderer/ModSystemFpHands.cs.patch create mode 100644 patches/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs.patch create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs.patch create mode 100644 sources/shaders/entityanimated.fsh create mode 100644 sources/shaders/entityanimated.vsh diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index c871eacc..accf1729 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -156,6 +156,16 @@ "BeginMotionWrite", "EndMotionWrite", "ApplyOptimumMotionBlendState", + "InstallOptimumMotionWriteHooks", + }, + // TAA P3: the uniform block a buffer feeds and the point it is bound to. + // Vanilla had one block per program and Bind() hard-coded binding point 0; + // the entity motion writer adds a second ("AnimationPrev") beside it, and + // Update routes the bone upload through OptimumEntityMotion by block name. + ["Vintagestory.Client.NoObf.UBO"] = new() + { + "BlockName", + "BindingPoint", }, ["Vintagestory.Client.NoObf.ShaderPrograms"] = new() { @@ -699,6 +709,9 @@ new("Vintagestory.Client.NoObf.UBO", "Dispose", 0), new("Vintagestory.Client.NoObf.UBO", "Update", 3, new[] { "System.Object", "System.Int32", "System.Int32" }), + // TAA P3: the second "AnimationPrev" uniform block for the skinned-entity + // motion writer, created beside "Animation" while TAA is on. + new("Vintagestory.Client.NoObf.ShaderProgramEntityanimated", "initUbos", 0), // Vulkan backend: the packed-face storage buffer the SSBO chunk path uses. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UpdateSSBOMesh", 2), // Vulkan backend, world rendering: the render systems that reach past diff --git a/Optimum.Patcher/mod-patcher.cs b/Optimum.Patcher/mod-patcher.cs index b84ba2be..fc298884 100644 --- a/Optimum.Patcher/mod-patcher.cs +++ b/Optimum.Patcher/mod-patcher.cs @@ -157,6 +157,12 @@ private static Manifest EssentialsManifest() new("Vintagestory.GameContent.EntityShapeRenderer", ".ctor", 2), new("Vintagestory.GameContent.EntityShapeRenderer", "BeforeRender", 1), new("Vintagestory.GameContent.EntityShapeRenderer", "DoRender3DOpaqueBatched", 2), + // TAA P3: the first-person hands draw entity geometry outside the + // shared entity pass, with their own program, their own FOV and + // their own copy of the animation blocks, so both methods carry + // motion-writer changes. + new("Vintagestory.GameContent.EntityPlayerShapeRenderer", "DoRender3DOpaque", 2), + new("Vintagestory.GameContent.ModSystemFpHands", "LoadShaders", 0), new("Vintagestory.GameContent.WeatherSimulationParticles", "asyncParticleSpawn", 2), new("Vintagestory.GameContent.WeatherSystemClient", "OnRenderFrame", 2), new("Vintagestory.GameContent.WeatherSimulationSound", "updateSounds", 1), @@ -244,6 +250,10 @@ private static Manifest SurvivalManifest() new("Vintagestory.GameContent.GearRenderer", "LoadShader", 0), new("Vintagestory.GameContent.GearRenderer", "OnRenderFrame", 2), new("Vintagestory.GameContent.GearRenderer", "updateSuperMechState", 2), + // TAA P3: the echo chamber draws three meshes on the shared + // entityanimated program from DoRender3DOpaque, so it opens the + // motion-attachment window itself. + new("Vintagestory.GameContent.EchoChamberRenderer", "DoRender3DOpaque", 2), ]); } diff --git a/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs new file mode 100644 index 00000000..53480d29 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs @@ -0,0 +1,733 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The skinned-entity motion-vector writer (TAA P3), driven through the seam with +/// the real entityanimated program and read back as pixels. +/// +/// The thing under test is the double skinning: the same vertex is transformed +/// once by modelMatrix * Animation.values[jointId] and once by +/// prevModelMatrix * AnimationPrev.values[jointId], and the difference, +/// converted into render pixels, is the motion vector. Two bone sets that differ +/// by a known translation must therefore produce exactly that translation in +/// pixels - which a "motion is non-zero when the entity moved" assertion could +/// not tell apart from a sign flip, an axis swap or the two blocks being fed the +/// same buffer (the failure mode the UBO binding-point fix exists for). +/// +/// The still case is the baseline, not the test: identical bone sets and no +/// camera movement must give exactly (0, 0), because a converged entity that +/// wobbles is what a wrong previous transform looks like on screen. +/// +/// As in TaaMotionWriterTests the RGBA16F attachment comes back through an RGBA8 +/// decode pass, because the seam's readback is fixed at four bytes per pixel from +/// colour attachment 0. +/// +public class TaaEntityMotionWriterTests +{ + private readonly ITestOutputHelper _output; + + public TaaEntityMotionWriterTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// Normal pointing up, no glow, and no wind-mode bits, so no vertex warp runs. + private const int UpNormalFlags = 7 << 18; + + /// MAXANIMATEDELEMENTS as the corpus stamps it; the UBO is that many mat4. + private const int MaxAnimatedElements = 35; + + private const int AnimationUboBytes = MaxAnimatedElements * 16 * 4; + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + private static float[] Translation(float x, float y, float z) => new[] + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 1f, 0f, + x, y, z, 1f, + }; + + // ------------------------------------------------------------------ tests + + /// + /// A pose that did not change, under a camera that did not move, is zero + /// motion - and the writer still stamps its own depth, so the resolve accepts + /// the pixel instead of silently falling back to camera reprojection. + /// + [SkippableFact] + public void AnUnchangedPoseWritesZeroMotionAndTheFragmentDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderEntityMotion(device!, + previousBone: Identity, + previousModelMatrix: Identity, + historyValid: 1, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f); + + _output.WriteLine($"still: mv = ({centre.MotionX}, {centre.MotionY}), writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + // Identity matrices put the quad at NDC z = 0, which is window depth + // 0.5 on both backends. + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The bone the vertex is weighted to having been somewhere else last frame is + /// the entity's own motion: with identity camera matrices a bone translation + /// of d moves the pixel by exactly d * 0.5 * renderSize, and the sign is + /// "where the pixel was", not "where it went". + /// + [SkippableTheory] + [InlineData(0.25f, 0f)] + [InlineData(0f, -0.125f)] + [InlineData(-0.1875f, 0.0625f)] + public void APreviousBoneTransformShowsUpAsTheExactPixelDisplacement(float boneX, float boneY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderEntityMotion(device!, + previousBone: Translation(boneX, boneY, 0f), + previousModelMatrix: Identity, + historyValid: 1, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f); + + float expectedX = boneX * 0.5f * Size; + float expectedY = boneY * 0.5f * Size; + + _output.WriteLine($"bone ({boneX}, {boneY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The renderer's model matrix having been somewhere else last frame moves the + /// entity exactly as a bone does. Separate from the bone case because the two + /// come from different sources - the model matrix through a uniform, the bones + /// through the second UBO - and a shader that dropped one of the two factors + /// would still pass the other test. + /// + [SkippableFact] + public void APreviousModelMatrixShowsUpAsTheExactPixelDisplacement() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float modelX = -0.25f; + const float modelY = 0.125f; + Decoded centre = RenderEntityMotion(device!, + previousBone: Identity, + previousModelMatrix: Translation(modelX, modelY, 0f), + historyValid: 1, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f); + + float expectedX = modelX * 0.5f * Size; + float expectedY = modelY * 0.5f * Size; + + _output.WriteLine($"model ({modelX}, {modelY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + } + } + + /// + /// Without usable history - a spawn, a changed animator or mesh, a first/third + /// person switch, the entity off-screen last frame - the writer must not read + /// the previous pose at all. It falls back to treating the surface as static + /// in the world, so only the camera's own movement displaces it; the C# side + /// raises taaReactive for the same draw so the resolve leans on this frame. + /// + /// The previous bone here is deliberately a large translation: if the shader + /// took the history branch anyway, the vector would be that instead. + /// + [SkippableFact] + public void WithoutUsableHistoryTheVectorIsCameraMotionOnly() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float cameraDeltaX = 0.25f; + const float cameraDeltaY = -0.125f; + Decoded centre = RenderEntityMotion(device!, + previousBone: Translation(0.75f, 0.75f, 0f), + previousModelMatrix: Translation(-0.5f, 0.5f, 0f), + historyValid: 0, + cameraDeltaX: cameraDeltaX, + cameraDeltaY: cameraDeltaY, + previousGlobalWarp: 0f); + + float expectedX = cameraDeltaX * 0.5f * Size; + float expectedY = cameraDeltaY * 0.5f * Size; + + _output.WriteLine($"no history: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + } + } + + /// + /// Vertex animation that differed last frame is motion too, for entities as + /// much as for terrain: the previous warp state runs through the same + /// WarpState overloads in the vertexwarp include. The values are chosen so + /// applyGlobalWarping's phase argument saturates at zero over the whole quad, + /// which turns the warp into a constant offset with a closed-form expectation + /// instead of a "not zero" assertion. + /// + [SkippableFact] + public void APreviousWarpStateThatDiffersFromThisFrameProducesItsOwnMotion() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float previousWarp = 8f; + Decoded centre = RenderEntityMotion(device!, + previousBone: Identity, + previousModelMatrix: Identity, + historyValid: 1, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: previousWarp); + + double offsetX = (Math.Sin(0.0) + Math.Sin(0.5) + Math.Sin(1.0) / 3.0) / 30.0 * previousWarp; + float expectedX = (float)(offsetX * 0.5 * Size); + + _output.WriteLine($"warp-only: mv = ({centre.MotionX}, {centre.MotionY}), expected ({expectedX}, 0)"); + + Assert.True(Math.Abs(expectedX) > 1f, "the warp displacement chosen is too small to test"); + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float WriterDepth { get; } + } + + /// + /// Draws one skinned quad with the real entityanimated program compiled as a + /// motion writer, then decodes the motion attachment and returns its centre + /// pixel. The current pose is always the identity, so every expectation is + /// stated entirely in terms of the previous-frame inputs. + /// + private unsafe Decoded RenderEntityMotion( + VulkanDevice device, + float[] previousBone, + float[] previousModelMatrix, + int historyValid, + float cameraDeltaX, + float cameraDeltaY, + float previousGlobalWarp) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + // USEOIT 0 is the opaque entity pass - the only one that writes into + // Primary's motion attachment; the OIT twin fills six outputs on + // Transparent and never touches it. Built here rather than taken from + // ShaderCorpus.Variants() because a global USEOIT 0 is not a + // configuration the client produces for every program. + var variant = new ShaderCorpus.ShaderVariant + { + Name = "taa-entity-opaque", + UseOit = 0, + TaaMotion = 1, + TaaMotionLocation = 2, + MaxAnimatedElements = MaxAnimatedElements, + }; + + List stages = ShaderCorpus.BuildProgram("entityanimated", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "entityanimated"); + + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + "entityanimated declares no taaRenderSize, so it is not a motion writer"); + Assert.True(seam.GetUniformLocation(program, "taaHistoryValid") >= 0, + "entityanimated declares no taaHistoryValid, so it cannot reject stale history"); + + int nextUnit = BindEveryDeclaredSampler(device, seam, program); + int atlas = CreateWhiteTexture(seam); + seam.SetSamplerUnit(program, "entityTex", nextUnit); + seam.BindTexture(nextUnit, atlas); + + // The two bone blocks. Feeding them from separate buffers is the point: + // one buffer serving both declarations is exactly what a hard-coded + // binding point would produce, and it would make every motion vector zero. + int animation = seam.CreateUniformBuffer(program, 0, "Animation", AnimationUboBytes); + int animationPrev = seam.CreateUniformBuffer(program, 1, "AnimationPrev", AnimationUboBytes); + WriteBone(seam, animation, Identity); + WriteBone(seam, animationPrev, previousBone); + + // Primary stand-in: colour, glow and the motion attachment at index 2. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(scene, 0b111); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + int mesh = seam.CreateMesh(BuildSkinnedQuad(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.UseProgram(program); + SetMatrix(seam, program, "projectionMatrix", Identity); + SetMatrix(seam, program, "viewMatrix", Identity); + SetMatrix(seam, program, "modelMatrix", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixFar", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); + SetSceneUniforms(seam, program); + SetWarpUniforms(seam, program, previousGlobalWarp); + + SetMatrix(seam, program, "prevProjectionMatrix", Identity); + SetMatrix(seam, program, "prevViewMatrix", Identity); + SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); + SetInt(seam, program, "taaHistoryValid", historyValid); + SetFloat(seam, program, "taaReactive", historyValid != 0 ? 0f : 1f); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", 0f, 0f); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(mesh); + + byte[] decoded = DecodeMotion(seam, motion); + seam.Present(); + + int offset = ((Size / 2) * Size + Size / 2) * 4; + + AssertClean(seam); + + return new Decoded( + (decoded[offset] / 255f * 2f - 1f) * DecodeScale, + (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, + decoded[offset + 2] / 255f); + } + + private static unsafe void WriteBone(IOptimumGraphicsDevice seam, int ubo, float[] matrix) + { + // Only joint 0 is referenced by the mesh below; the rest of the block + // stays zero, which is what a shader that read the wrong joint would show. + fixed (float* values = matrix) + { + seam.UpdateUniformBuffer(ubo, (IntPtr)values, 0, 16 * sizeof(float)); + } + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-entity-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-entity-decode.fsh" }, + }, "taa-entity-decode"); + + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int quadMesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// A quad in entityanimated's attribute layout: xyz, uv, rgba, flags, then the + /// custom float (damageEffectIn, location 4) and custom int (jointId, + /// location 5). Normals are absent, which is what puts uv on location 1 the + /// way the shader declares it. + /// + private static MeshData BuildSkinnedQuad() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + Vintagestory.API.MathTools.ColorUtil.WhiteArgb, + flags: UpNormalFlags); + } + + mesh.CustomFloats = new CustomMeshDataPartFloat(4) + { + Count = 4, + InterleaveSizes = new[] { 1 }, + InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, + }; + mesh.CustomInts = new CustomMeshDataPartInt(4) + { + Count = 4, + InterleaveSizes = new[] { 1 }, + InterleaveOffsets = new[] { 0 }, + InterleaveStride = 4, + }; + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + return mesh; + } + + /// + /// Enough of the lighting and fog surface to keep the fragment alive: an + /// unlit, fully transparent fragment is discarded before it can write a + /// motion vector, and the test would read the cleared attachment instead. + /// alphaTest is pushed below zero so nothing can discard at all. + /// + private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) + { + SetFloat(seam, program, "alphaTest", -1f); + SetFloat(seam, program, "viewDistance", 1024f); + SetFloat(seam, program, "viewDistanceLod0", 1024f); + SetFloat(seam, program, "zNear", 0.1f); + SetFloat(seam, program, "zFar", 1024f); + SetFloat(seam, program, "fogMinIn", 0f); + SetFloat(seam, program, "fogDensityIn", 0f); + SetFloat(seam, program, "shadowRangeFar", 1024f); + SetFloat(seam, program, "shadowRangeNear", 64f); + SetFloat(seam, program, "shadowMapWidthInv", 1f); + SetFloat(seam, program, "shadowMapHeightInv", 1f); + SetFloat(seam, program, "shadowIntensity", 0f); + SetFloat(seam, program, "damageEffect", 0f); + SetFloat(seam, program, "glitchEffectStrength", 0f); + SetInt(seam, program, "glitchFlicker", 0); + SetInt(seam, program, "entityId", 1); + SetInt(seam, program, "extraGlow", 0); + SetInt(seam, program, "addRenderFlags", 0); + SetFloat(seam, program, "frostAlpha", 0f); + SetFloat3(seam, program, "rgbaAmbientIn", 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaLightIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaFogIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "renderColor", 1f, 1f, 1f, 1f); + SetFloat2(seam, program, "frameSize", Size, Size); + } + + /// + /// Both halves of the warp state, pinned so this frame's warp is a no-op and + /// only the previous one moves anything. + /// + private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, float previousGlobalWarp) + { + SetFloat(seam, program, "timeCounter", 0f); + SetFloat(seam, program, "windWaveCounter", 0f); + SetFloat(seam, program, "windWaveCounterHighFreq", 0f); + SetFloat(seam, program, "waterWaveCounter", 0f); + SetFloat(seam, program, "windSpeed", 0f); + SetFloat(seam, program, "globalWarpIntensity", 0f); + SetFloat(seam, program, "glitchWaviness", 0f); + SetFloat(seam, program, "windWaveIntensity", 1f); + SetFloat(seam, program, "waterWaveIntensity", 1f); + SetInt(seam, program, "perceptionEffectId", 1); + SetFloat(seam, program, "perceptionEffectIntensity", 0f); + SetFloat3(seam, program, "playerpos", 0f, 0f, 0f); + + SetFloat(seam, program, "prevTimeCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounterHighFreq", 0f); + SetFloat(seam, program, "prevWaterWaveCounter", 0f); + SetFloat(seam, program, "prevWindSpeed", 0f); + SetFloat(seam, program, "prevGlobalWarpIntensity", previousGlobalWarp); + SetFloat(seam, program, "prevGlitchWaviness", 0f); + SetFloat(seam, program, "prevWindWaveIntensity", 1f); + SetFloat(seam, program, "prevWaterWaveIntensity", 1f); + SetInt(seam, program, "prevPerceptionEffectId", 1); + SetFloat(seam, program, "prevPerceptionEffectIntensity", 0f); + SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetFloat4( + IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z, w); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + { + var white = new byte[] { 255, 255, 255, 255 }; + fixed (byte* pixels = white) + { + return seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + } + + private static int BindEveryDeclaredSampler( + VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + { + int unit = 0; + foreach (string samplerName in device.SamplerNamesOf(programId)) + { + int texture = CreateWhiteTexture(seam); + seam.SetSamplerUnit(programId, samplerName, unit); + seam.BindTexture(unit, texture); + unit++; + } + return unit; + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-entity-motion-coverage-tests.cs b/Optimum.Tests/taa-entity-motion-coverage-tests.cs new file mode 100644 index 00000000..def2eb05 --- /dev/null +++ b/Optimum.Tests/taa-entity-motion-coverage-tests.cs @@ -0,0 +1,438 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P3 skinned-entity motion-vector writer: the +/// entityanimated shader pair, the second bone block that carries the previous +/// pose, the per-draw hook that fills it, and the plumbing that has to ship all +/// of it (patcher entries, mod-patcher manifests). +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests) proves the numbers. +/// What they catch is the failure this project keeps hitting: a change that works +/// in the build tree and never reaches the installed runtime because a patcher +/// entry was missed. +/// +public class TaaEntityMotionCoverageTests +{ + // ------------------------------------------------------------- the shaders + + [Fact] + public void TheEntityVertexShaderSkinsTheVertexTwiceThroughTheSameWarpBranch() + { + string vertex = Read("sources/shaders/entityanimated.vsh"); + + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("out vec4 taaPrevClip;", vertex); + + // The previous half of every input the current position uses. + foreach (string uniform in new[] + { + "prevProjectionMatrix", "prevViewMatrix", "prevModelMatrix", + "cameraPosDelta", "taaHistoryValid", + }) + { + Assert.True(DeclaresUniform(vertex, uniform), + uniform + " is not declared by entityanimated.vsh"); + } + + // The second bone block, mirroring "Animation" so the same skinning runs + // twice. A shader that reused ElementTransforms would silently produce + // zero motion for every animated entity. + Assert.Contains("uniform AnimationPrev", vertex); + Assert.Contains("mat4 values[MAXANIMATEDELEMENTS];", vertex); + Assert.Contains("} PrevElementTransforms;", vertex); + Assert.Contains("prevModelMatrix * PrevElementTransforms.values[jointId]", vertex); + + // Same branch as the current position, with the previous warp state. + Assert.Contains("WarpState taaPrev = previousWarpState();", vertex); + Assert.Contains("applyLiquidWarpingState(taaPrev, true, taaPrevWorld, 5)", vertex); + Assert.Contains("applyVertexWarpingState(taaPrev, renderFlags, taaPrevWorld)", vertex); + Assert.Contains("applyGlobalWarpingState(taaPrev, taaPrevWorld)", vertex); + + // No usable history: camera-only motion, the same rule the terrain writer + // and the resolve's fallback use. + Assert.Contains("taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0);", vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevViewMatrix * taaPrevWorld);", vertex); + + // The writer has to sit before `int renderFlags = extraGlow + flags;` + // shadows the flat output, or it warps the previous position with the + // wrong flags. + int writer = vertex.IndexOf("taaHistoryValid != 0", StringComparison.Ordinal); + // LastIndexOf: the comment above the writer quotes the shadowing line. + int shadow = vertex.LastIndexOf("int renderFlags = extraGlow + flags;", StringComparison.Ordinal); + Assert.True(writer >= 0 && shadow > writer, + "the TAA writer must run before renderFlags is shadowed by the local"); + } + + [Fact] + public void TheEntityFragmentShaderWritesTheMotionAttachmentWithItsOwnDepth() + { + string fragment = Read("sources/shaders/entityanimated.fsh"); + + Assert.Contains("#if TAAMOTION > 0", fragment); + Assert.Contains("in vec4 taaPrevClip;", fragment); + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + + // The contract taa-resolve.fsh consumes. + Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + Assert.Contains("return vec4(prevPixel - currentPixel, reactive, writerDepth);", fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + + // The first-person hand and item programs write gl_FragDepth, so the + // writer depth has to carry the same offset or the resolve's depth-match + // test rejects every hand pixel. + Assert.Contains("gl_FragDepth = gl_FragCoord.z + depthOffset;", fragment); + Assert.Contains("outMotion = taaMotionVector(taaReactive, clamp(gl_FragCoord.z + depthOffset, 0.0, 1.0));", fragment); + Assert.Contains("outMotion = taaMotionVector(taaReactive, gl_FragCoord.z);", fragment); + + // Only the opaque variant writes into Primary; the OIT twin already + // fills six outputs on Transparent. + Assert.Contains("#if TAAMOTION > 0 && USEOIT==0", fragment); + Assert.True(DeclaresUniform(fragment, "taaReactive")); + Assert.True(DeclaresUniform(fragment, "taaRenderSize")); + Assert.True(DeclaresUniform(fragment, "taaJitterPx")); + } + + // ------------------------------------------------------- the previous pose + + /// + /// Both programs that compile entityanimated.vsh need their own copy of the + /// previous-pose block: the shared one from initUbos, and the first-person + /// hands', which clears the program's UBOs and rebuilds them by hand. + /// + [Fact] + public void BothEntityanimatedProgramsCreateTheirOwnPreviousBoneBlock() + { + string shared = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs"); + + Assert.Contains("ubos[\"Animation\"] = ScreenManager.Platform.CreateUBO(ProgramId, 0, \"Animation\"", shared); + Assert.Contains("if (!Oit && Vintagestory.API.Config.OptimumConfig.EffectiveTaa)", shared); + Assert.Contains("ubos[\"AnimationPrev\"] = ScreenManager.Platform.CreateUBO(ProgramId, 1, \"AnimationPrev\"", shared); + + string fpHands = ReadPatchedOrSource( + "patches/VSEssentials/EntityRenderer/ModSystemFpHands.cs.patch", + "VSEssentials/EntityRenderer/ModSystemFpHands.cs"); + + Assert.Contains("if (OptimumConfig.EffectiveTaa)", fpHands); + Assert.Contains( + "fpModeHandShader.UBOs[\"AnimationPrev\"] = capi.Render.CreateUBO(fpModeHandShader, 1, \"AnimationPrev\"", + fpHands); + } + + /// + /// A second uniform block only works if the buffer remembers which point it + /// belongs to. Vanilla's Bind() hard-coded binding point 0, which would make + /// every Update of "Animation" steal the block "AnimationPrev" was bound to. + /// + [Fact] + public void TheUniformBufferRemembersItsBlockAndBindingPoint() + { + string ubo = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs"); + + Assert.Contains("public string BlockName;", ubo); + Assert.Contains("public int BindingPoint;", ubo); + Assert.Contains("GL.BindBufferBase((BufferRangeTarget)35345, BindingPoint, Handle);", ubo); + Assert.DoesNotContain("GL.BindBufferBase((BufferRangeTarget)35345, 0, Handle);", ubo); + + // The bone upload is the gate every entity draw passes through. + Assert.Contains("if (BlockName == \"Animation\")", ubo); + Assert.Contains( + "optimumProgram.ubos.TryGetValue(\"AnimationPrev\", out var optimumPrevBones)", + ubo); + Assert.Contains( + "OptimumEntityMotion.OnAnimationUpload(optimumProgram, optimumPrevBones, data, size);", + ubo); + + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // Both backends record it, or the GL path binds to point 0 regardless. + Assert.Equal(2, Count(platform, "BlockName = blockName;")); + Assert.Equal(2, Count(platform, "BindingPoint = bindingPoint;")); + } + + // ---------------------------------------------------- the per-draw history + + [Fact] + public void TheFrameContractKeepsPerEntityHistoryKeyedOnTheAnimator() + { + string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + string vertex = Read("sources/shaders/entityanimated.vsh"); + string fragment = Read("sources/shaders/entityanimated.fsh"); + + Assert.Contains("public static class OptimumEntityMotion", frame); + // Identity of the animator's own array is what decides whether last + // frame's pose belongs to this entity: a respawn, a re-tesselation or a + // changed animator hands over a different array and gets no history. + Assert.Contains("ConditionalWeakTable", frame); + Assert.Contains("histories.GetValue(bones, _ => new History());", frame); + + // Every uniform the hook sets must be declared by the shader that reads + // it, or the HasUniform guard silently drops it. + foreach (string uniform in new[] { "prevProjectionMatrix", "prevViewMatrix", "prevModelMatrix" }) + { + Assert.Contains("program.UniformMatrix(\"" + uniform + "\"", frame); + Assert.True(DeclaresUniform(vertex, uniform), uniform + " is set but declared by no shader"); + } + Assert.Contains("program.Uniform(\"taaHistoryValid\", valid ? 1 : 0);", frame); + Assert.True(DeclaresUniform(vertex, "taaHistoryValid")); + Assert.Contains("program.Uniform(\"taaReactive\", valid ? 0f : 1f);", frame); + Assert.True(DeclaresUniform(fragment, "taaReactive")); + + // The two warp uniforms an entity overrides for itself; the global + // previous warp state would replay a warp the entity never had. + Assert.Contains("uniformName == \"windWaveIntensity\"", frame); + Assert.Contains("uniformName == \"waterWaveCounter\"", frame); + Assert.Contains("program.Uniform(\"prevWindWaveIntensity\"", frame); + Assert.Contains("program.Uniform(\"prevWaterWaveCounter\"", frame); + + // History is only usable when the same entity was drawn last frame, under + // the same view, with the same joint count, in a frame that did not reset. + string validity = MethodBodyAfter(frame, "public static void OnAnimationUpload"); + Assert.Contains("!frame.Reset &&", validity); + Assert.Contains("history.PreviousFrame == frame.FrameIndex - 1 &&", validity); + Assert.Contains("history.PrevFloatCount == floats &&", validity); + Assert.Contains("history.PrevView == view &&", validity); + Assert.Contains("frame.WasViewCaptured(view)", validity); + + // The roll happens once per frame, so an entity drawn twice in a frame + // still compares against the frame before rather than its own first draw. + Assert.Contains("if (history.CapturedFrame != frame.FrameIndex)", validity); + } + + /// + /// The hand FOV is a different view with a different previous projection, and + /// nothing tells a draw which one it is under except the projection last + /// loaded by Set3DProjection. + /// + [Fact] + public void TheFrameContractTracksWhichViewTheLoadedProjectionBelongsTo() + { + string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + + Assert.Contains("EnumTemporalView ActiveView { get; }", frame); + Assert.Contains("public EnumTemporalView ActiveView { get; private set; }", frame); + + string record = MethodBodyAfter(frame, "public void RecordProjection(EnumTemporalView view, double[] matrix)"); + Assert.Contains("ActiveView = view;", record); + + // Reset per frame, so a frame that never sets up the hand view cannot + // inherit it from the last one. + string advance = MethodBodyAfter(frame, "public void Advance("); + Assert.Contains("ActiveView = EnumTemporalView.World;", advance); + + // The writer reads the previous projection of that same view. + Assert.Contains("frame.GetPrevProjection(view)", frame); + } + + [Fact] + public void TheHooksAreGatedOnASingleFlagRaisedWhereTaaMotionIsStamped() + { + string registry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + Assert.Contains("OptimumEntityMotion.Enabled = taaMotion;", registry); + + string shaderProgram = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs"); + Assert.Contains( + "if (OptimumEntityMotion.Enabled) OptimumEntityMotion.NoteWarpUniform(uniformName, value);", + shaderProgram); + Assert.Contains( + "if (OptimumEntityMotion.Enabled && uniformName == \"modelMatrix\") OptimumEntityMotion.NoteModelMatrix(matrix);", + shaderProgram); + } + + // ------------------------------------------------- the draw-buffer windows + + [Fact] + public void EveryEntityPassOpensTheMotionDrawBufferWindow() + { + string entities = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs"); + + // Reuses the terrain stage's window rather than adding a second pair. + Assert.Contains( + "bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite();", + entities); + Assert.Contains("optimumPlatform.EndMotionWrite();", entities); + + // The mod-side renderers reach the same window through the API. + string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + Assert.Contains("public static class OptimumMotionWrite", frame); + Assert.Contains("public static Func BeginHook;", frame); + Assert.Contains("public static Action EndHook;", frame); + + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains("OptimumMotionWrite.BeginHook = BeginMotionWrite;", platform); + Assert.Contains("OptimumMotionWrite.EndHook = EndMotionWrite;", platform); + // Installed on both framebuffer setup paths, device and GL. + Assert.Equal(2, Count(platform, "InstallOptimumMotionWriteHooks();")); + + foreach ((string patch, string source) in new[] + { + ("patches/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs.patch", + "VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs"), + ("patches/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs.patch", + "VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs"), + }) + { + string renderer = ReadPatchedOrSource(patch, source); + Assert.Contains("bool optimumMotionWrite = OptimumMotionWrite.Begin();", renderer); + Assert.Contains("if (optimumMotionWrite) OptimumMotionWrite.End();", renderer); + } + } + + // -------------------------------------------------------------- the ship + + [Fact] + public void CecilPatcherShipsEveryEntityMotionMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"BlockName\",", patcher); + Assert.Contains("\"BindingPoint\",", patcher); + Assert.Contains("[\"Vintagestory.Client.NoObf.UBO\"]", patcher); + Assert.Contains("\"InstallOptimumMotionWriteHooks\",", patcher); + + Assert.Contains("\"Vintagestory.Client.NoObf.ShaderProgramEntityanimated\", \"initUbos\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.SystemRenderEntities\", \"OnRenderOpaque3D\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.UBO\", \"Update\", 3", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.UBO\", \"Bind\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"CreateUBO\", 4", patcher); + } + + [Fact] + public void ModPatcherManifestsCarryTheChangedModRenderers() + { + string manifest = Read("Optimum.Patcher/mod-patcher.cs"); + + Assert.Contains( + "new(\"Vintagestory.GameContent.EntityPlayerShapeRenderer\", \"DoRender3DOpaque\", 2)", + manifest); + Assert.Contains("new(\"Vintagestory.GameContent.ModSystemFpHands\", \"LoadShaders\", 0)", manifest); + Assert.Contains( + "new(\"Vintagestory.GameContent.EchoChamberRenderer\", \"DoRender3DOpaque\", 2)", + manifest); + } + + /// + /// The patched shader files only reach a running client if `make deploy` and + /// every packager copy sources/shaders - they do already, directory-wide, so + /// this only guards against a regression that starts naming files. + /// + [Fact] + public void DeployAndEveryPackagerShipTheEntityShaderOverrides() + { + foreach (string path in new[] + { + "Makefile", "scripts/package-linux.sh", "scripts/package-macos.sh", "scripts/package-linux.ps1", + }) + { + string text = Read(path); + Assert.Contains("sources/shaders", text.Replace('\\', '/')); + Assert.DoesNotContain("entityanimated.vsh", text); + } + } + + // ----------------------------------------------------------------- helpers + + private static string MethodBodyAfter(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such method: " + signature); + return signature + BodyOf(source, signature); + } + + private static bool DeclaresUniform(string shader, string name) + { + foreach (string line in shader.Replace("\r\n", "\n").Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("uniform ", StringComparison.Ordinal)) continue; + string declaration = trimmed.Substring("uniform ".Length); + int semicolon = declaration.IndexOf(';'); + if (semicolon < 0) continue; + declaration = declaration.Substring(0, semicolon); + int assign = declaration.IndexOf('='); + if (assign >= 0) declaration = declaration.Substring(0, assign); + int space = declaration.TrimEnd().LastIndexOf(' '); + if (space < 0) continue; + if (declaration.TrimEnd().Substring(space + 1) == name) return true; + } + return false; + } + + private static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + private static int Count(string source, string value) + { + int count = 0; + int offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/patches/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs.patch b/patches/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs.patch new file mode 100644 index 00000000..03823258 --- /dev/null +++ b/patches/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs.patch @@ -0,0 +1,31 @@ +diff --git a/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs b/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs +index 35f866f..06d5a99 100644 +--- a/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs ++++ b/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs +@@ -296,11 +296,25 @@ namespace Vintagestory.GameContent + prog.Uniform("depthOffset", -0.3f - GameMath.Max(0, capi.Settings.Int["fieldOfView"] / 90f - 1) / 2f); + + capi.Render.GlPushMatrix(); + capi.Render.GlLoadMatrix(capi.Render.CameraMatrixOrigin); + +- base.DoRender3DOpaqueBatched(dt, false); ++ // Optimum TAA (P3): the hands are drawn outside the shared ++ // entity pass, with their own program and their own FOV, so ++ // they open the motion-attachment window themselves. The view ++ // this draw belongs to is already recorded by the ++ // Set3DProjection above, which is what makes the writer pick ++ // the hand FOV's previous projection rather than the world's. ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ base.DoRender3DOpaqueBatched(dt, false); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + capi.Render.GlPopMatrix(); + + prog.Stop(); + } diff --git a/patches/VSEssentials/EntityRenderer/ModSystemFpHands.cs.patch b/patches/VSEssentials/EntityRenderer/ModSystemFpHands.cs.patch new file mode 100644 index 00000000..f07cc87e --- /dev/null +++ b/patches/VSEssentials/EntityRenderer/ModSystemFpHands.cs.patch @@ -0,0 +1,26 @@ +diff --git a/VSEssentials/EntityRenderer/ModSystemFpHands.cs b/VSEssentials/EntityRenderer/ModSystemFpHands.cs +index db237ee..e4b894f 100644 +--- a/VSEssentials/EntityRenderer/ModSystemFpHands.cs ++++ b/VSEssentials/EntityRenderer/ModSystemFpHands.cs +@@ -34,10 +34,21 @@ namespace Vintagestory.GameContent + if (ok) + { + foreach (var ubo in fpModeHandShader.UBOs.Values) ubo.Dispose(); + fpModeHandShader.UBOs.Clear(); + fpModeHandShader.UBOs["Animation"] = capi.Render.CreateUBO(fpModeHandShader, 0, "Animation", GlobalConstants.MaxAnimatedElements * 16 * 4); ++ // Optimum TAA (P3): this program is its own copy of entityanimated, ++ // so it needs its own copy of the previous-pose block too - the one ++ // ShaderProgramEntityanimated.initUbos creates belongs to the shared ++ // program and the clear above would have thrown it away anyway. ++ // The block only exists while TAA is on (TAAMOTION), which is what ++ // EffectiveTaa reports; asking for a block that is not declared is a ++ // GL error. ++ if (OptimumConfig.EffectiveTaa) ++ { ++ fpModeHandShader.UBOs["AnimationPrev"] = capi.Render.CreateUBO(fpModeHandShader, 1, "AnimationPrev", GlobalConstants.MaxAnimatedElements * 16 * 4); ++ } + } + + return ok; + } + diff --git a/patches/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs.patch b/patches/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs.patch new file mode 100644 index 00000000..a1ddb6e1 --- /dev/null +++ b/patches/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs.patch @@ -0,0 +1,43 @@ +diff --git a/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs b/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs +index 1df7c9d..666c84e 100644 +--- a/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs ++++ b/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs +@@ -207,18 +207,32 @@ namespace Vintagestory.GameContent + + capi.Render.GlPopMatrix(); + + if (meshRef1 != null) + { +- prog.BindTexture2D("entityTex", echoTexture1.TextureId, 0); +- capi.Render.RenderMesh(meshRef1); ++ // Optimum TAA (P3): three meshes, one pose, one model matrix - and ++ // drawn from DoRender3DOpaque rather than the shared batched pass, ++ // so this renderer opens the motion-attachment window itself. The ++ // previous pose came from the Animation UBO update above; all three ++ // draws reuse it, which is exactly right because they share the ++ // transform. ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ prog.BindTexture2D("entityTex", echoTexture1.TextureId, 0); ++ capi.Render.RenderMesh(meshRef1); + +- prog.BindTexture2D("entityTex", echoTexture2.TextureId, 0); +- capi.Render.RenderMesh(meshRef2); ++ prog.BindTexture2D("entityTex", echoTexture2.TextureId, 0); ++ capi.Render.RenderMesh(meshRef2); + +- prog.BindTexture2D("entityTex", echoTexture3.TextureId, 0); +- capi.Render.RenderMesh(meshRef3); ++ prog.BindTexture2D("entityTex", echoTexture3.TextureId, 0); ++ capi.Render.RenderMesh(meshRef3); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + } + + prog.Stop(); + + } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 16f9b50a..8c3deebf 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..43ccc85 100644 +index 6edf0c9..6abf6ed 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -685,7 +685,7 @@ index 6edf0c9..43ccc85 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1589,457 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1589,458 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -900,6 +900,7 @@ index 6edf0c9..43ccc85 100644 + } + TaaTargetsReady = taaRequested && MotionAttachmentIndex >= 0 + && list[OptimumTaaHistoryIndexA] != null && list[OptimumTaaHistoryIndexB] != null; ++ InstallOptimumMotionWriteHooks(); + + // FSR renders at a reduced scale and resolves into a native-sized target. + if (ClientSettings.OptimumRenderScale < 1.0f) @@ -1143,7 +1144,7 @@ index 6edf0c9..43ccc85 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2071,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2072,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1158,7 +1159,7 @@ index 6edf0c9..43ccc85 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2098,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2099,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1175,7 +1176,7 @@ index 6edf0c9..43ccc85 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2143,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2144,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1214,7 +1215,7 @@ index 6edf0c9..43ccc85 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2356,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2357,53 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1239,6 +1240,7 @@ index 6edf0c9..43ccc85 100644 + } + TaaTargetsReady = taaRequested && MotionAttachmentIndex >= 0 + && list[OptimumTaaHistoryIndexA] != null && list[OptimumTaaHistoryIndexB] != null; ++ InstallOptimumMotionWriteHooks(); + if (ClientSettings.OptimumRenderScale < 1.0f) + { + int nativeWidth = ((NativeWindow)window).ClientSize.X; @@ -1267,7 +1269,7 @@ index 6edf0c9..43ccc85 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2510,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2512,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1282,7 +1284,7 @@ index 6edf0c9..43ccc85 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,12 +2533,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2535,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1372,7 +1374,7 @@ index 6edf0c9..43ccc85 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2634,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2636,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1401,7 +1403,7 @@ index 6edf0c9..43ccc85 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,38 +2668,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,38 +2670,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1537,7 +1539,7 @@ index 6edf0c9..43ccc85 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2827,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2829,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1578,7 +1580,7 @@ index 6edf0c9..43ccc85 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2870,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2872,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1645,7 +1647,7 @@ index 6edf0c9..43ccc85 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2940,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2942,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1667,7 +1669,7 @@ index 6edf0c9..43ccc85 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2964,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2966,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1694,7 +1696,7 @@ index 6edf0c9..43ccc85 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +2989,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +2991,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1836,7 +1838,7 @@ index 6edf0c9..43ccc85 100644 //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3132,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3134,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1869,7 +1871,7 @@ index 6edf0c9..43ccc85 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3167,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3169,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1931,7 +1933,7 @@ index 6edf0c9..43ccc85 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3244,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3246,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1978,7 +1980,7 @@ index 6edf0c9..43ccc85 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3293,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3295,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2014,7 +2016,7 @@ index 6edf0c9..43ccc85 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,55 +3340,266 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,55 +3342,282 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2101,6 +2103,22 @@ index 6edf0c9..43ccc85 100644 + } + + /// ++ /// Optimum TAA (P3): publishes the draw-buffer window to code that cannot see ++ /// this type. ++ /// ++ /// The first-person hands and the echo chamber draw entity geometry from the ++ /// mod assemblies, which only reference the API, so they reach ++ /// through OptimumMotionWrite's two delegates. ++ /// Installed wherever the TAA targets are (re)evaluated, which is also the ++ /// only place that could have invalidated them. ++ /// ++ private void InstallOptimumMotionWriteHooks() ++ { ++ OptimumMotionWrite.BeginHook = BeginMotionWrite; ++ OptimumMotionWrite.EndHook = EndMotionWrite; ++ } ++ ++ /// + /// Optimum TAA (P3): adds the motion attachment to Primary's draw-buffer mask + /// for the duration of one motion-writing pass, and + /// takes it back out. @@ -2298,7 +2316,7 @@ index 6edf0c9..43ccc85 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3647,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3665,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2324,7 +2342,7 @@ index 6edf0c9..43ccc85 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3680,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3698,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2346,7 +2364,7 @@ index 6edf0c9..43ccc85 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3709,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3727,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2444,7 +2462,7 @@ index 6edf0c9..43ccc85 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +3808,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +3826,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2647,7 +2665,7 @@ index 6edf0c9..43ccc85 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4017,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4035,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2814,7 +2832,7 @@ index 6edf0c9..43ccc85 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4187,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4205,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2838,7 +2856,7 @@ index 6edf0c9..43ccc85 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4216,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4234,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2876,7 +2894,7 @@ index 6edf0c9..43ccc85 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4276,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4294,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2919,7 +2937,7 @@ index 6edf0c9..43ccc85 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4329,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4347,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2978,7 +2996,7 @@ index 6edf0c9..43ccc85 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4444,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4462,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3052,7 +3070,7 @@ index 6edf0c9..43ccc85 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4542,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4560,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3083,7 +3101,7 @@ index 6edf0c9..43ccc85 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4579,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4597,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3118,7 +3136,7 @@ index 6edf0c9..43ccc85 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4626,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4644,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3147,7 +3165,7 @@ index 6edf0c9..43ccc85 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4657,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4675,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3164,6 +3182,8 @@ index 6edf0c9..43ccc85 100644 + UBO optimumUbo = new UBO(); + optimumUbo.Handle = optimumDevice.CreateUniformBuffer(shaderProgramId, bindingPoint, blockName, size); + optimumUbo.Size = size; ++ optimumUbo.BlockName = blockName; ++ optimumUbo.BindingPoint = bindingPoint; + return optimumUbo; + } int num = GL.GenBuffer(); @@ -3171,7 +3191,20 @@ index 6edf0c9..43ccc85 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2605,10 +4694,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +4702,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); + ScreenManager.Platform.CheckGlError(); + UBO uBO = new UBO(); + uBO.Handle = num; + uBO.Size = size; ++ uBO.BlockName = blockName; ++ uBO.BindingPoint = bindingPoint; + uBO.Unbind(); + ScreenManager.Platform.CheckGlError(); + return uBO; + } + +@@ -2605,10 +4716,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3188,7 +3221,7 @@ index 6edf0c9..43ccc85 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4746,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4768,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3233,7 +3266,7 @@ index 6edf0c9..43ccc85 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4783,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4805,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3254,7 +3287,7 @@ index 6edf0c9..43ccc85 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4802,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4824,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3275,7 +3308,7 @@ index 6edf0c9..43ccc85 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4821,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4843,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3296,7 +3329,7 @@ index 6edf0c9..43ccc85 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4840,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4862,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3317,7 +3350,7 @@ index 6edf0c9..43ccc85 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4863,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4885,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3338,7 +3371,7 @@ index 6edf0c9..43ccc85 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4906,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4928,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3364,7 +3397,7 @@ index 6edf0c9..43ccc85 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5148,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5170,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3386,7 +3419,7 @@ index 6edf0c9..43ccc85 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5346,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5368,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3409,7 +3442,7 @@ index 6edf0c9..43ccc85 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5420,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5442,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3458,7 +3491,7 @@ index 6edf0c9..43ccc85 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5490,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5512,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3490,7 +3523,7 @@ index 6edf0c9..43ccc85 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5520,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5542,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3516,7 +3549,7 @@ index 6edf0c9..43ccc85 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5868,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5890,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3546,7 +3579,7 @@ index 6edf0c9..43ccc85 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5922,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5944,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch index 63473dc1..5518c817 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs -index 815363d..0b21d87 100644 +index 815363d..d6f9a13 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs -@@ -109,102 +109,220 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -109,102 +109,231 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable { int value = ScreenManager.Platform.GenSampler(isLinear); customSamplers.Add(uniformName, value); @@ -17,6 +17,11 @@ index 815363d..0b21d87 100644 public void Uniform(string uniformName, float value) { CheckShaderIsActive(); ++ // Optimum TAA (P3): windWaveIntensity and waterWaveCounter are the two warp ++ // uniforms an entity draw overrides for itself, so the previous frame's ++ // values for them have to be remembered per entity rather than taken from ++ // the global previous warp state. One static bool read when TAA is off. ++ if (OptimumEntityMotion.Enabled) OptimumEntityMotion.NoteWarpUniform(uniformName, value); + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) + { @@ -189,6 +194,12 @@ index 815363d..0b21d87 100644 public void UniformMatrix(string uniformName, float[] matrix) { CheckShaderIsActive(); ++ // Optimum TAA (P3): the model matrix a draw is about to use, kept so the ++ // bone upload that follows can store it as that entity's previous model ++ // matrix. Recorded here because every entity renderer - the shared pass, ++ // the first-person hands, the echo chamber, any mod - sets it by this name ++ // through this one method. ++ if (OptimumEntityMotion.Enabled && uniformName == "modelMatrix") OptimumEntityMotion.NoteModelMatrix(matrix); + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) + { @@ -223,7 +234,7 @@ index 815363d..0b21d87 100644 [MethodImpl(MethodImplOptions.AggressiveInlining)] protected void CheckShaderIsActive() -@@ -220,10 +338,37 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -220,10 +349,37 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable return uniformLocations.ContainsKey(uniformName); } @@ -261,7 +272,7 @@ index 815363d..0b21d87 100644 GL.BindTexture((TextureTarget)3553, textureId); if (customSamplers.TryGetValue(samplerName, out var value)) { -@@ -240,10 +385,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -240,10 +396,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable BindTexture2D(samplerName, textureId, textureLocations[samplerName]); } @@ -285,7 +296,7 @@ index 815363d..0b21d87 100644 GL.BindTexture((TextureTarget)34067, textureId); if (clampTToEdge) { -@@ -251,15 +409,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -251,15 +420,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable } } @@ -313,7 +324,7 @@ index 815363d..0b21d87 100644 public void Use() { -@@ -269,11 +439,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -269,11 +450,23 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable } if (disposed) { @@ -338,7 +349,7 @@ index 815363d..0b21d87 100644 if (includes.Contains("fogandlight.fsh")) { Uniform("zNear", shaderUniforms.ZNear); -@@ -369,14 +551,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -369,14 +562,27 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable } } @@ -369,7 +380,7 @@ index 815363d..0b21d87 100644 { ubo.Value.Unbind(); } -@@ -388,10 +583,24 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable +@@ -388,10 +594,24 @@ public abstract class ShaderProgramBase : IShaderProgram, IDisposable if (disposed) { return; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs.patch new file mode 100644 index 00000000..17489f0f --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs.patch @@ -0,0 +1,29 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs +index 63fb5e6..635ded4 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs +@@ -498,13 +498,24 @@ public class ShaderProgramEntityanimated : ShaderProgram + return num; + } + + public void initUbos() + { ++ // Mono.Cecil transplant. + foreach (UBORef value in ubos.Values) + { + value.Dispose(); + } + ubos.Clear(); + ubos["Animation"] = ScreenManager.Platform.CreateUBO(ProgramId, 0, "Animation", GlobalConstants.MaxAnimatedElements * 16 * 4); ++ // Optimum TAA (P3): the same bones one frame ago, for the motion-vector ++ // writer in entityanimated.vsh. The block only exists when TAAMOTION was ++ // stamped 1, which is exactly OptimumConfig.EffectiveTaa - asking GL for ++ // the index of a block that is not there yields INVALID_INDEX and a GL ++ // error. The OIT variant renders into Transparent and never writes the ++ // motion attachment, so it gets no second block either. ++ if (!Oit && Vintagestory.API.Config.OptimumConfig.EffectiveTaa) ++ { ++ ubos["AnimationPrev"] = ScreenManager.Platform.CreateUBO(ProgramId, 1, "AnimationPrev", GlobalConstants.MaxAnimatedElements * 16 * 4); ++ } + } + } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index 90b3e2dc..4541b2bf 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..ad4177e 100644 +index 4a24e75..6177166 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -193,7 +193,7 @@ index 4a24e75..ad4177e 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +452,44 @@ public class ShaderRegistry +@@ -333,10 +452,48 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; @@ -227,6 +227,10 @@ index 4a24e75..ad4177e 100644 + // - the same condition SSAOLEVEL above is stamped from, so the two can + // never disagree. + bool taaMotion = OptimumConfig.EffectiveTaa; ++ // The per-uniform hooks in ShaderProgramBase and the bone-upload hook in ++ // UBO exist for the entity writer only; this is the single flag that tells ++ // them whether any shader in the process was built with it. ++ OptimumEntityMotion.Enabled = taaMotion; + int taaMotionLocation = ((ClientSettings.SSAOQuality > 0) ? 4 : 2); + string taaDefines = "#define TAAMOTION " + (taaMotion ? 1 : 0) + "\r\n#define TAAMOTIONLOCATION " + taaMotionLocation + "\r\n"; + Shader taaFrag = program.FragmentShader; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch index 115291f5..1799b086 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs -index 374f827..324fffa 100644 +index 374f827..9d3cbfe 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs @@ -1,15 +1,36 @@ @@ -281,23 +281,30 @@ index 374f827..324fffa 100644 public void OnRenderOpaque3D(float deltaTime) { RuntimeStats.renderedEntities = 0; -@@ -82,15 +303,44 @@ public class SystemRenderEntities : ClientSystem +@@ -82,15 +303,57 @@ public class SystemRenderEntities : ClientSystem game.Platform.GlDisableCullFace(); game.GlMatrixModeModelView(); game.GlPushMatrix(); game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); game.Platform.GlToggleBlend(on: true); - foreach (KeyValuePair entityRenderer2 in game.EntityRenderers) ++ // Optimum TAA (P3): every draw in the batched loop below goes through ++ // entityanimated, which writes motion vectors, so the motion attachment ++ // joins Primary's draw-buffer mask for the whole loop. The held-item and ++ // first-person passes above open their own window where they need one; a ++ // pass whose shader has no motion output must stay outside it, or the ++ // attachment keeps whatever was there and the resolve reprojects a pixel ++ // by a vector that belongs to another surface. A no-op when TAA is off. ++ ClientPlatformWindows optimumPlatform = game.Platform as ClientPlatformWindows; ++ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); + OptimumEntityShaderState.End(); + bool shaderStateCacheEnabled = OptimumConfig.EffectiveEntityShaderStateCache && !optimumEntityShaderCacheDisabled; + bool shaderSegmentActive = false; + bool shaderSegmentUnavailable = false; + try - { -- if (entityRenderer2.Value.entity.IsRendered) ++ { + foreach (KeyValuePair entityRenderer2 in game.EntityRenderers) - { -- entityRenderer2.Value.DoRender3DOpaqueBatched(deltaTime, isShadowPass: false); ++ { + if (entityRenderer2.Value.entity.IsRendered) + { + bool supportsShaderStateCache = shaderStateCacheEnabled && !shaderSegmentUnavailable && entityRenderer2.Value is IOptimumEntityShaderRenderer shaderRenderer && shaderRenderer.OptimumShaderStateCompatible; @@ -316,20 +323,26 @@ index 374f827..324fffa 100644 + } + } + finally -+ { + { +- if (entityRenderer2.Value.entity.IsRendered) + if (shaderSegmentActive) + { + EndOptimumEntityShaderSegment(); + } + else -+ { + { +- entityRenderer2.Value.DoRender3DOpaqueBatched(deltaTime, isShadowPass: false); + OptimumEntityShaderState.End(); ++ } ++ if (optimumMotionWrite) ++ { ++ optimumPlatform.EndMotionWrite(); } } game.GlPopMatrix(); entityanimated.Stop(); ScreenManager.FrameProfiler.Mark("ree-op-b"); -@@ -127,10 +377,25 @@ public class SystemRenderEntities : ClientSystem +@@ -127,10 +390,25 @@ public class SystemRenderEntities : ClientSystem shaderProgramChunkshadowmap.Use(); } foreach (KeyValuePair entityRenderer in game.EntityRenderers) diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs.patch index bc33cb5b..609628d8 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs.patch @@ -1,14 +1,24 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs b/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs -index f250b1b..5518d04 100644 +index f250b1b..db1cc08 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/UBO.cs -@@ -7,33 +7,64 @@ using Vintagestory.API.Util; +@@ -7,33 +7,74 @@ using Vintagestory.API.Util; namespace Vintagestory.Client.NoObf; public class UBO : UBORef { + // Mono.Cecil transplant. ++ // The block this buffer feeds and the point it is bound to. Vanilla knew ++ // neither: every program had exactly one uniform block and Bind() hard-coded ++ // binding point 0. TAA's entity writer adds a second block ("AnimationPrev") ++ // next to "Animation", so re-binding on every Update would otherwise take ++ // over the first block's binding point and hand both declarations the same ++ // buffer. Recorded by CreateUBO on both backends. ++ public string BlockName; ++ ++ public int BindingPoint; ++ + // Handle carries the device's uniform-buffer handle on the device path, the + // same convention VAO.VaoId uses for meshes, so UBORef stays the type mods + // already hold and the binding point survives from CreateUBO. @@ -21,7 +31,8 @@ index f250b1b..5518d04 100644 + return; + } GL.BindBuffer((BufferTarget)35345, Handle); - GL.BindBufferBase((BufferRangeTarget)35345, 0, Handle); +- GL.BindBufferBase((BufferRangeTarget)35345, 0, Handle); ++ GL.BindBufferBase((BufferRangeTarget)35345, BindingPoint, Handle); } public override void Dispose() @@ -67,7 +78,7 @@ index f250b1b..5518d04 100644 { GL.BufferData((BufferTarget)35345, base.Size, (IntPtr)gCHandleProvider.Pointer, (BufferUsageHint)35048); } -@@ -44,20 +75,43 @@ public class UBO : UBORef +@@ -44,20 +85,59 @@ public class UBO : UBORef { if (Unsafe.SizeOf() != base.Size) { @@ -92,6 +103,22 @@ index f250b1b..5518d04 100644 public override void Update(object data, int offset, int size) { ++ // Optimum TAA (P3): the skinned-entity motion writer needs the same ++ // entity's previous pose, and this upload is the one gate every entity ++ // draw passes through - the animator's bone matrices reaching "Animation" ++ // immediately before the draw. Routing it here covers the shared entity ++ // pass, the first-person hands (their own program) and the echo chamber ++ // without each renderer keeping its own history. Costs one string compare ++ // per UBO upload when TAA is off, and nothing at all for a program that ++ // has no AnimationPrev block. ++ if (BlockName == "Animation") ++ { ++ ShaderProgramBase optimumProgram = ShaderProgramBase.CurrentShaderProgram; ++ if (optimumProgram != null && optimumProgram.ubos.TryGetValue("AnimationPrev", out var optimumPrevBones)) ++ { ++ OptimumEntityMotion.OnAnimationUpload(optimumProgram, optimumPrevBones, data, size); ++ } ++ } + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) + { diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index 5b02490c..c03c9ead 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -32,6 +32,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/HudEntityNameTags.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ParticlePoolQuads.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/RenderAPIGame.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramEntityanimated.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs index 4b0fc1c8..d53d49a2 100644 --- a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using Vintagestory.API.MathTools; #nullable disable @@ -121,6 +122,16 @@ public interface IOptimumTemporalContext /// Whether the view was actually set up this frame (the hand view is absent in third person). bool IsViewCaptured(EnumTemporalView view); + /// + /// The view the projection matrix currently loaded belongs to: whatever + /// Set3DProjection last recorded. A draw issued now is under this + /// FOV, so its previous position has to go through the same view's + /// previous projection - which is how the first-person hands, drawn under + /// their own FOV between two Set3DProjection calls, are told apart + /// from the world without every call site having to opt in. + /// + EnumTemporalView ActiveView { get; } + float[] CameraMatrix { get; } float[] PrevCameraMatrix { get; } float[] CameraMatrixOrigin { get; } @@ -239,6 +250,9 @@ public bool JitterActive public bool Reset { get; private set; } public EnumTemporalResetReason ResetReason { get; private set; } + /// See . + public EnumTemporalView ActiveView { get; private set; } + public float ZNear { get; private set; } public float ZFar { get; private set; } public float Fov { get; private set; } @@ -291,6 +305,7 @@ public void Advance( viewCapturedPrev[i] = viewCaptured[i]; viewCaptured[i] = false; } + ActiveView = EnumTemporalView.World; Array.Copy(cameraMatrix, cameraMatrixPrev, 16); Array.Copy(cameraMatrixOrigin, cameraMatrixOriginPrev, 16); PrevPlayerpos.Set(Playerpos.X, Playerpos.Y, Playerpos.Z); @@ -396,6 +411,7 @@ public void RecordProjection(EnumTemporalView view, double[] matrix) float[] dest = projection[(int)view]; for (int i = 0; i < 16; i++) dest[i] = (float)matrix[i]; viewCaptured[(int)view] = true; + ActiveView = view; } /// @@ -468,6 +484,226 @@ public void ApplyMotionUniforms(IShaderProgram program) } } + /// + /// The switch that lets a motion-writing pass into Primary's motion + /// attachment, reachable from code that cannot see ClientPlatformWindows. + /// + /// The draw-buffer window itself lives in the platform layer (BeginMotionWrite / + /// EndMotionWrite); the mod-side renderers that draw entity geometry of their + /// own - the first-person hands and the echo chamber - are in assemblies that + /// only see the API, so the platform installs its two delegates here once and + /// they call through. A null hook (no platform, TAA off, headless tests) makes + /// Begin report false, which is exactly what a caller does when the window + /// could not be opened. + /// + public static class OptimumMotionWrite + { + /// Installed by ClientPlatformWindows. Render thread only. + public static Func BeginHook; + + /// Installed by ClientPlatformWindows. Render thread only. + public static Action EndHook; + + public static bool Begin() + { + Func hook = BeginHook; + return hook != null && hook(); + } + + public static void End() + { + Action hook = EndHook; + if (hook != null) hook(); + } + } + + /// + /// Per-entity previous transforms for the skinned-entity motion writer (TAA P3). + /// + /// Every draw that feeds entityanimated goes through one narrow gate: it sets + /// modelMatrix and then uploads the animator's bone matrices into the + /// "Animation" uniform block, immediately before the draw. The lib routes that + /// upload here, which is why the first-person hands (their own program and FOV), + /// the echo chamber (the shared program, three meshes, one pose) and any mod + /// entity renderer all get motion vectors without each one growing its own + /// history bookkeeping. + /// + /// History is keyed on the animator's own Matrices array, so identity is + /// the thing that actually decides whether last frame's pose belongs to this + /// entity: a respawned entity, a re-tesselated shape or a changed animator hands + /// over a different array and gets no history, which is precisely when it must + /// not have one. Entries die with the animator - the table holds no strong + /// reference to it. + /// + /// Render thread only. + /// + public static class OptimumEntityMotion + { + /// + /// Whether the motion writers are compiled into the shaders at all. Set by + /// ShaderRegistry from the same value it stamps TAAMOTION with, so the + /// per-uniform hooks below cost one static bool read when TAA is off. + /// + public static bool Enabled; + + private sealed class History + { + public float[] PrevBones = new float[0]; + public float[] CurBones = new float[0]; + public int PrevFloatCount = -1; + public int CurFloatCount = -1; + + public readonly float[] PrevModelMatrix = new float[16]; + public readonly float[] CurModelMatrix = new float[16]; + + public float PrevWindWaveIntensity = 1f; + public float CurWindWaveIntensity = 1f; + public float PrevWaterWaveCounter; + public float CurWaterWaveCounter; + + public EnumTemporalView PrevView; + public EnumTemporalView CurView; + + /// The frame CurBones et al. were captured in; -1 = never. + public long CapturedFrame = -1; + /// The frame PrevBones et al. were captured in; -1 = never. + public long PreviousFrame = -1; + } + + private static readonly ConditionalWeakTable histories = new ConditionalWeakTable(); + + private static readonly float[] modelMatrixScratch = new float[16]; + private static float windWaveIntensityScratch = 1f; + private static float waterWaveCounterScratch; + + private static IShaderProgram sharedUniformProgram; + private static long sharedUniformFrame = -1; + private static EnumTemporalView sharedUniformView; + + /// + /// Remembers the model matrix a draw just set, so the upload that follows can + /// store it as next frame's previous one. Called from ShaderProgramBase for + /// the uniform named "modelMatrix" only. + /// + public static void NoteModelMatrix(float[] matrix) + { + if (matrix == null || matrix.Length < 16) return; + Array.Copy(matrix, modelMatrixScratch, 16); + } + + /// + /// Remembers a warp uniform that varies per draw rather than per frame. + /// EntityShapeRenderer overrides windWaveIntensity per entity and the echo + /// chamber pins both to zero, so the previous frame's values for these two + /// have to be stored per entity - the global PrevWarp would replay a warp the + /// entity never had. + /// + public static void NoteWarpUniform(string uniformName, float value) + { + if (uniformName == "windWaveIntensity") windWaveIntensityScratch = value; + else if (uniformName == "waterWaveCounter") waterWaveCounterScratch = value; + } + + /// + /// Called by the lib just before an entity's bone matrices reach the GPU. + /// Uploads the same entity's previous pose into + /// and sets the writer's per-draw uniforms, then records this draw's state + /// as next frame's previous one. + /// + /// The program in use; must be an entityanimated motion writer. + /// Its "AnimationPrev" uniform block. + /// The array being uploaded into "Animation". + /// How many bytes of it the draw uses. + public static void OnAnimationUpload(IShaderProgram program, UBORef previousBones, object boneMatrices, int byteCount) + { + if (!Enabled || program == null || previousBones == null || previousBones.Disposed) return; + if (!program.HasUniform("taaHistoryValid")) return; + + float[] bones = boneMatrices as float[]; + if (bones == null || byteCount <= 0) return; + + int floats = byteCount / 4; + if (floats <= 0 || floats > bones.Length) return; + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + EnumTemporalView view = frame.ActiveView; + History history = histories.GetValue(bones, _ => new History()); + + // One roll per frame, not per draw: an entity drawn twice in a frame + // (opaque then after-OIT) must both times compare against the frame + // before, not against its own first draw. + if (history.CapturedFrame != frame.FrameIndex) + { + float[] swap = history.PrevBones; + history.PrevBones = history.CurBones; + history.CurBones = swap; + history.PrevFloatCount = history.CurFloatCount; + Array.Copy(history.CurModelMatrix, history.PrevModelMatrix, 16); + history.PrevWindWaveIntensity = history.CurWindWaveIntensity; + history.PrevWaterWaveCounter = history.CurWaterWaveCounter; + history.PrevView = history.CurView; + history.PreviousFrame = history.CapturedFrame; + history.CapturedFrame = frame.FrameIndex; + } + + if (history.CurBones.Length < floats) history.CurBones = new float[floats]; + Array.Copy(bones, history.CurBones, floats); + history.CurFloatCount = floats; + Array.Copy(modelMatrixScratch, history.CurModelMatrix, 16); + history.CurWindWaveIntensity = windWaveIntensityScratch; + history.CurWaterWaveCounter = waterWaveCounterScratch; + history.CurView = view; + + // Valid only if the very same entity was drawn last frame, under the same + // view (a first/third person switch changes both the FOV and the mesh), + // with the same joint count, and the frame itself did not reset. + bool valid = + !frame.Reset && + history.PreviousFrame == frame.FrameIndex - 1 && + history.PrevFloatCount == floats && + history.PrevView == view && + frame.WasViewCaptured(view); + + previousBones.Update(valid ? history.PrevBones : history.CurBones, 0, byteCount); + + // Per-frame, per-program half: the previous camera and the previous warp + // state are the same for every entity in the pass. + if (!ReferenceEquals(sharedUniformProgram, program) || + sharedUniformFrame != frame.FrameIndex || + sharedUniformView != view) + { + sharedUniformProgram = program; + sharedUniformFrame = frame.FrameIndex; + sharedUniformView = view; + if (program.HasUniform("prevProjectionMatrix")) + { + program.UniformMatrix("prevProjectionMatrix", frame.GetPrevProjection(view)); + } + if (program.HasUniform("prevViewMatrix")) + { + program.UniformMatrix("prevViewMatrix", frame.PrevCameraMatrixOrigin); + } + frame.ApplyMotionUniforms(program); + } + + // Per-draw half. + if (program.HasUniform("prevModelMatrix")) + { + program.UniformMatrix("prevModelMatrix", valid ? history.PrevModelMatrix : history.CurModelMatrix); + } + program.Uniform("taaHistoryValid", valid ? 1 : 0); + if (program.HasUniform("taaReactive")) program.Uniform("taaReactive", valid ? 0f : 1f); + if (program.HasUniform("prevWindWaveIntensity")) + { + program.Uniform("prevWindWaveIntensity", valid ? history.PrevWindWaveIntensity : history.CurWindWaveIntensity); + } + if (program.HasUniform("prevWaterWaveCounter")) + { + program.Uniform("prevWaterWaveCounter", valid ? history.PrevWaterWaveCounter : history.CurWaterWaveCounter); + } + } + } + /// /// The process-wide holder of the temporal frame contract. Static because the /// producers are scattered across the render loop, the platform layer and the diff --git a/sources/shaders/entityanimated.fsh b/sources/shaders/entityanimated.fsh new file mode 100644 index 00000000..c2229778 --- /dev/null +++ b/sources/shaders/entityanimated.fsh @@ -0,0 +1,184 @@ +#version 330 core +// Optimum override of the vanilla entityanimated.fsh: adds the TAA motion-vector +// output (P3). Everything else is vanilla, line for line. +// +// Only the opaque (USEOIT == 0) variant writes motion: the OIT variant already +// fills six outputs on the Transparent framebuffer and never touches Primary. +// The varying itself is declared for both so the vertex and fragment interfaces +// match whichever way the program was compiled. +#extension GL_ARB_explicit_attrib_location: enable + +in vec2 uv; +in vec4 color; +in vec4 rgbaFog; +in float fogAmount; +in float glowLevel; +in vec3 vertexPosition; +flat in int renderFlags; +in vec3 normal; +in vec4 worldPos; +in vec3 blockLight; +in vec4 camPos; +in float damageEffect; +in float fragFrostAlpha; + +// Our include system is dumb and does not do conditional includes +// So we add a OIT preprocceor test to oit.fsh as well +#include oit.fsh + +#if USEOIT==0 + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + #if SSAOLEVEL > 0 + in vec4 fragPosition; + in vec4 gnormal; + layout(location = 2) out vec4 outGNormal; + layout(location = 3) out vec4 outGPosition; + #endif +#endif + +// TAA motion vectors (Optimum P3); see chunkopaque.fsh for the contract. +// The alpha channel is this fragment's WINDOW depth, which for the first-person +// hand and item programs is gl_FragCoord.z + depthOffset, not gl_FragCoord.z - +// they write gl_FragDepth below, and the resolve compares what it finds here +// against the depth buffer. Writing the un-offset value would make the resolve +// reject every hand pixel and fall back to camera reprojection on the one class +// of geometry whose motion differs most from the camera's. +#if TAAMOTION > 0 +in vec4 taaPrevClip; +#if USEOIT==0 +uniform vec2 taaRenderSize; +uniform vec2 taaJitterPx; +uniform float taaReactive = 0.0; +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; + +vec4 taaMotionVector(float reactive, float writerDepth) +{ + if (taaPrevClip.w <= 1e-6) return vec4(0.0); + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + return vec4(prevPixel - currentPixel, reactive, writerDepth); +} +#endif +#endif + +uniform sampler2D entityTex; +uniform float alphaTest = 0.001; +uniform float glitchEffectStrength; +uniform int entityId; +uniform int glitchFlicker; +#if defined(ALLOWDEPTHOFFSET) +#if ALLOWDEPTHOFFSET > 0 +uniform float depthOffset; +#endif +#endif + +#include vertexflagbits.ash +#include fogandlight.fsh +#include noise3d.ash +#include noise2d.ash +#include underwatereffects.fsh + +void main() { + float b = 1; + + if (damageEffect > 0) { + float f = cnoise2(floor(vec2(uv.x, uv.y) * 4096) / 4); + if (f < damageEffect - 1.3) discard; + b = min(1, f * 1.5 + 0.65 + (1-damageEffect)); + } + + vec4 texColor = texture(entityTex, uv); + + #if SHADOWQUALITY > 0 + float intensity = 0.34 + (1 - shadowIntensity)/8.0; // this was 0.45, which makes shadow acne visible on blocks + #else + float intensity = 0.45; + #endif + + + //float seed = mod(entityId, 1000) / 5.0; - this is broken on NVIDIA cards O_O + int eidfloor = (entityId / 100) * 100; + float seed = (entityId - eidfloor) / 5.0; + + texColor = applyFrostEffect(fragFrostAlpha, texColor, normal, vertexPosition + vec3(seed)); + if (psychedelicStrength > Epsilon) texColor = applyPsychedelicEffect(texColor, vertexPosition, 0); + if (glitchStrength > Epsilon) texColor = applyRustEffect(texColor, normal, vertexPosition + vec3(seed), 0); + + texColor *= color; + texColor.rgb *= b; + +#if USEOIT>0 + vec4 outColor; +#endif + + float murkiness=getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadowWithNormal(texColor, 0, normal, 1, intensity, worldPos.xyz); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadowWithNormal(texColor, fogAmount, normal, 1, intensity, worldPos.xyz); + } + + + if (glitchFlicker >0 && glitchEffectStrength > 0) { + float g = gnoise(vec3(gl_FragCoord.y / 2.0, gl_FragCoord.x / 2.0, windWaveCounter*30 + entityId * 3)); + outColor.a *= mix(1, clamp(0.7 + g / 2, 0, 1), glitchEffectStrength); + + float b = gnoise(vec3(0, 0, windWaveCounter*60 + entityId * 3)); + outColor.a *= mix(1, clamp(b * 10 + 2, 0, 1), glitchEffectStrength); + } + +#if NORMALVIEW == 0 + if (outColor.a < alphaTest) discard; +#endif + + + + float glow = 0; +#if SHINYEFFECT > 0 + outColor = mix(applyReflectiveEffect(outColor, glow, renderFlags, uv, normal, worldPos, camPos, vec3(1)), outColor, min(1, 2 * fogAmount)); +#endif + +#if USEOIT==0 && SSAOLEVEL > 0 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = vec4(gnormal.xyz, 0); +#endif + +#if NORMALVIEW > 0 + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); +#endif + + + +#if USEOIT > 0 + OIT(outColor, glowLevel+glow); +#else + outGlow = vec4(glowLevel + glow, 0, 0, color.a); +#endif + + + +#if defined(ALLOWDEPTHOFFSET) && ALLOWDEPTHOFFSET > 0 + // This likely tanks performance in any other scenario so we do only only for the first person mode rendering. See also https://www.khronos.org/opengl/wiki/Early_Fragment_Test#Limitations + gl_FragDepth = gl_FragCoord.z + depthOffset; + + // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it + #if USEOIT==0 && SSAOLEVEL > 0 + outGPosition.w=1; + #endif + +#endif + + +#if TAAMOTION > 0 && USEOIT==0 + // Opaque skinned entities are not reactive on their own; the C# side raises + // taaReactive to 1 for a draw whose per-entity history was unusable, where + // the vector above is camera-only and the history must not be trusted. + #if defined(ALLOWDEPTHOFFSET) && ALLOWDEPTHOFFSET > 0 + outMotion = taaMotionVector(taaReactive, clamp(gl_FragCoord.z + depthOffset, 0.0, 1.0)); + #else + outMotion = taaMotionVector(taaReactive, gl_FragCoord.z); + #endif +#endif +} \ No newline at end of file diff --git a/sources/shaders/entityanimated.vsh b/sources/shaders/entityanimated.vsh new file mode 100644 index 00000000..69bf9f7b --- /dev/null +++ b/sources/shaders/entityanimated.vsh @@ -0,0 +1,160 @@ +#version 330 core +// Optimum override of the vanilla entityanimated.vsh: adds the TAA motion-vector +// writer for skinned entities (P3). Everything else is vanilla, line for line. +// +// The previous position is the same skinning run twice: previous model matrix x +// previous bone matrix from the AnimationPrev block, then the SAME warp branch +// with the previous frame's WarpState, then the previous unjittered projection +// for whichever view this draw belongs to (world FOV, or the hand FOV for the +// first-person hands). Without usable history - the entity spawned, its animator +// or mesh changed, it was off-screen last frame, or the camera switched between +// first and third person - the vertex falls back to camera-only motion and C# +// raises taaReactive so the resolve leans on this frame instead. +#extension GL_ARB_explicit_attrib_location: enable + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in int flags; +layout(location = 4) in float damageEffectIn; +layout(location = 5) in int jointId; + +uniform vec3 rgbaAmbientIn; +uniform vec4 rgbaLightIn; +uniform vec4 rgbaFogIn; +uniform float fogMinIn; +uniform float fogDensityIn; +uniform vec4 renderColor; +uniform int addRenderFlags; +uniform float frostAlpha = 0; +uniform mat4 projectionMatrix; +uniform mat4 viewMatrix; +uniform mat4 modelMatrix; +uniform int extraGlow; + +// No longer needed but kept to not break mods that still assign these values +uniform int skipRenderJointId; +uniform int skipRenderJointId2; + +// UBO:Animation,0,4800 +layout (std140) uniform Animation +{ + mat4 values[MAXANIMATEDELEMENTS]; // MAXANIMATEDELEMENTS constant is defined during game engine shader loading. +} ElementTransforms; + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED projection for this draw's view +uniform mat4 prevViewMatrix; // previous frame's CameraMatrixOrigin (the view entities draw under) +uniform mat4 prevModelMatrix; // this renderer's model matrix as it was last frame +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +uniform int taaHistoryValid = 0; // 0 = no usable per-entity history, use camera-only motion + +// UBO:AnimationPrev,1,4800 +layout (std140) uniform AnimationPrev +{ + mat4 values[MAXANIMATEDELEMENTS]; +} PrevElementTransforms; + +out vec4 taaPrevClip; +#endif + +out vec2 uv; +out vec4 color; +out vec4 rgbaFog; +out float fogAmount; +out vec3 vertexPosition; +out vec4 worldPos; +out float damageEffect; +out vec4 camPos; +out float fragFrostAlpha; +flat out int renderFlags; + +out vec4 glPos; + +out vec3 normal; +#if SSAOLEVEL > 0 +out vec4 fragPosition; +out vec4 gnormal; +#endif + + +#include vertexflagbits.ash +#include shadowcoords.vsh +#include fogandlight.vsh +#include vertexwarp.vsh + +void main(void) +{ + damageEffect = damageEffectIn; + mat4 animModelMat = modelMatrix * ElementTransforms.values[jointId]; + worldPos = animModelMat * vec4(vertexPositionIn, 1.0); + + renderFlags = flags | addRenderFlags; + + if ((renderFlags & WindModeFruitMask) > 0) { + fragFrostAlpha = frostAlpha / 4; + renderFlags &= ~WindModeFruitMask; + } else fragFrostAlpha = frostAlpha; + + if ((renderFlags & WindModeWaterMask) > 0) { + worldPos = applyLiquidWarping(true, worldPos, 5); + } else { + worldPos = applyVertexWarping(renderFlags, worldPos); + } + worldPos = applyGlobalWarping(worldPos); + + +#if TAAMOTION > 0 + // Placed here, before the local `int renderFlags = extraGlow + flags;` below + // shadows the flat output: the warp branch has to see the same flags the + // current position was warped with, fruit-mask clearing included. + { + vec4 taaPrevWorld; + if (taaHistoryValid != 0) { + mat4 taaPrevAnimMat = prevModelMatrix * PrevElementTransforms.values[jointId]; + taaPrevWorld = taaPrevAnimMat * vec4(vertexPositionIn, 1.0); + WarpState taaPrev = previousWarpState(); + if ((renderFlags & WindModeWaterMask) > 0) { + taaPrevWorld = applyLiquidWarpingState(taaPrev, true, taaPrevWorld, 5); + } else { + taaPrevWorld = applyVertexWarpingState(taaPrev, renderFlags, taaPrevWorld); + } + taaPrevWorld = applyGlobalWarpingState(taaPrev, taaPrevWorld); + } else { + // Treat the surface as static in the world: its camera-relative position + // a frame ago differed by the camera's own movement only (accuracy rule 4). + taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0); + } + taaPrevClip = prevProjectionMatrix * (prevViewMatrix * taaPrevWorld); + } +#endif + + vertexPosition = vertexPositionIn.xyz * 1.5; + + vec4 cameraPos = camPos = viewMatrix * worldPos; + + uv = uvIn; + int renderFlags = extraGlow + flags; + color = renderColor * colorIn * applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos); + rgbaFog = rgbaFogIn; + + // Distance fade out + color.a *= clamp(20 * (1.05 - length(worldPos.xz) / viewDistance) - 5, -1, 1); + + gl_Position = projectionMatrix * cameraPos; + calcShadowMapCoords(viewMatrix, worldPos); + + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + normal = unpackNormal(renderFlags); + normal = (animModelMat * vec4(normal.x, normal.y, normal.z, 0)).xyz; + + #if SSAOLEVEL > 0 + fragPosition = cameraPos; + gnormal = viewMatrix * vec4(normal, 0); + #endif +} \ No newline at end of file From afb5e1f7b890548e231e10da429efa1258f015a2 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 20:36:31 +0200 Subject: [PATCH 028/226] wip(taa): P3 standard shader - held/dropped items, quern top; verified by GPU readback standard.vsh/.fsh gain the TAA motion-vector writer: the previous position is the previous model matrix run through the caller's own dontWarpVertices branch with the previous frame's WarpState and the previous unjittered projection of the draw's view, with the same extraZOffset applied to both clip positions and the ALLOWDEPTHOFFSET writer depth for the first-person item program. OptimumStandardMotion (API fork) keeps the previous transform per drawn object, keyed on a stable identity - the attachment point pose for a held item, the renderer instance for a dropped item and the quern top - with the mesh as the shape, so a swapped stack, a re-tesselation, a missed frame, a view switch or a frame reset all void the history and raise taaReactive. Nothing is stored on the mod-fork types, so no new members have to reach the installed runtime. EntityShapeRenderer.RenderItem, EntityItemRenderer and QuernTopRenderer name themselves to the store and open a narrow BeginMotionWrite/EndMotionWrite window around their own draw only; the shadow pass never opens one. Verified: Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests reads the motion attachment back on the GPU and confirms a still object is (0,0) with writer depth 0.5, a previous model matrix of d gives exactly d*0.5*renderSize pixels on three displacements, no history gives camera motion only rather than the stale matrix, and a differing previous warp state produces its closed-form offset with dontWarpVertices 0 but nothing with dontWarpVertices 1 - i.e. the branch is replayed, not applied unconditionally. Plus the source-coverage tests, extract-patches/check-patches (0 conflicts, 0 pending), the Release build and both suites (824 and 274 passing). NOT verified in game on either backend. --- Optimum.Patcher/mod-patcher.cs | 8 + .../TaaStandardMotionWriterTests.cs | 684 ++++++++++++++++++ .../taa-standard-motion-coverage-tests.cs | 308 ++++++++ .../EntityItemRenderer.cs.patch | 43 ++ .../EntityShapeRenderer.cs.patch | 44 ++ .../QuernTopRenderer.cs.patch | 32 + .../Client/Render/OptimumTemporalFrame.cs | 144 ++++ sources/shaders/standard.fsh | 184 +++++ sources/shaders/standard.vsh | 163 +++++ 9 files changed, 1610 insertions(+) create mode 100644 Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs create mode 100644 Optimum.Tests/taa-standard-motion-coverage-tests.cs create mode 100644 patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch create mode 100644 patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs.patch create mode 100644 sources/shaders/standard.fsh create mode 100644 sources/shaders/standard.vsh diff --git a/Optimum.Patcher/mod-patcher.cs b/Optimum.Patcher/mod-patcher.cs index fc298884..9137de44 100644 --- a/Optimum.Patcher/mod-patcher.cs +++ b/Optimum.Patcher/mod-patcher.cs @@ -163,6 +163,11 @@ private static Manifest EssentialsManifest() // motion-writer changes. new("Vintagestory.GameContent.EntityPlayerShapeRenderer", "DoRender3DOpaque", 2), new("Vintagestory.GameContent.ModSystemFpHands", "LoadShaders", 0), + // TAA P3: the standard-shader motion writer. Held items (both hands, + // and the first-person item program) get their previous transform in + // RenderItem; dropped items in EntityItemRenderer.DoRender3DOpaque + // above. Both also open the motion-attachment window around their draw. + new("Vintagestory.GameContent.EntityShapeRenderer", "RenderItem", 5), new("Vintagestory.GameContent.WeatherSimulationParticles", "asyncParticleSpawn", 2), new("Vintagestory.GameContent.WeatherSystemClient", "OnRenderFrame", 2), new("Vintagestory.GameContent.WeatherSimulationSound", "updateSounds", 1), @@ -254,6 +259,9 @@ private static Manifest SurvivalManifest() // entityanimated program from DoRender3DOpaque, so it opens the // motion-attachment window itself. new("Vintagestory.GameContent.EchoChamberRenderer", "DoRender3DOpaque", 2), + // TAA P3: the quern top is the block-entity model that actually + // moves, so it keeps a previous model matrix and writes motion. + new("Vintagestory.GameContent.QuernTopRenderer", "OnRenderFrame", 2), ]); } diff --git a/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs new file mode 100644 index 00000000..59ce303d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs @@ -0,0 +1,684 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The standard-shader motion-vector writer (TAA P3), driven through the seam with +/// the real standard program and read back as pixels. This is the writer every +/// held item, first-person item, dropped item and block-entity model goes through. +/// +/// What it has to get right, and what a "motion is non-zero when the item moved" +/// assertion could not tell apart from a sign flip or an axis swap: +/// - a previous model matrix turns into exactly that displacement in render pixels; +/// - a still object under a still camera is exactly (0, 0), because a converged +/// item that wobbles is what a wrong previous transform looks like on screen; +/// - without usable history the vector is camera motion only, never the stale +/// previous model matrix; +/// - the previous warp state is replayed through the caller's own +/// dontWarpVertices branch, not unconditionally: a draw that asks for no warp +/// at all must produce no warp motion even when the previous warp state differs. +/// +/// As in TaaEntityMotionWriterTests the RGBA16F attachment comes back through an +/// RGBA8 decode pass, because the seam's readback is fixed at four bytes per pixel +/// from colour attachment 0. Decode quantisation is 2*DecodeScale/255 px, so the +/// tolerances stay above it. +/// +public class TaaStandardMotionWriterTests +{ + private readonly ITestOutputHelper _output; + + public TaaStandardMotionWriterTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// Normal pointing up, no glow, and no wind-mode bits, so no vertex warp runs. + private const int UpNormalFlags = 7 << 18; + + /// standard.vsh: 0 = full warp, 2 = the held item's quarter warp, anything else = none. + private const int WarpFull = 0; + private const int WarpNone = 1; + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + private static float[] Translation(float x, float y, float z) => new[] + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 1f, 0f, + x, y, z, 1f, + }; + + // ------------------------------------------------------------------ tests + + /// + /// An object that did not move, under a camera that did not move, is zero + /// motion - and the writer still stamps its own depth, so the resolve accepts + /// the pixel instead of silently falling back to camera reprojection. + /// + [SkippableFact] + public void AnUnmovedItemWritesZeroMotionAndTheFragmentDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderStandardMotion(device!, + previousModelMatrix: Identity, + historyValid: 1, + dontWarpVertices: WarpFull, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f); + + _output.WriteLine($"still: mv = ({centre.MotionX}, {centre.MotionY}), writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + // Identity matrices put the quad at NDC z = 0, which is window depth + // 0.5 on both backends. + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The model matrix this item was drawn with last frame is its motion: with + /// identity camera matrices a translation of d moves the pixel by exactly + /// d * 0.5 * renderSize, and the sign is "where the pixel was", not "where it + /// went". + /// + [SkippableTheory] + [InlineData(0.25f, 0f)] + [InlineData(0f, -0.125f)] + [InlineData(-0.1875f, 0.0625f)] + public void APreviousModelMatrixShowsUpAsTheExactPixelDisplacement(float modelX, float modelY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderStandardMotion(device!, + previousModelMatrix: Translation(modelX, modelY, 0f), + historyValid: 1, + dontWarpVertices: WarpFull, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f); + + float expectedX = modelX * 0.5f * Size; + float expectedY = modelY * 0.5f * Size; + + _output.WriteLine($"model ({modelX}, {modelY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// Without usable history - the item appeared, the held stack was swapped, the + /// object was not drawn last frame, the camera switched between first and + /// third person - the writer must not read the previous model matrix at all. + /// It falls back to treating the surface as static in the world, so only the + /// camera's own movement displaces it; C# raises taaReactive for the same draw + /// so the resolve leans on this frame. + /// + /// The previous model matrix here is deliberately a large translation: if the + /// shader took the history branch anyway, the vector would be that instead. + /// + [SkippableFact] + public void WithoutUsableHistoryTheVectorIsCameraMotionOnly() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float cameraDeltaX = 0.25f; + const float cameraDeltaY = -0.125f; + Decoded centre = RenderStandardMotion(device!, + previousModelMatrix: Translation(-0.5f, 0.5f, 0f), + historyValid: 0, + dontWarpVertices: WarpFull, + cameraDeltaX: cameraDeltaX, + cameraDeltaY: cameraDeltaY, + previousGlobalWarp: 0f); + + float expectedX = cameraDeltaX * 0.5f * Size; + float expectedY = cameraDeltaY * 0.5f * Size; + + _output.WriteLine($"no history: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + } + } + + /// + /// Vertex animation that differed last frame is motion too. The values are + /// chosen so applyGlobalWarping's phase argument saturates at zero over the + /// whole quad, which turns the warp into a constant offset with a closed-form + /// expectation instead of a "not zero" assertion. + /// + /// The second half is the branch test: the very same previous warp state with + /// dontWarpVertices set to "no warp" must produce no warp motion. A writer + /// that applied the warp unconditionally - the easy mistake, since the current + /// position's branch is three lines further up - would pass the first half and + /// fail this one, and would give every unwarped block-entity model a motion + /// vector it never had. + /// + [SkippableFact] + public void ThePreviousWarpStateIsReplayedThroughTheCallersOwnBranch() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float previousWarp = 8f; + + Decoded warped = RenderStandardMotion(device!, + previousModelMatrix: Identity, + historyValid: 1, + dontWarpVertices: WarpFull, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: previousWarp); + + double offsetX = (Math.Sin(0.0) + Math.Sin(0.5) + Math.Sin(1.0) / 3.0) / 30.0 * previousWarp; + float expectedX = (float)(offsetX * 0.5 * Size); + + _output.WriteLine($"warped: mv = ({warped.MotionX}, {warped.MotionY}), expected ({expectedX}, 0)"); + + Assert.True(Math.Abs(expectedX) > 1f, "the warp displacement chosen is too small to test"); + Assert.InRange(warped.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(warped.MotionY, -0.3f, 0.3f); + + Decoded unwarped = RenderStandardMotion(device!, + previousModelMatrix: Identity, + historyValid: 1, + dontWarpVertices: WarpNone, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: previousWarp); + + _output.WriteLine($"unwarped: mv = ({unwarped.MotionX}, {unwarped.MotionY}), expected (0, 0)"); + + Assert.InRange(unwarped.MotionX, -0.3f, 0.3f); + Assert.InRange(unwarped.MotionY, -0.3f, 0.3f); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float WriterDepth { get; } + } + + /// + /// Draws one quad with the real standard program compiled as a motion writer, + /// then decodes the motion attachment and returns its centre pixel. The + /// current model matrix is always the identity and this frame's warp state is + /// pinned to a no-op, so every expectation is stated entirely in terms of the + /// previous-frame inputs. + /// + private unsafe Decoded RenderStandardMotion( + VulkanDevice device, + float[] previousModelMatrix, + int historyValid, + int dontWarpVertices, + float cameraDeltaX, + float cameraDeltaY, + float previousGlobalWarp) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = new ShaderCorpus.ShaderVariant + { + Name = "taa-standard", + TaaMotion = 1, + TaaMotionLocation = 2, + }; + + List stages = ShaderCorpus.BuildProgram("standard", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "standard"); + + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + "standard declares no taaRenderSize, so it is not a motion writer"); + Assert.True(seam.GetUniformLocation(program, "taaHistoryValid") >= 0, + "standard declares no taaHistoryValid, so it cannot reject stale history"); + Assert.True(seam.GetUniformLocation(program, "prevModelMatrix") >= 0, + "standard declares no prevModelMatrix, so it has no previous transform to use"); + + BindEveryDeclaredSampler(device, seam, program); + + // Primary stand-in: colour, glow and the motion attachment at index 2. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(scene, 0b111); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + int mesh = seam.CreateMesh(BuildQuad(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.UseProgram(program); + SetMatrix(seam, program, "projectionMatrix", Identity); + SetMatrix(seam, program, "viewMatrix", Identity); + SetMatrix(seam, program, "modelMatrix", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixFar", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); + SetSceneUniforms(seam, program, dontWarpVertices); + SetWarpUniforms(seam, program, previousGlobalWarp); + + SetMatrix(seam, program, "prevProjectionMatrix", Identity); + SetMatrix(seam, program, "prevViewMatrix", Identity); + SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); + SetInt(seam, program, "taaHistoryValid", historyValid); + SetFloat(seam, program, "taaReactive", historyValid != 0 ? 0f : 1f); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", 0f, 0f); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(mesh); + + byte[] decoded = DecodeMotion(seam, motion); + seam.Present(); + + int offset = ((Size / 2) * Size + Size / 2) * 4; + + AssertClean(seam); + + return new Decoded( + (decoded[offset] / 255f * 2f - 1f) * DecodeScale, + (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, + decoded[offset + 2] / 255f); + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-standard-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-standard-decode.fsh" }, + }, "taa-standard-decode"); + + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int quadMesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// A quad in standard.vsh's attribute layout: xyz, uv, rgba, flags. Normals + /// are absent, which is what puts uv on location 1 the way the shader declares + /// it; GLOWSUB is not defined, so there is no fifth attribute. + /// + private static MeshData BuildQuad() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + Vintagestory.API.MathTools.ColorUtil.WhiteArgb, + flags: UpNormalFlags); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + return mesh; + } + + /// + /// Enough of the lighting, fog and overlay surface to keep the fragment alive: + /// a fully transparent fragment is discarded before it can write a motion + /// vector, and the test would read the cleared attachment instead. alphaTest + /// is pushed below zero so nothing can discard at all. + /// + private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program, int dontWarpVertices) + { + SetInt(seam, program, "dontWarpVertices", dontWarpVertices); + SetInt(seam, program, "fadeFromSpheresFog", 0); + SetInt(seam, program, "addRenderFlags", 0); + SetInt(seam, program, "extraGlow", 0); + SetFloat(seam, program, "extraZOffset", 0f); + + SetFloat(seam, program, "alphaTest", -1f); + SetFloat(seam, program, "viewDistance", 1024f); + SetFloat(seam, program, "viewDistanceLod0", 1024f); + SetFloat(seam, program, "zNear", 0.1f); + SetFloat(seam, program, "zFar", 1024f); + SetFloat(seam, program, "fogMinIn", 0f); + SetFloat(seam, program, "fogDensityIn", 0f); + SetFloat(seam, program, "shadowRangeFar", 1024f); + SetFloat(seam, program, "shadowRangeNear", 64f); + SetFloat(seam, program, "shadowMapWidthInv", 1f); + SetFloat(seam, program, "shadowMapHeightInv", 1f); + SetFloat(seam, program, "shadowIntensity", 0f); + SetFloat(seam, program, "damageEffect", 0f); + SetFloat(seam, program, "overlayOpacity", 0f); + SetFloat(seam, program, "extraGodray", 0f); + SetFloat(seam, program, "ssaoAttn", 0f); + SetInt(seam, program, "applySsao", 0); + SetInt(seam, program, "tempGlowMode", 0); + SetInt(seam, program, "normalShaded", 0); + SetInt(seam, program, "skyShaded", 0); + SetFloat3(seam, program, "rgbaAmbientIn", 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaLightIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaFogIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaGlowIn", 0f, 0f, 0f, 0f); + SetFloat4(seam, program, "rgbaTint", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "averageColor", 1f, 1f, 1f, 1f); + SetFloat2(seam, program, "frameSize", Size, Size); + } + + /// + /// Both halves of the warp state, pinned so this frame's warp is a no-op and + /// only the previous one moves anything. + /// + private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, float previousGlobalWarp) + { + SetFloat(seam, program, "timeCounter", 0f); + SetFloat(seam, program, "windWaveCounter", 0f); + SetFloat(seam, program, "windWaveCounterHighFreq", 0f); + SetFloat(seam, program, "waterWaveCounter", 0f); + SetFloat(seam, program, "windSpeed", 0f); + SetFloat(seam, program, "globalWarpIntensity", 0f); + SetFloat(seam, program, "glitchWaviness", 0f); + SetFloat(seam, program, "windWaveIntensity", 1f); + SetFloat(seam, program, "waterWaveIntensity", 1f); + SetInt(seam, program, "perceptionEffectId", 1); + SetFloat(seam, program, "perceptionEffectIntensity", 0f); + SetFloat3(seam, program, "playerpos", 0f, 0f, 0f); + + SetFloat(seam, program, "prevTimeCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounterHighFreq", 0f); + SetFloat(seam, program, "prevWaterWaveCounter", 0f); + SetFloat(seam, program, "prevWindSpeed", 0f); + SetFloat(seam, program, "prevGlobalWarpIntensity", previousGlobalWarp); + SetFloat(seam, program, "prevGlitchWaviness", 0f); + SetFloat(seam, program, "prevWindWaveIntensity", 1f); + SetFloat(seam, program, "prevWaterWaveIntensity", 1f); + SetInt(seam, program, "prevPerceptionEffectId", 1); + SetFloat(seam, program, "prevPerceptionEffectIntensity", 0f); + SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetFloat4( + IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z, w); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + { + var white = new byte[] { 255, 255, 255, 255 }; + fixed (byte* pixels = white) + { + return seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + } + + private static int BindEveryDeclaredSampler( + VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + { + int unit = 0; + foreach (string samplerName in device.SamplerNamesOf(programId)) + { + int texture = CreateWhiteTexture(seam); + seam.SetSamplerUnit(programId, samplerName, unit); + seam.BindTexture(unit, texture); + unit++; + } + return unit; + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-standard-motion-coverage-tests.cs b/Optimum.Tests/taa-standard-motion-coverage-tests.cs new file mode 100644 index 00000000..b6dd3f4f --- /dev/null +++ b/Optimum.Tests/taa-standard-motion-coverage-tests.cs @@ -0,0 +1,308 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P3 standard-shader motion-vector writer: the +/// standard shader pair, the per-object previous-transform store that feeds it, +/// the renderers that name themselves to it, and the plumbing that has to ship +/// all of it (patcher entries, mod-patcher manifests, packaging). +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests) proves the numbers. +/// What they catch is the failure this project keeps hitting: a change that works +/// in the build tree and never reaches the installed runtime because a patcher +/// entry was missed. +/// +public class TaaStandardMotionCoverageTests +{ + // ------------------------------------------------------------- the shaders + + [Fact] + public void TheStandardVertexShaderReplaysTheCallersOwnWarpBranch() + { + string vertex = Read("sources/shaders/standard.vsh"); + + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("out vec4 taaPrevClip;", vertex); + + // The previous half of every input the current position uses. + foreach (string uniform in new[] + { + "prevProjectionMatrix", "prevViewMatrix", "prevModelMatrix", + "cameraPosDelta", "taaHistoryValid", + }) + { + Assert.True(DeclaresUniform(vertex, uniform), + uniform + " is not declared by standard.vsh"); + } + + Assert.Contains("taaPrevWorld = prevModelMatrix * vec4(vertexPositionIn, 1.0);", vertex); + Assert.Contains("WarpState taaPrev = previousWarpState();", vertex); + + // Both warp branches, replayed exactly as the current position takes them: + // a dropped item passes dontWarpVertices 0, a held item 2 (quarter warp), + // and a block-entity model can pass neither. + Assert.Contains("if (dontWarpVertices == 0) {", vertex); + Assert.Contains("if (dontWarpVertices == 2) {", vertex); + Assert.Contains("applyVertexWarpingState(taaPrev, flags | addRenderFlags, taaPrevWorld)", vertex); + Assert.Contains("applyGlobalWarpingState(taaPrev, taaPrevWorld)", vertex); + Assert.Contains("taaPrevWorld = mix(taaPrevWorld, taaNewPos, 0.25);", vertex); + + // No usable history: camera-only motion, the same rule the terrain and + // entity writers and the resolve's fallback use. + Assert.Contains("taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0);", vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevViewMatrix * taaPrevWorld);", vertex); + + // Accuracy rule 1: the z-fighting w-offset applies to both clip positions + // or the pair disagrees by it. + Assert.Contains("gl_Position.w += extraZOffset;", vertex); + Assert.Contains("taaPrevClip.w += extraZOffset;", vertex); + } + + [Fact] + public void TheStandardFragmentShaderWritesTheMotionAttachmentWithItsOwnDepth() + { + string fragment = Read("sources/shaders/standard.fsh"); + + Assert.Contains("#if TAAMOTION > 0", fragment); + Assert.Contains("in vec4 taaPrevClip;", fragment); + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + + // The contract taa-resolve.fsh consumes. + Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + Assert.Contains("return vec4(prevPixel - currentPixel, reactive, writerDepth);", fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + + // The first-person item program writes gl_FragDepth, so the writer depth + // has to carry the same offset or the resolve's depth-match test rejects + // every first-person item pixel. + Assert.Contains("gl_FragDepth = gl_FragCoord.z + depthOffset;", fragment); + Assert.Contains("outMotion = taaMotionVector(taaReactive, clamp(gl_FragCoord.z + depthOffset, 0.0, 1.0));", fragment); + Assert.Contains("outMotion = taaMotionVector(taaReactive, gl_FragCoord.z);", fragment); + + Assert.True(DeclaresUniform(fragment, "taaReactive")); + Assert.True(DeclaresUniform(fragment, "taaRenderSize")); + Assert.True(DeclaresUniform(fragment, "taaJitterPx")); + } + + // ---------------------------------------------------- the per-draw history + + [Fact] + public void TheFrameContractKeepsPerObjectHistoryForStandardShaderDraws() + { + string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + string vertex = Read("sources/shaders/standard.vsh"); + string fragment = Read("sources/shaders/standard.fsh"); + + Assert.Contains("public static class OptimumStandardMotion", frame); + Assert.Contains("ConditionalWeakTable", frame); + + string apply = BodyOf(frame, "public static bool Apply(IShaderProgram program, object identity, object shape, float[] modelMatrix)"); + + // Every uniform the hook sets must be declared by the shader that reads + // it, or the HasUniform guard silently drops it. + foreach (string uniform in new[] { "prevProjectionMatrix", "prevViewMatrix", "prevModelMatrix" }) + { + Assert.Contains("program.UniformMatrix(\"" + uniform + "\"", apply); + Assert.True(DeclaresUniform(vertex, uniform), uniform + " is set but declared by no shader"); + } + Assert.Contains("program.Uniform(\"taaHistoryValid\", valid ? 1 : 0);", apply); + Assert.True(DeclaresUniform(vertex, "taaHistoryValid")); + Assert.Contains("program.Uniform(\"taaReactive\", valid ? 0f : 1f);", apply); + Assert.True(DeclaresUniform(fragment, "taaReactive")); + + // The shared warp/jitter/render-size block comes from the one helper the + // terrain and entity writers use, not from a second copy of it. + Assert.Contains("frame.ApplyMotionUniforms(program);", apply); + + // The two warp uniforms a standard-shader draw overrides for itself: a + // swimming dropped item sets waterWaveCounter. Read through the recorder + // the entity writer already installs rather than a second hook. + Assert.Contains("OptimumEntityMotion.ScratchWindWaveIntensity", apply); + Assert.Contains("OptimumEntityMotion.ScratchWaterWaveCounter", apply); + Assert.Contains("program.Uniform(\"prevWindWaveIntensity\"", apply); + Assert.Contains("program.Uniform(\"prevWaterWaveCounter\"", apply); + + // History is only usable when the same object was drawn last frame, with + // the same mesh, under the same view, in a frame that did not reset. + Assert.Contains("!frame.Reset &&", apply); + Assert.Contains("history.PreviousFrame == frame.FrameIndex - 1 &&", apply); + Assert.Contains("ReferenceEquals(history.PrevShape, shape) &&", apply); + Assert.Contains("history.PrevView == view &&", apply); + Assert.Contains("frame.WasViewCaptured(view)", apply); + + // The roll happens once per frame, so something drawn twice in a frame + // still compares against the frame before rather than its own first draw. + Assert.Contains("if (history.CapturedFrame != frame.FrameIndex)", apply); + + // The hand FOV is a different view with a different previous projection. + Assert.Contains("EnumTemporalView view = frame.ActiveView;", apply); + Assert.Contains("frame.GetPrevProjection(view)", apply); + } + + // ------------------------------------------------ the instrumented callers + + /// + /// Each standard-shader user that draws in the Opaque stage has to name itself + /// to the store and open the draw-buffer window around its own draw. A narrow + /// window, because a standard-shader draw that is not instrumented must stay + /// outside it - inside it the attachment would keep another surface's vector. + /// + [Theory] + [InlineData("patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch", + "VSEssentials/EntityRenderer/EntityShapeRenderer.cs", + "OptimumStandardMotion.Apply(prog, apap, renderInfo.ModelRef, ItemModelMat.Values);")] + [InlineData("patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch", + "VSEssentials/EntityRenderer/EntityItemRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, renderInfo.ModelRef, ModelMat);")] + [InlineData("patches/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs.patch", + "VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, meshref, ModelMat.Values);")] + public void EveryInstrumentedStandardShaderUserStoresItsPreviousTransformAndOpensTheWindow( + string patch, string source, string apply) + { + string renderer = ReadPatchedOrSource(patch, source); + + Assert.Contains(apply, renderer); + Assert.Contains("OptimumMotionWrite.Begin();", renderer); + Assert.Contains("if (optimumMotionWrite) OptimumMotionWrite.End();", renderer); + } + + /// + /// The shadow pass runs the same code with a different program that has no + /// motion output at all, so it must never open the window. + /// + [Fact] + public void TheShadowPassNeverOpensTheMotionWindow() + { + foreach ((string patch, string source) in new[] + { + ("patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch", + "VSEssentials/EntityRenderer/EntityShapeRenderer.cs"), + ("patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch", + "VSEssentials/EntityRenderer/EntityItemRenderer.cs"), + }) + { + string renderer = ReadPatchedOrSource(patch, source); + Assert.Contains("bool optimumMotionWrite = !isShadowPass && OptimumMotionWrite.Begin();", renderer); + } + } + + // --------------------------------------------------------------- the ship + + [Fact] + public void ModPatcherManifestsCarryTheChangedStandardShaderRenderers() + { + string manifest = Read("Optimum.Patcher/mod-patcher.cs"); + + Assert.Contains("new(\"Vintagestory.GameContent.EntityShapeRenderer\", \"RenderItem\", 5)", manifest); + Assert.Contains("new(\"Vintagestory.GameContent.EntityItemRenderer\", \"DoRender3DOpaque\", 2)", manifest); + Assert.Contains("new(\"Vintagestory.GameContent.QuernTopRenderer\", \"OnRenderFrame\", 2)", manifest); + } + + /// + /// An external mod that ships its own standard shader would not have the + /// writer, so TAA has to switch itself off rather than reproject items by + /// whatever happens to be in the attachment. + /// + [Fact] + public void TheScannerDisablesTaaForAnExternalStandardShader() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + + Assert.Contains("HasExternalShader(report, \"standard.vsh\")", scanner); + Assert.Contains("HasExternalShader(report, \"standard.fsh\")", scanner); + Assert.Contains("AddFeatureDecision(report, \"Taa\"", scanner); + } + + /// + /// The overrides only reach a running client if `make deploy` and every + /// packager copy sources/shaders - they do already, directory-wide, so this + /// only guards against a regression that starts naming files. + /// + [Fact] + public void DeployAndEveryPackagerShipTheStandardShaderOverrides() + { + foreach (string path in new[] + { + "Makefile", "scripts/package-linux.sh", "scripts/package-macos.sh", "scripts/package-linux.ps1", + }) + { + string text = Read(path); + Assert.Contains("sources/shaders", text.Replace('\\', '/')); + Assert.DoesNotContain("standard.vsh", text); + } + } + + // ----------------------------------------------------------------- helpers + + private static bool DeclaresUniform(string shader, string name) + { + foreach (string line in shader.Replace("\r\n", "\n").Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("uniform ", StringComparison.Ordinal)) continue; + string declaration = trimmed.Substring("uniform ".Length); + int semicolon = declaration.IndexOf(';'); + if (semicolon < 0) continue; + declaration = declaration.Substring(0, semicolon); + int assign = declaration.IndexOf('='); + if (assign >= 0) declaration = declaration.Substring(0, assign); + int space = declaration.TrimEnd().LastIndexOf(' '); + if (space < 0) continue; + if (declaration.TrimEnd().Substring(space + 1) == name) return true; + } + return false; + } + + private static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch b/patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch new file mode 100644 index 00000000..3a777c14 --- /dev/null +++ b/patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch @@ -0,0 +1,43 @@ +diff --git a/VSEssentials/EntityRenderer/EntityItemRenderer.cs b/VSEssentials/EntityRenderer/EntityItemRenderer.cs +index 30357cd..4a4be3f 100644 +--- a/VSEssentials/EntityRenderer/EntityItemRenderer.cs ++++ b/VSEssentials/EntityRenderer/EntityItemRenderer.cs +@@ -202,10 +202,15 @@ namespace Vintagestory.GameContent + + prog.ProjectionMatrix = rapi.CurrentProjectionMatrix; + prog.ViewMatrix = rapi.CameraMatrixOriginf; + prog.ModelMatrix = ModelMat; + ++ // Optimum TAA (P3): this dropped item's previous transform. The renderer ++ // instance is the identity - one per entity, gone with it - and the item's ++ // mesh is the shape. A no-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, renderInfo.ModelRef, ModelMat); ++ + + ItemStack stack = entityitem.Itemstack; + AdvancedParticleProperties[] ParticleProperties = stack.Block?.ParticleProperties; + + if (stack.Block != null && !capi.IsGamePaused) +@@ -234,11 +239,21 @@ namespace Vintagestory.GameContent + if (!renderInfo.CullFaces) + { + rapi.GlDisableCullFace(); + } + +- rapi.RenderMultiTextureMesh(renderInfo.ModelRef, textureSampleName); ++ // Optimum TAA (P3): the standard shader writes motion vectors, so the ++ // motion attachment joins Primary's draw-buffer mask for this one draw. ++ bool optimumMotionWrite = !isShadowPass && OptimumMotionWrite.Begin(); ++ try ++ { ++ rapi.RenderMultiTextureMesh(renderInfo.ModelRef, textureSampleName); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + if (!renderInfo.CullFaces) + { + rapi.GlEnableCullFace(); + } diff --git a/patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch b/patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch new file mode 100644 index 00000000..b33338f3 --- /dev/null +++ b/patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch @@ -0,0 +1,44 @@ +diff --git a/VSEssentials/EntityRenderer/EntityShapeRenderer.cs b/VSEssentials/EntityRenderer/EntityShapeRenderer.cs +index 83b9fc0..8cb8da5 100644 +--- a/VSEssentials/EntityRenderer/EntityShapeRenderer.cs ++++ b/VSEssentials/EntityRenderer/EntityShapeRenderer.cs +@@ -522,19 +522,38 @@ namespace Vintagestory.GameContent + prog.Uniform("normalShaded", renderInfo.NormalShaded ? 1 : 0); + + prog.UniformMatrix("projectionMatrix", rapi.CurrentProjectionMatrix); + prog.UniformMatrix("viewMatrix", rapi.CameraMatrixOriginf); + prog.UniformMatrix("modelMatrix", ItemModelMat.Values); ++ ++ // Optimum TAA (P3): the held item's previous transform. The attachment ++ // point pose is the identity - one per hand, replaced whenever the ++ // animator is - and the item's mesh is the shape, so swapping the held ++ // stack voids the history instead of reprojecting the new item by the ++ // old one's motion. A no-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, apap, renderInfo.ModelRef, ItemModelMat.Values); + } + + + if (!renderInfo.CullFaces) + { + rapi.GlDisableCullFace(); + } + +- rapi.RenderMultiTextureMesh(renderInfo.ModelRef, samplername); ++ // Optimum TAA (P3): the standard shader writes motion vectors, so the ++ // motion attachment joins Primary's draw-buffer mask for this one draw. ++ // A narrow window, because the standard-shader users that are not ++ // instrumented must stay outside it. ++ bool optimumMotionWrite = !isShadowPass && OptimumMotionWrite.Begin(); ++ try ++ { ++ rapi.RenderMultiTextureMesh(renderInfo.ModelRef, samplername); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + if (!isShadowPass) prog.Uniform("tempGlowMode", 0); + if (!renderInfo.CullFaces) + { + rapi.GlEnableCullFace(); diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs.patch new file mode 100644 index 00000000..57d1ecd9 --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs.patch @@ -0,0 +1,32 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs +index 16752ee..4b40d3e 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs +@@ -63,11 +63,26 @@ namespace Vintagestory.GameContent + .Values + ; + + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; +- rpi.RenderMesh(meshref); ++ ++ // Optimum TAA (P3): the quern top is the one block-entity model that ++ // really moves - it spins - so its previous model matrix is worth ++ // keeping. The renderer instance is the identity, the uploaded mesh the ++ // shape. The motion attachment joins Primary's draw-buffer mask for this ++ // one draw. Both are no-ops when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, meshref, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMesh(meshref); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + prog.Stop(); + + + + if (ShouldRotateManual) diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs index d53d49a2..89e4b5f8 100644 --- a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -576,6 +576,19 @@ private sealed class History private static float windWaveIntensityScratch = 1f; private static float waterWaveCounterScratch; + /// + /// The two per-draw warp uniforms as the draw in progress last set them. + /// Shared with : the standard shader's + /// users override the very same two names (a swimming dropped item sets + /// waterWaveCounter, an entity sets windWaveIntensity), and both writers + /// read them through the one recorder in ShaderProgramBase rather than + /// growing a second hook. + /// + internal static float ScratchWindWaveIntensity => windWaveIntensityScratch; + + /// See . + internal static float ScratchWaterWaveCounter => waterWaveCounterScratch; + private static IShaderProgram sharedUniformProgram; private static long sharedUniformFrame = -1; private static EnumTemporalView sharedUniformView; @@ -704,6 +717,137 @@ public static void OnAnimationUpload(IShaderProgram program, UBORef previousBone } } + /// + /// Per-object previous transforms for the standard-shader motion writer (TAA P3). + /// + /// The standard shader has no bone upload to hang the history off, so its users + /// name themselves: a renderer calls after it has set this + /// draw's modelMatrix and before it draws, passing a stable identity + /// object and the mesh it is about to render. Held items key on the attachment + /// point pose (one per hand, replaced when the animator changes), dropped items + /// and block-entity renderers on the renderer instance itself, which is exactly + /// as long-lived as the thing it draws. Nothing is stored on the mod-fork types, + /// so no new fields have to be transplanted into the installed runtime. + /// + /// A draw that never calls this is not instrumented at all - and, because the + /// motion attachment is only in the draw-buffer mask while a writer holds the + /// window open, it also writes nothing, so the resolve falls back to camera + /// reprojection for it rather than reprojecting it by a stale vector. + /// + /// Render thread only. + /// + public static class OptimumStandardMotion + { + private sealed class History + { + public readonly float[] PrevModelMatrix = new float[16]; + public readonly float[] CurModelMatrix = new float[16]; + + /// The mesh drawn last frame; a different one means a different shape. + public object PrevShape; + public object CurShape; + + public float PrevWindWaveIntensity = 1f; + public float CurWindWaveIntensity = 1f; + public float PrevWaterWaveCounter; + public float CurWaterWaveCounter; + + public EnumTemporalView PrevView; + public EnumTemporalView CurView; + + public long CapturedFrame = -1; + public long PreviousFrame = -1; + } + + private static readonly ConditionalWeakTable histories = new ConditionalWeakTable(); + + private static IShaderProgram sharedUniformProgram; + private static long sharedUniformFrame = -1; + private static EnumTemporalView sharedUniformView; + + /// + /// Feeds one standard-shader draw's previous transform to the writer. + /// + /// The standard-shader program in use, already active. + /// A stable object that means "this drawn thing". + /// The mesh about to be drawn; history is void when it changed. + /// The model matrix this draw set, 16 floats. + /// Whether the writer got a usable previous transform. + public static bool Apply(IShaderProgram program, object identity, object shape, float[] modelMatrix) + { + if (!OptimumEntityMotion.Enabled || program == null || identity == null) return false; + if (modelMatrix == null || modelMatrix.Length < 16) return false; + if (!program.HasUniform("taaHistoryValid")) return false; + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + EnumTemporalView view = frame.ActiveView; + History history = histories.GetValue(identity, _ => new History()); + + // One roll per frame, not per draw: something drawn twice in a frame must + // both times compare against the frame before, not against its own first draw. + if (history.CapturedFrame != frame.FrameIndex) + { + Array.Copy(history.CurModelMatrix, history.PrevModelMatrix, 16); + history.PrevShape = history.CurShape; + history.PrevWindWaveIntensity = history.CurWindWaveIntensity; + history.PrevWaterWaveCounter = history.CurWaterWaveCounter; + history.PrevView = history.CurView; + history.PreviousFrame = history.CapturedFrame; + history.CapturedFrame = frame.FrameIndex; + } + + Array.Copy(modelMatrix, history.CurModelMatrix, 16); + history.CurShape = shape; + history.CurWindWaveIntensity = OptimumEntityMotion.ScratchWindWaveIntensity; + history.CurWaterWaveCounter = OptimumEntityMotion.ScratchWaterWaveCounter; + history.CurView = view; + + bool valid = + !frame.Reset && + history.PreviousFrame == frame.FrameIndex - 1 && + ReferenceEquals(history.PrevShape, shape) && + history.PrevView == view && + frame.WasViewCaptured(view); + + // Per-frame, per-program half: the previous camera and the previous global + // warp state are the same for every draw the pass makes. + if (!ReferenceEquals(sharedUniformProgram, program) || + sharedUniformFrame != frame.FrameIndex || + sharedUniformView != view) + { + sharedUniformProgram = program; + sharedUniformFrame = frame.FrameIndex; + sharedUniformView = view; + if (program.HasUniform("prevProjectionMatrix")) + { + program.UniformMatrix("prevProjectionMatrix", frame.GetPrevProjection(view)); + } + if (program.HasUniform("prevViewMatrix")) + { + program.UniformMatrix("prevViewMatrix", frame.PrevCameraMatrixOrigin); + } + frame.ApplyMotionUniforms(program); + } + + if (program.HasUniform("prevModelMatrix")) + { + program.UniformMatrix("prevModelMatrix", valid ? history.PrevModelMatrix : history.CurModelMatrix); + } + program.Uniform("taaHistoryValid", valid ? 1 : 0); + if (program.HasUniform("taaReactive")) program.Uniform("taaReactive", valid ? 0f : 1f); + if (program.HasUniform("prevWindWaveIntensity")) + { + program.Uniform("prevWindWaveIntensity", valid ? history.PrevWindWaveIntensity : history.CurWindWaveIntensity); + } + if (program.HasUniform("prevWaterWaveCounter")) + { + program.Uniform("prevWaterWaveCounter", valid ? history.PrevWaterWaveCounter : history.CurWaterWaveCounter); + } + + return valid; + } + } + /// /// The process-wide holder of the temporal frame contract. Static because the /// producers are scattered across the render loop, the platform layer and the diff --git a/sources/shaders/standard.fsh b/sources/shaders/standard.fsh new file mode 100644 index 00000000..a12f2e94 --- /dev/null +++ b/sources/shaders/standard.fsh @@ -0,0 +1,184 @@ +#version 330 core +// Optimum override of the vanilla standard.fsh: adds the TAA motion-vector output +// (P3); see chunkopaque.fsh for the contract. Everything else is vanilla, line +// for line. +// +// The alpha channel is this fragment's WINDOW depth, which for the first-person +// item program is gl_FragCoord.z + depthOffset, not gl_FragCoord.z - it writes +// gl_FragDepth below, and the resolve compares what it finds here against the +// depth buffer. Writing the un-offset value would make the resolve reject every +// first-person item pixel and fall back to camera reprojection on geometry whose +// motion differs most from the camera's. +#extension GL_ARB_explicit_attrib_location: enable + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if SSAOLEVEL > 0 +in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +uniform sampler2D tex; +uniform float extraGodray = 0; +uniform float alphaTest = 0.001; +uniform float ssaoAttn = 0; +uniform int applySsao = 1; +uniform int tempGlowMode; + +// Texture overlay "hack" +// We only have the base texture UV coordinates, which, for blocks and items in inventory is the block or item texture atlas, but none uv coords for a dedicated overlay texture +// So lets remove the base offset (baseUvOrigin) and rescale the coords (baseTextureSize / overlayTextureSize) to get useful UV coordinates for the overlay texture +uniform sampler2D tex2dOverlay; +uniform float overlayOpacity; +uniform vec2 overlayTextureSize; +uniform vec2 baseTextureSize; +uniform vec2 baseUvOrigin; +uniform int normalShaded; +uniform int skyShaded; +uniform float damageEffect = 0; +#if defined(ALLOWDEPTHOFFSET) +#if ALLOWDEPTHOFFSET > 0 +uniform float depthOffset; +#endif +#endif +uniform vec4 averageColor; + +#if TAAMOTION > 0 +in vec4 taaPrevClip; +uniform vec2 taaRenderSize; +uniform vec2 taaJitterPx; +uniform float taaReactive = 0.0; +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; + +vec4 taaMotionVector(float reactive, float writerDepth) +{ + if (taaPrevClip.w <= 1e-6) return vec4(0.0); + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + return vec4(prevPixel - currentPixel, reactive, writerDepth); +} +#endif + +in vec2 uv; +in vec4 color; +in vec4 rgbaFog; +in float fogAmount; +in float glowLevel; +in vec4 rgbaGlow; +in vec4 camPos; +in vec4 worldPos; +in vec3 normal; +flat in int renderFlags; + + +#include fogandlight.fsh +#include noise2d.ash +#include underwatereffects.fsh + +void main() { + float b = 1; + + if (damageEffect > 0) { + float f = cnoise2(floor(vec2(uv.x, uv.y) * 4096) / 4); + if (f < damageEffect - 1.3) discard; + b = min(1, f * 1.5 + 0.65 + (1-damageEffect)); + } + + if (overlayOpacity > 0) { + vec2 uvOverlay = (uv - baseUvOrigin) * (baseTextureSize / overlayTextureSize); + + vec4 col1 = texture(tex2dOverlay, uvOverlay); + vec4 col2 = texture(tex, uv); + + float a1 = overlayOpacity * col1.a * min(1, col2.a * 100); + float a2 = col2.a * (1 - a1); + + outColor = vec4( + (a1 * col1.r + col2.r * a2) / (a1+a2), + (a1 * col1.b + col2.g * a2) / (a1+a2), + (a1 * col1.g + col2.b * a2) / (a1+a2), + a1 + a2 + ) * color; + + } else { + outColor = texture(tex, uv) * color; + } + +#if BLOOM == 0 + outColor.rgb *= 1 + glowLevel; +#endif + + if (tempGlowMode == 1) { + float f = (averageColor.r+averageColor.g+averageColor.b) / (rgbaGlow.r+rgbaGlow.g+rgbaGlow.b); + f=max(f,0.6); + // Use multiply so some texture is still visible, use 'f' to adjust to same brightness + outColor.rgb = mix(outColor.rgb, outColor.rgb * rgbaGlow.rgb / f, min(1.5, glowLevel*2)); + + } else { + outColor.rgb = mix(outColor.rgb, rgbaGlow.rgb, glowLevel * rgbaGlow.a); + } + + if (normalShaded > 0) { + float b = min(1, getBrightnessFromNormal(normal, 1, 0.45) + min(0.5, glowLevel)); + outColor *= vec4(b, b, b, 1); + } + + float murkiness=skyShaded > 0 ? getSkyMurkiness() : getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadow(outColor, 0); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadow(outColor, fogAmount); + } + +#if NORMALVIEW == 0 + if (outColor.a < alphaTest) discard; +#endif + + float glow = 0; +#if SHINYEFFECT > 0 + outColor = mix(applyReflectiveEffect(outColor, glow, renderFlags, uv, normal, worldPos, camPos, vec3(1)), outColor, min(1, 2 * fogAmount)); + glow = pow(max(0.0, dot(normal, lightPosition)), 6) / 8 * shadowIntensity * (1 - fogAmount); +#endif + +#if SSAOLEVEL > 0 + if (applySsao > 0) { + outGPosition = vec4(camPos.xyz, fogAmount + glowLevel); + } else { + outGPosition = vec4(camPos.xyz, 1); + } + outGNormal = vec4(gnormal.xyz, ssaoAttn); + +#endif + +#if NORMALVIEW > 0 + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); +#endif + + outColor.rgb *= b; + outGlow = vec4(glowLevel + glow, extraGodray - fogAmount, 0, outColor.a); + +#if defined(ALLOWDEPTHOFFSET) +#if ALLOWDEPTHOFFSET > 0 + // This likely tanks performance in any other scenario so we do only only for the first person mode rendering. See also https://www.khronos.org/opengl/wiki/Early_Fragment_Test#Limitations + gl_FragDepth = gl_FragCoord.z + depthOffset; + + // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it + #if SSAOLEVEL > 0 + outGPosition.w=1; + #endif +#endif +#endif + +#if TAAMOTION > 0 + // Items and block-entity models are not reactive on their own; the C# side + // raises taaReactive to 1 for a draw whose per-object history was unusable, + // where the vector above is camera-only and the history must not be trusted. + #if defined(ALLOWDEPTHOFFSET) && ALLOWDEPTHOFFSET > 0 + outMotion = taaMotionVector(taaReactive, clamp(gl_FragCoord.z + depthOffset, 0.0, 1.0)); + #else + outMotion = taaMotionVector(taaReactive, gl_FragCoord.z); + #endif +#endif +} diff --git a/sources/shaders/standard.vsh b/sources/shaders/standard.vsh new file mode 100644 index 00000000..58e0ca15 --- /dev/null +++ b/sources/shaders/standard.vsh @@ -0,0 +1,163 @@ +#version 330 core +// Optimum override of the vanilla standard.vsh: adds the TAA motion-vector writer +// for everything the standard shader draws (P3) - held items in both hands, the +// first-person item, dropped items and block-entity models such as the quern top. +// Everything else is vanilla, line for line. +// +// The previous position is the same run with previous inputs: the previous model +// matrix this object was drawn with, the SAME dontWarpVertices branch evaluated +// with the previous frame's WarpState, the previous unjittered projection of +// whichever view the draw belongs to (world FOV, or the hand FOV for the +// first-person item), and the same extraZOffset the current clip position gets. +// Without usable history - the object appeared, its mesh changed, it was not +// drawn last frame, or the camera switched between first and third person - the +// vertex falls back to camera-only motion and C# raises taaReactive so the +// resolve leans on this frame instead. +#extension GL_ARB_explicit_attrib_location: enable + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in int flags; +#if defined(GLOWSUB) +layout(location = 4) in float glowSub; +#endif + +uniform vec4 rgbaTint; +uniform vec3 rgbaAmbientIn; +uniform vec4 rgbaLightIn; +uniform vec4 rgbaGlowIn; +uniform vec4 rgbaFogIn; +uniform int extraGlow; +uniform float fogMinIn; +uniform float fogDensityIn; + +uniform mat4 projectionMatrix; +uniform mat4 viewMatrix; +uniform mat4 modelMatrix; + +uniform int dontWarpVertices; +uniform int fadeFromSpheresFog; +uniform int addRenderFlags; +uniform float extraZOffset; + +out vec2 uv; +out vec4 color; +out vec4 rgbaFog; +out vec4 rgbaGlow; +out float fogAmount; +out vec4 camPos; +out vec4 worldPos; +flat out int renderFlags; + +out vec3 normal; +#if SSAOLEVEL > 0 +out vec4 gnormal; +#endif + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED projection for this draw's view +uniform mat4 prevViewMatrix; // previous frame's CameraMatrixOrigin (the view standard draws use) +uniform mat4 prevModelMatrix; // this object's model matrix as it was last frame +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +uniform int taaHistoryValid = 0; // 0 = no usable per-object history, use camera-only motion +out vec4 taaPrevClip; +#endif + + +#include vertexflagbits.ash +#include shadowcoords.vsh +#include fogandlight.vsh +#include vertexwarp.vsh + +void main(void) +{ + worldPos = modelMatrix * vec4(vertexPositionIn, 1.0); + + if (dontWarpVertices == 0) { + worldPos = applyVertexWarping(flags | addRenderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + } + if (dontWarpVertices == 2) { + int windMode = ((flags | addRenderFlags) >> WindModePosition) & 0xF; + vec4 newPos = applyVertexWarping(flags | addRenderFlags, worldPos); + worldPos = mix(worldPos, newPos, 0.25); // Hardcoded intensity downscale of 4x + worldPos = applyGlobalWarping(worldPos); + } + +#if TAAMOTION > 0 + // The same vertex, one frame ago. The warp branch below has to be the caller's + // exact branch - a held item passes dontWarpVertices 2, a dropped item 0 - or + // the two positions differ by a warp the object never had. + { + vec4 taaPrevWorld; + if (taaHistoryValid != 0) { + taaPrevWorld = prevModelMatrix * vec4(vertexPositionIn, 1.0); + WarpState taaPrev = previousWarpState(); + if (dontWarpVertices == 0) { + taaPrevWorld = applyVertexWarpingState(taaPrev, flags | addRenderFlags, taaPrevWorld); + taaPrevWorld = applyGlobalWarpingState(taaPrev, taaPrevWorld); + } + if (dontWarpVertices == 2) { + vec4 taaNewPos = applyVertexWarpingState(taaPrev, flags | addRenderFlags, taaPrevWorld); + taaPrevWorld = mix(taaPrevWorld, taaNewPos, 0.25); // same hardcoded 4x downscale as above + taaPrevWorld = applyGlobalWarpingState(taaPrev, taaPrevWorld); + } + } else { + // Treat the surface as static in the world: its camera-relative position + // a frame ago differed by the camera's own movement only (accuracy rule 4). + taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0); + } + taaPrevClip = prevProjectionMatrix * (prevViewMatrix * taaPrevWorld); + // The z-fighting nudge applies to both positions or the pair disagrees by it. + taaPrevClip.w += extraZOffset; + } +#endif + + camPos = viewMatrix * worldPos; + + uv = uvIn; + + float gs = 0.0; +#if defined(GLOWSUB) + gs = glowSub; +#endif + + int glow = clamp(extraGlow + (flags & GlowLevelBitMask) - int(gs * 255), 0, 255); + + renderFlags = glow | (flags & ~GlowLevelBitMask); + rgbaGlow.rgb = rgbaGlowIn.rgb * max(vec3(0), (1 - vec3(3*gs))); + rgbaGlow.a = rgbaGlowIn.a; + + color = rgbaTint * applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, camPos) * colorIn; +#if defined(GLOWSUB) + color.rgb *= 1 - 0.5 * gs; + color.rgb = mix(color.rgb, rgbaGlow.rgb, max(0, glowLevel - gs) / 2); +#endif + + + if (fadeFromSpheresFog > 0) { + color.a *= clamp(1 - getSpheresFogAmount(vertexPositionIn * 10), 0, 1); + } + + // Distance fade out + color.a *= clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5, -1, 1); + + rgbaFog = rgbaFogIn; + gl_Position = projectionMatrix * camPos; + calcShadowMapCoords(viewMatrix, worldPos); + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + gl_Position.w += extraZOffset; + + normal = unpackNormal(flags); + normal = normalize((modelMatrix * vec4(normal.x, normal.y, normal.z, 0)).xyz); + + #if SSAOLEVEL > 0 + gnormal = viewMatrix * vec4(normal, 0); + #endif +} \ No newline at end of file From efe57f1951c94ecd2894f84d1e647bec755ab543 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 20:54:02 +0200 Subject: [PATCH 029/226] wip(taa): P3 instanced mech renderers - per-instance previous transforms, verified by GPU readback The instanced program (MechNetworkRenderer / every MechBlockRenderer) now writes Primary's motion attachment. The previous transform travels as instance attributes in the same interleaved buffer as the current one (locations 9..12 plus a metadata vec4 at 13, OptimumInstanceMotion.CreateInstanceFloats), because one draw covers every gear of a shape and they share no previous transform. History is keyed on the device object per instance buffer, never on the slot: the buffer is rebuilt every frame from a dictionary whose order changes as blocks are placed and streamed, so a new, reordered or one-frame-absent instance gets no history, camera-only motion and reactive 1. Verified: Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests (6 GPU readbacks - still = (0,0) with its own depth, three known displacements, two instances in one draw each getting their own vector, and the no-history case falling back to camera motion with reactive 1); Optimum.Tests history tests (reordering, missed frame, twice in one frame, layout); full suites 847 + 280 green; extract-patches / check-patches clean (0 pending, 0 conflict). Not verified in game on either backend - the task forbade launching the client and make deploy. --- Optimum.Patcher/mod-patcher.cs | 28 + .../TaaInstancedMotionWriterTests.cs | 657 ++++++++++++++++++ .../taa-instanced-motion-coverage-tests.cs | 308 ++++++++ .../taa-instanced-motion-history-tests.cs | 293 ++++++++ .../Renderer/AngledCageGearRenderer.cs.patch | 41 ++ .../Renderer/AngledGearBlockRenderer.cs.patch | 43 ++ .../Renderer/ClutchBlockRenderer.cs.patch | 78 +++ .../Renderer/CreativeRotorRenderer.cs.patch | 103 +++ .../GenericMechBlockRenderer.cs.patch | 41 ++ .../Renderer/MechBlockRenderer.cs.patch | 44 ++ .../Renderer/MechNetworkRenderer.cs.patch | 30 + .../Renderer/PulverizerRenderer.cs.patch | 106 +++ .../TransmissionBlockRenderer.cs.patch | 56 ++ .../Client/Render/OptimumTemporalFrame.cs | 204 ++++++ sources/shaders/instanced.fsh | 77 ++ sources/shaders/instanced.vsh | 114 +++ 16 files changed, 2223 insertions(+) create mode 100644 Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs create mode 100644 Optimum.Tests/taa-instanced-motion-coverage-tests.cs create mode 100644 Optimum.Tests/taa-instanced-motion-history-tests.cs create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs.patch create mode 100644 sources/shaders/instanced.fsh create mode 100644 sources/shaders/instanced.vsh diff --git a/Optimum.Patcher/mod-patcher.cs b/Optimum.Patcher/mod-patcher.cs index 9137de44..b2b2c186 100644 --- a/Optimum.Patcher/mod-patcher.cs +++ b/Optimum.Patcher/mod-patcher.cs @@ -262,6 +262,34 @@ private static Manifest SurvivalManifest() // TAA P3: the quern top is the block-entity model that actually // moves, so it keeps a previous model matrix and writes motion. new("Vintagestory.GameContent.QuernTopRenderer", "OnRenderFrame", 2), + // TAA P3, the instanced writer: every mechanical-power renderer now + // fills OptimumInstanceMotion's instance layout (light, transform, + // previous transform, metadata) instead of vanilla's light+transform, + // so the buffer allocations, the transform writers and the instance + // counts all move together. MechNetworkRenderer sets the pass uniforms + // and opens the draw-buffer window around the whole loop. + new("Vintagestory.GameContent.Mechanics.MechNetworkRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.MechBlockRenderer", "UpdateCustomFloatBuffer", 0), + new("Vintagestory.GameContent.Mechanics.MechBlockRenderer", "UpdateLightAndTransformMatrix", 7), + new("Vintagestory.GameContent.Mechanics.GenericMechBlockRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.GenericMechBlockRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.AngledCageGearRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.AngledCageGearRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.AngledGearsBlockRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.AngledGearsBlockRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.TransmissionBlockRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.TransmissionBlockRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.ClutchBlockRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.ClutchBlockRenderer", "UpdateLightAndTransformMatrix", 9), + new("Vintagestory.GameContent.Mechanics.ClutchBlockRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.CreativeRotorRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.CreativeRotorRenderer", "createCustomFloats", 1), + new("Vintagestory.GameContent.Mechanics.CreativeRotorRenderer", "UpdateLightAndTransformMatrix", 8), + new("Vintagestory.GameContent.Mechanics.CreativeRotorRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.Mechanics.PulverizerRenderer", ".ctor", 4), + new("Vintagestory.GameContent.Mechanics.PulverizerRenderer", "createCustomFloats", 1), + new("Vintagestory.GameContent.Mechanics.PulverizerRenderer", "UpdateLightAndTransformMatrix", 8), + new("Vintagestory.GameContent.Mechanics.PulverizerRenderer", "OnRenderFrame", 2), ]); } diff --git a/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs new file mode 100644 index 00000000..75914925 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs @@ -0,0 +1,657 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The instanced motion-vector writer (TAA P3), driven through the seam with the +/// real instanced program and read back as pixels. This is the writer every +/// mechanical-power block goes through - axles, gears, clutches, transmissions, +/// creative rotors, pulverizers - all of them drawn as instances of one mesh. +/// +/// What it has to get right, and what a "the gear moved, so motion is non-zero" +/// assertion could not tell apart from a sign flip, an axis swap or a shared +/// uniform standing in for a per-instance attribute: +/// - a previous instance transform turns into exactly that displacement in render +/// pixels, with the sign "where the pixel was"; +/// - a gear that did not turn under a camera that did not move is exactly (0, 0); +/// - two instances in ONE draw get their OWN previous transforms - the failure +/// mode of the whole design is every instance reading one gear's matrix; +/// - an instance whose history the C# side could not match (a new device, a +/// reordered buffer) falls back to camera-only motion and carries reactive 1, +/// instead of reprojecting by whatever matrix landed in its slot. +/// +/// As in TaaStandardMotionWriterTests the RGBA16F attachment comes back through +/// an RGBA8 decode pass, because the seam's readback is fixed at four bytes per +/// pixel from colour attachment 0. Decode quantisation is 2*DecodeScale/255 px, +/// so the tolerances stay above it. +/// +public class TaaInstancedMotionWriterTests +{ + private readonly ITestOutputHelper _output; + + public TaaInstancedMotionWriterTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// Normal pointing up, no glow. + private const int UpNormalFlags = 7 << 18; + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + private static float[] Translation(float x, float y, float z) => new[] + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 1f, 0f, + x, y, z, 1f, + }; + + // ------------------------------------------------------------------ tests + + /// + /// A gear that did not turn, under a camera that did not move, is zero motion - + /// and the writer still stamps its own depth, so the resolve accepts the pixel + /// instead of silently falling back to camera reprojection. + /// + [SkippableFact] + public void AnUnmovedInstanceWritesZeroMotionAndTheFragmentDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + var instance = new Instance(Identity, Identity, historyValid: true); + Decoded centre = RenderInstancedMotion(device!, new[] { instance }, 0f, 0f)[Size / 2]; + + _output.WriteLine($"still: mv = ({centre.MotionX}, {centre.MotionY}), writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + // Identity matrices put the quad at NDC z = 0, which is window depth + // 0.5 on both backends. + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The transform this instance was drawn with last frame is its motion: with + /// identity camera matrices a translation of d moves the pixel by exactly + /// d * 0.5 * renderSize, and the sign is "where the pixel was", not "where it + /// went". + /// + [SkippableTheory] + [InlineData(0.25f, 0f)] + [InlineData(0f, -0.125f)] + [InlineData(-0.1875f, 0.0625f)] + public void APreviousInstanceTransformShowsUpAsTheExactPixelDisplacement(float prevX, float prevY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + var instance = new Instance(Identity, Translation(prevX, prevY, 0f), historyValid: true); + Decoded centre = RenderInstancedMotion(device!, new[] { instance }, 0f, 0f)[Size / 2]; + + float expectedX = prevX * 0.5f * Size; + float expectedY = prevY * 0.5f * Size; + + _output.WriteLine($"prev ({prevX}, {prevY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The point of putting the previous transform in the instance stream rather + /// than in a uniform: two devices drawn by ONE instanced draw call each get + /// their own previous transform. A writer that took it from a uniform - or + /// that keyed history on the buffer slot instead of the device - would give + /// both halves of this image the same vector, which is what a whole gear + /// network smearing into one direction looks like on screen. + /// + [SkippableFact] + public void EachInstanceInOneDrawGetsItsOwnPreviousTransform() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float leftPrevX = 0.25f; + const float rightPrevY = -0.125f; + + // The current transforms put instance 0 in the left half of the image + // and instance 1 in the right half; the quad is one unit across, so the + // halves do not overlap. + var left = new Instance( + Translation(-0.5f, 0f, 0f), + MultiplyTranslations(-0.5f + leftPrevX, 0f), + historyValid: true); + var right = new Instance( + Translation(0.5f, 0f, 0f), + MultiplyTranslations(0.5f, rightPrevY), + historyValid: true); + + Decoded[] row = RenderInstancedMotion(device!, new[] { left, right }, 0f, 0f); + Decoded leftPixel = row[Size / 4]; + Decoded rightPixel = row[Size * 3 / 4]; + + float expectedLeftX = leftPrevX * 0.5f * Size; + float expectedRightY = rightPrevY * 0.5f * Size; + + _output.WriteLine($"left: mv = ({leftPixel.MotionX}, {leftPixel.MotionY}), expected ({expectedLeftX}, 0)"); + _output.WriteLine($"right: mv = ({rightPixel.MotionX}, {rightPixel.MotionY}), expected (0, {expectedRightY})"); + + Assert.InRange(leftPixel.MotionX, expectedLeftX - 0.3f, expectedLeftX + 0.3f); + Assert.InRange(leftPixel.MotionY, -0.3f, 0.3f); + Assert.InRange(rightPixel.MotionX, -0.3f, 0.3f); + Assert.InRange(rightPixel.MotionY, expectedRightY - 0.3f, expectedRightY + 0.3f); + } + } + + /// + /// Without usable history - a device placed this frame, a chunk that streamed + /// in, a buffer whose instances were reordered - the writer must not read the + /// previous transform at all. It falls back to treating the block as static in + /// the world, so only the camera's own movement displaces it, and the reactive + /// channel the C# side stamped comes through so the resolve leans on this frame. + /// + /// The previous transform here is deliberately a large translation: if the + /// shader took the history branch anyway, the vector would be that instead. + /// + [SkippableFact] + public void WithoutUsableHistoryTheVectorIsCameraMotionOnlyAndReactive() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float cameraDeltaX = 0.25f; + const float cameraDeltaY = -0.125f; + + var instance = new Instance(Identity, Translation(-0.5f, 0.5f, 0f), historyValid: false); + Decoded centre = RenderInstancedMotion(device!, new[] { instance }, cameraDeltaX, cameraDeltaY)[Size / 2]; + + float expectedX = cameraDeltaX * 0.5f * Size; + float expectedY = cameraDeltaY * 0.5f * Size; + + _output.WriteLine($"no history: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY}), reactive = {centre.Reactive}"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.Reactive, 0.9f, 1.1f); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Instance + { + public Instance(float[] transform, float[] previousTransform, bool historyValid) + { + Transform = transform; + PreviousTransform = previousTransform; + HistoryValid = historyValid; + } + + public float[] Transform { get; } + public float[] PreviousTransform { get; } + public bool HistoryValid { get; } + } + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float reactive, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + Reactive = reactive; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float Reactive { get; } + public float WriterDepth { get; } + } + + /// Two translations composed: the current placement plus the previous offset. + private static float[] MultiplyTranslations(float x, float y) => Translation(x, y, 0f); + + /// + /// Draws the given instances with the real instanced program compiled as a + /// motion writer, then decodes the motion attachment and returns the middle + /// scanline. Every expectation is stated in terms of the previous-frame inputs: + /// both camera matrices are the identity and no jitter is applied. + /// + private unsafe Decoded[] RenderInstancedMotion( + VulkanDevice device, Instance[] instances, float cameraDeltaX, float cameraDeltaY) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = new ShaderCorpus.ShaderVariant + { + Name = "taa-instanced", + TaaMotion = 1, + TaaMotionLocation = 2, + }; + + List stages = ShaderCorpus.BuildProgram("instanced", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "instanced"); + + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + "instanced declares no taaRenderSize, so it is not a motion writer"); + Assert.True(seam.GetUniformLocation(program, "prevModelViewMatrix") >= 0, + "instanced declares no prevModelViewMatrix, so it has no previous camera"); + + BindEveryDeclaredSampler(device, seam, program); + + // Primary stand-in: colour, glow and the motion attachment at index 2. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(scene, 0b111); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + int mesh = seam.CreateMesh(BuildInstancedQuad(instances), staticDraw: false); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.UseProgram(program); + SetMatrix(seam, program, "projectionMatrix", Identity); + SetMatrix(seam, program, "modelViewMatrix", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixFar", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); + SetSceneUniforms(seam, program); + + SetMatrix(seam, program, "prevProjectionMatrix", Identity); + SetMatrix(seam, program, "prevModelViewMatrix", Identity); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", 0f, 0f); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMeshInstanced(mesh, instances.Length); + + byte[] decoded = DecodeMotion(seam, motion); + seam.Present(); + + AssertClean(seam); + + var row = new Decoded[Size]; + for (int x = 0; x < Size; x++) + { + int offset = ((Size / 2) * Size + x) * 4; + row[x] = new Decoded( + (decoded[offset] / 255f * 2f - 1f) * DecodeScale, + (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, + decoded[offset + 2] / 255f, + decoded[offset + 3] / 255f); + } + return row; + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// Unlike the other writers' harnesses this one carries the reactive channel + /// too, because "no history" and "reactive" are one decision here. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.b, 0.0, 1.0), + clamp(m.a, 0.0, 1.0)); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-instanced-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-instanced-decode.fsh" }, + }, "taa-instanced-decode"); + + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int quadMesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// A quad in instanced.vsh's attribute layout - xyz, uv, rgbaBlockIn, flags, + /// with no normals, which is what puts uv on location 1 - plus the instance + /// stream in OptimumInstanceMotion's layout: light rgba at 4, transform at + /// 5..8, previous transform at 9..12 and the TAA metadata at 13. The instance + /// values are written by hand rather than through + /// OptimumInstanceMotion.WriteInstance so the shader contract is tested + /// independently of the history bookkeeping (which + /// Optimum.Tests/taa-instanced-motion-tests.cs drives directly). + /// + private static MeshData BuildInstancedQuad(Instance[] instances) + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + ColorUtil.WhiteArgb, + flags: UpNormalFlags); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + + CustomMeshDataPartFloat part = OptimumInstanceMotion.CreateInstanceFloats(instances.Length); + for (int i = 0; i < instances.Length; i++) + { + int j = i * OptimumInstanceMotion.InstanceFloats; + part.Values[j + OptimumInstanceMotion.LightOffset] = 1f; + part.Values[j + OptimumInstanceMotion.LightOffset + 1] = 1f; + part.Values[j + OptimumInstanceMotion.LightOffset + 2] = 1f; + part.Values[j + OptimumInstanceMotion.LightOffset + 3] = 1f; + Array.Copy(instances[i].Transform, 0, part.Values, j + OptimumInstanceMotion.TransformOffset, 16); + Array.Copy(instances[i].PreviousTransform, 0, part.Values, j + OptimumInstanceMotion.PrevTransformOffset, 16); + part.Values[j + OptimumInstanceMotion.MetaOffset] = instances[i].HistoryValid ? 1f : 0f; + part.Values[j + OptimumInstanceMotion.MetaOffset + 1] = instances[i].HistoryValid ? 0f : 1f; + } + part.Count = instances.Length * OptimumInstanceMotion.InstanceFloats; + mesh.CustomFloats = part; + return mesh; + } + + /// + /// Enough of the lighting, fog and shadow surface to keep the fragment alive: + /// a fragment below alphaTest is discarded before it can write a motion + /// vector, and the test would read the cleared attachment instead. + /// + private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) + { + SetFloat(seam, program, "alphaTest", -1f); + SetFloat(seam, program, "viewDistance", 1024f); + SetFloat(seam, program, "viewDistanceLod0", 1024f); + SetFloat(seam, program, "zNear", 0.1f); + SetFloat(seam, program, "zFar", 1024f); + SetFloat(seam, program, "fogMinIn", 0f); + SetFloat(seam, program, "fogDensityIn", 0f); + SetFloat(seam, program, "shadowRangeFar", 1024f); + SetFloat(seam, program, "shadowRangeNear", 64f); + SetFloat(seam, program, "shadowMapWidthInv", 1f); + SetFloat(seam, program, "shadowMapHeightInv", 1f); + SetFloat(seam, program, "shadowIntensity", 0f); + SetFloat(seam, program, "extraGodray", 0f); + SetFloat(seam, program, "ssaoAttn", 0f); + SetInt(seam, program, "applySsao", 0); + SetInt(seam, program, "normalShaded", 0); + SetInt(seam, program, "skyShaded", 0); + SetFloat3(seam, program, "rgbaAmbientIn", 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaFogIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "averageColor", 1f, 1f, 1f, 1f); + SetFloat2(seam, program, "frameSize", Size, Size); + SetFloat3(seam, program, "playerpos", 0f, 0f, 0f); + SetFloat(seam, program, "timeCounter", 0f); + SetFloat(seam, program, "windWaveCounter", 0f); + SetFloat(seam, program, "windWaveCounterHighFreq", 0f); + SetFloat(seam, program, "waterWaveCounter", 0f); + SetFloat(seam, program, "windSpeed", 0f); + SetFloat(seam, program, "globalWarpIntensity", 0f); + SetFloat(seam, program, "glitchWaviness", 0f); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetFloat4( + IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z, w); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + { + var white = new byte[] { 255, 255, 255, 255 }; + fixed (byte* pixels = white) + { + return seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + } + + private static int BindEveryDeclaredSampler( + VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + { + int unit = 0; + foreach (string samplerName in device.SamplerNamesOf(programId)) + { + int texture = CreateWhiteTexture(seam); + seam.SetSamplerUnit(programId, samplerName, unit); + seam.BindTexture(unit, texture); + unit++; + } + return unit; + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vec2f value) { } + public void Uniform(string uniformName, Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-instanced-motion-coverage-tests.cs b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs new file mode 100644 index 00000000..66e1edea --- /dev/null +++ b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs @@ -0,0 +1,308 @@ +using System; +using System.IO; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P3 instanced motion-vector writer: the instanced +/// shader pair, the per-instance previous-transform store that feeds it, the +/// renderers that fill it, and the plumbing that has to ship all of it +/// (mod-patcher manifests, scanner rules, packaging). +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests) proves the numbers +/// the shader produces, and TaaInstancedMotionHistoryTests below proves the C# +/// side matches an instance to the right device. +/// +public class TaaInstancedMotionCoverageTests +{ + // ------------------------------------------------------------- the shaders + + [Fact] + public void TheInstancedVertexShaderReprojectsThroughThePerInstancePreviousTransform() + { + string vertex = Read("sources/shaders/instanced.vsh"); + + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("out vec4 taaPrevClip;", vertex); + Assert.Contains("out float taaInstanceReactive;", vertex); + + // The previous transform is per instance, not a uniform: one draw covers + // every gear of a shape, and they do not share a previous transform. + Assert.Contains("layout(location = 9) in mat4 prevTransform;", vertex); + Assert.Contains("layout(location = 13) in vec4 taaInstanceMeta;", vertex); + + foreach (string uniform in new[] { "prevProjectionMatrix", "prevModelViewMatrix", "cameraPosDelta" }) + { + Assert.True(DeclaresUniform(vertex, uniform), + uniform + " is not declared by instanced.vsh"); + } + + Assert.Contains("taaPrevWorld = prevTransform * vec4(vertexPosition, 1.0);", vertex); + + // No usable history: camera-only motion, the same rule the terrain, + // entity and standard writers and the resolve's fallback use. + Assert.Contains("taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0);", vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevWorld);", vertex); + Assert.Contains("taaInstanceReactive = taaInstanceMeta.y;", vertex); + + // The current position takes no vertex warp and no w-offset in this + // shader, so neither may appear on the previous one. + Assert.DoesNotContain("applyVertexWarping", vertex); + Assert.DoesNotContain("taaPrevClip.w +=", vertex); + } + + [Fact] + public void TheInstancedFragmentShaderWritesTheMotionAttachment() + { + string fragment = Read("sources/shaders/instanced.fsh"); + + Assert.Contains("#if TAAMOTION > 0", fragment); + Assert.Contains("in vec4 taaPrevClip;", fragment); + Assert.Contains("in float taaInstanceReactive;", fragment); + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + + // The contract taa-resolve.fsh consumes. + Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + Assert.Contains("return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z);", fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + Assert.Contains("outMotion = taaMotionVector(taaInstanceReactive);", fragment); + + Assert.True(DeclaresUniform(fragment, "taaRenderSize")); + Assert.True(DeclaresUniform(fragment, "taaJitterPx")); + } + + // ------------------------------------------------- the per-instance history + + [Fact] + public void TheFrameContractKeepsPerInstanceHistoryKeyedOnTheDevice() + { + string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + string vertex = Read("sources/shaders/instanced.vsh"); + + Assert.Contains("public static class OptimumInstanceMotion", frame); + + // Keyed on the buffer and then the device object, never on the slot: the + // instance buffer is rebuilt from a dictionary whose order changes as + // blocks are placed and broken. + Assert.Contains("ConditionalWeakTable>", frame); + + string write = BodyOf(frame, + "public static void WriteInstance(float[] values, int index, Vec4f lightRgba, float[] transform)"); + Assert.Contains("Entry entry = entries.GetValue(device, _ => new Entry());", write); + Assert.Contains("if (entry.CapturedFrame != frame.FrameIndex)", write); + Assert.Contains("!frame.Reset &&", write); + Assert.Contains("entry.PreviousFrame == frame.FrameIndex - 1 &&", write); + Assert.Contains("entry.PrevView == view &&", write); + Assert.Contains("frame.WasViewCaptured(view)", write); + + string pass = BodyOf(frame, "public static void ApplyPassUniforms(IShaderProgram program)"); + foreach (string uniform in new[] { "prevProjectionMatrix", "prevModelViewMatrix" }) + { + Assert.Contains("program.UniformMatrix(\"" + uniform + "\"", pass); + Assert.True(DeclaresUniform(vertex, uniform), uniform + " is set but declared by no shader"); + } + // The hand FOV is a different view with a different previous projection. + Assert.Contains("EnumTemporalView view = frame.ActiveView;", pass); + Assert.Contains("frame.GetPrevProjection(view)", pass); + // The shared warp/jitter/render-size block comes from the one helper the + // other three writers use, not from a second copy of it. + Assert.Contains("frame.ApplyMotionUniforms(program);", pass); + } + + // ------------------------------------------------ the instrumented renderers + + /// + /// Every mechanical-power renderer allocates its instance buffer in the shared + /// layout and writes its transforms through the shared writer, because the + /// layout is a contract with instanced.vsh: a renderer that kept vanilla's + /// 20-float stride would feed the shader another instance's matrix. + /// + [Theory] + [InlineData("GenericMechBlockRenderer")] + [InlineData("AngledCageGearRenderer")] + [InlineData("AngledGearBlockRenderer")] + [InlineData("TransmissionBlockRenderer")] + [InlineData("ClutchBlockRenderer")] + [InlineData("CreativeRotorRenderer")] + [InlineData("PulverizerRenderer")] + public void EveryMechanicalRendererUsesTheSharedInstanceLayout(string renderer) + { + string source = ReadPatchedOrSource( + "patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/" + renderer + ".cs.patch", + "VSSurvivalMod/Systems/MechanicalPower/Renderer/" + renderer + ".cs"); + + Assert.Contains("OptimumInstanceMotion.CreateInstanceFloats(", source); + Assert.Contains("OptimumInstanceMotion.InstanceFloats", source); + // Vanilla's hand-rolled layout and instance stride must be gone, or the + // buffer and the shader disagree about where an instance starts. + Assert.DoesNotContain("InterleaveStride = 16 + 4 * 16", source); + Assert.DoesNotContain("* 20;", source); + } + + [Fact] + public void TheInstanceWriterAndTheDeviceItBelongsToAreNamedTogether() + { + string baseRenderer = ReadPatchedOrSource( + "patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs.patch", + "VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs"); + + // The device is named before its transforms are written, so the history + // below matches the same gear rather than the same buffer slot. + Assert.Contains("OptimumInstanceMotion.NoteDevice(dev);", baseRenderer); + Assert.Contains("OptimumInstanceMotion.WriteInstance(values, index, lightRgba, tmpMat);", baseRenderer); + + // The sub-mesh renderers that write their own transforms do the same. + foreach (string renderer in new[] { "ClutchBlockRenderer", "CreativeRotorRenderer", "PulverizerRenderer" }) + { + string source = ReadPatchedOrSource( + "patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/" + renderer + ".cs.patch", + "VSSurvivalMod/Systems/MechanicalPower/Renderer/" + renderer + ".cs"); + Assert.Contains("OptimumInstanceMotion.WriteInstance(values, index, lightRgba, tmpMat);", source); + } + } + + [Fact] + public void TheInstancedPassSetsItsUniformsAndOpensTheMotionWindow() + { + string renderer = ReadPatchedOrSource( + "patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs.patch", + "VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs"); + + Assert.Contains("OptimumInstanceMotion.ApplyPassUniforms(prog);", renderer); + Assert.Contains("bool optimumMotionWrite = OptimumMotionWrite.Begin();", renderer); + Assert.Contains("if (optimumMotionWrite) OptimumMotionWrite.End();", renderer); + } + + // --------------------------------------------------------------- the ship + + [Fact] + public void ModPatcherManifestsCarryTheChangedMechanicalRenderers() + { + string manifest = Read("Optimum.Patcher/mod-patcher.cs"); + + foreach (string entry in new[] + { + "new(\"Vintagestory.GameContent.Mechanics.MechNetworkRenderer\", \"OnRenderFrame\", 2)", + "new(\"Vintagestory.GameContent.Mechanics.MechBlockRenderer\", \"UpdateCustomFloatBuffer\", 0)", + "new(\"Vintagestory.GameContent.Mechanics.MechBlockRenderer\", \"UpdateLightAndTransformMatrix\", 7)", + "new(\"Vintagestory.GameContent.Mechanics.GenericMechBlockRenderer\", \".ctor\", 4)", + "new(\"Vintagestory.GameContent.Mechanics.GenericMechBlockRenderer\", \"OnRenderFrame\", 2)", + "new(\"Vintagestory.GameContent.Mechanics.AngledCageGearRenderer\", \".ctor\", 4)", + "new(\"Vintagestory.GameContent.Mechanics.AngledGearsBlockRenderer\", \".ctor\", 4)", + "new(\"Vintagestory.GameContent.Mechanics.TransmissionBlockRenderer\", \".ctor\", 4)", + "new(\"Vintagestory.GameContent.Mechanics.ClutchBlockRenderer\", \"UpdateLightAndTransformMatrix\", 9)", + "new(\"Vintagestory.GameContent.Mechanics.CreativeRotorRenderer\", \"UpdateLightAndTransformMatrix\", 8)", + "new(\"Vintagestory.GameContent.Mechanics.PulverizerRenderer\", \"UpdateLightAndTransformMatrix\", 8)", + }) + { + Assert.Contains(entry, manifest); + } + } + + /// + /// An external mod that ships its own instanced shader would not have the + /// writer, so TAA has to switch itself off rather than reproject gears by + /// whatever happens to be in the attachment. + /// + [Fact] + public void TheScannerDisablesTaaForAnExternalInstancedShader() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + + Assert.Contains("HasExternalShader(report, \"instanced.vsh\")", scanner); + Assert.Contains("HasExternalShader(report, \"instanced.fsh\")", scanner); + Assert.Contains("AddFeatureDecision(report, \"Taa\"", scanner); + } + + /// + /// The overrides only reach a running client if `make deploy` and every + /// packager copy sources/shaders - they do already, directory-wide, so this + /// only guards against a regression that starts naming files. + /// + [Fact] + public void DeployAndEveryPackagerShipTheInstancedShaderOverrides() + { + foreach (string path in new[] + { + "Makefile", "scripts/package-linux.sh", "scripts/package-macos.sh", "scripts/package-linux.ps1", + }) + { + string text = Read(path); + Assert.Contains("sources/shaders", text.Replace('\\', '/')); + Assert.DoesNotContain("instanced.vsh", text); + } + } + + // ----------------------------------------------------------------- helpers + + internal static bool DeclaresUniform(string shader, string name) + { + foreach (string line in shader.Replace("\r\n", "\n").Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("uniform ", StringComparison.Ordinal)) continue; + string declaration = trimmed.Substring("uniform ".Length); + int semicolon = declaration.IndexOf(';'); + if (semicolon < 0) continue; + declaration = declaration.Substring(0, semicolon); + int assign = declaration.IndexOf('='); + if (assign >= 0) declaration = declaration.Substring(0, assign); + int space = declaration.TrimEnd().LastIndexOf(' '); + if (space < 0) continue; + if (declaration.TrimEnd().Substring(space + 1) == name) return true; + } + return false; + } + + internal static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + internal static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + internal static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/Optimum.Tests/taa-instanced-motion-history-tests.cs b/Optimum.Tests/taa-instanced-motion-history-tests.cs new file mode 100644 index 00000000..3b19f3fe --- /dev/null +++ b/Optimum.Tests/taa-instanced-motion-history-tests.cs @@ -0,0 +1,293 @@ +using System; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The per-instance previous-transform store behind the TAA P3 instanced writer, +/// driven directly. This is the half the GPU test cannot reach: the shader is +/// handed a previous transform and a validity flag per instance, and everything +/// that can go wrong upstream of it - matching an instance to the wrong device, +/// believing a transform from two frames ago, trusting a slot that another gear +/// occupied last frame - looks identical on the GPU side. +/// +/// The mechanical-power renderers rebuild the whole instance buffer every frame +/// from a dictionary whose enumeration order changes as blocks are placed, broken +/// and streamed in, so "slot 3 last frame" is not this instance's previous +/// transform. These tests state that in the terms the renderer uses. +/// +/// They drive the process-wide OptimumTemporal.Frame, which no other test touches +/// (TemporalFrameTests deliberately uses its own instance), and restore the +/// writer's enable flag afterwards. +/// +public class TaaInstancedMotionHistoryTests +{ + private static readonly double[] IdentityProjection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + private static float[] Transform(float x) => new[] + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 1f, 0f, + x, 0f, 0f, 1f, + }; + + private static void AdvanceFrame() + { + OptimumTemporalFrame frame = OptimumTemporal.Frame; + frame.Advance(16.6f, 1920, 1080, 1f, 0.1f, 3000f, 1.2f, new Vec3d(0, 0, 0), new DefaultShaderUniforms()); + // A view is only usable as "previous" once it has been captured, which is + // what Set3DProjection does in the client. + frame.RecordProjection(EnumTemporalView.World, IdentityProjection); + } + + private static float PrevX(float[] values, int index) + { + // Column-major mat4: the translation's x is component 12. + return values[index * OptimumInstanceMotion.InstanceFloats + OptimumInstanceMotion.PrevTransformOffset + 12]; + } + + private static float Valid(float[] values, int index) + { + return values[index * OptimumInstanceMotion.InstanceFloats + OptimumInstanceMotion.MetaOffset]; + } + + private static float Reactive(float[] values, int index) + { + return values[index * OptimumInstanceMotion.InstanceFloats + OptimumInstanceMotion.MetaOffset + 1]; + } + + private static void Write(float[] buffer, int index, object device, float x) + { + OptimumInstanceMotion.NoteDevice(device); + OptimumInstanceMotion.WriteInstance(buffer, index, new Vec4f(1, 1, 1, 1), Transform(x)); + } + + // ------------------------------------------------------------------ layout + + /// + /// The instance layout is a contract with instanced.vsh: rgbaLightIn at + /// location 4, transform at 5..8, prevTransform at 9..12 and the metadata at + /// 13, which the attribute assignment produces from ten interleaved vec4s. + /// + [Fact] + public void TheInstanceLayoutMatchesTheShadersAttributeNumbering() + { + CustomMeshDataPartFloat part = OptimumInstanceMotion.CreateInstanceFloats(3); + + Assert.Equal(40, OptimumInstanceMotion.InstanceFloats); + Assert.Equal(120, part.Values.Length); + Assert.Equal(120, part.AllocationSize); + Assert.Equal(160, part.InterleaveStride); + Assert.Equal(new[] { 4, 4, 4, 4, 4, 4, 4, 4, 4, 4 }, part.InterleaveSizes); + Assert.Equal(new[] { 0, 16, 32, 48, 64, 80, 96, 112, 128, 144 }, part.InterleaveOffsets); + Assert.True(part.Instanced); + Assert.False(part.StaticDraw); + + // The offsets the writer uses have to be the same ones, in floats. + Assert.Equal(0, OptimumInstanceMotion.LightOffset); + Assert.Equal(4, OptimumInstanceMotion.TransformOffset); + Assert.Equal(20, OptimumInstanceMotion.PrevTransformOffset); + Assert.Equal(36, OptimumInstanceMotion.MetaOffset); + } + + // ----------------------------------------------------------------- history + + [Fact] + public void ADeviceDrawnForTheFirstTimeGetsNoHistory() + { + OptimumEntityMotion.Enabled = true; + try + { + var buffer = new float[OptimumInstanceMotion.InstanceFloats]; + var device = new object(); + + AdvanceFrame(); + AdvanceFrame(); + Write(buffer, 0, device, 5f); + + Assert.Equal(0f, Valid(buffer, 0)); + Assert.Equal(1f, Reactive(buffer, 0)); + // The previous transform still has to be a sane matrix rather than + // zeros, because a zero matrix would put prevClip.w at 0 and lose the + // fragment to the "unwritten" branch instead of the camera fallback. + Assert.Equal(5f, PrevX(buffer, 0)); + } + finally + { + OptimumEntityMotion.Enabled = false; + } + } + + [Fact] + public void TheSameDeviceGetsTheTransformItWasDrawnWithLastFrame() + { + OptimumEntityMotion.Enabled = true; + try + { + var buffer = new float[OptimumInstanceMotion.InstanceFloats]; + var device = new object(); + + AdvanceFrame(); + AdvanceFrame(); + Write(buffer, 0, device, 1f); + + AdvanceFrame(); + Write(buffer, 0, device, 2f); + + Assert.Equal(1f, Valid(buffer, 0)); + Assert.Equal(0f, Reactive(buffer, 0)); + Assert.Equal(1f, PrevX(buffer, 0)); + } + finally + { + OptimumEntityMotion.Enabled = false; + } + } + + /// + /// The failure the whole design exists to prevent: the buffer is rebuilt every + /// frame and its order is the order of a dictionary, so two devices can swap + /// slots between frames without anything moving on screen. Keyed on the slot, + /// both gears would be reprojected by the other one's matrix. + /// + [Fact] + public void ReorderedInstancesStillGetTheirOwnPreviousTransform() + { + OptimumEntityMotion.Enabled = true; + try + { + var buffer = new float[2 * OptimumInstanceMotion.InstanceFloats]; + var deviceA = new object(); + var deviceB = new object(); + + AdvanceFrame(); + AdvanceFrame(); + Write(buffer, 0, deviceA, 1f); + Write(buffer, 1, deviceB, 10f); + + AdvanceFrame(); + Write(buffer, 0, deviceB, 20f); + Write(buffer, 1, deviceA, 2f); + + Assert.Equal(1f, Valid(buffer, 0)); + Assert.Equal(10f, PrevX(buffer, 0)); + Assert.Equal(1f, Valid(buffer, 1)); + Assert.Equal(1f, PrevX(buffer, 1)); + } + finally + { + OptimumEntityMotion.Enabled = false; + } + } + + /// + /// A device that was not drawn in the previous frame - the chunk was out of + /// range, the network was rebuilt, the block was just placed - has no previous + /// position in this camera's space, so it must not be given one two frames old. + /// + [Fact] + public void ADeviceThatMissedAFrameGetsNoHistory() + { + OptimumEntityMotion.Enabled = true; + try + { + var buffer = new float[OptimumInstanceMotion.InstanceFloats]; + var device = new object(); + + AdvanceFrame(); + AdvanceFrame(); + Write(buffer, 0, device, 1f); + + AdvanceFrame(); // drawn nowhere this frame + AdvanceFrame(); + Write(buffer, 0, device, 3f); + + Assert.Equal(0f, Valid(buffer, 0)); + Assert.Equal(1f, Reactive(buffer, 0)); + Assert.Equal(3f, PrevX(buffer, 0)); + } + finally + { + OptimumEntityMotion.Enabled = false; + } + } + + /// + /// The same device drawn twice into the same buffer in one frame - which the + /// pulverizer does for its two pounders - still compares against the frame + /// before, not against its own first write. + /// + [Fact] + public void TwoWritesInOneFrameBothCompareAgainstTheFrameBefore() + { + OptimumEntityMotion.Enabled = true; + try + { + var buffer = new float[2 * OptimumInstanceMotion.InstanceFloats]; + var device = new object(); + + AdvanceFrame(); + AdvanceFrame(); + Write(buffer, 0, device, 1f); + + AdvanceFrame(); + Write(buffer, 0, device, 2f); + Write(buffer, 1, device, 3f); + + Assert.Equal(1f, PrevX(buffer, 0)); + Assert.Equal(1f, PrevX(buffer, 1)); + } + finally + { + OptimumEntityMotion.Enabled = false; + } + } + + /// + /// With TAA off the writers are not compiled into the shaders at all, so the + /// store does no bookkeeping - the instance still gets a well-formed previous + /// transform and a zero validity flag, which is what the unused attributes + /// carry. + /// + [Fact] + public void WithTheWriterDisabledNoHistoryIsKept() + { + OptimumEntityMotion.Enabled = false; + + var buffer = new float[OptimumInstanceMotion.InstanceFloats]; + var device = new object(); + + AdvanceFrame(); + AdvanceFrame(); + Write(buffer, 0, device, 1f); + AdvanceFrame(); + Write(buffer, 0, device, 2f); + + Assert.Equal(0f, Valid(buffer, 0)); + Assert.Equal(2f, PrevX(buffer, 0)); + } + + /// + /// A slot beyond the buffer is dropped rather than throwing: the renderers + /// size their buffers for a fixed number of devices and a network larger than + /// that would otherwise take the client down. + /// + [Fact] + public void AnInstanceBeyondTheBufferIsDropped() + { + var buffer = new float[OptimumInstanceMotion.InstanceFloats]; + OptimumInstanceMotion.NoteDevice(new object()); + OptimumInstanceMotion.WriteInstance(buffer, 1, new Vec4f(1, 1, 1, 1), Transform(1f)); + + foreach (float value in buffer) Assert.Equal(0f, value); + } +} diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs.patch new file mode 100644 index 00000000..2bb5df4e --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs.patch @@ -0,0 +1,41 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs +index 360d667..47f312f 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs +@@ -34,20 +34,13 @@ namespace Vintagestory.GameContent.Mechanics + } + } + + //blockMesh.Rgba2 = null; + +- // 16 floats matrix, 4 floats light rgbs +- blockMesh.CustomFloats = matrixAndLightFloats = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- blockMesh.CustomFloats.SetAllocationSize((16 + 4) * 10100); ++ // 16 floats matrix, 4 floats light rgbs, and the TAA writer's previous ++ // transform and metadata beside them (Optimum P3, OptimumInstanceMotion). ++ blockMesh.CustomFloats = matrixAndLightFloats = OptimumInstanceMotion.CreateInstanceFloats(10100); + + this.blockMeshRef = capi.Render.UploadMesh(blockMesh); + } + + +@@ -67,11 +60,11 @@ namespace Vintagestory.GameContent.Mechanics + { + UpdateCustomFloatBuffer(); + + if (quantityBlocks > 0) + { +- matrixAndLightFloats.Count = quantityBlocks * 20; ++ matrixAndLightFloats.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats; + capi.Render.UpdateMesh(blockMeshRef, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef, quantityBlocks); + } + } diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs.patch new file mode 100644 index 00000000..83faaee4 --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs.patch @@ -0,0 +1,43 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs +index f6b801e..723f9a4 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs +@@ -21,20 +21,13 @@ namespace Vintagestory.GameContent.Mechanics + + capi.Tesselator.TesselateShape(textureSoureBlock, API.Common.Shape.TryGet(capi, "shapes/block/wood/mechanics/angledgearbox-cage.json"), out MeshData gearboxCageMesh, rot); + capi.Tesselator.TesselateShape(textureSoureBlock, API.Common.Shape.TryGet(capi, "shapes/block/wood/mechanics/angledgearbox-peg.json"), out MeshData gearboxPegMesh, rot); + + +- // 16 floats matrix, 4 floats light rgbs +- gearboxPegMesh.CustomFloats = floatsPeg = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- gearboxPegMesh.CustomFloats.SetAllocationSize((16 + 4) * 10100); ++ // 16 floats matrix, 4 floats light rgbs, and the TAA writer's previous ++ // transform and metadata beside them (Optimum P3, OptimumInstanceMotion). ++ gearboxPegMesh.CustomFloats = floatsPeg = OptimumInstanceMotion.CreateInstanceFloats(10100); + + gearboxCageMesh.CustomFloats = floatsCage = floatsPeg.Clone(); + + this.gearboxPeg = capi.Render.UploadMesh(gearboxPegMesh); + this.gearboxCage = capi.Render.UploadMesh(gearboxCageMesh); +@@ -71,12 +64,12 @@ namespace Vintagestory.GameContent.Mechanics + { + UpdateCustomFloatBuffer(); + + if (quantityBlocks > 0) + { +- floatsPeg.Count = quantityBlocks * 20; +- floatsCage.Count = quantityBlocks * 20; ++ floatsPeg.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; ++ floatsCage.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + + updateMesh.CustomFloats = floatsPeg; + capi.Render.UpdateMesh(gearboxPeg, updateMesh); + + updateMesh.CustomFloats = floatsCage; diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs.patch new file mode 100644 index 00000000..f8c6197f --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs.patch @@ -0,0 +1,78 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs +index 1403c82..a3a30e3 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs +@@ -28,29 +28,14 @@ namespace Vintagestory.GameContent.Mechanics + capi.Tesselator.TesselateShape(textureSoureBlock, ovshape, out MeshData blockMesh2, rot); + + //blockMesh1.Rgba2 = null; + //blockMesh2.Rgba2 = null; + +- // 16 floats matrix, 4 floats light rgbs +- blockMesh1.CustomFloats = matrixAndLightFloats1 = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- blockMesh1.CustomFloats.SetAllocationSize((16 + 4) * 10100); +- blockMesh2.CustomFloats = matrixAndLightFloats2 = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- blockMesh2.CustomFloats.SetAllocationSize((16 + 4) * 10100); ++ // 16 floats matrix, 4 floats light rgbs, and the TAA writer's previous ++ // transform and metadata beside them (Optimum P3, OptimumInstanceMotion). ++ blockMesh1.CustomFloats = matrixAndLightFloats1 = OptimumInstanceMotion.CreateInstanceFloats(10100); ++ blockMesh2.CustomFloats = matrixAndLightFloats2 = OptimumInstanceMotion.CreateInstanceFloats(10100); + + this.blockMeshRef1 = capi.Render.UploadMesh(blockMesh1); + this.blockMeshRef2 = capi.Render.UploadMesh(blockMesh2); + } + +@@ -95,34 +80,27 @@ namespace Vintagestory.GameContent.Mechanics + Mat4f.MulQuat(tmpMat, quat); + + Mat4f.Translate(tmpMat, tmpMat, -axis.X, -axis.Y, -axis.Z); + //if (xtraTransform != null) Mat4f.Mul(tmpMat, tmpMat, xtraTransform); + +- int j = index * 20; +- values[j] = lightRgba.R; +- values[++j] = lightRgba.G; +- values[++j] = lightRgba.B; +- values[++j] = lightRgba.A; +- +- for (int i = 0; i < 16; i++) +- { +- values[++j] = tmpMat[i]; +- } ++ // Optimum TAA (P3): light and this frame's transform as vanilla wrote them, ++ // plus the same device's previous transform for the motion writer. ++ OptimumInstanceMotion.WriteInstance(values, index, lightRgba, tmpMat); + return tmpMat; + } + + public override void OnRenderFrame(float deltaTime, IShaderProgram prog) + { + UpdateCustomFloatBuffer(); + + if (quantityBlocks > 0) + { +- matrixAndLightFloats1.Count = quantityBlocks * 20; ++ matrixAndLightFloats1.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats1; + capi.Render.UpdateMesh(blockMeshRef1, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef1, quantityBlocks); +- matrixAndLightFloats2.Count = quantityBlocks * 20; ++ matrixAndLightFloats2.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats2; + capi.Render.UpdateMesh(blockMeshRef2, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef2, quantityBlocks); + } + } diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs.patch new file mode 100644 index 00000000..e2480f49 --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs.patch @@ -0,0 +1,103 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs +index c72a8ba..89fa069 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs +@@ -41,12 +41,13 @@ namespace Vintagestory.GameContent.Mechanics + //blockMesh1.Rgba2 = null; + //blockMesh2.Rgba2 = null; + //blockMesh3.Rgba2 = null; + //blockMesh4.Rgba2 = null; + +- int count = (16 + 4) * 2100; +- // 16 floats matrix, 4 floats light rgbs ++ // 16 floats matrix, 4 floats light rgbs, plus the TAA writer's previous ++ // transform and metadata (Optimum P3, OptimumInstanceMotion). ++ int count = 2100; + blockMesh1.CustomFloats = matrixAndLightFloats1 = createCustomFloats(count); + blockMesh2.CustomFloats = matrixAndLightFloats2 = createCustomFloats(count); + blockMesh3.CustomFloats = matrixAndLightFloats3 = createCustomFloats(count); + blockMesh4.CustomFloats = matrixAndLightFloats4 = createCustomFloats(count); + matrixAndLightFloats5 = createCustomFloats(count); +@@ -55,22 +56,15 @@ namespace Vintagestory.GameContent.Mechanics + this.blockMeshRef2 = capi.Render.UploadMesh(blockMesh2); + this.blockMeshRef3 = capi.Render.UploadMesh(blockMesh3); + this.blockMeshRef4 = capi.Render.UploadMesh(blockMesh4); + } + +- private CustomMeshDataPartFloat createCustomFloats(int count) ++ // Optimum TAA (P3): the instance layout is OptimumInstanceMotion's, which adds ++ // the previous transform and the TAA metadata to vanilla's light and transform. ++ private CustomMeshDataPartFloat createCustomFloats(int instanceCapacity) + { +- CustomMeshDataPartFloat result = new CustomMeshDataPartFloat(count) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- result.SetAllocationSize(count); +- return result; ++ return OptimumInstanceMotion.CreateInstanceFloats(instanceCapacity); + } + + protected override void UpdateLightAndTransformMatrix(int index, Vec3f distToCamera, float rotation, IMechanicalPowerRenderable dev) + { + float rot1 = dev.AngleRad; +@@ -154,47 +148,40 @@ namespace Vintagestory.GameContent.Mechanics + + Mat4f.MulQuat(tmpMat, quat); + + Mat4f.Translate(tmpMat, tmpMat, -axis.X, -axis.Y, -axis.Z); + +- int j = index * 20; +- values[j] = lightRgba.R; +- values[++j] = lightRgba.G; +- values[++j] = lightRgba.B; +- values[++j] = lightRgba.A; +- +- for (int i = 0; i < 16; i++) +- { +- values[++j] = tmpMat[i]; +- } ++ // Optimum TAA (P3): light and this frame's transform as vanilla wrote them, ++ // plus the same device's previous transform for the motion writer. ++ OptimumInstanceMotion.WriteInstance(values, index, lightRgba, tmpMat); + } + + public override void OnRenderFrame(float deltaTime, IShaderProgram prog) + { + UpdateCustomFloatBuffer(); + + if (quantityBlocks > 0) + { +- matrixAndLightFloats1.Count = quantityBlocks * 20; ++ matrixAndLightFloats1.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats1; + capi.Render.UpdateMesh(blockMeshRef1, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef1, quantityBlocks); +- matrixAndLightFloats2.Count = quantityBlocks * 20; ++ matrixAndLightFloats2.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats2; + capi.Render.UpdateMesh(blockMeshRef2, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef2, quantityBlocks); +- matrixAndLightFloats3.Count = quantityBlocks * 20; ++ matrixAndLightFloats3.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats3; + capi.Render.UpdateMesh(blockMeshRef3, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef3, quantityBlocks); + + //Sub elements 4 and 5 are the two spinbar balls, each has the exact same mesh (blockMeshRef4) but a different transform +- matrixAndLightFloats4.Count = quantityBlocks * 20; ++ matrixAndLightFloats4.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats4; + capi.Render.UpdateMesh(blockMeshRef4, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef4, quantityBlocks); +- matrixAndLightFloats5.Count = quantityBlocks * 20; ++ matrixAndLightFloats5.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats5; + capi.Render.UpdateMesh(blockMeshRef4, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef4, quantityBlocks); + } + } diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs.patch new file mode 100644 index 00000000..73a8a6c5 --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs.patch @@ -0,0 +1,41 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs +index e227341..5144315 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs +@@ -31,20 +31,13 @@ namespace Vintagestory.GameContent.Mechanics + capi.Tesselator.TesselateShape(textureSoureBlock, ovshape, out MeshData overlayMesh, rot); + blockMesh.AddMeshData(overlayMesh); + } + } + +- // 16 floats matrix, 4 floats light rgbs +- blockMesh.CustomFloats = matrixAndLightFloats = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- blockMesh.CustomFloats.SetAllocationSize((16 + 4) * 10100); ++ // 16 floats matrix, 4 floats light rgbs, and the TAA writer's previous ++ // transform and metadata beside them (Optimum P3, OptimumInstanceMotion). ++ blockMesh.CustomFloats = matrixAndLightFloats = OptimumInstanceMotion.CreateInstanceFloats(10100); + + this.blockMeshRef = capi.Render.UploadMesh(blockMesh); + } + + +@@ -66,11 +59,11 @@ namespace Vintagestory.GameContent.Mechanics + { + UpdateCustomFloatBuffer(); + + if (quantityBlocks > 0) + { +- matrixAndLightFloats.Count = quantityBlocks * 20; ++ matrixAndLightFloats.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats; + capi.Render.UpdateMesh(blockMeshRef, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef, quantityBlocks); + } + } diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs.patch new file mode 100644 index 00000000..43058409 --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs.patch @@ -0,0 +1,44 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs +index f092853..530ef36 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs +@@ -51,10 +51,15 @@ namespace Vintagestory.GameContent.Mechanics + { + //double precision int-double subtraction is needed here (even though the desired result is a float). + // It's needed to have enough significant figures in the result, as the integer size could be large e.g. 50000 but the difference should be small (can easily be less than 5) + tmp.Set((float)(dev.Position.X - pos.X), (float)(dev.Position.InternalY - pos.Y), (float)(dev.Position.Z - pos.Z)); + ++ // Optimum TAA (P3): name the device whose instances are written next, so ++ // the previous transforms below are matched to the same gear rather than ++ // to whatever ended up in the same buffer slot last frame. ++ OptimumInstanceMotion.NoteDevice(dev); ++ + UpdateLightAndTransformMatrix(i, tmp, dev.AngleRad % GameMath.TWOPI, dev); + i++; + } + } + +@@ -76,20 +81,13 @@ namespace Vintagestory.GameContent.Mechanics + + Mat4f.MulQuat(tmpMat, quat); + + Mat4f.Translate(tmpMat, tmpMat, -0.5f, -0.5f, -0.5f); + +- int j = index * 20; +- values[j] = lightRgba.R; +- values[++j] = lightRgba.G; +- values[++j] = lightRgba.B; +- values[++j] = lightRgba.A; +- +- for (int i = 0; i < 16; i++) +- { +- values[++j] = tmpMat[i]; +- } ++ // Optimum TAA (P3): writes the light and this frame's transform exactly as ++ // vanilla did, and the same device's previous transform beside it. ++ OptimumInstanceMotion.WriteInstance(values, index, lightRgba, tmpMat); + } + + + public virtual void OnRenderFrame(float deltaTime, IShaderProgram prog) + { diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs.patch new file mode 100644 index 00000000..048c6d4b --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs.patch @@ -0,0 +1,30 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs +index 0056eb6..954ea99 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs +@@ -98,15 +98,25 @@ namespace Vintagestory.GameContent.Mechanics + prog.Uniform("fogMinIn", capi.Render.FogMin); + prog.Uniform("fogDensityIn", capi.Render.FogDensity); + prog.UniformMatrix("projectionMatrix", capi.Render.CurrentProjectionMatrix); + prog.UniformMatrix("modelViewMatrix", capi.Render.CameraMatrixOriginf); + ++ // Optimum TAA (P3): the previous camera for the motion writer, then the ++ // draw-buffer window that lets these draws into Primary's motion ++ // attachment. The window stays around this loop only - every draw inside ++ // it is the instanced program, which writes the attachment; a draw that ++ // does not write it would leave the previous surface's vector standing. ++ OptimumInstanceMotion.ApplyPassUniforms(prog); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ + for (int i = 0; i < MechBlockRenderer.Count; i++) + { + MechBlockRenderer[i].OnRenderFrame(deltaTime, prog); + } + ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ + prog.Stop(); + } + else + { + // TODO: Needs a custom shadow map shader diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs.patch new file mode 100644 index 00000000..5f31ab8e --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs.patch @@ -0,0 +1,106 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs +index b1d6807..402a564 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs +@@ -45,12 +45,13 @@ namespace Vintagestory.GameContent.Mechanics + string metal; + + public PulverizerRenderer(ICoreClientAPI capi, MechanicalPowerMod mechanicalPowerMod, Block textureSoureBlock, CompositeShape shapeLoc) : base(capi, mechanicalPowerMod) + { + +- // 16 floats matrix, 4 floats light rgbs +- int count = (16 + 4) * 200; ++ // 16 floats matrix, 4 floats light rgbs, plus the TAA writer's previous ++ // transform and metadata (Optimum P3, OptimumInstanceMotion). ++ int count = 200; + + + AssetLocation loc = new AssetLocation("shapes/block/wood/mechanics/pulverizer-moving.json"); + Shape shape = API.Common.Shape.TryGet(capi, loc); + Vec3f rot = new Vec3f(shapeLoc.rotateX, shapeLoc.rotateY + 90F, shapeLoc.rotateZ); +@@ -84,22 +85,15 @@ namespace Vintagestory.GameContent.Mechanics + rPounderMeshrefs[i] = capi.Render.UploadMesh(rPounderMesh); + } + + } + +- private CustomMeshDataPartFloat createCustomFloats(int count) ++ // Optimum TAA (P3): the instance layout is OptimumInstanceMotion's, which adds ++ // the previous transform and the TAA metadata to vanilla's light and transform. ++ private CustomMeshDataPartFloat createCustomFloats(int instanceCapacity) + { +- CustomMeshDataPartFloat result = new CustomMeshDataPartFloat(count) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- result.SetAllocationSize(count); +- return result; ++ return OptimumInstanceMotion.CreateInstanceFloats(instanceCapacity); + } + + protected override void UpdateLightAndTransformMatrix(int index, Vec3f distToCamera, float rotation, IMechanicalPowerRenderable dev) + { + BEBehaviorMPPulverizer bhpu = dev as BEBehaviorMPPulverizer; +@@ -203,20 +197,13 @@ namespace Vintagestory.GameContent.Mechanics + + Mat4f.MulQuat(tmpMat, quat); + + Mat4f.Translate(tmpMat, tmpMat, -axis.X, -axis.Y, -axis.Z); + +- int j = index * 20; +- values[j] = lightRgba.R; +- values[++j] = lightRgba.G; +- values[++j] = lightRgba.B; +- values[++j] = lightRgba.A; +- +- for (int i = 0; i < 16; i++) +- { +- values[++j] = tmpMat[i]; +- } ++ // Optimum TAA (P3): light and this frame's transform as vanilla wrote them, ++ // plus the same device's previous transform for the motion writer. ++ OptimumInstanceMotion.WriteInstance(values, index, lightRgba, tmpMat); + } + + public override void OnRenderFrame(float deltaTime, IShaderProgram prog) + { + quantityAxles = 0; +@@ -229,11 +216,11 @@ namespace Vintagestory.GameContent.Mechanics + UpdateCustomFloatBuffer(); + + // axles + if (quantityAxles > 0) + { +- matrixAndLightFloatsAxle.Count = quantityAxles * 20; ++ matrixAndLightFloatsAxle.Count = quantityAxles * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloatsAxle; + capi.Render.UpdateMesh(toggleMeshref, updateMesh); + capi.Render.RenderMeshInstanced(toggleMeshref, quantityAxles); + } + +@@ -244,19 +231,19 @@ namespace Vintagestory.GameContent.Mechanics + int qLpounder = quantityLPounders[i]; + int qRpounder = quantityRPounders[i]; + + if (qLpounder > 0) + { +- matrixAndLightFloatsLPounder[i].Count = qLpounder * 20; ++ matrixAndLightFloatsLPounder[i].Count = qLpounder * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloatsLPounder[i]; + capi.Render.UpdateMesh(lPoundMeshrefs[i], updateMesh); + capi.Render.RenderMeshInstanced(lPoundMeshrefs[i], qLpounder); + } + + if (qRpounder > 0) + { +- matrixAndLightFloatsRPounder[i].Count = qRpounder * 20; ++ matrixAndLightFloatsRPounder[i].Count = qRpounder * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloatsRPounder[i]; + capi.Render.UpdateMesh(rPounderMeshrefs[i], updateMesh); + capi.Render.RenderMeshInstanced(rPounderMeshrefs[i], qRpounder); + } + } diff --git a/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs.patch b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs.patch new file mode 100644 index 00000000..920c63f4 --- /dev/null +++ b/patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs.patch @@ -0,0 +1,56 @@ +diff --git a/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs b/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs +index 0838be1..f6c6932 100644 +--- a/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs ++++ b/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs +@@ -30,29 +30,14 @@ namespace Vintagestory.GameContent.Mechanics + capi.Tesselator.TesselateShape(textureSoureBlock, ovshape, out MeshData blockMesh2, rot); + + //blockMesh1.Rgba2 = null; + //blockMesh2.Rgba2 = null; + +- // 16 floats matrix, 4 floats light rgbs +- blockMesh1.CustomFloats = matrixAndLightFloats1 = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- blockMesh1.CustomFloats.SetAllocationSize((16 + 4) * 10100); +- blockMesh2.CustomFloats = matrixAndLightFloats2 = new CustomMeshDataPartFloat((16 + 4) * 10100) +- { +- Instanced = true, +- InterleaveOffsets = new int[] { 0, 16, 32, 48, 64 }, +- InterleaveSizes = new int[] { 4, 4, 4, 4, 4 }, +- InterleaveStride = 16 + 4 * 16, +- StaticDraw = false, +- }; +- blockMesh2.CustomFloats.SetAllocationSize((16 + 4) * 10100); ++ // 16 floats matrix, 4 floats light rgbs, and the TAA writer's previous ++ // transform and metadata beside them (Optimum P3, OptimumInstanceMotion). ++ blockMesh1.CustomFloats = matrixAndLightFloats1 = OptimumInstanceMotion.CreateInstanceFloats(10100); ++ blockMesh2.CustomFloats = matrixAndLightFloats2 = OptimumInstanceMotion.CreateInstanceFloats(10100); + + this.blockMeshRef1 = capi.Render.UploadMesh(blockMesh1); + this.blockMeshRef2 = capi.Render.UploadMesh(blockMesh2); + } + +@@ -79,15 +64,15 @@ namespace Vintagestory.GameContent.Mechanics + { + UpdateCustomFloatBuffer(); + + if (quantityBlocks > 0) + { +- matrixAndLightFloats1.Count = quantityBlocks * 20; ++ matrixAndLightFloats1.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats1; + capi.Render.UpdateMesh(blockMeshRef1, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef1, quantityBlocks); +- matrixAndLightFloats2.Count = quantityBlocks * 20; ++ matrixAndLightFloats2.Count = quantityBlocks * OptimumInstanceMotion.InstanceFloats; + updateMesh.CustomFloats = matrixAndLightFloats2; + capi.Render.UpdateMesh(blockMeshRef2, updateMesh); + capi.Render.RenderMeshInstanced(blockMeshRef2, quantityBlocks); + } + } diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs index 89e4b5f8..fdab2909 100644 --- a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -848,6 +848,210 @@ public static bool Apply(IShaderProgram program, object identity, object shape, } } + /// + /// Per-instance previous transforms for the instanced motion writer (TAA P3). + /// + /// The mechanical-power renderers issue one draw per block shape and hand the + /// GPU a per-instance mat4 transform, rebuilt from scratch every frame + /// for every device in view. Slot order is the enumeration order of a + /// dictionary that gains and loses entries as blocks are placed, broken and + /// streamed in, so "the matrix that was in slot 3 last frame" is not this + /// instance's previous transform - it is some other gear's. History is + /// therefore keyed on the device object itself, per instance buffer, and a + /// device that was not drawn into that same buffer in the previous frame gets + /// no history at all (camera-only motion plus reactive 1) instead of another + /// block's matrix. + /// + /// The previous transform travels to the shader the same way the current one + /// does: as instance attributes in the very same interleaved buffer, four + /// vec4s for the matrix and one vec4 of metadata. That is why the layout lives + /// here rather than in each renderer - it is a contract with instanced.vsh, + /// not a renderer detail. A mesh built without it (the cloth renderer shares + /// the instanced program) reads the default attribute (0,0,0,1), whose x = 0 + /// is exactly "no history", on both backends. + /// + /// Render thread only. + /// + public static class OptimumInstanceMotion + { + /// Floats per instance: light rgba, transform, previous transform, TAA metadata. + public const int InstanceFloats = 4 + 16 + 16 + 4; + + /// Where the light rgba starts within an instance, in floats. + public const int LightOffset = 0; + + /// Where the current transform starts within an instance, in floats. + public const int TransformOffset = 4; + + /// Where the previous transform starts within an instance, in floats. + public const int PrevTransformOffset = 20; + + /// Where the TAA metadata vec4 starts within an instance, in floats. + public const int MetaOffset = 36; + + private sealed class Entry + { + public float[] Prev = new float[16]; + public float[] Cur = new float[16]; + public EnumTemporalView PrevView; + public EnumTemporalView CurView; + public long CapturedFrame = -1; + public long PreviousFrame = -1; + } + + // Keyed on the instance buffer first, so a renderer that fills several + // buffers for one device (the creative rotor's five sub-meshes, the + // pulverizer's axle and pounders) keeps one history per sub-mesh; and on + // the device object second, so it dies with the block entity behaviour. + private static readonly ConditionalWeakTable> buffers = + new ConditionalWeakTable>(); + + private static object currentDevice; + + /// + /// The instance-buffer layout instanced.vsh expects: rgbaLightIn at + /// location 4, transform at 5..8, prevTransform at 9..12 and the TAA + /// metadata at 13. Allocated whether or not TAA is on, because the shaders + /// are recompiled when TAA is toggled but the instance buffers are not. + /// + /// How many instances the buffer must hold. + public static CustomMeshDataPartFloat CreateInstanceFloats(int instanceCapacity) + { + int floats = InstanceFloats * instanceCapacity; + CustomMeshDataPartFloat part = new CustomMeshDataPartFloat(floats) + { + Instanced = true, + InterleaveOffsets = new int[] { 0, 16, 32, 48, 64, 80, 96, 112, 128, 144 }, + InterleaveSizes = new int[] { 4, 4, 4, 4, 4, 4, 4, 4, 4, 4 }, + InterleaveStride = InstanceFloats * 4, + StaticDraw = false + }; + part.SetAllocationSize(floats); + return part; + } + + /// + /// Names the device whose instances are written next. Called once per + /// device per frame by the renderer's buffer fill, before the transforms + /// for that device reach any buffer. + /// + public static void NoteDevice(object device) + { + currentDevice = device; + } + + /// + /// Writes one instance: the light colour and this frame's transform as + /// vanilla did, plus the same device's transform from the previous frame + /// and the metadata that tells the shader whether to believe it. + /// + /// The instance buffer being filled. + /// The instance slot in that buffer. + /// The instance's light colour. + /// This frame's 16-float transform. + public static void WriteInstance(float[] values, int index, Vec4f lightRgba, float[] transform) + { + if (values == null || transform == null || index < 0) return; + + int j = index * InstanceFloats; + if (j + InstanceFloats > values.Length) return; + + values[j + LightOffset] = lightRgba.R; + values[j + LightOffset + 1] = lightRgba.G; + values[j + LightOffset + 2] = lightRgba.B; + values[j + LightOffset + 3] = lightRgba.A; + + for (int i = 0; i < 16; i++) + { + values[j + TransformOffset + i] = transform[i]; + } + + bool valid = false; + object device = currentDevice; + + if (OptimumEntityMotion.Enabled && device != null) + { + OptimumTemporalFrame frame = OptimumTemporal.Frame; + EnumTemporalView view = frame.ActiveView; + ConditionalWeakTable entries = + buffers.GetValue(values, _ => new ConditionalWeakTable()); + Entry entry = entries.GetValue(device, _ => new Entry()); + + // One roll per frame, not per written instance: a device that + // writes twice into the same buffer in one frame must both times + // compare against the frame before, not against its own first write. + if (entry.CapturedFrame != frame.FrameIndex) + { + float[] swap = entry.Prev; + entry.Prev = entry.Cur; + entry.Cur = swap; + entry.PrevView = entry.CurView; + entry.PreviousFrame = entry.CapturedFrame; + entry.CapturedFrame = frame.FrameIndex; + } + + for (int i = 0; i < 16; i++) + { + entry.Cur[i] = transform[i]; + } + entry.CurView = view; + + // The transform is camera-relative, so the previous one only means + // anything together with the previous camera: the same device, drawn + // into the same buffer, in the frame immediately before, under the + // same view, in a frame that did not reset. + valid = + !frame.Reset && + entry.PreviousFrame == frame.FrameIndex - 1 && + entry.PrevView == view && + frame.WasViewCaptured(view); + + float[] previous = valid ? entry.Prev : entry.Cur; + for (int i = 0; i < 16; i++) + { + values[j + PrevTransformOffset + i] = previous[i]; + } + } + else + { + for (int i = 0; i < 16; i++) + { + values[j + PrevTransformOffset + i] = transform[i]; + } + } + + // x selects the branch in the vertex shader, y is the reactive value the + // fragment writer stamps: a new or reordered instance has no history, so + // the resolve is told to lean on this frame. + values[j + MetaOffset] = valid ? 1f : 0f; + values[j + MetaOffset + 1] = valid ? 0f : 1f; + values[j + MetaOffset + 2] = 0f; + values[j + MetaOffset + 3] = 0f; + } + + /// + /// Sets the per-pass half of the writer's uniforms: the previous camera and + /// the shared warp/jitter/render-size block. Called once per frame by the + /// renderer that owns the instanced program, after it has set this frame's + /// projection and model-view matrices. + /// + public static void ApplyPassUniforms(IShaderProgram program) + { + if (!OptimumEntityMotion.Enabled || program == null) return; + if (!program.HasUniform("prevProjectionMatrix")) return; + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + EnumTemporalView view = frame.ActiveView; + + program.UniformMatrix("prevProjectionMatrix", frame.GetPrevProjection(view)); + if (program.HasUniform("prevModelViewMatrix")) + { + program.UniformMatrix("prevModelViewMatrix", frame.PrevCameraMatrixOrigin); + } + frame.ApplyMotionUniforms(program); + } + } + /// /// The process-wide holder of the temporal frame contract. Static because the /// producers are scattered across the render loop, the platform layer and the diff --git a/sources/shaders/instanced.fsh b/sources/shaders/instanced.fsh new file mode 100644 index 00000000..bf6c3efa --- /dev/null +++ b/sources/shaders/instanced.fsh @@ -0,0 +1,77 @@ +#version 330 core +// Optimum override of the vanilla instanced.fsh: writes the TAA motion +// attachment for the instanced mechanical-power renderers (P3). Everything else +// is vanilla, line for line. +// +// instanced has no ALLOWDEPTHOFFSET variant, so the writer depth is plain +// gl_FragCoord.z - the same space the resolve compares against the depth buffer. +#extension GL_ARB_explicit_attrib_location: enable + +in vec4 color; +in vec2 uv; +in vec4 rgbaFog; +in float fogAmount; +in float glowLevel; +flat in int renderFlags; +in vec3 normal; +in vec4 worldPos; +in float normalShadeIntensity; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if SSAOLEVEL > 0 +in vec4 fragPosition; +in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + + +uniform sampler2D tex; +uniform float alphaTest = 0.1; + +#if TAAMOTION > 0 +in vec4 taaPrevClip; +in float taaInstanceReactive; +uniform vec2 taaRenderSize; +uniform vec2 taaJitterPx; +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; + +vec4 taaMotionVector(float reactive) +{ + if (taaPrevClip.w <= 1e-6) return vec4(0.0); + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z); +} +#endif + +#include fogandlight.fsh + +void main () { + outColor = texture(tex, uv) * color; + if (outColor.a < alphaTest) discard; + + outColor = applyFogAndShadowWithNormal(outColor, fogAmount, normal, 1, 0.45, worldPos.xyz); + + //outColor = vec4((normal.x + 0.5) / 2, (normal.y + 0.5)/2, (normal.z+0.5)/2, 1); + + outGlow = vec4(glowLevel, 0, 0, outColor.a); + +#if SSAOLEVEL > 0 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = gnormal; +#endif + +#if NORMALVIEW > 0 + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); +#endif + +#if TAAMOTION > 0 + // Mechanical blocks are opaque and not reactive on their own; the C# side + // stamps reactive 1 per instance when its history was unusable, where the + // vector above is camera-only and the history must not be trusted. + outMotion = taaMotionVector(taaInstanceReactive); +#endif + +} \ No newline at end of file diff --git a/sources/shaders/instanced.vsh b/sources/shaders/instanced.vsh new file mode 100644 index 00000000..c7468517 --- /dev/null +++ b/sources/shaders/instanced.vsh @@ -0,0 +1,114 @@ +#version 330 core +// Optimum override of the vanilla instanced.vsh: adds the TAA motion-vector +// writer for the instanced mechanical-power renderers (P3) - axles, gears, +// clutches, transmissions, creative rotors and pulverizers. Everything else is +// vanilla, line for line. +// +// An instanced draw has no per-object uniforms to hang a previous transform off: +// every instance carries its own camera-relative mat4, rebuilt each frame, and +// the slot order changes as devices are added and removed. The previous +// transform therefore arrives the same way the current one does, as instance +// attributes in the same interleaved buffer (see OptimumInstanceMotion), with a +// metadata vec4 whose x says whether the C# side could match this instance to +// the same device in the previous frame. Without that match the vertex falls +// back to camera-only motion and the metadata's y raises reactive, so the +// resolve leans on this frame instead of reprojecting by another gear's matrix. +#extension GL_ARB_explicit_attrib_location: enable + +layout(location = 0) in vec3 vertexPosition; // Per vertex +layout(location = 1) in vec2 uvIn; // Per vertex +layout(location = 2) in vec4 rgbaBlockIn; // Per vertex (rgb = block light, a=sun light level) +layout(location = 3) in int renderFlagsIn; // Per vertex + +layout(location = 4) in vec4 rgbaLightIn; // Per instance +layout(location = 5) in mat4 transform; // Per instance + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +layout(location = 9) in mat4 prevTransform; // Per instance: this device's transform last frame +layout(location = 13) in vec4 taaInstanceMeta; // Per instance: x = history usable, y = reactive +#endif + +uniform vec4 rgbaFogIn; +uniform vec3 rgbaAmbientIn; +uniform float fogMinIn; +uniform float fogDensityIn; +uniform mat4 projectionMatrix; +uniform mat4 modelViewMatrix; + +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED projection for this draw's view +uniform mat4 prevModelViewMatrix; // previous frame's CameraMatrixOrigin (the view instanced draws use) +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +#endif + +out vec4 color; +out vec2 uv; +out vec4 rgbaFog; +out float fogAmount; +out vec3 normal; +out vec4 worldPos; + +#if SSAOLEVEL > 0 +out vec4 fragPosition; +out vec4 gnormal; +#endif + +flat out int renderFlags; + +#if TAAMOTION > 0 +out vec4 taaPrevClip; +out float taaInstanceReactive; +#endif + +#include vertexflagbits.ash +#include shadowcoords.vsh +#include fogandlight.vsh + +void main() +{ + worldPos = transform * vec4(vertexPosition, 1.0); + vec4 cameraPos = modelViewMatrix * worldPos; + +#if TAAMOTION > 0 + // The same vertex, one frame ago. instanced.vsh applies no vertex warp and no + // w-offset, so the previous position is the previous instance transform run + // through the previous camera - nothing else has to be replayed. + { + vec4 taaPrevWorld; + if (taaInstanceMeta.x != 0.0) { + taaPrevWorld = prevTransform * vec4(vertexPosition, 1.0); + } else { + // Treat the block as static in the world: its camera-relative position + // a frame ago differed by the camera's own movement only (accuracy rule 4). + taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0); + } + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevWorld); + taaInstanceReactive = taaInstanceMeta.y; + } +#endif + + + calcShadowMapCoords(modelViewMatrix, worldPos); + + uv = uvIn; + color = applyLight(rgbaAmbientIn, rgbaLightIn * rgbaBlockIn, renderFlagsIn, cameraPos); + rgbaFog = rgbaFogIn; + + // Distance fade out + color.a = clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5, -1, 1); + gl_Position = projectionMatrix * cameraPos; + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + renderFlags = renderFlagsIn; + + normal = unpackNormal(renderFlagsIn); + normal = normalize((transform * vec4(normal.x, normal.y, normal.z, 0)).xyz); + + #if SSAOLEVEL > 0 + fragPosition = cameraPos; + gnormal = modelViewMatrix * vec4(normal.xyz, 0); + #endif +} From fa69e9d28ca7512a4d5f3b2ccff7efa57b9e8da0 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 21:19:56 +0200 Subject: [PATCH 030/226] wip(taa): P3 review fixes - camera-capture ordering, motion-window target guard, packagers, translation gate Adversarial review of P3 (5555fc8..3126098) against TAA-PLAN.md's motion-vector accuracy rules. Four real defects, each with a regression test; full finish sequence green (extract + check-patches, Release build, Optimum.Tests 853, Optimum.Render.Vulkan.Tests 282, Cecil patcher 235 members / 255 methods). 1. cameraPosDelta and playerpos were one frame stale (accuracy rules 2 and 4). ClientMain called OptimumTemporalFrame.Advance BEFORE the Before render stage, but PlayerCamera.OnBeforeRenderFrame3D - which runs inside that stage - is the only writer of EntityPlayer.CameraPos and shUniforms.PlayerPos. Advance therefore snapshotted the previous frame's position, so every writer reprojected a static surface by cam(N-1) - cam(N-2) while its previous view matrix, frozen after the stage by CaptureCamera, was the genuine cam(N-1) rotation. The two agree only at constant camera speed; every acceleration, stop and jump showed up as motion on ground that never moved, and PrevPlayerpos fed the warp replay a position two frames old. Split the capture out into OptimumTemporalFrame.CaptureCameraPosition and call it next to CaptureCamera, so translation and rotation always belong to the same frame. The teleport and rebase reset detectors move with it and now fire in the frame they happen. 2. BeginMotionWrite changed a different framebuffer on each backend. The device call names Primary's FBO; GL.DrawBuffers applies to whatever is bound, so a caller reaching it under the shadow map or Transparent would rewrite that target's draw-buffer set on GL and Primary's on Vulkan. Refuse the window unless Primary is the bound target - identical on both paths, and the caller falls back to camera reprojection, which is what a pass outside Primary has to do anyway. 3. scripts/package.ps1 (Windows) and scripts/package-macos.ps1 never copied sources/shaderincludes. Those packages shipped chunkopaque, chunktopsoil, entityanimated, standard and instanced calling WarpState overloads the vanilla vertexwarp.vsh does not declare: every one of those programs fails to compile the moment TAA is switched on. The coverage test that was meant to catch this listed three packagers by hand; it now derives the list from every scripts/ package* that overlays sources/shaders. 4. The Vulkan translation gate never saw the entity motion writer. Its writer is inside #if USEOIT == 0, which no ShaderCorpus variant produces, and the two-argument writer that stamps gl_FragCoord.z + depthOffset only exists under ALLOWDEPTHOFFSET, which only ModSystemFpHands' private copies of entityanimated and standard stamp. Added ShaderVariant.ExtraPrefix and translation tests for the six shipped configurations (opaque entity, FP hands, FP item, each with and without the SSAO G-buffer), plus a preprocessor check that the writer, the AnimationPrev block and the depth-offset alpha actually survive. All translate. Also: BeginMotionWrite/EndMotionWrite allocated two DrawBuffersEnum arrays per window, and the standard-shader windows open per draw - per held item, dropped item and quern, every frame. Both sets are built once and kept (new optimumMotionDrawBuffersOn/Off, transplanted). --- Optimum.Patcher/Program.cs | 2 + Optimum.Render.Vulkan.Tests/ShaderCorpus.cs | 17 +- .../ShaderTranslationTests.cs | 110 +++++++++++ .../taa-instanced-motion-history-tests.cs | 4 +- .../taa-terrain-motion-coverage-tests.cs | 127 +++++++++++- Optimum.Tests/temporal-frame-tests.cs | 127 +++++++++++- .../ClientMain.cs.patch | 25 ++- .../ClientPlatformWindows.cs.patch | 180 ++++++++++-------- scripts/package-macos.ps1 | 8 + scripts/package.ps1 | 11 ++ .../Client/Render/OptimumTemporalFrame.cs | 91 ++++++--- 11 files changed, 578 insertions(+), 124 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index accf1729..8b30fbe0 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -157,6 +157,8 @@ "EndMotionWrite", "ApplyOptimumMotionBlendState", "InstallOptimumMotionWriteHooks", + "optimumMotionDrawBuffersOn", + "optimumMotionDrawBuffersOff", }, // TAA P3: the uniform block a buffer feeds and the point it is bound to. // Vanilla had one block per program and Bind() hard-coded binding point 0; diff --git a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs index c9b0f145..fca7982b 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs @@ -194,6 +194,15 @@ public sealed class ShaderVariant /// Primary colour attachment the motion texture occupies: 4 with the SSAO G-buffer, 2 without. public int TaaMotionLocation = 2; + /// + /// Defines a caller put on the program itself before the engine's block, + /// the way ModSystemFpHands stamps ALLOWDEPTHOFFSET on its two private + /// copies of standard and entityanimated. Those copies are the only place + /// the gl_FragDepth writer exists, so without this the corpus never + /// translates it. + /// + public string ExtraPrefix = ""; + public override string ToString() => Name; } @@ -288,7 +297,13 @@ public static string PrefixFor(EnumShaderType stage, ShaderVariant variant) lines.Add($"#define TAAMOTIONLOCATION {variant.TaaMotionLocation}"); } - return string.Join("\r\n", lines) + "\r\n"; + string prefix = string.Join("\r\n", lines) + "\r\n"; + // The client puts the program's own PrefixCode first and appends the + // engine block to it (ShaderRegistry.registerDefaultShaderCodePrefixes + // does `PrefixCode = PrefixCode + ...`), so a caller's defines lead. + return variant.ExtraPrefix.Length > 0 + ? variant.ExtraPrefix + "\r\n" + prefix + : prefix; } /// Builds the two stages of one program, ready for translation. diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs index 1063b530..482661cf 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs @@ -71,6 +71,116 @@ public void EveryVanillaProgramTranslatesToSpirv() } } + /// + /// The corpus rows above all carry USEOIT 1 and no ALLOWDEPTHOFFSET, because + /// those are the settings every program shares. The TAA motion writers live + /// in exactly the configurations they leave out: + /// + /// - entityanimated's writer is inside `#if USEOIT == 0`, which only the + /// opaque Entityanimated registration and ModSystemFpHands' hand shader + /// produce, so the corpus has never translated the entity writer at all - + /// including its second AnimationPrev uniform block, the only place the + /// backend meets two named blocks in one program; + /// - the two-argument writer that stamps `gl_FragCoord.z + depthOffset` into + /// the motion alpha only exists when ALLOWDEPTHOFFSET is stamped, which + /// ModSystemFpHands does for its private copies of entityanimated and + /// standard - the first-person hands and the first-person item. + /// + /// Those are shipped configurations, so they belong in the translation gate. + /// + [SkippableFact] + public void MotionWritersTranslateInTheConfigurationsTheClientReallyBuilds() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + + // (program, variant) pairs the client produces with TAA on. + var cases = new List<(string Program, ShaderCorpus.ShaderVariant Variant)>(); + foreach (int ssao in new[] { 0, 2 }) + { + int location = ssao > 0 ? 4 : 2; + + cases.Add(("entityanimated", new ShaderCorpus.ShaderVariant + { + Name = $"entity-opaque-ssao{ssao}", + UseOit = 0, SsaoLevel = ssao, DynLights = 4, ShadowQuality = 2, + TaaMotion = 1, TaaMotionLocation = location, + })); + cases.Add(("entityanimated", new ShaderCorpus.ShaderVariant + { + Name = $"entity-fphands-ssao{ssao}", + UseOit = 0, SsaoLevel = ssao, DynLights = 4, ShadowQuality = 2, + TaaMotion = 1, TaaMotionLocation = location, + ExtraPrefix = "#define ALLOWDEPTHOFFSET 1", + })); + cases.Add(("standard", new ShaderCorpus.ShaderVariant + { + Name = $"standard-fpitem-ssao{ssao}", + SsaoLevel = ssao, DynLights = 4, ShadowQuality = 2, + TaaMotion = 1, TaaMotionLocation = location, + ExtraPrefix = "#define ALLOWDEPTHOFFSET 1", + })); + } + + using var compiler = new ShaderCompiler(); + var failures = new List(); + + foreach ((string program, ShaderCorpus.ShaderVariant variant) in cases) + { + var stages = ShaderCorpus.BuildProgram(program, files, includes, variant); + Assert.NotEmpty(stages); + + TranslatedProgram result = ShaderTranslator.Translate(stages, compiler); + if (!result.Success) + { + failures.Add($"[{variant.Name}] {program}: {string.Join("; ", result.Errors)}"); + continue; + } + + foreach (KeyValuePair stage in result.Spirv) + { + Assert.True(stage.Value.Length >= 20 && stage.Value.Length % 4 == 0, + $"{variant.Name} {program} {stage.Key}: malformed SPIR-V"); + Assert.Equal(0x07230203u, BitConverter.ToUInt32(stage.Value, 0)); + } + } + + Assert.True(failures.Count == 0, string.Join("\n", failures)); + } + + /// + /// The writer only means anything if it is actually in the translated source. + /// A define typo or a stray guard would leave every assertion above passing + /// on a shader that emits no motion at all. + /// + [SkippableFact] + public void TheEntityMotionWriterSurvivesThePreprocessorInTheOpaqueConfiguration() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = new ShaderCorpus.ShaderVariant + { + Name = "entity-opaque", UseOit = 0, SsaoLevel = 2, DynLights = 4, + TaaMotion = 1, TaaMotionLocation = 4, + ExtraPrefix = "#define ALLOWDEPTHOFFSET 1", + }; + + var stages = ShaderCorpus.BuildProgram("entityanimated", files, includes, variant); + + string vertex = stages.Single(s => s.Stage == EnumShaderType.VertexShader).Code; + Assert.Contains("PrevElementTransforms", vertex); + Assert.Contains("previousWarpState()", vertex); + Assert.Contains("applyVertexWarpingState", vertex); + + string fragment = stages.Single(s => s.Stage == EnumShaderType.FragmentShader).Code; + Assert.Contains("outMotion", fragment); + Assert.Contains("gl_FragCoord.z + depthOffset", fragment); + } + [SkippableFact] public void TranslatedProgramsProduceValidSpirvForEveryStage() { diff --git a/Optimum.Tests/taa-instanced-motion-history-tests.cs b/Optimum.Tests/taa-instanced-motion-history-tests.cs index 3b19f3fe..48331d67 100644 --- a/Optimum.Tests/taa-instanced-motion-history-tests.cs +++ b/Optimum.Tests/taa-instanced-motion-history-tests.cs @@ -43,7 +43,9 @@ private static float[] Transform(float x) => new[] private static void AdvanceFrame() { OptimumTemporalFrame frame = OptimumTemporal.Frame; - frame.Advance(16.6f, 1920, 1080, 1f, 0.1f, 3000f, 1.2f, new Vec3d(0, 0, 0), new DefaultShaderUniforms()); + var uniforms = new DefaultShaderUniforms(); + frame.Advance(16.6f, 1920, 1080, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(0, 0, 0), uniforms); // A view is only usable as "previous" once it has been captured, which is // what Set3DProjection does in the client. frame.RecordProjection(EnumTemporalView.World, IdentityProjection); diff --git a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs index 0124b727..284fd30c 100644 --- a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using Xunit; @@ -227,9 +228,57 @@ public void ThePlatformOpensAndClosesTheMotionDrawBufferOnBothBackends() "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", platform); - // GL path: the same two sets, built as DrawBuffers arrays. - Assert.Contains("DrawBuffersEnum[] optimumMotionDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1];", platform); - Assert.Contains("DrawBuffersEnum[] optimumRestoreDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex];", platform); + // GL path: the same two sets, as DrawBuffers arrays - built once and + // kept, not allocated per window. The narrow windows open per draw (every + // held item, dropped item and block-entity model), so allocating them + // inside would be two garbage arrays per instrumented draw per frame. + Assert.Contains("private DrawBuffersEnum[] optimumMotionDrawBuffersOn;", platform); + Assert.Contains("private DrawBuffersEnum[] optimumMotionDrawBuffersOff;", platform); + Assert.Contains("GL.DrawBuffers(optimumMotionDrawBuffersOn.Length, optimumMotionDrawBuffersOn);", platform); + Assert.Contains("GL.DrawBuffers(optimumMotionDrawBuffersOff.Length, optimumMotionDrawBuffersOff);", platform); + + // Both cached fields have to be transplanted, or the Cecil'd build has + // BeginMotionWrite referring to members the shipped type does not carry. + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"optimumMotionDrawBuffersOn\"", patcher); + Assert.Contains("\"optimumMotionDrawBuffersOff\"", patcher); + + // And nothing inside the window allocates. + int begin = platform.IndexOf("public bool BeginMotionWrite()", StringComparison.Ordinal); + int end = platform.IndexOf("private void ApplyOptimumMotionBlendState()", begin, StringComparison.Ordinal); + Assert.True(begin >= 0 && end > begin); + string window = platform.Substring(begin, end - begin); + Assert.Equal(2, Count(window, "new DrawBuffersEnum[")); + foreach (string allocation in new[] { "new float[", "new int[", "new List<" }) + { + Assert.False(window.Contains(allocation, StringComparison.Ordinal), + "the per-draw motion window must not allocate: " + allocation); + } + } + + /// + /// The two backends must not disagree about WHICH framebuffer the window + /// changes. The device call names Primary; GL.DrawBuffers applies to whatever + /// is bound, so a caller that reached BeginMotionWrite under the shadow map or + /// the Transparent target would rewrite that target's draw-buffer set on GL + /// and Primary's on Vulkan. Refusing the window unless Primary is the bound + /// target makes both paths behave the same and costs the caller only the + /// camera fallback it would have to use anyway. + /// + [Fact] + public void TheMotionWindowOnlyOpensWhilePrimaryIsTheBoundTarget() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + int begin = platform.IndexOf("public bool BeginMotionWrite()", StringComparison.Ordinal); + Assert.True(begin >= 0); + int drawBuffers = platform.IndexOf("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", begin, StringComparison.Ordinal); + Assert.True(drawBuffers > begin); + + string guards = platform.Substring(begin, drawBuffers - begin); + Assert.Contains("if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false;", guards); } /// @@ -308,6 +357,45 @@ public void TheLiquidDepthPrepassStaysJitteredAndWritesNoMotion() /// contract sets have to be the same strings; a typo on either side is a /// silent zero, which looks exactly like "the surface did not move". /// + /// + /// Where the frame contract takes the camera position decides whether the + /// terrain writer's cameraPosDelta belongs to the frame being drawn. + /// PlayerCamera.OnBeforeRenderFrame3D writes EntityPlayer.CameraPos and + /// shUniforms.PlayerPos from inside the Before render stage, which runs after + /// Advance; taking them in Advance read the previous frame's values and paired + /// a stale translation with a fresh previous rotation. Both halves belong next + /// to CaptureCamera, after the Before stage and before the Opaque one. + /// + [Fact] + public void TheCameraPositionIsCapturedAfterTheBeforeStageWroteIt() + { + string main = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + int advance = main.IndexOf("OptimumTemporal.Frame.Advance(", StringComparison.Ordinal); + int beforeStage = main.IndexOf("TriggerRenderStage(EnumRenderStage.Before, dt);", StringComparison.Ordinal); + int captureCamera = main.IndexOf("OptimumTemporal.Frame.CaptureCamera(", StringComparison.Ordinal); + int capturePos = main.IndexOf("OptimumTemporal.Frame.CaptureCameraPosition(", StringComparison.Ordinal); + + Assert.True(advance >= 0, "the frame contract is never advanced"); + Assert.True(beforeStage >= 0, "the Before render stage is not in the patched body"); + Assert.True(captureCamera >= 0, "the camera matrices are never frozen"); + Assert.True(capturePos >= 0, "the camera position is never captured"); + Assert.True(advance < beforeStage, "Advance has to run before the Before stage so the prepass sees the jitter"); + Assert.True(beforeStage < capturePos, "the camera position must be read after PlayerCamera wrote it"); + + // The rotation and the translation of the frame's camera are taken + // together, so the previous view matrix and the previous position always + // belong to the same frame. + Assert.True(Math.Abs(captureCamera - capturePos) < 700, + "the camera matrices and the camera position have to be captured together"); + + // And Advance must no longer be able to read it: the parameter is gone. + int advanceEnd = main.IndexOf(';', advance); + Assert.DoesNotContain("CameraPos", main.Substring(advance, advanceEnd - advance)); + } + [Fact] public void TheFrameContractSetsExactlyTheUniformNamesTheWritersDeclare() { @@ -375,14 +463,37 @@ public void DeployAndEveryPackagerShipTheShaderIncludes() Assert.Contains("sources/shaderincludes", Read("Makefile")); Assert.Equal(2, Count(Read("Makefile"), "assets/game/shaderincludes")); - foreach (string script in new[] + // Derived, never a hand-kept list: any script that overlays + // sources/shaders is a packaging path a user can install from, so it has + // to overlay sources/shaderincludes too. Enumerating them by hand is how + // scripts/package.ps1 (the Windows packager) and scripts/package-macos.ps1 + // were left behind in the first place, which would have shipped every + // TAA writer calling WarpState overloads the vanilla include never + // declares - a compile failure on every terrain, entity, item and + // instanced program the moment TAA is switched on. + string scriptsDirectory = Path.GetDirectoryName(PatchReader.FindRepositoryFile("scripts/package-linux.sh"))!; + var packagers = new List(); + foreach (string path in Directory.EnumerateFiles(scriptsDirectory, "package*")) + { + string text = File.ReadAllText(path); + if (!text.Contains("sources/shaders", StringComparison.Ordinal)) continue; + packagers.Add(Path.GetFileName(path)); + + Assert.True(text.Contains("sources/shaderincludes", StringComparison.Ordinal), + Path.GetFileName(path) + " overlays sources/shaders but not sources/shaderincludes"); + Assert.True(text.Contains("assets/game/shaderincludes", StringComparison.Ordinal), + Path.GetFileName(path) + " has no assets/game/shaderincludes destination"); + } + + // A guard on the guard: if the enumeration ever finds nothing, the loop + // above passes vacuously. + foreach (string expected in new[] { - "scripts/package-linux.sh", "scripts/package-macos.sh", "scripts/package-linux.ps1", + "package-linux.sh", "package-macos.sh", "package-linux.ps1", + "package-macos.ps1", "package.ps1", }) { - string text = Read(script); - Assert.Contains("sources/shaderincludes", text); - Assert.Contains("assets/game/shaderincludes", text); + Assert.Contains(expected, packagers); } } diff --git a/Optimum.Tests/temporal-frame-tests.cs b/Optimum.Tests/temporal-frame-tests.cs index d6258798..8b72e92c 100644 --- a/Optimum.Tests/temporal-frame-tests.cs +++ b/Optimum.Tests/temporal-frame-tests.cs @@ -42,9 +42,16 @@ private static DefaultShaderUniforms Uniforms(double refX = 0, double refZ = 0) }; } + /// + /// One whole frame's worth of contract updates in the order ClientMain does + /// them: Advance rotates and computes the jitter, then - once the Before + /// render stage has written the camera position and playerpos - + /// CaptureCameraPosition takes those. + /// private static void Advance(OptimumTemporalFrame frame, Vec3d cameraPos, DefaultShaderUniforms uniforms, float renderScale = 1f) { - frame.Advance(16.6f, Width, Height, renderScale, 0.1f, 3000f, 1.2f, cameraPos, uniforms); + frame.Advance(16.6f, Width, Height, renderScale, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(cameraPos, uniforms); } // --- rotation ------------------------------------------------------------ @@ -277,13 +284,129 @@ public void RenderSizeChangeIsAResizeReset() var pos = new Vec3d(0, 0, 0); Advance(frame, pos, uniforms); - frame.Advance(16.6f, 1280, 720, 1f, 0.1f, 3000f, 1.2f, pos, uniforms); + frame.Advance(16.6f, 1280, 720, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(pos, uniforms); Assert.True(frame.Reset); Assert.Equal(EnumTemporalResetReason.Resize, frame.ResetReason); Assert.Equal(1280, frame.RenderWidth); } + // --- camera position capture ordering -------------------------------------- + + /// + /// The camera position is written by PlayerCamera from inside the Before + /// render stage, which runs AFTER Advance and BEFORE the camera matrices are + /// frozen. Capturing it in Advance therefore read the previous frame's + /// position, and every terrain, entity and instanced writer reprojected a + /// static surface by cam(N-1) - cam(N-2) while its previous view matrix was + /// the genuine cam(N-1) rotation. The two agree only while the camera moves + /// at a constant speed; every acceleration showed up as motion on ground + /// that never moved. + /// + /// This drives the contract in the order ClientMain does it, with the camera + /// moving in between, and demands the delta of the frame being drawn. + /// + [Fact] + public void CameraDeltaIsTheMovementOfTheFrameBeingDrawnNotThePreviousOne() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + + // Frame 1: camera at the origin. + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(0, 64, 0), uniforms); + + // Frame 2: the camera moved one block since frame 1. + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(1, 64, 0), uniforms); + Assert.Equal(1f, frame.CameraPosDelta.X, 5); + + // Frame 3: the camera accelerated to four blocks per frame. A capture + // taken before the Before stage would still report the one block of + // frame 2 here, which is exactly the failure this guards. + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(5, 64, 0), uniforms); + Assert.Equal(4f, frame.CameraPosDelta.X, 5); + + // Frame 4: the camera stopped. Still exact, not the previous four. + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(5, 64, 0), uniforms); + Assert.Equal(0f, frame.CameraPosDelta.X, 5); + } + + /// + /// The warp noise is sampled at worldPos + playerpos, so the previous frame's + /// playerpos has to be the one the previous frame's draws actually used. + /// PlayerCamera writes it in the same Before stage as the camera position. + /// + [Fact] + public void PreviousPlayerposIsThePositionTheFrameBeforeReallyDrewWith() + { + var frame = NewFrame(); + + var first = Uniforms(); + first.PlayerPos.Set(1, 2, 3); + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, first); + frame.CaptureCameraPosition(new Vec3d(0, 0, 0), first); + Assert.Equal(1f, frame.Playerpos.X, 5); + + var second = Uniforms(); + second.PlayerPos.Set(4, 5, 6); + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, second); + frame.CaptureCameraPosition(new Vec3d(0, 0, 0), second); + + Assert.Equal(4f, frame.Playerpos.X, 5); + Assert.Equal(1f, frame.PrevPlayerpos.X, 5); + Assert.Equal(2f, frame.PrevPlayerpos.Y, 5); + Assert.Equal(3f, frame.PrevPlayerpos.Z, 5); + } + + /// + /// Capturing twice in one frame - a second render pass, a debug capture - must + /// difference against the previous frame both times, never against the first + /// call's own value, or the second call reports a zero delta. + /// + [Fact] + public void CapturingTheCameraPositionTwiceInOneFrameKeepsTheSameDelta() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(0, 0, 0), uniforms); + + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(2, 0, 0), uniforms); + Assert.Equal(2f, frame.CameraPosDelta.X, 5); + + frame.CaptureCameraPosition(new Vec3d(2, 0, 0), uniforms); + Assert.Equal(2f, frame.CameraPosDelta.X, 5); + } + + /// + /// A frame that never reaches the capture (no world, no player) must report no + /// camera movement rather than repeating the previous frame's delta, which a + /// writer would apply to geometry that did not move. + /// + [Fact] + public void AdvanceWithoutACaptureLeavesNoCameraMovement() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(0, 0, 0), uniforms); + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + frame.CaptureCameraPosition(new Vec3d(3, 0, 0), uniforms); + Assert.Equal(3f, frame.CameraPosDelta.X, 5); + + frame.Advance(16.6f, Width, Height, 1f, 0.1f, 3000f, 1.2f, uniforms); + Assert.Equal(0f, frame.CameraPosDelta.X, 5); + Assert.Equal(0f, frame.CameraPosDelta.Y, 5); + Assert.Equal(0f, frame.CameraPosDelta.Z, 5); + } + // --- reset flag lifetime --------------------------------------------------- [Fact] diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index 1a910925..b0e81d6b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..9253b0f 100644 +index 67feafa..fa55f21 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -294,14 +294,14 @@ index 67feafa..9253b0f 100644 + // pass - including the LiquidDepth prepass in the Before stage - sees one + // consistent snapshot and the same jitter. + FrameBufferRef primaryFb = ((Platform.FrameBuffers != null && Platform.FrameBuffers.Count > 0) ? Platform.FrameBuffers[0] : null); -+ OptimumTemporal.Frame.Advance(dt * 1000f, (primaryFb != null) ? primaryFb.Width : Width, (primaryFb != null) ? primaryFb.Height : Height, OptimumConfig.EffectiveRenderScale, MainCamera.ZNear, MainCamera.ZFar, MainCamera.Fov, EntityPlayer?.CameraPos, shUniforms); ++ OptimumTemporal.Frame.Advance(dt * 1000f, (primaryFb != null) ? primaryFb.Width : Width, (primaryFb != null) ? primaryFb.Height : Height, OptimumConfig.EffectiveRenderScale, MainCamera.ZNear, MainCamera.ZFar, MainCamera.Fov, shUniforms); + OptimumTemporal.Frame.JitterActive = OptimumConfig.EffectiveTaa || OptimumConfig.TaaJitterDev; TriggerRenderStage(EnumRenderStage.Before, dt); Platform.GlEnableDepthTest(); Platform.GlDepthMask(flag: true); ScreenManager.FrameProfiler.Mark("rendOpaque-12before"); if (AmbientManager.ShadowQuality > 0 && (double)AmbientManager.DropShadowIntensity > 0.01) -@@ -1140,10 +1252,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1140,10 +1252,24 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo } ScreenManager.FrameProfiler.Mark("rendOpaque-3shadows"); GlMatrixModeModelView(); @@ -313,13 +313,20 @@ index 67feafa..9253b0f 100644 + // culls and draws with. Recorded here rather than relying on Set3DProjection + // alone, which vanilla happens to call once per frame from the sky renderer. + OptimumTemporal.Frame.CaptureCamera(MainCamera.CameraMatrix, MainCamera.CameraMatrixOrigin); ++ // The camera position and playerpos belong here, not to Advance: PlayerCamera ++ // writes both from inside the Before render stage, which has already run by ++ // now but had not when Advance was called. Capturing them there gave every ++ // writer a camera delta and a previous playerpos one frame behind the camera ++ // matrices frozen on the line above, and the difference between the two is ++ // the camera's acceleration - visible as motion on surfaces that never moved. ++ OptimumTemporal.Frame.CaptureCameraPosition(EntityPlayer?.CameraPos, shUniforms); + OptimumTemporal.Frame.RecordProjection(EnumTemporalView.World, top); double[] top2 = api.Render.MvMatrix.Top; for (int i = 0; i < 16; i++) { PerspectiveProjectionMat[i] = top[i]; PerspectiveViewMat[i] = top2[i]; -@@ -1180,10 +1299,15 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1180,10 +1306,15 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo TriggerRenderStage(EnumRenderStage.AfterOIT, dt); } @@ -335,7 +342,7 @@ index 67feafa..9253b0f 100644 dt = DeltaTimeLimiter; } TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); -@@ -1420,10 +1544,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1420,10 +1551,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo { float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); @@ -350,7 +357,7 @@ index 67feafa..9253b0f 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1693,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1700,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -383,7 +390,7 @@ index 67feafa..9253b0f 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2261,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2268,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -408,7 +415,7 @@ index 67feafa..9253b0f 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2690,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2697,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -422,7 +429,7 @@ index 67feafa..9253b0f 100644 } public void DoReconnect() -@@ -3531,6 +3681,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3688,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 8c3deebf..688fcfb9 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..6abf6ed 100644 +index 6edf0c9..6179fd1 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -102,7 +102,7 @@ index 6edf0c9..6abf6ed 100644 private Logger logger; private int doResize; -@@ -93,10 +182,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -138,6 +138,17 @@ index 6edf0c9..6abf6ed 100644 + /// + public bool OptimumMotionWriteActive { get; private set; } + ++ /// ++ /// Optimum TAA (P3): the two draw-buffer sets the motion window switches ++ /// between, built once. The narrow windows are opened per draw - every held ++ /// item, every dropped item, every quern - so allocating both arrays inside ++ /// BeginMotionWrite/EndMotionWrite put two allocations per instrumented draw ++ /// per frame on the render thread's hot path. ++ /// ++ private DrawBuffersEnum[] optimumMotionDrawBuffersOn; ++ ++ private DrawBuffersEnum[] optimumMotionDrawBuffersOff; ++ + private bool TaaTargetsReady; + + // Optimum TAA resolve state (P2): which history slot is written this frame, @@ -180,7 +191,7 @@ index 6edf0c9..6abf6ed 100644 private bool serverRunning; private bool gamepause; -@@ -256,10 +412,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +423,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -204,7 +215,7 @@ index 6edf0c9..6abf6ed 100644 get { return serverRunning; -@@ -278,11 +447,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,11 +458,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -232,7 +243,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindFramebuffer((FramebufferTarget)36160, 0); return; } -@@ -297,11 +482,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -297,11 +493,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -256,7 +267,7 @@ index 6edf0c9..6abf6ed 100644 } public override bool GlErrorChecking { get; set; } -@@ -314,10 +511,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -314,10 +522,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } set { @@ -277,7 +288,7 @@ index 6edf0c9..6abf6ed 100644 if (!supportsGlDebugMode) { throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -335,11 +542,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -335,11 +553,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } glDebugMode = value; } @@ -306,7 +317,7 @@ index 6edf0c9..6abf6ed 100644 public override bool MouseGrabbed { -@@ -478,41 +701,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,41 +712,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -453,7 +464,7 @@ index 6edf0c9..6abf6ed 100644 public void LogAndTestHardwareInfosStage1() { -@@ -533,10 +857,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -533,10 +868,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } @@ -491,7 +502,7 @@ index 6edf0c9..6abf6ed 100644 logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +927,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -576,10 +938,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CheckGlError("testhwinfo"); } @@ -510,7 +521,7 @@ index 6edf0c9..6abf6ed 100644 public override string GetFrameworkInfos() { -@@ -702,24 +1061,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1072,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -553,7 +564,7 @@ index 6edf0c9..6abf6ed 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1173,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1184,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -577,7 +588,7 @@ index 6edf0c9..6abf6ed 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1406,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1417,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -615,7 +626,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1449,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1460,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -635,7 +646,7 @@ index 6edf0c9..6abf6ed 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1480,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1491,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -652,7 +663,7 @@ index 6edf0c9..6abf6ed 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1525,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1536,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -685,7 +696,7 @@ index 6edf0c9..6abf6ed 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1589,458 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1600,458 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -1144,7 +1155,7 @@ index 6edf0c9..6abf6ed 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2072,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2083,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1159,7 +1170,7 @@ index 6edf0c9..6abf6ed 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2099,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2110,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1176,7 +1187,7 @@ index 6edf0c9..6abf6ed 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2144,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2155,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1215,7 +1226,7 @@ index 6edf0c9..6abf6ed 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2357,53 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2368,53 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1269,7 +1280,7 @@ index 6edf0c9..6abf6ed 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2512,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2523,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1284,7 +1295,7 @@ index 6edf0c9..6abf6ed 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,12 +2535,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2546,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1374,7 +1385,7 @@ index 6edf0c9..6abf6ed 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2636,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2647,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1403,7 +1414,7 @@ index 6edf0c9..6abf6ed 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,38 +2670,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,38 +2681,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1539,7 +1550,7 @@ index 6edf0c9..6abf6ed 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2829,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2840,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1580,7 +1591,7 @@ index 6edf0c9..6abf6ed 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2872,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2883,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1647,7 +1658,7 @@ index 6edf0c9..6abf6ed 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2942,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2953,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1669,7 +1680,7 @@ index 6edf0c9..6abf6ed 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2966,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2977,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1696,7 +1707,7 @@ index 6edf0c9..6abf6ed 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +2991,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +3002,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1838,7 +1849,7 @@ index 6edf0c9..6abf6ed 100644 //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3134,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3145,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1871,7 +1882,7 @@ index 6edf0c9..6abf6ed 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3169,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3180,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1933,7 +1944,7 @@ index 6edf0c9..6abf6ed 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3246,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3257,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1980,7 +1991,7 @@ index 6edf0c9..6abf6ed 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3295,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3306,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2016,7 +2027,7 @@ index 6edf0c9..6abf6ed 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,55 +3342,282 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,55 +3353,297 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2142,6 +2153,15 @@ index 6edf0c9..6abf6ed 100644 + if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false; + if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false; + if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return false; ++ // The mask only means anything while Primary is the target being drawn ++ // into. The device call below names the framebuffer, but GL.DrawBuffers ++ // applies to whatever is bound, so without this check a caller that ++ // reached here under the shadow map or the Transparent framebuffer would ++ // rewrite THAT target's draw-buffer set on the GL path and leave the two ++ // backends behaving differently. Refusing the window instead costs the ++ // caller nothing: it falls back to camera reprojection, which is what a ++ // pass outside Primary has to do anyway. ++ if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false; + + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) @@ -2150,12 +2170,15 @@ index 6edf0c9..6abf6ed 100644 + } + else + { -+ DrawBuffersEnum[] optimumMotionDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1]; -+ for (int optimumDb = 0; optimumDb <= MotionAttachmentIndex; optimumDb++) ++ if (optimumMotionDrawBuffersOn == null || optimumMotionDrawBuffersOn.Length != MotionAttachmentIndex + 1) + { -+ optimumMotionDrawBuffers[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); ++ optimumMotionDrawBuffersOn = new DrawBuffersEnum[MotionAttachmentIndex + 1]; ++ for (int optimumDb = 0; optimumDb <= MotionAttachmentIndex; optimumDb++) ++ { ++ optimumMotionDrawBuffersOn[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); ++ } } -+ GL.DrawBuffers(optimumMotionDrawBuffers.Length, optimumMotionDrawBuffers); ++ GL.DrawBuffers(optimumMotionDrawBuffersOn.Length, optimumMotionDrawBuffersOn); } + OptimumMotionWriteActive = true; + // Blending is per-attachment state that GlToggleBlend re-applies whenever @@ -2191,21 +2214,22 @@ index 6edf0c9..6abf6ed 100644 - string text = Marshal.PtrToStringAnsi(message, length); - Logger.Notification("{0} {1} | {2}", severity, type, text); - if ((int)type == 33356) -- { -- throw new Exception(text); -- } + // MotionAttachmentIndex is also the size of the default set (2 without + // the SSAO G-buffer, 4 with it), because the attachment was appended + // after it. + optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + return; + } -+ DrawBuffersEnum[] optimumRestoreDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex]; -+ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) ++ if (optimumMotionDrawBuffersOff == null || optimumMotionDrawBuffersOff.Length != MotionAttachmentIndex) + { -+ optimumRestoreDrawBuffers[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); -+ } -+ GL.DrawBuffers(optimumRestoreDrawBuffers.Length, optimumRestoreDrawBuffers); ++ optimumMotionDrawBuffersOff = new DrawBuffersEnum[MotionAttachmentIndex]; ++ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) + { +- throw new Exception(text); ++ optimumMotionDrawBuffersOff[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); + } + } ++ GL.DrawBuffers(optimumMotionDrawBuffersOff.Length, optimumMotionDrawBuffersOff); + } + + /// @@ -2223,7 +2247,7 @@ index 6edf0c9..6abf6ed 100644 + optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); + optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0); + return; - } ++ } + GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774); + GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0); } @@ -2316,7 +2340,7 @@ index 6edf0c9..6abf6ed 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3665,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3691,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2342,7 +2366,7 @@ index 6edf0c9..6abf6ed 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3698,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3724,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2364,7 +2388,7 @@ index 6edf0c9..6abf6ed 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3727,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3753,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2462,7 +2486,7 @@ index 6edf0c9..6abf6ed 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +3826,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +3852,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2665,7 +2689,7 @@ index 6edf0c9..6abf6ed 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4035,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4061,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2832,7 +2856,7 @@ index 6edf0c9..6abf6ed 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4205,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4231,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2856,7 +2880,7 @@ index 6edf0c9..6abf6ed 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4234,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4260,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2894,7 +2918,7 @@ index 6edf0c9..6abf6ed 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4294,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4320,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2937,7 +2961,7 @@ index 6edf0c9..6abf6ed 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4347,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4373,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2996,7 +3020,7 @@ index 6edf0c9..6abf6ed 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4462,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4488,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3070,7 +3094,7 @@ index 6edf0c9..6abf6ed 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4560,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4586,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3101,7 +3125,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4597,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4623,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3136,7 +3160,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4644,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4670,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3165,7 +3189,7 @@ index 6edf0c9..6abf6ed 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4675,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4701,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3191,7 +3215,7 @@ index 6edf0c9..6abf6ed 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +4702,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +4728,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3204,7 +3228,7 @@ index 6edf0c9..6abf6ed 100644 return uBO; } -@@ -2605,10 +4716,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4742,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3221,7 +3245,7 @@ index 6edf0c9..6abf6ed 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4768,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4794,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3266,7 +3290,7 @@ index 6edf0c9..6abf6ed 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4805,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4831,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3287,7 +3311,7 @@ index 6edf0c9..6abf6ed 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4824,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4850,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3308,7 +3332,7 @@ index 6edf0c9..6abf6ed 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4843,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4869,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3329,7 +3353,7 @@ index 6edf0c9..6abf6ed 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4862,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4888,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3350,7 +3374,7 @@ index 6edf0c9..6abf6ed 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4885,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4911,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3371,7 +3395,7 @@ index 6edf0c9..6abf6ed 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4928,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4954,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3397,7 +3421,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5170,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5196,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3419,7 +3443,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5368,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5394,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3442,7 +3466,7 @@ index 6edf0c9..6abf6ed 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5442,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5468,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3491,7 +3515,7 @@ index 6edf0c9..6abf6ed 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5512,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5538,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3523,7 +3547,7 @@ index 6edf0c9..6abf6ed 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5542,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5568,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3549,7 +3573,7 @@ index 6edf0c9..6abf6ed 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5890,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5916,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3579,7 +3603,7 @@ index 6edf0c9..6abf6ed 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5944,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5970,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/scripts/package-macos.ps1 b/scripts/package-macos.ps1 index 35ec83eb..39f7da0c 100644 --- a/scripts/package-macos.ps1 +++ b/scripts/package-macos.ps1 @@ -126,6 +126,14 @@ try { Get-ChildItem $shaderSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderDst } } + # 5b-2. Overlay optimized shader includes (TAA P3). Same asset-name override + # mechanism as shaders, separate directory. + $shaderIncSrc = Join-Path $repoRoot 'sources/shaderincludes' + $shaderIncDst = Join-Path $appDir 'assets/game/shaderincludes' + if (Test-Path $shaderIncSrc) { + Get-ChildItem $shaderIncSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderIncDst } + } + # Merge translation strings (text-based; vanilla JSON has case-duplicate keys that break ConvertFrom-Json). # Read/write explicitly as UTF-8 via .NET, not Get-Content/Set-Content: # on Windows PowerShell 5.1 those cmdlets default to the system codepage diff --git a/scripts/package.ps1 b/scripts/package.ps1 index 557f8b8a..d99f0262 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -341,6 +341,17 @@ try { Get-ChildItem $shaderSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderDst } } + # Apply optimized shader includes (TAA P3). Same asset-name override + # mechanism as shaders, separate directory - without it the shipped + # chunkopaque/chunktopsoil/entityanimated/standard/instanced overrides call + # WarpState overloads the vanilla vertexwarp.vsh does not declare, and every + # one of those programs fails to compile with TAA on. + $shaderIncSrc = Join-Path $repoRoot 'sources/shaderincludes' + $shaderIncDst = Join-Path $stageDir 'assets/game/shaderincludes' + if (Test-Path $shaderIncSrc) { + Get-ChildItem $shaderIncSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderIncDst } + } + # Merge translation strings (text-based; vanilla JSON has case-duplicate keys that break ConvertFrom-Json). # Read/write explicitly as UTF-8 via .NET, not Get-Content/Set-Content: # Windows PowerShell 5.1 (what the Windows installer launches) defaults diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs index fdab2909..656b1666 100644 --- a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -280,11 +280,17 @@ public void RequestReset(EnumTemporalResetReason reason) /// once per frame, immediately after DefaultShaderUniforms.Update and before /// the Before render stage, so every pass in the frame sees one consistent /// snapshot. + /// + /// The camera position and playerpos are deliberately NOT captured + /// here: PlayerCamera.OnBeforeRenderFrame3D writes both from inside the + /// Before render stage, which runs after this call, so reading them here + /// would snapshot the previous frame's values and hand every writer a + /// camera delta and a previous playerpos one frame out of step with the + /// camera matrices frozen later in the frame. + /// does that half, next to . /// /// Optimum's render scale (1 = native). The jitter /// sequence gets more phases the more the image is upscaled. - /// EntityPlayer.CameraPos, differenced in double - /// precision. May be null before a world is loaded. public void Advance( float deltaTimeMs, int renderWidth, @@ -293,7 +299,6 @@ public void Advance( float zNear, float zFar, float fov, - Vec3d cameraPosIn, DefaultShaderUniforms uniforms) { // --- rotate current -> previous ------------------------------------- @@ -309,6 +314,11 @@ public void Advance( Array.Copy(cameraMatrix, cameraMatrixPrev, 16); Array.Copy(cameraMatrixOrigin, cameraMatrixOriginPrev, 16); PrevPlayerpos.Set(Playerpos.X, Playerpos.Y, Playerpos.Z); + // The camera position rolls here even though it is captured later in + // the frame, so CaptureCameraPosition can be called more than once + // and still difference against the previous frame rather than + // against its own earlier call. + cameraPosPrev.Set(cameraPos); PrevWarp = Warp; FrameIndex++; @@ -319,10 +329,10 @@ public void Advance( ZFar = zFar; Fov = fov; Warp = OptimumWarpState.FromUniforms(uniforms); - if (uniforms != null && uniforms.PlayerPos != null) - { - Playerpos.Set(uniforms.PlayerPos.X, uniforms.PlayerPos.Y, uniforms.PlayerPos.Z); - } + // Zeroed here and filled by CaptureCameraPosition, so a frame that + // never reaches the capture reports no camera movement rather than + // repeating the previous frame's. + CameraPosDelta.Set(0f, 0f, 0f); EnumTemporalResetReason reason = pendingReset; pendingReset = EnumTemporalResetReason.None; @@ -337,12 +347,59 @@ public void Advance( reason = EnumTemporalResetReason.Resize; } - // --- camera position delta, teleport detection ----------------------- + ResetReason = reason; + Reset = reason != EnumTemporalResetReason.None; + + // --- jitter ---------------------------------------------------------- + int phaseCount = Math.Max(1, OptimumTemporalMath.JitterPhaseCount(renderScale > 0f ? 1f / renderScale : 1f)); + int phase = (int)(FrameIndex % phaseCount); + double jx = OptimumTemporalMath.Halton(phase + 1, 2) - 0.5; + double jy = OptimumTemporalMath.Halton(phase + 1, 3) - 0.5; + // Halton(2,3) never lands on (0.5, 0.5), but a zero offset would make a + // frame contribute no new sub-pixel sample at all, so it is excluded by + // construction rather than by luck. + if (jx == 0.0 && jy == 0.0) jx = 0.25; + JitterSequencePx.X = (float)jx; + JitterSequencePx.Y = (float)jy; + JitterPx.X = jitterActive ? JitterSequencePx.X : 0f; + JitterPx.Y = jitterActive ? JitterSequencePx.Y : 0f; + } + + /// + /// Captures the camera position and playerpos for the frame, and + /// with them the camera delta every motion-vector writer reprojects a + /// static surface by, plus the two reset causes that only these values + /// can reveal: a teleport and a reference-position rebase. + /// + /// Called after the Before render stage has run, because that is where + /// PlayerCamera writes both - together with , + /// so the translation and the rotation of the previous camera belong to + /// the same frame. Calling it from would pair a + /// one-frame-stale delta with an up-to-date previous view matrix, and the + /// difference between the two shows up as motion on every static surface + /// whenever the camera's speed changes. + /// + /// Safe to call more than once per frame: the roll happened in Advance, + /// so a second call recomputes the same delta from the same previous + /// position. + /// + /// EntityPlayer.CameraPos, differenced in double + /// precision. May be null before a world is loaded. + /// The shader uniforms, for playerpos and the + /// reference position the warp noise is sampled against. + public void CaptureCameraPosition(Vec3d cameraPosIn, DefaultShaderUniforms uniforms) + { + EnumTemporalResetReason reason = ResetReason; + + if (uniforms != null && uniforms.PlayerPos != null) + { + Playerpos.Set(uniforms.PlayerPos.X, uniforms.PlayerPos.Y, uniforms.PlayerPos.Z); + } + if (cameraPosIn != null) { if (hasCameraPos) { - cameraPosPrev.Set(cameraPos); double dx = cameraPosIn.X - cameraPosPrev.X; double dy = cameraPosIn.Y - cameraPosPrev.Y; double dz = cameraPosIn.Z - cameraPosPrev.Z; @@ -354,7 +411,6 @@ public void Advance( } else { - cameraPosPrev.Set(cameraPosIn); CameraPosDelta.Set(0f, 0f, 0f); hasCameraPos = true; } @@ -366,7 +422,6 @@ public void Advance( hasCameraPos = false; } - // --- reference-position rebase --------------------------------------- Vec3d reference = uniforms?.playerReferencePos; if (reference != null) { @@ -385,20 +440,6 @@ public void Advance( ResetReason = reason; Reset = reason != EnumTemporalResetReason.None; if (Reset) CameraPosDelta.Set(0f, 0f, 0f); - - // --- jitter ---------------------------------------------------------- - int phaseCount = Math.Max(1, OptimumTemporalMath.JitterPhaseCount(renderScale > 0f ? 1f / renderScale : 1f)); - int phase = (int)(FrameIndex % phaseCount); - double jx = OptimumTemporalMath.Halton(phase + 1, 2) - 0.5; - double jy = OptimumTemporalMath.Halton(phase + 1, 3) - 0.5; - // Halton(2,3) never lands on (0.5, 0.5), but a zero offset would make a - // frame contribute no new sub-pixel sample at all, so it is excluded by - // construction rather than by luck. - if (jx == 0.0 && jy == 0.0) jx = 0.25; - JitterSequencePx.X = (float)jx; - JitterSequencePx.Y = (float)jy; - JitterPx.X = jitterActive ? JitterSequencePx.X : 0f; - JitterPx.Y = jitterActive ? JitterSequencePx.Y : 0f; } /// From fa4ab491f57418d47e4697ca2ce564e5c76c14ca Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 21:20:35 +0200 Subject: [PATCH 031/226] docs(taa): record P3 status - exact vs fallback per class and the findings to carry --- TAA-PLAN.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/TAA-PLAN.md b/TAA-PLAN.md index a896b81c..20e05e6a 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -282,6 +282,89 @@ must be anchored at the pixel centre plus mv, not at the unjittered current posi - Verify per class with the debug views and directional tests; measure vertex-warp cost on dense foliage and the UBO snapshot cost with crowds. +P3 status (2026-09-10): writers landed for all five opaque classes on `feat/taa` +(ce3cc1f terrain, abba239 skinned entities, c6da92f standard shader, 3126098 instanced, +58bc11d review fixes). **Not verified in game on either backend** - no phase of P3 ran +`make deploy` or the client, so by rule 3 none of this is done until someone deploys, runs +on Vulkan and OpenGL, confirms the renderer from the log and compares the debug views. +GPU proof is Vulkan-only (`Optimum.Render.Vulkan.Tests` is the only GPU harness), and every +GPU test so far runs with `taaJitterPx = 0`, so the jittered case is untested everywhere. + +Exact vs fallback, per class: + +| Class | Status | Why | +|---|---|---| +| chunkopaque (passes 0, 1, 2, 8) and chunktopsoil | exact | prevRel = truePos + cameraPosDelta, warp replayed with `previousWarpState()`, z-offset applied to both clips | +| chunkopaque pass 7 (AfterOIT overlay) | exact | own window in `RenderAfterOIT` | +| LiquidDepth prepass | no motion, by design | own target, no writer, attachment never in its mask | +| Skinned entities, batched opaque pass | exact | previous model matrix + `AnimationPrev` bones, hooked on the one bone upload every entity draw makes | +| First-person hands, echo chamber | exact | own programs/windows; hands reproject through `GetPrevProjection(Hand)` | +| Skinned entities, OIT | no motion | six OIT outputs on Transparent; reactive policy is P4 | +| Skinned entities, AfterOIT (`DoRender3DAfterOIT`) | fallback | that loop draws arbitrary per-renderer shaders, so it must stay outside a window | +| Held items (both hands + FP item), dropped items, quern top | exact | `OptimumStandardMotion.Apply` + a narrow window per draw | +| Every other standard-shader user | fallback | uninstrumented and outside the window, so nothing is written and the resolve camera-reprojects. Exact for the static ones (signs, molds, knapping, ground storage, support beams); **wrong-but-bounded for the moving ones**: HelveHammer, FruitpressContents, Resonator, EntityBlockFalling, Bloomery/Forge/Firepit contents will ghost until instrumented (one `Apply` call plus a `Begin`/`End` pair each) | +| Instanced mechanical power | exact | per-instance previous transform + metadata in the instance stream, history keyed on the device object | +| ClothManager (shares the instanced program) | fallback | 20-float instance mesh, draws outside the window; its missing attributes read (0,0,0,1), i.e. no history | +| Mod geometry | fallback | writer-depth mismatch, as designed | + +Findings to carry: + +(a) **Where the frame contract reads the camera decides whether the delta is this frame's.** +`PlayerCamera.OnBeforeRenderFrame3D` is the only writer of `EntityPlayer.CameraPos` and +`shUniforms.PlayerPos`, and it runs inside the Before render stage - after `Advance`, before +`CaptureCamera`. Reading them in `Advance` paired a one-frame-stale translation with a fresh +previous rotation; the difference is the camera's acceleration, and it painted motion onto +static ground. `CaptureCameraPosition` now takes both next to `CaptureCamera` (58bc11d). Any +future value the contract snapshots has to be placed against the stage that writes it, not +against the top of the loop. + +(b) **A draw-buffer window is per target, and the two backends disagree about that for free.** +`SetDrawBuffers` names the framebuffer, `GL.DrawBuffers` uses the bound one. `BeginMotionWrite` +now refuses unless Primary is bound. + +(c) **A new asset directory needs every packager, and the list must be derived.** Two of the five +packaging scripts were missed; with TAA on those builds fail to compile every writer program, +because the shipped overrides call `WarpState` overloads the vanilla `vertexwarp.vsh` does not +declare. The coverage test now enumerates `scripts/package*` instead of listing three by name. + +(d) **The shader corpus only covers configurations its variant rows produce.** `USEOIT 0` and +`ALLOWDEPTHOFFSET` are stamped per program by the client, not globally, so the entity writer and +the `gl_FragCoord.z + depthOffset` writer depth were outside the translation gate entirely. +`ShaderVariant.ExtraPrefix` plus explicit per-program cases now cover them. + +(e) **Instance buffers doubled in stride unconditionally** (20 -> 40 floats), TAA on or off, +because shaders recompile on a TAA toggle and instance buffers do not. Roughly 1.6 MB per mech +buffer. Deliberate; revisit only together with a buffer-rebuild-on-toggle. + +(f) **Installed-runtime gap, all three mod-fork stages.** `mod-patcher` transplants from the +runtime donor assemblies patched by `patches/runtime/**`, not from the `VSEssentials`/ +`VSSurvivalMod` forks. `Methods` entries were added, but until the matching +`patches/runtime/**` patches exist the installed runtime keeps the vanilla bodies, so first-person +hands, the echo chamber, held/dropped items, the quern and every mech renderer get no motion +there (camera fallback; for the mech renderers also the 20-float layout under a 40-float shader, +whose unbacked attributes read (0,0,0,1) = no history - believed safe, untested). +`Optimum.Tests/mod-patcher-manifest-consistency-tests.cs` only cross-checks `Members`/`Types`/ +`Interfaces`, which is why the additions pass today. + +(g) **`Entityanimated_Oit` compiles the `AnimationPrev` block with no buffer behind it.** +`USEOIT` is a fragment-only define, so the vertex shader cannot gate on it. On GL the block keeps +the default binding point 0 and aliases `Animation`; on Vulkan `_boundUniformBuffers` is keyed by +block name and `Use()` re-binds, so it resolves to the last-bound buffer of that name. Never read, +because `taaHistoryValid` is never set for that program and defaults to 0. Harmless, but it is a +declared-and-unfed block. + +(h) **`reactive` is read by the resolve whether or not the pixel was written.** A rejected pixel +still contributes `motion.b` from whatever surface last wrote there this frame. Bounded (the +attachment is cleared to zero each frame), worth a look when P4 starts writing reactive in anger. + +(i) **Per-instance CWT churn.** `OptimumInstanceMotion.WriteInstance` does two +`ConditionalWeakTable` lookups per instance per frame (buffer, then device). Fine at realistic +gear counts, measurable at the 10100-instance capacity. Not optimised, because a single-slot memo +would hold a strong reference to a ~1.6 MB buffer. + +(j) **The vertex-warp and UBO cost measurements P3 asks for were not taken** - no crowd, no dense +foliage, no gear-network numbers. Still owed before P5's performance matrix. + **P4. Transparency, particles, volumetrics, sky, decals, late overlays.** - Liquid velocity pass; OIT revealage reactive; particle writers; cloud/aurora/sky policies with the infinite-direction reprojection; decals inherit motion; AfterFinalComposition/AfterBlit content From 57139c16f988d898fe033ada9d851d7ee44b79cd Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 21:56:01 +0200 Subject: [PATCH 032/226] wip(taa): P4 liquid velocity pass - motion for water, verified by GPU readback The OIT liquid draw renders into the Transparent target with six oit.fsh outputs, so it cannot write Primary's motion attachment. TAA-PLAN.md accuracy rule 7 therefore calls for a dedicated pass, and this is it: after the OIT merge and after every AfterOIT renderer - still inside the temporal window - the liquid pools are drawn once more into Primary by chunkliquidmotion, a program whose only output is the motion attachment. - sources/shaders/chunkliquidmotion.vsh/.fsh: chunkliquid.vsh's position path verbatim (same liquid wave warp, same divisor, same 0.0008/z w-offset on both clip positions), the previous position through previousWarpState(), cameraPosDelta and the previous unjittered projection, and rg/b/a = (previousPixel - currentUnjitteredPixel, reactive, gl_FragCoord.z). - ClientPlatformWindows.BeginMotionOnlyWrite/EndMotionOnlyWrite: the motion attachment alone in Primary's draw-buffer set (bitmask on the device, GL_NONE in every other slot on GL), so a second draw of already-shaded geometry cannot touch the merged image. - ChunkRenderer.RenderLiquidMotion: LEQUAL against Primary depth, blending off, culling off as the OIT draw runs, and the depth write ON - the OIT liquid draw writes no depth at all, so without it the resolve's writer-depth test would reject every liquid pixel and the pass would buy nothing. Documented in the shader and the method. - reactive 0.3 (uniform taaLiquidReactive) for the foam and flow-UV animation. - Scanner disables TAA for an external chunkliquid/chunkliquidmotion shader. Verified: 9 GPU readback tests in Optimum.Render.Vulkan.Tests (TaaLiquidMotionTests) - still camera, three known camera translations to an exact pixel displacement, two jittered cases proving the projection shear and taaJitterPx cancel (the case P3 never covered), reactive 0.3, writer depth, uncovered pixels keeping a zero alpha, the previous liquid wave replayed through the same warp, and colour attachment 0 byte-identical over the whole target. 8 source-coverage tests in Optimum.Tests, including a verbatim comparison of the warp branch against the vanilla chunkliquid.vsh. Full suites: Vulkan 291/291; Optimum.Tests 860 passed with one pre-existing environmental failure (TheStateOverloadsAreVanillaMathsWithTheUniformsRead FromTheStruct - a past `make deploy` overwrote .vanilla's vertexwarp.vsh reference with Optimum's copy; unrelated to this change). extract-patches + check-patches clean. Not yet run in the game on either backend. --- .../ShaderCompatibilityScanner.cs | 7 + Optimum.Patcher/Program.cs | 10 + .../TaaLiquidMotionTests.cs | 770 ++++++++++++++++++ .../taa-liquid-motion-coverage-tests.cs | 409 ++++++++++ .../ChunkRenderer.cs.patch | 145 +++- .../ClientMain.cs.patch | 31 +- .../ClientPlatformWindows.cs.patch | 249 ++++-- .../ShaderPrograms.cs.patch | 9 +- .../ShaderRegistry.cs.patch | 12 +- sources/shaders/chunkliquidmotion.fsh | 67 ++ sources/shaders/chunkliquidmotion.vsh | 105 +++ 11 files changed, 1696 insertions(+), 118 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs create mode 100644 Optimum.Tests/taa-liquid-motion-coverage-tests.cs create mode 100644 sources/shaders/chunkliquidmotion.fsh create mode 100644 sources/shaders/chunkliquidmotion.vsh diff --git a/Optimum.Launcher/ShaderCompatibilityScanner.cs b/Optimum.Launcher/ShaderCompatibilityScanner.cs index c56f7f2d..1a33e32c 100644 --- a/Optimum.Launcher/ShaderCompatibilityScanner.cs +++ b/Optimum.Launcher/ShaderCompatibilityScanner.cs @@ -335,6 +335,13 @@ private static void FinalizeReport(ShaderCompatibilityReport report) HasExternalShader(report, "entityanimated.vsh") || HasExternalShader(report, "entityanimated.fsh") || HasExternalShader(report, "standard.vsh") || HasExternalShader(report, "standard.fsh") || HasExternalShader(report, "instanced.vsh") || HasExternalShader(report, "instanced.fsh") || + // The liquid velocity pass re-draws the liquid pools through its own + // program and has to land on exactly the surface chunkliquid.vsh + // shaded; an external copy of either file breaks that agreement, and + // its vectors would then be rejected or - worse - accepted for a + // surface half a pixel away. + HasExternalShader(report, "chunkliquid.vsh") || + HasExternalShader(report, "chunkliquidmotion.vsh") || HasExternalShader(report, "chunkliquidmotion.fsh") || HasExternalShader(report, "vertexwarp.vsh"); AddFeatureDecision(report, "Taa", externalMotionShader, "external shader owns a motion-vector writer contract"); diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 8b30fbe0..eab37449 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -159,6 +159,11 @@ "InstallOptimumMotionWriteHooks", "optimumMotionDrawBuffersOn", "optimumMotionDrawBuffersOff", + // TAA P4: the motion-only window the liquid velocity pass opens - the + // motion attachment alone, every other colour attachment masked out. + "BeginMotionOnlyWrite", + "EndMotionOnlyWrite", + "optimumMotionOnlyDrawBuffers", }, // TAA P3: the uniform block a buffer feeds and the point it is bound to. // Vanilla had one block per program and Bind() hard-coded binding point 0; @@ -175,6 +180,8 @@ "FsrRcas", "TaaDebug", "TaaResolve", + // TAA P4: the liquid velocity pass program. + "ChunkLiquidMotion", }, ["Vintagestory.Client.NoObf.ShaderRegistry"] = new() { @@ -276,6 +283,9 @@ "SetOptimumTextureLodBias", // TAA P3: previous-frame transforms for the terrain motion writers. "SetOptimumMotionUniforms", + // TAA P4: the liquid velocity pass and its reactive constant. + "RenderLiquidMotion", + "OptimumLiquidReactive", }, // ChunkTesselatorManager: skip RecalcPriority+Sort when the player hasn't moved // (_lastSortPlayerPos/_lastSortYaw), plus the multi-tesselator worker pool and diff --git a/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs new file mode 100644 index 00000000..5134c427 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs @@ -0,0 +1,770 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The liquid velocity pass (TAA P4, TAA-PLAN.md accuracy rule 7), driven +/// through the seam with the real chunkliquidmotion program and read back as +/// pixels. +/// +/// The pass exists because the OIT liquid draw cannot write Primary's motion +/// attachment, so the liquid pools are drawn a second time into Primary by a +/// program that writes NOTHING but that attachment. Two things therefore have to +/// hold at once: the vector, reactive value and writer depth must be right, and +/// the shaded image must come out of the pass byte for byte as it went in. Both +/// are asserted here. +/// +/// The projection is a minimal perspective-shaped matrix rather than the +/// identity the P3 writer tests use: clip.w = -z_view is what makes the +/// jitter shear (P[8] -= 2*jx/W) displace the raster position by exactly +/// jx pixels, and the jittered case is one of the things P3 never covered. +/// +/// The motion attachment is RGBA16F and ReadDefaultFramebuffer reads four +/// bytes per pixel from colour attachment 0, so the values come back through a +/// second fullscreen pass that decodes them into an RGBA8 target. That is a +/// readback detail, not part of the contract. +/// +public class TaaLiquidMotionTests +{ + private readonly ITestOutputHelper _output; + + public TaaLiquidMotionTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// The value ChunkRenderer stamps into taaLiquidReactive. + private const float LiquidReactive = 0.3f; + + /// + /// The quad sits one unit in front of the camera, on the plane the matrix + /// below maps to window depth 0.5. + /// + private const float QuadZ = -1f; + + /// + /// chunkliquid.vsh's "pretend the surface is closer" w-offset: + /// gl_Position.w += 0.0008 / max(0.1, gl_Position.z). With the matrix + /// below every vertex of the quad has clip.z = 0 and clip.w = 1, so the + /// offset is a constant 0.008 on both the current and the previous clip + /// position - which divides both projected positions, and therefore the + /// motion vector, by this factor. + /// + private const float WOffsetFactor = 1.008f; + + /// + /// A perspective-shaped projection, column-major: x and y pass through, + /// clip.w = -z and clip.z = -z - 1. At the quad's z = -1 that + /// is clip = (x, y, 0, 1), so NDC z is 0 and the window depth 0.5 - the same + /// value the identity matrix gives the P3 writer tests, but with the w that + /// makes a jitter shear behave the way it does in the game. + /// + private static readonly float[] Projection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, -1, -1, + 0, 0, -1, 0, + }; + + private static float[] Jittered(float jitterX, float jitterY) + { + float[] sheared = (float[])Projection.Clone(); + sheared[8] -= 2f * jitterX / Size; + sheared[9] -= 2f * jitterY / Size; + return sheared; + } + + // ------------------------------------------------------------------ tests + + /// + /// A camera that did not move produces no motion, the reactive value the + /// renderer set, and the fragment's own window depth - which is the whole + /// point of the pass: without a matching writer depth the resolve rejects + /// the vector and camera-reprojects the water instead. + /// + [SkippableFact] + public void AStillCameraWritesZeroMotionTheReactiveValueAndTheFragmentDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderLiquidMotion(device!, 0f, 0f); + Decoded centre = result.At(Size / 2, Size / 2); + + _output.WriteLine($"still: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.Reactive, LiquidReactive - 0.01f, LiquidReactive + 0.01f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The camera translating by a known amount moves the water surface by + /// exactly that amount in pixels, and the sign is "where the pixel was". + /// + [SkippableTheory] + [InlineData(0.25f, 0f)] + [InlineData(0f, -0.125f)] + [InlineData(-0.1875f, 0.0625f)] + public void ACameraTranslationShowsUpAsTheExactPixelDisplacement(float deltaX, float deltaY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderLiquidMotion(device!, deltaX, deltaY); + Decoded centre = result.At(Size / 2, Size / 2); + + // prevRel = truePos + cameraPosDelta with both matrices as above, so + // the previous clip position differs by exactly the delta and the + // pixel difference is delta * 0.5 * Size, divided by the w-offset + // both positions carry. + float expectedX = deltaX * 0.5f * Size / WOffsetFactor; + float expectedY = deltaY * 0.5f * Size / WOffsetFactor; + + _output.WriteLine($"delta ({deltaX}, {deltaY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.Reactive, LiquidReactive - 0.01f, LiquidReactive + 0.01f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The jittered case, which no P3 GPU test covered: the projection carries + /// this frame's sub-pixel shear and the fragment shader is told the same + /// offset in taaJitterPx. The two have to cancel - the vector describes where + /// the surface went, not where the sampling grid went - so the result must be + /// the unjittered result, not one displaced by the jitter. + /// + /// A writer that forgot the subtraction would be off by the jitter (up to + /// half a pixel, which is the entire signal TAA is resolving); one that + /// subtracted it with the wrong sign would be off by twice that. + /// + [SkippableTheory] + [InlineData(0.375f, -0.25f)] + [InlineData(-0.5f, 0.5f)] + public void TheJitterInTheProjectionAndInTheUniformCancel(float jitterX, float jitterY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float deltaX = 0.25f; + const float deltaY = -0.125f; + + Result result = RenderLiquidMotion(device!, deltaX, deltaY, jitterX, jitterY); + Decoded centre = result.At(Size / 2, Size / 2); + + float expectedX = deltaX * 0.5f * Size / WOffsetFactor; + float expectedY = deltaY * 0.5f * Size / WOffsetFactor; + + _output.WriteLine($"jitter ({jitterX}, {jitterY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + // The jitter is a whole decode step (0.25 px) or more, so a missing + // or wrongly signed subtraction cannot hide inside this tolerance. + Assert.True(Math.Abs(jitterX) >= 0.25f && Math.Abs(jitterY) >= 0.25f, + "the jitter chosen is smaller than the decode quantisation"); + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The pass re-draws geometry that has already been shaded into Primary + /// through the OIT merge, so the one thing it must not do is touch the + /// image. The draw-buffer mask is the motion attachment alone, and colour + /// attachment 0 has to come back holding exactly what it was cleared to - + /// over the quad as well as beside it. + /// + [SkippableFact] + public void TheVelocityPassLeavesColourAttachmentZeroUntouched() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderLiquidMotion(device!, 0.25f, 0f); + + // The quad covers the middle of the target and the motion attachment + // proves the draw really happened there. + Decoded centre = result.At(Size / 2, Size / 2); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + + // ClearColour was (0.2, 0.4, 0.6, 1) - see RenderLiquidMotion. + byte[] expected = { 51, 102, 153, 255 }; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + int offset = (y * Size + x) * 4; + for (int channel = 0; channel < 4; channel++) + { + Assert.True(Math.Abs(result.Colour[offset + channel] - expected[channel]) <= 1, + $"colour attachment 0 was written at ({x}, {y}) channel {channel}: " + + $"{result.Colour[offset + channel]} instead of {expected[channel]}"); + } + } + } + } + + /// + /// A pixel no liquid covered keeps a zero alpha, which is what makes the + /// resolve's writer-depth test reject it and use the camera fallback. + /// + [SkippableFact] + public void PixelsNoLiquidCoveredKeepAZeroWriterDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderLiquidMotion(device!, 0.25f, 0f); + Decoded corner = result.At(2, 2); + + _output.WriteLine($"corner: mv = ({corner.MotionX}, {corner.MotionY}), " + + $"writerDepth = {corner.WriterDepth}"); + Assert.InRange(corner.WriterDepth, 0f, 0.01f); + } + } + + /// + /// The liquid wave warp is replayed with the PREVIOUS frame's state, through + /// the same code that produced this frame's position (the WarpState overload + /// of applyLiquidWarping in the vertexwarp include). + /// + /// The warp is gradient noise, so there is no closed form to compare + /// against; what is exactly known is its shape. applyLiquidWarpingState only + /// ever displaces worldPos.y, and the noise it samples varies with + /// worldPos.x. So with this frame's wave intensity at zero and the + /// previous frame's at three - and the camera perfectly still - the motion has + /// to be y-only, non-zero, and different from pixel to pixel across the quad. + /// A writer that evaluated the warp with the current state instead would + /// produce exactly zero everywhere, which is the failure this catches. + /// + [SkippableFact] + public void ThePreviousLiquidWaveIsReplayedThroughTheSameWarp() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + // waterFlagsIn bit 0 = "should animate", which is the branch + // chunkliquid.vsh takes into applyLiquidWarping with div = 5. + Result result = RenderLiquidMotion(device!, 0f, 0f, waterFlags: 1, previousWaterWaveIntensity: 3f); + + int row = Size / 2; + int moved = 0; + var values = new List(); + // The quad spans NDC [-0.5, 0.5], i.e. pixels 16..48. + for (int x = 20; x < 44; x++) + { + Decoded pixel = result.At(x, row); + Assert.InRange(pixel.WriterDepth, 0.48f, 0.52f); + // The warp moves y and only y. + Assert.InRange(pixel.MotionX, -0.3f, 0.3f); + values.Add(pixel.MotionY); + if (Math.Abs(pixel.MotionY) > 0.3f) moved++; + } + + _output.WriteLine("previous wave, mv.y across the quad: " + + string.Join(", ", values.Select(v => v.ToString("0.0")))); + + Assert.True(moved > values.Count / 4, + "the previous frame's liquid wave produced no vertical motion: " + + "the warp is not being replayed with the previous state"); + Assert.True(values.Max() - values.Min() > 0.3f, + "the vertical motion is constant across the quad, so it is not the noise field"); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float reactive, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + Reactive = reactive; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float Reactive { get; } + public float WriterDepth { get; } + } + + private sealed class Result + { + public byte[] Motion = Array.Empty(); + public byte[] Reactive = Array.Empty(); + public byte[] Colour = Array.Empty(); + + public Decoded At(int x, int y) + { + int offset = (y * Size + x) * 4; + return new Decoded( + (Motion[offset] / 255f * 2f - 1f) * DecodeScale, + (Motion[offset + 1] / 255f * 2f - 1f) * DecodeScale, + Reactive[offset] / 255f, + Motion[offset + 2] / 255f); + } + } + + /// + /// Draws one liquid quad with the real chunkliquidmotion program, into a + /// Primary stand-in whose draw-buffer mask is the motion attachment alone - + /// exactly what ClientPlatformWindows.BeginMotionOnlyWrite does - and returns + /// the decoded motion attachment together with colour attachment 0. + /// + private unsafe Result RenderLiquidMotion( + VulkanDevice device, + float cameraDeltaX, + float cameraDeltaY, + float jitterX = 0f, + float jitterY = 0f, + int waterFlags = 0, + float previousWaterWaveIntensity = 0f) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(v => v.Name == "taa-no-ssao"); + Assert.Equal(1, variant.TaaMotion); + Assert.Equal(2, variant.TaaMotionLocation); + Assert.Equal(1, variant.WavingStuff); + + List stages = ShaderCorpus.BuildProgram("chunkliquidmotion", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "chunkliquidmotion"); + + // The writer only exists if the shader really declares it; without this + // the test would pass on a shader that dropped the output entirely. + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + "chunkliquidmotion declares no taaRenderSize, so it is not a motion writer"); + + // Primary stand-in: colour, glow and the motion attachment at index 2, + // which is where SetupDefaultFrameBuffers puts it without the SSAO + // G-buffer and what TAAMOTIONLOCATION was stamped with above. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + seam.SetTextureParameter(colour, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(colour, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + int mesh = seam.CreateMesh(BuildLiquidQuad(waterFlags), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + + // Clear with every attachment enabled: ClearColor honours the + // draw-buffer mask on the device, so a masked-out attachment would keep + // undefined contents and the "untouched" check below would be vacuous. + seam.SetDrawBuffers(scene, 0b111); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + // The motion-only window: attachment 2 alone, which is what makes the + // second draw of already-shaded geometry harmless. + seam.SetDrawBuffers(scene, 1 << 2); + + seam.UseProgram(program); + SetMatrix(seam, program, "projectionMatrix", Jittered(jitterX, jitterY)); + SetMatrix(seam, program, "modelViewMatrix", Identity); + // The previous projection is the UNJITTERED one, as the frame contract + // hands it out: a previous position through a jittered matrix would carry + // two frames' jitter difference instead of the surface's movement. + SetMatrix(seam, program, "prevProjectionMatrix", Projection); + SetMatrix(seam, program, "prevModelViewMatrix", Identity); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", jitterX, jitterY); + SetFloat(seam, program, "taaLiquidReactive", LiquidReactive); + SetWarpUniforms(seam, program, previousWaterWaveIntensity); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(mesh); + + byte[] decodedMotion = DecodeMotion(seam, motion, reactive: false); + byte[] decodedReactive = DecodeMotion(seam, motion, reactive: true); + // Back to the full set before reading attachment 0, so the read is not + // looking at a target whose only enabled attachment is the motion one. + seam.SetDrawBuffers(scene, 0b111); + var result = new Result + { + Motion = decodedMotion, + Reactive = decodedReactive, + Colour = ReadColour(seam, scene), + }; + seam.Present(); + + AssertClean(seam); + return result; + } + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + /// Colour attachment 0 of the scene target, read inside the frame. + private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(scene); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// With the blue channel is put in red at full + /// scale, so the 0.3 can be checked without the mv quantisation. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture, bool reactive) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +uniform int reactiveOnly; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + if (reactiveOnly != 0) { + outColor = vec4(clamp(m.b, 0.0, 1.0), 0.0, 0.0, 1.0); + return; + } + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-liquid-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-liquid-decode.fsh" }, + }, "taa-liquid-decode"); + + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int quadMesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + SetInt(seam, decode, "reactiveOnly", reactive ? 1 : 0); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// A liquid quad with the attribute layout the liquid pool uses, which is + /// what puts waterFlagsIn at location 6: xyz(0), uv(1), rgba(2), flags(3), + /// two custom floats (4, the flow vector) and two custom ints (5 colormap + /// data, 6 water flags) - the same parts ChunkRenderer allocates for + /// EnumChunkRenderPass.Liquid. + /// + private static MeshData BuildLiquidQuad(int waterFlags) + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + float[] positions = + { + -0.5f, -0.5f, QuadZ, + 0.5f, -0.5f, QuadZ, + 0.5f, 0.5f, QuadZ, + -0.5f, 0.5f, QuadZ, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + Vintagestory.API.MathTools.ColorUtil.WhiteArgb, + flags: 0); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + + mesh.CustomFloats = new CustomMeshDataPartFloat + { + Values = new float[8], + Count = 8, + InterleaveOffsets = new int[1], + InterleaveSizes = new int[1] { 2 }, + InterleaveStride = 8, + }; + + var customInts = new int[8]; + for (int i = 0; i < 4; i++) + { + customInts[i * 2] = 0; // colormap data + customInts[i * 2 + 1] = waterFlags; // water flags + } + mesh.CustomInts = new CustomMeshDataPartInt + { + Values = customInts, + Count = 8, + InterleaveOffsets = new int[2] { 0, 4 }, + InterleaveSizes = new int[2] { 1, 1 }, + InterleaveStride = 8, + Conversion = DataConversion.Integer, + }; + + return mesh; + } + + /// + /// Both halves of the warp state. This frame's wave intensities are zero, so + /// the current position is the plain quad; only the previous frame's water + /// wave moves anything, and only when the caller asks for it. Set explicitly + /// rather than left at zero: an unset uniform is a defined zero in GL but + /// whatever the block happens to hold on the device path. + /// + private static void SetWarpUniforms( + IOptimumGraphicsDevice seam, int program, float previousWaterWaveIntensity) + { + SetFloat(seam, program, "timeCounter", 0f); + SetFloat(seam, program, "windWaveCounter", 0f); + SetFloat(seam, program, "windWaveCounterHighFreq", 0f); + SetFloat(seam, program, "waterWaveCounter", 0f); + SetFloat(seam, program, "windSpeed", 0f); + SetFloat(seam, program, "globalWarpIntensity", 0f); + SetFloat(seam, program, "glitchWaviness", 0f); + SetFloat(seam, program, "windWaveIntensity", 0f); + SetFloat(seam, program, "waterWaveIntensity", 0f); + SetInt(seam, program, "perceptionEffectId", 1); + SetFloat(seam, program, "perceptionEffectIntensity", 0f); + SetFloat3(seam, program, "playerpos", 0f, 0f, 0f); + SetFloat3(seam, program, "origin", 0f, 0f, 0f); + + SetFloat(seam, program, "prevTimeCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounterHighFreq", 0f); + // Off a lattice point, so the gradient noise the wave samples is not + // trivially zero along the quad. + SetFloat(seam, program, "prevWaterWaveCounter", 1.234f); + SetFloat(seam, program, "prevWindSpeed", 0f); + SetFloat(seam, program, "prevGlobalWarpIntensity", 0f); + SetFloat(seam, program, "prevGlitchWaviness", 0f); + SetFloat(seam, program, "prevWindWaveIntensity", 0f); + SetFloat(seam, program, "prevWaterWaveIntensity", previousWaterWaveIntensity); + SetInt(seam, program, "prevPerceptionEffectId", 1); + SetFloat(seam, program, "prevPerceptionEffectIntensity", 0f); + SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs new file mode 100644 index 00000000..573a3882 --- /dev/null +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -0,0 +1,409 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P4 liquid velocity pass (TAA-PLAN.md accuracy +/// rule 7): the chunkliquidmotion program, the motion-only draw-buffer window, +/// the ChunkRenderer pass that drives it, its placement in the frame, and the +/// plumbing that has to ship it (patcher entries, shader registration, the +/// compatibility scanner). +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests) proves the numbers and +/// that colour attachment 0 is untouched. What these catch is the failure this +/// project keeps hitting: a change that works in the build tree and never +/// reaches the installed runtime because a patcher entry or a registration was +/// missed. +/// +public class TaaLiquidMotionCoverageTests +{ + // ------------------------------------------------------------- the shader + + [Fact] + public void TheLiquidWriterEmitsTheMotionContractAndNothingElse() + { + string vertex = Read("sources/shaders/chunkliquidmotion.vsh"); + string fragment = Read("sources/shaders/chunkliquidmotion.fsh"); + + // Compiled in only while TAA is on, exactly as the P3 writers are. + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("#if TAAMOTION > 0", fragment); + + // Previous transforms and the camera's own movement. + Assert.Contains("uniform mat4 prevProjectionMatrix;", vertex); + Assert.Contains("uniform mat4 prevModelViewMatrix;", vertex); + Assert.Contains("uniform vec3 cameraPosDelta;", vertex); + Assert.Contains("out vec4 taaPrevClip;", vertex); + + // prevRel = truePos + cameraPosDelta, warped with the previous state, + // through the previous unjittered projection and view (accuracy rule 4). + Assert.Contains("WarpState taaPrev = previousWarpState();", vertex); + Assert.Contains("vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0);", vertex); + Assert.Contains("taaPrevPos = taaLiquidWorldPos(taaPrev, taaPrevPos);", vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos);", vertex); + + // The fragment contract taa-resolve.fsh consumes. + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + Assert.Contains("uniform vec2 taaRenderSize;", fragment); + Assert.Contains("uniform vec2 taaJitterPx;", fragment); + Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + Assert.Contains( + "outMotion = vec4(prevPixel - currentPixel, clamp(taaLiquidReactive, 0.0, 1.0), gl_FragCoord.z);", + fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) {", fragment); + + // Foam and the flow-UV scroll animate in place, so the surface is + // reactive even where it reprojects perfectly. + Assert.Contains("uniform float taaLiquidReactive = 0.3;", fragment); + + // And NOTHING else is written. The pass re-draws geometry that has + // already been shaded into Primary through the OIT merge; a second + // colour output would overwrite that image. + foreach (string forbidden in new[] { "outColor", "outGlow", "outGNormal", "outGPosition", "OIT(" }) + { + Assert.False(fragment.Contains(forbidden, StringComparison.Ordinal), + "the liquid velocity pass must write only the motion attachment, found: " + forbidden); + } + // Exactly two output declarations: the real one and the TAA-off dummy + // that keeps the program compilable when the writer is preprocessed out. + Assert.Equal(2, Count(fragment, "layout(location")); + Assert.Contains("#else", fragment); + Assert.Contains("layout(location = 0) out vec4 outMotion;", fragment); + } + + /// + /// The velocity pass has to land on exactly the surface chunkliquid.vsh + /// shaded, or it depth-tests against a fragment a fraction of a pixel away + /// and reports another surface's motion. That means the same liquid wave + /// warp, the same divisor from the same water flags, and the same + /// "pretend the surface is closer" w-offset. + /// + [Fact] + public void TheVertexPathIsChunkliquidsPositionPathVerbatim() + { + string ours = Read("sources/shaders/chunkliquidmotion.vsh"); + + // The offset is applied to BOTH clip positions: it moves where the + // fragment lands, so leaving it off the previous one reports it as motion. + Assert.Contains("gl_Position.w += 0.0008 / max(0.1, gl_Position.z);", ours); + Assert.Contains("taaPrevClip.w += 0.0008 / max(0.1, taaPrevClip.z);", ours); + + // Both evaluations go through one function, so the current and previous + // positions cannot drift apart. + Assert.Contains("vec4 taaLiquidWorldPos(WarpState st, vec4 worldPos)", ours); + Assert.Contains("vec4 worldPos = taaLiquidWorldPos(currentWarpState(), truePos);", ours); + Assert.Contains("#include vertexwarp.vsh", ours); + + string? vanillaPath = TryFind(".vanilla/win-x64/vintagestory/assets/game/shaders/chunkliquid.vsh"); + // The vanilla shaders are proprietary and never committed; a checkout + // that has not bootstrapped has nothing to compare against. + if (vanillaPath == null) return; + + string vanilla = File.ReadAllText(vanillaPath); + + // The warp branch, from vanilla's main() and from our function, with the + // only permitted difference undone: the warp reaches the state-taking + // overload instead of the currentWarpState() wrapper. + string vanillaBranch = Between(vanilla, "if ((waterFlagsIn & 1) == 1) {", "vec4 cameraPos", 0); + int ourFunction = ours.IndexOf("vec4 taaLiquidWorldPos(WarpState st, vec4 worldPos)", StringComparison.Ordinal); + Assert.True(ourFunction > 0); + string ourBranch = Between(ours, "if ((waterFlagsIn & 1) == 1) {", "return worldPos;", ourFunction) + .Replace("applyLiquidWarpingState(st, ", "applyLiquidWarping("); + + Assert.Equal(Squash(vanillaBranch), Squash(ourBranch)); + } + + // ------------------------------------------------- the draw-buffer window + + /// + /// The window this pass opens is narrower than the P3 one: the motion + /// attachment is not ADDED to the set a shading pass writes, it REPLACES it, + /// so a second draw of already-shaded geometry cannot touch the colour, glow + /// or G-buffer attachments. + /// + [Fact] + public void ThePlatformOpensAMotionOnlyWindowOnBothBackends() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.Contains("public bool BeginMotionOnlyWrite()", platform); + Assert.Contains("public void EndMotionOnlyWrite()", platform); + + // Device path: the mask is the single motion bit, not the prefix mask. + Assert.Contains("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, 1 << MotionAttachmentIndex);", platform); + + // GL path: GL_NONE in every slot below the motion attachment, and a + // cached array - the pass runs once a frame, but the P3 window's rule + // that the draw-buffer sets are built once and kept applies here too. + Assert.Contains("private DrawBuffersEnum[] optimumMotionOnlyDrawBuffers;", platform); + Assert.Contains("optimumMotionOnlyDrawBuffers[optimumDb] = (DrawBuffersEnum)0;", platform); + Assert.Contains( + "optimumMotionOnlyDrawBuffers[MotionAttachmentIndex] = (DrawBuffersEnum)(36064 + MotionAttachmentIndex);", + platform); + Assert.Contains("GL.DrawBuffers(optimumMotionOnlyDrawBuffers.Length, optimumMotionOnlyDrawBuffers);", platform); + + // The same guards as the P3 window, including the one that keeps the two + // backends from disagreeing about which framebuffer the mask belongs to. + int begin = platform.IndexOf("public bool BeginMotionOnlyWrite()", StringComparison.Ordinal); + int drawBuffers = platform.IndexOf( + "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, 1 << MotionAttachmentIndex);", + begin, StringComparison.Ordinal); + Assert.True(drawBuffers > begin); + string guards = platform.Substring(begin, drawBuffers - begin); + Assert.Contains("if (OptimumMotionWriteActive) return false;", guards); + Assert.Contains("if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false;", guards); + Assert.Contains("if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false;", guards); + Assert.Contains("if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false;", guards); + + // Replace blending on the motion attachment, same as the P3 window. + int end = platform.IndexOf("public void EndMotionOnlyWrite()", begin, StringComparison.Ordinal); + Assert.True(end > begin); + Assert.Contains("ApplyOptimumMotionBlendState();", platform.Substring(begin, end - begin)); + + // Closing restores Primary's default set - the same restore the P3 + // window does, reached through it rather than duplicated. + Assert.Contains("EndMotionWrite();", platform.Substring(end)); + } + + // --------------------------------------------------------------- the pass + + [Fact] + public void ChunkRendererDrawsTheLiquidPoolsIntoTheMotionAttachmentOnly() + { + string chunk = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + string pass = MethodBodyAfter(chunk, "internal void RenderLiquidMotion(float deltaTime)"); + + // Off entirely with TAA off, and never without the window. + Assert.Contains("if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa)", pass); + Assert.Contains("if (!optimumPlatform.BeginMotionOnlyWrite())", pass); + Assert.Contains("optimumPlatform.EndMotionOnlyWrite();", pass); + // Closed on every path, including a throwing draw. + Assert.Contains("finally", pass); + + // Depth test on against Primary's depth, and the depth WRITE on: the OIT + // liquid draw writes no depth (LoadFrameBuffer(Transparent) drops the + // mask), so without this the resolve's writer-depth test rejects every + // liquid pixel and the pass buys nothing. + Assert.Contains("platform.GlEnableDepthTest();", pass); + Assert.Contains("platform.GlDepthMask(flag: true);", pass); + // No blending: the motion attachment is a vector, not a colour. + Assert.Contains("platform.GlToggleBlend(on: false);", pass); + // Culling off, as the OIT liquid draw runs. + Assert.Contains("platform.GlDisableCullFace();", pass); + // And the state the AfterOIT stage left is handed back, because this + // pass runs after that stage and the post chain starts from it. + Assert.Contains("platform.GlToggleBlend(on: true);", pass); + + // The same jittered projection and terrain view the OIT liquid draw + // used, the previous-frame transforms from the shared contract helper, + // and the reactive value. + Assert.Contains("liquidMotion.UniformMatrix(\"projectionMatrix\", game.CurrentProjectionMatrix);", pass); + Assert.Contains("liquidMotion.UniformMatrix(\"modelViewMatrix\", game.CurrentModelViewMatrix);", pass); + Assert.Contains("game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin);", pass); + Assert.Contains("SetOptimumMotionUniforms(liquidMotion);", pass); + Assert.Contains("liquidMotion.Uniform(\"taaLiquidReactive\", OptimumLiquidReactive);", pass); + + // Pool 4 is the liquid pool - the same one RenderOIT and the LiquidDepth + // prepass draw - and the SSBO path is off for it there too. + Assert.Contains("poolsByRenderPass[4][i].Render(cameraPos, \"origin\");", pass); + Assert.Contains("game.api.renderapi.useSSBOs = false;", pass); + + // The reactive value the plan starts from. + Assert.Contains("internal const float OptimumLiquidReactive = 0.3f;", chunk); + } + + /// + /// Placement is the whole reason this is a separate pass. It has to run + /// after the OIT merge (the liquid it re-draws was shaded into the + /// Transparent target), after every AfterOIT renderer (it writes depth for + /// the water surface, which would otherwise occlude geometry drawn later + /// that is legitimately visible through water), and before the resolve, + /// which only reads the motion attachment in RenderPostprocessingEffects. + /// + [Fact] + public void TheVelocityPassRunsAfterTheAfterOitStageAndInsideTheTemporalWindow() + { + // Read from the source of truth, not the patch: ordering is a property of + // the whole method, and a patch only carries its hunks plus three lines + // of context. That the method ships at all is asserted separately, by + // the patcher-target check below. + string main = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + int merge = main.IndexOf("Platform.MergeTransparentRenderPass();", StringComparison.Ordinal); + int afterOit = main.IndexOf("TriggerRenderStage(EnumRenderStage.AfterOIT, dt);", StringComparison.Ordinal); + int liquidMotion = main.IndexOf("chunkRenderer.RenderLiquidMotion(dt);", StringComparison.Ordinal); + int closeJitter = main.IndexOf("OptimumTemporal.Frame.JitterActive = false;", StringComparison.Ordinal); + + Assert.True(merge >= 0, "the OIT merge is not in the patched body"); + Assert.True(afterOit >= 0, "the AfterOIT stage is not in the patched body"); + Assert.True(liquidMotion >= 0, "the liquid velocity pass is never called"); + Assert.True(closeJitter >= 0, "the jitter window is never closed"); + + Assert.True(merge < liquidMotion, "the velocity pass must run after the OIT merge"); + Assert.True(afterOit < liquidMotion, "the velocity pass must run after every AfterOIT renderer"); + Assert.True(liquidMotion < closeJitter, "the velocity pass must run inside the temporal window"); + + // Skipped when the transparent pass that shaded the liquid did not run. + Assert.Contains("if (doTransparentRenderPass && chunkRenderer != null)", main); + } + + // -------------------------------------------------------- the ship + + [Fact] + public void TheProgramIsRegisteredAndOptional() + { + string registry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + + Assert.Contains( + "RegisterOptimumShaderProgram(\"chunkliquidmotion\", ShaderPrograms.ChunkLiquidMotion = new ShaderProgram());", + registry); + + // Optimum-only programs mark LoadError on a failed compile instead of + // failing the whole shader load, the way the FSR and TAA programs do. + Assert.Contains("shaderProgram == ShaderPrograms.ChunkLiquidMotion", registry); + } + + [Fact] + public void CecilPatcherShipsEveryLiquidMotionMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"BeginMotionOnlyWrite\"", patcher); + Assert.Contains("\"EndMotionOnlyWrite\"", patcher); + Assert.Contains("\"optimumMotionOnlyDrawBuffers\"", patcher); + Assert.Contains("\"ChunkLiquidMotion\"", patcher); + Assert.Contains("\"RenderLiquidMotion\"", patcher); + Assert.Contains("\"OptimumLiquidReactive\"", patcher); + + // The two bodies that call into all of it. + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"MainRenderLoop\", 1", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ShaderRegistry\", \"registerDefaultShaderProgramsPre\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ShaderRegistry\", \"loadRegisteredShaderPrograms\", 0", patcher); + } + + /// + /// A mod that ships its own chunkliquid.vsh moves the water surface the + /// velocity pass is aiming at; one that ships chunkliquidmotion replaces the + /// writer outright. Either way the vectors stop describing the surface that + /// was shaded, so TAA is disabled rather than fed wrong data. + /// + [Fact] + public void TheCompatibilityScannerDisablesTaaForAnExternalLiquidShader() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + + foreach (string shader in new[] + { + "chunkliquid.vsh", "chunkliquidmotion.vsh", "chunkliquidmotion.fsh", + }) + { + Assert.Contains("HasExternalShader(report, \"" + shader + "\")", scanner); + } + Assert.Contains("AddFeatureDecision(report, \"Taa\", externalMotionShader,", scanner); + } + + // ----------------------------------------------------------------- helpers + + private static string MethodBodyAfter(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such method: " + signature); + return signature + BodyOf(source, signature); + } + + private static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + /// The text between two markers, searching from . + private static string Between(string source, string start, string end, int from) + { + int begin = source.IndexOf(start, from, StringComparison.Ordinal); + Assert.True(begin >= 0, "marker not found: " + start); + int stop = source.IndexOf(end, begin, StringComparison.Ordinal); + Assert.True(stop > begin, "marker not found: " + end); + return source.Substring(begin, stop - begin); + } + + /// Every whitespace run collapsed to one space, so indentation and + /// vanilla's trailing whitespace cannot fail the comparison. + private static string Squash(string text) + { + var builder = new System.Text.StringBuilder(text.Length); + bool space = false; + foreach (char c in text) + { + if (char.IsWhiteSpace(c)) { space = true; continue; } + if (space && builder.Length > 0) builder.Append(' '); + space = false; + builder.Append(c); + } + return builder.ToString(); + } + + private static int Count(string source, string value) + { + int count = 0; + int offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch index 770f0222..dc2a9574 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs -index 431e51a..dfb4654 100644 +index 431e51a..d77cdb4 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs @@ -1,9 +1,11 @@ @@ -14,7 +14,7 @@ index 431e51a..dfb4654 100644 namespace Vintagestory.Client.NoObf; public class ChunkRenderer -@@ -40,10 +42,26 @@ public class ChunkRenderer +@@ -40,10 +42,36 @@ public class ChunkRenderer private float lastSetRainFall; @@ -35,13 +35,23 @@ index 431e51a..dfb4654 100644 + internal List edgePoolLocationsScratch; + + private float optimumTextureLodBias; ++ ++ /// ++ /// Optimum TAA (P4): the reactive value the liquid velocity pass stamps into ++ /// the motion attachment's blue channel. Foam, the flow-UV scroll and the ++ /// specular sparkle animate in place - the surface reprojects perfectly while ++ /// its shading does not - so the resolve is told to keep at least this much ++ /// of the current frame. TAA-PLAN.md's Conventions call 0.3 the starting ++ /// value, to be tuned by measurement. ++ /// ++ internal const float OptimumLiquidReactive = 0.3f; + public ChunkRenderer(int[] textureIds, ClientMain game) { this.textureIds = textureIds; platform = game.Platform; this.game = game; -@@ -105,10 +123,61 @@ public class ChunkRenderer +@@ -105,10 +133,61 @@ public class ChunkRenderer foreach (EnumChunkRenderPass item in values) { AddPoolsForAtlasAndPass(atlas, item, modelDataPoolMaxVertexSize, modelDataPoolMaxIndexSize, maxPartsPerPool); @@ -103,7 +113,7 @@ index 431e51a..dfb4654 100644 private void AddPoolsForAtlasAndPass(int atlas, EnumChunkRenderPass pass, int maxVertices, int maxIndices, int maxPartsPerPool) { switch (pass) -@@ -142,10 +211,16 @@ public class ChunkRenderer +@@ -142,10 +221,16 @@ public class ChunkRenderer culler.CullInvisibleChunks(); } @@ -120,7 +130,7 @@ index 431e51a..dfb4654 100644 subPixelPaddingX = game.BlockAtlasManager.SubPixelPaddingX; subPixelPaddingY = game.BlockAtlasManager.SubPixelPaddingY; Vec3d cameraPos = game.EntityPlayer.CameraPos; -@@ -170,10 +245,11 @@ public class ChunkRenderer +@@ -170,10 +255,11 @@ public class ChunkRenderer game.Platform.LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -132,7 +142,7 @@ index 431e51a..dfb4654 100644 RuntimeStats.availableTriangles = 0; accum += dt; if (accum > 5f) -@@ -208,23 +284,59 @@ public class ChunkRenderer +@@ -208,23 +294,59 @@ public class ChunkRenderer chunkshadowmap.Tex2d2D = textureIds[j]; poolsByRenderPass[5][j].Render(cameraPos, "origin", frustumCullMode); } @@ -143,9 +153,7 @@ index 431e51a..dfb4654 100644 + // At 60-270 blocks a chunk subtends 1-3 texels in the 1024² shadow map; + // thin foliage casts no resolvable shadow at that distance. + if (!ClientSettings.OptimumShadowFarVegetation || game.currentRenderStage != EnumRenderStage.ShadowFar) - { -- chunkshadowmap.Tex2d2D = textureIds[k]; -- poolsByRenderPass[2][k].Render(cameraPos, "origin", frustumCullMode); ++ { + OptimumDiagnostics.ShadowFarVegetation.Hit(); + for (int k = 0; k < textureIds.Length; k++) + { @@ -154,7 +162,9 @@ index 431e51a..dfb4654 100644 + } + } + else -+ { + { +- chunkshadowmap.Tex2d2D = textureIds[k]; +- poolsByRenderPass[2][k].Render(cameraPos, "origin", frustumCullMode); + OptimumDiagnostics.ShadowFarVegetation.Skip(); } for (int l = 0; l < textureIds.Length; l++) @@ -195,7 +205,7 @@ index 431e51a..dfb4654 100644 Vec3d cameraPos = game.EntityPlayer.CameraPos; ScreenManager.FrameProfiler.Mark("rend3D-ret-begin"); platform.GlDepthMask(flag: true); -@@ -232,10 +344,15 @@ public class ChunkRenderer +@@ -232,10 +354,15 @@ public class ChunkRenderer platform.GlToggleBlend(on: true); platform.GlEnableCullFace(); game.GlMatrixModeModelView(); @@ -211,7 +221,7 @@ index 431e51a..dfb4654 100644 chunkopaque.CameraUnderwater = game.shUniforms.CameraUnderwater; chunkopaque.RgbaFogIn = game.AmbientManager.BlendedFogColor; chunkopaque.RgbaAmbientIn = game.AmbientManager.BlendedAmbientColor; -@@ -249,10 +366,14 @@ public class ChunkRenderer +@@ -249,10 +376,14 @@ public class ChunkRenderer chunkopaque.Uniform("subpixelPaddingX", subPixelPaddingX); chunkopaque.Uniform("subpixelPaddingY", subPixelPaddingY); chunkopaque.SunPosition = game.GameWorldCalendar.SunPositionNormalized; @@ -226,7 +236,7 @@ index 431e51a..dfb4654 100644 chunkopaque.TerrainTex2D = textureIds[i]; chunkopaque.TerrainTexLinear2D = textureIds[i]; poolsByRenderPass[0][i].Render(cameraPos, "origin"); -@@ -268,10 +389,14 @@ public class ChunkRenderer +@@ -268,10 +399,14 @@ public class ChunkRenderer chunktopsoil.ProjectionMatrix = game.CurrentProjectionMatrix; chunktopsoil.ModelViewMatrix = game.CurrentModelViewMatrix; chunktopsoil.BlockTextureSize = blockTextureSize; @@ -241,7 +251,7 @@ index 431e51a..dfb4654 100644 chunktopsoil.TerrainTex2D = textureIds[j]; chunktopsoil.TerrainTexLinear2D = textureIds[j]; poolsByRenderPass[5][j].Render(cameraPos, "origin"); -@@ -314,23 +439,42 @@ public class ChunkRenderer +@@ -314,23 +449,42 @@ public class ChunkRenderer chunkopaque.TerrainTexLinear2D = textureIds[m]; poolsByRenderPass[8][m].Render(cameraPos, "origin"); } @@ -293,7 +303,110 @@ index 431e51a..dfb4654 100644 internal void RenderOIT(float deltaTime) { -@@ -407,10 +551,16 @@ public class ChunkRenderer +@@ -402,15 +556,119 @@ public class ChunkRenderer + chunktransparent.Stop(); + game.GlPopMatrix(); + ScreenManager.FrameProfiler.Mark("rend3D-ret-tp"); + } + ++ /// ++ /// Optimum TAA (P4): the liquid velocity pass (TAA-PLAN.md accuracy rule 7). ++ /// ++ /// The OIT liquid draw in renders into the ++ /// Transparent target with six oit.fsh outputs, so it cannot also write ++ /// Primary's motion attachment. Instead the same liquid pools are drawn once ++ /// more here, into Primary, with a program that writes NOTHING but the motion ++ /// attachment - every other colour attachment is masked out of the ++ /// draw-buffer set for the duration of the pass, so the image the OIT merge ++ /// produced is untouched. ++ /// ++ /// Placement: called from ClientMain.MainRenderLoop straight after the ++ /// AfterOIT stage, which is after the merge and after every AfterOIT ++ /// renderer (decals, the terrain overlay, AfterOIT entities), and still ++ /// inside the temporal window - the resolve does not run until ++ /// RenderPostprocessingEffects. Drawing it earlier would let the depth this ++ /// pass writes occlude geometry that is legitimately visible through water. ++ /// ++ /// Depth: the test is on (LEQUAL against Primary's depth, which is where the ++ /// opaque scene is, so a water surface in front of the terrain passes and one ++ /// behind it does not), and the WRITE is on. The OIT liquid draw writes no ++ /// depth at all - LoadFrameBuffer(Transparent) disables the depth mask - so ++ /// without this the motion attachment would carry the water surface's window ++ /// depth while the depth buffer carried the terrain behind it, the resolve's ++ /// writer-depth test would reject every liquid pixel, and the whole pass ++ /// would buy nothing. Writing it also hands the resolve the water surface's ++ /// own linear depth, which is the depth the vector belongs to. The cost is ++ /// that passes after this one which depth-test against Primary - the ++ /// AfterFinalComposition overlays - now see the water surface; that is ++ /// outside the temporal window and only happens with TAA on. ++ /// ++ /// Depth writes make draw order irrelevant: the buffer only ever decreases, ++ /// so the last fragment that passes the test is the nearest one, and the ++ /// motion attachment ends up holding the front-most liquid surface's vector - ++ /// the same surface the OIT pass shaded. ++ /// ++ internal void RenderLiquidMotion(float deltaTime) ++ { ++ if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) ++ { ++ return; ++ } ++ ClientPlatformWindows optimumPlatform = platform as ClientPlatformWindows; ++ if (optimumPlatform == null) ++ { ++ return; ++ } ++ ShaderProgram liquidMotion = ShaderPrograms.ChunkLiquidMotion; ++ if (liquidMotion == null || liquidMotion.LoadError || liquidMotion.Disposed) ++ { ++ return; ++ } ++ if (!optimumPlatform.BeginMotionOnlyWrite()) ++ { ++ return; ++ } ++ try ++ { ++ Vec3d cameraPos = game.EntityPlayer.CameraPos; ++ game.GlMatrixModeModelView(); ++ game.GlPushMatrix(); ++ game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); ++ platform.GlToggleBlend(on: false); ++ platform.GlEnableDepthTest(); ++ platform.GlDepthMask(flag: true); ++ // The OIT liquid draw runs with culling off (LoadFrameBuffer for the ++ // Transparent target disables it), so both faces of every liquid quad ++ // are candidates there and have to be here too. ++ platform.GlDisableCullFace(); ++ liquidMotion.Use(); ++ liquidMotion.UniformMatrix("projectionMatrix", game.CurrentProjectionMatrix); ++ liquidMotion.UniformMatrix("modelViewMatrix", game.CurrentModelViewMatrix); ++ liquidMotion.Uniform("taaLiquidReactive", OptimumLiquidReactive); ++ SetOptimumMotionUniforms(liquidMotion); ++ // The liquid pool has no SSBO layout (RenderOIT and the LiquidDepth ++ // prepass both turn it off for exactly this pass's geometry). ++ bool useSSBOs = game.api.renderapi.useSSBOs; ++ game.api.renderapi.useSSBOs = false; ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ poolsByRenderPass[4][i].Render(cameraPos, "origin"); ++ } ++ game.api.renderapi.useSSBOs = useSSBOs; ++ liquidMotion.Stop(); ++ game.GlPopMatrix(); ++ // Hand back the state the AfterOIT stage left, since this pass runs ++ // after it: the last AfterOIT renderer (SystemRenderEntities) leaves ++ // culling disabled and blending on, and the post chain that follows ++ // has always started from that. ++ platform.GlToggleBlend(on: true); ++ ScreenManager.FrameProfiler.Mark("rend3D-ret-lqmv"); ++ } ++ finally ++ { ++ optimumPlatform.EndMotionOnlyWrite(); ++ } ++ } ++ internal void RenderAfterOIT(float deltaTime) { game.GlPushMatrix(); @@ -310,7 +423,7 @@ index 431e51a..dfb4654 100644 platform.GlToggleBlend(on: false); platform.GlEnableDepthTest(); chunkopaque.Use(); -@@ -425,28 +575,36 @@ public class ChunkRenderer +@@ -425,28 +683,36 @@ public class ChunkRenderer chunkopaque.DayLight = game.shUniforms.SkyDaylight; chunkopaque.HorizonFog = game.AmbientManager.BlendedCloudDensity; chunkopaque.HaxyFade = 1; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index b0e81d6b..9930c48c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..fa55f21 100644 +index 67feafa..7bdb390 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -326,8 +326,25 @@ index 67feafa..fa55f21 100644 { PerspectiveProjectionMat[i] = top[i]; PerspectiveViewMat[i] = top2[i]; -@@ -1180,10 +1306,15 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1176,14 +1302,32 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + Platform.GlDepthMask(flag: true); + Platform.GlEnableDepthTest(); + Platform.GlCullFaceBack(); + Platform.GlEnableCullFace(); TriggerRenderStage(EnumRenderStage.AfterOIT, dt); ++ // Optimum TAA (P4): the liquid velocity pass, accuracy rule 7. It has to ++ // sit after the OIT merge (the liquid it re-draws was shaded into the ++ // Transparent target, which cannot reach Primary's motion attachment) and ++ // after every AfterOIT renderer, because it writes depth for the water ++ // surface and anything drawn later that depth-tests would be occluded by ++ // water it should be visible through. It is still inside the temporal ++ // window: the resolve does not run until RenderPostprocessingEffects. ++ // A no-op with TAA off, and skipped entirely when the transparent pass ++ // that shaded the liquid did not run. ++ if (doTransparentRenderPass && chunkRenderer != null) ++ { ++ chunkRenderer.RenderLiquidMotion(dt); ++ } } public void RenderAfterPostProcessing(float dt) @@ -342,7 +359,7 @@ index 67feafa..fa55f21 100644 dt = DeltaTimeLimiter; } TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); -@@ -1420,10 +1551,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1420,10 +1564,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo { float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); @@ -357,7 +374,7 @@ index 67feafa..fa55f21 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1700,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1713,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -390,7 +407,7 @@ index 67feafa..fa55f21 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2268,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2281,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -415,7 +432,7 @@ index 67feafa..fa55f21 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2697,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2710,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -429,7 +446,7 @@ index 67feafa..fa55f21 100644 } public void DoReconnect() -@@ -3531,6 +3688,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3701,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 688fcfb9..85b518e2 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..6179fd1 100644 +index 6edf0c9..2459097 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -102,7 +102,7 @@ index 6edf0c9..6179fd1 100644 private Logger logger; private int doResize; -@@ -93,10 +182,88 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,95 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -149,6 +149,13 @@ index 6edf0c9..6179fd1 100644 + + private DrawBuffersEnum[] optimumMotionDrawBuffersOff; + ++ /// ++ /// Optimum TAA (P4): the draw-buffer set of the liquid velocity pass - the ++ /// motion attachment alone, every other colour attachment GL_NONE. Built ++ /// once for the same reason as the two above. ++ /// ++ private DrawBuffersEnum[] optimumMotionOnlyDrawBuffers; ++ + private bool TaaTargetsReady; + + // Optimum TAA resolve state (P2): which history slot is written this frame, @@ -191,7 +198,7 @@ index 6edf0c9..6179fd1 100644 private bool serverRunning; private bool gamepause; -@@ -256,10 +423,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +430,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -215,7 +222,7 @@ index 6edf0c9..6179fd1 100644 get { return serverRunning; -@@ -278,11 +458,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,11 +465,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -243,7 +250,7 @@ index 6edf0c9..6179fd1 100644 GL.BindFramebuffer((FramebufferTarget)36160, 0); return; } -@@ -297,11 +493,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -297,11 +500,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -267,7 +274,7 @@ index 6edf0c9..6179fd1 100644 } public override bool GlErrorChecking { get; set; } -@@ -314,10 +522,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -314,10 +529,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } set { @@ -288,7 +295,7 @@ index 6edf0c9..6179fd1 100644 if (!supportsGlDebugMode) { throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -335,11 +553,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -335,11 +560,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } glDebugMode = value; } @@ -317,7 +324,7 @@ index 6edf0c9..6179fd1 100644 public override bool MouseGrabbed { -@@ -478,41 +712,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,41 +719,142 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -464,7 +471,7 @@ index 6edf0c9..6179fd1 100644 public void LogAndTestHardwareInfosStage1() { -@@ -533,10 +868,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -533,10 +875,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } @@ -502,7 +509,7 @@ index 6edf0c9..6179fd1 100644 logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +938,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -576,10 +945,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CheckGlError("testhwinfo"); } @@ -521,7 +528,7 @@ index 6edf0c9..6179fd1 100644 public override string GetFrameworkInfos() { -@@ -702,24 +1072,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1079,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -564,7 +571,7 @@ index 6edf0c9..6179fd1 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1184,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1191,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -588,7 +595,7 @@ index 6edf0c9..6179fd1 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1417,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1424,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -626,7 +633,7 @@ index 6edf0c9..6179fd1 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1460,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1467,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -646,7 +653,7 @@ index 6edf0c9..6179fd1 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1491,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1498,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -663,7 +670,7 @@ index 6edf0c9..6179fd1 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1536,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1543,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -696,7 +703,7 @@ index 6edf0c9..6179fd1 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,12 +1600,458 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1607,458 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -1155,7 +1162,7 @@ index 6edf0c9..6179fd1 100644 //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2083,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2090,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1170,7 +1177,7 @@ index 6edf0c9..6179fd1 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2110,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2117,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1187,7 +1194,7 @@ index 6edf0c9..6179fd1 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2155,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2162,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1226,7 +1233,7 @@ index 6edf0c9..6179fd1 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2368,53 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2375,53 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1280,7 +1287,7 @@ index 6edf0c9..6179fd1 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2523,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2530,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1295,7 +1302,7 @@ index 6edf0c9..6179fd1 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,12 +2546,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,12 +2553,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1385,7 +1392,7 @@ index 6edf0c9..6179fd1 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2647,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2654,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1414,7 +1421,7 @@ index 6edf0c9..6179fd1 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,38 +2681,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,38 +2688,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1550,7 +1557,7 @@ index 6edf0c9..6179fd1 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2840,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2847,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1591,7 +1598,7 @@ index 6edf0c9..6179fd1 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2883,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2890,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1658,7 +1665,7 @@ index 6edf0c9..6179fd1 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2953,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2960,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1680,7 +1687,7 @@ index 6edf0c9..6179fd1 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2977,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +2984,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1707,7 +1714,7 @@ index 6edf0c9..6179fd1 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +3002,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,24 +3009,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1849,7 +1856,7 @@ index 6edf0c9..6179fd1 100644 //IL_000f: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Unknown result type (might be due to invalid IL or missing references) //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3145,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3152,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1882,7 +1889,7 @@ index 6edf0c9..6179fd1 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3180,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3187,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1944,7 +1951,7 @@ index 6edf0c9..6179fd1 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3257,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3264,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1991,7 +1998,7 @@ index 6edf0c9..6179fd1 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3306,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3313,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2027,7 +2034,7 @@ index 6edf0c9..6179fd1 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,55 +3353,297 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,55 +3360,363 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2177,32 +2184,24 @@ index 6edf0c9..6179fd1 100644 + { + optimumMotionDrawBuffersOn[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); + } - } ++ } + GL.DrawBuffers(optimumMotionDrawBuffersOn.Length, optimumMotionDrawBuffersOn); - } ++ } + OptimumMotionWriteActive = true; + // Blending is per-attachment state that GlToggleBlend re-applies whenever + // a pass turns blending on; motion must never blend, so the mask change + // has to be paired with it right away for a pass that is already blending. + ApplyOptimumMotionBlendState(); + return true; - } - -- private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) ++ } ++ + /// + /// Optimum TAA (P3): restores Primary's default draw-buffer set (the 2 or 4 + /// colour attachments the setup left bound), taking the motion attachment + /// back out. Safe to call when returned false. + /// + public void EndMotionWrite() - { -- //IL_0000: Unknown result type (might be due to invalid IL or missing references) -- //IL_0006: Invalid comparison between Unknown and I4 -- //IL_0026: Unknown result type (might be due to invalid IL or missing references) -- //IL_0030: Unknown result type (might be due to invalid IL or missing references) -- //IL_0040: Unknown result type (might be due to invalid IL or missing references) -- //IL_0046: Invalid comparison between Unknown and I4 -- if ((int)type != 33361) ++ { + if (!OptimumMotionWriteActive) return; + OptimumMotionWriteActive = false; + if (MotionAttachmentIndex < 0) return; @@ -2210,10 +2209,7 @@ index 6edf0c9..6179fd1 100644 + + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) - { -- string text = Marshal.PtrToStringAnsi(message, length); -- Logger.Notification("{0} {1} | {2}", severity, type, text); -- if ((int)type == 33356) ++ { + // MotionAttachmentIndex is also the size of the default set (2 without + // the SSAO G-buffer, 4 with it), because the attachment was appended + // after it. @@ -2224,14 +2220,14 @@ index 6edf0c9..6179fd1 100644 + { + optimumMotionDrawBuffersOff = new DrawBuffersEnum[MotionAttachmentIndex]; + for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) - { -- throw new Exception(text); ++ { + optimumMotionDrawBuffersOff[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); } } + GL.DrawBuffers(optimumMotionDrawBuffersOff.Length, optimumMotionDrawBuffersOff); -+ } -+ + } + +- private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) + /// + /// Optimum TAA (P3): forces replace-blending on the motion attachment. + /// Terrain passes 2 and 8 draw with blending on, and a blended motion vector @@ -2239,17 +2235,94 @@ index 6edf0c9..6179fd1 100644 + /// neither. Mirrors what the GL path already does for the SSAO G-buffer. + /// + private void ApplyOptimumMotionBlendState() -+ { + { +- //IL_0000: Unknown result type (might be due to invalid IL or missing references) +- //IL_0006: Invalid comparison between Unknown and I4 +- //IL_0026: Unknown result type (might be due to invalid IL or missing references) +- //IL_0030: Unknown result type (might be due to invalid IL or missing references) +- //IL_0040: Unknown result type (might be due to invalid IL or missing references) +- //IL_0046: Invalid comparison between Unknown and I4 +- if ((int)type != 33361) + if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) -+ { + { +- string text = Marshal.PtrToStringAnsi(message, length); +- Logger.Notification("{0} {1} | {2}", severity, type, text); +- if ((int)type == 33356) + optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); + optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0); + return; + } + GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774); + GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0); ++ } ++ ++ /// ++ /// Optimum TAA (P4): opens the motion window with every OTHER colour ++ /// attachment masked out, for a pass that re-draws geometry only to record ++ /// its motion - the liquid velocity pass (TAA-PLAN.md accuracy rule 7). ++ /// ++ /// adds the motion attachment to the set a ++ /// shading pass already writes; this one replaces the set entirely, so the ++ /// colour, glow and SSAO G-buffer attachments Primary already holds - the ++ /// merged transparent image included - cannot be touched by a second draw of ++ /// the same geometry. The fragment shader declares no other output anyway; ++ /// the mask is what makes that true on the framebuffer as well, on both ++ /// backends (the device takes the mask directly, GL gets GL_NONE in every ++ /// other slot). ++ /// ++ /// Closed by , which restores Primary's ++ /// default set exactly as does. Same guards as ++ /// , including the Primary-is-bound one: ++ /// GL.DrawBuffers applies to the bound framebuffer while the device call ++ /// names one, and a caller under another target would make the two backends ++ /// disagree. ++ /// ++ /// Whether the motion attachment is now the only enabled one. ++ public bool BeginMotionOnlyWrite() ++ { ++ if (OptimumMotionWriteActive) return false; ++ if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false; ++ if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false; ++ if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return false; ++ if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false; ++ ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, 1 << MotionAttachmentIndex); ++ } ++ else ++ { ++ if (optimumMotionOnlyDrawBuffers == null || optimumMotionOnlyDrawBuffers.Length != MotionAttachmentIndex + 1) + { +- throw new Exception(text); ++ optimumMotionOnlyDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1]; ++ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) ++ { ++ // GL_NONE: a fragment output bound to this draw buffer is ++ // discarded, which is what keeps the shaded image intact. ++ optimumMotionOnlyDrawBuffers[optimumDb] = (DrawBuffersEnum)0; ++ } ++ optimumMotionOnlyDrawBuffers[MotionAttachmentIndex] = (DrawBuffersEnum)(36064 + MotionAttachmentIndex); + } ++ GL.DrawBuffers(optimumMotionOnlyDrawBuffers.Length, optimumMotionOnlyDrawBuffers); + } ++ OptimumMotionWriteActive = true; ++ ApplyOptimumMotionBlendState(); ++ return true; ++ } ++ ++ /// ++ /// Optimum TAA (P4): closes the window ++ /// opened. The restore is the same one does - ++ /// Primary's default colour set, motion back out - so this is that method ++ /// under the name that pairs with the Begin the caller used. ++ /// ++ public void EndMotionOnlyWrite() ++ { ++ EndMotionWrite(); } public override void BlitPrimaryToDefault() @@ -2340,7 +2413,7 @@ index 6edf0c9..6179fd1 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3691,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3764,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2366,7 +2439,7 @@ index 6edf0c9..6179fd1 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3724,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3797,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2388,7 +2461,7 @@ index 6edf0c9..6179fd1 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3753,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3826,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2486,7 +2559,7 @@ index 6edf0c9..6179fd1 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +3852,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +3925,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2689,7 +2762,7 @@ index 6edf0c9..6179fd1 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4061,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4134,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2856,7 +2929,7 @@ index 6edf0c9..6179fd1 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4231,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4304,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2880,7 +2953,7 @@ index 6edf0c9..6179fd1 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4260,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4333,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2918,7 +2991,7 @@ index 6edf0c9..6179fd1 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4320,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4393,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -2961,7 +3034,7 @@ index 6edf0c9..6179fd1 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4373,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4446,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3020,7 +3093,7 @@ index 6edf0c9..6179fd1 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4488,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4561,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3094,7 +3167,7 @@ index 6edf0c9..6179fd1 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4586,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4659,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3125,7 +3198,7 @@ index 6edf0c9..6179fd1 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4623,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4696,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3160,7 +3233,7 @@ index 6edf0c9..6179fd1 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4670,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4743,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3189,7 +3262,7 @@ index 6edf0c9..6179fd1 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4701,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4774,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3215,7 +3288,7 @@ index 6edf0c9..6179fd1 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +4728,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +4801,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3228,7 +3301,7 @@ index 6edf0c9..6179fd1 100644 return uBO; } -@@ -2605,10 +4742,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4815,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3245,7 +3318,7 @@ index 6edf0c9..6179fd1 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4794,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4867,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3290,7 +3363,7 @@ index 6edf0c9..6179fd1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4831,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4904,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3311,7 +3384,7 @@ index 6edf0c9..6179fd1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4850,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4923,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3332,7 +3405,7 @@ index 6edf0c9..6179fd1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4869,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4942,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3353,7 +3426,7 @@ index 6edf0c9..6179fd1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4888,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4961,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3374,7 +3447,7 @@ index 6edf0c9..6179fd1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4911,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4984,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3395,7 +3468,7 @@ index 6edf0c9..6179fd1 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4954,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5027,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3421,7 +3494,7 @@ index 6edf0c9..6179fd1 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5196,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5269,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3443,7 +3516,7 @@ index 6edf0c9..6179fd1 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5394,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5467,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3466,7 +3539,7 @@ index 6edf0c9..6179fd1 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5468,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5541,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3515,7 +3588,7 @@ index 6edf0c9..6179fd1 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5538,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5611,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3547,7 +3620,7 @@ index 6edf0c9..6179fd1 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5568,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5641,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3573,7 +3646,7 @@ index 6edf0c9..6179fd1 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5916,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5989,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3603,7 +3676,7 @@ index 6edf0c9..6179fd1 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5970,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6043,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch index a8db90bc..25104799 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -index f19d524..5fad806 100644 +index f19d524..41b023f 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -@@ -40,10 +40,18 @@ public static class ShaderPrograms +@@ -40,10 +40,23 @@ public static class ShaderPrograms public static ShaderProgramEntityanimated Entityanimated; @@ -15,6 +15,11 @@ index f19d524..5fad806 100644 + public static ShaderProgram TaaDebug; + + public static ShaderProgram TaaResolve; ++ ++ // Optimum TAA (P4): the liquid velocity pass (TAA-PLAN.md accuracy rule 7). ++ // Registered like the other Optimum-only programs, so a failed compile marks ++ // LoadError instead of failing the whole shader load. ++ public static ShaderProgram ChunkLiquidMotion; + public static ShaderProgramFindbright Findbright; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index 4541b2bf..aa7b493d 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..6177166 100644 +index 4a24e75..5ec03b8 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..6177166 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,156 @@ public class ShaderRegistry +@@ -181,39 +183,158 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -23,6 +23,8 @@ index 4a24e75..6177166 100644 + RegisterOptimumShaderProgram("fsr-rcas", ShaderPrograms.FsrRcas = new ShaderProgram()); + RegisterOptimumShaderProgram("taa-debug", ShaderPrograms.TaaDebug = new ShaderProgram()); + RegisterOptimumShaderProgram("taa-resolve", ShaderPrograms.TaaResolve = new ShaderProgram()); ++ // Optimum TAA (P4): the liquid velocity pass. ++ RegisterOptimumShaderProgram("chunkliquidmotion", ShaderPrograms.ChunkLiquidMotion = new ShaderProgram()); + } + + private static void RegisterOptimumShaderProgram(string name, ShaderProgram program) @@ -152,7 +154,7 @@ index 4a24e75..6177166 100644 + bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); + OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); + } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve) ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.ChunkLiquidMotion) + { + shaderProgram.LoadError |= !compiled; + } @@ -180,7 +182,7 @@ index 4a24e75..6177166 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +415,11 @@ public class ShaderRegistry +@@ -296,11 +417,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -193,7 +195,7 @@ index 4a24e75..6177166 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +452,48 @@ public class ShaderRegistry +@@ -333,10 +454,48 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/sources/shaders/chunkliquidmotion.fsh b/sources/shaders/chunkliquidmotion.fsh new file mode 100644 index 00000000..4d5d8b12 --- /dev/null +++ b/sources/shaders/chunkliquidmotion.fsh @@ -0,0 +1,67 @@ +#version 330 core +#extension GL_ARB_explicit_attrib_location: enable + +// Optimum TAA (P4): the fragment half of the liquid velocity pass. +// +// This shader writes ONE attachment - Primary's motion attachment - and no +// colour, no glow and no G-buffer: ChunkRenderer.RenderLiquidMotion masks every +// other attachment out of the draw-buffer set for the duration of the pass, on +// both backends, so the shaded image the OIT merge already produced is left +// exactly as it was. +// +// It does write depth. The OIT liquid draw cannot: LoadFrameBuffer(Transparent) +// disables the depth mask, so the water surface never reaches Primary's depth +// attachment (which the Transparent target shares) and Primary's depth at a +// water pixel is the opaque surface BEHIND the water. The resolve accepts a +// motion vector only where the writer's own window depth matches the depth +// buffer within abs(a - depth) <= max(2e-4, 8e-4 * depth), so a velocity pass +// that left depth alone would have every one of its vectors rejected and the +// water would fall back to camera reprojection - which is the ghosting this +// pass exists to remove. Writing the surface's depth here makes the two agree +// and gives the resolve the water surface's own linear depth for its +// disocclusion test, which is the depth the motion vector belongs to. +// TAA-PLAN.md rule 7 states this ("writes the surface's motion and depth"). +// +// rg = previousPixel - currentUnjitteredPixel in render pixels, b = reactive, +// a = this fragment's window depth - the same contract chunkopaque.fsh writes. + +#if TAAMOTION > 0 +in vec4 taaPrevClip; +uniform vec2 taaRenderSize; // render-target size in pixels +uniform vec2 taaJitterPx; // this frame's sub-pixel shear, in pixels + +// Foam, flow-UV scrolling and the specular sparkle animate in place: the +// surface does not move, but its shading does, so history that reprojects +// perfectly still has to be weighted down. 0.3 is the plan's starting value +// (Conventions: "animated liquid textures 0.3 initial, tuned by measurement"). +uniform float taaLiquidReactive = 0.3; + +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; +#else +// TAA off: this program is never used - ChunkRenderer.RenderLiquidMotion +// returns before binding it - but it is still registered and compiled, and a +// fragment stage with no output at all is not worth handing to two different +// shader translators. One dummy attachment keeps it trivially valid. +layout(location = 0) out vec4 outMotion; +#endif + + +void main() +{ +#if TAAMOTION > 0 + // A previous position behind the previous camera is not a motion vector; a + // zero alpha routes the pixel to the resolve's camera fallback, exactly as + // in chunkopaque.fsh. Depth is still written for it, because the fragment + // is genuinely the visible surface either way. + if (taaPrevClip.w <= 1e-6) { + outMotion = vec4(0.0); + return; + } + + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + outMotion = vec4(prevPixel - currentPixel, clamp(taaLiquidReactive, 0.0, 1.0), gl_FragCoord.z); +#else + outMotion = vec4(0.0); +#endif +} diff --git a/sources/shaders/chunkliquidmotion.vsh b/sources/shaders/chunkliquidmotion.vsh new file mode 100644 index 00000000..e9c96da0 --- /dev/null +++ b/sources/shaders/chunkliquidmotion.vsh @@ -0,0 +1,105 @@ +#version 330 core +#extension GL_ARB_explicit_attrib_location: enable + +// Optimum TAA (P4): the liquid velocity pass (TAA-PLAN.md accuracy rule 7). +// +// The OIT liquid draw cannot write Primary's motion attachment - it renders +// into the Transparent target and its six oit.fsh outputs already fill that +// framebuffer's attachment set - so the liquid pools are drawn a second time, +// into Primary, by this program, which writes nothing but the motion +// attachment (ChunkRenderer.RenderLiquidMotion opens the draw-buffer window +// with every other colour attachment masked out). +// +// The whole point is that the position this program computes is the SAME +// position chunkliquid.vsh computed for the same vertex: same liquid wave warp, +// same divisor from the same water flags, and the same "pretend the surface is +// closer" w-offset at the end. Anything else and the velocity pass would +// depth-test against a surface a fraction of a pixel away from the one that was +// shaded, and the motion vector would belong to a neighbouring fragment. +// +// The previous position follows accuracy rule 4, exactly as chunkopaque.vsh +// does: the chunk's camera-relative position moved by the camera's own motion, +// the warp re-evaluated through previousWarpState(), and the previous +// UNJITTERED projection with the previous CameraMatrixOrigin. + +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlags; +layout(location = 4) in vec2 flowVector; +layout(location = 5) in int colormapData; +layout(location = 6) in int waterFlagsIn; + +uniform vec3 origin; +uniform mat4 projectionMatrix; +uniform mat4 modelViewMatrix; + +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED world projection +uniform mat4 prevModelViewMatrix; // previous frame's CameraMatrixOrigin +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +out vec4 taaPrevClip; +#endif + +#include vertexflagbits.ash +#include vertexwarp.vsh + + +// chunkliquid.vsh's position path, verbatim, as a function of the warp state so +// the same code can be evaluated for this frame and for the previous one. The +// vanilla body reads: +// +// if ((waterFlagsIn & 1) == 1) { +// float div = ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) ? 90 : 5; +// float oceanity = ((waterFlagsIn >> 2) & 0xff) * OneOver255; +// div *= max(0.2, 1 - oceanity); +// worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, div); +// } +// else if ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) { +// worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, 90); +// } +// +// with applyLiquidWarping being the currentWarpState() wrapper of the overload +// called here. +vec4 taaLiquidWorldPos(WarpState st, vec4 worldPos) +{ + if ((waterFlagsIn & 1) == 1) { + float div = ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) ? 90 : 5; + + float oceanity = ((waterFlagsIn >> 2) & 0xff) * OneOver255; + div *= max(0.2, 1 - oceanity); + + worldPos = applyLiquidWarpingState(st, (waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, div); + } + else if ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) { + worldPos = applyLiquidWarpingState(st, (waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, 90); + } + + return worldPos; +} + + +void main(void) +{ + vec4 truePos = vec4(xyz + origin, 1.0); + + vec4 worldPos = taaLiquidWorldPos(currentWarpState(), truePos); + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + // chunkliquid.vsh's last line: the liquid surface is pretended to be closer + // than it is, so it always wins against stairs and slabs beside it. It moves + // where the fragment lands, so it belongs on both clip positions. + gl_Position.w += 0.0008 / max(0.1, gl_Position.z); + +#if TAAMOTION > 0 + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0); + taaPrevPos = taaLiquidWorldPos(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + taaPrevClip.w += 0.0008 / max(0.1, taaPrevClip.z); + } +#endif +} From 267df5ff69062b99553545ef8219dc5704358a25 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 22:18:20 +0200 Subject: [PATCH 033/226] wip(taa): P4 particles - cube writer and OIT-merge reactive, verified by GPU readback Particles are two render classes and get two policies, both from TAA-PLAN.md's inventory table. Cube particles are drawn on Primary in the Opaque stage, blended, with depth writes on, so they write the motion attachment themselves: - sources/shaders/particlescube.vsh/.fsh: vanilla untouched, the writer added beside it. The previous position is the camera-only one - particle instance positions are camera-relative against EntityPlayer.CameraPos, so prevPos = particlePosition + cameraPosDelta is accuracy rule 4's prevRel - with the warp replayed through previousWarpState() and the previous unjittered projection. Per-particle history is not in the instance buffer (position + scale, 4 floats, stride 16) and would double an allocation sized by MaxCubeParticles; with reactive 1 the resolve throws the history away anyway, so the writer's real job is claiming the pixel with a matching writer depth so that reactive 1 is delivered at all. - SystemRenderParticles.OnRenderFrame3D opens the P3 window before GlToggleBlend (which is what forces replace blending on the attachment) and closes it in a finally; quad particles stay untouched. Quad particles and every other OIT transparent cannot write the attachment - six oit.fsh outputs on the Transparent target - so the merge gives them their reactive value, as the plan's Conventions say ("1 - revealage from the merge"): - sources/shaders/transparentcompose.fsh writes vec4(0, 0, clamp(anet), 0) into the motion attachment, anet being the coverage vanilla already computes for its own blend. - ClientPlatformWindows.MergeTransparentRenderPass opens the window after the global blend mode is set and puts the motion attachment on FUNC_ADD (ONE, ONE) via the new ApplyOptimumMotionAccumulateBlendState, so rg and a add zero and only b accumulates: the opaque surface's vector and writer depth behind the transparency survive, and a pixel with no transparent content over it is left bit for bit as it was. No per-attachment colour mask was needed on the seam. - Scanner disables TAA for an external particlescube or transparentcompose. Also fixes the pre-existing TaaTerrainMotionCoverageTests failure: the vanilla vertexwarp.vsh reference was read from .vanilla/win-x64/..., which `make deploy` overwrites with Optimum's own include. Both that test and the new ones now read vanilla out of .vanilla/archives/vs_client_*.tar.gz through the new VanillaShaderArchive helper. Verified: 12 GPU readback tests in Optimum.Render.Vulkan.Tests (TaaParticleMotionTests) - still camera, three known camera translations to an exact pixel displacement, two jittered cases proving the projection shear and taaJitterPx cancel, reactive 1, writer depth, uncovered pixels keeping a zero alpha and no reactive, the particle still shading into colour attachment 0, and the merge adding 1 - revealage into b at three coverages while rg and a keep the opaque writer's values and an existing reactive 1 survives. 10 source-coverage tests in Optimum.Tests, including a verbatim comparison of the position path against the archive's particlescube.vsh and a "strip every #if TAAMOTION region and you are back at vanilla" check on all three overrides. Full suites: Vulkan 303/303; Optimum.Tests 871 passed, 0 failed. extract-patches + check-patches clean. Not yet run in the game on either backend. --- .../ShaderCompatibilityScanner.cs | 7 + Optimum.Patcher/Program.cs | 11 + .../TaaParticleMotionTests.cs | 917 ++++++++++++++++++ .../taa-particle-motion-coverage-tests.cs | 422 ++++++++ .../taa-terrain-motion-coverage-tests.cs | 14 +- Optimum.Tests/vanilla-shader-archive.cs | 73 ++ .../ClientPlatformWindows.cs.patch | 136 ++- .../SystemRenderParticles.cs.patch | 72 ++ patches/cecil-owned.list | 1 + sources/shaders/particlescube.fsh | 91 ++ sources/shaders/particlescube.vsh | 165 ++++ sources/shaders/transparentcompose.fsh | 81 ++ 12 files changed, 1941 insertions(+), 49 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs create mode 100644 Optimum.Tests/taa-particle-motion-coverage-tests.cs create mode 100644 Optimum.Tests/vanilla-shader-archive.cs create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch create mode 100644 sources/shaders/particlescube.fsh create mode 100644 sources/shaders/particlescube.vsh create mode 100644 sources/shaders/transparentcompose.fsh diff --git a/Optimum.Launcher/ShaderCompatibilityScanner.cs b/Optimum.Launcher/ShaderCompatibilityScanner.cs index 1a33e32c..2167bb52 100644 --- a/Optimum.Launcher/ShaderCompatibilityScanner.cs +++ b/Optimum.Launcher/ShaderCompatibilityScanner.cs @@ -342,6 +342,13 @@ private static void FinalizeReport(ShaderCompatibilityReport report) // surface half a pixel away. HasExternalShader(report, "chunkliquid.vsh") || HasExternalShader(report, "chunkliquidmotion.vsh") || HasExternalShader(report, "chunkliquidmotion.fsh") || + // Cube particles write the motion attachment themselves, and the + // OIT merge is where every transparent that cannot write it gets its + // reactive value. An external copy of either drops that content back + // to camera reprojection with no reactive flag at all, which ghosts + // exactly the fast-moving, alpha-blended pixels TAA is worst at. + HasExternalShader(report, "particlescube.vsh") || HasExternalShader(report, "particlescube.fsh") || + HasExternalShader(report, "transparentcompose.fsh") || HasExternalShader(report, "vertexwarp.vsh"); AddFeatureDecision(report, "Taa", externalMotionShader, "external shader owns a motion-vector writer contract"); diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index eab37449..48bf55ba 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -81,6 +81,11 @@ "OptimumDynamicLightCache", "OptimumRenderScale", }, + // TAA P4: the cube-particle motion writer's previous-frame uniforms. + ["Vintagestory.Client.NoObf.SystemRenderParticles"] = new() + { + "SetOptimumMotionUniforms", + }, ["Vintagestory.Client.NoObf.SystemRenderPlayerEffects"] = new() { "GetOptimumLightRadius", @@ -164,6 +169,10 @@ "BeginMotionOnlyWrite", "EndMotionOnlyWrite", "optimumMotionOnlyDrawBuffers", + // TAA P4: additive blending on the motion attachment for the OIT merge, + // which contributes the transparent layer's coverage to the reactive + // channel without touching the vector or the writer depth under it. + "ApplyOptimumMotionAccumulateBlendState", }, // TAA P3: the uniform block a buffer feeds and the point it is bound to. // Vanilla had one block per program and Bind() hard-coded binding point 0; @@ -750,6 +759,8 @@ // window only if something tells them to. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Window_Resize", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "MergeTransparentRenderPass", 0), + // TAA P4: the cube-particle motion window and its uniforms. + new("Vintagestory.Client.NoObf.SystemRenderParticles", "OnRenderFrame3D", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFinalComposition", 0), // GuiCompositeMainMenuLeft: Optimum link in main menu (no lambdas) new("Vintagestory.Client.GuiCompositeMainMenuLeft", "Compose", 0), diff --git a/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs new file mode 100644 index 00000000..15b7c650 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs @@ -0,0 +1,917 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The TAA P4 particle policies, driven through the seam with the real +/// programs and read back as pixels. +/// +/// Two halves, because particles are two different render classes: +/// +/// (a) Cube particles are drawn on Primary inside the Opaque stage, blended, +/// with depth writes on, so they write the motion attachment themselves - +/// the camera-only vector (there is no per-particle history in the instance +/// buffer), reactive 1 and their own window depth. +/// (b) Quad particles - and every other OIT transparent - cannot: their six +/// oit.fsh outputs fill the Transparent target. They get their reactive +/// value from the OIT merge instead, which adds `1 - revealage` into the +/// motion attachment's blue channel while leaving the vector and the writer +/// depth of the surface underneath untouched. +/// +/// The projection is the perspective-SHAPED matrix the liquid tests introduced +/// rather than the identity the P3 writer tests use: clip.w = -z_view is +/// what makes the jitter shear (P[8] -= 2*jx/W) displace the raster +/// position by exactly jx pixels, so the jittered case proves something. +/// +public class TaaParticleMotionTests +{ + private readonly ITestOutputHelper _output; + + public TaaParticleMotionTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// The particle quad sits on the plane the matrix below maps to window depth 0.5. + private const float QuadZ = -1f; + + /// + /// A perspective-shaped projection, column-major: x and y pass through, + /// clip.w = -z and clip.z = -z - 1. At z = -1 that is + /// clip = (x, y, 0, 1), so NDC z is 0 and the window depth 0.5. + /// + private static readonly float[] Projection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, -1, -1, + 0, 0, -1, 0, + }; + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + private static float[] Jittered(float jitterX, float jitterY) + { + float[] sheared = (float[])Projection.Clone(); + sheared[8] -= 2f * jitterX / Size; + sheared[9] -= 2f * jitterY / Size; + return sheared; + } + + // ------------------------------------------------ (a) cube particle writer + + /// + /// A camera that did not move produces no motion, reactive 1 - the value + /// that makes the resolve take this frame's pixel outright - and the + /// fragment's own window depth, which is what gets the reactive value past + /// the resolve's writer-depth test in the first place. + /// + [SkippableFact] + public void ACubeParticleWritesZeroMotionFullReactiveAndItsOwnDepth() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderCubeParticles(device!, 0f, 0f); + Decoded centre = result.At(Size / 2, Size / 2); + + _output.WriteLine($"still: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.Reactive, 0.99f, 1.01f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The camera translating by a known amount moves the particle by exactly + /// that amount in pixels, and the sign is "where the pixel was". The + /// instance positions are camera-relative and rebased against + /// EntityPlayer.CameraPos every frame, which is the origin cameraPosDelta is + /// measured in, so the previous position is position + cameraPosDelta - the + /// same rule accuracy rule 4 gives a chunk vertex. + /// + [SkippableTheory] + [InlineData(0.25f, 0f)] + [InlineData(0f, -0.125f)] + [InlineData(-0.1875f, 0.0625f)] + public void ACameraTranslationShowsUpAsTheExactPixelDisplacement(float deltaX, float deltaY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderCubeParticles(device!, deltaX, deltaY); + Decoded centre = result.At(Size / 2, Size / 2); + + float expectedX = deltaX * 0.5f * Size; + float expectedY = deltaY * 0.5f * Size; + + _output.WriteLine($"delta ({deltaX}, {deltaY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.Reactive, 0.99f, 1.01f); + } + } + + /// + /// The jittered case, which no P3 GPU test covered: the projection carries + /// this frame's sub-pixel shear and the fragment shader is told the same + /// offset in taaJitterPx. The two have to cancel - the vector describes where + /// the surface went, not where the sampling grid went - so the result must be + /// the unjittered one. A writer that forgot the subtraction would be off by + /// the jitter; one that subtracted it with the wrong sign, by twice that. + /// + [SkippableTheory] + [InlineData(0.375f, -0.25f)] + [InlineData(-0.5f, 0.5f)] + public void TheJitterInTheProjectionAndInTheUniformCancel(float jitterX, float jitterY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float deltaX = 0.25f; + const float deltaY = -0.125f; + + Result result = RenderCubeParticles(device!, deltaX, deltaY, jitterX, jitterY); + Decoded centre = result.At(Size / 2, Size / 2); + + float expectedX = deltaX * 0.5f * Size; + float expectedY = deltaY * 0.5f * Size; + + _output.WriteLine($"jitter ({jitterX}, {jitterY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + // The jitter is a whole decode step (0.25 px) or more, so a missing + // or wrongly signed subtraction cannot hide inside this tolerance. + Assert.True(Math.Abs(jitterX) >= 0.25f && Math.Abs(jitterY) >= 0.25f, + "the jitter chosen is smaller than the decode quantisation"); + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// A pixel no particle covered keeps a zero alpha, which is what makes the + /// resolve's writer-depth test reject it and use the camera fallback - and + /// what keeps reactive 1 from leaking onto the whole screen. + /// + [SkippableFact] + public void PixelsNoParticleCoveredKeepAZeroWriterDepthAndNoReactive() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderCubeParticles(device!, 0.25f, 0f); + Decoded corner = result.At(2, 2); + + _output.WriteLine($"corner: mv = ({corner.MotionX}, {corner.MotionY}), " + + $"reactive = {corner.Reactive}, writerDepth = {corner.WriterDepth}"); + Assert.InRange(corner.WriterDepth, 0f, 0.01f); + Assert.InRange(corner.Reactive, 0f, 0.01f); + } + } + + /// + /// The writer is an addition, not a replacement: the particle still shades + /// into colour attachment 0. A shader that lost its colour output - or a + /// draw-buffer window that masked it out, which is what the liquid velocity + /// pass deliberately does - would leave the clear value behind. + /// + [SkippableFact] + public void TheCubeParticleStillShadesIntoColourAttachmentZero() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderCubeParticles(device!, 0f, 0f); + + int offset = ((Size / 2) * Size + Size / 2) * 4; + byte[] cleared = { 51, 102, 153, 255 }; // the (0.2, 0.4, 0.6, 1) clear + bool changed = false; + for (int channel = 0; channel < 4; channel++) + { + if (Math.Abs(result.Colour[offset + channel] - cleared[channel]) > 1) changed = true; + } + + _output.WriteLine("centre colour: " + + string.Join(", ", Enumerable.Range(0, 4).Select(c => result.Colour[offset + c]))); + Assert.True(changed, "the particle wrote no colour: the motion output replaced the shading path"); + } + } + + // ------------------------------------------- (b) OIT merge reactive + + /// + /// The merge's whole contract in one readback: where transparent content + /// covers the pixel the reactive channel becomes 1 - revealage, and where it + /// does not the motion attachment is left exactly as the opaque writer left + /// it. The vector and the writer depth survive in both cases - they are what + /// the resolve reprojects the opaque surface behind the transparency with. + /// + [SkippableTheory] + // revealage, expected reactive contributed by the merge + [InlineData(1.0f, 0f)] + [InlineData(0.25f, 0.75f)] + [InlineData(0f, 1f)] + public void TheMergeAddsOneMinusRevealageIntoTheReactiveChannelOnly( + float revealage, float expectedReactive) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + // What an opaque writer left in the attachment before the merge ran. + const float seedMotionX = 4f; + const float seedMotionY = -8f; + const float seedReactive = 0f; + const float seedDepth = 0.5f; + + Result result = RenderTransparentCompose( + device!, revealage, seedMotionX, seedMotionY, seedReactive, seedDepth); + Decoded centre = result.At(Size / 2, Size / 2); + + _output.WriteLine($"revealage {revealage}: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + // rg and a are the opaque writer's, untouched. + Assert.InRange(centre.MotionX, seedMotionX - 0.3f, seedMotionX + 0.3f); + Assert.InRange(centre.MotionY, seedMotionY - 0.3f, seedMotionY + 0.3f); + Assert.InRange(centre.WriterDepth, seedDepth - 0.01f, seedDepth + 0.01f); + + // b is the transparent layer's coverage. + Assert.InRange(centre.Reactive, expectedReactive - 0.02f, expectedReactive + 0.02f); + } + } + + /// + /// Additive rather than replacing: a surface that already declared itself + /// reactive stays reactive after the merge, whatever the transparency over + /// it says. (The resolve clamps the sum, so "more than 1" only ever means + /// "distrust the history", which both contributors were asking for.) + /// + [SkippableFact] + public void TheMergeDoesNotClearAnExistingReactiveValue() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + // A fully opaque pixel under fully clear glass: revealage 1, so the + // merge contributes nothing, and the writer's own reactive survives. + Result result = RenderTransparentCompose(device!, 1f, 2f, 2f, 1f, 0.5f); + Decoded centre = result.At(Size / 2, Size / 2); + + _output.WriteLine($"existing reactive kept: {centre.Reactive}"); + Assert.InRange(centre.Reactive, 0.99f, 1.01f); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float reactive, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + Reactive = reactive; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float Reactive { get; } + public float WriterDepth { get; } + } + + private sealed class Result + { + public byte[] Motion = Array.Empty(); + public byte[] Reactive = Array.Empty(); + public byte[] Colour = Array.Empty(); + + public Decoded At(int x, int y) + { + int offset = (y * Size + x) * 4; + return new Decoded( + (Motion[offset] / 255f * 2f - 1f) * DecodeScale, + (Motion[offset + 1] / 255f * 2f - 1f) * DecodeScale, + Reactive[offset] / 255f, + Motion[offset + 2] / 255f); + } + } + + /// + /// Draws one instanced cube particle with the real particlescube program + /// into a Primary stand-in whose motion attachment is enabled - what + /// SystemRenderParticles does through BeginMotionWrite - and returns the + /// decoded motion attachment together with colour attachment 0. + /// + private unsafe Result RenderCubeParticles( + VulkanDevice device, + float cameraDeltaX, + float cameraDeltaY, + float jitterX = 0f, + float jitterY = 0f) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(v => v.Name == "taa-no-ssao"); + Assert.Equal(1, variant.TaaMotion); + Assert.Equal(2, variant.TaaMotionLocation); + + List stages = ShaderCorpus.BuildProgram("particlescube", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "particlescube"); + + // The writer only exists if the shader really declares it; without this + // the test would pass on a shader that dropped the output entirely. + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + "particlescube declares no taaRenderSize, so it is not a motion writer"); + + (int scene, int motion) = CreatePrimaryStandIn(seam, out int colour); + + int mesh = seam.CreateMesh(BuildParticleCube(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.SetDrawBuffers(scene, 0b111); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.UseProgram(program); + SetMatrix(seam, program, "projectionMatrix", Jittered(jitterX, jitterY)); + SetMatrix(seam, program, "modelViewMatrix", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixFar", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); + // The previous projection is the UNJITTERED one, as the frame contract + // hands it out: a previous position through a jittered matrix would carry + // two frames' jitter difference instead of the surface's movement. + SetMatrix(seam, program, "prevProjectionMatrix", Projection); + SetMatrix(seam, program, "prevModelViewMatrix", Identity); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", jitterX, jitterY); + SetFloat3(seam, program, "rgbaAmbientIn", 1f, 1f, 1f); + SetFloat(seam, program, "fogMinIn", 0f); + SetFloat(seam, program, "fogDensityIn", 0f); + SetWarpUniforms(seam, program); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + // Cube particles are drawn blended (SystemRenderParticles calls + // GlToggleBlend(on: true)); the motion attachment is forced to replace + // blending by ApplyOptimumMotionBlendState, which is what this mirrors. + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetBlendFuncSeparate(0, 770, 771, 770, 771); + seam.SetBlendEquation(2, 32774); + seam.SetBlendFuncSeparate(2, 1, 0, 1, 0); + seam.DrawMeshInstanced(mesh, 1); + + byte[] decodedMotion = DecodeMotion(seam, motion, reactive: false); + byte[] decodedReactive = DecodeMotion(seam, motion, reactive: true); + seam.BindFramebuffer(scene); + seam.SetDrawBuffers(scene, 0b111); + var result = new Result + { + Motion = decodedMotion, + Reactive = decodedReactive, + Colour = ReadColour(seam, scene), + }; + seam.Present(); + + AssertClean(seam); + return result; + } + + /// + /// Runs the real transparentcompose program over a motion attachment that + /// already holds an opaque writer's vector and depth, with the same + /// per-attachment blend state + /// ClientPlatformWindows.ApplyOptimumMotionAccumulateBlendState sets. + /// + private unsafe Result RenderTransparentCompose( + VulkanDevice device, + float revealage, + float seedMotionX, + float seedMotionY, + float seedReactive, + float seedDepth) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(v => v.Name == "taa-no-ssao"); + + List stages = ShaderCorpus.BuildProgram("transparentcompose", files, includes, variant); + Assert.NotEmpty(stages); + int compose = LinkFromCorpus(seam, stages, "transparentcompose"); + + (int scene, int motion) = CreatePrimaryStandIn(seam, out int colour); + + // The inputs the merge samples. Only revealage matters to the reactive + // value; the rest are present so the pass is the real one. + int revealageTexture = SolidTexture(seam, revealage, 0f, 0f, 1f); + int accumulation = SolidTexture(seam, 0f, 0f, 0f, 0f); + int inGlow = SolidTexture(seam, 0f, 0f, 0f, 1f); + int oitReveal = SolidTexture(seam, 0f, 0f, 0f, 0f); + int oitAccumulation = seam.CreateTexture2DArray(Size, Size, 3, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba); + + int quad = FullscreenQuad(seam); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.SetDrawBuffers(scene, 0b111); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + // The opaque writer's contribution: a motion-only pass, exactly the + // shape BeginMotionOnlyWrite gives the liquid velocity pass. + int seedProgram = SeedMotionProgram(seam); + seam.SetDrawBuffers(scene, 1 << 2); + seam.UseProgram(seedProgram); + SetFloat(seam, seedProgram, "seedR", seedMotionX); + SetFloat(seam, seedProgram, "seedG", seedMotionY); + SetFloat(seam, seedProgram, "seedB", seedReactive); + SetFloat(seam, seedProgram, "seedA", seedDepth); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quad); + + // The merge itself: colour attachments back in the set, the global + // blend mode MergeTransparentRenderPass sets, and the motion attachment + // on FUNC_ADD (ONE, ONE) so its rg and a survive. + seam.SetDrawBuffers(scene, 0b111); + seam.SetDepthTest(false); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetBlendFuncSeparate(0, 770, 771, 770, 771); + seam.SetBlendEquation(2, 32774); + seam.SetBlendFuncSeparate(2, 1, 1, 1, 1); + + seam.UseProgram(compose); + seam.SetSamplerUnit(compose, "revealage", 10); + seam.BindTexture(10, revealageTexture); + seam.SetSamplerUnit(compose, "accumulation", 11); + seam.BindTexture(11, accumulation); + seam.SetSamplerUnit(compose, "inGlow", 12); + seam.BindTexture(12, inGlow); + seam.SetSamplerUnit(compose, "OITreveal", 13); + seam.BindTexture(13, oitReveal); + seam.SetSamplerUnit(compose, "OITaccumulation", 14); + seam.BindTexture(14, oitAccumulation); + seam.DrawMesh(quad); + + byte[] decodedMotion = DecodeMotion(seam, motion, reactive: false); + byte[] decodedReactive = DecodeMotion(seam, motion, reactive: true); + seam.BindFramebuffer(scene); + seam.SetDrawBuffers(scene, 0b111); + var result = new Result + { + Motion = decodedMotion, + Reactive = decodedReactive, + Colour = ReadColour(seam, scene), + }; + seam.Present(); + + AssertClean(seam); + return result; + } + + /// Colour, glow and an RGBA16F motion attachment at index 2 - what + /// SetupDefaultFrameBuffers builds without the SSAO G-buffer. + private static (int Scene, int Motion) CreatePrimaryStandIn(IOptimumGraphicsDevice seam, out int colour) + { + colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + seam.SetTextureParameter(colour, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(colour, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + return (scene, motion); + } + + private static unsafe int SolidTexture( + IOptimumGraphicsDevice seam, float r, float g, float b, float a) + { + var pixels = new byte[Size * Size * 4]; + byte[] value = + { + (byte)Math.Clamp((int)MathF.Round(r * 255f), 0, 255), + (byte)Math.Clamp((int)MathF.Round(g * 255f), 0, 255), + (byte)Math.Clamp((int)MathF.Round(b * 255f), 0, 255), + (byte)Math.Clamp((int)MathF.Round(a * 255f), 0, 255), + }; + for (int i = 0; i < pixels.Length; i += 4) Array.Copy(value, 0, pixels, i, 4); + + fixed (byte* source = pixels) + { + int texture = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, (IntPtr)source, false); + seam.SetTextureParameter(texture, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(texture, OptimumGlConstants.TextureMagFilter, 9728); + return texture; + } + } + + /// The opaque writer's stand-in: writes the motion attachment and + /// nothing else, the way chunkliquidmotion does. + private static int SeedMotionProgram(IOptimumGraphicsDevice seam) + { + const string vertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string fragment = @"#version 330 core +uniform float seedR; +uniform float seedG; +uniform float seedB; +uniform float seedA; +layout(location = 2) out vec4 outMotion; +void main(void) { outMotion = vec4(seedR, seedG, seedB, seedA); } +"; + return LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = vertex, PrefixCode = "", Filename = "taa-particle-seed.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = fragment, PrefixCode = "", Filename = "taa-particle-seed.fsh" }, + }, "taa-particle-seed"); + } + + private static int FullscreenQuad(IOptimumGraphicsDevice seam) + { + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int mesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "quad upload failed"); + return mesh; + } + + /// Colour attachment 0 of the scene target, read inside the frame. + private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(scene); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// With the blue channel is put in red at full + /// scale, so reactive can be checked without the mv quantisation. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture, bool reactive) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +uniform int reactiveOnly; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + if (reactiveOnly != 0) { + outColor = vec4(clamp(m.b, 0.0, 1.0), 0.0, 0.0, 1.0); + return; + } + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-particle-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-particle-decode.fsh" }, + }, "taa-particle-decode"); + + int quadMesh = FullscreenQuad(seam); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + SetInt(seam, decode, "reactiveOnly", reactive ? 1 : 0); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// One cube particle with the attribute layout ParticlePoolQuads allocates: + /// xyz(0), normals(1), uv(2), flags(3) - the model has no rgba - then the + /// instanced custom floats as particlePosition(4) and scale(5), and the + /// instanced custom bytes as particleDir(6), rgbaLightIn(7) and + /// rgbaBlockIn(8). + /// + /// The "cube" is one quad on the z = -1 plane, which is all the writer needs + /// to cover the middle of the target. + /// + private static MeshData BuildParticleCube() + { + var mesh = new MeshData(4, 6, withNormals: true, withUv: true, withRgba: false, withFlags: true); + + mesh.xyz = new[] + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + mesh.Uv = new[] { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + // The packed normal is only read for lighting and the SSAO G-normal, + // never for the position or the motion vector. + mesh.Normals = new int[4]; + mesh.NormalsCount = 4; + mesh.VerticesCount = 4; + + mesh.Indices = new[] { 0, 1, 2, 0, 2, 3 }; + mesh.IndicesCount = 6; + + // particlePosition (3) + scale (1), stride 16 - ParticlePoolQuads' own + // CustomFloats shape. The particle sits on the plane the projection maps + // to window depth 0.5. + mesh.CustomFloats = new CustomMeshDataPartFloat + { + Instanced = true, + StaticDraw = false, + Values = new[] { 0f, 0f, QuadZ, 1f }, + Count = 4, + InterleaveSizes = new[] { 3, 1 }, + InterleaveOffsets = new[] { 0, 12 }, + InterleaveStride = 16, + }; + + // particleDir (4) + rgbaLightIn (4) + rgbaBlockIn (4), normalized bytes. + var bytes = new byte[12]; + for (int i = 0; i < 12; i++) bytes[i] = 255; + mesh.CustomBytes = new CustomMeshDataPartByte + { + Conversion = DataConversion.NormalizedFloat, + Instanced = true, + StaticDraw = false, + Values = bytes, + Count = 12, + InterleaveSizes = new[] { 4, 4, 4 }, + InterleaveOffsets = new[] { 0, 4, 8 }, + InterleaveStride = 12, + }; + + // renderFlags is per instance, as ParticlePoolQuads uploads it. The + // array is vertex-count long because MeshData derives FlagsCount from + // VerticesCount; only the first entry is read, for the one instance. + mesh.Flags = new int[4]; + mesh.FlagsInstanced = true; + + return mesh; + } + + /// + /// Both halves of the warp state, set explicitly: an unset uniform is a + /// defined zero in GL but whatever the block happens to hold on the device + /// path. Every intensity is zero, so neither the current nor the previous + /// position is warped and the motion is the camera's alone. + /// + private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program) + { + SetFloat(seam, program, "timeCounter", 0f); + SetFloat(seam, program, "windWaveCounter", 0f); + SetFloat(seam, program, "windWaveCounterHighFreq", 0f); + SetFloat(seam, program, "waterWaveCounter", 0f); + SetFloat(seam, program, "windSpeed", 0f); + SetFloat(seam, program, "globalWarpIntensity", 0f); + SetFloat(seam, program, "glitchWaviness", 0f); + SetFloat(seam, program, "windWaveIntensity", 0f); + SetFloat(seam, program, "waterWaveIntensity", 0f); + SetInt(seam, program, "perceptionEffectId", 1); + SetFloat(seam, program, "perceptionEffectIntensity", 0f); + SetFloat3(seam, program, "playerpos", 0f, 0f, 0f); + + SetFloat(seam, program, "prevTimeCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounter", 0f); + SetFloat(seam, program, "prevWindWaveCounterHighFreq", 0f); + SetFloat(seam, program, "prevWaterWaveCounter", 0f); + SetFloat(seam, program, "prevWindSpeed", 0f); + SetFloat(seam, program, "prevGlobalWarpIntensity", 0f); + SetFloat(seam, program, "prevGlitchWaviness", 0f); + SetFloat(seam, program, "prevWindWaveIntensity", 0f); + SetFloat(seam, program, "prevWaterWaveIntensity", 0f); + SetInt(seam, program, "prevPerceptionEffectId", 1); + SetFloat(seam, program, "prevPerceptionEffectIntensity", 0f); + SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vec2f value) { } + public void Uniform(string uniformName, Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-particle-motion-coverage-tests.cs b/Optimum.Tests/taa-particle-motion-coverage-tests.cs new file mode 100644 index 00000000..c02437de --- /dev/null +++ b/Optimum.Tests/taa-particle-motion-coverage-tests.cs @@ -0,0 +1,422 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P4 particle policies: the cube-particle motion +/// writer (drawn on Primary, so it writes the attachment itself), the reactive +/// value the OIT merge contributes for everything drawn into the Transparent +/// target, and the plumbing that has to ship both (patcher entries, the +/// compatibility scanner). +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaParticleMotionTests) proves the numbers. +/// What these catch is the failure this project keeps hitting: a change that +/// works in the build tree and never reaches the installed runtime because a +/// patcher entry or a registration was missed - and the second one, that an +/// override drifted away from the vanilla shader it is supposed to be identical +/// to with TAA off. +/// +public class TaaParticleMotionCoverageTests +{ + // ------------------------------------------------- (a) the cube writer + + [Fact] + public void TheCubeParticleWriterEmitsTheMotionContractBesideItsVanillaOutputs() + { + string vertex = Read("sources/shaders/particlescube.vsh"); + string fragment = Read("sources/shaders/particlescube.fsh"); + + // Compiled in only while TAA is on, exactly as the P3 writers are. + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("#if TAAMOTION > 0", fragment); + + // Previous transforms and the camera's own movement. + Assert.Contains("uniform mat4 prevProjectionMatrix;", vertex); + Assert.Contains("uniform mat4 prevModelViewMatrix;", vertex); + Assert.Contains("uniform vec3 cameraPosDelta;", vertex); + Assert.Contains("out vec4 taaPrevClip;", vertex); + + // The camera-only previous position: the instance positions are + // camera-relative, so prevPos = pos + cameraPosDelta is the same rule + // accuracy rule 4 gives a chunk vertex, and the warp is replayed with + // the previous frame's state through the very same function. + Assert.Contains("WarpState taaPrev = previousWarpState();", vertex); + Assert.Contains( + "vec4 taaPrevPos = taaParticleWorldPos(taaPrev, particlePosition + cameraPosDelta);", + vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos);", vertex); + + // The fragment contract taa-resolve.fsh consumes. + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + Assert.Contains("uniform vec2 taaRenderSize;", fragment); + Assert.Contains("uniform vec2 taaJitterPx;", fragment); + Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + // reactive 1: a cube particle is alpha-blended, appears and disappears + // between frames, and has no per-particle history - the resolve must + // take this frame's pixel. + Assert.Contains( + "outMotion = vec4(prevPixel - currentPixel, 1.0, gl_FragCoord.z);", + fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) {", fragment); + + // Unlike the liquid velocity pass, this writer is an ADDITION to a + // shading pass: the vanilla outputs have to still be there, or the + // particles would stop being drawn. + Assert.Contains("layout(location = 0) out vec4 outColor;", fragment); + Assert.Contains("layout(location = 1) out vec4 outGlow;", fragment); + Assert.Contains("layout(location = 2) out vec4 outGNormal;", fragment); + Assert.Contains("layout(location = 3) out vec4 outGPosition;", fragment); + } + + /// + /// The previous position has to come out of the same expression the current + /// one does, or the two disagree by whatever drifted between the copies and + /// that difference is reported as motion. Vanilla's own lines are left + /// untouched in the override (the additive rule), so the twin beside them is + /// compared against them here. + /// + [Fact] + public void TheCubeParticlePositionPathIsVanillasVerbatim() + { + string ours = Read("sources/shaders/particlescube.vsh"); + + Assert.Contains("vec4 taaParticleWorldPos(WarpState st, vec3 taaParticlePosition)", ours); + Assert.Contains("#include vertexwarp.vsh", ours); + + string? vanilla = VanillaShaderArchive.TryRead("shaders/particlescube.vsh"); + // The vanilla shaders are proprietary and never committed; a checkout + // that has not bootstrapped has nothing to compare against. + if (vanilla == null) return; + + // From vanilla's main(): the first "#if defined(VEC3SCALE)" in the file + // is the attribute declaration, not the position branch. + int vanillaMain = vanilla.IndexOf("void main()", StringComparison.Ordinal); + Assert.True(vanillaMain > 0); + string vanillaBranch = Between(vanilla, "#if defined(VEC3SCALE)", "vec4 cameraPos", vanillaMain); + + int ourFunction = ours.IndexOf( + "vec4 taaParticleWorldPos(WarpState st, vec3 taaParticlePosition)", StringComparison.Ordinal); + Assert.True(ourFunction > 0); + string ourBranch = Between(ours, "#if defined(VEC3SCALE)", "return taaWorldPos;", ourFunction) + // The only permitted differences: the local name, the argument name, + // and the warp reaching the state-taking overloads instead of the + // currentWarpState() wrappers. + .Replace("taaWorldPos", "worldPos") + .Replace("taaParticlePosition", "particlePosition") + .Replace("applyVertexWarpingState(st, ", "applyVertexWarping(") + .Replace("applyGlobalWarpingState(st, ", "applyGlobalWarping("); + + Assert.Equal(Squash(vanillaBranch), Squash(ourBranch)); + } + + // ------------------------------------------- (b) the OIT merge reactive + + [Fact] + public void TheMergeWritesOneMinusRevealageIntoTheReactiveChannelOnly() + { + string compose = Read("sources/shaders/transparentcompose.fsh"); + + Assert.Contains("#if TAAMOTION > 0", compose); + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", compose); + + // Zero into r, g and a: with the motion attachment on FUNC_ADD (ONE, + // ONE) those channels add zero, so the vector and the writer depth of + // the opaque surface behind the transparency survive and only the + // reactive channel accumulates. + Assert.Contains("outMotion = vec4(0.0, 0.0, clamp(anet, 0.0, 1.0), 0.0);", compose); + + // anet is vanilla's own coverage term - the alpha this pass is + // composited with - not a second, parallel computation. + Assert.Contains("float anet = 1.0 - texelFetch(revealage, ivec2(gl_FragCoord), 0).r;", compose); + Assert.Contains("outColor = vec4(unproject(k), anet);", compose); + } + + /// + /// TAA off must be byte-identical to today's chain. For a whole-file shader + /// override that means: delete every #if TAAMOTION > 0 region and + /// the comments, and what is left has to be the vanilla file. + /// + /// The vanilla text is read from the release archive, never from + /// .vanilla/win-x64/...: make deploy copies Optimum's + /// overrides straight into that tree, so a test that compared against it + /// would be comparing a file with itself from the first deploy onwards. + /// + [Theory] + [InlineData("particlescube.vsh")] + [InlineData("particlescube.fsh")] + [InlineData("transparentcompose.fsh")] + public void WithTaaOffTheOverridesAreTheVanillaShaders(string shader) + { + string ours = Read("sources/shaders/" + shader); + string? vanilla = VanillaShaderArchive.TryRead("shaders/" + shader); + if (vanilla == null) return; + + Assert.Equal(Squash(StripComments(vanilla)), Squash(StripComments(StripTaaRegions(ours)))); + } + + // --------------------------------------------------------- the passes + + [Fact] + public void SystemRenderParticlesOpensTheMotionWindowAroundTheCubeDraw() + { + // Read from the source of truth, not the patch: a patch carries its + // hunks plus three lines of context, which is not a parsable method + // body. That the change ships at all is asserted separately, from the + // patch and the patcher's target list, below. + string particles = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs"); + + string pass = MethodBodyAfter(particles, "public void OnRenderFrame3D(float deltaTime)"); + + // The P3 window (the motion attachment ADDED to Primary's set), not the + // motion-only one: this pass shades and writes motion in one draw. + Assert.Contains("optimumPlatform.BeginMotionWrite()", pass); + Assert.Contains("optimumPlatform.EndMotionWrite();", pass); + // Closed on every path, including a throwing draw. + Assert.Contains("finally", pass); + + // The window has to be open before GlToggleBlend, because that call is + // what forces replace blending onto the motion attachment. + int begin = pass.IndexOf("BeginMotionWrite()", StringComparison.Ordinal); + int blend = pass.IndexOf("GlToggleBlend(on: true)", StringComparison.Ordinal); + Assert.True(begin >= 0 && blend > begin, + "the motion window must be opened before blending is turned on"); + + Assert.Contains("SetOptimumMotionUniforms(particlescube);", pass); + + // The previous-frame uniforms, from the shared frame contract. + string uniforms = MethodBodyAfter(particles, "private void SetOptimumMotionUniforms(IShaderProgram program)"); + Assert.Contains("frame.GetPrevProjection(EnumTemporalView.World)", uniforms); + Assert.Contains("frame.PrevCameraMatrixOrigin", uniforms); + Assert.Contains("frame.ApplyMotionUniforms(program);", uniforms); + + // Quad particles stay out of it: they render into the Transparent + // target, where the motion attachment is not even present. + string oit = MethodBodyAfter(particles, "public void OnRenderFrame3DOIT(float deltaTime)"); + Assert.DoesNotContain("MotionWrite", oit); + + // And the change reaches the installed runtime: the patch exists and + // carries the window. + string? patch = TryFind("patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch"); + Assert.True(patch != null, "SystemRenderParticles has no patch, so the change never ships"); + Assert.Contains("BeginMotionWrite()", PatchReader.ReadPatchedContent(patch!)); + } + + [Fact] + public void TheMergeOpensTheWindowAndPutsTheMotionAttachmentOnAdditiveBlending() + { + // Source of truth, for the same reason as above; the patch is checked + // for the two new symbols at the end. + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + string merge = MethodBodyAfter(platform, "public override void MergeTransparentRenderPass()"); + + Assert.Contains("bool optimumMotionWrite = BeginMotionWrite();", merge); + Assert.Contains("ApplyOptimumMotionAccumulateBlendState();", merge); + Assert.Contains("EndMotionWrite();", merge); + + // The window is opened after the global blend mode is set, or the mode + // would overwrite the per-attachment factors; and the attachment is put + // back on replace before the window closes, so no later pass inherits + // the accumulating factors. + int mode = merge.IndexOf("SetBlend(true, EnumBlendMode.Standard);", StringComparison.Ordinal); + int begin = merge.IndexOf("BeginMotionWrite();", StringComparison.Ordinal); + int accumulate = merge.IndexOf("ApplyOptimumMotionAccumulateBlendState();", StringComparison.Ordinal); + int draw = merge.IndexOf("RenderFullscreenTriangle(screenQuad);", StringComparison.Ordinal); + int restore = merge.IndexOf("ApplyOptimumMotionBlendState();", StringComparison.Ordinal); + int end = merge.IndexOf("EndMotionWrite();", StringComparison.Ordinal); + Assert.True(mode >= 0 && begin > mode, "the window must be opened after the global blend mode is set"); + Assert.True(accumulate > begin && draw > accumulate, "additive blending must be set before the draw"); + Assert.True(restore > draw && end > restore, "replace blending must be restored before the window closes"); + + // The blend state itself, on both backends: FUNC_ADD with (ONE, ONE). + string state = MethodBodyAfter(platform, "private void ApplyOptimumMotionAccumulateBlendState()"); + Assert.Contains("if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return;", state); + Assert.Contains("optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774);", state); + Assert.Contains("optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1);", state); + Assert.Contains("GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774);", state); + Assert.Contains("GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)1);", state); + + string? patch = TryFind("patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"); + Assert.True(patch != null, "ClientPlatformWindows has no patch, so the change never ships"); + string patched = PatchReader.ReadPatchedContent(patch!); + Assert.Contains("ApplyOptimumMotionAccumulateBlendState", patched); + Assert.Contains("bool optimumMotionWrite = BeginMotionWrite();", patched); + } + + // -------------------------------------------------------------- the ship + + [Fact] + public void CecilPatcherShipsEveryParticleMotionMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"Vintagestory.Client.NoObf.SystemRenderParticles\", \"OnRenderFrame3D\", 1", patcher); + Assert.Contains("\"SetOptimumMotionUniforms\"", patcher); + Assert.Contains("\"ApplyOptimumMotionAccumulateBlendState\"", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"MergeTransparentRenderPass\", 0", patcher); + } + + /// + /// A mod that ships its own particlescube replaces the writer outright; one + /// that ships transparentcompose removes the only reactive value every OIT + /// transparent has. Either way that content falls back to camera + /// reprojection with no reactive flag at all, which ghosts exactly the + /// fast-moving alpha-blended pixels TAA is worst at, so TAA is disabled + /// rather than fed wrong data. + /// + [Fact] + public void TheCompatibilityScannerDisablesTaaForAnExternalParticleOrMergeShader() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + + foreach (string shader in new[] + { + "particlescube.vsh", "particlescube.fsh", "transparentcompose.fsh", + }) + { + Assert.Contains("HasExternalShader(report, \"" + shader + "\")", scanner); + } + Assert.Contains("AddFeatureDecision(report, \"Taa\", externalMotionShader,", scanner); + } + + // ---------------------------------------------------------------- helpers + + /// + /// Every #if TAAMOTION > 0 region removed, including its + /// #else branch if it has one, with nested conditionals tracked so a + /// region that contains one is not cut short. + /// + private static string StripTaaRegions(string source) + { + var kept = new List(); + int depth = 0; + bool inTaa = false; + + foreach (string line in source.Split('\n')) + { + string trimmed = line.Trim(); + + if (!inTaa && trimmed.StartsWith("#if TAAMOTION", StringComparison.Ordinal)) + { + inTaa = true; + depth = 1; + continue; + } + + if (inTaa) + { + if (trimmed.StartsWith("#if", StringComparison.Ordinal)) depth++; + else if (trimmed.StartsWith("#endif", StringComparison.Ordinal)) + { + depth--; + if (depth == 0) inTaa = false; + } + continue; + } + + kept.Add(line); + } + + Assert.False(inTaa, "unterminated #if TAAMOTION region"); + return string.Join("\n", kept); + } + + /// Whole-line // comments dropped from both sides, so the + /// override's explanations do not have to exist in vanilla. + private static string StripComments(string source) + { + var kept = new List(); + foreach (string line in source.Split('\n')) + { + if (line.TrimStart().StartsWith("//", StringComparison.Ordinal)) continue; + kept.Add(line); + } + return string.Join("\n", kept); + } + + private static string MethodBodyAfter(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such method: " + signature); + return signature + BodyOf(source, signature); + } + + private static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + /// The text between two markers, searching from . + private static string Between(string source, string start, string end, int from) + { + int begin = source.IndexOf(start, from, StringComparison.Ordinal); + Assert.True(begin >= 0, "marker not found: " + start); + int stop = source.IndexOf(end, begin, StringComparison.Ordinal); + Assert.True(stop > begin, "marker not found: " + end); + return source.Substring(begin, stop - begin); + } + + /// Every whitespace run collapsed to one space, so indentation and + /// vanilla's trailing whitespace cannot fail the comparison. + private static string Squash(string text) + { + var builder = new StringBuilder(text.Length); + bool space = false; + foreach (char c in text) + { + if (char.IsWhiteSpace(c)) { space = true; continue; } + if (space && builder.Length > 0) builder.Append(' '); + space = false; + builder.Append(c); + } + return builder.ToString(); + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs index 284fd30c..a149d756 100644 --- a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -78,12 +78,14 @@ public void TheVertexWarpOverrideCarriesAWarpStateAndKeepsTheVanillaEntryPoints( [Fact] public void TheStateOverloadsAreVanillaMathsWithTheUniformsReadFromTheStruct() { - string? vanillaPath = TryFind(".vanilla/win-x64/vintagestory/assets/game/shaderincludes/vertexwarp.vsh"); - // The vanilla shaders are proprietary and never committed; a checkout - // that has not bootstrapped has nothing to compare against. - if (vanillaPath == null) return; - - string vanilla = File.ReadAllText(vanillaPath!); + // From the release archive, never from .vanilla/win-x64/...: `make deploy` + // copies sources/shaderincludes straight into that tree, so this + // comparison was reading Optimum's own include as the vanilla reference + // (and failing) after the first deploy. The vanilla shaders are + // proprietary and never committed, so a checkout that has not + // bootstrapped has nothing to compare against and skips. + string? vanilla = VanillaShaderArchive.TryRead("shaderincludes/vertexwarp.vsh"); + if (vanilla == null) return; string ours = Read("sources/shaderincludes/vertexwarp.vsh"); foreach ((string vanillaSignature, string ourSignature) in new[] diff --git a/Optimum.Tests/vanilla-shader-archive.cs b/Optimum.Tests/vanilla-shader-archive.cs new file mode 100644 index 00000000..7f239575 --- /dev/null +++ b/Optimum.Tests/vanilla-shader-archive.cs @@ -0,0 +1,73 @@ +using System; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Optimum.Tests; + +/// +/// Reads a vanilla shader or shader include out of the client release archive +/// under .vanilla/archives/. +/// +/// Deliberately NOT out of .vanilla/win-x64/vintagestory/assets/: +/// make deploy copies every file in sources/shaders and +/// sources/shaderincludes straight into that tree, so from the first +/// deploy onwards a test that compared an override against it would be +/// comparing the file with itself - and silently pass whatever it was meant to +/// catch. That happened to the vertexwarp comparison in +/// (it failed loudly, which was +/// luck: the include had grown the WarpState overloads the test asserts are +/// absent from vanilla). +/// +/// Returns null when the checkout has not been bootstrapped - the vanilla +/// assets are proprietary and never committed - so callers skip rather than +/// fail. +/// +public static class VanillaShaderArchive +{ + /// A file under the archive's assets/game/, e.g. + /// "shaders/particlescube.vsh" or "shaderincludes/vertexwarp.vsh". + public static string? TryRead(string assetRelativePath) + { + string? archive = TryFindArchive(); + if (archive == null) return null; + + string suffix = "assets/game/" + assetRelativePath; + + using FileStream file = File.OpenRead(archive); + using var gzip = new GZipStream(file, CompressionMode.Decompress); + using var reader = new TarReader(gzip); + + while (reader.GetNextEntry() is { } entry) + { + if (!entry.Name.EndsWith(suffix, StringComparison.Ordinal)) continue; + if (entry.DataStream == null) continue; + + using var text = new StreamReader(entry.DataStream, Encoding.UTF8); + return text.ReadToEnd(); + } + + return null; + } + + private static string? TryFindArchive() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + string candidate = Path.Combine(directory.FullName, ".vanilla", "archives"); + if (Directory.Exists(candidate)) + { + string[] archives = Directory.GetFiles(candidate, "vs_client_*.tar.gz"); + if (archives.Length > 0) + { + Array.Sort(archives, StringComparer.Ordinal); + return archives[^1]; + } + } + directory = directory.Parent; + } + return null; + } +} diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 85b518e2..7abb3c48 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..2459097 100644 +index 6edf0c9..5a00757 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -1714,7 +1714,7 @@ index 6edf0c9..2459097 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,24 +3009,137 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3009,187 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1744,6 +1744,25 @@ index 6edf0c9..2459097 100644 + GL.Disable((EnableCap)2929); + GL.Enable((EnableCap)3042); + GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); ++ } ++ // Optimum TAA (P4): everything drawn into the Transparent target - quad ++ // particles, OIT entities, liquid shading, the cloud layers - writes six ++ // oit.fsh outputs and cannot reach Primary's motion attachment. The plan ++ // gives that content its reactive value here instead ("OIT transparents ++ // 1 - revealage from the merge"), which is exactly the coverage this ++ // pass already computes for its own blend. ++ // ++ // The vector and the writer depth of the opaque surface underneath must ++ // survive, so the motion attachment is put on FUNC_ADD with (ONE, ONE) ++ // and the shader writes zero into rg and a: those channels add zero, ++ // only b accumulates, and a pixel with no transparent content over it is ++ // left bit-for-bit as it was. The window is opened after the blend mode ++ // is set, because the global mode would otherwise overwrite the ++ // per-attachment factors. ++ bool optimumMotionWrite = BeginMotionWrite(); ++ if (optimumMotionWrite) ++ { ++ ApplyOptimumMotionAccumulateBlendState(); } - GL.Disable((EnableCap)2929); - GL.Enable((EnableCap)3042); @@ -1755,8 +1774,41 @@ index 6edf0c9..2459097 100644 transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; RenderFullscreenTriangle(screenQuad); transparentcompose.Stop(); - } - ++ if (optimumMotionWrite) ++ { ++ // Back to replace before the window closes, so no later pass can ++ // inherit the accumulating factors on this attachment. ++ ApplyOptimumMotionBlendState(); ++ EndMotionWrite(); ++ } ++ } ++ ++ /// ++ /// Optimum TAA (P4): additive blending on the motion attachment alone, for ++ /// the one pass that contributes to a channel of it rather than owning the ++ /// pixel - the OIT merge, which adds the transparent layer's coverage into ++ /// the reactive channel while leaving the motion vector and the writer depth ++ /// of the surface underneath alone. ++ /// ++ /// (ONE, ONE) with FUNC_ADD on every channel: the merge's fragment shader ++ /// writes zero into r, g and a, so only b changes. A colour mask would say ++ /// the same thing, but the per-attachment blend seam already exists on both ++ /// backends and a per-attachment colour mask does not. ++ /// ++ private void ApplyOptimumMotionAccumulateBlendState() ++ { ++ if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; ++ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ if (optimumDevice != null) ++ { ++ optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); ++ optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1); ++ return; ++ } ++ GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774); ++ GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)1); ++ } ++ + /// + /// Optimum TAA (P2): reprojects and blends the history into this frame's + /// history slot from the jittered Primary colour/glow, the motion attachment @@ -1849,14 +1901,12 @@ index 6edf0c9..2459097 100644 + GlEnableDepthTest(); + LoadFrameBuffer(EnumFrameBuffer.Primary); + return true; -+ } -+ + } + public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) - //IL_0020: Unknown result type (might be due to invalid IL or missing references) - //IL_0189: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3152,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3204,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1889,7 +1939,7 @@ index 6edf0c9..2459097 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3187,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3239,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1951,7 +2001,7 @@ index 6edf0c9..2459097 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3264,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3316,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1998,7 +2048,7 @@ index 6edf0c9..2459097 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3313,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3365,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2034,7 +2084,7 @@ index 6edf0c9..2459097 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,55 +3360,363 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,55 +3412,363 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2413,7 +2463,7 @@ index 6edf0c9..2459097 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3764,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3816,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2439,7 +2489,7 @@ index 6edf0c9..2459097 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3797,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3849,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2461,7 +2511,7 @@ index 6edf0c9..2459097 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3826,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +3878,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2559,7 +2609,7 @@ index 6edf0c9..2459097 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +3925,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +3977,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2762,7 +2812,7 @@ index 6edf0c9..2459097 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4134,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4186,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2929,7 +2979,7 @@ index 6edf0c9..2459097 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4304,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4356,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2953,7 +3003,7 @@ index 6edf0c9..2459097 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4333,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4385,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -2991,7 +3041,7 @@ index 6edf0c9..2459097 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4393,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4445,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -3034,7 +3084,7 @@ index 6edf0c9..2459097 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4446,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4498,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3093,7 +3143,7 @@ index 6edf0c9..2459097 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4561,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4613,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3167,7 +3217,7 @@ index 6edf0c9..2459097 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4659,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4711,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3198,7 +3248,7 @@ index 6edf0c9..2459097 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4696,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4748,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3233,7 +3283,7 @@ index 6edf0c9..2459097 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4743,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4795,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3262,7 +3312,7 @@ index 6edf0c9..2459097 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4774,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4826,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3288,7 +3338,7 @@ index 6edf0c9..2459097 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +4801,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +4853,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3301,7 +3351,7 @@ index 6edf0c9..2459097 100644 return uBO; } -@@ -2605,10 +4815,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +4867,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3318,7 +3368,7 @@ index 6edf0c9..2459097 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4867,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4919,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3363,7 +3413,7 @@ index 6edf0c9..2459097 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4904,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4956,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3384,7 +3434,7 @@ index 6edf0c9..2459097 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4923,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4975,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3405,7 +3455,7 @@ index 6edf0c9..2459097 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4942,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4994,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3426,7 +3476,7 @@ index 6edf0c9..2459097 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4961,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5013,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3447,7 +3497,7 @@ index 6edf0c9..2459097 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4984,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5036,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3468,7 +3518,7 @@ index 6edf0c9..2459097 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5027,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5079,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3494,7 +3544,7 @@ index 6edf0c9..2459097 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5269,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5321,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3516,7 +3566,7 @@ index 6edf0c9..2459097 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5467,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5519,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3539,7 +3589,7 @@ index 6edf0c9..2459097 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5541,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5593,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3588,7 +3638,7 @@ index 6edf0c9..2459097 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5611,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5663,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3620,7 +3670,7 @@ index 6edf0c9..2459097 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5641,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5693,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3646,7 +3696,7 @@ index 6edf0c9..2459097 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +5989,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6041,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3676,7 +3726,7 @@ index 6edf0c9..2459097 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6043,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6095,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch new file mode 100644 index 00000000..23ba42fc --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch @@ -0,0 +1,72 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs +index a9db192..f59db79 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs +@@ -127,15 +127,65 @@ public class SystemRenderParticles : ClientSystem, IAsyncParticleManager + public void OnRenderFrame3D(float deltaTime) + { + UpdateParticleFountain(); + ShaderProgramParticlescube particlescube = ShaderPrograms.Particlescube; + particlescube.Use(); +- game.Platform.GlToggleBlend(on: true); +- Render(1, deltaTime); ++ // Optimum TAA (P4): cube particles are the one particle class drawn on ++ // Primary, so they write the motion attachment themselves - the quad ++ // particles go into the Transparent target and get their reactive value ++ // from the OIT merge instead. The window has to be opened before ++ // GlToggleBlend, because that call is what forces replace blending onto ++ // the motion attachment (a blended vector belongs to neither surface). ++ // Everything here is a no-op with TAA off: BeginMotionWrite returns false ++ // and the shader preprocesses back to vanilla. ++ ClientPlatformWindows optimumPlatform = game.Platform as ClientPlatformWindows; ++ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); ++ try ++ { ++ game.Platform.GlToggleBlend(on: true); ++ if (optimumMotionWrite) ++ { ++ SetOptimumMotionUniforms(particlescube); ++ } ++ Render(1, deltaTime); ++ } ++ finally ++ { ++ if (optimumMotionWrite) ++ { ++ optimumPlatform.EndMotionWrite(); ++ } ++ } + particlescube.Stop(); + } + ++ /// ++ /// Optimum TAA (P4): the previous-frame half of the cube-particle writer's ++ /// uniforms - the previous unjittered world projection and the previous ++ /// CameraMatrixOrigin, plus the render size, this frame's jitter, the camera ++ /// delta and the previous warp state that ApplyMotionUniforms carries. ++ /// ++ /// The same set ChunkRenderer.SetOptimumMotionUniforms hands the terrain ++ /// writers; particle instance positions are camera-relative against ++ /// EntityPlayer.CameraPos, which is the origin cameraPosDelta is measured in, ++ /// so the previous position is truePos + cameraPosDelta exactly as for a ++ /// chunk vertex. No allocation: every value is a cached array or a scalar. ++ /// ++ private void SetOptimumMotionUniforms(IShaderProgram program) ++ { ++ OptimumTemporalFrame frame = OptimumTemporal.Frame; ++ if (program.HasUniform("prevProjectionMatrix")) ++ { ++ program.UniformMatrix("prevProjectionMatrix", frame.GetPrevProjection(EnumTemporalView.World)); ++ } ++ if (program.HasUniform("prevModelViewMatrix")) ++ { ++ program.UniformMatrix("prevModelViewMatrix", frame.PrevCameraMatrixOrigin); ++ } ++ frame.ApplyMotionUniforms(program); ++ } ++ + public void OnRenderFrame3DOIT(float deltaTime) + { + ShaderProgramParticlesquad particlesquad = ShaderPrograms.Particlesquad; + particlesquad.Use(); + Render(0, deltaTime); diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index c03c9ead..55d647d8 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -39,6 +39,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerEffects.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch diff --git a/sources/shaders/particlescube.fsh b/sources/shaders/particlescube.fsh new file mode 100644 index 00000000..70cd7e00 --- /dev/null +++ b/sources/shaders/particlescube.fsh @@ -0,0 +1,91 @@ +#version 330 core +#extension GL_ARB_explicit_attrib_location: enable + +// Optimum override of the vanilla particlescube.fsh (TAA P4). Vanilla is +// untouched; the motion attachment is written beside its outputs and the whole +// addition preprocesses away when TAAMOTION is 0. + +in vec4 color; +in vec2 uv; +in float glowLevel; +in float fogAmount; +in vec4 rgbaFog; +in vec3 normal; +in vec4 worldPos; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if SSAOLEVEL > 0 +in vec4 fragPosition; +in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +// TAA motion vectors (Optimum P4). TAAMOTIONLOCATION is the Primary colour +// attachment the motion texture occupies (2 without the SSAO G-buffer, 4 with +// it); SystemRenderParticles opens the draw-buffer window that lets this +// attachment be written at all, and the blend seam forces replace blending on +// it - a blended motion vector averages two surfaces' displacements and belongs +// to neither. +#if TAAMOTION > 0 +in vec4 taaPrevClip; +uniform vec2 taaRenderSize; // render-target size in pixels +uniform vec2 taaJitterPx; // this frame's sub-pixel shear, in pixels +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; +#endif + +#include fogandlight.fsh +#include underwatereffects.fsh + +void main() +{ + #if SHADOWQUALITY > 0 + float intensity = 0.34 + (1 - shadowIntensity)/8.0; // this was 0.45, which makes shadow acne visible on blocks + #else + float intensity = 0.45; + #endif + + + + float murkiness = getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadowWithNormal(color, 0, normal, 1, intensity, worldPos.xyz); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadowWithNormal(color, fogAmount, normal, 1, intensity, worldPos.xyz); + } + + outGlow = vec4(glowLevel, 0, 0, outColor.a); + //outColor = vec4((normal.x + 1) / 2.0, (normal.y + 1) / 2.0, (normal.z + 1) / 2.0, 1); + +#if SSAOLEVEL > 0 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = vec4(gnormal.xyz, outColor.a); +#endif + +#if TAAMOTION > 0 + // b = 1: a cube particle is reactive, always. Its previous position is the + // camera-only one (see the vertex shader), it is alpha-blended over whatever + // was behind it, and it appears and disappears from one frame to the next - + // three separate reasons why last frame's colour at the reprojected location + // is not this particle. Reactive 1 makes the resolve take this frame's pixel + // (taa-resolve.fsh: alpha = max(alpha, reactive)). + // + // a = gl_FragCoord.z: the writer depth. Cube particles are drawn with the + // depth mask on, so this is the value that ends up in Primary's depth + // attachment and the resolve's writer-depth test accepts the pixel. Without + // it the reactive value would never be delivered. + // + // A previous position behind the previous camera is not a motion vector; a + // zero alpha routes the pixel to the resolve's camera fallback, exactly as + // in chunkopaque.fsh. + if (taaPrevClip.w <= 1e-6) { + outMotion = vec4(0.0); + } else { + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + outMotion = vec4(prevPixel - currentPixel, 1.0, gl_FragCoord.z); + } +#endif +} \ No newline at end of file diff --git a/sources/shaders/particlescube.vsh b/sources/shaders/particlescube.vsh new file mode 100644 index 00000000..cc7042d3 --- /dev/null +++ b/sources/shaders/particlescube.vsh @@ -0,0 +1,165 @@ +#version 330 core +#extension GL_ARB_explicit_attrib_location: enable + +// Optimum override of the vanilla particlescube.vsh (TAA P4). +// +// Cube particles are drawn on Primary, inside the Opaque stage, with blending +// on and depth writes on - so unlike the quad particles (which go through the +// OIT path and are covered by the merge's revealage reactive) they can and must +// write Primary's motion attachment themselves. TAA-PLAN.md's inventory row for +// this class reads "reactive 1, replace-blend on motion (P4)". +// +// Every vanilla line below is untouched; the TAA block is added beside it and +// preprocesses away entirely when TAAMOTION is 0. +// +// The previous position is the CAMERA-ONLY one: the particle is treated as +// standing still in the world and only the camera is allowed to have moved +// (accuracy rule 4's prevRel = truePos + cameraPosDelta). Per-particle previous +// positions are not available - the instance buffer carries position and scale +// only (ParticlePoolQuads' CustomFloats: 3 + 1 floats, stride 16), and adding a +// previous-position channel would double an allocation sized by +// MaxCubeParticles for every pool, main-thread and off-thread. It would also +// buy nothing today: the fragment stage writes reactive 1, so the resolve's +// `alpha = max(alpha, reactive)` throws this pixel's history away whatever the +// vector says. What the writer is really for is exactly that: claiming the +// pixel with a matching writer depth so the reactive value is delivered, and a +// smeared particle trail is replaced by the current frame's particle. + +layout (location = 0) in vec3 vertexPosition; // Per vertex +layout (location = 1) in vec4 normalv; // Per vertex +layout (location = 2) in vec2 uv; // Per vertex +layout (location = 3) in int renderFlags; // Per instance + +layout (location = 4) in vec3 particlePosition; // Per instance (=per particle) +#if defined(VEC3SCALE) +layout (location = 5) in vec3 scale; // Per instance +#else +layout (location = 5) in float scale; // Per instance +#endif +layout (location = 6) in vec4 particleDir; // Per instance +layout (location = 7) in vec4 rgbaLightIn; // Per instance +layout (location = 8) in vec4 rgbaBlockIn; // Per instance + +uniform vec4 rgbaFogIn; +uniform vec3 rgbaAmbientIn; +uniform float fogMinIn; +uniform float fogDensityIn; +uniform mat4 projectionMatrix; +uniform mat4 modelViewMatrix; + +out vec4 color; +out vec4 rgbaFog; +out vec3 normal; +out float fogAmount; +out vec4 worldPos; + +// TAA motion vectors (Optimum P4). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED world projection +uniform mat4 prevModelViewMatrix; // previous frame's CameraMatrixOrigin +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +out vec4 taaPrevClip; +#endif +#if SSAOLEVEL > 0 +out vec4 fragPosition; +out vec4 gnormal; +#endif + +#include vertexflagbits.ash +#include shadowcoords.vsh +#include fogandlight.vsh +#include vertexwarp.vsh + +#define M_PI 3.1415926535897932384626433832795 + +mat4 rotation3d(vec3 axis, float angle) { + axis = normalize(axis); + float s = sin(angle); + float c = cos(angle); + float oc = 1.0 - c; + + return mat4( + oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0, + oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0, + oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0, + 0.0, 0.0, 0.0, 1.0 + ); +} + +float atan2(in float y, in float x) +{ + bool s = (abs(x) > abs(y)); + return mix(M_PI/2.0 - atan(x,y), atan(y,x), s); +} + + +#if TAAMOTION > 0 +// The position half of vanilla's main() below, as a function of the warp state +// and the particle's position, so the same code can be evaluated for this frame +// and for the previous one. Vanilla's own lines are left exactly as they are; +// this is the twin beside them, and the coverage test compares the two. +vec4 taaParticleWorldPos(WarpState st, vec3 taaParticlePosition) +{ + vec4 taaWorldPos; +#if defined(VEC3SCALE) + mat4 rotMat = rotation3d(vec3(0,1,0), atan2(particleDir.z, particleDir.x) + particleDir.w); + taaWorldPos = rotMat * (vec4(vertexPosition,1.0) * vec4(scale,1.0)) + vec4(taaParticlePosition, 1.0); + taaWorldPos.w=1; +#else + taaWorldPos = vec4(vertexPosition * scale + taaParticlePosition, 1.0); +#endif + + taaWorldPos = applyVertexWarpingState(st, renderFlags, taaWorldPos); + taaWorldPos = applyGlobalWarpingState(st, taaWorldPos); + return taaWorldPos; +} +#endif + + +void main() +{ +#if defined(VEC3SCALE) + mat4 rotMat = rotation3d(vec3(0,1,0), atan2(particleDir.z, particleDir.x) + particleDir.w); + worldPos = rotMat * (vec4(vertexPosition,1.0) * vec4(scale,1.0)) + vec4(particlePosition, 1.0); + worldPos.w=1; +#else + worldPos = vec4(vertexPosition * scale + particlePosition, 1.0); +#endif + + worldPos = applyVertexWarping(renderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + int flags = min(255, 2 * (renderFlags & 0xff)); // increase the glow on cube particles + color = applyLight(rgbaAmbientIn, rgbaLightIn, flags, cameraPos) * rgbaBlockIn; + + fogAmount = getFogLevel(vec4(particlePosition, 0), fogMinIn, fogDensityIn); + rgbaFog = rgbaFogIn; + normal = normalv.xyz; + + calcShadowMapCoords(modelViewMatrix, worldPos); + +#if SSAOLEVEL > 0 + + fragPosition = cameraPos; + gnormal = modelViewMatrix * vec4(normal.xyz, 0.25); +#endif + +#if TAAMOTION > 0 + // The same vertex, one frame ago: the particle where it is now, moved by + // exactly the camera's own motion (accuracy rule 4 - the instance positions + // are camera-relative, rebased against EntityPlayer.CameraPos every frame, + // which is the same origin cameraPosDelta is measured in), the warp + // re-evaluated with the previous frame's counters, and the previous + // UNJITTERED projection with the previous CameraMatrixOrigin. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = taaParticleWorldPos(taaPrev, particlePosition + cameraPosDelta); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + } +#endif +} \ No newline at end of file diff --git a/sources/shaders/transparentcompose.fsh b/sources/shaders/transparentcompose.fsh new file mode 100644 index 00000000..f10be164 --- /dev/null +++ b/sources/shaders/transparentcompose.fsh @@ -0,0 +1,81 @@ +#version 330 core + +// Optimum override of the vanilla transparentcompose.fsh (TAA P4): the OIT +// merge, plus the reactive value for everything that was drawn into the +// Transparent target. +// +// Quad particles, OIT entities, liquid shading and anything else that goes +// through oit.fsh cannot write Primary's motion attachment - their six OIT +// outputs already fill the Transparent target's attachment set - so +// TAA-PLAN.md's Conventions give them their reactive value here instead: +// "OIT transparents `1 - revealage` from the merge". This pass is the one place +// in the frame where the total coverage of all that transparent content over a +// pixel is known: `anet` below, which vanilla already computes and hands to +// outColor's alpha for the blend that follows. +// +// Only the blue channel of the motion attachment is meant to change. The rg +// (the vector) and a (the writer depth) of whatever opaque surface wrote this +// pixel have to survive, or the transparent content in front would delete the +// motion of the geometry behind it. That is done with blending rather than a +// colour mask: ClientPlatformWindows.MergeTransparentRenderPass puts the motion +// attachment on FUNC_ADD with (ONE, ONE), and the fragment writes zero into rg +// and a, so those channels add zero and only b accumulates. Where nothing +// transparent covers the pixel anet is 0 and the attachment is bit-for-bit +// unchanged. + + +uniform sampler2D accumulation; +uniform sampler2D revealage; +uniform sampler2D inGlow; + +uniform sampler2D OITreveal; +uniform sampler2DArray OITaccumulation; + +in vec2 v_texcoord; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if SSAOLEVEL > 0 +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +#if TAAMOTION > 0 +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; +#endif + +#define OIT_BINS 3 + +vec3 unproject(vec4 a){ + return a.w < 0.0001 ? vec3(0.0) : a.xyz / a.w; +} + +void main(){ + + vec4 reveal = 1.0 - texelFetch(OITreveal, ivec2(gl_FragCoord), 0); + float anet = 1.0 - texelFetch(revealage, ivec2(gl_FragCoord), 0).r; + vec4 k = vec4(0.0); + float a = 1.0; + + for(int i = 0; i < OIT_BINS; i++){ + + vec4 bin = texelFetch(OITaccumulation, ivec3(gl_FragCoord.xy, i), 0); + float anet_k = reveal[i]; + + k += vec4(unproject(bin) * anet_k, anet_k) * a; + a *= 1.0 - anet_k; + + } + + outColor = vec4(unproject(k), anet); + outGlow = texture(inGlow, v_texcoord); + +#if TAAMOTION > 0 + // anet is the fraction of this pixel the transparent layer covers - the + // very alpha this pass is composited with. It is the reactive value: at 1 + // the pixel is entirely transparent content with no motion vector of its + // own, at 0 the pixel is untouched by it. + outMotion = vec4(0.0, 0.0, clamp(anet, 0.0, 1.0), 0.0); +#endif + +} From 504ce71edf37a7130c924824facd438d486ecb10 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 22:40:12 +0200 Subject: [PATCH 034/226] wip(taa): P4 sky, clouds, decals and late overlays - verified by GPU readback Sky/celestial: sky colour, night sky and sun/moon leave Primary's depth at 1 and never claim a motion pixel, so the resolve's infinite-direction fallback already owns them; documented and pinned by tests, no writer added. Clouds/aurora: new taa-skymotion pass (fullscreen triangle at window depth 1, GL_LEQUAL, depth writes off, motion-only draw-buffer window) claims the sky pixels after the OIT merge and the liquid velocity pass. It writes the camera-rotation-only reprojection of the view direction, gl_FragCoord.z as the writer depth, and a reactive value interpolated from the Transparent target's revealage coverage towards taaCloudReactive - so a cloud bank stops smearing while clear, dithered sky keeps its full history weight. Decals: they push the depth buffer nearer than the block they sit on, which breaks the terrain writer's depth match at close range, so decals.vsh/.fsh now write the terrain previous path themselves (previousWarpState, cameraPosDelta, both z-offsets) with their own gl_FragCoord.z, inside a motion window opened in SystemRenderDecals.OnRenderFrame3D. Late overlays: BeginMotionWrite/BeginMotionOnlyWrite now refuse once OptimumTemporal.Frame.JitterActive is cleared, which happens in RenderAfterPostProcessing - before AfterFinalComposition and AfterBlit. Verified: dotnet build; Optimum.Tests 885 passed; Optimum.Render.Vulkan.Tests 310 passed, including 7 new GPU readback cases (rotation vector at four pixels, reactive 1 at full coverage, 0 at none, 0.75 at half, depth-test rejection of a pixel another writer claimed, two jittered cases, colour attachment untouched); extract-patches and check-patches clean. NOT verified in game on either backend. --- .../ShaderCompatibilityScanner.cs | 6 + Optimum.Patcher/Program.cs | 13 + .../TaaSkyMotionTests.cs | 710 ++++++++++++++++++ .../taa-sky-decal-motion-coverage-tests.cs | 484 ++++++++++++ .../temporal-render-inventory-tests.cs | 62 ++ TAA-PLAN.md | 60 +- .../ClientMain.cs.patch | 25 +- .../ClientPlatformWindows.cs.patch | 256 +++++-- .../ShaderPrograms.cs.patch | 9 +- .../ShaderRegistry.cs.patch | 12 +- .../SystemRenderDecals.cs.patch | 91 +++ patches/cecil-owned.list | 1 + sources/shaders/decals.fsh | 87 +++ sources/shaders/decals.vsh | 118 +++ sources/shaders/taa-skymotion.fsh | 107 +++ sources/shaders/taa-skymotion.vsh | 25 + 16 files changed, 1984 insertions(+), 82 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs create mode 100644 Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch create mode 100644 sources/shaders/decals.fsh create mode 100644 sources/shaders/decals.vsh create mode 100644 sources/shaders/taa-skymotion.fsh create mode 100644 sources/shaders/taa-skymotion.vsh diff --git a/Optimum.Launcher/ShaderCompatibilityScanner.cs b/Optimum.Launcher/ShaderCompatibilityScanner.cs index 2167bb52..dfece6ef 100644 --- a/Optimum.Launcher/ShaderCompatibilityScanner.cs +++ b/Optimum.Launcher/ShaderCompatibilityScanner.cs @@ -349,6 +349,12 @@ private static void FinalizeReport(ShaderCompatibilityReport report) // exactly the fast-moving, alpha-blended pixels TAA is worst at. HasExternalShader(report, "particlescube.vsh") || HasExternalShader(report, "particlescube.fsh") || HasExternalShader(report, "transparentcompose.fsh") || + // A decal that no longer writes the attachment leaves the block's + // vector behind a depth the decal itself moved, which the resolve + // rejects; and an external sky-motion pass would decide the reactive + // policy for every cloud pixel in the frame. + HasExternalShader(report, "decals.vsh") || HasExternalShader(report, "decals.fsh") || + HasExternalShader(report, "taa-skymotion.vsh") || HasExternalShader(report, "taa-skymotion.fsh") || HasExternalShader(report, "vertexwarp.vsh"); AddFeatureDecision(report, "Taa", externalMotionShader, "external shader owns a motion-vector writer contract"); diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 48bf55ba..01060e26 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -86,6 +86,11 @@ { "SetOptimumMotionUniforms", }, + // TAA P4: the decal motion writer's previous-frame uniforms. + ["Vintagestory.Client.NoObf.SystemRenderDecals"] = new() + { + "SetOptimumMotionUniforms", + }, ["Vintagestory.Client.NoObf.SystemRenderPlayerEffects"] = new() { "GetOptimumLightRadius", @@ -173,6 +178,10 @@ // which contributes the transparent layer's coverage to the reactive // channel without touching the vector or the writer depth under it. "ApplyOptimumMotionAccumulateBlendState", + // TAA P4: the sky / volumetric-cloud motion and reactive pass and the + // reactive constant it stamps. + "RenderOptimumSkyMotion", + "OptimumCloudReactive", }, // TAA P3: the uniform block a buffer feeds and the point it is bound to. // Vanilla had one block per program and Bind() hard-coded binding point 0; @@ -191,6 +200,8 @@ "TaaResolve", // TAA P4: the liquid velocity pass program. "ChunkLiquidMotion", + // TAA P4: the sky / volumetric-cloud motion pass program. + "TaaSkyMotion", }, ["Vintagestory.Client.NoObf.ShaderRegistry"] = new() { @@ -761,6 +772,8 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "MergeTransparentRenderPass", 0), // TAA P4: the cube-particle motion window and its uniforms. new("Vintagestory.Client.NoObf.SystemRenderParticles", "OnRenderFrame3D", 1), + // TAA P4: the decal motion window. + new("Vintagestory.Client.NoObf.SystemRenderDecals", "OnRenderFrame3D", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFinalComposition", 0), // GuiCompositeMainMenuLeft: Optimum link in main menu (no lambdas) new("Vintagestory.Client.GuiCompositeMainMenuLeft", "Compose", 0), diff --git a/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs new file mode 100644 index 00000000..8ffbf207 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs @@ -0,0 +1,710 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The sky / volumetric-cloud motion pass (TAA P4), driven through the seam with +/// the real taa-skymotion program and read back as pixels. +/// +/// The pass is a fullscreen triangle at window depth 1.0 drawn with GL_LEQUAL, +/// so it covers the sky and nothing else, and it writes the motion attachment +/// alone. Four things have to hold and are asserted here: +/// +/// 1. the vector is the camera-ROTATION-only reprojection of the pixel's view +/// direction - a point on the celestial sphere does not parallax, so the +/// previous view-projection's translation column has to be dropped (the +/// shader multiplies the direction with w = 0); +/// 2. the reactive value follows the transparent layer's coverage, read from +/// the Transparent target's revealage attachment - 1 where a cloud fully +/// covers the pixel, 0 on clear sky, which is what keeps the dithered sky +/// gradient converging; +/// 3. a pixel some surface already wrote depth for is rejected by the depth +/// test, so the terrain/entity/liquid/particle writers keep their vectors; +/// 4. the shaded image is untouched. +/// +/// The projection is the same perspective-SHAPED matrix the liquid test uses +/// (clip.w = -z_view), because that is what makes the jitter shear +/// P[8] -= 2*jx/W displace a raster position by exactly jx pixels; the +/// jittered case is covered below. +/// +public class TaaSkyMotionTests +{ + private readonly ITestOutputHelper _output; + + public TaaSkyMotionTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// The camera yaw, in radians, between the previous frame and this one. + private const float Yaw = 0.1f; + + /// + /// A perspective-shaped projection, column-major: x and y pass through, + /// clip.w = -z and clip.z = -z - 1. + /// + private static readonly float[] Projection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, -1, -1, + 0, 0, -1, 0, + }; + + /// + /// The inverse of after the jitter shear, in the + /// form ClientPlatformWindows.RenderOptimumSkyMotion hands the shader. + /// + /// Sheared: clip.x = x - (2jx/S) z, clip.y = y - (2jy/S) z, + /// clip.z = -z - w, clip.w = -z. Inverting gives + /// z = -d, w = d - c, x = a - (2jx/S) d, + /// y = b - (2jy/S) d. + /// + private static float[] InverseJittered(float jitterX, float jitterY) => new[] + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 0f, -1f, + -2f * jitterX / Size, -2f * jitterY / Size, -1f, 1f, + }; + + /// + /// The previous view-projection: times a yaw + /// rotation of . Worked out by hand rather than by a + /// matrix helper, so the test states the maths the shader has to reproduce + /// instead of recomputing it the same way. + /// + private static float[] PreviousViewProjection(float yaw) + { + float c = MathF.Cos(yaw); + float s = MathF.Sin(yaw); + return new[] + { + c, 0f, s, s, + 0f, 1f, 0f, 0f, + s, 0f, -c, -c, + 0f, 0f, -1f, 0f, + }; + } + + /// + /// The contract, restated independently of the shader: the view direction + /// through the (unjittered-by-the-inverse) raster position, rotated into the + /// previous camera, projected, and expressed as + /// previousPixel - currentUnjitteredPixel. + /// + private static (float X, float Y) ExpectedMotion(int pixelX, int pixelY, float yaw, float jitterX, float jitterY) + { + float fragX = pixelX + 0.5f; + float fragY = pixelY + 0.5f; + float nx = fragX / Size * 2f - 1f - 2f * jitterX / Size; + float ny = fragY / Size * 2f - 1f - 2f * jitterY / Size; + + float c = MathF.Cos(yaw); + float s = MathF.Sin(yaw); + // direction = (nx, ny, -1); rotated: (c*nx - s, ny, -s*nx - c); + // clip.w = -z' = s*nx + c. + float denominator = s * nx + c; + float prevNdcX = (c * nx - s) / denominator; + float prevNdcY = ny / denominator; + + float prevPixelX = (prevNdcX * 0.5f + 0.5f) * Size; + float prevPixelY = (prevNdcY * 0.5f + 0.5f) * Size; + + return (prevPixelX - (fragX - jitterX), prevPixelY - (fragY - jitterY)); + } + + // ------------------------------------------------------------------ tests + + /// + /// The headline case: a fullscreen cloud at depth 1 with a pure rotation + /// delta. The vector is the rotation reprojection at every pixel, the + /// reactive value is 1 because the cloud covers the pixel completely, and + /// the writer depth is 1.0 so the resolve accepts it against the sky depth. + /// + [SkippableFact] + public void AFullscreenCloudAtDepthOneUnderPureRotationWritesTheRotationVectorAndReactiveOne() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderSkyMotion(device!, coverage: 1f); + + foreach ((int x, int y) in new[] { (32, 32), (48, 40), (20, 12), (56, 56) }) + { + Decoded pixel = result.At(x, y); + (float expectedX, float expectedY) = ExpectedMotion(x, y, Yaw, 0f, 0f); + + _output.WriteLine($"({x}, {y}): mv = ({pixel.MotionX}, {pixel.MotionY}), " + + $"expected ({expectedX}, {expectedY}), reactive = {pixel.Reactive}, " + + $"writerDepth = {pixel.WriterDepth}"); + + Assert.InRange(pixel.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(pixel.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(pixel.Reactive, 0.99f, 1.01f); + Assert.InRange(pixel.WriterDepth, 0.99f, 1.01f); + } + + // A rotation really does move the sky: a test whose expected value + // is zero everywhere cannot tell a sign error from a missing writer. + Decoded centre = result.At(32, 32); + Assert.True(Math.Abs(centre.MotionX) > 1f, + "the yaw produced no horizontal motion, so the rotation is not being applied"); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + } + } + + /// + /// Clear sky - nothing transparent over the pixel - keeps reactive 0, so the + /// dithered sky gradient goes on converging. The vector is still written. + /// + [SkippableFact] + public void ClearSkyKeepsAZeroReactiveValueAndStillGetsTheVector() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderSkyMotion(device!, coverage: 0f); + Decoded centre = result.At(32, 32); + (float expectedX, float expectedY) = ExpectedMotion(32, 32, Yaw, 0f, 0f); + + _output.WriteLine($"clear sky: mv = ({centre.MotionX}, {centre.MotionY}), reactive = {centre.Reactive}"); + + Assert.InRange(centre.Reactive, 0f, 0.01f); + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.99f, 1.01f); + } + } + + /// + /// Half coverage lands strictly between the two, and strictly above the + /// coverage itself - the shader interpolates from the coverage towards the + /// full reactive value rather than stepping - so a thin wisp is neither + /// ignored nor treated like a solid cloud bank. + /// + [SkippableFact] + public void PartialCloudCoverageInterpolatesTheReactiveValue() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderSkyMotion(device!, coverage: 0.5f); + Decoded centre = result.At(32, 32); + + _output.WriteLine($"half coverage: reactive = {centre.Reactive}"); + + // mix(0.5, 1.0, 0.5) = 0.75. + Assert.InRange(centre.Reactive, 0.73f, 0.77f); + } + } + + /// + /// The one thing the pass must never do: take a pixel away from a real + /// writer. The left half of the target is covered by a seed writer that + /// leaves both a vector and a depth of 0.5; the sky pass draws at depth 1.0 + /// under GL_LEQUAL and has to be rejected there, while the right half - still + /// at the far plane - is claimed. + /// + [SkippableFact] + public void PixelsAnotherWriterAlreadyClaimedAreRejectedByTheDepthTest() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderSkyMotion(device!, coverage: 1f, seedLeftHalf: true); + + Decoded covered = result.At(12, 32); + Decoded sky = result.At(52, 32); + + _output.WriteLine($"covered: mv = ({covered.MotionX}, {covered.MotionY}), " + + $"reactive = {covered.Reactive}, writerDepth = {covered.WriterDepth}"); + _output.WriteLine($"sky: mv = ({sky.MotionX}, {sky.MotionY}), " + + $"reactive = {sky.Reactive}, writerDepth = {sky.WriterDepth}"); + + // The seed's own values, untouched: (8, -8) px, reactive 0.25, depth 0.5. + Assert.InRange(covered.MotionX, 7.7f, 8.3f); + Assert.InRange(covered.MotionY, -8.3f, -7.7f); + Assert.InRange(covered.Reactive, 0.24f, 0.26f); + Assert.InRange(covered.WriterDepth, 0.48f, 0.52f); + + // And the sky half really was written, or the assertion above would + // pass on a pass that drew nothing at all. + Assert.InRange(sky.WriterDepth, 0.99f, 1.01f); + Assert.InRange(sky.Reactive, 0.99f, 1.01f); + } + } + + /// + /// The jittered case: the projection's inverse carries this frame's shear and + /// the fragment shader is told the same offset in taaJitterPx. The two have + /// to cancel - the vector describes where the sky went, not where the + /// sampling grid went. + /// + [SkippableTheory] + [InlineData(0.375f, -0.25f)] + [InlineData(-0.5f, 0.5f)] + public void TheJitterInTheInverseProjectionAndInTheUniformCancel(float jitterX, float jitterY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result jitteredResult = RenderSkyMotion(device!, coverage: 1f, jitterX: jitterX, jitterY: jitterY); + + foreach ((int x, int y) in new[] { (32, 32), (44, 20) }) + { + Decoded pixel = jitteredResult.At(x, y); + (float expectedX, float expectedY) = ExpectedMotion(x, y, Yaw, jitterX, jitterY); + (float unjitteredX, float unjitteredY) = ExpectedMotion(x, y, Yaw, 0f, 0f); + + _output.WriteLine($"jitter ({jitterX}, {jitterY}) at ({x}, {y}): " + + $"mv = ({pixel.MotionX}, {pixel.MotionY}), expected ({expectedX}, {expectedY}), " + + $"unjittered would be ({unjitteredX}, {unjitteredY})"); + + Assert.InRange(pixel.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(pixel.MotionY, expectedY - 0.3f, expectedY + 0.3f); + } + + // The jitter is a whole decode step (0.25 px) or more, so a missing + // or wrongly signed cancellation cannot hide inside the tolerance. + Assert.True(Math.Abs(jitterX) >= 0.25f && Math.Abs(jitterY) >= 0.25f, + "the jitter chosen is smaller than the decode quantisation"); + } + } + + /// + /// The pass runs after the OIT merge has already composed the frame into + /// Primary, so colour attachment 0 has to come back exactly as it went in - + /// which is what the motion-only draw-buffer mask is for. + /// + [SkippableFact] + public void TheSkyPassLeavesColourAttachmentZeroUntouched() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Result result = RenderSkyMotion(device!, coverage: 1f); + + Assert.InRange(result.At(32, 32).WriterDepth, 0.99f, 1.01f); + + byte[] expected = { 51, 102, 153, 255 }; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + int offset = (y * Size + x) * 4; + for (int channel = 0; channel < 4; channel++) + { + Assert.True(Math.Abs(result.Colour[offset + channel] - expected[channel]) <= 1, + $"colour attachment 0 was written at ({x}, {y}) channel {channel}: " + + $"{result.Colour[offset + channel]} instead of {expected[channel]}"); + } + } + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float reactive, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + Reactive = reactive; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float Reactive { get; } + public float WriterDepth { get; } + } + + private sealed class Result + { + public byte[] Motion = Array.Empty(); + public byte[] Reactive = Array.Empty(); + public byte[] Colour = Array.Empty(); + + public Decoded At(int x, int y) + { + int offset = (y * Size + x) * 4; + return new Decoded( + (Motion[offset] / 255f * 2f - 1f) * DecodeScale, + (Motion[offset + 1] / 255f * 2f - 1f) * DecodeScale, + Reactive[offset] / 255f, + Motion[offset + 2] / 255f); + } + } + + private unsafe Result RenderSkyMotion( + VulkanDevice device, + float coverage, + float jitterX = 0f, + float jitterY = 0f, + bool seedLeftHalf = false) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(v => v.Name == "taa-no-ssao"); + Assert.Equal(1, variant.TaaMotion); + Assert.Equal(2, variant.TaaMotionLocation); + + List stages = ShaderCorpus.BuildProgram("taa-skymotion", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "taa-skymotion"); + + Assert.True(seam.GetUniformLocation(program, "taaRenderSize") >= 0, + "taa-skymotion declares no taaRenderSize, so it is not a motion writer"); + + // Primary stand-in: colour, glow and the motion attachment at index 2, + // which is where SetupDefaultFrameBuffers puts it without the SSAO + // G-buffer and what TAAMOTIONLOCATION was stamped with above. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + seam.SetTextureParameter(colour, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(colour, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + // The Transparent target's revealage attachment, standing in for + // frameBuffers[1].ColorTextureIds[1]: revealage = 1 - coverage. + int reveal = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + seam.SetTextureParameter(reveal, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(reveal, OptimumGlConstants.TextureMagFilter, 9728); + int revealTarget = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(revealTarget, EnumFramebufferAttachment.ColorAttachment0, reveal, 0); + seam.SetDrawBuffers(revealTarget, 0b1); + + int seedProgram = seamSeedProgram(seam); + int seedMesh = seam.CreateMesh(BuildQuad(-1f, 0f, 0f), staticDraw: true); + Assert.True(seedMesh > 0, seam.GetError() ?? "seed mesh upload failed"); + + seam.BeginFrame(); + + seam.BindFramebuffer(revealTarget); + float revealValue = 1f - coverage; + seam.ClearColor(0, revealValue, revealValue, revealValue, 1f); + + seam.BindFramebuffer(scene); + seam.SetDrawBuffers(scene, 0b111); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.SetViewport(0, 0, Size, Size); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetDepthFunc(0x203); // GL_LEQUAL + + if (seedLeftHalf) + { + // A writer that owns the left half at depth 0.5, with the motion + // attachment as the only enabled target - exactly the shape of a + // motion-only window. + seam.SetDrawBuffers(scene, 1 << 2); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.UseProgram(seedProgram); + seam.DrawMesh(seedMesh); + } + + // The motion-only window: attachment 2 alone, which is what leaves the + // already-composed image alone. + seam.SetDrawBuffers(scene, 1 << 2); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "transparentRevealTex", 14); + seam.BindTexture(14, reveal); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", jitterX, jitterY); + SetMatrix(seam, program, "taaInvViewProjJittered", InverseJittered(jitterX, jitterY)); + SetMatrix(seam, program, "taaPrevViewProj", PreviousViewProjection(Yaw)); + SetFloat(seam, program, "taaCloudReactive", 1f); + + // Depth test on, depth writes OFF: the pass reads the depth buffer to + // decide where the sky is and must not change it. + seam.SetDepthTest(true); + seam.SetDepthMask(false); + seam.DrawFullscreenTriangle(); + + byte[] decodedMotion = DecodeMotion(seam, motion, reactive: false); + byte[] decodedReactive = DecodeMotion(seam, motion, reactive: true); + seam.SetDrawBuffers(scene, 0b111); + var result = new Result + { + Motion = decodedMotion, + Reactive = decodedReactive, + Colour = ReadColour(seam, scene), + }; + seam.Present(); + + AssertClean(seam); + return result; + } + + /// + /// A stand-in for any real motion writer: covers the left half at window + /// depth 0.5 and stamps a vector, a reactive value and its own depth into + /// the attachment. Location 2 is hard-coded because this program is not + /// built through the corpus and so has no TAAMOTIONLOCATION define. + /// + private static int seamSeedProgram(IOptimumGraphicsDevice seam) + { + const string vertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string fragment = @"#version 330 core +layout(location = 2) out vec4 outMotion; +void main(void) { outMotion = vec4(8.0, -8.0, 0.25, gl_FragCoord.z); } +"; + return LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = vertex, PrefixCode = "", Filename = "taa-sky-seed.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = fragment, PrefixCode = "", Filename = "taa-sky-seed.fsh" }, + }, "taa-sky-seed"); + } + + /// A quad spanning x in [minX, maxX], y in [-1, 1], at NDC z. + private static MeshData BuildQuad(float minX, float maxX, float z) + { + return new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { minX, -1f, z, maxX, -1f, z, maxX, 1f, z, minX, 1f, z }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + } + + /// Colour attachment 0 of the scene target, read inside the frame. + private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(scene); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0. + /// + private static unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture, bool reactive) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +uniform int reactiveOnly; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + if (reactiveOnly != 0) { + outColor = vec4(clamp(m.b, 0.0, 1.0), 0.0, 0.0, 1.0); + return; + } + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-sky-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-sky-decode.fsh" }, + }, "taa-sky-decode"); + + int quadMesh = seam.CreateMesh(BuildQuad(-1f, 1f, 0f), staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + SetInt(seam, decode, "reactiveOnly", reactive ? 1 : 0); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs new file mode 100644 index 00000000..76d56a0f --- /dev/null +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P4 sky / volumetrics / decal / late-overlay +/// policies: +/// +/// (a) the sky and cloud policy - which of the celestial layers need a writer +/// at all, and the one pass that was added for the two that do; +/// (b) decals, which write the surface's motion themselves because they move +/// the depth buffer out from under the block's writer depth; +/// (c) AfterFinalComposition and AfterBlit, which run after the resolve, draw +/// with the unjittered projection, and are refused the motion window. +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaSkyMotionTests) proves the numbers. +/// +public class TaaSkyDecalMotionCoverageTests +{ + // ------------------------------------------------- (a) sky and clouds + + /// + /// Sky colour, the night sky, the sun and the moon draw on Primary with the + /// depth test disabled or the depth mask off, so they leave Primary's depth + /// at the far plane and never claim a motion pixel. taa-resolve.fsh's camera + /// fallback then unprojects a depth of 1 - a point at infinity - and + /// reprojects it, which IS the infinite-direction reprojection those layers + /// need. None of them is given a writer, and this pins down the depth state + /// that makes that correct. + /// + [Fact] + public void TheCelestialLayersWriteNoDepthAndThereforeNeedNoWriter() + { + string nightSky = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs"); + string skyColor = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs"); + string sunMoon = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs"); + + // Depth test off for the whole pass: nothing is written to depth at all. + Assert.Contains("GlDisableDepthTest();", nightSky); + Assert.Contains("GlDisableDepthTest();", skyColor); + // The sun and moon DO test depth (terrain occludes them) but never write + // it, so the sky depth of 1 survives underneath them. + Assert.Contains("GlDepthMask(flag: false);", sunMoon); + + // And none of the three is instrumented - the whole point of the row. + foreach (string source in new[] { nightSky, skyColor, sunMoon }) + { + Assert.DoesNotContain("MotionWrite", source); + Assert.DoesNotContain("SetOptimumMotionUniforms", source); + } + } + + /// + /// The volumetric clouds and the aurora are the two celestial layers that + /// move independently of the camera, and both are drawn into the Transparent + /// target, where Primary's motion attachment does not exist. They get a + /// reactive value from the sky pass instead, gated on the coverage the + /// Transparent target's revealage attachment records. + /// + [Fact] + public void TheSkyPassWritesTheRotationVectorAndACoverageGatedReactiveValue() + { + string vertex = Read("sources/shaders/taa-skymotion.vsh"); + string fragment = Read("sources/shaders/taa-skymotion.fsh"); + + // Depth 1: the triangle survives only where nothing wrote depth. + Assert.Contains("gl_Position = vec4(x, y, 1.0, 1.0);", vertex); + + Assert.Contains("#if TAAMOTION > 0", fragment); + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + Assert.Contains("uniform sampler2D transparentRevealTex;", fragment); + Assert.Contains("uniform float taaCloudReactive", fragment); + + // Coverage = 1 - revealage, the same term transparentcompose.fsh + // composites with. + Assert.Contains( + "float coverage = clamp(1.0 - texelFetch(transparentRevealTex, ivec2(gl_FragCoord.xy), 0).r, 0.0, 1.0);", + fragment); + // Interpolated, not stepped: a clear pixel keeps reactive 0 and its full + // history weight, a fully covered one gets taaCloudReactive. + Assert.Contains( + "float reactive = mix(coverage, clamp(taaCloudReactive, 0.0, 1.0), coverage);", + fragment); + + // The rotation-only reprojection: a direction (w = 0) through the + // previous view-projection drops its translation column, which is what + // "a point on the celestial sphere does not parallax" means in maths. + Assert.Contains("vec4 prevClip = taaPrevViewProj * vec4(direction, 0.0);", fragment); + Assert.Contains("vec4 farH = taaInvViewProjJittered * vec4(ndc, 1.0, 1.0);", fragment); + + // The motion contract taa-resolve.fsh consumes. + Assert.Contains("vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); + Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); + Assert.Contains("outMotion = vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z);", fragment); + + // A fragment stage with no output at all is not worth handing to two + // translators; the TAA-off build keeps one dummy attachment. + Assert.Contains("#else", fragment); + Assert.Contains("layout(location = 0) out vec4 outMotion;", fragment); + } + + [Fact] + public void TheSkyPassRunsLastInTheSceneStillInsideTheTemporalWindow() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + string clientMain = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + string pass = MethodBodyAfter(platform, "internal bool RenderOptimumSkyMotion()"); + + // The motion-only window, like the liquid velocity pass: this one + // re-records motion for pixels the frame has already shaded. + Assert.Contains("BeginMotionOnlyWrite()", pass); + Assert.Contains("EndMotionOnlyWrite();", pass); + Assert.Contains("finally", pass); + + // Depth test on, depth writes OFF, and GL_LEQUAL - without the depth + // func change a triangle at the far plane draws nothing at all under the + // default GL_LESS. + Assert.Contains("GlEnableDepthTest();", pass); + Assert.Contains("GlDepthMask(flag: false);", pass); + Assert.Contains("GlDepthFunc(EnumDepthFunction.Lequal);", pass); + // ...and handed back afterwards. + Assert.Contains("GlDepthFunc(EnumDepthFunction.Less);", pass); + Assert.Contains("GlDepthMask(flag: true);", pass); + + // The revealage it reads is the very texture the merge composited with. + Assert.Contains("transparent.ColorTextureIds[1]", pass); + string merge = MethodBodyAfter(platform, "public override void MergeTransparentRenderPass()"); + Assert.Contains("transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1];", merge); + + // Ordering inside the scene phase: after the liquid velocity pass, which + // is after the OIT merge and after every AfterOIT renderer. + string loop = MethodBodyAfter(clientMain, "public void MainRenderLoop(float dt)"); + int afterOit = loop.IndexOf("TriggerRenderStage(EnumRenderStage.AfterOIT, dt);", StringComparison.Ordinal); + int liquid = loop.IndexOf("chunkRenderer.RenderLiquidMotion(dt);", StringComparison.Ordinal); + int sky = loop.IndexOf("RenderOptimumSkyMotion();", StringComparison.Ordinal); + Assert.True(afterOit >= 0 && liquid > afterOit, "the liquid velocity pass must follow the AfterOIT stage"); + Assert.True(sky > liquid, "the sky motion pass must be the last writer of the scene phase"); + + // And it is still inside the temporal window: the jitter is not closed + // until RenderAfterPostProcessing, which is a different method. + Assert.DoesNotContain("JitterActive = false", loop); + + // Registered as an Optimum-only program, so a failed compile marks + // LoadError instead of failing the whole shader load. + string registry = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + Assert.Contains( + "RegisterOptimumShaderProgram(\"taa-skymotion\", ShaderPrograms.TaaSkyMotion = new ShaderProgram());", + registry); + Assert.Contains("shaderProgram == ShaderPrograms.TaaSkyMotion", registry); + } + + // ---------------------------------------------------------- (b) decals + + /// + /// The justification the plan asks for, pinned to the code: the decal pass + /// runs with the depth mask on (inherited from the AfterOIT setup in + /// ClientMain) and decals.vsh pushes the fragment nearer than the block. So + /// the depth buffer at a decal pixel is NOT the depth the terrain writer + /// recorded, and leaving the attachment untouched would demote the pixel to + /// the camera fallback wherever that offset exceeds the resolve's tolerance. + /// The decal writes the surface's motion with its own depth instead. + /// + [Fact] + public void DecalsMoveTheDepthBufferAndThereforeWriteTheMotionThemselves() + { + string clientMain = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + string vertex = Read("sources/shaders/decals.vsh"); + string fragment = Read("sources/shaders/decals.fsh"); + + // The depth state the AfterOIT stage runs under: mask on, test on. + string loop = MethodBodyAfter(clientMain, "public void MainRenderLoop(float dt)"); + int mask = loop.IndexOf("Platform.GlDepthMask(flag: true);", StringComparison.Ordinal); + int afterOit = loop.IndexOf("TriggerRenderStage(EnumRenderStage.AfterOIT, dt);", StringComparison.Ordinal); + Assert.True(mask >= 0 && afterOit > mask, + "the AfterOIT stage no longer runs with depth writes on; the decal policy has to be revisited"); + + // Vanilla's z-offset, which is what moves the depth buffer. + Assert.Contains("gl_Position.w += zOffset * 0.00025 / max(0.1, gl_Position.z * 0.05);", vertex); + // ...applied to the previous clip position too, or the shift itself + // would be reported as motion. + Assert.Contains( + "taaPrevClip.w += taaPrevZOffset * 0.00025 / max(0.1, taaPrevClip.z * 0.05);", + vertex); + + // The terrain previous path of accuracy rule 4, through the same warp + // functions chunkopaque.vsh uses. + Assert.Contains("#if TAAMOTION > 0", vertex); + Assert.Contains("uniform mat4 prevProjectionMatrix;", vertex); + Assert.Contains("uniform mat4 prevModelViewMatrix;", vertex); + Assert.Contains("uniform vec3 cameraPosDelta;", vertex); + Assert.Contains("WarpState taaPrev = previousWarpState();", vertex); + Assert.Contains("vec4 taaPrevPos = vec4(vertexPos + origin + cameraPosDelta, 1.0);", vertex); + Assert.Contains("taaPrevPos = applyVertexWarpingState(taaPrev, renderFlagsIn, taaPrevPos);", vertex); + Assert.Contains("taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos);", vertex); + Assert.Contains("taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos);", vertex); + + // reactive 0: crack progress is left to the resolve's colour clipping, + // which is the inventory row's own wording. + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", fragment); + Assert.Contains("outMotion = vec4(prevPixel - currentPixel, 0.0, gl_FragCoord.z);", fragment); + // Vanilla's own outputs still there - this is an addition to a shading + // pass, not a replacement of it. + Assert.Contains("layout(location = 0) out vec4 outColor;", fragment); + Assert.Contains("layout(location = 1) out vec4 outGlow;", fragment); + } + + [Fact] + public void SystemRenderDecalsOpensTheMotionWindowAroundItsDraw() + { + string decals = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs"); + + string pass = MethodBodyAfter(decals, "public void OnRenderFrame3D(float deltaTime)"); + + // The P3 window (motion ADDED to Primary's set), not the motion-only + // one: this pass shades and writes motion in the same draw. + Assert.Contains("optimumPlatform.BeginMotionWrite()", pass); + Assert.Contains("optimumPlatform.EndMotionWrite();", pass); + Assert.Contains("finally", pass); + Assert.Contains("SetOptimumMotionUniforms(shaderProgramDecals);", pass); + + // The window has to be open before GlToggleBlend, because that call is + // what forces replace blending onto the motion attachment - and the + // decal pass blends. + int begin = pass.IndexOf("BeginMotionWrite()", StringComparison.Ordinal); + int blend = pass.IndexOf("GlToggleBlend(on: true)", StringComparison.Ordinal); + Assert.True(begin >= 0 && blend > begin, + "the motion window must be opened before blending is turned on"); + + string uniforms = MethodBodyAfter(decals, "private void SetOptimumMotionUniforms(IShaderProgram program)"); + Assert.Contains("frame.GetPrevProjection(EnumTemporalView.World)", uniforms); + Assert.Contains("frame.PrevCameraMatrixOrigin", uniforms); + Assert.Contains("frame.ApplyMotionUniforms(program);", uniforms); + + string? patch = TryFind("patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch"); + Assert.True(patch != null, "SystemRenderDecals has no patch, so the change never ships"); + Assert.Contains("BeginMotionWrite()", PatchReader.ReadPatchedContent(patch!)); + } + + // ------------------------------- (c) AfterFinalComposition / AfterBlit + + /// + /// The late overlays - selection boxes, work-item guides, the knapping, + /// clay-form and anvil surfaces, and the AfterBlit rifts - all run after the + /// resolve has consumed the motion attachment and after the jitter window is + /// closed, so they draw with the unjittered projection and must not be able + /// to open a motion window. + /// + [Fact] + public void TheLateStagesRunAfterTheResolveWithTheUnjitteredProjection() + { + string screenManager = Read("build/VintagestoryLib/Vintagestory.Client/ScreenManager.cs"); + string clientMain = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // The frame's order, from the one place that sequences it. + string render = MethodBodyAfter(screenManager, "internal void Render(float dt)"); + int post = render.IndexOf("Platform.RenderPostprocessingEffects(projectMatrix);", StringComparison.Ordinal); + int afterPost = render.IndexOf("CurrentScreen.RenderAfterPostProcessing(dt);", StringComparison.Ordinal); + int afterFinal = render.IndexOf("CurrentScreen.RenderAfterFinalComposition(dt);", StringComparison.Ordinal); + int blit = render.IndexOf("Platform.BlitPrimaryToDefault();", StringComparison.Ordinal); + int afterBlit = render.IndexOf("CurrentScreen.RenderAfterBlit(dt);", StringComparison.Ordinal); + Assert.True(post >= 0 && afterPost > post, + "AfterPostProcessing must follow the post chain that contains the resolve"); + Assert.True(afterFinal > afterPost && blit > afterFinal && afterBlit > blit, + "AfterFinalComposition and AfterBlit must follow it too"); + + // The resolve is the first thing in that post chain. + string postChain = MethodBodyAfter(platform, "public override void RenderPostprocessingEffects(float[] projectMatrix)"); + Assert.Contains("RenderOptimumTaaResolve();", postChain); + + // The jitter window closes before any of them. + string afterPostProcessing = MethodBodyAfter(clientMain, "public void RenderAfterPostProcessing(float dt)"); + int close = afterPostProcessing.IndexOf("OptimumTemporal.Frame.JitterActive = false;", StringComparison.Ordinal); + int trigger = afterPostProcessing.IndexOf("TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt);", StringComparison.Ordinal); + Assert.True(close >= 0 && trigger > close, + "the jitter window must be closed before the first late stage runs"); + + // ...and with it closed, CurrentProjectionMatrix returns the unjittered + // matrix unconditionally, whatever a late renderer asks for. + string projection = MethodBodyAfter(clientMain, "public float[] CurrentProjectionMatrix"); + Assert.Contains("if (OptimumTemporal.Frame.JitterActive", projection); + + // The late renderers really do use that getter. + Assert.Contains("prog.ProjectionMatrix = rpi.CurrentProjectionMatrix;", + Read("VSSurvivalMod/BlockEntityRenderer/KnappingRenderer.cs")); + } + + /// + /// And the motion window is refused there, on the same flag. Without this + /// guard the check would rest on "Primary happens not to be bound", which is + /// false: RenderFinalComposition leaves Primary bound, so an + /// AfterFinalComposition renderer that called BeginMotionWrite would get it. + /// + [Fact] + public void TheMotionWindowIsRefusedOnceTheTemporalWindowIsClosed() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + foreach (string signature in new[] + { + "public bool BeginMotionWrite()", + "public bool BeginMotionOnlyWrite()", + }) + { + string body = MethodBodyAfter(platform, signature); + Assert.Contains("if (!OptimumTemporal.Frame.JitterActive) return false;", body); + // The other two guards that were already there, kept together with + // it so a refactor cannot drop one silently. + Assert.Contains("if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false;", body); + Assert.Contains("if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false;", body); + } + + // The AfterBlit content is further out still - it draws into the default + // framebuffer - which the Primary-is-bound guard catches on its own. + Assert.Contains("capi.Event.RegisterRenderer(this, EnumRenderStage.AfterBlit, \"riftrenderer\");", + Read("VSSurvivalMod/Systems/Rifts/RiftRenderer.cs")); + } + + // -------------------------------------------------------------- the ship + + [Fact] + public void CecilPatcherShipsEverySkyAndDecalMotionMethodAndMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"Vintagestory.Client.NoObf.SystemRenderDecals\", \"OnRenderFrame3D\", 1", patcher); + Assert.Contains("\"RenderOptimumSkyMotion\"", patcher); + Assert.Contains("\"OptimumCloudReactive\"", patcher); + Assert.Contains("\"TaaSkyMotion\"", patcher); + + // The patch has to be owned, or extract-patches writes a file nothing + // ships. + Assert.Contains( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch", + Read("patches/cecil-owned.list")); + } + + [Fact] + public void TheCompatibilityScannerDisablesTaaForAnExternalDecalOrSkyShader() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + + foreach (string shader in new[] + { + "decals.vsh", "decals.fsh", "taa-skymotion.vsh", "taa-skymotion.fsh", + }) + { + Assert.Contains("HasExternalShader(report, \"" + shader + "\")", scanner); + } + } + + /// + /// TAA off must be byte-identical to today's chain. Delete every + /// #if TAAMOTION > 0 region and the comments, and what is left has + /// to be the vanilla file - read from the release archive, never from + /// .vanilla/win-x64/..., which make deploy overwrites with + /// these very overrides. + /// + [Theory] + [InlineData("decals.vsh")] + [InlineData("decals.fsh")] + public void WithTaaOffTheOverridesAreTheVanillaShaders(string shader) + { + string ours = Read("sources/shaders/" + shader); + string? vanilla = VanillaShaderArchive.TryRead("shaders/" + shader); + if (vanilla == null) return; + + Assert.Equal(Squash(StripComments(vanilla)), Squash(StripComments(StripTaaRegions(ours)))); + } + + // ---------------------------------------------------------------- helpers + + private static string StripTaaRegions(string source) + { + var kept = new List(); + int depth = 0; + bool inTaa = false; + + foreach (string line in source.Split('\n')) + { + string trimmed = line.Trim(); + + if (!inTaa && trimmed.StartsWith("#if TAAMOTION", StringComparison.Ordinal)) + { + inTaa = true; + depth = 1; + continue; + } + + if (inTaa) + { + if (trimmed.StartsWith("#if", StringComparison.Ordinal)) depth++; + else if (trimmed.StartsWith("#endif", StringComparison.Ordinal)) + { + depth--; + if (depth == 0) inTaa = false; + } + continue; + } + + kept.Add(line); + } + + Assert.False(inTaa, "unterminated #if TAAMOTION region"); + return string.Join("\n", kept); + } + + private static string StripComments(string source) + { + var kept = new List(); + foreach (string line in source.Split('\n')) + { + if (line.TrimStart().StartsWith("//", StringComparison.Ordinal)) continue; + kept.Add(line); + } + return string.Join("\n", kept); + } + + private static string MethodBodyAfter(string source, string signature) + { + return signature + BodyOf(source, signature); + } + + private static string BodyOf(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "no such function: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open > start); + + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated function body: " + signature); + } + + private static string Squash(string text) + { + var builder = new StringBuilder(text.Length); + bool space = false; + foreach (char c in text) + { + if (char.IsWhiteSpace(c)) { space = true; continue; } + if (space && builder.Length > 0) builder.Append(' '); + space = false; + builder.Append(c); + } + return builder.ToString(); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } +} diff --git a/Optimum.Tests/temporal-render-inventory-tests.cs b/Optimum.Tests/temporal-render-inventory-tests.cs index 650aa8e5..de362174 100644 --- a/Optimum.Tests/temporal-render-inventory-tests.cs +++ b/Optimum.Tests/temporal-render-inventory-tests.cs @@ -131,6 +131,68 @@ public void KnappingClayFormAndAnvilRenderersRenderAtAfterFinalComposition() Assert.Contains("api.Event.UnregisterRenderer(this, EnumRenderStage.AfterFinalComposition);", anvil); } + /// + /// TAA P4 statuses for the classes the plan's inventory table calls out for + /// this phase, pinned to the code that implements them. + /// + [Fact] + public void TheSkyAndCloudRowIsTheSkyMotionPassAndNoCelestialWriter() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + string clientMain = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + // The pass exists, is registered, and is called last in the scene phase. + Assert.Contains("internal bool RenderOptimumSkyMotion()", platform); + Assert.Contains("optimumSkyMotionPlatform.RenderOptimumSkyMotion();", clientMain); + Assert.Contains( + "RegisterOptimumShaderProgram(\"taa-skymotion\", ShaderPrograms.TaaSkyMotion = new ShaderProgram());", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs")); + + // Night sky and sky colour draw with the depth test off, so their pixels + // keep depth 1 and the resolve's infinite-direction fallback owns them; + // the sun and moon test depth but never write it. That is the whole + // reason none of the three has a writer. + Assert.Contains("GlDisableDepthTest();", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs")); + Assert.Contains("GlDisableDepthTest();", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs")); + Assert.Contains("GlDepthMask(flag: false);", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs")); + + // The aurora and the volumetric clouds are OIT content: their coverage + // reaches the sky pass through the Transparent target's revealage. + Assert.Contains("capi.Event.RegisterRenderer(this, EnumRenderStage.OIT, \"aurora\");", + Read("VSEssentials/Systems/Weather/AuroraRenderer.cs")); + // The aurora goes through oit.fsh, so its coverage lands in the very + // revealage attachment the sky pass reads. The vanilla shaders are + // proprietary and never committed, so an un-bootstrapped checkout skips. + string? aurora = VanillaShaderArchive.TryRead("shaders/aurora.fsh"); + if (aurora != null) Assert.Contains("#include oit.fsh", aurora); + } + + [Fact] + public void TheDecalRowIsAWriterOfItsOwn() + { + string decals = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs"); + + // Drawn on Primary in the AfterOIT stage, and instrumented there. + Assert.Contains("EnumRenderStage.AfterOIT, \"decals\", 0.5", decals); + Assert.Contains("optimumPlatform.BeginMotionWrite()", decals); + Assert.Contains("SetOptimumMotionUniforms(shaderProgramDecals);", decals); + Assert.Contains("layout(location = TAAMOTIONLOCATION) out vec4 outMotion;", + Read("sources/shaders/decals.fsh")); + } + + [Fact] + public void TheLateStageRowsAreRefusedTheMotionWindow() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + string clientMain = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + Assert.Contains("if (!OptimumTemporal.Frame.JitterActive) return false;", platform); + Assert.Contains("OptimumTemporal.Frame.JitterActive = false;", clientMain); + } + [Fact] public void VulkanPresentBlitIsTheOnlyYFlip() { diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 20e05e6a..879d8f3b 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -166,10 +166,11 @@ never jittered. | Instanced mechanical power | Opaque / Primary | instanced | exact with previous instance transforms (P3) | | Particles cube | Opaque / Primary, blend on | particlescube | reactive 1, replace-blend on motion (P4) | | Particles quad | OIT / Transparent | particlesquad | reactive via revealage (P4) | -| Clouds (volumetric, map), aurora, night sky, sun/moon, sky colour | OIT/Opaque | dedicated | fallback + reactive; sky uses infinite-direction reprojection (P4) | -| Decals | AfterOIT / Primary | decal shader | inherits surface motion; crack progress rejected by colour clipping (P4) | -| Work-item guides, selection boxes, wireframes | AfterFinalComposition / Primary | various | outside window, unjittered (P1) | -| Rifts | AfterBlit / Default | rift | outside window; noted as FG gap | +| Night sky, sun/moon, sky colour | Opaque / Primary, no depth write | nightsky, sky, celestialobject, standard | no writer needed: depth stays 1, the resolve's infinite-direction fallback is exact (P4, verified) | +| Clouds (volumetric), aurora | OIT / Transparent | cloudvolumetric, aurora | rotation-only vector + coverage-gated reactive from the `taa-skymotion` pass on the sky pixels (P4) | +| Decals | AfterOIT / Primary | decals | exact: writes the terrain previous path itself, with its own depth, because the z-offset moves the depth buffer out from under the block's writer depth; crack progress rejected by colour clipping (P4) | +| Work-item guides, selection boxes, wireframes | AfterFinalComposition / Primary | various | outside window, unjittered (P1); the motion window is refused there on `JitterActive` (P4) | +| Rifts | AfterBlit / Default | rift | outside window, default framebuffer, motion window refused; noted as FG gap (P4) | | Mod geometry via `IRenderAPI` | any | any | fallback via writerDepth mismatch; opt-in writer API later | ## Frame-generation and ray-reconstruction readiness (constraints, not built here) @@ -370,6 +371,57 @@ foliage, no gear-network numbers. Still owed before P5's performance matrix. infinite-direction reprojection; decals inherit motion; AfterFinalComposition/AfterBlit content verified outside the window. State per class exact vs fallback in the inventory test. +P4 status, sky / volumetrics / decals / late overlays (2026-09-10): landed on `feat/taa`. +**Not verified in game on either backend** - no phase of P4 ran `make deploy` or the client, so +by rule 3 none of it is done. GPU proof is Vulkan-only. + +| Class | Status | Why | +|---|---|---| +| Sky colour, night sky | fallback, and exact | depth test off for the whole pass, so depth stays 1 and the resolve's infinite-direction reprojection is the right answer; no writer, by design | +| Sun, moon, celestial objects | fallback, bounded | depth tested but never written (`GlDepthMask(false)`), so the same fallback applies; it ignores the celestial rotation itself, which is ~0.004 deg per frame | +| Volumetric clouds, aurora | vector exact for the camera, reactive by coverage | drawn into Transparent, so they cannot write Primary's attachment; the new `taa-skymotion` pass claims the sky pixels (depth 1, GL_LEQUAL, depth writes off), writes the rotation-only vector and `mix(coverage, taaCloudReactive, coverage)` from the Transparent revealage. Their own scrolling is not in the vector - the reactive value is what stops the smear | +| Clear sky under a cloudless view | exact, full history | coverage 0 means reactive 0, so the dithered gradient keeps converging | +| Decals | exact | own writer: terrain previous path + `previousWarpState()` + both z-offsets, with `a = gl_FragCoord.z`, which is what the decal itself puts in the depth buffer | +| AfterFinalComposition overlays | outside the window | jitter closed in `RenderAfterPostProcessing`; `BeginMotionWrite` now refuses on `JitterActive` | +| Rifts (AfterBlit) | outside the window | default framebuffer; the Primary-is-bound guard refuses on its own. Still the FG gap the plan records | + +Findings to carry: + +(k) **Clouds were already getting a reactive value, from the merge.** The particle stage recorded +that they were not, because `SystemRenderOITLayers` rebinds Transparent's attachment **0** to its +private revealage texture. Attachment **1** - `oit.fsh`'s `outReveal`, the one +`transparentcompose.Revealage2D` reads - is untouched by that rebind, and `cloudvolumetric.fsh` +writes `outReveal = vec4(1.0 - k.a)` into it under the multiplicative blend factors `BeforeOIT` +sets. So cloud coverage does reach `anet`. What the sky pass adds is a reactive value at the +sky's own strength rather than the cloud's alpha, plus an explicit vector and writer depth. + +(l) **`BeginMotionWrite` had no window guard, only a target guard.** `RenderFinalComposition` +leaves Primary bound, so an `AfterFinalComposition` renderer could have opened a motion window +after the resolve had already read the attachment. It is now refused on +`OptimumTemporal.Frame.JitterActive`, which is the temporal window itself. + +(m) **The sky pass needs GL_LEQUAL and the client runs GL_LESS.** A fullscreen triangle at the far +plane draws nothing at all under the default. The pass sets and restores the depth func through +`GlDepthFunc`; anything else that ever wants to draw at exactly the far plane has the same problem. + +(n) **The cloud vector is camera-only.** `taa-skymotion` reprojects the view direction, not the +cloud: a cloud scrolling across a still camera has mv 0 and is carried entirely by the reactive +value. That is correct for the resolve (reactive 1 discards the history) but it is wrong data for +the later consumers the plan is built for - FSR/XeSS reactive+mv, and frame generation especially. +A real cloud vector needs `cloudOffset`'s previous value and the ray-marched hit position, i.e. a +motion output from `cloudvolumetric.fsh` itself, which cannot reach Primary's attachment without a +second pass over the cloud volume. + +(o) **Decals near the camera were the actual bug.** With the block's vector left in place, the +decal's z-offset moves the depth buffer by ~1.3e-3 in window depth at one block's distance against +a tolerance of ~7.2e-4, so every close decal silently demoted its pixel to the camera fallback. +Mid- and far-range decals stayed inside the tolerance, which is why "leave it untouched" looks +correct until you measure it. + +(p) **The GL path of the new pass has never executed.** `Optimum.Render.Vulkan.Tests` is the only +GPU harness; the sky pass's GL branch is the shared `GlDepthFunc`/`GlToggleBlend` helpers plus +`BeginMotionOnlyWrite`'s existing GL branch, all of which are still unproven on OpenGL. + **P5. Integration, sharpen, settings, fallback, acceptance.** - RCAS variant with a sharpness uniform and true bypass; no double sharpening with FSR1 render scale; `TaaMipBias` optional and measured; settings rows in `GuiCompositeSettings.cs.patch`; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index 9930c48c..1fd8daf9 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..7bdb390 100644 +index 67feafa..f5dd1c6 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -326,7 +326,7 @@ index 67feafa..7bdb390 100644 { PerspectiveProjectionMat[i] = top[i]; PerspectiveViewMat[i] = top2[i]; -@@ -1176,14 +1302,32 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1176,14 +1302,43 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo Platform.GlDepthMask(flag: true); Platform.GlEnableDepthTest(); Platform.GlCullFaceBack(); @@ -344,6 +344,17 @@ index 67feafa..7bdb390 100644 + if (doTransparentRenderPass && chunkRenderer != null) + { + chunkRenderer.RenderLiquidMotion(dt); ++ } ++ // Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. ++ // Last of the scene phase, because it reads the Transparent target's ++ // revealage (which the OIT merge has just consumed) and because it must ++ // see the final depth buffer: it draws only where nothing wrote depth, ++ // so every writer that could claim a pixel has to have run first. Still ++ // inside the temporal window; a no-op with TAA off, and skipped when the ++ // transparent pass that produced the revealage did not run. ++ if (doTransparentRenderPass && Platform is ClientPlatformWindows optimumSkyMotionPlatform) ++ { ++ optimumSkyMotionPlatform.RenderOptimumSkyMotion(); + } } @@ -359,7 +370,7 @@ index 67feafa..7bdb390 100644 dt = DeltaTimeLimiter; } TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); -@@ -1420,10 +1564,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1420,10 +1575,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo { float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); @@ -374,7 +385,7 @@ index 67feafa..7bdb390 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1713,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1724,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -407,7 +418,7 @@ index 67feafa..7bdb390 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2281,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2292,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -432,7 +443,7 @@ index 67feafa..7bdb390 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2710,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2721,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -446,7 +457,7 @@ index 67feafa..7bdb390 100644 } public void DoReconnect() -@@ -3531,6 +3701,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3712,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 7abb3c48..488782e5 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..5a00757 100644 +index 6edf0c9..f519573 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -2084,7 +2084,7 @@ index 6edf0c9..5a00757 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,55 +3412,363 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3412,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2120,20 +2120,20 @@ index 6edf0c9..5a00757 100644 + { + GL.DrawBuffers(2, array); + } -+ } -+ } -+ } -+ -+ private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -+ { -+ //IL_0000: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0006: Invalid comparison between Unknown and I4 -+ //IL_0026: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0030: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0040: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0046: Invalid comparison between Unknown and I4 -+ if ((int)type != 33361) -+ { + } + } + } + + private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) +@@ -2014,28 +3454,460 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + //IL_0030: Unknown result type (might be due to invalid IL or missing references) + //IL_0040: Unknown result type (might be due to invalid IL or missing references) + //IL_0046: Invalid comparison between Unknown and I4 + if ((int)type != 33361) + { +- string text = Marshal.PtrToStringAnsi(message, length); +- Logger.Notification("{0} {1} | {2}", severity, type, text); +- if ((int)type == 33356) + string text = Marshal.PtrToStringAnsi(message, length); + Logger.Notification("{0} {1} | {2}", severity, type, text); + if ((int)type == 33356) @@ -2209,6 +2209,17 @@ index 6edf0c9..5a00757 100644 + if (OptimumMotionWriteActive) return false; + if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false; + if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false; ++ // Optimum TAA (P4): the motion attachment only means anything inside the ++ // temporal window. JitterActive is that window - ClientMain opens it in ++ // MainRenderLoop and closes it in RenderAfterPostProcessing, before the ++ // AfterFinalComposition overlays (selection boxes, work-item guides, the ++ // knapping/clay-form/anvil surfaces) and the AfterBlit rifts run. Those ++ // stages draw with the UNJITTERED projection into an image the resolve ++ // has already consumed, so a vector from them would describe a different ++ // sampling grid and be read by nobody this frame - and by next frame's ++ // resolve as a stale writer depth if the attachment were left holding it. ++ // Refusing costs them nothing: they are not in the history either way. ++ if (!OptimumTemporal.Frame.JitterActive) return false; + if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return false; + // The mask only means anything while Primary is the target being drawn + // into. The device call below names the framebuffer, but GL.DrawBuffers @@ -2272,12 +2283,11 @@ index 6edf0c9..5a00757 100644 + for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) + { + optimumMotionDrawBuffersOff[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); - } - } ++ } ++ } + GL.DrawBuffers(optimumMotionDrawBuffersOff.Length, optimumMotionDrawBuffersOff); - } - -- private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) ++ } ++ + /// + /// Optimum TAA (P3): forces replace-blending on the motion attachment. + /// Terrain passes 2 and 8 draw with blending on, and a blended motion vector @@ -2285,21 +2295,11 @@ index 6edf0c9..5a00757 100644 + /// neither. Mirrors what the GL path already does for the SSAO G-buffer. + /// + private void ApplyOptimumMotionBlendState() - { -- //IL_0000: Unknown result type (might be due to invalid IL or missing references) -- //IL_0006: Invalid comparison between Unknown and I4 -- //IL_0026: Unknown result type (might be due to invalid IL or missing references) -- //IL_0030: Unknown result type (might be due to invalid IL or missing references) -- //IL_0040: Unknown result type (might be due to invalid IL or missing references) -- //IL_0046: Invalid comparison between Unknown and I4 -- if ((int)type != 33361) ++ { + if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) - { -- string text = Marshal.PtrToStringAnsi(message, length); -- Logger.Notification("{0} {1} | {2}", severity, type, text); -- if ((int)type == 33356) ++ { + optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); + optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0); + return; @@ -2335,6 +2335,17 @@ index 6edf0c9..5a00757 100644 + if (OptimumMotionWriteActive) return false; + if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false; + if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false; ++ // Optimum TAA (P4): the motion attachment only means anything inside the ++ // temporal window. JitterActive is that window - ClientMain opens it in ++ // MainRenderLoop and closes it in RenderAfterPostProcessing, before the ++ // AfterFinalComposition overlays (selection boxes, work-item guides, the ++ // knapping/clay-form/anvil surfaces) and the AfterBlit rifts run. Those ++ // stages draw with the UNJITTERED projection into an image the resolve ++ // has already consumed, so a vector from them would describe a different ++ // sampling grid and be read by nobody this frame - and by next frame's ++ // resolve as a stale writer depth if the attachment were left holding it. ++ // Refusing costs them nothing: they are not in the history either way. ++ if (!OptimumTemporal.Frame.JitterActive) return false; + if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return false; + if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false; + @@ -2358,7 +2369,7 @@ index 6edf0c9..5a00757 100644 + optimumMotionOnlyDrawBuffers[MotionAttachmentIndex] = (DrawBuffersEnum)(36064 + MotionAttachmentIndex); } + GL.DrawBuffers(optimumMotionOnlyDrawBuffers.Length, optimumMotionOnlyDrawBuffers); - } ++ } + OptimumMotionWriteActive = true; + ApplyOptimumMotionBlendState(); + return true; @@ -2373,8 +2384,125 @@ index 6edf0c9..5a00757 100644 + public void EndMotionOnlyWrite() + { + EndMotionWrite(); ++ } ++ ++ /// ++ /// Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. ++ /// ++ /// A fullscreen triangle at window depth 1.0, drawn with the depth test on ++ /// and GL_LEQUAL, so it survives only where nothing wrote depth - the sky. ++ /// It writes the motion attachment and nothing else (the motion-only window), ++ /// and it never writes depth. ++ /// ++ /// Why it exists is spelled out in taa-skymotion.fsh: sky colour, the night ++ /// sky, the sun and the moon already reproject correctly through the ++ /// resolve's own infinite-direction fallback and need no writer, but the ++ /// volumetric clouds and the aurora move independently of the camera and are ++ /// drawn into the Transparent target, where Primary's motion attachment does ++ /// not exist. Their coverage is read back here from that target's revealage ++ /// attachment - the same texture ++ /// hands the compose as Revealage2D - and turned into the reactive value that ++ /// stops a sweeping cloud edge from smearing. ++ /// ++ /// Ordering: called from ClientMain.MainRenderLoop immediately after the ++ /// liquid velocity pass, which is after the OIT merge (so the revealage is ++ /// this frame's) and after every AfterOIT renderer, and still inside the ++ /// temporal window (the resolve does not run until ++ /// RenderPostprocessingEffects). ++ /// ++ /// A no-op with TAA off, and it never runs unless Primary is the bound ++ /// target - refuses otherwise. ++ /// ++ internal bool RenderOptimumSkyMotion() ++ { ++ if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false; ++ if (!TaaTargetsReady || MotionAttachmentIndex < 0) return false; ++ ShaderProgram skyMotion = ShaderPrograms.TaaSkyMotion; ++ if (skyMotion == null || skyMotion.LoadError || skyMotion.Disposed) return false; ++ if (frameBuffers == null || frameBuffers.Count <= 1) return false; ++ FrameBufferRef primary = frameBuffers[0]; ++ FrameBufferRef transparent = frameBuffers[1]; ++ if (primary == null || primary.Disposed || transparent == null || transparent.Disposed) return false; ++ if (transparent.ColorTextureIds == null || transparent.ColorTextureIds.Length < 2) return false; ++ ++ OptimumTemporalFrame frame = OptimumTemporal.Frame; ++ if (!frame.WasViewCaptured(EnumTemporalView.World)) return false; ++ ++ // The same two matrices the resolve builds for its camera fallback: this ++ // frame's jittered view-projection inverted, and the previous frame's ++ // unjittered one. Building them here rather than reusing the resolve's ++ // keeps the pass independent of whether the resolve ran yet - it has not. ++ float[] projection = frame.GetProjection(EnumTemporalView.World); ++ double[] jittered = new double[16]; ++ for (int i = 0; i < 16; i++) ++ { ++ jittered[i] = projection[i]; ++ } ++ OptimumTemporalMath.ApplyProjectionJitter(jittered, frame.JitterPx.X, frame.JitterPx.Y, primary.Width, primary.Height); ++ float[] projectionJittered = new float[16]; ++ for (int i = 0; i < 16; i++) ++ { ++ projectionJittered[i] = (float)jittered[i]; ++ } ++ float[] viewProj = Mat4f.Mul(new float[16], projectionJittered, frame.CameraMatrixOrigin); ++ float[] invViewProj = Mat4f.Invert(new float[16], viewProj); ++ if (invViewProj == null) return false; ++ float[] prevViewProj = Mat4f.Mul(new float[16], frame.GetPrevProjection(EnumTemporalView.World), frame.PrevCameraMatrixOrigin); ++ ++ // Blending off: this pass owns the pixels it survives on, and the ++ // reactive value it computes already accounts for the coverage that ++ // would otherwise be blended in. ++ GlToggleBlend(on: false); ++ GlEnableDepthTest(); ++ GlDepthMask(flag: false); ++ // GL_LEQUAL, so a triangle at exactly the far plane passes where the ++ // depth buffer is still at the far plane. The default is GL_LESS, under ++ // which this pass would draw nothing at all. ++ GlDepthFunc(EnumDepthFunction.Lequal); ++ GlDisableCullFace(); ++ ++ if (!BeginMotionOnlyWrite()) ++ { ++ GlDepthFunc(EnumDepthFunction.Less); ++ GlDepthMask(flag: true); ++ GlToggleBlend(on: true); ++ return false; + } ++ try ++ { ++ skyMotion.Use(); ++ skyMotion.BindTexture2D("transparentRevealTex", transparent.ColorTextureIds[1], 0); ++ skyMotion.Uniform("taaRenderSize", (float)primary.Width, (float)primary.Height); ++ skyMotion.Uniform("taaJitterPx", frame.JitterPx.X, frame.JitterPx.Y); ++ skyMotion.UniformMatrix("taaInvViewProjJittered", invViewProj); ++ skyMotion.UniformMatrix("taaPrevViewProj", prevViewProj); ++ skyMotion.Uniform("taaCloudReactive", OptimumCloudReactive); ++ RenderFullscreenTriangle(screenQuad); ++ skyMotion.Stop(); ++ } ++ finally ++ { ++ EndMotionOnlyWrite(); ++ // Everything this pass changed, back the way the AfterOIT stage and ++ // the liquid velocity pass left it: depth writes on, GL_LESS, and ++ // blending on for the post chain that follows. ++ GlDepthFunc(EnumDepthFunction.Less); ++ GlDepthMask(flag: true); ++ GlToggleBlend(on: true); ++ } ++ ScreenManager.FrameProfiler.Mark("rend3D-ret-skymv"); ++ return true; } ++ /// ++ /// Optimum TAA (P4): the reactive value a fully cloud-covered sky pixel gets ++ /// from . 1 = do not trust the history ++ /// there at all, which is what a cloud bank scrolling across a static sky ++ /// needs. Partial coverage interpolates towards it from the coverage itself ++ /// (see taa-skymotion.fsh), so a clear pixel still gets 0. ++ /// ++ internal const float OptimumCloudReactive = 1f; ++ public override void BlitPrimaryToDefault() { //IL_002c: Unknown result type (might be due to invalid IL or missing references) @@ -2463,7 +2591,7 @@ index 6edf0c9..5a00757 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3816,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3955,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2489,7 +2617,7 @@ index 6edf0c9..5a00757 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3849,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +3988,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2511,7 +2639,7 @@ index 6edf0c9..5a00757 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +3878,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +4017,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2609,7 +2737,7 @@ index 6edf0c9..5a00757 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +3977,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +4116,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2812,7 +2940,7 @@ index 6edf0c9..5a00757 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4186,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4325,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2979,7 +3107,7 @@ index 6edf0c9..5a00757 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4356,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4495,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3003,7 +3131,7 @@ index 6edf0c9..5a00757 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4385,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4524,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3041,7 +3169,7 @@ index 6edf0c9..5a00757 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4445,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4584,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -3084,7 +3212,7 @@ index 6edf0c9..5a00757 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4498,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4637,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3143,7 +3271,7 @@ index 6edf0c9..5a00757 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4613,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4752,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3217,7 +3345,7 @@ index 6edf0c9..5a00757 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4711,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4850,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3248,7 +3376,7 @@ index 6edf0c9..5a00757 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4748,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4887,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3283,7 +3411,7 @@ index 6edf0c9..5a00757 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4795,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4934,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3312,7 +3440,7 @@ index 6edf0c9..5a00757 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4826,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4965,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3338,7 +3466,7 @@ index 6edf0c9..5a00757 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +4853,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +4992,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3351,7 +3479,7 @@ index 6edf0c9..5a00757 100644 return uBO; } -@@ -2605,10 +4867,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +5006,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3368,7 +3496,7 @@ index 6edf0c9..5a00757 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +4919,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5058,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3413,7 +3541,7 @@ index 6edf0c9..5a00757 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4956,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5095,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3434,7 +3562,7 @@ index 6edf0c9..5a00757 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4975,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5114,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3455,7 +3583,7 @@ index 6edf0c9..5a00757 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4994,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5133,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3476,7 +3604,7 @@ index 6edf0c9..5a00757 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5013,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5152,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3497,7 +3625,7 @@ index 6edf0c9..5a00757 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5036,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5175,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3518,7 +3646,7 @@ index 6edf0c9..5a00757 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5079,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5218,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3544,7 +3672,7 @@ index 6edf0c9..5a00757 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5321,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5460,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3566,7 +3694,7 @@ index 6edf0c9..5a00757 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5519,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5658,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3589,7 +3717,7 @@ index 6edf0c9..5a00757 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5593,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5732,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3638,7 +3766,7 @@ index 6edf0c9..5a00757 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5663,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5802,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3670,7 +3798,7 @@ index 6edf0c9..5a00757 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5693,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5832,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3696,7 +3824,7 @@ index 6edf0c9..5a00757 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6041,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6180,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3726,7 +3854,7 @@ index 6edf0c9..5a00757 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6095,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6234,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch index 25104799..2143e03f 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -index f19d524..41b023f 100644 +index f19d524..63dd560 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -@@ -40,10 +40,23 @@ public static class ShaderPrograms +@@ -40,10 +40,28 @@ public static class ShaderPrograms public static ShaderProgramEntityanimated Entityanimated; @@ -20,6 +20,11 @@ index f19d524..41b023f 100644 + // Registered like the other Optimum-only programs, so a failed compile marks + // LoadError instead of failing the whole shader load. + public static ShaderProgram ChunkLiquidMotion; ++ ++ // Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. ++ // Registered like the other Optimum-only programs, so a failed compile marks ++ // LoadError instead of failing the whole shader load. ++ public static ShaderProgram TaaSkyMotion; + public static ShaderProgramFindbright Findbright; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index aa7b493d..431897bf 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..5ec03b8 100644 +index 4a24e75..f8965d2 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..5ec03b8 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,158 @@ public class ShaderRegistry +@@ -181,39 +183,160 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -25,6 +25,8 @@ index 4a24e75..5ec03b8 100644 + RegisterOptimumShaderProgram("taa-resolve", ShaderPrograms.TaaResolve = new ShaderProgram()); + // Optimum TAA (P4): the liquid velocity pass. + RegisterOptimumShaderProgram("chunkliquidmotion", ShaderPrograms.ChunkLiquidMotion = new ShaderProgram()); ++ // Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. ++ RegisterOptimumShaderProgram("taa-skymotion", ShaderPrograms.TaaSkyMotion = new ShaderProgram()); + } + + private static void RegisterOptimumShaderProgram(string name, ShaderProgram program) @@ -154,7 +156,7 @@ index 4a24e75..5ec03b8 100644 + bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); + OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); + } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.ChunkLiquidMotion) ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.ChunkLiquidMotion || shaderProgram == ShaderPrograms.TaaSkyMotion) + { + shaderProgram.LoadError |= !compiled; + } @@ -182,7 +184,7 @@ index 4a24e75..5ec03b8 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +417,11 @@ public class ShaderRegistry +@@ -296,11 +419,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -195,7 +197,7 @@ index 4a24e75..5ec03b8 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +454,48 @@ public class ShaderRegistry +@@ -333,10 +456,48 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch new file mode 100644 index 00000000..61551032 --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch @@ -0,0 +1,91 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs +index f52d0f6..e0a7d3c 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs +@@ -387,10 +387,34 @@ public class SystemRenderDecals : ClientSystem, IDecalApi + { + UpdateDecal(item); + } + } + ++ /// ++ /// Optimum TAA (P4): the previous-frame uniforms the decal motion writer ++ /// reads. The same set ChunkRenderer hands its terrain writers - decals sit ++ /// on terrain and follow accuracy rule 4's previous path exactly - kept here ++ /// rather than shared because the patcher transplants per type. ++ /// ++ /// The projection and the view are set by name because the decal program ++ /// declares them only while TAA is on; everything else goes through the frame ++ /// contract's guarded setter. ++ /// ++ private void SetOptimumMotionUniforms(IShaderProgram program) ++ { ++ OptimumTemporalFrame frame = OptimumTemporal.Frame; ++ if (program.HasUniform("prevProjectionMatrix")) ++ { ++ program.UniformMatrix("prevProjectionMatrix", frame.GetPrevProjection(EnumTemporalView.World)); ++ } ++ if (program.HasUniform("prevModelViewMatrix")) ++ { ++ program.UniformMatrix("prevModelViewMatrix", frame.PrevCameraMatrixOrigin); ++ } ++ frame.ApplyMotionUniforms(program); ++ } ++ + public void OnRenderFrame3D(float deltaTime) + { + Vec3d cameraPos = game.EntityPlayer.CameraPos; + if (decalOrigin.SquareDistanceTo(cameraPos) > 1000000f) + { +@@ -399,10 +423,24 @@ public class SystemRenderDecals : ClientSystem, IDecalApi + } + if (decals.Count > 0) + { + game.GlPushMatrix(); + game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); ++ // Optimum TAA (P4): a decal overwrites the depth buffer with a value ++ // slightly nearer than the block it sits on (decals.vsh's zOffset ++ // w-offset), so the block's own motion vector - whose writer depth is ++ // the block's - stops matching and the resolve demotes the pixel to ++ // its camera fallback. The decal therefore writes the surface's ++ // motion itself, with its own depth, and the motion attachment joins ++ // Primary's draw-buffer mask for this one pass. ++ // ++ // The window is opened BEFORE GlToggleBlend, because that call is ++ // what forces replace blending onto the motion attachment: this pass ++ // blends colour, and a blended motion vector belongs to neither ++ // surface. A no-op when TAA is off. ++ ClientPlatformWindows optimumPlatform = game.Platform as ClientPlatformWindows; ++ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); + game.Platform.GlToggleBlend(on: true); + game.Platform.GlDisableCullFace(); + ShaderProgramDecals shaderProgramDecals = ShaderPrograms.Decals; + shaderProgramDecals.Use(); + shaderProgramDecals.WindWaveCounter = game.shUniforms.WindWaveCounter; +@@ -414,11 +452,25 @@ public class SystemRenderDecals : ClientSystem, IDecalApi + shaderProgramDecals.FogDensityIn = game.AmbientManager.BlendedFogDensity; + shaderProgramDecals.FogMinIn = game.AmbientManager.BlendedFogMin; + shaderProgramDecals.Origin = new Vec3f((float)(decalOrigin.X - cameraPos.X), (float)(decalOrigin.Y - cameraPos.Y), (float)(decalOrigin.Z - cameraPos.Z)); + shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix; + shaderProgramDecals.ModelViewMatrix = game.CurrentModelViewMatrix; +- decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); ++ if (optimumMotionWrite) ++ { ++ SetOptimumMotionUniforms(shaderProgramDecals); ++ } ++ try ++ { ++ decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); ++ } ++ finally ++ { ++ if (optimumMotionWrite) ++ { ++ optimumPlatform.EndMotionWrite(); ++ } ++ } + shaderProgramDecals.Stop(); + game.Platform.GlToggleBlend(on: true); + game.Platform.GlEnableCullFace(); + game.GlPopMatrix(); + } diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index 55d647d8..8be8a74d 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -37,6 +37,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch diff --git a/sources/shaders/decals.fsh b/sources/shaders/decals.fsh new file mode 100644 index 00000000..dcf230ce --- /dev/null +++ b/sources/shaders/decals.fsh @@ -0,0 +1,87 @@ +#version 330 core + +// Optimum override of the vanilla decals.fsh (TAA P4). +// +// Decals are the crack overlay on a block being broken (and the rot/soot +// overlays). They are drawn on Primary in the AfterOIT stage with the depth +// test AND the depth mask on, and decals.vsh pretends they are closer than the +// block under them so they always win the z-fight: +// +// gl_Position.w += zOffset * 0.00025 / max(0.1, gl_Position.z * 0.05); +// +// That is why the attachment cannot simply be left alone. The terrain writer +// put the block's vector there with a = the BLOCK's window depth; the decal +// then overwrites the depth buffer with its own, slightly nearer value, and +// taa-resolve.fsh accepts a vector only while +// abs(motion.a - depth) <= max(2e-4, 8e-4 * depth). Close to the camera that +// offset is larger than the tolerance, so every near decal would silently +// demote its block to the camera fallback - which is exact for a static block +// but wrong for the swaying ones the offset was raised for in the first place +// ("not enough for leaves :o", decals.vsh). +// +// So the decal writes the motion itself: the SAME surface motion the block has +// (the chunk previous path of accuracy rule 4, replayed through the same +// vertexwarp functions), with a = its own gl_FragCoord.z, which is exactly what +// lands in the depth buffer. The pixel is then accepted at any distance. +// +// Vanilla's own lines below are untouched; the whole addition preprocesses away +// when TAAMOTION is 0. + +in vec2 decalUv; +in vec2 blockUv; +in vec2 decalUvSize; +in vec4 color; +in vec2 decalUvStart; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; + +uniform sampler2D decalTexture; +uniform sampler2D blockTexture; + +// TAA motion vectors (Optimum P4). TAAMOTIONLOCATION is the Primary colour +// attachment the motion texture occupies (2 without the SSAO G-buffer, 4 with +// it); SystemRenderDecals opens the draw-buffer window that lets this +// attachment be written at all, and the blend seam forces replace blending on +// it - the decal pass draws with blending ON, and a blended motion vector is a +// weighted average of two surfaces' displacements, which belongs to neither. +#if TAAMOTION > 0 +in vec4 taaPrevClip; +uniform vec2 taaRenderSize; // render-target size in pixels +uniform vec2 taaJitterPx; // this frame's sub-pixel shear, in pixels +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; +#endif + +void main() +{ + vec2 uv = vec2(decalUvStart.x + mod(decalUv.x, decalUvSize.x), decalUvStart.y + mod(decalUv.y, decalUvSize.y)); + + outColor = color * texture(decalTexture, uv); + + + float blockAlpha = texture(blockTexture, blockUv).a; + if (outColor.a < 0.01 || blockAlpha < 0.01) discard; + + outGlow = vec4(0, 0, 0, outColor.a); + +#if TAAMOTION > 0 + // b = 0: a decal is an opaque overlay on a static-or-swaying block surface + // and its vector is that surface's own, so the history is trustworthy. The + // one thing that does change without moving is the crack stage advancing to + // the next texture, and TAA-PLAN.md's inventory row leaves that to the + // resolve's neighbourhood clipping ("crack progress rejected by colour + // clipping") rather than throwing the whole pixel's history away every time + // a block is being hit. + // + // A previous position behind the previous camera is not a motion vector; a + // zero alpha routes the pixel to the resolve's camera fallback, exactly as + // in chunkopaque.fsh. + if (taaPrevClip.w <= 1e-6) { + outMotion = vec4(0.0); + } else { + vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + outMotion = vec4(prevPixel - currentPixel, 0.0, gl_FragCoord.z); + } +#endif +} diff --git a/sources/shaders/decals.vsh b/sources/shaders/decals.vsh new file mode 100644 index 00000000..eee1f384 --- /dev/null +++ b/sources/shaders/decals.vsh @@ -0,0 +1,118 @@ +#version 330 core +// code will change the version to 430 if USESSBO > 0 + +// Optimum override of the vanilla decals.vsh (TAA P4). See decals.fsh for why +// decals write the motion attachment instead of inheriting the terrain's +// vector. Vanilla's own lines are untouched; the TAA block is added beside them +// and preprocesses away entirely when TAAMOTION is 0. +#extension GL_ARB_explicit_attrib_location: enable + + #if USESSBO > 0 +layout(location = 0) in vec4 rgbaLightIn; +layout(location = 1) in vec2 blockUvIn; +layout(location = 2) in vec2 decalUvSizeIn; +layout(location = 3) in vec2 decalUvStartIn; + #else +layout(location = 0) in vec3 vertexPos; +layout(location = 1) in vec2 decalUvIn; +// rgb = block light, a=sun light level +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; +layout(location = 4) in vec2 blockUvIn; +layout(location = 5) in vec2 decalUvSizeIn; +layout(location = 6) in vec2 decalUvStartIn; // Argh >.< + #endif + +uniform vec4 rgbaFogIn; +uniform vec3 rgbaAmbientIn; +uniform float fogDensityIn; +uniform float fogMinIn; +uniform vec3 origin; +uniform mat4 projectionMatrix; +uniform mat4 modelViewMatrix; + +out vec2 decalUv; +out vec2 blockUv; +out vec2 decalUvSize; +out vec2 decalUvStart; +out vec4 color; + +// TAA motion vectors (Optimum P4). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +uniform mat4 prevProjectionMatrix; // previous frame's UNJITTERED world projection +uniform mat4 prevModelViewMatrix; // previous frame's CameraMatrixOrigin +uniform vec3 cameraPosDelta; // cameraPos(this frame) - cameraPos(previous frame) +out vec4 taaPrevClip; +#endif + +#include vertexflagbits.ash +#include shadowcoords.vsh +#include fogandlight.vsh +#include vertexwarp.vsh + + #if USESSBO > 0 +layout(binding = 3, std430) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + + +void main () { + #if USESSBO > 0 + FaceData vdata = faces[gl_VertexID / 4]; + int vIndex = gl_VertexID & 0x03; + int renderFlagsIn = vdata.flags[vIndex]; + vec3 vertexPos = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #endif + vec4 worldpos = vec4(vertexPos + origin, 1.0); + + worldpos = applyVertexWarping(renderFlagsIn, worldpos); + worldpos = applyGlobalWarping(worldpos); + + vec4 cameraPos = modelViewMatrix * worldpos; + + gl_Position = projectionMatrix * cameraPos; + + + color = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlagsIn, cameraPos); + color = applyFog(worldpos, color, rgbaFogIn, fogMinIn, fogDensityIn); + color.a = 1; + + // We pretend the decal is closer to the camera to enforce it + // always being drawn on top + //gl_Position.w += 0.0012; - not enough for leaves :o + int zOffset = 1 + ((renderFlagsIn & ZOffsetBitMask) >> 8); + gl_Position.w += zOffset * 0.00025 / max(0.1, gl_Position.z * 0.05); + + #if USESSBO > 0 + decalUv = UnpackUv(vdata, vIndex, 0, 0); + #else + decalUv = decalUvIn; + #endif + blockUv = blockUvIn; + decalUvSize = decalUvSizeIn; + decalUvStart = decalUvStartIn; + +#if TAAMOTION > 0 + // The same vertex, one frame ago, through the same code path as the terrain + // under it (chunkopaque.vsh): the decal's camera-relative position moved by + // exactly the camera's own motion (accuracy rule 4 - the decal mesh is baked + // against decalOrigin and `origin` re-expresses that origin relative to this + // frame's camera, so vertexPos + origin is already a camera-relative + // position), the warp re-evaluated with the previous frame's counters, and + // the previous UNJITTERED projection with the previous CameraMatrixOrigin. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(vertexPos + origin + cameraPosDelta, 1.0); + taaPrevPos = applyVertexWarpingState(taaPrev, renderFlagsIn, taaPrevPos); + taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + + // The "pretend the decal is closer" w-offset above shifts where the + // fragment lands on screen, so leaving it off the previous position + // would report that shift as motion. + int taaPrevZOffset = 1 + ((renderFlagsIn & ZOffsetBitMask) >> 8); + taaPrevClip.w += taaPrevZOffset * 0.00025 / max(0.1, taaPrevClip.z * 0.05); + } +#endif +} diff --git a/sources/shaders/taa-skymotion.fsh b/sources/shaders/taa-skymotion.fsh new file mode 100644 index 00000000..40cc65f6 --- /dev/null +++ b/sources/shaders/taa-skymotion.fsh @@ -0,0 +1,107 @@ +#version 330 core +#extension GL_ARB_explicit_attrib_location: enable + +// Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. +// +// TAA-PLAN.md's inventory row for "Clouds (volumetric, map), aurora, night sky, +// sun/moon, sky colour" reads "fallback + reactive; sky uses infinite-direction +// reprojection (P4)". This pass is that row. +// +// WHAT ALREADY WORKED WITHOUT IT. Sky colour, the night sky, the sun and the +// moon all draw on Primary with the depth test off or the depth mask off, so +// they leave the depth buffer at 1.0 and never touch the motion attachment. +// taa-resolve.fsh's writer-depth test then fails (motion.a is 0), and its +// camera fallback unprojects a depth of 1 - a point at infinity - and +// reprojects it through the previous view-projection. That is already the +// infinite-direction reprojection those layers need, and it is exact for +// anything painted on the celestial sphere. None of them needs a writer, and +// none is given one. +// +// WHAT DID NOT. Volumetric clouds and the aurora are drawn into the Transparent +// target during the OIT stage, where Primary's motion attachment does not +// exist, and they MOVE independently of the camera: the cloud map scrolls with +// cloudOffset and the aurora's noise animates with auroraCounter. Reprojected +// by camera rotation alone their history lands on the cloud that used to be +// there, and a cloud edge sweeping across the sky smears. They need a reactive +// value, and the only place their coverage is known per pixel is the Transparent +// target's revealage attachment - the same texture transparentcompose.fsh reads +// as `revealage` and turns into `anet`. +// +// So this pass runs on Primary after the OIT merge, covers the sky pixels only +// (see the vertex shader), and writes: +// rg = the camera-ROTATION-only reprojection of this pixel's view direction, +// b = the reactive value, scaled by how much transparent content covers the +// pixel, and +// a = gl_FragCoord.z = 1.0, which matches the depth buffer at every pixel +// this pass survives, so the resolve accepts the vector instead of +// recomputing its own. +// +// A clear-sky pixel comes out with coverage 0 and therefore reactive 0: the sky +// gradient is dithered (ShaderProgramSky's DitherSeed) and is exactly the kind +// of static, noisy signal temporal accumulation is best at, so it keeps its +// full history weight. + +#if TAAMOTION > 0 +uniform sampler2D transparentRevealTex; // Transparent colour 1: oit.fsh's outReveal +uniform vec2 taaRenderSize; // render-target size in pixels +uniform vec2 taaJitterPx; // this frame's sub-pixel shear, in pixels +uniform mat4 taaInvViewProjJittered; // raster NDC -> camera-relative world (this frame) +uniform mat4 taaPrevViewProj; // camera-relative world -> previous unjittered clip + +// The reactive value a fully covered sky pixel gets. 1 = never trust the +// history there. Partial coverage interpolates towards it from the coverage +// itself, so a wisp at 20% alpha is not treated like a solid cloud bank. +uniform float taaCloudReactive = 1.0; + +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; +#else +// TAA off: the pass never runs (ClientPlatformWindows.RenderOptimumSkyMotion +// returns before binding it), but the program is still registered and compiled, +// and a fragment stage with no output at all is not worth handing to two shader +// translators. One dummy attachment keeps it trivially valid. +layout(location = 0) out vec4 outMotion; +#endif + +in vec2 texCoord; + +void main() +{ +#if TAAMOTION > 0 + // The transparent layer's coverage of this pixel, which is 1 - revealage - + // the identical term transparentcompose.fsh composites with. Clouds + // multiply their own (1 - density) into that attachment through the + // per-attachment blend factors SystemRenderOITLayers sets, the aurora and + // the quad particles through oit.fsh's outReveal, so every transparent + // thing that can sit in front of the sky is in here. + float coverage = clamp(1.0 - texelFetch(transparentRevealTex, ivec2(gl_FragCoord.xy), 0).r, 0.0, 1.0); + float reactive = mix(coverage, clamp(taaCloudReactive, 0.0, 1.0), coverage); + + // The view direction through this raster position. The inverse projection + // is the JITTERED one, so the direction belongs to the sample that was + // actually taken, not to the pixel centre. + vec2 ndc = gl_FragCoord.xy / taaRenderSize * 2.0 - 1.0; + vec4 farH = taaInvViewProjJittered * vec4(ndc, 1.0, 1.0); + // A point at infinity is a direction: the w divide only scales it, and a + // negative w flips it, so the sign is all that has to be carried over. + vec3 direction = farH.w < 0.0 ? -farH.xyz : farH.xyz; + + // w = 0 drops the previous view-projection's translation column, which is + // exactly "the camera may have rotated, it may not have moved": a point on + // the celestial sphere does not parallax. + vec4 prevClip = taaPrevViewProj * vec4(direction, 0.0); + if (prevClip.w <= 1e-6) { + // Behind the previous camera - not a motion vector. A zero alpha routes + // the pixel back to the resolve's own camera fallback, exactly as in + // chunkopaque.fsh; the reactive value is still delivered, because + // taa-resolve.fsh reads motion.b whether or not the pixel was accepted. + outMotion = vec4(0.0, 0.0, reactive, 0.0); + return; + } + + vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * taaRenderSize; + vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; + outMotion = vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z); +#else + outMotion = vec4(0.0); +#endif +} diff --git a/sources/shaders/taa-skymotion.vsh b/sources/shaders/taa-skymotion.vsh new file mode 100644 index 00000000..bd254c18 --- /dev/null +++ b/sources/shaders/taa-skymotion.vsh @@ -0,0 +1,25 @@ +#version 330 core + +// Optimum TAA (P4): the vertex half of the sky/cloud motion pass. +// +// A fullscreen triangle generated from gl_VertexID, exactly like the other +// Optimum post passes (taa-resolve, fsr-easu), with one difference that is the +// whole point of the pass: gl_Position.z equals gl_Position.w, so the NDC depth +// is 1 and the window depth is 1.0 - the far plane, which is the value Primary's +// depth attachment still holds wherever no geometry was drawn. +// +// With the depth test on and GL_LEQUAL the triangle therefore passes on sky +// pixels only (1.0 <= 1.0) and is rejected by every pixel any surface wrote +// depth for (depth < 1.0). That is what keeps this pass from overwriting the +// motion vectors the terrain, entity, liquid and particle writers already put +// down. The pass never writes depth itself. + +out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 1.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); +} From 48274339380369c0f6360eb9e0fd3d022ff9b534 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 22:54:17 +0200 Subject: [PATCH 035/226] wip(taa): P4 movers - exact motion for the animated standard-shader renderers The P3 carry-over: the standard-shader block-entity renderers that actually move now keep a previous model matrix through OptimumStandardMotion and open the motion-attachment window around their own draw, instead of ghosting on the resolve's camera fallback. Helve hammer, resonator disc, fruitpress mash, bloomery/forge/firepit contents, the pot lid (its own identity, because the renderer's is taken by the static pot body) and falling blocks (identity per entity, one window around the shared loop). Mod-patcher manifest entries for all eight methods. Verified: extract-patches + check-patches (0 pending, 0 conflict); Release build; Optimum.Tests 907 passed; Optimum.Render.Vulkan.Tests 318 passed, including the new TaaMoverMotionTests, which is the first GPU proof with taaJitterPx != 0 - zeroing the jitter uniform while leaving the projection sheared fails 7 of its 8 cases. NOT verified in game on either backend. --- Optimum.Patcher/mod-patcher.cs | 16 + .../TaaMoverMotionTests.cs | 710 ++++++++++++++++++ .../taa-mover-motion-coverage-tests.cs | 371 +++++++++ TAA-PLAN.md | 38 + .../Entities/EntityBlockFalling.cs.patch | 79 +- .../BloomeryContentsRenderer.cs.patch | 29 + .../FirepitContentsRenderer.cs.patch | 29 + .../ForgeContentsRenderer.cs.patch | 31 + .../FruitpressContentsRenderer.cs.patch | 29 + .../HelveHammerRenderer.cs.patch | 32 + .../PotInFirepitRenderer.cs.patch | 57 ++ .../ResonatorRenderer.cs.patch | 31 + 12 files changed, 1449 insertions(+), 3 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs create mode 100644 Optimum.Tests/taa-mover-motion-coverage-tests.cs create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs.patch create mode 100644 patches/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs.patch diff --git a/Optimum.Patcher/mod-patcher.cs b/Optimum.Patcher/mod-patcher.cs index b2b2c186..11ea6771 100644 --- a/Optimum.Patcher/mod-patcher.cs +++ b/Optimum.Patcher/mod-patcher.cs @@ -168,6 +168,10 @@ private static Manifest EssentialsManifest() // RenderItem; dropped items in EntityItemRenderer.DoRender3DOpaque // above. Both also open the motion-attachment window around their draw. new("Vintagestory.GameContent.EntityShapeRenderer", "RenderItem", 5), + // TAA P4: the falling-block renderer is shared by every falling + // block in view, so each block keys its own previous model matrix + // on its entity and the window is opened once around the loop. + new("Vintagestory.GameContent.ModSystemRenderFallingBlocksFast", "OnRenderFrame", 2), new("Vintagestory.GameContent.WeatherSimulationParticles", "asyncParticleSpawn", 2), new("Vintagestory.GameContent.WeatherSystemClient", "OnRenderFrame", 2), new("Vintagestory.GameContent.WeatherSimulationSound", "updateSounds", 1), @@ -262,6 +266,18 @@ private static Manifest SurvivalManifest() // TAA P3: the quern top is the block-entity model that actually // moves, so it keeps a previous model matrix and writes motion. new("Vintagestory.GameContent.QuernTopRenderer", "OnRenderFrame", 2), + // TAA P4: the remaining moving standard-shader block-entity + // renderers. Each keeps a previous model matrix through + // OptimumStandardMotion and opens the motion-attachment window + // around its own draw; without these entries the installed runtime + // keeps the vanilla bodies and they ghost on the camera fallback. + new("Vintagestory.GameContent.HelveHammerRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.FruitpressContentsRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.ResonatorRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.BloomeryContentsRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.ForgeContentsRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.FirepitContentsRenderer", "OnRenderFrame", 2), + new("Vintagestory.GameContent.PotInFirepitRenderer", "OnRenderFrame", 2), // TAA P3, the instanced writer: every mechanical-power renderer now // fills OptimumInstanceMotion's instance layout (light, transform, // previous transform, metadata) instead of vanilla's light+transform, diff --git a/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs new file mode 100644 index 00000000..7f9a877f --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs @@ -0,0 +1,710 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The standard-shader motion writer as the TAA P4 "movers" drive it: a +/// block-entity model - the helve hammer's head, the resonator's disc, the pot +/// lid, a falling block - drawn with dontWarpVertices set to "no warp" and +/// a previous model matrix from OptimumStandardMotion, under a jittered +/// projection. +/// +/// The jitter is the point. Every P3 GPU test ran with taaJitterPx = 0, and +/// with the identity projection those tests use the NDC shear +/// P[8] -= 2*jx/W is a no-op on a quad at z = 0, so a jittered case there +/// would have proved nothing. This file uses the perspective-SHAPED matrix from +/// TaaLiquidMotionTests (clip.w = -z_view, quad at z = -1), where the shear +/// displaces the raster position by exactly jx pixels - so if the writer forgot +/// either half of the convention (subtract the jitter from the current pixel, +/// reproject the previous one through an UNJITTERED matrix) the vector comes back +/// off by the jitter and these assertions fail. +/// +/// What is being proved: +/// - a mover's previous model matrix is exactly its pixel displacement, and the +/// answer does not change when the frame is jittered; +/// - a mover that did not move is exactly zero even under jitter, because a +/// converged block entity that shimmers is what a leaked jitter looks like; +/// - without usable history the vector is camera motion only, never the stale +/// previous matrix - the case every one of these renderers hits on its first +/// frame, on a mesh swap, and after a reset. +/// +/// As in TaaStandardMotionWriterTests the RGBA16F attachment comes back through +/// an RGBA8 decode pass, because the seam's readback is fixed at four bytes per +/// pixel from colour attachment 0. +/// +public class TaaMoverMotionTests +{ + private readonly ITestOutputHelper _output; + + public TaaMoverMotionTests(ITestOutputHelper output) => _output = output; + + private const int Size = 64; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 32f; + + /// Normal pointing up, no glow, no wind-mode bits, so no vertex warp runs. + private const int UpNormalFlags = 7 << 18; + + /// + /// standard.vsh: 0 = full warp, 2 = the held item's quarter warp, anything else + /// = none. Block-entity models pass "none", which is what every renderer in + /// this phase does. + /// + private const int WarpNone = 1; + + /// The view-space z the test quad sits at; clip.w = -z = 1 there. + private const float QuadViewZ = -1f; + + private static readonly float[] Identity = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + }; + + private static float[] Translation(float x, float y, float z) => new[] + { + 1f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, + 0f, 0f, 1f, 0f, + x, y, z, 1f, + }; + + /// + /// The perspective-SHAPED projection, column-major, plus the NDC jitter shear + /// the frame contract applies (`P[8] -= 2*jx/W; P[9] -= 2*jy/H`). + /// + /// clip = (x + m02*z, y + m12*z, -z - 1, -z). At z = -1 that is clip.w = 1, so + /// ndc.xy = (x + 2*jx/W, y + 2*jy/H) and the raster position moves by exactly + /// (jx, jy) pixels - the property that makes a jittered assertion mean + /// something. ndc.z = 0, so window depth is 0.5. + /// + private static float[] Projection(float jitterX, float jitterY) + { + var p = new float[16]; + p[0] = 1f; // m00 + p[5] = 1f; // m11 + p[8] = -2f * jitterX / Size; // m02, the jitter shear + p[9] = -2f * jitterY / Size; // m12 + p[10] = -1f; // m22 + p[11] = -1f; // m32: clip.w = -z_view + p[14] = -1f; // m23 + p[15] = 0f; // m33 + return p; + } + + // ------------------------------------------------------------------ tests + + /// + /// A block entity that is not moving, under a still camera, in a jittered + /// frame, is exactly zero motion - and still stamps its own depth, so the + /// resolve accepts the pixel instead of camera-reprojecting it. + /// + /// A writer that forgot `gl_FragCoord.xy - taaJitterPx` would report the jitter + /// itself here, which is a sub-pixel wobble on every still block entity in the + /// world and precisely the artefact TAA is supposed to remove. + /// + [SkippableTheory] + [InlineData(0f, 0f)] + [InlineData(0.37f, -0.24f)] + [InlineData(-0.5f, 0.5f)] + public void AStillMoverIsZeroMotionEvenUnderJitter(float jitterX, float jitterY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderMoverMotion(device!, + previousModelMatrix: Identity, + historyValid: 1, + jitterX: jitterX, + jitterY: jitterY, + cameraDeltaX: 0f, + cameraDeltaY: 0f); + + _output.WriteLine($"jitter ({jitterX}, {jitterY}): mv = ({centre.MotionX}, {centre.MotionY}), " + + $"writerDepth = {centre.WriterDepth}"); + + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The model matrix the mover was drawn with last frame is its motion: with + /// identity view matrices a translation of d displaces the pixel by exactly + /// d * 0.5 * renderSize, and the sign is "where the pixel was", not "where it + /// went". + /// + /// The jitter is deliberately large and asymmetric here: it enters the current + /// pixel through the shear and must leave again through `- taaJitterPx`, + /// leaving the same answer as the unjittered frame. The companion test below + /// asserts that equality directly. + /// + [SkippableTheory] + [InlineData(0.25f, 0f, 0.5f, -0.5f)] + [InlineData(0f, -0.125f, -0.5f, 0.5f)] + [InlineData(-0.1875f, 0.0625f, 0.31f, 0.47f)] + public void APreviousModelMatrixIsTheExactPixelDisplacementUnderJitter( + float modelX, float modelY, float jitterX, float jitterY) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + Decoded centre = RenderMoverMotion(device!, + previousModelMatrix: Translation(modelX, modelY, 0f), + historyValid: 1, + jitterX: jitterX, + jitterY: jitterY, + cameraDeltaX: 0f, + cameraDeltaY: 0f); + + float expectedX = modelX * 0.5f * Size; + float expectedY = modelY * 0.5f * Size; + + _output.WriteLine($"model ({modelX}, {modelY}) jitter ({jitterX}, {jitterY}): " + + $"mv = ({centre.MotionX}, {centre.MotionY}), expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + Assert.InRange(centre.WriterDepth, 0.48f, 0.52f); + } + } + + /// + /// The convention says the vector excludes the jitter. That is only testable as + /// an equality between two frames of the same scene that differ by nothing but + /// the jitter phase, which is what this is: same previous matrix, same camera, + /// two different Halton-sized offsets, one answer. + /// + [SkippableFact] + public void TheVectorIsTheSameWhicheverJitterPhaseTheFrameIsOn() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + float[] previous = Translation(0.25f, -0.125f, 0f); + + Decoded unjittered = RenderMoverMotion(device!, previous, 1, 0f, 0f, 0f, 0f); + Decoded jittered = RenderMoverMotion(device!, previous, 1, 0.5f, -0.5f, 0f, 0f); + + _output.WriteLine($"unjittered = ({unjittered.MotionX}, {unjittered.MotionY}), " + + $"jittered = ({jittered.MotionX}, {jittered.MotionY})"); + + Assert.InRange(jittered.MotionX, unjittered.MotionX - 0.3f, unjittered.MotionX + 0.3f); + Assert.InRange(jittered.MotionY, unjittered.MotionY - 0.3f, unjittered.MotionY + 0.3f); + } + } + + /// + /// The case every one of these renderers hits on its first frame, whenever its + /// mesh is re-uploaded, and after a reset: OptimumStandardMotion.Apply + /// reports no usable history, so the writer must not touch the previous model + /// matrix at all. It treats the surface as static in the world, so only the + /// camera's own movement displaces it, and C# raises taaReactive for the + /// same draw. + /// + /// The previous model matrix here is a large translation on purpose: if the + /// shader took the history branch anyway, the vector would be that instead of + /// the camera delta, and the test would say so rather than merely noticing + /// "some motion". + /// + [SkippableFact] + public void WithoutUsableHistoryTheMoverFallsBackToCameraMotionOnly() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float cameraDeltaX = 0.25f; + const float cameraDeltaY = -0.125f; + + Decoded centre = RenderMoverMotion(device!, + previousModelMatrix: Translation(-0.5f, 0.5f, 0f), + historyValid: 0, + jitterX: 0.42f, + jitterY: 0.13f, + cameraDeltaX: cameraDeltaX, + cameraDeltaY: cameraDeltaY); + + float expectedX = cameraDeltaX * 0.5f * Size; + float expectedY = cameraDeltaY * 0.5f * Size; + + _output.WriteLine($"no history: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"expected ({expectedX}, {expectedY})"); + + Assert.InRange(centre.MotionX, expectedX - 0.3f, expectedX + 0.3f); + Assert.InRange(centre.MotionY, expectedY - 0.3f, expectedY + 0.3f); + } + } + + // ---------------------------------------------------------------- harness + + private readonly struct Decoded + { + public Decoded(float motionX, float motionY, float writerDepth) + { + MotionX = motionX; + MotionY = motionY; + WriterDepth = writerDepth; + } + + public float MotionX { get; } + public float MotionY { get; } + public float WriterDepth { get; } + } + + /// + /// Draws one block-entity-shaped quad with the real standard program compiled + /// as a motion writer, then decodes the motion attachment and returns its + /// centre pixel. The current model matrix is always the identity and this + /// frame's warp state is pinned to a no-op, so every expectation is stated + /// entirely in terms of the previous-frame inputs and the jitter. + /// + private unsafe Decoded RenderMoverMotion( + VulkanDevice device, + float[] previousModelMatrix, + int historyValid, + float jitterX, + float jitterY, + float cameraDeltaX, + float cameraDeltaY) + { + IOptimumGraphicsDevice seam = device; + + var files = ShaderCorpus.LoadShaderFiles(); + var includes = ShaderCorpus.LoadIncludes(); + var variant = new ShaderCorpus.ShaderVariant + { + Name = "taa-mover", + TaaMotion = 1, + TaaMotionLocation = 2, + }; + + List stages = ShaderCorpus.BuildProgram("standard", files, includes, variant); + Assert.NotEmpty(stages); + int program = LinkFromCorpus(seam, stages, "standard"); + + Assert.True(seam.GetUniformLocation(program, "taaJitterPx") >= 0, + "standard declares no taaJitterPx, so it cannot exclude the jitter"); + Assert.True(seam.GetUniformLocation(program, "prevModelMatrix") >= 0, + "standard declares no prevModelMatrix, so a mover has no previous transform to use"); + + BindEveryDeclaredSampler(device, seam, program); + + // Primary stand-in: colour, glow and the motion attachment at index 2. + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + + int scene = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.ColorAttachment2, motion, 0); + seam.AttachTexture(scene, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(scene, 0b111); + Assert.True(seam.CheckFramebufferComplete(scene, out string status), status); + + int mesh = seam.CreateMesh(BuildQuad(), staticDraw: true); + Assert.True(mesh > 0, seam.GetError() ?? "mesh upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(scene); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + + seam.UseProgram(program); + + // This frame is jittered; the previous projection never is. That pair is + // the whole convention under test. + SetMatrix(seam, program, "projectionMatrix", Projection(jitterX, jitterY)); + SetMatrix(seam, program, "prevProjectionMatrix", Projection(0f, 0f)); + SetMatrix(seam, program, "viewMatrix", Identity); + SetMatrix(seam, program, "prevViewMatrix", Identity); + SetMatrix(seam, program, "modelMatrix", Identity); + SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); + SetMatrix(seam, program, "toShadowMapSpaceMatrixFar", Identity); + SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); + + SetSceneUniforms(seam, program); + SetWarpUniforms(seam, program); + + SetInt(seam, program, "taaHistoryValid", historyValid); + SetFloat(seam, program, "taaReactive", historyValid != 0 ? 0f : 1f); + SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); + SetFloat2(seam, program, "taaRenderSize", Size, Size); + SetFloat2(seam, program, "taaJitterPx", jitterX, jitterY); + + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); // GL_LEQUAL + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(mesh); + + byte[] decoded = DecodeMotion(seam, motion); + seam.Present(); + + int offset = ((Size / 2) * Size + Size / 2) * 4; + + AssertClean(seam); + + return new Decoded( + (decoded[offset] / 255f * 2f - 1f) * DecodeScale, + (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, + decoded[offset + 2] / 255f); + } + + /// + /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because + /// the seam's readback is fixed at four bytes per pixel from attachment 0, and + /// reads it back inside the same frame. + /// + private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + { + const string decodeVertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string decodeFragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.a, 0.0, 1.0), + 1.0); +} +"; + int decode = LinkFromCorpus(seam, new List + { + new() { Stage = EnumShaderType.VertexShader, Code = decodeVertex, PrefixCode = "", Filename = "taa-mover-decode.vsh" }, + new() { Stage = EnumShaderType.FragmentShader, Code = decodeFragment, PrefixCode = "", Filename = "taa-mover-decode.fsh" }, + }, "taa-mover-decode"); + + var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + int quadMesh = seam.CreateMesh(quad, staticDraw: true); + Assert.True(quadMesh > 0, seam.GetError() ?? "decode mesh upload failed"); + + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawMesh(quadMesh); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// A quad in standard.vsh's attribute layout (xyz, uv, rgba, flags), placed at + /// view-space z = -1 so the perspective-shaped projection gives it clip.w = 1 + /// and the jitter shear a real pixel displacement. It is deliberately smaller + /// than the viewport so the centre pixel stays covered under every jitter. + /// + private static MeshData BuildQuad() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + + float[] positions = + { + -0.5f, -0.5f, QuadViewZ, + 0.5f, -0.5f, QuadViewZ, + 0.5f, 0.5f, QuadViewZ, + -0.5f, 0.5f, QuadViewZ, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], + Vintagestory.API.MathTools.ColorUtil.WhiteArgb, + flags: UpNormalFlags); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + return mesh; + } + + /// + /// Enough of the lighting, fog and overlay surface to keep the fragment alive: + /// a discarded fragment writes no motion vector and the test would read the + /// cleared attachment instead. alphaTest is pushed below zero so nothing can + /// discard at all, and dontWarpVertices is the block-entity value. + /// + private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) + { + SetInt(seam, program, "dontWarpVertices", WarpNone); + SetInt(seam, program, "fadeFromSpheresFog", 0); + SetInt(seam, program, "addRenderFlags", 0); + SetInt(seam, program, "extraGlow", 0); + SetFloat(seam, program, "extraZOffset", 0f); + + SetFloat(seam, program, "alphaTest", -1f); + SetFloat(seam, program, "viewDistance", 1024f); + SetFloat(seam, program, "viewDistanceLod0", 1024f); + SetFloat(seam, program, "zNear", 0.1f); + SetFloat(seam, program, "zFar", 1024f); + SetFloat(seam, program, "fogMinIn", 0f); + SetFloat(seam, program, "fogDensityIn", 0f); + SetFloat(seam, program, "shadowRangeFar", 1024f); + SetFloat(seam, program, "shadowRangeNear", 64f); + SetFloat(seam, program, "shadowMapWidthInv", 1f); + SetFloat(seam, program, "shadowMapHeightInv", 1f); + SetFloat(seam, program, "shadowIntensity", 0f); + SetFloat(seam, program, "damageEffect", 0f); + SetFloat(seam, program, "overlayOpacity", 0f); + SetFloat(seam, program, "extraGodray", 0f); + SetFloat(seam, program, "ssaoAttn", 0f); + SetInt(seam, program, "applySsao", 0); + SetInt(seam, program, "tempGlowMode", 0); + SetInt(seam, program, "normalShaded", 0); + SetInt(seam, program, "skyShaded", 0); + SetFloat3(seam, program, "rgbaAmbientIn", 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaLightIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaFogIn", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "rgbaGlowIn", 0f, 0f, 0f, 0f); + SetFloat4(seam, program, "rgbaTint", 1f, 1f, 1f, 1f); + SetFloat4(seam, program, "averageColor", 1f, 1f, 1f, 1f); + SetFloat2(seam, program, "frameSize", Size, Size); + } + + /// + /// Both halves of the warp state pinned to the same no-op, so nothing in this + /// file's expectations comes from vertex animation - a block-entity model + /// passes "no warp" anyway, and P3 already covers the warp branches. + /// + private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program) + { + foreach (string prefix in new[] { "", "prev" }) + { + SetFloat(seam, program, Name(prefix, "timeCounter"), 0f); + SetFloat(seam, program, Name(prefix, "windWaveCounter"), 0f); + SetFloat(seam, program, Name(prefix, "windWaveCounterHighFreq"), 0f); + SetFloat(seam, program, Name(prefix, "waterWaveCounter"), 0f); + SetFloat(seam, program, Name(prefix, "windSpeed"), 0f); + SetFloat(seam, program, Name(prefix, "globalWarpIntensity"), 0f); + SetFloat(seam, program, Name(prefix, "glitchWaviness"), 0f); + SetFloat(seam, program, Name(prefix, "windWaveIntensity"), 1f); + SetFloat(seam, program, Name(prefix, "waterWaveIntensity"), 1f); + SetInt(seam, program, Name(prefix, "perceptionEffectId"), 1); + SetFloat(seam, program, Name(prefix, "perceptionEffectIntensity"), 0f); + SetFloat3(seam, program, Name(prefix, "playerpos"), 0f, 0f, 0f); + } + } + + private static string Name(string prefix, string uniform) + { + if (prefix.Length == 0) return uniform; + return prefix + char.ToUpperInvariant(uniform[0]) + uniform.Substring(1); + } + + private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y); + } + + private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z); + } + + private static void SetFloat4( + IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, x, y, z, w); + } + + private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniformMatrix(program, location, matrix); + } + + private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + { + var white = new byte[] { 255, 255, 255, 255 }; + fixed (byte* pixels = white) + { + return seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + } + + private static int BindEveryDeclaredSampler( + VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + { + int unit = 0; + foreach (string samplerName in device.SamplerNamesOf(programId)) + { + int texture = CreateWhiteTexture(seam); + seam.SetSamplerUnit(programId, samplerName, unit); + seam.BindTexture(unit, texture); + unit++; + } + return unit; + } + + private static int LinkFromCorpus( + IOptimumGraphicsDevice seam, List stages, string name) + { + var program = new CorpusProgram { PassName = name }; + + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int programId = seam.LinkProgram(program); + Assert.True(programId > 0, name + ": " + (seam.GetError() ?? "link failed")); + return programId; + } + + private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + var created = new VulkanDevice { DebugMode = true }; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + private static void AssertClean(IOptimumGraphicsDevice seam) + { + string? diagnostics = seam.GetError(); + Assert.True(string.IsNullOrEmpty(diagnostics), "device diagnostics:\n" + diagnostics); + } + + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Tests/taa-mover-motion-coverage-tests.cs b/Optimum.Tests/taa-mover-motion-coverage-tests.cs new file mode 100644 index 00000000..ec9f0583 --- /dev/null +++ b/Optimum.Tests/taa-mover-motion-coverage-tests.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the TAA P4 "movers" carry-over: the standard-shader block +/// entity renderers that actually move and therefore have to write exact motion +/// instead of ghosting on the resolve's camera fallback. +/// +/// The load-bearing test here is . +/// It enumerates the mod forks rather than listing files, so a renderer added or +/// un-instrumented later fails the build instead of silently ghosting: the failure +/// mode this phase exists to fix is invisible in a screenshot of a still scene and +/// only shows up as a smear while something on screen is animating. +/// +/// Text assertions only prove the wiring exists - the GPU test +/// (Optimum.Render.Vulkan.Tests/TaaMoverMotionTests) proves the numbers. +/// +public class TaaMoverMotionCoverageTests +{ + /// The mod forks that are sources of truth for mod code. + private static readonly string[] ModForks = { "VSEssentials", "VSSurvivalMod", "VSCreativeMod" }; + + /// + /// Every standard-shader user that is deliberately NOT instrumented, with the + /// reason. Adding a file here is a decision, not a shortcut: "it does not move" + /// has to be true, because the resolve's camera reprojection is exact only for + /// a surface that is static in the world. + /// + /// Keys are repository-relative paths with forward slashes. + /// + private static readonly Dictionary NotInstrumented = new() + { + ["VSSurvivalMod/BlockEntityRenderer/AnvilPartRenderer.cs"] = + "The anvil base, flux and work item sit at the block position. The top mesh sinks by " + + "hammerHits/250 - a discrete step on a hit, not a per-frame animation - so a wrong " + + "vector would last one frame; the hot work item itself is drawn by AnvilWorkItemRenderer " + + "on the mod's own smithing program, which declares no motion output at all.", + + ["VSSurvivalMod/BlockEntityRenderer/BlockEntitySignPostRenderer.cs"] = + "A text quad nailed to the sign post at a fixed offset from the block position.", + + ["VSSurvivalMod/BlockEntityRenderer/ChestLabelRenderer.cs"] = + "A label quad nailed to the chest at a fixed offset from the block position.", + + ["VSSurvivalMod/BlockEntityRenderer/ClayFormRenderer.cs"] = + "The work item is drawn at the block position and only changes when a voxel is added " + + "or removed, which re-uploads the mesh. Its second draw is the recipe outline on " + + "AfterFinalComposition, which is outside the temporal window entirely.", + + ["VSSurvivalMod/BlockEntityRenderer/CrucibleInFirepitRenderer.cs"] = + "The crucible sits still in the firepit; only its glow changes.", + + ["VSSurvivalMod/BlockEntityRenderer/GroundStorageRenderer.cs"] = + "Stacks are drawn at fixed offsets inside the block; the per-frame work is a " + + "once-a-second temperature refresh, not motion.", + + ["VSSurvivalMod/BlockEntityRenderer/IngotMoldRenderer.cs"] = + "The fill quad's height changes only when metal is poured in or the mold is emptied, " + + "in discrete steps, and a step swaps the mesh, so history would be rejected anyway.", + + ["VSSurvivalMod/BlockEntityRenderer/KnappingRenderer.cs"] = + "The knapping surface is drawn at the block position and changes only when a voxel is " + + "knocked off, which re-uploads the mesh. Its AfterFinalComposition guide draw is " + + "outside the temporal window entirely.", + + ["VSSurvivalMod/BlockEntityRenderer/SignRenderer.cs"] = + "A text quad nailed to the sign at a fixed offset from the block position.", + + ["VSSurvivalMod/BlockEntityRenderer/ToolMoldRenderer.cs"] = + "As IngotMoldRenderer: the fill level changes in discrete steps and each step picks a " + + "different quad mesh, so there is no continuous motion to record.", + + ["VSSurvivalMod/Systems/SupportBeams/ModSystemSupportBeamPlacer.cs"] = + "The beam preview follows the player's aim, but its model matrix is the fixed start " + + "block and the shape is re-uploaded (reloadMeshRef) on every change of the end offset, " + + "so a previous model matrix would never be valid history.", + }; + + /// + /// Standard-shader users that must carry the writer. The P3 three (held items, + /// dropped items, the quern) plus the P4 movers. + /// + private static readonly string[] Instrumented = + { + "VSEssentials/EntityRenderer/EntityShapeRenderer.cs", + "VSEssentials/EntityRenderer/EntityItemRenderer.cs", + "VSEssentials/Entities/EntityBlockFalling.cs", + "VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs", + "VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs", + }; + + /// + /// The named list and the scan have to agree: the scan is what catches a new + /// renderer, the list is what catches an instrumented one quietly losing its + /// writer while still being found by the scan. + /// + [Fact] + public void EveryNamedInstrumentedRendererIsFoundByTheScanAndStillWritesMotion() + { + List users = StandardShaderUsers(); + + foreach (string source in Instrumented) + { + Assert.Contains(source, users); + Assert.Contains("OptimumStandardMotion.Apply(", ReadRepositoryFile(source)); + } + } + + // ------------------------------------------------------------- the census + + /// + /// The table that makes the phase checkable: every file in the mod forks that + /// draws through the standard shader program is either a motion writer or has + /// a written reason not to be. Nothing may be silently absent. + /// + [Fact] + public void EveryStandardShaderUserInTheModForksIsInstrumentedOrExplicitlyExempt() + { + List users = StandardShaderUsers(); + + // If the discovery itself breaks, everything below passes vacuously. + Assert.True(users.Count >= 20, + "only " + users.Count + " standard-shader users found; the scan is broken"); + + var missing = new List(); + foreach (string user in users) + { + string text = ReadRepositoryFile(user); + bool instrumented = text.Contains("OptimumStandardMotion.Apply(", StringComparison.Ordinal); + bool exempt = NotInstrumented.ContainsKey(user); + + if (instrumented && exempt) + { + missing.Add(user + " is instrumented AND on the exemption list"); + } + else if (!instrumented && !exempt) + { + missing.Add(user + " draws on the standard shader, writes no motion, and has no " + + "reason on TaaMoverMotionCoverageTests.NotInstrumented"); + } + } + + Assert.True(missing.Count == 0, string.Join("\n", missing)); + } + + /// + /// An exemption without a reason is a to-do pretending to be a decision. + /// + [Fact] + public void EveryExemptionCarriesARealReasonAndNamesAFileThatStillExists() + { + foreach ((string path, string reason) in NotInstrumented) + { + Assert.True(reason.Length >= 60, path + ": the exemption reason is too thin to be one"); + Assert.True( + File.Exists(Path.Combine(RepositoryRoot(), path.Replace('/', Path.DirectorySeparatorChar))), + path + " is on the exemption list but no longer exists"); + } + } + + /// + /// The exemption list must not grow stale in the other direction either: a file + /// listed there that no longer draws on the standard shader is a leftover. + /// + [Fact] + public void NoExemptionNamesAFileThatNoLongerDrawsOnTheStandardShader() + { + List users = StandardShaderUsers(); + foreach (string path in NotInstrumented.Keys) + { + Assert.Contains(path, users); + } + } + + // -------------------------------------------------------- the instrumented + + /// + /// Each mover names itself to the per-object store and opens the draw-buffer + /// window around its own draw. The window has to be narrow: a standard-shader + /// draw that is not instrumented must stay outside it, or the attachment would + /// keep whatever surface wrote there before. + /// + [Theory] + [InlineData("VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, meshref, ModelMat.Values);")] + [InlineData("VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, mashMeshref, ModelMat.Values);")] + [InlineData("VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, cylinderMeshRef, ModelMat.Values);")] + [InlineData("VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, cubeModelRef, ModelMat.Values);")] + [InlineData("VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, coalMeshRef, ModelMat.Values);")] + [InlineData("VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs", + "OptimumStandardMotion.Apply(prog, this, meshref, ModelMat.Values);")] + [InlineData("VSEssentials/Entities/EntityBlockFalling.cs", + "OptimumStandardMotion.Apply(prog, entity, entity.meshRef, ModelMat.Values);")] + public void EveryMoverStoresItsPreviousTransformAndOpensTheWindow(string source, string apply) + { + string renderer = ReadRepositoryFile(source); + + Assert.Contains(apply, renderer); + Assert.Contains("OptimumMotionWrite.Begin();", renderer); + Assert.Contains("OptimumMotionWrite.End();", renderer); + + // Paired, and closed on the exception path: a window left open would put + // the motion attachment in every later draw's mask. + Assert.Equal( + Count(renderer, "OptimumMotionWrite.Begin();"), + Count(renderer, "OptimumMotionWrite.End();")); + Assert.Contains("finally", renderer); + } + + /// + /// Two draws in one renderer need two identities. The pot body and its lid + /// share a renderer instance and a Matrixf, so keying both on this would + /// hand the lid the body's previous matrix - a zero vector on the one part of + /// the pot that actually moves. + /// + [Fact] + public void ThePotAndItsLidKeepSeparateHistories() + { + string renderer = ReadRepositoryFile("VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs"); + + Assert.Contains( + "OptimumStandardMotion.Apply(prog, this, potRef == null ? potWithFoodRef : potRef, ModelMat.Values);", + renderer); + Assert.Contains("OptimumStandardMotion.Apply(prog, lidRef, lidRef, ModelMat.Values);", renderer); + Assert.Equal(2, Count(renderer, "OptimumStandardMotion.Apply(")); + Assert.Equal(2, Count(renderer, "OptimumMotionWrite.Begin();")); + Assert.Equal(2, Count(renderer, "OptimumMotionWrite.End();")); + } + + /// + /// The falling-block renderer is one renderer for every falling block in view, + /// so the identity has to be the entity. Keying on the renderer would give + /// every block the last block's previous matrix. + /// + [Fact] + public void FallingBlocksKeyTheirHistoryOnTheEntityAndShareOneWindow() + { + string renderer = ReadRepositoryFile("VSEssentials/Entities/EntityBlockFalling.cs"); + + Assert.Contains("OptimumStandardMotion.Apply(prog, entity, entity.meshRef, ModelMat.Values);", renderer); + Assert.DoesNotContain("OptimumStandardMotion.Apply(prog, this,", renderer); + + // One window around the loop, not one per block. + Assert.Equal(1, Count(renderer, "OptimumMotionWrite.Begin();")); + int begin = renderer.IndexOf("OptimumMotionWrite.Begin();", StringComparison.Ordinal); + int loop = renderer.IndexOf("foreach (var entity in fallingBlocks.Values)", StringComparison.Ordinal); + Assert.True(begin >= 0 && loop > begin, "the window must open before the loop, not inside it"); + } + + /// + /// The helve hammer runs the same method for the shadow stages with a program + /// that has no motion output, so the window must sit inside its Opaque branch. + /// + [Fact] + public void TheHelveHammerOpensNoWindowInItsShadowBranch() + { + string renderer = ReadRepositoryFile("VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs"); + + int opaque = renderer.IndexOf("if (stage == EnumRenderStage.Opaque)", StringComparison.Ordinal); + int elseBranch = renderer.IndexOf("} else", opaque, StringComparison.Ordinal); + int begin = renderer.IndexOf("OptimumMotionWrite.Begin();", StringComparison.Ordinal); + + Assert.True(opaque >= 0 && elseBranch > opaque, "the stage branch moved"); + Assert.InRange(begin, opaque, elseBranch); + } + + // --------------------------------------------------------------- the ship + + /// + /// P3 finding (f): a mod-fork change only reaches the installed runtime through + /// a mod-patcher manifest entry. Without these the vanilla bodies stay and every + /// mover ghosts there while the build tree looks correct. + /// + [Theory] + [InlineData("Vintagestory.GameContent.ModSystemRenderFallingBlocksFast")] + [InlineData("Vintagestory.GameContent.HelveHammerRenderer")] + [InlineData("Vintagestory.GameContent.FruitpressContentsRenderer")] + [InlineData("Vintagestory.GameContent.ResonatorRenderer")] + [InlineData("Vintagestory.GameContent.BloomeryContentsRenderer")] + [InlineData("Vintagestory.GameContent.ForgeContentsRenderer")] + [InlineData("Vintagestory.GameContent.FirepitContentsRenderer")] + [InlineData("Vintagestory.GameContent.PotInFirepitRenderer")] + public void ModPatcherManifestsCarryEveryChangedMover(string type) + { + string manifest = ReadRepositoryFile("Optimum.Patcher/mod-patcher.cs"); + + Assert.Contains("new(\"" + type + "\", \"OnRenderFrame\", 2)", manifest); + } + + // ----------------------------------------------------------------- helpers + + /// + /// Every C# file in the mod forks that draws through the standard shader + /// program, repository-relative with forward slashes. Discovery is by scan so + /// that a new renderer cannot be missed by being absent from a list. + /// + private static List StandardShaderUsers() + { + string root = RepositoryRoot(); + var users = new List(); + + foreach (string fork in ModForks) + { + string forkRoot = Path.Combine(root, fork); + if (!Directory.Exists(forkRoot)) continue; + + foreach (string file in Directory.EnumerateFiles(forkRoot, "*.cs", SearchOption.AllDirectories)) + { + string text = File.ReadAllText(file); + if (!UsesStandardShader(text)) continue; + + users.Add(Path.GetRelativePath(root, file).Replace('\\', '/')); + } + } + + users.Sort(StringComparer.Ordinal); + return users; + } + + /// + /// A file draws on the standard shader when it names it at all - the type + /// IStandardShaderProgram, the StandardShader property or + /// PreparedStandardShader, every one of which contains the same + /// substring - and then draws a mesh with it. Deliberately loose on the first + /// half: over-reporting costs an exemption line, under-reporting costs a + /// ghosting renderer nobody notices. + /// + private static bool UsesStandardShader(string text) + { + if (!text.Contains("StandardShader", StringComparison.Ordinal)) return false; + + return text.Contains("RenderMesh(", StringComparison.Ordinal) || + text.Contains("RenderMultiTextureMesh(", StringComparison.Ordinal); + } + + private static int Count(string text, string needle) + { + int count = 0; + for (int i = text.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = text.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + return count; + } + + private static string ReadRepositoryFile(string relativePath) + { + return File.ReadAllText(Path.Combine(RepositoryRoot(), + relativePath.Replace('/', Path.DirectorySeparatorChar))); + } + + private static string RepositoryRoot() + { + return Path.GetDirectoryName(PatchReader.FindRepositoryFile("TAA-PLAN.md"))!; + } +} diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 879d8f3b..a4a8e2cd 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -422,6 +422,44 @@ correct until you measure it. GPU harness; the sky pass's GL branch is the shared `GlDepthFunc`/`GlToggleBlend` helpers plus `BeginMotionOnlyWrite`'s existing GL branch, all of which are still unproven on OpenGL. +P4 status, movers (the P3 carry-over) (2026-09-10): landed on `feat/taa`. +**Not verified in game on either backend** - no phase of P4 ran `make deploy` or the client. +Every standard-shader user in the mod forks now either writes motion or is on an explicit +exemption list with a reason, enumerated by scan in +`Optimum.Tests/taa-mover-motion-coverage-tests.cs` rather than listed by hand. + +| Class | Status | Why | +|---|---|---| +| Helve hammer, resonator disc, fruitpress mash, pot lid | exact | continuous per-frame animation; `OptimumStandardMotion.Apply` keyed on the renderer (the pot lid on `lidRef`, because the pot body already holds the renderer's key) plus a narrow `Begin`/`End` window | +| Bloomery, forge and firepit contents | exact | their model matrices track fuel level, voxel height and the cooking transform, all of which move between frames | +| Falling blocks | exact | history keyed on the `EntityBlockFalling` entity, not on the renderer, which is shared by every falling block in view; one window around the whole loop | +| Anvil parts, molds, signs, chest labels, knapping, clay forming, ground storage, crucible, support-beam preview | fallback, and exact | static in the world, so the resolve's camera reprojection is the right answer; each carries a written reason on the exemption list | +| Forge work item, anvil work item | fallback | drawn on the mod's own `smithingWorkItemShader`, which declares no motion output at all; a writer there is a separate shader override | + +Findings to carry: + +(q) **One renderer can be two drawn things.** `PotInFirepitRenderer` draws a static pot body and a +rattling lid from one `OnRenderFrame`, through one `Matrixf`. Keying both on `this` would have +handed the lid the body's previous matrix - a zero vector on the only part of the pot that moves - +and the `ConditionalWeakTable` would have silently accepted it. The identity has to mean "this +drawn thing", not "this renderer": the lid keys on `lidRef`. + +(r) **A shared renderer must key on the drawn object.** `ModSystemRenderFallingBlocksFast` is one +`IRenderer` for every falling block in view, so its identity is the entity. The window, by +contrast, is per target and not per draw, so it opens once around the loop. + +(s) **The jittered case is now covered, and it needed a perspective-shaped projection.** +`Optimum.Render.Vulkan.Tests/TaaMoverMotionTests` drives the standard writer with +`taaJitterPx != 0`, which every P3 GPU test left at zero. With the identity projection those tests +use, the NDC shear `P[8] -= 2*jx/W` is a no-op on a quad at z = 0, so a jittered case there would +have asserted nothing. Zeroing `taaJitterPx` while leaving the projection sheared fails 7 of the 8 +new cases, which is what makes them evidence. + +(t) **P3 finding (f) is unchanged and now covers eight more methods.** `mod-patcher` `Methods` +entries were added for every mover, but `patches/runtime/**` still has no donor for any of them, +so the installed runtime keeps the vanilla bodies and every one of these renderers ghosts there +while the build tree is correct. `check-patches.sh` reports 0 problems either way. + **P5. Integration, sharpen, settings, fallback, acceptance.** - RCAS variant with a sharpness uniform and true bypass; no double sharpening with FSR1 render scale; `TaaMipBias` optional and measured; settings rows in `GuiCompositeSettings.cs.patch`; diff --git a/patches/VSEssentials/Entities/EntityBlockFalling.cs.patch b/patches/VSEssentials/Entities/EntityBlockFalling.cs.patch index 1f91df43..ec456525 100644 --- a/patches/VSEssentials/Entities/EntityBlockFalling.cs.patch +++ b/patches/VSEssentials/Entities/EntityBlockFalling.cs.patch @@ -1,8 +1,81 @@ diff --git a/VSEssentials/Entities/EntityBlockFalling.cs b/VSEssentials/Entities/EntityBlockFalling.cs -index 0615808..7f72be4 100644 +index 0615808..24b806d 100644 --- a/VSEssentials/Entities/EntityBlockFalling.cs +++ b/VSEssentials/Entities/EntityBlockFalling.cs -@@ -108,10 +108,12 @@ namespace Vintagestory.GameContent +@@ -71,47 +71,63 @@ namespace Vintagestory.GameContent + plrPos = capi.World.Player.Entity.Pos.XYZ; + + Vec3d curPos = new Vec3d(); + + +- foreach (var entity in fallingBlocks.Values) ++ // Optimum TAA (P4): falling blocks translate and tumble every frame, so ++ // each one keeps its own previous model matrix keyed on the entity - the ++ // renderer is shared by all of them, so it cannot be the identity. One ++ // window around the whole loop rather than one per block: the draw-buffer ++ // mask is the same for every draw in it. No-op when TAA is off. ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try + { +- if (!IsRendered(entity)) continue; +- if (entity.meshRef == null) ++ foreach (var entity in fallingBlocks.Values) + { +- genMesh(entity); +- } ++ if (!IsRendered(entity)) continue; ++ if (entity.meshRef == null) ++ { ++ genMesh(entity); ++ } + +- Vec4f lightrgbs = capi.World.BlockAccessor.GetLightRGBs((int)entity.Pos.X, (int)entity.Pos.Y, (int)entity.Pos.Z); +- prog.RgbaLightIn = lightrgbs; ++ Vec4f lightrgbs = capi.World.BlockAccessor.GetLightRGBs((int)entity.Pos.X, (int)entity.Pos.Y, (int)entity.Pos.Z); ++ prog.RgbaLightIn = lightrgbs; + +- curPos.Set(entity.Pos.X + entity.SelectionBox.X1, entity.Pos.Y + entity.SelectionBox.Y1, entity.Pos.Z + entity.SelectionBox.Z1); ++ curPos.Set(entity.Pos.X + entity.SelectionBox.X1, entity.Pos.Y + entity.SelectionBox.Y1, entity.Pos.Z + entity.SelectionBox.Z1); + +- float div = entity.Collided ? 4f : 1.5f; ++ float div = entity.Collided ? 4f : 1.5f; + +- double rotaccumThis = rotaccum + (entity.EntityId.GetHashCode() % 1000) / 1000f * GameMath.TWOPI; ++ double rotaccumThis = rotaccum + (entity.EntityId.GetHashCode() % 1000) / 1000f * GameMath.TWOPI; + +- prog.ModelMatrix = ModelMat +- .Identity() +- .Translate( +- curPos.X - camPos.X + GameMath.Sin(capi.InWorldEllapsedMilliseconds / 120f + 30) / 20f / div, +- curPos.Y - camPos.Y, +- curPos.Z - camPos.Z + GameMath.Cos(capi.InWorldEllapsedMilliseconds / 110f + 20) / 20f / div +- ) +- .RotateX((float)(Math.Sin(rotaccumThis * 10) / 10.0 / div)) +- .RotateZ((float)(Math.Cos(10 + rotaccumThis * 9.0) / 10.0 / div)) +- .Values +- ; ++ prog.ModelMatrix = ModelMat ++ .Identity() ++ .Translate( ++ curPos.X - camPos.X + GameMath.Sin(capi.InWorldEllapsedMilliseconds / 120f + 30) / 20f / div, ++ curPos.Y - camPos.Y, ++ curPos.Z - camPos.Z + GameMath.Cos(capi.InWorldEllapsedMilliseconds / 110f + 20) / 20f / div ++ ) ++ .RotateX((float)(Math.Sin(rotaccumThis * 10) / 10.0 / div)) ++ .RotateZ((float)(Math.Cos(10 + rotaccumThis * 9.0) / 10.0 / div)) ++ .Values ++ ; + +- rapi.RenderMultiTextureMesh(entity.meshRef, "tex"); ++ OptimumStandardMotion.Apply(prog, entity, entity.meshRef, ModelMat.Values); ++ rapi.RenderMultiTextureMesh(entity.meshRef, "tex"); ++ } ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); + } + prog.Stop(); } @@ -15,7 +88,7 @@ index 0615808..7f72be4 100644 int posz = entity.blockEntityAttributes?.GetInt("posz", entity.initialPos.Z) ?? entity.initialPos.Z; BlockEntity be = capi.World.BlockAccessor.GetBlockEntity(new BlockPos(posx, posy, posz)); -@@ -140,27 +142,35 @@ namespace Vintagestory.GameContent +@@ -140,27 +156,35 @@ namespace Vintagestory.GameContent } entity.reusedMeshRef = true; diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs.patch new file mode 100644 index 00000000..9330e327 --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs.patch @@ -0,0 +1,29 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs +index 130ea55..a981d67 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs +@@ -68,11 +68,23 @@ namespace Vintagestory.GameContent + .Translate(8 / 16f + pos.X - camPos.X, pos.Y - camPos.Y + voxelHeight / 32f, 8 / 16f + pos.Z - camPos.Z) + .Values + ; + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; +- rpi.RenderMesh(cubeModelRef); ++ // Optimum TAA (P4): the contents sink as the bloomery burns down - ++ // voxelHeight drops, and the model matrix's Y translation with it - so ++ // the previous model matrix is worth keeping. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, cubeModelRef, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMesh(cubeModelRef); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + prog.Stop(); + } + + + public void Dispose() diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs.patch new file mode 100644 index 00000000..65795259 --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs.patch @@ -0,0 +1,29 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs +index e2f1f8f..f4dae3d 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs +@@ -159,11 +159,23 @@ namespace Vintagestory.GameContent + ; + + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; + +- rpi.RenderMultiTextureMesh(meshref, "tex"); ++ // Optimum TAA (P4): the cooked item's Transform is animated by the ++ // block entity (rotation and scale change while it cooks), so the ++ // previous model matrix is worth keeping. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, meshref, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMultiTextureMesh(meshref, "tex"); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + prog.Stop(); + } + + public void Dispose() diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs.patch new file mode 100644 index 00000000..844cfacc --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs.patch @@ -0,0 +1,31 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs +index aa1d785..16466c2 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs +@@ -245,11 +245,25 @@ namespace Vintagestory.GameContent + prog.ExtraGlow = burning ? glow : 0; + prog.ModelMatrix = ModelMat.Identity().Translate(pos.X - camPos.X, pos.Y - camPos.Y + (fuelLevel - 1) / 16f / 4f, pos.Z - camPos.Z).Values; + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; + +- rpi.RenderMultiTextureMesh(coalMeshRef, "tex"); ++ // Optimum TAA (P4): the coal bed sinks as fuel is consumed - the ++ // model matrix's Y translation follows fuelLevel - so the previous ++ // model matrix is worth keeping. The work-item draw above runs the ++ // mod's own smithing program, which declares no motion output, so it ++ // stays outside the window. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, coalMeshRef, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMultiTextureMesh(coalMeshRef, "tex"); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + prog.Stop(); + } + } + diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs.patch new file mode 100644 index 00000000..51948dd1 --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs.patch @@ -0,0 +1,29 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs +index 5f7f7f8..bf1739a 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs +@@ -181,11 +181,23 @@ namespace Vintagestory.GameContent + ; + + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; + +- rpi.RenderMultiTextureMesh(mashMeshref, "tex"); ++ // Optimum TAA (P4): the mash is squeezed - the model matrix's Y scale ++ // follows squeezeRel while the press is worked - so its previous model ++ // matrix is worth keeping. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, mashMeshref, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMultiTextureMesh(mashMeshref, "tex"); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + prog.Stop(); + } + + public void Dispose() diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs.patch new file mode 100644 index 00000000..3050052b --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs.patch @@ -0,0 +1,32 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs +index 6321a20..f9dbd1d 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs +@@ -72,11 +72,26 @@ namespace Vintagestory.GameContent + { + IStandardShaderProgram prog = rpi.PreparedStandardShader(pos.X, pos.Y, pos.Z); + prog.ModelMatrix = ModelMat.Values; + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; +- rpi.RenderMultiTextureMesh(meshref, "tex"); ++ ++ // Optimum TAA (P4): the helve hammer swings every frame it is ++ // powered, so the camera fallback would ghost the head across the ++ // screen. The renderer instance is the identity, the uploaded mesh ++ // the shape. The shadow branch below never opens a window: it runs ++ // a program with no motion output. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, meshref, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMultiTextureMesh(meshref, "tex"); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + prog.Stop(); + + AngleRad = be.Angle; + } else + { diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs.patch new file mode 100644 index 00000000..51f4e7d6 --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs.patch @@ -0,0 +1,57 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs +index 76886d6..f763a9e 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs +@@ -89,11 +89,24 @@ namespace Vintagestory.GameContent + ; + + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; + +- rpi.RenderMultiTextureMesh(potRef == null ? potWithFoodRef : potRef, "tex"); ++ // Optimum TAA (P4): the pot body itself is static, but it shares the ++ // renderer with the lid below and both draws have to be inside a window ++ // or the lid's vector would sit on top of the body's unwritten pixels. ++ // Identity is the renderer, shape the mesh actually drawn. ++ OptimumStandardMotion.Apply(prog, this, potRef == null ? potWithFoodRef : potRef, ModelMat.Values); ++ bool optimumPotMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMultiTextureMesh(potRef == null ? potWithFoodRef : potRef, "tex"); ++ } ++ finally ++ { ++ if (optimumPotMotionWrite) OptimumMotionWrite.End(); ++ } + + if (!isInOutputSlot) + { + float origx = GameMath.Sin(capi.World.ElapsedMilliseconds / 300f) * 5 / 16f; + float origz = GameMath.Cos(capi.World.ElapsedMilliseconds / 300f) * 5 / 16f; +@@ -112,11 +125,25 @@ namespace Vintagestory.GameContent + ; + prog.ViewMatrix = rpi.CameraMatrixOriginf; + prog.ProjectionMatrix = rpi.CurrentProjectionMatrix; + + +- rpi.RenderMultiTextureMesh(lidRef, "tex"); ++ // Optimum TAA (P4): the lid rattles while the pot cooks - its origin ++ // orbits and it rocks on X and Z, all driven by elapsed time - so it ++ // is a real mover. It needs its own identity because the renderer's ++ // is already taken by the pot body, and lidRef is exactly as ++ // long-lived as the lid it draws. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, lidRef, lidRef, ModelMat.Values); ++ bool optimumLidMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMultiTextureMesh(lidRef, "tex"); ++ } ++ finally ++ { ++ if (optimumLidMotionWrite) OptimumMotionWrite.End(); ++ } + } + + prog.Stop(); + } + diff --git a/patches/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs.patch b/patches/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs.patch new file mode 100644 index 00000000..80dfccf4 --- /dev/null +++ b/patches/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs.patch @@ -0,0 +1,31 @@ +diff --git a/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs b/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs +index 8d5b972..256c7e1 100644 +--- a/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs ++++ b/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs +@@ -91,11 +91,25 @@ namespace Vintagestory.GameContent + .Rotate(discRotRad) + .Scale(0.9f, 0.9f, 0.9f) + .Translate(origx, origy, origz) + .Values + ; +- rpi.RenderMesh(cylinderMeshRef); ++ // Optimum TAA (P4): the disc spins - discRotRad.Y is driven by elapsed ++ // time - so the camera fallback would ghost it. This renderer also ++ // registers on AfterFinalComposition and runs the same body there; the ++ // window refuses outside the temporal jitter window, so that draw writes ++ // nothing. No-op when TAA is off. ++ OptimumStandardMotion.Apply(prog, this, cylinderMeshRef, ModelMat.Values); ++ bool optimumMotionWrite = OptimumMotionWrite.Begin(); ++ try ++ { ++ rpi.RenderMesh(cylinderMeshRef); ++ } ++ finally ++ { ++ if (optimumMotionWrite) OptimumMotionWrite.End(); ++ } + + prog.Stop(); + } + + internal void UpdateMeshes(MeshData cylinderMesh) From e39eb4b15e2a0f0ee6a1c8c19ee2245019cf3a05 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 23:15:23 +0200 Subject: [PATCH 036/226] wip(taa): P4 review fixes - reactive survives the behind-camera branch in the liquid and cube-particle writers; translation gate covers decals USESSBO, particlescube VEC3SCALE and TAA-with-waving-off; liquid pass depth-state comment corrected to GL_LESS and its depth side effects listed Adversarial review of the four P4 stage commits (8f64e11..8d09ef1). 1. chunkliquidmotion.fsh and particlescube.fsh dropped the reactive value whenever the previous clip position landed behind the previous camera (taaPrevClip.w <= 1e-6) by writing a flat vec4(0.0). taa-resolve.fsh reads motion.b whether or not the writer-depth test accepted the pixel (finding (h)), so that handed an animating water surface or a cube particle FULL history weight in exactly the frames the camera swung hardest - the worst case, not the safe one. taa-skymotion.fsh already got this right; both writers now match it. Zero alpha and zero vector are unchanged, so the pixel still routes to the resolve's camera fallback. GPU regression: TaaLiquidMotionTests .APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue (mirror-z previous view => prevClip.w < 0); verified to fail on the old shader. Source regression in both coverage test files, scoped to the branch. 2. Two shipped define combinations of the new shaders were outside the Vulkan translation gate: decals with USESSBO 1 (the branch where vertexPos and renderFlagsIn are locals unpacked from the face buffer - what the client really runs, since UseSSBOs defaults on) and particlescube with VEC3SCALE 1 (stamped by VSEssentials' EntityParticleSystem, i.e. NOT the dead branch the particle stage recorded). Added as explicit cases, plus a taa-no-waving corpus row so WAVINGSTUFF 0 - which gates every vertexwarp body the writers replay - is translated with TAAMOTION on for every program. All translate. Coverage tests pin both cases so they cannot be dropped as redundant. 3. RenderLiquidMotion's doc comment claimed LEQUAL; the pass runs under the client's GL_LESS and deliberately does not change it. Also records the full set of consumers its depth write affects: the SSAO bilateral blur's depth-guided weights and the AfterBlit rift renderer, not only the AfterFinalComposition overlays. Verified: extract-patches + check-patches (0 conflict, 0 pending), Release build, Optimum.Tests 909 passed, Optimum.Render.Vulkan.Tests 319 passed on GPU with validation layers. Not verified in game - launching was out of scope. --- Optimum.Render.Vulkan.Tests/ShaderCorpus.cs | 11 ++++ .../ShaderTranslationTests.cs | 32 ++++++++++++ .../TaaLiquidMotionTests.cs | 52 ++++++++++++++++++- .../taa-liquid-motion-coverage-tests.cs | 9 ++++ .../taa-particle-motion-coverage-tests.cs | 33 ++++++++++++ .../taa-sky-decal-motion-coverage-tests.cs | 23 ++++++++ .../ChunkRenderer.cs.patch | 35 +++++++------ sources/shaders/chunkliquidmotion.fsh | 10 +++- sources/shaders/particlescube.fsh | 9 +++- 9 files changed, 194 insertions(+), 20 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs index fca7982b..61c26dff 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs @@ -252,6 +252,17 @@ public static IEnumerable Variants() Name = "taa-with-ssao", SsaoLevel = 2, DynLights = 4, TaaMotion = 1, TaaMotionLocation = 4, }; + // TAA on with the waving-stuff, foam and shiny settings off. Those are + // ordinary client settings, and WAVINGSTUFF in particular is what gates + // the bodies of every vertexwarp function the motion writers replay for + // the previous frame - so with TAA on it is a shipped combination that + // no other row produced (the "everything-off" row carries TAAMOTION 0). + yield return new ShaderVariant + { + Name = "taa-no-waving", + TaaMotion = 1, TaaMotionLocation = 2, + WavingStuff = 0, FoamEffect = 0, ShinyEffect = 0, + }; } /// diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs index 482661cf..698a2788 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs @@ -122,6 +122,38 @@ public void MotionWritersTranslateInTheConfigurationsTheClientReallyBuilds() TaaMotion = 1, TaaMotionLocation = location, ExtraPrefix = "#define ALLOWDEPTHOFFSET 1", })); + // TAA P4 review: the decal writer's SSBO branch. USESSBO tracks + // ScreenManager.Platform.UseSSBOs, which is on by default, and it is + // the branch where vertexPos and renderFlagsIn are locals unpacked + // from the face buffer rather than vertex attributes - so the + // previous-position block reads different symbols there. The corpus + // rows that carry USESSBO 1 all carry TAAMOTION 0, so the + // combination the client really ships was outside the gate. + cases.Add(("decals", new ShaderCorpus.ShaderVariant + { + Name = $"decals-ssbo-ssao{ssao}", + SsaoLevel = ssao, DynLights = 4, ShadowQuality = 2, UseSsbo = 1, + TaaMotion = 1, TaaMotionLocation = location, + })); + cases.Add(("decals", new ShaderCorpus.ShaderVariant + { + Name = $"decals-nossbo-ssao{ssao}", + SsaoLevel = ssao, DynLights = 4, ShadowQuality = 2, UseSsbo = 0, + TaaMotion = 1, TaaMotionLocation = location, + })); + // TAA P4 review: the cube-particle writer's VEC3SCALE branch. + // VSEssentials' EntityParticleSystem stamps `#define VEC3SCALE 1` on + // its private copy of particlescube (EntityParticleSystem.cs:190), + // which is the per-axis-scale position path - a second place the + // twin previous-position function has to agree with vanilla's own + // lines. No corpus row produces it. + cases.Add(("particlescube", new ShaderCorpus.ShaderVariant + { + Name = $"particlescube-vec3scale-ssao{ssao}", + SsaoLevel = ssao, DynLights = 4, ShadowQuality = 2, + TaaMotion = 1, TaaMotionLocation = location, + ExtraPrefix = "#define VEC3SCALE 1", + })); } using var compiler = new ShaderCompiler(); diff --git a/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs index 5134c427..5e31105d 100644 --- a/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs @@ -301,6 +301,53 @@ public void ThePreviousLiquidWaveIsReplayedThroughTheSameWarp() } } + /// + /// A fragment whose previous position ends up BEHIND the previous camera has + /// no motion vector - the perspective divide would flip it - so the writer + /// bails out with a zero alpha and lets taa-resolve.fsh camera-reproject the + /// pixel. What it must NOT drop on that path is the reactive value: + /// taa-resolve.fsh reads motion.b whether or not the writer-depth test + /// accepted the pixel (TAA-PLAN.md finding (h)), so a zero there would hand + /// an animating water surface full history weight in exactly the frames the + /// camera swung hardest. + /// + /// The previous view here mirrors z, so the quad at view z = -1 lands at + /// z = +1 in the previous frame's view and the previous clip w (= -z) is + /// negative. Before the P4 review fix this test failed on the reactive + /// channel alone, with the vector and the depth already correct. + /// + [SkippableFact] + public void APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + float[] mirrorZ = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, -1, 0, + 0, 0, 0, 1, + }; + + Result result = RenderLiquidMotion(device!, 0f, 0f, previousView: mirrorZ); + Decoded centre = result.At(Size / 2, Size / 2); + + _output.WriteLine($"behind: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + // No vector, and the zero alpha that routes the pixel to the camera + // fallback rather than pretending the writer owns it. + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.WriterDepth, 0f, 0.01f); + // ... but the reactive value is still delivered. + Assert.InRange(centre.Reactive, LiquidReactive - 0.01f, LiquidReactive + 0.01f); + } + } + // ---------------------------------------------------------------- harness private readonly struct Decoded @@ -349,7 +396,8 @@ private unsafe Result RenderLiquidMotion( float jitterX = 0f, float jitterY = 0f, int waterFlags = 0, - float previousWaterWaveIntensity = 0f) + float previousWaterWaveIntensity = 0f, + float[]? previousView = null) { IOptimumGraphicsDevice seam = device; @@ -418,7 +466,7 @@ private unsafe Result RenderLiquidMotion( // hands it out: a previous position through a jittered matrix would carry // two frames' jitter difference instead of the surface's movement. SetMatrix(seam, program, "prevProjectionMatrix", Projection); - SetMatrix(seam, program, "prevModelViewMatrix", Identity); + SetMatrix(seam, program, "prevModelViewMatrix", previousView ?? Identity); SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); SetFloat2(seam, program, "taaRenderSize", Size, Size); SetFloat2(seam, program, "taaJitterPx", jitterX, jitterY); diff --git a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs index 573a3882..b64915ea 100644 --- a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -55,6 +55,15 @@ public void TheLiquidWriterEmitsTheMotionContractAndNothingElse() "outMotion = vec4(prevPixel - currentPixel, clamp(taaLiquidReactive, 0.0, 1.0), gl_FragCoord.z);", fragment); Assert.Contains("if (taaPrevClip.w <= 1e-6) {", fragment); + // ... and the reactive value crosses that branch. taa-resolve.fsh reads + // motion.b whether or not the writer-depth test accepted the pixel + // (TAA-PLAN.md finding (h)), and the foam and flow-UV animation 0.3 + // stands for is happening on this fragment either way. P4 review fix - + // the GPU proof is TaaLiquidMotionTests + // .APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue. + string behindCamera = Between(fragment, "if (taaPrevClip.w <= 1e-6) {", "}", 0); + Assert.Contains("clamp(taaLiquidReactive, 0.0, 1.0)", behindCamera); + Assert.DoesNotContain("outMotion = vec4(0.0);", behindCamera); // Foam and the flow-UV scroll animate in place, so the surface is // reactive even where it reprojects perfectly. diff --git a/Optimum.Tests/taa-particle-motion-coverage-tests.cs b/Optimum.Tests/taa-particle-motion-coverage-tests.cs index c02437de..57a36a96 100644 --- a/Optimum.Tests/taa-particle-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-particle-motion-coverage-tests.cs @@ -64,6 +64,16 @@ public void TheCubeParticleWriterEmitsTheMotionContractBesideItsVanillaOutputs() "outMotion = vec4(prevPixel - currentPixel, 1.0, gl_FragCoord.z);", fragment); Assert.Contains("if (taaPrevClip.w <= 1e-6) {", fragment); + // ... and on that branch the reactive value survives. taa-resolve.fsh + // reads motion.b whether or not the writer-depth test accepted the pixel + // (TAA-PLAN.md finding (h)), so writing a plain vec4(0.0) here would give + // the particle FULL history weight in exactly the frames the camera swung + // hard enough to put it behind last frame's camera. P4 review fix; the + // liquid pass carries its 0.3 across the same branch and taa-skymotion + // its cloud reactive. + string behindCamera = Between(fragment, "if (taaPrevClip.w <= 1e-6) {", "}", 0); + Assert.Contains("outMotion = vec4(0.0, 0.0, 1.0, 0.0);", behindCamera); + Assert.DoesNotContain("outMotion = vec4(0.0);", behindCamera); // Unlike the liquid velocity pass, this writer is an ADDITION to a // shading pass: the vanilla outputs have to still be there, or the @@ -115,6 +125,29 @@ public void TheCubeParticlePositionPathIsVanillasVerbatim() Assert.Equal(Squash(vanillaBranch), Squash(ourBranch)); } + /// + /// VEC3SCALE is a shipped configuration, not a dead branch: VSEssentials' + /// EntityParticleSystem stamps it on its own copy of particlescube, and that + /// copy takes the per-axis-scale position path - a second place the twin + /// previous-position function has to agree with vanilla's own lines. + /// + /// No ShaderCorpus variant row produces it (the rows move the engine's own + /// defines, and VEC3SCALE is a caller's), so the translation gate needs an + /// explicit case. This test is what stops that case from being deleted as + /// redundant: the branch is compiled by nobody else in the suite. + /// + [Fact] + public void TheVec3ScaleParticleVariantIsAShippedConfigurationAndIsInTheTranslationGate() + { + Assert.Contains( + "prog.VertexShader.PrefixCode += \"#define VEC3SCALE 1\\n\";", + Read("VSEssentials/Systems/ParticleEntity/EntityParticleSystem.cs")); + + string gate = Read("Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs"); + Assert.Contains("particlescube-vec3scale-ssao", gate); + Assert.Contains("ExtraPrefix = \"#define VEC3SCALE 1\"", gate); + } + // ------------------------------------------- (b) the OIT merge reactive [Fact] diff --git a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs index 76d56a0f..82583358 100644 --- a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -374,6 +374,29 @@ public void WithTaaOffTheOverridesAreTheVanillaShaders(string shader) Assert.Equal(Squash(StripComments(vanilla)), Squash(StripComments(StripTaaRegions(ours)))); } + /// + /// The decal writer has two shapes, because decals.vsh has two: with + /// USESSBO the vertex position and the render flags are locals unpacked from + /// the face buffer, without it they are vertex attributes. USESSBO tracks + /// ScreenManager.Platform.UseSSBOs and is on by default, so the SSBO shape is + /// what the game really runs - and the ShaderCorpus rows that carry USESSBO 1 + /// all carry TAAMOTION 0, which left the combination outside the translation + /// gate entirely. Explicit cases cover it; this test keeps them there. + /// + [Fact] + public void BothDecalVertexShapesAreInTheTranslationGate() + { + string vertex = Read("sources/shaders/decals.vsh"); + // The TAA block reads symbols the SSBO branch declares as locals. + Assert.Contains("#if USESSBO > 0", vertex); + Assert.Contains("int renderFlagsIn = vdata.flags[vIndex];", vertex); + Assert.Contains("vec4 taaPrevPos = vec4(vertexPos + origin + cameraPosDelta, 1.0);", vertex); + + string gate = Read("Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs"); + Assert.Contains("decals-ssbo-ssao", gate); + Assert.Contains("decals-nossbo-ssao", gate); + } + // ---------------------------------------------------------------- helpers private static string StripTaaRegions(string source) diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch index dc2a9574..49d3e0d4 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs -index 431e51a..d77cdb4 100644 +index 431e51a..96ac74a 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs @@ -1,9 +1,11 @@ @@ -303,7 +303,7 @@ index 431e51a..d77cdb4 100644 internal void RenderOIT(float deltaTime) { -@@ -402,15 +556,119 @@ public class ChunkRenderer +@@ -402,15 +556,124 @@ public class ChunkRenderer chunktransparent.Stop(); game.GlPopMatrix(); ScreenManager.FrameProfiler.Mark("rend3D-ret-tp"); @@ -327,18 +327,23 @@ index 431e51a..d77cdb4 100644 + /// RenderPostprocessingEffects. Drawing it earlier would let the depth this + /// pass writes occlude geometry that is legitimately visible through water. + /// -+ /// Depth: the test is on (LEQUAL against Primary's depth, which is where the -+ /// opaque scene is, so a water surface in front of the terrain passes and one -+ /// behind it does not), and the WRITE is on. The OIT liquid draw writes no -+ /// depth at all - LoadFrameBuffer(Transparent) disables the depth mask - so -+ /// without this the motion attachment would carry the water surface's window -+ /// depth while the depth buffer carried the terrain behind it, the resolve's -+ /// writer-depth test would reject every liquid pixel, and the whole pass -+ /// would buy nothing. Writing it also hands the resolve the water surface's -+ /// own linear depth, which is the depth the vector belongs to. The cost is -+ /// that passes after this one which depth-test against Primary - the -+ /// AfterFinalComposition overlays - now see the water surface; that is -+ /// outside the temporal window and only happens with TAA on. ++ /// Depth: the test is on - GL_LESS, the client's default and what the OIT ++ /// liquid draw itself ran under, deliberately not changed here so this pass ++ /// keeps exactly the same water fragments that pass looked at - and the WRITE ++ /// is on. The OIT liquid draw writes no depth at all - ++ /// LoadFrameBuffer(Transparent) disables the depth mask - so without this the ++ /// motion attachment would carry the water surface's window depth while the ++ /// depth buffer carried the terrain behind it, the resolve's writer-depth ++ /// test would reject every liquid pixel, and the whole pass would buy ++ /// nothing. Writing it also hands the resolve the water surface's own linear ++ /// depth, which is the depth the vector belongs to. ++ /// ++ /// The cost is that everything after this pass which reads or tests Primary's ++ /// depth now sees the water surface: the SSAO bilateral blur's depth-guided ++ /// weights (RenderPostprocessingEffects), the AfterFinalComposition overlays ++ /// (selection boxes, work-item guides), and the AfterBlit rift renderer, ++ /// which samples that depth texture directly. All of that is outside the ++ /// temporal window and only happens with TAA on. + /// + /// Depth writes make draw order irrelevant: the buffer only ever decreases, + /// so the last fragment that passes the test is the nearest one, and the @@ -423,7 +428,7 @@ index 431e51a..d77cdb4 100644 platform.GlToggleBlend(on: false); platform.GlEnableDepthTest(); chunkopaque.Use(); -@@ -425,28 +683,36 @@ public class ChunkRenderer +@@ -425,28 +688,36 @@ public class ChunkRenderer chunkopaque.DayLight = game.shUniforms.SkyDaylight; chunkopaque.HorizonFog = game.AmbientManager.BlendedCloudDensity; chunkopaque.HaxyFade = 1; diff --git a/sources/shaders/chunkliquidmotion.fsh b/sources/shaders/chunkliquidmotion.fsh index 4d5d8b12..ed511deb 100644 --- a/sources/shaders/chunkliquidmotion.fsh +++ b/sources/shaders/chunkliquidmotion.fsh @@ -53,8 +53,16 @@ void main() // zero alpha routes the pixel to the resolve's camera fallback, exactly as // in chunkopaque.fsh. Depth is still written for it, because the fragment // is genuinely the visible surface either way. + // + // The reactive value is delivered anyway: taa-resolve.fsh reads motion.b + // whether or not the writer-depth test accepted the pixel (P3 finding (h)), + // and the foam and flow-UV animation that 0.3 stands for is happening on + // this fragment regardless of where it was last frame. Zeroing b here would + // hand a water pixel FULL history weight in exactly the frames the camera + // swung hardest - the worst case, not the safe one. taa-skymotion.fsh keeps + // its reactive value on the same branch for the same reason. if (taaPrevClip.w <= 1e-6) { - outMotion = vec4(0.0); + outMotion = vec4(0.0, 0.0, clamp(taaLiquidReactive, 0.0, 1.0), 0.0); return; } diff --git a/sources/shaders/particlescube.fsh b/sources/shaders/particlescube.fsh index 70cd7e00..5670ab74 100644 --- a/sources/shaders/particlescube.fsh +++ b/sources/shaders/particlescube.fsh @@ -79,9 +79,14 @@ void main() // // A previous position behind the previous camera is not a motion vector; a // zero alpha routes the pixel to the resolve's camera fallback, exactly as - // in chunkopaque.fsh. + // in chunkopaque.fsh - but b stays 1. taa-resolve.fsh reads motion.b whether + // or not the writer-depth test accepted the pixel (P3 finding (h)), and this + // is a particle either way: dropping the reactive value here would give the + // pixel full history weight precisely when the camera swung hard enough to + // put the particle behind last frame's camera, which is the frame the + // history is least like it. if (taaPrevClip.w <= 1e-6) { - outMotion = vec4(0.0); + outMotion = vec4(0.0, 0.0, 1.0, 0.0); } else { vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; From 5333db02163ac090ac05e5042ec48febafb74db7 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 23:16:58 +0200 Subject: [PATCH 037/226] docs(taa): record P4 status Inventory table rows for liquid, cube/quad particles, OIT entities and clouds updated to what actually shipped (vector vs reactive, and which pass writes it). New whole-phase P4 status section: exact vs fallback vs reactive per class in one table, the six findings the review added - (u) a bailing writer must still deliver reactive, (v) three shipped define combinations the translation gate missed, (w) the liquid depth write reaches the SSAO bilateral blur and the rift renderer too, (x) why the never-restored per-attachment blend is correct and what it depends on, (y) the merge's reactive is overwritten by every later replace-blend writer, (z) the sky pass's per-frame array churn, (aa) the revealage the reactive policy rests on has two blend meanings - and an explicit list of what only the game can answer, since no phase of P4 deployed or ran it. --- TAA-PLAN.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/TAA-PLAN.md b/TAA-PLAN.md index a4a8e2cd..10b0bfd4 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -158,16 +158,16 @@ never jittered. | Class | Stage / target | Shader | Motion policy | |---|---|---|---| | Chunk opaque / topsoil / pass-7 overlay | Opaque, AfterOIT / Primary | chunkopaque, chunktopsoil | exact (P3) | -| Liquid | OIT / Transparent | chunkliquid | exact via liquid velocity pass (P4) + reactive foam | +| Liquid | OIT / Transparent, motion via a second Primary pass | chunkliquid, chunkliquidmotion | exact (P4): dedicated velocity pass, depth test AND write on, reactive 0.3 | | LiquidDepth prepass | Before / LiquidDepth quarter res | chunkliquiddepth | jittered NDC shear, no motion | -| Entities (skinned) | Opaque, OIT, AfterOIT / Primary, Transparent | entityanimated(_oit) | exact opaque (P3); OIT reactive | +| Entities (skinned) | Opaque, OIT, AfterOIT / Primary, Transparent | entityanimated(_oit) | exact opaque (P3); OIT gets the merge's `1 - revealage` reactive only (P4) | | Held items, dropped items, block-entity models | Opaque / Primary | standard | exact (P3, standard writer) | | First-person hands | Opaque / Primary, hand FOV, depthOffset | fp hands program | exact with hand-FOV previous (P3) | | Instanced mechanical power | Opaque / Primary | instanced | exact with previous instance transforms (P3) | -| Particles cube | Opaque / Primary, blend on | particlescube | reactive 1, replace-blend on motion (P4) | -| Particles quad | OIT / Transparent | particlesquad | reactive via revealage (P4) | +| Particles cube | Opaque / Primary, blend on | particlescube | camera-only vector, reactive 1, replace-blend on motion (P4) | +| Particles quad | OIT / Transparent | particlesquad | no vector; reactive `1 - revealage` added by the merge (P4) | | Night sky, sun/moon, sky colour | Opaque / Primary, no depth write | nightsky, sky, celestialobject, standard | no writer needed: depth stays 1, the resolve's infinite-direction fallback is exact (P4, verified) | -| Clouds (volumetric), aurora | OIT / Transparent | cloudvolumetric, aurora | rotation-only vector + coverage-gated reactive from the `taa-skymotion` pass on the sky pixels (P4) | +| Clouds (volumetric), aurora | OIT / Transparent | cloudvolumetric, aurora, taa-skymotion | camera-rotation-only vector + coverage-gated reactive from `taa-skymotion` on the sky pixels; no reactive of their own where they sit in front of terrain (P4) | | Decals | AfterOIT / Primary | decals | exact: writes the terrain previous path itself, with its own depth, because the z-offset moves the depth buffer out from under the block's writer depth; crack progress rejected by colour clipping (P4) | | Work-item guides, selection boxes, wireframes | AfterFinalComposition / Primary | various | outside window, unjittered (P1); the motion window is refused there on `JitterActive` (P4) | | Rifts | AfterBlit / Default | rift | outside window, default framebuffer, motion window refused; noted as FG gap (P4) | @@ -460,6 +460,103 @@ entries were added for every mover, but `patches/runtime/**` still has no donor so the installed runtime keeps the vanilla bodies and every one of these renderers ghosts there while the build tree is correct. `check-patches.sh` reports 0 problems either way. +P4 status, whole phase, after the adversarial review (2026-09-10): landed on `feat/taa` +(8f64e11 liquid, 13d9eb3 particles, 957f4e0 sky/clouds/decals/late overlays, 8d09ef1 movers, +b1c293f review fixes). **Not verified in game on either backend** - no phase of P4 ran +`make deploy` or the client, so by rule 3 none of it is done. GPU proof is Vulkan-only +(`Optimum.Render.Vulkan.Tests` is the only GPU harness), and every GL branch added in this phase +has never executed. + +Exact vs fallback vs reactive, per class (the inventory table above is the ships-with-it form): + +| Class | Vector | Reactive | Why | +|---|---|---|---| +| Liquid surfaces | exact (P4) | 0.3, constant | `chunkliquidmotion` re-draws the liquid pools into Primary through the motion-only window, replaying `chunkliquid.vsh`'s position path verbatim - same liquid warp through `applyLiquidWarpingState`, same `w += 0.0008/max(0.1, z)` offset on both clips. Depth test GL_LESS and depth **write on**, so `a` matches the buffer and the resolve accepts the pixel | +| Cube particles | fallback, camera-only | 1 | the instance stream carries position and scale only (stride 16, sized by MaxCubeParticles for four pools), so there is no previous per-particle position; reactive 1 makes the resolve ignore the history anyway. Wrong data for FSR/XeSS mv and for frame generation | +| Quad particles, OIT entities, liquid shading, aurora | none | `1 - revealage`, additive | six oit.fsh outputs already fill Transparent; the merge adds `anet` into `b` alone under FUNC_ADD (ONE, ONE) with rg and a written as zero, so the opaque vector underneath survives bit-for-bit | +| Sky colour, night sky | none, by design | 0 | depth test off for the whole pass, so depth stays 1 and the resolve's infinite-direction fallback is the exact answer | +| Sun, moon, celestial objects | none, by design | 0 | depth tested, never written (`GlDepthMask(false)`); the fallback ignores only the celestial rotation, ~0.004 deg per frame | +| Volumetric clouds | camera-rotation-only | `mix(coverage, 1, coverage)` on sky pixels | `taa-skymotion` claims depth-1 pixels under GL_LEQUAL and writes rg, b and `a = 1.0`. A cloud in front of terrain keeps the terrain's vector and gets only the merge's `anet` | +| Clear sky | exact | 0 | coverage 0, so the dithered gradient keeps full history weight | +| Decals | exact (P4) | 0 | own writer: chunk previous path + `previousWarpState()` + both z-offsets, `a = gl_FragCoord.z`. Crack progress is left to the resolve's colour clipping | +| Helve hammer, resonator, fruitpress, pot lid, bloomery/forge/firepit contents, falling blocks | exact (P4) | 0 | `OptimumStandardMotion.Apply` keyed on the drawn thing plus a narrow window | +| Static standard-shader users (anvil parts, molds, signs, knapping, clay forming, ground storage, ...) | fallback | 0 | camera reprojection is the right answer; each on the scanned exemption list with a reason | +| Forge/anvil work items | fallback | 0 | the mod's own `smithingWorkItemShader` declares no motion output | +| AfterFinalComposition overlays, AfterBlit rifts | none | none | outside the temporal window; `BeginMotionWrite`/`BeginMotionOnlyWrite` refuse on `JitterActive` | + +Findings to carry: + +(u) **A writer that bails out must still deliver its reactive value.** The liquid and cube-particle +writers wrote a flat `vec4(0.0)` whenever the previous clip position landed behind the previous +camera. `taa-resolve.fsh` reads `motion.b` whether or not the writer-depth test accepted the pixel +(finding (h)), so that gave an animating water surface or a particle FULL history weight in exactly +the frames the camera swung hardest. Both now keep `b` and zero only the vector and the alpha, as +`taa-skymotion.fsh` already did. GPU regression: +`TaaLiquidMotionTests.APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue`. + +(v) **The translation gate only covers combinations some corpus row produces, and P4 added three +it did not.** `decals` with `USESSBO 1` (the branch where `vertexPos` and `renderFlagsIn` are +locals unpacked from the face buffer - the one the client actually runs, `UseSSBOs` defaults on), +`particlescube` with `VEC3SCALE 1` (stamped by `VSEssentials`' `EntityParticleSystem`, so **not** +the dead branch the particle stage recorded), and any writer at `WAVINGSTUFF 0`, which gates the +body of every `vertexwarp` function the writers replay. All three are now in +`ShaderTranslationTests`, the third as a `taa-no-waving` corpus row that applies to every program, +and coverage tests pin the two explicit cases so they cannot be dropped as redundant. + +(w) **The liquid velocity pass writes depth into Primary, and more things read that than the +liquid stage recorded.** Besides the AfterFinalComposition overlays (a block outline on a submerged +block is now occluded by the water surface), the SSAO **bilateral blur** takes its depth-guided +weights from `frameBuffers[0].DepthTextureId`, and the AfterBlit **rift renderer** samples it +directly. All three change with TAA on and are unmeasured. The alternative - keep depth writes off +and output `a` = the depth sampled from Primary's own depth texture - was not taken because rule 7 +prescribes writing the surface's depth; it stays the fallback if the overlays look wrong in game. + +(x) **Replace blending on the motion attachment is set but never restored, and that is correct only +because the attachment leaves the draw-buffer mask.** Per-attachment blend state is global pipeline +state, not per-framebuffer, on both backends. The discipline everything relies on is: set the +global blend mode first, then the per-attachment overrides. `SetBlend` on the Vulkan device resets +every attachment exactly as GL's non-indexed `glBlendFunc` does (`GlStateTracker.SetBlend`), so a +`GlToggleBlend` inside an open window re-applies the motion override at the end and the two +backends agree. A caller that sets a per-attachment factor and then a global mode has it silently +undone. + +(y) **The merge's reactive is written first and overwritten by everything after it.** The OIT merge +adds `anet` over the whole screen, and then the AfterOIT terrain overlay, the AfterOIT entities, +the decals, the liquid velocity pass and the sky pass all write the attachment with **replace** +blending. So a transparent thing in front of a decal, of pass-7 terrain or of water contributes no +reactive at those pixels. Bounded and deliberate (each of those writers owns a better answer for +its own pixel), but it means the merge's value only survives where nothing later claimed the pixel. + +(z) **`RenderOptimumSkyMotion` allocates five small arrays per frame** (one `double[16]`, four +`float[16]`) to build its two matrices, exactly as `RenderOptimumTaaResolve` already does. Per +frame, not per draw, so it is inside the rule - but the phase doubled that churn and neither is +cached. Fold both into fields when P5 measures. + +(aa) **The revealage the reactive policy rests on has two different blend meanings.** With Optimum's +`SystemRenderOITLayers` active, `BeforeOIT` puts Transparent's attachment 1 on `(GL_DST_COLOR, +GL_ZERO)`, which composes `oit.fsh`'s `outReveal = 1 - alpha` multiplicatively - the correct +revealage. The vanilla factors that `LoadFrameBuffer` and `RestoreVanillaTransparentState` set, +`(GL_ZERO, GL_ONE_MINUS_SRC_COLOR)`, compose it as a product of alphas instead. Both +`transparentcompose.fsh` and `taa-skymotion.fsh` read that one texture, so they always agree with +each other - but if the OIT layer system ever falls back, the reactive value flips meaning along +with the composite alpha. Pre-existing, unmeasured, and now load-bearing for TAA. + +Still owed for P4 (rule 3), in the game, on both backends, with the renderer confirmed from the log: +- whether the liquid velocity pass removes the water ghosting it was built for, and what its depth + write does to block outlines on submerged blocks, to SSAO near water, and to rifts; +- whether reactive 1 on cloud-covered sky visibly costs the sky's own anti-aliasing, and whether + `mix(coverage, 1, coverage)` is the right curve (it is a chosen shape, not a measured one); +- whether the decal writer removed the near-decal ghosting finding (o) identified; +- whether the movers (helve hammer head, resonator disc, pot lid, a falling block through its + tumble) are clean; +- whether reactive 1 on faint cube particles costs the temporal AA of the geometry behind them; +- the vertex-warp cost of evaluating the warp twice on liquid, particles and decals - finding (j) + is still unpaid; +- the GL path of everything P4 added: `BeginMotionOnlyWrite`'s `GL_NONE` draw-buffer array, + `ApplyOptimumMotionAccumulateBlendState`'s `glBlendFunci`, and the sky pass's depth-func dance. +- `taaLiquidReactive` (0.3) and `taaCloudReactive` (1.0) are hard-coded constants, not settings; + wiring them to `OptimumConfig` is P5 work. + **P5. Integration, sharpen, settings, fallback, acceptance.** - RCAS variant with a sharpness uniform and true bypass; no double sharpening with FSR1 render scale; `TaaMipBias` optional and measured; settings rows in `GuiCompositeSettings.cs.patch`; From f4425b5bfe4100f05dfd9ac47f46354b6b87425b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 23:22:52 +0200 Subject: [PATCH 038/226] fix(review): ship shaderc to the install dir, harden the dev scripts and create the shaderincludes destination - Makefile: the INSTALL_DIR deploy now copies libshaderc_shared.so into Lib/ like the vanilla-dir deploy already did, so the installed Vulkan backend can load it. - scripts/dev/client-renderer.sh: the savegame grep no longer decides the exit status; at the menu it prints "no world loaded yet" and the script still reports renderer detection. - scripts/dev/run-client.sh: a failed optimum.json rewrite aborts before the launch instead of silently starting the client on the previous renderer. - packagers: package.ps1/package-linux.ps1/package-macos.ps1 New-Item the assets/game/shaderincludes destination and package-linux.sh/package-macos.sh mkdir -p it before copying; package.ps1 also asserts the staged assets/game/shaderincludes/vertexwarp.vsh. - DeployAndEveryPackagerShipTheShaderIncludes asserts the mkdir/New-Item lines and the new required-file entry. Verified: bash -n on all four shell scripts, scripts/dev/client-renderer.sh /dev/null exits 0, dotnet test Optimum.Tests -c Release --filter TaaTerrainMotionCoverageTests -> 18 passed. --- Makefile | 1 + .../taa-terrain-motion-coverage-tests.cs | 16 ++++++++++++++++ scripts/dev/client-renderer.sh | 4 +++- scripts/dev/run-client.sh | 10 +++++++++- scripts/package-linux.ps1 | 3 +++ scripts/package-linux.sh | 3 +++ scripts/package-macos.ps1 | 3 +++ scripts/package-macos.sh | 3 +++ scripts/package.ps1 | 6 +++++- 9 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 9dd7c4de..940bf642 100644 --- a/Makefile +++ b/Makefile @@ -125,6 +125,7 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client cp $(MOD_OUT)/VSCreativeMod.dll $(INSTALL_DIR)/Mods/; \ cp $(MOD_OUT)/cairo-sharp.dll $(INSTALL_DIR)/Lib/; \ cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(INSTALL_DIR)/; cp $(MOD_OUT)/Silk.NET.*.dll $(INSTALL_DIR)/; \ + if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(INSTALL_DIR)/Lib/; fi; \ cp sources/shaders/*.fsh sources/shaders/*.vsh $(INSTALL_DIR)/assets/game/shaders/; \ if [ -d "sources/shaderincludes" ]; then cp sources/shaderincludes/* $(INSTALL_DIR)/assets/game/shaderincludes/; fi; \ if [ -d "sources/lang" ]; then for f in sources/lang/*.json; do [ -f "$$f" ] || continue; dst="$(INSTALL_DIR)/assets/game/lang/$$(basename $$f)"; [ -f "$$dst" ] || continue; python3 -c "import json,sys; s=json.load(open(sys.argv[1],encoding='utf-8-sig')); d=json.load(open(sys.argv[2],encoding='utf-8-sig')); d.update(s); json.dump(d,open(sys.argv[2],'w',encoding='utf-8'),ensure_ascii=False,indent='\t')" "$$f" "$$dst"; done; fi; \ diff --git a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs index a149d756..bea1e668 100644 --- a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -485,6 +485,16 @@ public void DeployAndEveryPackagerShipTheShaderIncludes() Path.GetFileName(path) + " overlays sources/shaders but not sources/shaderincludes"); Assert.True(text.Contains("assets/game/shaderincludes", StringComparison.Ordinal), Path.GetFileName(path) + " has no assets/game/shaderincludes destination"); + + // The vanilla client tree has no shaderincludes directory of its own, + // so the destination has to be created before the copy: cp into a + // missing directory fails and Copy-Item writes a single file named + // after the directory instead - either way the includes never ship. + bool createsDestination = Path.GetExtension(path) == ".ps1" + ? text.Contains("New-Item -ItemType Directory -Force -Path $shaderIncDst", StringComparison.Ordinal) + : text.Contains("mkdir -p \"$SHADER_INC_DST\"", StringComparison.Ordinal); + Assert.True(createsDestination, + Path.GetFileName(path) + " copies shader includes without creating the destination directory"); } // A guard on the guard: if the enumeration ever finds nothing, the loop @@ -497,6 +507,12 @@ public void DeployAndEveryPackagerShipTheShaderIncludes() { Assert.Contains(expected, packagers); } + + // The Windows packager asserts its staged tree before sealing it; the + // include belongs in that list, so a silently skipped overlay fails the + // package instead of shipping. + Assert.Contains("'assets/game/shaderincludes/vertexwarp.vsh'", + File.ReadAllText(PatchReader.FindRepositoryFile("scripts/package.ps1"))); } /// diff --git a/scripts/dev/client-renderer.sh b/scripts/dev/client-renderer.sh index af056741..387ec9cd 100755 --- a/scripts/dev/client-renderer.sh +++ b/scripts/dev/client-renderer.sh @@ -4,4 +4,6 @@ LOG="${1:-/tmp/optimum-client.log}" grep -m1 -E "\[Optimum\] (Vulkan|OpenGL) renderer" "$LOG" || echo "no renderer line yet in $LOG" grep -m1 "Graphics Card Renderer" "$LOG" -grep -m1 "Savegame .* loaded" "$LOG" +# The exit status must reflect renderer detection only: at the main menu no +# savegame line exists yet and a bare grep would fail the whole script. +grep -m1 "Savegame .* loaded" "$LOG" || echo "no world loaded yet" diff --git a/scripts/dev/run-client.sh b/scripts/dev/run-client.sh index 94d81f85..0dabd2b8 100755 --- a/scripts/dev/run-client.sh +++ b/scripts/dev/run-client.sh @@ -13,10 +13,18 @@ REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" CLIENT="$REPO/.vanilla/win-x64/vintagestory" LOG="${CLIENT_LOG:-/tmp/optimum-client.log}" if [[ -n "${RENDERER:-}" ]]; then - python3 - "$DATA_PATH/ModConfig/optimum.json" "$RENDERER" <<'PY' + # Check the status explicitly - set -e would not help here anyway, and a failed + # rewrite (missing, invalid or unwritable optimum.json) must abort the launch: + # starting the client regardless silently runs it on the old renderer, which is + # exactly the false verification this script exists to prevent. + if ! python3 - "$DATA_PATH/ModConfig/optimum.json" "$RENDERER" <<'PY' import json,sys p,r=sys.argv[1],sys.argv[2]; d=json.load(open(p)); d['Renderer']=r; json.dump(d,open(p,'w'),indent=2) PY + then + echo "failed to set Renderer=$RENDERER in $DATA_PATH/ModConfig/optimum.json; not launching" >&2 + exit 1 + fi fi cd "$CLIENT" || exit 1 setsid prime-run dotnet Vintagestory.dll --dataPath "$DATA_PATH" -o "$WORLD" > "$LOG" 2>&1 < /dev/null & diff --git a/scripts/package-linux.ps1 b/scripts/package-linux.ps1 index c706cb43..d55d7630 100644 --- a/scripts/package-linux.ps1 +++ b/scripts/package-linux.ps1 @@ -126,6 +126,9 @@ try { $shaderIncSrc = Join-Path $repoRoot 'sources/shaderincludes' $shaderIncDst = Join-Path $stageDir 'assets/game/shaderincludes' if (Test-Path $shaderIncSrc) { + # The vanilla tree may not have this directory - Copy-Item into a missing + # destination writes a file named after it instead of the includes. + New-Item -ItemType Directory -Force -Path $shaderIncDst | Out-Null Get-ChildItem $shaderIncSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderIncDst } } diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 9847f50d..b74865b0 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -337,6 +337,9 @@ fi SHADER_INC_SRC="$REPO_ROOT/sources/shaderincludes" SHADER_INC_DST="$STAGE_DIR/assets/game/shaderincludes" if [[ -d "$SHADER_INC_SRC" ]]; then + # The vanilla tree may not have this directory at all - cp into a missing + # destination would drop the includes silently. + mkdir -p "$SHADER_INC_DST" find "$SHADER_INC_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADER_INC_DST/" \; fi diff --git a/scripts/package-macos.ps1 b/scripts/package-macos.ps1 index 39f7da0c..f793f0d0 100644 --- a/scripts/package-macos.ps1 +++ b/scripts/package-macos.ps1 @@ -131,6 +131,9 @@ try { $shaderIncSrc = Join-Path $repoRoot 'sources/shaderincludes' $shaderIncDst = Join-Path $appDir 'assets/game/shaderincludes' if (Test-Path $shaderIncSrc) { + # The vanilla tree may not have this directory - Copy-Item into a missing + # destination writes a file named after it instead of the includes. + New-Item -ItemType Directory -Force -Path $shaderIncDst | Out-Null Get-ChildItem $shaderIncSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderIncDst } } diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index ab33927b..d9631398 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -207,6 +207,9 @@ fi SHADER_INC_SRC="$REPO_ROOT/sources/shaderincludes" SHADER_INC_DST="$APP_DIR/assets/game/shaderincludes" if [[ -d "$SHADER_INC_SRC" ]]; then + # The vanilla tree may not have this directory at all - cp into a missing + # destination would drop the includes silently. + mkdir -p "$SHADER_INC_DST" find "$SHADER_INC_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADER_INC_DST/" \; fi diff --git a/scripts/package.ps1 b/scripts/package.ps1 index d99f0262..3bfbcc39 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -349,6 +349,9 @@ try { $shaderIncSrc = Join-Path $repoRoot 'sources/shaderincludes' $shaderIncDst = Join-Path $stageDir 'assets/game/shaderincludes' if (Test-Path $shaderIncSrc) { + # The vanilla tree may not have this directory - Copy-Item into a missing + # destination writes a file named after it instead of the includes. + New-Item -ItemType Directory -Force -Path $shaderIncDst | Out-Null Get-ChildItem $shaderIncSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shaderIncDst } } @@ -418,7 +421,8 @@ try { '.optimum/donors/VSSurvivalMod.Donor.dll', '.optimum/vanilla/Mods/VSEssentials.dll', '.optimum/vanilla/Mods/VSSurvivalMod.dll', - '.optimum/standalone-install' + '.optimum/standalone-install', + 'assets/game/shaderincludes/vertexwarp.vsh' )) { if (-not (Test-Path (Join-Path $stageDir $requiredStageFile))) { throw "Required package file not found: $requiredStageFile" From 1e0fd051e56bd3f32813f0d587dd3267135e90b4 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 23:23:24 +0200 Subject: [PATCH 039/226] fix(review): make the attachment-preservation check an observation, anchor the renderer-fallback and preprocessed-shader assertions, and read TAA sources from sources/ --- .../AttachmentSemanticsTests.cs | 31 +++++++++++++------ .../ShaderTranslationTests.cs | 21 +++++++++++-- .../api-patcher-type-forward-tests.cs | 2 +- .../taa-entity-motion-coverage-tests.cs | 6 ++-- .../taa-instanced-motion-coverage-tests.cs | 2 +- .../taa-standard-motion-coverage-tests.cs | 2 +- .../vulkan-backend-integration-tests.cs | 12 +++++-- 7 files changed, 55 insertions(+), 21 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs index 70a6f79d..8eddf37d 100644 --- a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Runtime.InteropServices; using Optimum.Render.Vulkan.Core; using Optimum.Render.Vulkan.Shaders; @@ -125,12 +126,12 @@ void main(void) /// shader statically writes only location 0. The Vulkan spec leaves the /// unwritten locations' contents undefined rather than promising they are /// preserved, so this documents what this driver actually does rather than - /// asserting a guarantee the TAA design may not lean on: measured on this - /// device, an attachment the shader never writes keeps its prior contents, - /// the same as if it had been masked out of glDrawBuffers. + /// asserting a guarantee the TAA design may not lean on. The unwritten + /// attachments are still read back, but only to log whether they were + /// preserved; the run fails only on validation errors. /// [SkippableFact] - public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContentsOnThisDriver() + public unsafe void UnwrittenButEnabledAttachmentContentsAreObservedNotAsserted() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); @@ -173,17 +174,27 @@ public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContentsOnThisDriver() Assert.Equal(255, color[0]); Assert.Equal(0, color[2]); - // Observed reality on this driver, not a Vulkan guarantee: locations - // the shader never wrote came through unchanged, exactly like the - // masked-out case above. The TAA resolve pass must not be written to - // depend on this - it has to name every attachment it touches in - // both the shader and the draw-buffer mask, as the tests above do. + // Locations that are enabled in the draw-buffer mask but never + // written by the fragment shader hold undefined contents per the + // Vulkan spec, so this is an observation and not an assertion: a + // conforming driver is free to leave anything there. It is recorded + // so the behaviour of the machine the suite runs on is visible in + // the log. The TAA resolve pass must not depend on it either way - + // it has to name every attachment it touches in both the shader and + // the draw-buffer mask, as the tests above do. + bool preserved = true; for (int i = 1; i <= 4; i++) { byte[] untouched = ReadTexture(context!, commands, textures, attachment[i], size); - Assert.All(untouched, b => Assert.Equal(seeds[i], b)); + bool attachmentPreserved = untouched.All(b => b == seeds[i]); + preserved &= attachmentPreserved; + _output.WriteLine( + $"attachment {i}: seed 0x{seeds[i]:X2}, first byte 0x{untouched[0]:X2}, " + + $"preserved={attachmentPreserved}"); } + _output.WriteLine($"unwritten-but-enabled attachments preserved on this driver: {preserved}"); + ValidationAssert.NoErrors(messages); } } diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs index 698a2788..8b6c029e 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs @@ -203,16 +203,33 @@ public void TheEntityMotionWriterSurvivesThePreprocessorInTheOpaqueConfiguration var stages = ShaderCorpus.BuildProgram("entityanimated", files, includes, variant); - string vertex = stages.Single(s => s.Stage == EnumShaderType.VertexShader).Code; + using var compiler = new ShaderCompiler(); + + // The raw Code still carries every #if branch, so asserting on it would + // pass even when TAAMOTION or USEOIT compile the writer out. Only the + // preprocessed text says what the compiler actually sees. + string vertex = Preprocess(compiler, stages, EnumShaderType.VertexShader); Assert.Contains("PrevElementTransforms", vertex); Assert.Contains("previousWarpState()", vertex); Assert.Contains("applyVertexWarpingState", vertex); - string fragment = stages.Single(s => s.Stage == EnumShaderType.FragmentShader).Code; + string fragment = Preprocess(compiler, stages, EnumShaderType.FragmentShader); Assert.Contains("outMotion", fragment); Assert.Contains("gl_FragCoord.z + depthOffset", fragment); } + /// Runs one stage through the real preprocessor and returns its text. + private static string Preprocess( + ShaderCompiler compiler, IReadOnlyList stages, EnumShaderType stage) + { + ShaderStageSource source = stages.Single(s => s.Stage == stage); + ShaderCompileResult result = + compiler.Preprocess(source.Code, source.PrefixCode, source.Filename, source.Stage); + + Assert.True(result.Success, $"{stage}: {result.Error}"); + return result.PreprocessedText; + } + [SkippableFact] public void TranslatedProgramsProduceValidSpirvForEveryStage() { diff --git a/Optimum.Tests/api-patcher-type-forward-tests.cs b/Optimum.Tests/api-patcher-type-forward-tests.cs index d8263ef3..58b885df 100644 --- a/Optimum.Tests/api-patcher-type-forward-tests.cs +++ b/Optimum.Tests/api-patcher-type-forward-tests.cs @@ -39,7 +39,7 @@ public void ContractsProject_IncludesCoreManagedTypes() [Fact] public void ForkProject_ExcludesContractsTypes() { - string fork = Read("VintagestoryApi/VintagestoryAPI.csproj"); + string fork = Read("sources/VintagestoryApi/VintagestoryAPI.csproj"); // The fork excludes types that live in contracts to avoid CS0433. Assert.Contains(" BeginHook;", frame); Assert.Contains("public static Action EndHook;", frame); diff --git a/Optimum.Tests/taa-instanced-motion-coverage-tests.cs b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs index 66e1edea..438d32c7 100644 --- a/Optimum.Tests/taa-instanced-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs @@ -81,7 +81,7 @@ public void TheInstancedFragmentShaderWritesTheMotionAttachment() [Fact] public void TheFrameContractKeepsPerInstanceHistoryKeyedOnTheDevice() { - string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + string frame = Read("sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); string vertex = Read("sources/shaders/instanced.vsh"); Assert.Contains("public static class OptimumInstanceMotion", frame); diff --git a/Optimum.Tests/taa-standard-motion-coverage-tests.cs b/Optimum.Tests/taa-standard-motion-coverage-tests.cs index b6dd3f4f..df429389 100644 --- a/Optimum.Tests/taa-standard-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-standard-motion-coverage-tests.cs @@ -94,7 +94,7 @@ public void TheStandardFragmentShaderWritesTheMotionAttachmentWithItsOwnDepth() [Fact] public void TheFrameContractKeepsPerObjectHistoryForStandardShaderDraws() { - string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + string frame = Read("sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); string vertex = Read("sources/shaders/standard.vsh"); string fragment = Read("sources/shaders/standard.fsh"); diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 697ddb5b..a24d49b3 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using Xunit; namespace Optimum.Tests; @@ -125,9 +126,14 @@ public void OpenGlRemainsTheDefaultRenderer() Assert.Contains("public static string Renderer = \"opengl\";", config); Assert.Contains("public string Renderer { get; set; } = \"opengl\";", config); - // Anything the normaliser does not recognise falls back to opengl. - Assert.Contains("StringComparison.OrdinalIgnoreCase) ? \"auto\" :", config); - Assert.Contains("\"opengl\";", config); + // Anything the normaliser does not recognise falls back to opengl. The + // assertion is anchored to the tail of the normaliser's ternary chain - + // a bare "opengl" would already be satisfied by the field declarations + // above and could not detect the fallback branch being dropped. + string normalised = Regex.Replace(config, @"\s+", " "); + Assert.Contains( + "string.Equals(requestedRenderer, \"auto\", StringComparison.OrdinalIgnoreCase) ? \"auto\" : \"opengl\";", + normalised); } /// From f7696d23d5aaf0997821197a429fb483acbc6dfd Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 23:25:49 +0200 Subject: [PATCH 040/226] fix(review): dedupe shared depth deletes, size R16f dumps, align TAA debug tolerance CodeRabbit findings 8, 9 and 13. - ClientPlatformWindows.DisposeFrameBuffers: SetupOptimumFrameBuffers gives Transparent Primary's depth texture, so both FrameBufferRefs carried the same handle and it was deleted twice - a double free on the device path and a double tick of VulkanStats. Both branches now delete each texture handle once, via a HashSet. VulkanDevice.ReleaseTexture also only counts a delete that actually found a texture. - TextureDump: one BytesPerTexel helper now serves both the readback size and the decode switch, with an R16Sfloat branch (2 bytes, read as System.Half, converted like R32Sfloat). R16f previously fell back to 4 bytes and every row decoded from the wrong offset. - taa-debug.fsh: the validity view used a fixed 1e-4 depth tolerance while taa-resolve.fsh accepts max(2e-4, 8e-4 * depth), so it painted red where the resolve reprojects happily. Both now use the same expression. Verified: dotnet build VintageStory.slnx -c Release; dotnet test Optimum.Render.Vulkan.Tests --filter "TextureDump|VulkanDeviceIntegration" (27 passed, on a real GPU); dotnet test Optimum.Tests -c Release (911 passed); scripts/extract-patches.sh and scripts/check-patches.sh clean. --- .../TextureDumpTests.cs | 72 +++++++++++ .../VulkanDeviceIntegrationTests.cs | 34 +++++ Optimum.Render.Vulkan/Core/TextureDump.cs | 45 +++++-- Optimum.Render.Vulkan/Core/VulkanStats.cs | 3 + Optimum.Render.Vulkan/VulkanDevice.cs | 17 +-- Optimum.Tests/taa-pipeline-coverage-tests.cs | 53 ++++++++ .../ClientPlatformWindows.cs.patch | 122 +++++++++++------- sources/shaders/taa-debug.fsh | 5 +- 8 files changed, 285 insertions(+), 66 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs b/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs index 18c22cbf..d4aa655f 100644 --- a/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs +++ b/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs @@ -81,4 +81,76 @@ public void WritesRgba16FloatTextureAsPpm() } } } + + /// + /// R16f (the OIT revealage attachment's format) is two bytes per texel. It + /// used to fall through to the 4-byte default, so the readback was sized + /// twice as large as the image and every row was decoded from the wrong + /// offset. Known values in, known greys out. + /// + [Fact] + public void WritesR16FloatTextureAsPpm() + { + const int width = 4; + const int height = 2; + + Assert.Equal(2, TextureDump.BytesPerTexel(Format.R16Sfloat)); + + string directory = Path.Combine(Path.GetTempPath(), "optimum-texture-dump-tests-" + Guid.NewGuid()); + string? previousDir = Environment.GetEnvironmentVariable("OPTIMUM_DUMP_DIR"); + string? previousTrace = Environment.GetEnvironmentVariable("OPTIMUM_RENDER_TRACE"); + try + { + Environment.SetEnvironmentVariable("OPTIMUM_DUMP_DIR", directory); + Environment.SetEnvironmentVariable("OPTIMUM_RENDER_TRACE", null); + + // Motion-like mapping: value / 64 * 127 + 128, clamped to [0,255]. + float[] values = { 0f, 16f, -32f, 64f, -64f, 8f, -8f, 32f }; + byte[] expected = new byte[values.Length]; + var texels = new Half[width * height]; + for (int i = 0; i < texels.Length; i++) + { + texels[i] = (Half)values[i]; + expected[i] = (byte)Math.Clamp(values[i] / 64f * 127f + 128f, 0f, 255f); + } + byte[] data = MemoryMarshal.AsBytes(texels).ToArray(); + Assert.Equal(width * height * 2, data.Length); + + bool written = TextureDump.Write( + textureId: 4321, + width: width, + height: height, + bgra: false, + format: Format.R16Sfloat, + data: data); + + Assert.True(written); + + string path = Directory.GetFiles(directory, "*-texture-4321-*.ppm").SingleOrDefault() + ?? throw new Xunit.Sdk.XunitException("No dump file was written."); + + byte[] file = File.ReadAllBytes(path); + string header = $"P6\n{width} {height}\n255\n"; + Assert.Equal(header, System.Text.Encoding.ASCII.GetString(file, 0, header.Length)); + Assert.Equal(header.Length + width * height * 3, file.Length); + + for (int i = 0; i < texels.Length; i++) + { + int pixel = header.Length + i * 3; + // Greyscale: all three channels carry the same converted value. + Assert.Equal(expected[i], file[pixel]); + Assert.Equal(expected[i], file[pixel + 1]); + Assert.Equal(expected[i], file[pixel + 2]); + } + } + finally + { + Environment.SetEnvironmentVariable("OPTIMUM_DUMP_DIR", previousDir); + Environment.SetEnvironmentVariable("OPTIMUM_RENDER_TRACE", previousTrace); + if (Directory.Exists(directory)) + { + try { Directory.Delete(directory, recursive: true); } catch (IOException) { } + } + } + } } diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index d1d8b37c..8d61d593 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -1644,6 +1644,40 @@ public unsafe void AMeshUpdateWritesEachPartAtItsOwnDestinationOffset() } } + /// + /// Primary and Transparent share one depth texture, so DisposeFrameBuffers + /// used to hand the same id to DeleteTexture twice. The second delete must + /// be a no-op: no validation error, and no second tick of the deleted + /// counter, which otherwise reported more textures freed than ever existed. + /// + [SkippableFact] + public void DeletingTheSameTextureTwiceCountsAndFreesItOnce() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 8; + + int texture = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + long before = Optimum.Render.Vulkan.Core.VulkanStats.TexturesDeleted; + seam.DeleteTexture(texture); + long afterFirst = Optimum.Render.Vulkan.Core.VulkanStats.TexturesDeleted; + seam.DeleteTexture(texture); + long afterSecond = Optimum.Render.Vulkan.Core.VulkanStats.TexturesDeleted; + + Assert.Equal(before + 1, afterFirst); + Assert.Equal(afterFirst, afterSecond); + + // The device stays usable, and the layers saw nothing wrong. + seam.BeginFrame(); + seam.Present(); + AssertClean(seam); + } + } + private static void AssertClean(IOptimumGraphicsDevice device) { string? diagnostics = device.GetError(); diff --git a/Optimum.Render.Vulkan/Core/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs index 76201588..6a2ff4a3 100644 --- a/Optimum.Render.Vulkan/Core/TextureDump.cs +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -155,7 +155,8 @@ public static int[] Take() /// normalisation: /// - Colour-shaped float data (R16G16B16A16Sfloat) is clamped to [0,1] and /// scaled to a byte, same as any other colour channel. - /// - Single-channel float data (R32Sfloat) is treated as motion-like and + /// - Single-channel float data (R32Sfloat and R16Sfloat, the latter read as + /// System.Half) is treated as motion-like and /// mapped from [-64,64] pixels to [0,255], with 128 standing for zero /// displacement - there is no separate "depth" convention to distinguish /// it from motion at this format, so callers dumping true depth should @@ -167,13 +168,7 @@ public static bool Write(int textureId, int width, int height, bool bgra, Format { if (width <= 0 || height <= 0) return false; - int bytesPerPixel = format switch - { - Format.R16G16B16A16Sfloat => 8, - Format.R32Sfloat => 4, - Format.R8Unorm or Format.R8Uint or Format.R8Srgb => 1, - _ => 4, - }; + int bytesPerPixel = BytesPerTexel(format); if (data.Length < width * height * bytesPerPixel) return false; try @@ -211,6 +206,23 @@ public static bool Write(int textureId, int width, int height, bool bgra, Format } break; } + case Format.R16Sfloat: + { + var halves = MemoryMarshal.Cast(data); + for (int y = 0; y < height; y++) + { + var source = halves.Slice(y * width, width); + for (int x = 0; x < width; x++) + { + byte value = MotionByte((float)source[x]); + row[x * 3] = value; + row[x * 3 + 1] = value; + row[x * 3 + 2] = value; + } + writer.Write(row); + } + break; + } case Format.R32Sfloat: { var floats = MemoryMarshal.Cast(data); @@ -275,6 +287,23 @@ public static bool Write(int textureId, int width, int height, bool bgra, Format } } + /// + /// Bytes per texel for the formats the dump path is expected to see. One + /// table serves both the size check and the decode switch, so a format can + /// never be sized one way and read another; R16Sfloat sized as 4 bytes made + /// every row of an R16f readback start on the wrong texel. Anything + /// unrecognised falls back to 4 (8-bit RGBA), the blanket assumption the + /// default decode branch makes. + /// + public static int BytesPerTexel(Format format) => format switch + { + Format.R16G16B16A16Sfloat => 8, + Format.R32Sfloat => 4, + Format.R16Sfloat => 2, + Format.R8Unorm or Format.R8Uint or Format.R8Srgb => 1, + _ => 4, + }; + /// Clamps [0,1] colour data to a byte. private static byte ColorByte(float value) => (byte)(Math.Clamp(value, 0f, 1f) * 255f); diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 9bc6f733..70a1ecdb 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -43,6 +43,9 @@ internal static class VulkanStats public static void NoteTextureDeleted() => Interlocked.Increment(ref _texturesDeleted); public static void NoteFrame() => Interlocked.Increment(ref _frames); + /// Textures deleted since the last . + public static long TexturesDeleted => Interlocked.Read(ref _texturesDeleted); + public static void NoteUpload(long elapsedTicks) { Interlocked.Increment(ref _uploads); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 1e04708f..a21eec0e 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -1304,7 +1304,10 @@ private void ReleaseTexture(int textureId) VulkanTexture? texture = _textures.Get(textureId); if (texture != null) _descriptors.Release(texture.Id); _textures.Delete(textureId, _frames); - VulkanStats.NoteTextureDeleted(); + // Only a delete that found something is a delete. Deleting an id twice + // (framebuffers share a depth texture) otherwise inflated the counter + // past the number of textures that ever existed. + if (texture != null) VulkanStats.NoteTextureDeleted(); } public void SetTextureParameter(int textureId, int parameterName, int value) => @@ -2589,16 +2592,10 @@ private void DumpRequestedTextures() /// /// Bytes per texel for the formats the dump path is expected to see. - /// Anything unrecognised falls back to 4 (8-bit RGBA), the previous - /// blanket assumption, rather than guessing wrong in either direction. + /// Shared with 's decode switch so the + /// readback size and the reader always agree on the stride. /// - private static int BytesPerPixel(Format format) => format switch - { - Format.R16G16B16A16Sfloat => 8, - Format.R32Sfloat => 4, - Format.R8Unorm or Format.R8Uint or Format.R8Srgb => 1, - _ => 4, - }; + private static int BytesPerPixel(Format format) => TextureDump.BytesPerTexel(format); public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) { diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index 8259529d..76c5ffda 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -274,6 +274,59 @@ public void TaaResolveShaderPairExistsAndReadsHistoryAndCurrentColour() Assert.NotEmpty(fsh); } + [Fact] + public void DisposeFrameBuffersDeletesTheSharedDepthTextureOnlyOnce() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // Transparent shares Primary's depth texture, so the same handle sits + // in two FrameBufferRefs and a naive loop deletes it twice - a double + // free on the device path and a double count in VulkanStats. + Assert.Contains("transparent.DepthTextureId = primary.DepthTextureId;", platform); + + int dispose = platform.IndexOf("public void DisposeFrameBuffers(", StringComparison.Ordinal); + Assert.True(dispose >= 0); + int end = platform.IndexOf("public override void ClearFrameBuffer(", dispose, StringComparison.Ordinal); + string body = end > dispose ? platform.Substring(dispose, end - dispose) : platform.Substring(dispose); + + Assert.Contains("HashSet deletedTextures = new HashSet();", body); + // Device path and GL path both gate every texture delete on the set. + Assert.Contains("if (deletedTextures.Add(buffers[k].DepthTextureId))", body); + Assert.Contains("if (deletedTextures.Add(buffers[i].DepthTextureId))", body); + Assert.Contains("if (deletedTextures.Add(buffers[k].ColorTextureIds[n]))", body); + Assert.Contains("if (deletedTextures.Add(buffers[i].ColorTextureIds[j]))", body); + // No unguarded delete is left behind on either path. + Assert.Equal(4, Count(body, "deletedTextures.Add(")); + Assert.Equal(1, Count(body, "optimumDevice.DeleteTexture(buffers[k].DepthTextureId);")); + Assert.Equal(1, Count(body, "GL.DeleteTexture(buffers[i].DepthTextureId);")); + } + + [Fact] + public void TaaDebugValidityUsesTheResolvePassDepthTolerance() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + string debug = Read("sources/shaders/taa-debug.fsh"); + + // The resolve pass accepts a writer whose recorded depth is within a + // value-scaled tolerance; the debug validity view has to use the same + // expression or it paints red where the resolve reprojects happily. + Assert.Contains("abs(motion.a - depth) <= max(2e-4, 8e-4 * depth)", resolve); + Assert.Contains("abs(motion.a - sceneDepth) <= max(2e-4, 8e-4 * sceneDepth)", debug); + Assert.Equal(Tolerance(resolve, "depth"), Tolerance(debug, "sceneDepth")); + Assert.DoesNotContain("abs(motion.a - sceneDepth) < 1e-4", debug); + } + + /// The depth-match tolerance expression, with the depth variable normalised. + private static string Tolerance(string shader, string depthName) + { + int start = shader.IndexOf("abs(motion.a - " + depthName + ")", StringComparison.Ordinal); + Assert.True(start >= 0); + int end = shader.IndexOf(')', shader.IndexOf("max(", start, StringComparison.Ordinal) + 4); + return shader.Substring(start, end - start + 1).Replace(depthName, "DEPTH"); + } + private static int Count(string source, string value) { int count = 0; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 488782e5..851b5c81 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..f519573 100644 +index 6edf0c9..87ede39 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -1302,7 +1302,7 @@ index 6edf0c9..f519573 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,12 +2553,89 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2553,115 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1369,6 +1369,11 @@ index 6edf0c9..f519573 100644 public void DisposeFrameBuffers(List buffers) { + // Mono.Cecil transplant. ++ // SetupOptimumFrameBuffers shares one depth texture between Primary and ++ // Transparent, so the same handle appears in more than one FrameBufferRef. ++ // Deleting it twice double-frees on the device path and makes ++ // VulkanStats.NoteTextureDeleted over-count, so every handle is deleted once. ++ HashSet deletedTextures = new HashSet(); + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) + { @@ -1377,10 +1382,16 @@ index 6edf0c9..f519573 100644 + if (buffers[k] != null) + { + optimumDevice.DeleteFramebuffer(buffers[k].FboId); -+ optimumDevice.DeleteTexture(buffers[k].DepthTextureId); ++ if (deletedTextures.Add(buffers[k].DepthTextureId)) ++ { ++ optimumDevice.DeleteTexture(buffers[k].DepthTextureId); ++ } + for (int n = 0; n < buffers[k].ColorTextureIds.Length; n++) + { -+ optimumDevice.DeleteTexture(buffers[k].ColorTextureIds[n]); ++ if (deletedTextures.Add(buffers[k].ColorTextureIds[n])) ++ { ++ optimumDevice.DeleteTexture(buffers[k].ColorTextureIds[n]); ++ } + } + buffers[k].Disposed = true; + } @@ -1392,7 +1403,24 @@ index 6edf0c9..f519573 100644 if (buffers[i] != null) { GL.DeleteFramebuffer(buffers[i].FboId); -@@ -1591,11 +2654,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +- GL.DeleteTexture(buffers[i].DepthTextureId); ++ if (deletedTextures.Add(buffers[i].DepthTextureId)) ++ { ++ GL.DeleteTexture(buffers[i].DepthTextureId); ++ } + for (int j = 0; j < buffers[i].ColorTextureIds.Length; j++) + { +- GL.DeleteTexture(buffers[i].ColorTextureIds[j]); ++ if (deletedTextures.Add(buffers[i].ColorTextureIds[j])) ++ { ++ GL.DeleteTexture(buffers[i].ColorTextureIds[j]); ++ } + } + buffers[i].Disposed = true; + } + } + } +@@ -1591,11 +2671,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1421,7 +1449,7 @@ index 6edf0c9..f519573 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,38 +2688,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,38 +2705,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -1557,7 +1585,7 @@ index 6edf0c9..f519573 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +2847,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +2864,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1598,7 +1626,7 @@ index 6edf0c9..f519573 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +2890,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +2907,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -1665,7 +1693,7 @@ index 6edf0c9..f519573 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +2960,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +2977,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1687,7 +1715,7 @@ index 6edf0c9..f519573 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +2984,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +3001,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1714,7 +1742,7 @@ index 6edf0c9..f519573 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3009,187 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3026,187 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1906,7 +1934,7 @@ index 6edf0c9..f519573 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3204,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3221,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1939,7 +1967,7 @@ index 6edf0c9..f519573 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3239,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3256,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2001,7 +2029,7 @@ index 6edf0c9..f519573 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3316,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3333,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2048,7 +2076,7 @@ index 6edf0c9..f519573 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3365,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3382,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2084,7 +2112,7 @@ index 6edf0c9..f519573 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3412,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3429,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2125,7 +2153,7 @@ index 6edf0c9..f519573 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2014,28 +3454,460 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2014,28 +3471,460 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0030: Unknown result type (might be due to invalid IL or missing references) //IL_0040: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Invalid comparison between Unknown and I4 @@ -2591,7 +2619,7 @@ index 6edf0c9..f519573 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3955,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3972,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2617,7 +2645,7 @@ index 6edf0c9..f519573 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3988,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +4005,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2639,7 +2667,7 @@ index 6edf0c9..f519573 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +4017,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +4034,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2737,7 +2765,7 @@ index 6edf0c9..f519573 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +4116,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +4133,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2940,7 +2968,7 @@ index 6edf0c9..f519573 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4325,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4342,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3107,7 +3135,7 @@ index 6edf0c9..f519573 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4495,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4512,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3131,7 +3159,7 @@ index 6edf0c9..f519573 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4524,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4541,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3169,7 +3197,7 @@ index 6edf0c9..f519573 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4584,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4601,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -3212,7 +3240,7 @@ index 6edf0c9..f519573 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4637,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4654,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3271,7 +3299,7 @@ index 6edf0c9..f519573 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4752,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4769,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3345,7 +3373,7 @@ index 6edf0c9..f519573 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4850,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4867,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3376,7 +3404,7 @@ index 6edf0c9..f519573 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4887,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4904,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3411,7 +3439,7 @@ index 6edf0c9..f519573 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4934,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4951,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3440,7 +3468,7 @@ index 6edf0c9..f519573 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4965,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4982,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3466,7 +3494,7 @@ index 6edf0c9..f519573 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +4992,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +5009,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3479,7 +3507,7 @@ index 6edf0c9..f519573 100644 return uBO; } -@@ -2605,10 +5006,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +5023,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3496,7 +3524,7 @@ index 6edf0c9..f519573 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +5058,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5075,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3541,7 +3569,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5095,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3562,7 +3590,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5114,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5131,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3583,7 +3611,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5133,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5150,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3604,7 +3632,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5152,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5169,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3625,7 +3653,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5175,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5192,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3646,7 +3674,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5218,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5235,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3672,7 +3700,7 @@ index 6edf0c9..f519573 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5460,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5477,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3694,7 +3722,7 @@ index 6edf0c9..f519573 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5658,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5675,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3717,7 +3745,7 @@ index 6edf0c9..f519573 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5732,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5749,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3766,7 +3794,7 @@ index 6edf0c9..f519573 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5802,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5819,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3798,7 +3826,7 @@ index 6edf0c9..f519573 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5832,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5849,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3824,7 +3852,7 @@ index 6edf0c9..f519573 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6180,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6197,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3854,7 +3882,7 @@ index 6edf0c9..f519573 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6234,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6251,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/sources/shaders/taa-debug.fsh b/sources/shaders/taa-debug.fsh index a07ea3d6..f730790a 100644 --- a/sources/shaders/taa-debug.fsh +++ b/sources/shaders/taa-debug.fsh @@ -45,7 +45,10 @@ void main(void) { outColor = vec4(0.0, 0.0, 0.0, 1.0); } - else if (abs(motion.a - sceneDepth) < 1e-4) + // Same tolerance as taa-resolve.fsh's `written` test: half precision on + // the RGBA16F alpha costs ~5e-4 near 1.0, so a fixed 1e-4 here reported + // mismatches for writers the resolve pass happily accepts. + else if (abs(motion.a - sceneDepth) <= max(2e-4, 8e-4 * sceneDepth)) { outColor = vec4(0.0, 1.0, 0.0, 1.0); } From 9e8164cd6582f743518a5b67225c54ceafdb251d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 10 Sep 2026 23:27:52 +0200 Subject: [PATCH 041/226] fix(review): sky reprojects as a direction, previous jitter and lost camera history in the frame record, runtime TAA disable restores FXAA, entity motion window per renderer PR #2 CodeRabbit + security F1. Verified: Optimum.Tests 915 passed, Optimum.Render.Vulkan.Tests 320 passed (new SkyDoesNotMoveUnderCameraTranslation), check-patches clean. --- Optimum.Patcher/Program.cs | 5 + .../TaaResolveTests.cs | 73 +++++++++++++ .../taa-entity-motion-coverage-tests.cs | 7 +- Optimum.Tests/taa-pipeline-coverage-tests.cs | 47 ++++++++ Optimum.Tests/temporal-frame-tests.cs | 47 ++++++++ .../ClientPlatformWindows.cs.patch | 101 +++++++++++------- .../SystemRenderEntities.cs.patch | 62 +++++++++-- .../Client/Render/OptimumTemporalFrame.cs | 26 ++++- .../VintagestoryApi/Config/OptimumConfig.cs | 16 +++ sources/shaders/taa-resolve.fsh | 8 +- 10 files changed, 337 insertions(+), 55 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 01060e26..b6d10d31 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -110,6 +110,9 @@ "PrepareOptimumEntityLights", "BeginOptimumEntityShaderSegment", "EndOptimumEntityShaderSegment", + // TAA review fix: per-renderer motion-window gate. + "optimumMotionWriterTypes", + "OptimumIsMotionWriter", }, ["Vintagestory.Client.NoObf.ClientChunk"] = new() { @@ -154,6 +157,8 @@ "CreateOptimumHistoryTarget", "CreateOptimumHistoryTargetGl", "DisableOptimumTaa", + "optimumTaaShaderReloadPending", + "OptimumRunPendingTaaShaderReload", "_taaFrameParity", "_taaHistoryValid", "taaResolvedColorTexture", diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index 87a72970..124b6ce2 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -328,6 +328,79 @@ public unsafe void AnOutlierHistoryValueIsClippedTowardTheNeighbourhood() } } + /// + /// Sky (depth == 1, nothing wrote the motion attachment) is a direction: a + /// camera translation must not move it. With a previous view-projection + /// whose clip w equals z (so directions project like points), a finite + /// reprojection would shift the history band by cameraDelta.x * Size / 2 = + /// 4 columns; the infinite-direction path keeps it where it is. + /// + [SkippableFact] + public unsafe void SkyDoesNotMoveUnderCameraTranslation() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new VulkanCommands(context!)) + using (var textures = new TextureManager(context!, commands)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + var inputs = CreateInputSet(textures); + UploadRgba16F(textures, inputs.SceneTex, (x, _) => (x % 2 == 0) ? 0.3f : 0.7f, + (x, _) => (x % 2 == 0) ? 0.3f : 0.7f, (x, _) => (x % 2 == 0) ? 0.3f : 0.7f, (_, _) => 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + // Sky everywhere, nothing wrote motion (a = 0). + UploadFlatR32F(textures, inputs.DepthTex, 1.0f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0f); + + const int stripeStart = 14, stripeWidth = 4; + const float background = 0.5f, stripe = 1.0f; + UploadRgba16F(textures, inputs.HistoryColor, + (x, _) => x is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (x, _) => x is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (x, _) => x is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (_, _) => 1f); + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + // linearDepth = -(viewMatrix * world).z = -1 for depth 1 under identity. + UploadFlatR32F(textures, inputs.HistoryDepth, -1.0f); + + // Identity, except clip.w = z so a w = 0 direction still divides. + float[] prevViewProj = (float[])Identity4.Clone(); + prevViewProj[11] = 1f; + prevViewProj[15] = 0f; + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + var uniforms = new TaaUniforms + { + ResetHistory = 0, + BlendAlpha = 0.05f, + PrevViewProj = prevViewProj, + CameraDelta = new[] { 0.25f, 0f, 0f }, + }; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + float stayed = AverageRed(colorBytes, stripeStart, stripeStart + stripeWidth); + float shifted = AverageRed(colorBytes, stripeStart - 4, stripeStart); + float control = AverageRed(colorBytes, 24, 28); + _output.WriteLine($"band in place={stayed}, band shifted by translation={shifted}, control={control}"); + + Assert.True(stayed > control + 0.1f, $"sky history should stay in place (avg {stayed} vs control {control})"); + Assert.True(shifted < control + 0.05f, $"camera translation must not move the sky (shifted window avg {shifted} vs control {control})"); + + ValidationAssert.NoErrors(messages); + } + } + /// /// With non-zero jitter, the per-pixel Blackman-Harris reconstruction in /// taa-resolve.fsh (the filtered/filteredWeight loop) is diff --git a/Optimum.Tests/taa-entity-motion-coverage-tests.cs b/Optimum.Tests/taa-entity-motion-coverage-tests.cs index def2eb05..355e9adb 100644 --- a/Optimum.Tests/taa-entity-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-entity-motion-coverage-tests.cs @@ -264,9 +264,10 @@ public void EveryEntityPassOpensTheMotionDrawBufferWindow() "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs"); // Reuses the terrain stage's window rather than adding a second pair. - Assert.Contains( - "bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite();", - entities); + // Per-renderer window (review fix): opened only around the game's own + // EntityShapeRenderer family, closed before any foreign renderer draws. + Assert.Contains("OptimumIsMotionWriter(entityRenderer2.Value)", entities); + Assert.Contains("optimumMotionWrite = optimumPlatform.BeginMotionWrite();", entities); Assert.Contains("optimumPlatform.EndMotionWrite();", entities); // The mod-side renderers reach the same window through the API. diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index 8259529d..55ea4353 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -308,4 +308,51 @@ private static string Read(string relativePath) return null; } } + + [Fact] + public void ARuntimeTaaFailureFallsBackToFxaa() + { + string config = Read("sources/VintagestoryApi/Config/OptimumConfig.cs"); + Assert.Contains("!TaaRuntimeDisabled &&", config); + Assert.Contains("public static bool DisableTaaAtRuntime()", config); + + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + int disable = platform.IndexOf("public void DisableOptimumTaa(string reason)", StringComparison.Ordinal); + Assert.True(disable > 0); + Assert.Contains("OptimumConfig.DisableTaaAtRuntime()", platform.Substring(disable, 1200)); + // The reload happens outside frame buffer setup, at the resolve decision. + int resolve = platform.IndexOf("private bool RenderOptimumTaaResolve()", StringComparison.Ordinal); + Assert.Contains("OptimumRunPendingTaaShaderReload();", platform.Substring(resolve, 400)); + Assert.Contains("ShaderRegistry.ReloadShaders();", platform); + + // Later rebuilds read EffectiveTaa, which now honours the runtime flag. + string registry = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + Assert.Contains("!OptimumConfig.EffectiveTaa ? 1 : 0", registry); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"optimumTaaShaderReloadPending\"", patcher); + Assert.Contains("\"OptimumRunPendingTaaShaderReload\"", patcher); + } + + [Fact] + public void TheEntityMotionWindowOnlyWrapsTheGamesOwnEntityRenderers() + { + string entities = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs"); + Assert.Contains("OptimumIsMotionWriter(entityRenderer2.Value)", entities); + Assert.Contains("StartsWith(\"Vintagestory.GameContent\"", entities); + // No whole-loop window any more. + Assert.DoesNotContain("optimumPlatform != null && optimumPlatform.BeginMotionWrite();", entities); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"OptimumIsMotionWriter\"", patcher); + Assert.Contains("\"optimumMotionWriterTypes\"", patcher); + } + + [Fact] + public void SkyPixelsReprojectAsDirections() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + Assert.Contains("bool sky = depth >= 0.999999;", resolve); + Assert.Contains("prevViewProj * vec4(world, 0.0)", resolve); + } } diff --git a/Optimum.Tests/temporal-frame-tests.cs b/Optimum.Tests/temporal-frame-tests.cs index 8b72e92c..6aee8324 100644 --- a/Optimum.Tests/temporal-frame-tests.cs +++ b/Optimum.Tests/temporal-frame-tests.cs @@ -473,4 +473,51 @@ private static double[] Diagonal(double value) m[15] = value; return m; } + + [Fact] + public void PreviousJitterIsTheJitterTheLastFrameRenderedWith() + { + var frame = NewFrame(); + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, Uniforms()); + frame.JitterActive = true; + float jx = frame.JitterPx.X, jy = frame.JitterPx.Y; + Assert.False(jx == 0f && jy == 0f); + // The window closes at the end of the frame, zeroing JitterPx. + frame.JitterActive = false; + Assert.Equal(0f, frame.JitterPx.X); + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, Uniforms()); + Assert.Equal(jx, frame.PrevJitterPx.X); + Assert.Equal(jy, frame.PrevJitterPx.Y); + } + + [Fact] + public void AFrameWithoutACameraCaptureDropsTheHistoryOnTheNextAdvance() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, uniforms); + frame.CaptureCameraPosition(new Vec3d(1, 2, 3), uniforms); + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, uniforms); + frame.CaptureCameraPosition(new Vec3d(1, 2, 3.5), uniforms); + Assert.False(frame.Reset); + // This frame never captures. + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, uniforms); + Assert.False(frame.Reset); + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, uniforms); + Assert.True(frame.Reset); + Assert.Equal(EnumTemporalResetReason.CameraHistoryLost, frame.ResetReason); + } + + [Fact] + public void CapturingEveryFrameNeverDropsTheHistory() + { + var frame = NewFrame(); + var uniforms = Uniforms(); + for (int i = 0; i < 5; i++) + { + frame.Advance(16f, Width, Height, 1f, 0.1f, 1000f, 70f, uniforms); + frame.CaptureCameraPosition(new Vec3d(i * 0.1, 0, 0), uniforms); + Assert.False(frame.Reset && frame.ResetReason == EnumTemporalResetReason.CameraHistoryLost); + } + } } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 488782e5..cfe69d9c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..f519573 100644 +index 6edf0c9..772e6e5 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -1714,7 +1714,7 @@ index 6edf0c9..f519573 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3009,187 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3009,188 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1817,6 +1817,7 @@ index 6edf0c9..f519573 100644 + /// + private bool RenderOptimumTaaResolve() + { ++ OptimumRunPendingTaaShaderReload(); + TaaResolvedThisFrame = false; + if (!TaaTargetsReady || !Vintagestory.API.Config.OptimumConfig.EffectiveTaa) + { @@ -1906,7 +1907,7 @@ index 6edf0c9..f519573 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3204,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3205,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1939,7 +1940,7 @@ index 6edf0c9..f519573 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3239,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3240,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2001,7 +2002,7 @@ index 6edf0c9..f519573 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3316,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3317,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2048,7 +2049,7 @@ index 6edf0c9..f519573 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3365,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3366,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2084,7 +2085,7 @@ index 6edf0c9..f519573 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3412,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3413,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2125,7 +2126,7 @@ index 6edf0c9..f519573 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2014,28 +3454,460 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2014,28 +3455,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0030: Unknown result type (might be due to invalid IL or missing references) //IL_0040: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Invalid comparison between Unknown and I4 @@ -2168,6 +2169,30 @@ index 6edf0c9..f519573 100644 + optimumTaaDisabled = true; + TaaTargetsReady = false; + MotionAttachmentIndex = -1; ++ // final.fsh is compiled with FXAA 0 whenever EffectiveTaa is true, so ++ // without this the fallback path has no anti-aliasing at all. The ++ // runtime flag makes EffectiveTaa false for the rest of the session ++ // (later frame buffer rebuilds stop retrying the failed allocation too) ++ // and the reload recompiles the shaders that read it. ++ if (Vintagestory.API.Config.OptimumConfig.DisableTaaAtRuntime()) ++ { ++ optimumTaaShaderReloadPending = true; ++ } ++ } ++ ++ /// ++ /// Set by ; consumed at the top of the next ++ /// frame's post-processing, outside any frame buffer setup, because a shader ++ /// reload from inside SetupDefaultFrameBuffers would recurse into it. ++ /// ++ private bool optimumTaaShaderReloadPending; ++ ++ private void OptimumRunPendingTaaShaderReload() ++ { ++ if (!optimumTaaShaderReloadPending) return; ++ optimumTaaShaderReloadPending = false; ++ ShaderRegistry.ReloadShaders(); ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.Toggle); + } + + /// @@ -2591,7 +2616,7 @@ index 6edf0c9..f519573 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3955,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +3980,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2617,7 +2642,7 @@ index 6edf0c9..f519573 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +3988,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +4013,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2639,7 +2664,7 @@ index 6edf0c9..f519573 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +4017,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +4042,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2737,7 +2762,7 @@ index 6edf0c9..f519573 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +4116,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +4141,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2940,7 +2965,7 @@ index 6edf0c9..f519573 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4325,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4350,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3107,7 +3132,7 @@ index 6edf0c9..f519573 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4495,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4520,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3131,7 +3156,7 @@ index 6edf0c9..f519573 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4524,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4549,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3169,7 +3194,7 @@ index 6edf0c9..f519573 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4584,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4609,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -3212,7 +3237,7 @@ index 6edf0c9..f519573 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4637,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4662,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3271,7 +3296,7 @@ index 6edf0c9..f519573 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4752,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4777,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3345,7 +3370,7 @@ index 6edf0c9..f519573 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4850,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4875,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3376,7 +3401,7 @@ index 6edf0c9..f519573 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4887,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4912,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3411,7 +3436,7 @@ index 6edf0c9..f519573 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4934,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4959,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3440,7 +3465,7 @@ index 6edf0c9..f519573 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +4965,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4990,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3466,7 +3491,7 @@ index 6edf0c9..f519573 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +4992,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +5017,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3479,7 +3504,7 @@ index 6edf0c9..f519573 100644 return uBO; } -@@ -2605,10 +5006,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +5031,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3496,7 +3521,7 @@ index 6edf0c9..f519573 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +5058,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5083,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3541,7 +3566,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5095,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5120,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3562,7 +3587,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5114,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5139,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3583,7 +3608,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5133,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5158,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3604,7 +3629,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5152,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5177,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3625,7 +3650,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5175,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5200,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3646,7 +3671,7 @@ index 6edf0c9..f519573 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5218,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5243,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3672,7 +3697,7 @@ index 6edf0c9..f519573 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5460,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5485,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3694,7 +3719,7 @@ index 6edf0c9..f519573 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5658,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5683,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3717,7 +3742,7 @@ index 6edf0c9..f519573 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5732,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5757,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3766,7 +3791,7 @@ index 6edf0c9..f519573 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5802,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5827,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3798,7 +3823,7 @@ index 6edf0c9..f519573 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5832,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5857,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3824,7 +3849,7 @@ index 6edf0c9..f519573 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6180,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6205,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3854,7 +3879,7 @@ index 6edf0c9..f519573 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6234,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6259,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch index 1799b086..31af9f48 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs -index 374f827..9d3cbfe 100644 +index 374f827..4c56324 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs @@ -1,15 +1,36 @@ @@ -281,7 +281,7 @@ index 374f827..9d3cbfe 100644 public void OnRenderOpaque3D(float deltaTime) { RuntimeStats.renderedEntities = 0; -@@ -82,15 +303,57 @@ public class SystemRenderEntities : ClientSystem +@@ -82,15 +303,71 @@ public class SystemRenderEntities : ClientSystem game.Platform.GlDisableCullFace(); game.GlMatrixModeModelView(); game.GlPushMatrix(); @@ -296,15 +296,21 @@ index 374f827..9d3cbfe 100644 + // attachment keeps whatever was there and the resolve reprojects a pixel + // by a vector that belongs to another surface. A no-op when TAA is off. + ClientPlatformWindows optimumPlatform = game.Platform as ClientPlatformWindows; -+ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); ++ // The window is opened per renderer, only around the game's own ++ // EntityShapeRenderer family (the entityanimated writers). A mod ++ // renderer with its own shader stays outside it, so it cannot leave ++ // another surface's vector standing under its pixels. ++ bool optimumMotionWrite = false; + OptimumEntityShaderState.End(); + bool shaderStateCacheEnabled = OptimumConfig.EffectiveEntityShaderStateCache && !optimumEntityShaderCacheDisabled; + bool shaderSegmentActive = false; + bool shaderSegmentUnavailable = false; + try -+ { + { +- if (entityRenderer2.Value.entity.IsRendered) + foreach (KeyValuePair entityRenderer2 in game.EntityRenderers) -+ { + { +- entityRenderer2.Value.DoRender3DOpaqueBatched(deltaTime, isShadowPass: false); + if (entityRenderer2.Value.entity.IsRendered) + { + bool supportsShaderStateCache = shaderStateCacheEnabled && !shaderSegmentUnavailable && entityRenderer2.Value is IOptimumEntityShaderRenderer shaderRenderer && shaderRenderer.OptimumShaderStateCompatible; @@ -318,20 +324,28 @@ index 374f827..9d3cbfe 100644 + EndOptimumEntityShaderSegment(); + shaderSegmentActive = false; + } ++ bool optimumWritesMotion = optimumPlatform != null && OptimumIsMotionWriter(entityRenderer2.Value); ++ if (optimumWritesMotion && !optimumMotionWrite) ++ { ++ optimumMotionWrite = optimumPlatform.BeginMotionWrite(); ++ } ++ else if (!optimumWritesMotion && optimumMotionWrite) ++ { ++ optimumPlatform.EndMotionWrite(); ++ optimumMotionWrite = false; ++ } + entityRenderer2.Value.DoRender3DOpaqueBatched(deltaTime, isShadowPass: false); + } + } + } + finally - { -- if (entityRenderer2.Value.entity.IsRendered) ++ { + if (shaderSegmentActive) + { + EndOptimumEntityShaderSegment(); + } + else - { -- entityRenderer2.Value.DoRender3DOpaqueBatched(deltaTime, isShadowPass: false); ++ { + OptimumEntityShaderState.End(); + } + if (optimumMotionWrite) @@ -342,7 +356,7 @@ index 374f827..9d3cbfe 100644 game.GlPopMatrix(); entityanimated.Stop(); ScreenManager.FrameProfiler.Mark("ree-op-b"); -@@ -127,10 +390,25 @@ public class SystemRenderEntities : ClientSystem +@@ -127,10 +404,25 @@ public class SystemRenderEntities : ClientSystem shaderProgramChunkshadowmap.Use(); } foreach (KeyValuePair entityRenderer in game.EntityRenderers) @@ -368,3 +382,31 @@ index 374f827..9d3cbfe 100644 entity.IsShadowRendered = true; entityRenderer.Value.DoRender3DOpaque(dt, isShadowPass: true); } +@@ -182,6 +474,27 @@ public class SystemRenderEntities : ClientSystem + + public override EnumClientSystemType GetSystemType() + { + return EnumClientSystemType.Render; + } ++ ++ private static readonly Dictionary optimumMotionWriterTypes = new Dictionary(); ++ ++ /// ++ /// Optimum TAA: whether a renderer's batched opaque draw goes through the ++ /// entityanimated motion writer. True for the game's own EntityShapeRenderer ++ /// family (Vintagestory.GameContent); a renderer from another assembly may ++ /// draw any shader, so it stays outside the motion window and the resolve ++ /// camera-reprojects its pixels. Cached per concrete type; render thread only. ++ /// ++ internal static bool OptimumIsMotionWriter(EntityRenderer renderer) ++ { ++ Type type = renderer.GetType(); ++ if (!optimumMotionWriterTypes.TryGetValue(type, out bool writes)) ++ { ++ writes = type.Namespace != null && type.Namespace.StartsWith("Vintagestory.GameContent", StringComparison.Ordinal) ++ && type.Assembly.GetName().Name == "VSEssentials"; ++ optimumMotionWriterTypes[type] = writes; ++ } ++ return writes; ++ } + } diff --git a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs index 656b1666..28e897e5 100644 --- a/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs +++ b/sources/VintagestoryApi/Client/Render/OptimumTemporalFrame.cs @@ -25,7 +25,10 @@ public enum EnumTemporalResetReason FovChange, RenderScale, Toggle, - Screenshot + Screenshot, + /// A frame advanced without : + /// the next capture would span two frames, so the history is dropped instead. + CameraHistoryLost } /// @@ -198,6 +201,8 @@ public sealed class OptimumTemporalFrame : IOptimumTemporalContext private EnumTemporalResetReason pendingReset; private bool jitterActive; + private readonly Vec2f appliedJitterPx = new Vec2f(); + private bool cameraCapturedThisFrame; public OptimumTemporalFrame() { @@ -228,6 +233,7 @@ public bool JitterActive jitterActive = value; JitterPx.X = value ? JitterSequencePx.X : 0f; JitterPx.Y = value ? JitterSequencePx.Y : 0f; + if (value) { appliedJitterPx.X = JitterPx.X; appliedJitterPx.Y = JitterPx.Y; } } } @@ -302,8 +308,20 @@ public void Advance( DefaultShaderUniforms uniforms) { // --- rotate current -> previous ------------------------------------- - PrevJitterPx.X = JitterPx.X; - PrevJitterPx.Y = JitterPx.Y; + // The jitter the previous frame really rendered with. JitterPx is + // zeroed when the jitter window closes, so it cannot be used here. + PrevJitterPx.X = appliedJitterPx.X; + PrevJitterPx.Y = appliedJitterPx.Y; + appliedJitterPx.X = 0f; + appliedJitterPx.Y = 0f; + // A frame that advanced without a camera capture leaves cameraPos + // one frame stale: the next capture would difference across two + // frames while the history was rendered with a zero delta. + if (FrameIndex > 0 && hasCameraPos && !cameraCapturedThisFrame) + { + RequestReset(EnumTemporalResetReason.CameraHistoryLost); + } + cameraCapturedThisFrame = false; for (int i = 0; i < ViewCount; i++) { Array.Copy(projection[i], projectionPrev[i], 16); @@ -363,6 +381,7 @@ public void Advance( JitterSequencePx.Y = (float)jy; JitterPx.X = jitterActive ? JitterSequencePx.X : 0f; JitterPx.Y = jitterActive ? JitterSequencePx.Y : 0f; + if (jitterActive) { appliedJitterPx.X = JitterPx.X; appliedJitterPx.Y = JitterPx.Y; } } /// @@ -390,6 +409,7 @@ public void Advance( public void CaptureCameraPosition(Vec3d cameraPosIn, DefaultShaderUniforms uniforms) { EnumTemporalResetReason reason = ResetReason; + cameraCapturedThisFrame = true; if (uniforms != null && uniforms.PlayerPos != null) { diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index 5551bd5e..72b1ac4d 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -502,8 +502,24 @@ public static class OptimumConfig // missing launcher scan must not disable it (IsShaderFeatureDisabled reports // everything disabled without a scan), only an explicit scan verdict does. public static bool EffectiveTaa => Taa && + !TaaRuntimeDisabled && !IsFeatureExplicitlyDisabled("Taa"); + /// + /// Set by the platform when TAA's frame buffers or resolve shader could not + /// be created; TAA stays off for the rest of the session and the shaders + /// that compile against (final.fsh's FXAA branch) + /// are rebuilt so the FXAA fallback really runs. + /// + public static bool TaaRuntimeDisabled { get; private set; } + + public static bool DisableTaaAtRuntime() + { + if (TaaRuntimeDisabled) return false; + TaaRuntimeDisabled = true; + return true; + } + public static bool EffectiveEntityLightBatch => EntityLightBatchEnabled && !IsShaderFeatureDisabled("EntityLightBatch"); diff --git a/sources/shaders/taa-resolve.fsh b/sources/shaders/taa-resolve.fsh index eb9cb8fb..3c261fc2 100644 --- a/sources/shaders/taa-resolve.fsh +++ b/sources/shaders/taa-resolve.fsh @@ -152,7 +152,13 @@ void main(void) } else { - vec4 prevClip = prevViewProj * vec4(world + cameraDelta, 1.0); + // Sky (depth == 1, nothing wrote depth) is a direction, not a point: + // reproject it with w = 0 so camera translation cannot move it (plan: + // "infinite-direction reprojection where depth == 1"). Finite surfaces + // translate by cameraDelta into the previous camera's frame. + bool sky = depth >= 0.999999; + vec4 prevClip = sky ? prevViewProj * vec4(world, 0.0) + : prevViewProj * vec4(world + cameraDelta, 1.0); if (prevClip.w <= 1e-6) { outColor = current; outGlow = glow; outDepth = vec4(linearDepth); return; } vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize; mv = prevPixel - currentUnjittered; From 59eb557cc14a9202f72e73a562b74ce9c5f5a010 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 00:07:11 +0200 Subject: [PATCH 042/226] fix(review): create the entity motion-writer type cache lazily Injected static fields get no initializer (vanilla's static ctor runs), so the per-renderer gate crashed the first entity frame with a NullReferenceException. Verified in game on Vulkan and OpenGL; coverage test guards the lazy form. --- Optimum.Tests/taa-pipeline-coverage-tests.cs | 5 +++++ .../SystemRenderEntities.cs.patch | 13 ++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index 15f0f87c..441fcf53 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -399,6 +399,11 @@ public void TheEntityMotionWindowOnlyWrapsTheGamesOwnEntityRenderers() string patcher = Read("Optimum.Patcher/Program.cs"); Assert.Contains("\"OptimumIsMotionWriter\"", patcher); Assert.Contains("\"optimumMotionWriterTypes\"", patcher); + // Injected static fields get no initializer (vanilla's static ctor + // runs), so the cache must be created lazily; a field initializer + // crashed the first entity frame with a NullReferenceException. + Assert.DoesNotContain("optimumMotionWriterTypes = new Dictionary", entities); + Assert.Contains("optimumMotionWriterTypes ??= new Dictionary()", entities); } [Fact] diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch index 31af9f48..acbe2032 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs -index 374f827..4c56324 100644 +index 374f827..4b3b3d3 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs @@ -1,15 +1,36 @@ @@ -382,14 +382,16 @@ index 374f827..4c56324 100644 entity.IsShadowRendered = true; entityRenderer.Value.DoRender3DOpaque(dt, isShadowPass: true); } -@@ -182,6 +474,27 @@ public class SystemRenderEntities : ClientSystem +@@ -182,6 +474,30 @@ public class SystemRenderEntities : ClientSystem public override EnumClientSystemType GetSystemType() { return EnumClientSystemType.Render; } + -+ private static readonly Dictionary optimumMotionWriterTypes = new Dictionary(); ++ // No field initializer: injected static fields are not initialised by the ++ // transplant (the static constructor is vanilla's), so it is created lazily. ++ private static Dictionary optimumMotionWriterTypes; + + /// + /// Optimum TAA: whether a renderer's batched opaque draw goes through the @@ -401,11 +403,12 @@ index 374f827..4c56324 100644 + internal static bool OptimumIsMotionWriter(EntityRenderer renderer) + { + Type type = renderer.GetType(); -+ if (!optimumMotionWriterTypes.TryGetValue(type, out bool writes)) ++ Dictionary cache = optimumMotionWriterTypes ??= new Dictionary(); ++ if (!cache.TryGetValue(type, out bool writes)) + { + writes = type.Namespace != null && type.Namespace.StartsWith("Vintagestory.GameContent", StringComparison.Ordinal) + && type.Assembly.GetName().Name == "VSEssentials"; -+ optimumMotionWriterTypes[type] = writes; ++ cache[type] = writes; + } + return writes; + } From b181cff19f80cf2dbb85139f45dce8c772cf9ac3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 00:56:14 +0200 Subject: [PATCH 043/226] fix(vulkan): present-path synchronisation, GL-parity write masks for unwritten outputs, precise barrier accesses, validation that cannot be missed - frame submit waits for the acquired swapchain image at all stages (first use is the present blit, a transfer) - render-finished semaphore owned by the acquired image, not a rolling counter - pipelines zero the colour write mask of every attachment the fragment shader never stores to (GL keeps contents, Vulkan wrote undefined values into the SSAO G-buffer under fullscreen passes) - barrier access masks follow the layouts; read-only depth still makes its storeOp write available - zero-instance draws are no-ops - OPTIMUM_VULKAN_VALIDATION=1 logs to $TMP/optimum-vulkan-validation.log; OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best,gpu via VK_EXT_validation_features - layer messages with braces no longer throw in the client logger Verified: synchronization + best-practices validation in game shows no backend hazards; 322+8 GPU tests, Optimum.Tests green. --- .../AttachmentSemanticsTests.cs | 8 ++- .../FragmentOutputAssignmentTests.cs | 26 ++++++++++ Optimum.Render.Vulkan/Core/FrameRing.cs | 6 ++- Optimum.Render.Vulkan/Core/PipelineCache.cs | 8 ++- Optimum.Render.Vulkan/Core/Swapchain.cs | 8 ++- Optimum.Render.Vulkan/Core/TextureManager.cs | 39 +++++++++++++- Optimum.Render.Vulkan/Core/VulkanContext.cs | 26 ++++++++++ .../Shaders/ProgramInterfaceLayout.cs | 52 +++++++++++++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 42 ++++++++++++--- .../vulkan-backend-integration-tests.cs | 39 ++++++++++++++ 10 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/FragmentOutputAssignmentTests.cs diff --git a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs index 8eddf37d..631a44d8 100644 --- a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -131,7 +131,7 @@ void main(void) /// preserved; the run fails only on validation errors. /// [SkippableFact] - public unsafe void UnwrittenButEnabledAttachmentContentsAreObservedNotAsserted() + public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContents() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); @@ -193,7 +193,11 @@ public unsafe void UnwrittenButEnabledAttachmentContentsAreObservedNotAsserted() $"preserved={attachmentPreserved}"); } - _output.WriteLine($"unwritten-but-enabled attachments preserved on this driver: {preserved}"); + _output.WriteLine($"unwritten-but-enabled attachments preserved: {preserved}"); + // No longer an observation: the pipeline zeroes the colour write + // mask of every attachment the fragment shader does not store to, + // so the driver cannot write undefined values into them. + Assert.True(preserved, "an enabled attachment the shader never writes must keep its contents"); ValidationAssert.NoErrors(messages); } diff --git a/Optimum.Render.Vulkan.Tests/FragmentOutputAssignmentTests.cs b/Optimum.Render.Vulkan.Tests/FragmentOutputAssignmentTests.cs new file mode 100644 index 00000000..0525d40c --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FragmentOutputAssignmentTests.cs @@ -0,0 +1,26 @@ +using Optimum.Render.Vulkan.Shaders; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The pipeline masks off colour attachments whose fragment output is never +/// stored to (GL keeps their contents; Vulkan would write undefined values). +/// The scan has to see every real store and no declaration. +/// +public class FragmentOutputAssignmentTests +{ + [Theory] + [InlineData("out vec4 outColor;\nvoid main(){ outColor = vec4(1.0); }", "outColor", true)] + [InlineData("out vec4 outGlow;\nvoid main(){ outGlow.rgb = vec3(0.0); }", "outGlow", true)] + [InlineData("out vec4 outGlow;\nvoid main(){ outGlow.a += 0.5; }", "outGlow", true)] + [InlineData("out vec4 arr[2];\nvoid main(){ arr[1] = vec4(0.0); }", "arr", true)] + [InlineData("layout(location = 3) out vec4 outGPosition;\nvoid main(){ }", "outGPosition", false)] + [InlineData("out vec4 outGNormal;\nvoid main(){ if (outGNormal == vec4(0.0)) discard; }", "outGNormal", false)] + [InlineData("out vec4 outColor;\nvoid OIT(vec4 c){ outColor = c; }\nvoid main(){ OIT(vec4(1.0)); }", "outColor", true)] + [InlineData("out vec4 outColor;\nout vec4 outColorHi;\nvoid main(){ outColorHi = vec4(1.0); }", "outColor", false)] + public void DetectsStoresAndIgnoresDeclarationsAndReads(string source, string name, bool expected) + { + Assert.Equal(expected, ProgramInterfaceLayout.FragmentOutputIsAssigned(source, name)); + } +} diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 0498fa57..be66c5e6 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -145,7 +145,11 @@ public bool TryAllocateUniforms(int size, out RingAllocation allocation) public void EndFrameAndSubmit( Semaphore waitSemaphore = default, Semaphore signalSemaphore = default, - PipelineStageFlags waitStage = PipelineStageFlags.ColorAttachmentOutputBit) + // The swapchain image's first use in the frame is the present blit, a + // transfer, which a COLOR_ATTACHMENT_OUTPUT wait does not order: the + // blit could overwrite an image the presentation engine still owns and + // the display would show a stale or torn frame. Wait at every stage. + PipelineStageFlags waitStage = PipelineStageFlags.AllCommandsBit) { Vk api = _context.Api; CommandBuffer commandBuffer = CommandBuffer; diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index 7b93b97b..a6478610 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -134,6 +134,12 @@ private Pipeline Create(PipelineRequest request) for (int i = 0; i < blendAttachments.Length; i++) { AttachmentBlend blend = i < request.Blend.Length ? request.Blend[i] : AttachmentBlend.Default; + // An enabled attachment the fragment shader never stores to keeps + // its contents, as it does on GL; Vulkan would write undefined + // values (validation: "Output variable was never written to"). + ColorComponentFlags writeMask = request.Program.Interface.WrittenFragmentOutputs.Contains(i) + ? blend.WriteMask + : 0; blendAttachments[i] = new PipelineColorBlendAttachmentState { BlendEnable = blend.Enabled, @@ -143,7 +149,7 @@ private Pipeline Create(PipelineRequest request) SrcAlphaBlendFactor = blend.SrcAlpha, DstAlphaBlendFactor = blend.DstAlpha, AlphaBlendOp = blend.AlphaOp, - ColorWriteMask = blend.WriteMask, + ColorWriteMask = writeMask, }; } diff --git a/Optimum.Render.Vulkan/Core/Swapchain.cs b/Optimum.Render.Vulkan/Core/Swapchain.cs index 4d1bc2ea..53cfd998 100644 --- a/Optimum.Render.Vulkan/Core/Swapchain.cs +++ b/Optimum.Render.Vulkan/Core/Swapchain.cs @@ -281,11 +281,17 @@ public bool TryAcquire(out uint imageIndex, out Semaphore waitSemaphore, out Sem { imageIndex = 0; waitSemaphore = _imageAvailable[_semaphoreIndex]; - signalSemaphore = _renderFinished[_semaphoreIndex]; Result result = _swapchainApi.AcquireNextImage( _context.Device, _handle, ulong.MaxValue, waitSemaphore, default, ref imageIndex); + // The render-finished semaphore belongs to the acquired IMAGE, not to a + // rolling counter: vkQueuePresentKHR keeps waiting on it until that image + // is presented, and the only moment it is provably free again is when + // the same image is re-acquired. A counter-indexed semaphore could be + // re-signalled while an earlier present still waits on it. + signalSemaphore = _renderFinished[(int)imageIndex % Math.Max(_renderFinished.Length, 1)]; + if (result is Result.ErrorOutOfDateKhr) { NeedsRecreation = true; diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index b5b2e298..9d138784 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -564,13 +564,18 @@ private void TransitionRange( CommandBuffer commandBuffer, VulkanTexture texture, uint baseMip, uint mipCount, ImageLayout from, ImageLayout to) { + // Execution dependency stays ALL_COMMANDS on both sides (a transition + // must order against every earlier use, and this backend does not + // track per-use stages); the access masks name what each layout is + // really used for, which is what makes the availability/visibility + // operations precise and keeps the layers quiet about them. var barrier = new ImageMemoryBarrier2 { SType = StructureType.ImageMemoryBarrier2, SrcStageMask = PipelineStageFlags2.AllCommandsBit, - SrcAccessMask = AccessFlags2.MemoryWriteBit, + SrcAccessMask = AccessForLayout(from, writer: true), DstStageMask = PipelineStageFlags2.AllCommandsBit, - DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + DstAccessMask = AccessForLayout(to, writer: false), OldLayout = from, NewLayout = to, Image = texture.Image, @@ -586,6 +591,36 @@ private void TransitionRange( _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); } + /// + /// The accesses a layout is used for: as the source side of a barrier the + /// writes that must be made available, as the destination side the reads + /// and writes that must see them. + /// + internal static AccessFlags2 AccessForLayout(ImageLayout layout, bool writer) => layout switch + { + ImageLayout.TransferDstOptimal => AccessFlags2.TransferWriteBit, + ImageLayout.TransferSrcOptimal => writer ? AccessFlags2.None : AccessFlags2.TransferReadBit, + ImageLayout.ColorAttachmentOptimal => writer + ? AccessFlags2.ColorAttachmentWriteBit + : AccessFlags2.ColorAttachmentReadBit | AccessFlags2.ColorAttachmentWriteBit, + ImageLayout.DepthAttachmentOptimal or ImageLayout.DepthStencilAttachmentOptimal => writer + ? AccessFlags2.DepthStencilAttachmentWriteBit + : AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.DepthStencilAttachmentWriteBit, + // Read-only depth is still written by the pass's storeOp, so as a + // source it must make that write available or the next transition is + // a write-after-write hazard (synchronization validation, 2026-09-11). + ImageLayout.DepthReadOnlyOptimal or ImageLayout.DepthStencilReadOnlyOptimal => writer + ? AccessFlags2.DepthStencilAttachmentWriteBit + : AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.ShaderSampledReadBit, + ImageLayout.ShaderReadOnlyOptimal => writer ? AccessFlags2.None : AccessFlags2.ShaderSampledReadBit, + ImageLayout.General => writer + ? AccessFlags2.MemoryWriteBit + : AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + ImageLayout.PresentSrcKhr => AccessFlags2.None, + ImageLayout.Undefined or ImageLayout.Preinitialized => writer ? AccessFlags2.None : AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + _ => writer ? AccessFlags2.MemoryWriteBit : AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + }; + // -------------------------------------------------------------------- helpers public static uint MipLevelsFor(uint width, uint height) => diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 0df9fa4a..609cfd5f 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -17,6 +17,9 @@ internal sealed class VulkanContextOptions /// Turns on the validation layers and the debug messenger. public bool EnableValidation; + /// Comma list of extra layer features: sync, best, gpu. + public string ValidationFeatures = ""; + /// Pins a physical device by index; -1 picks automatically. public int PreferredDeviceIndex = -1; @@ -210,9 +213,32 @@ private bool CreateInstance(VulkanContextOptions options, out string? failureRea ApiVersion = MinimumApiVersion, }; + // Extra layer features (sync validation, best practices, GPU + // assisted) through VK_EXT_validation_features, chained only when + // the layer is on and something was asked for. + var enables = new List(); + foreach (string feature in (options.ValidationFeatures ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + switch (feature.ToLowerInvariant()) + { + case "sync": enables.Add(ValidationFeatureEnableEXT.SynchronizationValidationExt); break; + case "best": enables.Add(ValidationFeatureEnableEXT.BestPracticesExt); break; + case "gpu": enables.Add(ValidationFeatureEnableEXT.GpuAssistedExt); break; + } + } + ValidationFeatureEnableEXT* enablesPtr = stackalloc ValidationFeatureEnableEXT[Math.Max(enables.Count, 1)]; + for (int i = 0; i < enables.Count; i++) enablesPtr[i] = enables[i]; + var validationFeatures = new ValidationFeaturesEXT + { + SType = StructureType.ValidationFeaturesExt, + EnabledValidationFeatureCount = (uint)enables.Count, + PEnabledValidationFeatures = enablesPtr, + }; + var createInfo = new InstanceCreateInfo { SType = StructureType.InstanceCreateInfo, + PNext = validation && enables.Count > 0 ? &validationFeatures : null, PApplicationInfo = &applicationInfo, EnabledExtensionCount = (uint)extensions.Count, PpEnabledExtensionNames = (byte**)extensionsPtr, diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs index 2149035f..e0aed47d 100644 --- a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -113,6 +113,17 @@ internal sealed class ProgramInterfaceLayout /// Fragment output locations for outputs that declared none. public Dictionary FragmentOutputLocations { get; } = new(StringComparer.Ordinal); + /// + /// Colour locations the fragment shader actually assigns somewhere in its + /// body. GL leaves an enabled attachment alone when the shader never writes + /// its output (undefined by the spec, preserved by every driver we ship on); + /// Vulkan writes undefined values into it. The device zeroes the colour + /// write mask of every attachment outside this set so both backends keep + /// the attachment's previous contents - the SSAO G-buffer under a + /// fullscreen compose pass, for one. + /// + public HashSet WrittenFragmentOutputs { get; } = new(); + /// Size of the generated block in bytes; 0 when it has no members. public int BlockSize { get; private set; } @@ -335,6 +346,11 @@ private static void AssignInterfaceLocations( var usedVertexInputs = new HashSet(); var usedVaryings = new HashSet(); var usedFragmentOutputs = new HashSet(); + // Declared outputs that the body never assigns (a G-buffer output kept + // under an #if that compiled out its store) are still declared; only + // outputs with a store count as written. + var fragmentOutputDeclarations = new List(); + string fragmentSource = ""; // Pass one: record every location the shaders stated outright. foreach ((EnumShaderType stage, ParsedShader parsed) in stages) @@ -351,6 +367,8 @@ private static void AssignInterfaceLocations( else if (stage == EnumShaderType.FragmentShader && declaration.Kind == GlslDeclarationKind.Output) { Occupy(usedFragmentOutputs, declaration.Location, LocationSpan(declaration)); + fragmentOutputDeclarations.Add(declaration); + fragmentSource = parsed.Source; } else { @@ -397,6 +415,8 @@ private static void AssignInterfaceLocations( { if (layout.FragmentOutputLocations.ContainsKey(declaration.Name)) continue; layout.FragmentOutputLocations[declaration.Name] = Reserve(usedFragmentOutputs, span); + fragmentOutputDeclarations.Add(declaration); + fragmentSource = parsed.Source; } else if (declaration.Kind is GlslDeclarationKind.Input or GlslDeclarationKind.Output) { @@ -408,8 +428,40 @@ private static void AssignInterfaceLocations( } } } + + foreach (GlslDeclaration declaration in fragmentOutputDeclarations) + { + int location = declaration.Location >= 0 + ? declaration.Location + : layout.FragmentOutputLocations.TryGetValue(declaration.Name, out int assignedLocation) ? assignedLocation : -1; + if (location < 0) continue; + if (!FragmentOutputIsAssigned(fragmentSource, declaration.Name)) continue; + for (int i = 0; i < LocationSpan(declaration); i++) layout.WrittenFragmentOutputs.Add(location + i); + } + } + + /// + /// Whether the fragment body stores to : a plain, + /// swizzled or indexed assignment, or a compound one. Declarations are + /// excluded by requiring the identifier not to be preceded by a type or + /// the "out" keyword on the same statement. + /// + internal static bool FragmentOutputIsAssigned(string source, string name) + { + var store = new System.Text.RegularExpressions.Regex( + @"(? /// How many consecutive locations a variable consumes. A vector of any width /// fits in one; a matrix takes one per column; an array multiplies by its diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index a21eec0e..6eebe237 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -131,7 +131,35 @@ private sealed class StagedStage /// the message that preceded the loss. /// private static readonly string? ValidationLogPath = - ValidationSetting != null && ValidationSetting.Contains('/') ? ValidationSetting : null; + ValidationSetting == null ? null + : ValidationSetting.Contains('/') ? ValidationSetting + : DefaultValidationLogPath; + + /// + /// Where a bare OPTIMUM_VULKAN_VALIDATION=1 mirrors the layer's messages. + /// Before this default the messages only surfaced when the client happened + /// to poll the error channel, and a whole class of hazards went unlogged. + /// + private static readonly string DefaultValidationLogPath = + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "optimum-vulkan-validation.log"); + + /// + /// OPTIMUM_VULKAN_VALIDATION_FEATURES: comma list of "sync" (synchronization + /// validation), "best" (best practices, vendor checks included) and "gpu" + /// (GPU-assisted). Requested through VK_EXT_validation_features so it does + /// not depend on the layer's environment variable names, which changed. + /// + private static readonly string ValidationFeatureSetting = + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_VALIDATION_FEATURES") ?? ""; + + /// + /// The client logs diagnostics through string.Format, and a layer message + /// that prints a struct ("pImageMemoryBarriers[0]: { ... }") throws a + /// FormatException there and is lost. Braces become brackets before the + /// message reaches either channel. + /// + private static string SanitiseForClientLog(string message) => + message.Replace('{', '[').Replace('}', ']'); private static void MirrorValidationMessage(string message) { @@ -235,9 +263,10 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // is the way to get them for a real client session, which is the // only place the world-loading paths actually run. EnableValidation = DebugMode || ValidationRequestedByEnvironment, + ValidationFeatures = ValidationFeatureSetting, DebugCallback = message => { - _diagnostics.Add(message); + _diagnostics.Add(SanitiseForClientLog(message)); MirrorValidationMessage(message); if (RenderTrace.Enabled) RenderTrace.Write("validation: program=" + (_state?.CurrentProgram ?? 0) + @@ -735,9 +764,9 @@ private void TransitionSwapchainImage( { SType = StructureType.ImageMemoryBarrier2, SrcStageMask = PipelineStageFlags2.AllCommandsBit, - SrcAccessMask = AccessFlags2.MemoryWriteBit, + SrcAccessMask = TextureManager.AccessForLayout(from, writer: true), DstStageMask = PipelineStageFlags2.AllCommandsBit, - DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + DstAccessMask = TextureManager.AccessForLayout(to, writer: false), OldLayout = from, NewLayout = to, Image = image, @@ -1662,6 +1691,7 @@ public void UpdateMeshStorageBuffer(int meshId, IntPtr data, int byteOffset, int public void DrawMeshInstanced(int meshId, int instanceCount) { + if (instanceCount <= 0) return; if (!PrepareDraw(_meshes.LayoutIdOf(meshId), meshId, out CommandBuffer commandBuffer)) return; Checkpoint(commandBuffer, CheckpointMarker.Draw(CheckpointKind.Draw, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); @@ -2010,7 +2040,7 @@ private void ReportUniformExhaustion(ShaderProgramResources program, string what " (" + _frames.Current.UniformBytesUsed + " of " + _frames.Current.UniformCapacity + " bytes used) at a draw with program " + program.ProgramId + " '" + ProgramNameOf(program.ProgramId) + "' for " + what; - _diagnostics.Add(message); + _diagnostics.Add(SanitiseForClientLog(message)); MirrorValidationMessage(message); } @@ -2467,7 +2497,7 @@ public int GetQueryResult(int queryId) { string message = VulkanContext.ErrorPrefix + "occlusion query " + queryId + " produced no result within two seconds of being flushed; reporting it as visible"; - _diagnostics.Add(message); + _diagnostics.Add(SanitiseForClientLog(message)); MirrorValidationMessage(message); return int.MaxValue; } diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index a24d49b3..2c76bbec 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -432,4 +432,43 @@ private static string AddedLines(string patch) => private static string Read(string relativePath) => File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + [Fact] + public void ThePresentPathWaitsForTheSwapchainImageAtEveryStageAndOwnsSemaphoresPerImage() + { + string ring = Read("Optimum.Render.Vulkan/Core/FrameRing.cs"); + // The first use of the acquired image is the present blit (transfer); + // a COLOR_ATTACHMENT_OUTPUT wait would not order it. + Assert.Contains("PipelineStageFlags waitStage = PipelineStageFlags.AllCommandsBit)", ring); + + string swapchain = Read("Optimum.Render.Vulkan/Core/Swapchain.cs"); + Assert.Contains("signalSemaphore = _renderFinished[(int)imageIndex %", swapchain); + Assert.DoesNotContain("signalSemaphore = _renderFinished[_semaphoreIndex];", swapchain); + } + + [Fact] + public void ValidationMessagesAlwaysReachAFileAndExtraFeaturesCanBeRequested() + { + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("DefaultValidationLogPath", device); + Assert.Contains("OPTIMUM_VULKAN_VALIDATION_FEATURES", device); + string context = Read("Optimum.Render.Vulkan/Core/VulkanContext.cs"); + Assert.Contains("ValidationFeatureEnableEXT.SynchronizationValidationExt", context); + Assert.Contains("ValidationFeatureEnableEXT.BestPracticesExt", context); + Assert.Contains("StructureType.ValidationFeaturesExt", context); + } + + [Fact] + public void UnwrittenFragmentOutputsAreMaskedOffInThePipeline() + { + string cache = Read("Optimum.Render.Vulkan/Core/PipelineCache.cs"); + Assert.Contains("request.Program.Interface.WrittenFragmentOutputs.Contains(i)", cache); + Assert.Contains("ColorWriteMask = writeMask,", cache); + string layout = Read("Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs"); + Assert.Contains("internal static bool FragmentOutputIsAssigned(string source, string name)", layout); + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("if (instanceCount <= 0) return;", device); + string textures = Read("Optimum.Render.Vulkan/Core/TextureManager.cs"); + Assert.Contains("internal static AccessFlags2 AccessForLayout(ImageLayout layout, bool writer)", textures); + } } From f904c2b08cb73eb1d37e1c7febe1299eb870cb88 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 07:20:27 +0200 Subject: [PATCH 044/226] docs(taa): record P4 acceptance and the Vulkan write-mask finding --- TAA-PLAN.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 10b0bfd4..f5deda1a 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -557,6 +557,17 @@ Still owed for P4 (rule 3), in the game, on both backends, with the renderer con - `taaLiquidReactive` (0.3) and `taaCloudReactive` (1.0) are hard-coded constants, not settings; wiring them to `OptimumConfig` is P5 work. +P4 status addendum (2026-09-11): P4 accepted in game by the user on Vulkan after commit 95bf71d +("that fixed the instability issue fully"). The Vulkan-only frame-to-frame shimmer that survived every +single-frame probe was not in the resolve: fullscreen passes on Primary left the SSAO normal/position +attachments write-enabled without storing to them, so Vulkan wrote undefined values into the G-buffer +every frame (GL keeps the old values) and SSAO's dark outlines flickered per frame. Found only after +the validation log was made readable (`OPTIMUM_VULKAN_VALIDATION=1` used to log nowhere) and +synchronization + best-practices validation were enabled. Fixed by masking unwritten fragment outputs +in the pipeline; the present path also got a correct wait stage and per-image semaphores. +Lessons: screenshots cannot capture one-frame alternation; read the layer's log, not the client log; +"looks identical per frame on both backends" says nothing about what alternates between frames. + **P5. Integration, sharpen, settings, fallback, acceptance.** - RCAS variant with a sharpness uniform and true bypass; no double sharpening with FSR1 render scale; `TaaMipBias` optional and measured; settings rows in `GuiCompositeSettings.cs.patch`; From f4b51a4b33489761ab85cdacca95d913b3b89662 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 07:27:05 +0200 Subject: [PATCH 045/226] fix(review): restore render state in finally blocks (liquid pass, sky pass culling, decals window) ChunkRenderer.RenderLiquidMotion: useSSBOs, the matrix pop and the blend restore all ran inside the try after the pool draws, so a throwing draw left the renderer with SSBOs off, an unbalanced matrix stack and blending off. The useSSBOs snapshot is now taken before the try and every restore sits in the finally, guarded by a pushedMatrix flag. ClientPlatformWindows.RenderOptimumSkyMotion: GlDisableCullFace() runs before BeginMotionOnlyWrite(), so neither the early return nor the finally undid it. Both paths call GlEnableCullFace() now. SystemRenderDecals.OnRenderFrame3D: the GL setup, shader activation and uniform setup ran between BeginMotionWrite() and the try; they are inside the try now, so EndMotionWrite() always runs. Verified: dotnet build VintageStory.slnx -c Release, scripts/extract-patches.sh + scripts/check-patches.sh (157 patches, 0 conflicts, 22 runtime donors), dotnet test Optimum.Tests -c Release (923 passed) including three new source-coverage assertions that the restores sit in the finally blocks. Not verified in game (no launch in this worktree). --- .../taa-liquid-motion-coverage-tests.cs | 61 ++++++++++++ .../taa-sky-decal-motion-coverage-tests.cs | 97 +++++++++++++++++++ .../ChunkRenderer.cs.patch | 34 ++++--- .../ClientPlatformWindows.cs.patch | 76 ++++++++------- .../SystemRenderDecals.cs.patch | 63 ++++++++---- 5 files changed, 264 insertions(+), 67 deletions(-) diff --git a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs index b64915ea..5c2eaa57 100644 --- a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -230,6 +230,48 @@ public void ChunkRendererDrawsTheLiquidPoolsIntoTheMotionAttachmentOnly() Assert.Contains("internal const float OptimumLiquidReactive = 0.3f;", chunk); } + /// + /// Review finding: the pass used to restore useSSBOs, pop the matrix and + /// turn blending back on INSIDE the try, after the pool draws. A throwing + /// draw then left the renderer with SSBOs off, an unbalanced matrix stack + /// and blending off for the rest of the frame. Every restore now sits in + /// the finally, and the useSSBOs snapshot is taken before the try so the + /// finally always has something to hand back. + /// + [Fact] + public void EveryLiquidPassRestoreRunsInTheFinallyBlock() + { + string chunk = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + string pass = MethodBodyAfter(chunk, "internal void RenderLiquidMotion(float deltaTime)"); + + var tryMatch = System.Text.RegularExpressions.Regex.Match(pass, @"try\s*\{"); + Assert.True(tryMatch.Success, "the pass no longer has a try block"); + int tryStart = tryMatch.Index; + int capture = pass.IndexOf("bool useSSBOs = game.api.renderapi.useSSBOs;", StringComparison.Ordinal); + Assert.True(capture >= 0 && capture < tryStart, + "the useSSBOs snapshot must be taken before the try, or the finally cannot restore it"); + + string restores = FinallyBlock(pass); + Assert.Contains("game.api.renderapi.useSSBOs = useSSBOs;", restores); + Assert.Contains("game.GlPopMatrix();", restores); + Assert.Contains("platform.GlToggleBlend(on: true);", restores); + Assert.Contains("optimumPlatform.EndMotionOnlyWrite();", restores); + + // ...and nowhere else: a restore left in the try is a restore a throwing + // pool draw skips. + string guarded = pass.Substring(tryStart, pass.IndexOf(restores, StringComparison.Ordinal) - tryStart); + Assert.DoesNotContain("game.api.renderapi.useSSBOs = useSSBOs;", guarded); + Assert.DoesNotContain("GlPopMatrix", guarded); + Assert.DoesNotContain("GlToggleBlend(on: true)", guarded); + + // The pop is balanced against the push that actually happened. + Assert.Contains("pushedMatrix = true;", pass); + Assert.Contains("if (pushedMatrix)", restores); + } + /// /// Placement is the whole reason this is a separate pass. It has to run /// after the OIT merge (the liquid it re-draws was shaded into the @@ -324,6 +366,25 @@ public void TheCompatibilityScannerDisablesTaaForAnExternalLiquidShader() // ----------------------------------------------------------------- helpers + /// The braced block of the method's finally clause. + private static string FinallyBlock(string body) + { + var match = System.Text.RegularExpressions.Regex.Match(body, @"finally\s*\{"); + Assert.True(match.Success, "no finally block"); + int open = body.IndexOf('{', match.Index); + int depth = 0; + for (int i = open; i < body.Length; i++) + { + if (body[i] == '{') depth++; + else if (body[i] == '}') + { + depth--; + if (depth == 0) return body.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated finally block"); + } + private static string MethodBodyAfter(string source, string signature) { int start = source.IndexOf(signature, StringComparison.Ordinal); diff --git a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs index 82583358..5e829a15 100644 --- a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -155,6 +155,41 @@ public void TheSkyPassRunsLastInTheSceneStillInsideTheTemporalWindow() Assert.Contains("shaderProgram == ShaderPrograms.TaaSkyMotion", registry); } + /// + /// Review finding: GlDisableCullFace() runs BEFORE BeginMotionOnlyWrite(), + /// so it is not covered by the try at all. Both the early return (the window + /// refused to open) and the finally restored depth and blend but left + /// culling disabled for everything that followed in the frame. Both paths + /// now re-enable it. + /// + [Fact] + public void TheSkyPassRestoresCullingOnBothPaths() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + string pass = MethodBodyAfter(platform, "internal bool RenderOptimumSkyMotion()"); + + // The disable that has to be undone, and it is outside the try. + int disable = pass.IndexOf("GlDisableCullFace();", StringComparison.Ordinal); + int begin = pass.IndexOf("if (!BeginMotionOnlyWrite())", StringComparison.Ordinal); + Assert.True(disable >= 0 && begin > disable, + "culling is no longer disabled before the window opens; revisit the restores"); + + // Path 1: the window refused to open. + string earlyReturn = pass.Substring(begin, pass.IndexOf("return false;", begin, StringComparison.Ordinal) - begin); + Assert.Contains("GlDepthFunc(EnumDepthFunction.Less);", earlyReturn); + Assert.Contains("GlDepthMask(flag: true);", earlyReturn); + Assert.Contains("GlToggleBlend(on: true);", earlyReturn); + Assert.Contains("GlEnableCullFace();", earlyReturn); + + // Path 2: the pass ran, threw or not. + string restores = FinallyBlock(pass); + Assert.Contains("EndMotionOnlyWrite();", restores); + Assert.Contains("GlDepthFunc(EnumDepthFunction.Less);", restores); + Assert.Contains("GlDepthMask(flag: true);", restores); + Assert.Contains("GlToggleBlend(on: true);", restores); + Assert.Contains("GlEnableCullFace();", restores); + } + // ---------------------------------------------------------- (b) decals /// @@ -242,6 +277,49 @@ public void SystemRenderDecalsOpensTheMotionWindowAroundItsDraw() Assert.Contains("BeginMotionWrite()", PatchReader.ReadPatchedContent(patch!)); } + /// + /// Review finding: only decalPool.Draw sat inside the try. The GL setup, the + /// shader activation and the uniform setup ran between BeginMotionWrite() + /// and the try, so a throw in any of them left the motion attachment in + /// Primary's draw-buffer mask - and replace blending on it - for the rest of + /// the frame. Everything the open window covers is inside the try now. + /// + [Fact] + public void TheDecalMotionWindowCoversEverythingItOpened() + { + string decals = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs"); + string pass = MethodBodyAfter(decals, "public void OnRenderFrame3D(float deltaTime)"); + + int begin = pass.IndexOf("optimumPlatform.BeginMotionWrite();", StringComparison.Ordinal); + Assert.True(begin >= 0); + var tryMatch = System.Text.RegularExpressions.Regex.Match(pass.Substring(begin), @"try\s*\{"); + Assert.True(tryMatch.Success, "the window opens outside any try"); + int tryStart = begin + tryMatch.Index; + + string restores = FinallyBlock(pass); + Assert.Contains("optimumPlatform.EndMotionWrite();", restores); + + // Nothing but the window's own bookkeeping happens between the open and + // the try: every statement below runs guarded. + string guarded = pass.Substring(tryStart, pass.IndexOf(restores, StringComparison.Ordinal) - tryStart); + foreach (string statement in new[] + { + "game.Platform.GlToggleBlend(on: true);", + "game.Platform.GlDisableCullFace();", + "shaderProgramDecals.Use();", + "shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix;", + "SetOptimumMotionUniforms(shaderProgramDecals);", + "decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant);", + }) + { + Assert.Contains(statement, guarded); + } + + string unguarded = pass.Substring(begin, tryStart - begin); + Assert.DoesNotContain("GlToggleBlend", unguarded); + Assert.DoesNotContain("shaderProgramDecals", unguarded); + } + // ------------------------------- (c) AfterFinalComposition / AfterBlit /// @@ -445,6 +523,25 @@ private static string StripComments(string source) return string.Join("\n", kept); } + /// The braced block of the method's finally clause. + private static string FinallyBlock(string body) + { + var match = System.Text.RegularExpressions.Regex.Match(body, @"finally\s*\{"); + Assert.True(match.Success, "no finally block"); + int open = body.IndexOf('{', match.Index); + int depth = 0; + for (int i = open; i < body.Length; i++) + { + if (body[i] == '{') depth++; + else if (body[i] == '}') + { + depth--; + if (depth == 0) return body.Substring(open, i - open + 1); + } + } + throw new InvalidOperationException("unterminated finally block"); + } + private static string MethodBodyAfter(string source, string signature) { return signature + BodyOf(source, signature); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch index 49d3e0d4..32a003ab 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs -index 431e51a..96ac74a 100644 +index 431e51a..df5d592 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs @@ -1,9 +1,11 @@ @@ -303,7 +303,7 @@ index 431e51a..96ac74a 100644 internal void RenderOIT(float deltaTime) { -@@ -402,15 +556,124 @@ public class ChunkRenderer +@@ -402,15 +556,134 @@ public class ChunkRenderer chunktransparent.Stop(); game.GlPopMatrix(); ScreenManager.FrameProfiler.Mark("rend3D-ret-tp"); @@ -370,11 +370,17 @@ index 431e51a..96ac74a 100644 + { + return; + } ++ // The liquid pool has no SSBO layout (RenderOIT and the LiquidDepth ++ // prepass both turn it off for exactly this pass's geometry). Captured ++ // before the try so the finally can always hand it back. ++ bool useSSBOs = game.api.renderapi.useSSBOs; ++ bool pushedMatrix = false; + try + { + Vec3d cameraPos = game.EntityPlayer.CameraPos; + game.GlMatrixModeModelView(); + game.GlPushMatrix(); ++ pushedMatrix = true; + game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); + platform.GlToggleBlend(on: false); + platform.GlEnableDepthTest(); @@ -388,26 +394,30 @@ index 431e51a..96ac74a 100644 + liquidMotion.UniformMatrix("modelViewMatrix", game.CurrentModelViewMatrix); + liquidMotion.Uniform("taaLiquidReactive", OptimumLiquidReactive); + SetOptimumMotionUniforms(liquidMotion); -+ // The liquid pool has no SSBO layout (RenderOIT and the LiquidDepth -+ // prepass both turn it off for exactly this pass's geometry). -+ bool useSSBOs = game.api.renderapi.useSSBOs; + game.api.renderapi.useSSBOs = false; + for (int i = 0; i < textureIds.Length; i++) + { + poolsByRenderPass[4][i].Render(cameraPos, "origin"); + } -+ game.api.renderapi.useSSBOs = useSSBOs; + liquidMotion.Stop(); -+ game.GlPopMatrix(); ++ ScreenManager.FrameProfiler.Mark("rend3D-ret-lqmv"); ++ } ++ finally ++ { ++ // Every restore lives here: a throwing pool draw must not leave the ++ // renderer with SSBOs off, an unbalanced matrix stack, blending off ++ // or the motion window still open for the rest of the frame. ++ game.api.renderapi.useSSBOs = useSSBOs; ++ if (pushedMatrix) ++ { ++ game.GlMatrixModeModelView(); ++ game.GlPopMatrix(); ++ } + // Hand back the state the AfterOIT stage left, since this pass runs + // after it: the last AfterOIT renderer (SystemRenderEntities) leaves + // culling disabled and blending on, and the post chain that follows + // has always started from that. + platform.GlToggleBlend(on: true); -+ ScreenManager.FrameProfiler.Mark("rend3D-ret-lqmv"); -+ } -+ finally -+ { + optimumPlatform.EndMotionOnlyWrite(); + } + } @@ -428,7 +438,7 @@ index 431e51a..96ac74a 100644 platform.GlToggleBlend(on: false); platform.GlEnableDepthTest(); chunkopaque.Use(); -@@ -425,28 +688,36 @@ public class ChunkRenderer +@@ -425,28 +698,36 @@ public class ChunkRenderer chunkopaque.DayLight = game.shUniforms.SkyDaylight; chunkopaque.HorizonFog = game.AmbientManager.BlendedCloudDensity; chunkopaque.HaxyFade = 1; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index b9401abc..8110777b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..9aff703 100644 +index 6edf0c9..6ccf6d0 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -45,10 +45,99 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract @@ -2154,7 +2154,7 @@ index 6edf0c9..9aff703 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2014,28 +3472,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2014,28 +3472,488 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0030: Unknown result type (might be due to invalid IL or missing references) //IL_0040: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Invalid comparison between Unknown and I4 @@ -2519,8 +2519,9 @@ index 6edf0c9..9aff703 100644 + GlDepthFunc(EnumDepthFunction.Less); + GlDepthMask(flag: true); + GlToggleBlend(on: true); ++ GlEnableCullFace(); + return false; - } ++ } + try + { + skyMotion.Use(); @@ -2537,12 +2538,15 @@ index 6edf0c9..9aff703 100644 + { + EndMotionOnlyWrite(); + // Everything this pass changed, back the way the AfterOIT stage and -+ // the liquid velocity pass left it: depth writes on, GL_LESS, and -+ // blending on for the post chain that follows. ++ // the liquid velocity pass left it: depth writes on, GL_LESS, ++ // blending on for the post chain that follows, and culling back on - ++ // GlDisableCullFace above runs before the window opens, so both this ++ // path and the early return have to undo it. + GlDepthFunc(EnumDepthFunction.Less); + GlDepthMask(flag: true); + GlToggleBlend(on: true); -+ } ++ GlEnableCullFace(); + } + ScreenManager.FrameProfiler.Mark("rend3D-ret-skymv"); + return true; } @@ -2644,7 +2648,7 @@ index 6edf0c9..9aff703 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +3997,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2083,10 +4001,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -2670,7 +2674,7 @@ index 6edf0c9..9aff703 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +4030,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +4034,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -2692,7 +2696,7 @@ index 6edf0c9..9aff703 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +4059,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +4063,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -2790,7 +2794,7 @@ index 6edf0c9..9aff703 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +4158,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +4162,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2993,7 +2997,7 @@ index 6edf0c9..9aff703 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4367,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4371,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3160,7 +3164,7 @@ index 6edf0c9..9aff703 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4537,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4541,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3184,7 +3188,7 @@ index 6edf0c9..9aff703 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4566,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4570,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3222,7 +3226,7 @@ index 6edf0c9..9aff703 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4626,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4630,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); } @@ -3265,7 +3269,7 @@ index 6edf0c9..9aff703 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4679,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4683,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3324,7 +3328,7 @@ index 6edf0c9..9aff703 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4794,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4798,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3398,7 +3402,7 @@ index 6edf0c9..9aff703 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4892,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4896,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3429,7 +3433,7 @@ index 6edf0c9..9aff703 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4929,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4933,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3464,7 +3468,7 @@ index 6edf0c9..9aff703 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +4976,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +4980,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3493,7 +3497,7 @@ index 6edf0c9..9aff703 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +5007,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +5011,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3519,7 +3523,7 @@ index 6edf0c9..9aff703 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,10 +5034,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,10 +5038,12 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3532,7 +3536,7 @@ index 6edf0c9..9aff703 100644 return uBO; } -@@ -2605,10 +5048,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2605,10 +5052,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) @@ -3549,7 +3553,7 @@ index 6edf0c9..9aff703 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +5100,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5104,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3594,7 +3598,7 @@ index 6edf0c9..9aff703 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5137,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5141,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3615,7 +3619,7 @@ index 6edf0c9..9aff703 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5156,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5160,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3636,7 +3640,7 @@ index 6edf0c9..9aff703 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5175,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5179,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3657,7 +3661,7 @@ index 6edf0c9..9aff703 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5194,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5198,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3678,7 +3682,7 @@ index 6edf0c9..9aff703 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5217,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5221,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3699,7 +3703,7 @@ index 6edf0c9..9aff703 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5260,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5264,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3725,7 +3729,7 @@ index 6edf0c9..9aff703 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5502,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5506,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3747,7 +3751,7 @@ index 6edf0c9..9aff703 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5700,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5704,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3770,7 +3774,7 @@ index 6edf0c9..9aff703 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5774,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5778,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3819,7 +3823,7 @@ index 6edf0c9..9aff703 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5844,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5848,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3851,7 +3855,7 @@ index 6edf0c9..9aff703 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5874,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5878,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3877,7 +3881,7 @@ index 6edf0c9..9aff703 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6222,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6226,29 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3907,7 +3911,7 @@ index 6edf0c9..9aff703 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6276,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6280,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch index 61551032..357b369e 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs -index f52d0f6..e0a7d3c 100644 +index f52d0f6..fa89321 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs @@ -387,10 +387,34 @@ public class SystemRenderDecals : ClientSystem, IDecalApi @@ -37,12 +37,29 @@ index f52d0f6..e0a7d3c 100644 Vec3d cameraPos = game.EntityPlayer.CameraPos; if (decalOrigin.SquareDistanceTo(cameraPos) > 1000000f) { -@@ -399,10 +423,24 @@ public class SystemRenderDecals : ClientSystem, IDecalApi +@@ -399,27 +423,59 @@ public class SystemRenderDecals : ClientSystem, IDecalApi } if (decals.Count > 0) { game.GlPushMatrix(); game.GlLoadMatrix(game.MainCamera.CameraMatrixOrigin); +- game.Platform.GlToggleBlend(on: true); +- game.Platform.GlDisableCullFace(); +- ShaderProgramDecals shaderProgramDecals = ShaderPrograms.Decals; +- shaderProgramDecals.Use(); +- shaderProgramDecals.WindWaveCounter = game.shUniforms.WindWaveCounter; +- shaderProgramDecals.WindWaveCounterHighFreq = game.shUniforms.WindWaveCounterHighFreq; +- shaderProgramDecals.BlockTexture2D = game.BlockAtlasManager.AtlasTextures[0].TextureId; +- shaderProgramDecals.DecalTexture2D = decalTextureAtlas.TextureId; +- shaderProgramDecals.RgbaFogIn = game.AmbientManager.BlendedFogColor; +- shaderProgramDecals.RgbaAmbientIn = game.AmbientManager.BlendedAmbientColor; +- shaderProgramDecals.FogDensityIn = game.AmbientManager.BlendedFogDensity; +- shaderProgramDecals.FogMinIn = game.AmbientManager.BlendedFogMin; +- shaderProgramDecals.Origin = new Vec3f((float)(decalOrigin.X - cameraPos.X), (float)(decalOrigin.Y - cameraPos.Y), (float)(decalOrigin.Z - cameraPos.Z)); +- shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix; +- shaderProgramDecals.ModelViewMatrix = game.CurrentModelViewMatrix; +- decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); +- shaderProgramDecals.Stop(); + // Optimum TAA (P4): a decal overwrites the depth buffer with a value + // slightly nearer than the block it sits on (decals.vsh's zOffset + // w-offset), so the block's own motion vector - whose writer depth is @@ -57,25 +74,33 @@ index f52d0f6..e0a7d3c 100644 + // surface. A no-op when TAA is off. + ClientPlatformWindows optimumPlatform = game.Platform as ClientPlatformWindows; + bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); - game.Platform.GlToggleBlend(on: true); - game.Platform.GlDisableCullFace(); - ShaderProgramDecals shaderProgramDecals = ShaderPrograms.Decals; - shaderProgramDecals.Use(); - shaderProgramDecals.WindWaveCounter = game.shUniforms.WindWaveCounter; -@@ -414,11 +452,25 @@ public class SystemRenderDecals : ClientSystem, IDecalApi - shaderProgramDecals.FogDensityIn = game.AmbientManager.BlendedFogDensity; - shaderProgramDecals.FogMinIn = game.AmbientManager.BlendedFogMin; - shaderProgramDecals.Origin = new Vec3f((float)(decalOrigin.X - cameraPos.X), (float)(decalOrigin.Y - cameraPos.Y), (float)(decalOrigin.Z - cameraPos.Z)); - shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix; - shaderProgramDecals.ModelViewMatrix = game.CurrentModelViewMatrix; -- decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); -+ if (optimumMotionWrite) -+ { -+ SetOptimumMotionUniforms(shaderProgramDecals); -+ } + try + { ++ // Everything between the window opening and EndMotionWrite lives ++ // inside the try: a throw in the GL setup, the shader activation, ++ // the uniform setup or the draw would otherwise leave the motion ++ // attachment in Primary's draw-buffer mask for the rest of the frame. ++ game.Platform.GlToggleBlend(on: true); ++ game.Platform.GlDisableCullFace(); ++ ShaderProgramDecals shaderProgramDecals = ShaderPrograms.Decals; ++ shaderProgramDecals.Use(); ++ shaderProgramDecals.WindWaveCounter = game.shUniforms.WindWaveCounter; ++ shaderProgramDecals.WindWaveCounterHighFreq = game.shUniforms.WindWaveCounterHighFreq; ++ shaderProgramDecals.BlockTexture2D = game.BlockAtlasManager.AtlasTextures[0].TextureId; ++ shaderProgramDecals.DecalTexture2D = decalTextureAtlas.TextureId; ++ shaderProgramDecals.RgbaFogIn = game.AmbientManager.BlendedFogColor; ++ shaderProgramDecals.RgbaAmbientIn = game.AmbientManager.BlendedAmbientColor; ++ shaderProgramDecals.FogDensityIn = game.AmbientManager.BlendedFogDensity; ++ shaderProgramDecals.FogMinIn = game.AmbientManager.BlendedFogMin; ++ shaderProgramDecals.Origin = new Vec3f((float)(decalOrigin.X - cameraPos.X), (float)(decalOrigin.Y - cameraPos.Y), (float)(decalOrigin.Z - cameraPos.Z)); ++ shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix; ++ shaderProgramDecals.ModelViewMatrix = game.CurrentModelViewMatrix; ++ if (optimumMotionWrite) ++ { ++ SetOptimumMotionUniforms(shaderProgramDecals); ++ } + decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); ++ shaderProgramDecals.Stop(); + } + finally + { @@ -84,8 +109,8 @@ index f52d0f6..e0a7d3c 100644 + optimumPlatform.EndMotionWrite(); + } + } - shaderProgramDecals.Stop(); game.Platform.GlToggleBlend(on: true); game.Platform.GlEnableCullFace(); game.GlPopMatrix(); } + } From ff60ccb6544e328f964f9316f1f6e313a39604e1 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 07:52:41 +0200 Subject: [PATCH 046/226] wip(taa): P5 acceptance and performance harness - build, patches and all tests green Adds the tooling P5 needs before the acceptance matrix can be run; no renderer behaviour changes and nothing new runs on the GPU. - scripts/dev/perf-capture.sh: launches through scripts/dev/run-client.sh with RENDERER=, optionally rewrites "Taa" in optimum.json, waits for the "[Client Chat] Welcome" line plus an 8 s warm-up, records a 30 s window, closes through scripts/dev/kill-client.sh and prints mean / 1% low / worst frame time plus a summary.csv. It re-reads the renderer out of the client log and refuses to report numbers on a silent OpenGL fallback (rule 1). It never pattern-kills. - build/VintagestoryLib ClientMain: OptimumLogFrameTime writes one line per second ("[Optimum] fps window= frames= mean= min= max= p99=") when OPTIMUM_FPS_LOG names a file. Off otherwise - one null check per frame - so TAA off is unchanged. Backend-neutral, unlike OPTIMUM_VULKAN_STATS, which only exists on Vulkan. Members and the method are listed in Optimum.Patcher/Program.cs; the caller MainRenderLoop is an existing transplant target. - scripts/dev/luma-diff.py: the still-frame luminance diff from the parity skill (centre 60% crop, --median over seven pairs), so the doc can name a real command. - docs/taa-acceptance.md: the P5 matrix as a checklist - 17 visual rows plus byte-identical-off, performance and memory, each with scene, exact commands, pass criterion and the measurement to record; preconditions (creative, wind stilled, storms off, noon, clear sky); the P4 items carried into P5. docs/ is git-ignored, so .gitignore gains a docs/* + negation for this one deliverable. - Optimum.Tests/taa-acceptance-harness-coverage-tests.cs: the doc names every matrix row TAA-PLAN.md lists, each row carries its four parts, the script drives the dev scripts and confirms the renderer, and the patcher ships the log members. Verified: bash scripts/extract-patches.sh; bash scripts/check-patches.sh (93 applied, 64 cecil, 0 pending/conflict; runtime patches 22 applied and exact donors compiled); dotnet build VintageStory.slnx -c Release (0 errors); dotnet test Optimum.Tests -c Release (929 passed, 34 skipped, 0 failed); dotnet test Optimum.Render.Vulkan.Tests (330 passed, 0 failed). NOT verified: the game was never launched (task prohibition), so the capture script has not been run end to end against a live client. --- .gitignore | 7 +- Optimum.Patcher/Program.cs | 9 + .../taa-acceptance-harness-coverage-tests.cs | 195 ++++++++++++++ docs/taa-acceptance.md | 239 ++++++++++++++++++ .../ClientMain.cs.patch | 122 +++++++-- scripts/dev/luma-diff.py | 59 +++++ scripts/dev/perf-capture.sh | 197 +++++++++++++++ 7 files changed, 809 insertions(+), 19 deletions(-) create mode 100644 Optimum.Tests/taa-acceptance-harness-coverage-tests.cs create mode 100644 docs/taa-acceptance.md create mode 100755 scripts/dev/luma-diff.py create mode 100755 scripts/dev/perf-capture.sh diff --git a/.gitignore b/.gitignore index 6ff83733..74acfb5f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,7 +66,12 @@ graphify-out/ __pycache__/ # Private development artifacts -docs/ +# (docs/* rather than docs/, so the one tracked deliverable below can be excepted - +# a negation inside a fully excluded directory is never reconsidered by git.) +docs/* +# ...except the TAA acceptance checklist: it is a deliverable the P5 coverage test +# (Optimum.Tests/taa-acceptance-harness-coverage-tests.cs) reads, so it has to be tracked. +!docs/taa-acceptance.md build-linux.sh build-macos.sh build-windows.ps1 diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index b6d10d31..86c75cd7 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -366,6 +366,15 @@ // (new property; the jittered getter itself is an existing transplant // target below). "CurrentProjectionMatrixUnjittered", + // TAA P5: OPTIMUM_FPS_LOG per-second frame-time line, read by + // scripts/dev/perf-capture.sh. Called from MainRenderLoop (a transplant + // target below); inert unless the env var names a file. + "optimumFpsLogPath", + "optimumFpsLogResolved", + "optimumFpsLogSamples", + "optimumFpsLogFrames", + "optimumFpsLogSeconds", + "OptimumLogFrameTime", }, ["Vintagestory.Client.NoObf.RenderAPIGame"] = new() { diff --git a/Optimum.Tests/taa-acceptance-harness-coverage-tests.cs b/Optimum.Tests/taa-acceptance-harness-coverage-tests.cs new file mode 100644 index 00000000..f3ebef42 --- /dev/null +++ b/Optimum.Tests/taa-acceptance-harness-coverage-tests.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// TAA P5 acceptance + performance harness. Mechanical coverage only: the capture +/// script and the acceptance document exist, the document names every row of the +/// P5 acceptance matrix as TAA-PLAN.md words it, and the client's per-second +/// frame-time log is wired and shipped by the Cecil patcher. +/// +public class TaaAcceptanceHarnessCoverageTests +{ + /// + /// The P5 acceptance matrix, verbatim from TAA-PLAN.md. Both the plan and + /// docs/taa-acceptance.md must name every one of these. + /// + private static readonly string[] MatrixRows = + { + "moving silhouettes on contrast", + "transparent foreground and background motion", + "thin fences", + "hand/world FOV", + "quern/gear", + "dropped items", + "fire", + "rain", + "clouds", + "aurora", + "underwater transitions", + "camera modes (shake, third person, mounted)", + "reference rebase", + "chunk replacement", + "shader reload", + "missing-resource fallback", + "normal/scaled/mega screenshots", + }; + + [Fact] + public void AcceptanceDocumentNamesEveryMatrixRowThePlanLists() + { + string plan = Collapse(Read("TAA-PLAN.md")); + string doc = Collapse(Read("docs/taa-acceptance.md")); + + foreach (string row in MatrixRows) + { + Assert.Contains(row, plan, StringComparison.Ordinal); + Assert.Contains(row, doc, StringComparison.Ordinal); + } + } + + [Fact] + public void EveryMatrixRowIsANumberedChecklistRowWithItsFourParts() + { + string doc = Read("docs/taa-acceptance.md"); + + for (int i = 0; i < MatrixRows.Length; i++) + { + string heading = "### A" + (i + 1) + ". " + MatrixRows[i]; + Assert.Contains(heading, doc, StringComparison.Ordinal); + + // Each row carries the scene, the commands, the pass criterion and the + // measurement to record - the four parts the task asks a row to have. + string body = Section(doc, heading); + Assert.Contains("- Scene:", body); + Assert.Contains("- Commands:", body); + Assert.Contains("- Pass:", body); + Assert.Contains("- Record:", body); + } + + // The two non-visual rows the plan also demands, plus the byte-identical check. + Assert.Contains("### A18. TAA off is byte-identical", doc); + Assert.Contains("### P1. Performance on the Arc 140V", doc); + Assert.Contains("### P2. Memory at 1080p", doc); + } + + [Fact] + public void AcceptanceDocumentFixesTheMeasurementAndTheFrozenScene() + { + string doc = Read("docs/taa-acceptance.md"); + + // Still-frame luminance diff, from the parity skill: centre 60% crop, seven + // pairs, compare medians. + Assert.Contains("centre 60% crop", doc); + Assert.Contains("seven pairs", doc); + Assert.Contains("medians", doc); + Assert.Contains("scripts/dev/luma-diff.py", doc); + + // Wind stilled, storms off, creative - otherwise the scene moves on its own. + Assert.Contains("/weather setw still", doc); + Assert.Contains("/weather setprecip -1", doc); + Assert.Contains("/gamemode creative", doc); + + // A launch is not a verification. + Assert.Contains("scripts/dev/client-renderer.sh", doc); + Assert.Contains("scripts/dev/perf-capture.sh", doc); + } + + [Fact] + public void PerfCaptureScriptDrivesTheDocumentedRunThroughTheDevScripts() + { + string script = Read("scripts/dev/perf-capture.sh"); + + Assert.Contains("scripts/dev/run-client.sh", script); + Assert.Contains("scripts/dev/kill-client.sh", script); + Assert.Contains("RENDERER=\"$RENDERER_ARG\"", script); + Assert.Contains("[Client Chat] Welcome", script); + Assert.Contains("OPTIMUM_FPS_LOG", script); + Assert.Contains("OPTIMUM_VULKAN_STATS", script); + Assert.Contains("SECONDS_WINDOW=30", script); + Assert.Contains("WARMUP=8", script); + Assert.Contains("data[\"Taa\"] = value", script); + Assert.Contains("mean frame time", script); + Assert.Contains("1%% low frame time", script); + + // The renderer is read back from the log, never assumed from the argument. + Assert.Contains("(Vulkan|OpenGL) renderer", script); + Assert.Contains("refusing to report numbers", script); + + // Rule 5: pattern-killing belongs to kill-client.sh alone. + Assert.DoesNotContain("pkill", script); + Assert.DoesNotContain("pgrep", script); + } + + [Fact] + public void ClientLogsFrameTimesPerSecondOnlyWhenTheEnvVarIsSet() + { + string clientMain = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + Assert.Contains("OptimumLogFrameTime(dt);", clientMain); + Assert.Contains("Environment.GetEnvironmentVariable(\"OPTIMUM_FPS_LOG\")", clientMain); + Assert.Contains("\"[Optimum] fps window={0:F3} frames={1} mean={2:F3} min={3:F3} max={4:F3} p99={5:F3}\"", clientMain); + // Off by default: no path, no work beyond the null check, so TAA off (and + // every ordinary run) is unchanged. + Assert.Contains("if (optimumFpsLogPath == null", clientMain); + // One line per second, not per frame. + Assert.Contains("if (optimumFpsLogSeconds < 1.0)", clientMain); + } + + [Fact] + public void CecilPatcherShipsTheFrameTimeLogMembers() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + Assert.Contains("\"optimumFpsLogPath\"", patcher); + Assert.Contains("\"optimumFpsLogResolved\"", patcher); + Assert.Contains("\"optimumFpsLogSamples\"", patcher); + Assert.Contains("\"optimumFpsLogFrames\"", patcher); + Assert.Contains("\"optimumFpsLogSeconds\"", patcher); + Assert.Contains("\"OptimumLogFrameTime\"", patcher); + // The caller is an existing transplant target; without it the injected + // method would never run. + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"MainRenderLoop\", 1", patcher); + } + + private static string Section(string document, string heading) + { + int start = document.IndexOf(heading, StringComparison.Ordinal); + Assert.True(start >= 0, "missing heading: " + heading); + int end = document.IndexOf("\n###", start + heading.Length, StringComparison.Ordinal); + return end < 0 ? document.Substring(start) : document.Substring(start, end - start); + } + + private static string Collapse(string value) + { + return Regex.Replace(value, "\\s+", " "); + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md new file mode 100644 index 00000000..b0e5e23a --- /dev/null +++ b/docs/taa-acceptance.md @@ -0,0 +1,239 @@ +# TAA acceptance matrix (TAA-PLAN.md P5) + +The P5 acceptance matrix as a runnable checklist. Every row is run **twice, once per +backend**, with the renderer confirmed from the log, and **TAA on vs off**. Nothing here +is passed on a launch alone: rule 1 of `CLAUDE.md` says a launch is not a verification. + +Tooling used by this document: + +| Tool | What it does | +|---|---| +| `scripts/dev/run-client.sh` | detached launch, `RENDERER=vulkan\|opengl` rewrites `optimum.json` | +| `scripts/dev/client-renderer.sh` | which renderer actually started - read it every time | +| `scripts/dev/screenshot.sh` | one PNG of the active window | +| `scripts/dev/kill-client.sh` | clean close; close as soon as a row is done | +| `scripts/dev/perf-capture.sh` | launch, warm up, record 30 s of frame times, close, print mean and 1% low | +| `scripts/dev/luma-diff.py` | still-frame luminance diff (parity skill section 2c) | + +## 0. Preconditions for every row + +Run once per session, in the world, before any measurement. Without these the scene moves +on its own and every luminance number is noise rather than temporal instability. + +``` +/gamemode creative # hunger damage and mob pressure change the picture mid-run +/time set 12:00 # fixed sun angle +/weather set clearsky # no storm lighting +/weather setprecip -1 # storms off +/weather setw still # wind stilled: foliage sway otherwise dominates the diff +/tprivate off # optional: keep chat overlays out of the crop +``` + +Renderer and TAA state, before each run: + +``` +scripts/dev/client-renderer.sh # must print "[Optimum] Vulkan renderer" or "[Optimum] OpenGL renderer:" +jq '.Renderer, .Taa, .TaaSharpness, .TaaMipBias' ~/.config/OptimumVintagestoryData/ModConfig/optimum.json +``` + +## 1. The measurement to record + +**Still-frame luminance diff** (`.claude/skills/vulkan-parity-debug/SKILL.md` section 2c). +Still camera, screenshot pairs one second apart, mean absolute luminance difference over the +centre 60% crop, **seven pairs per backend**, compare the **medians** - never a single pair. + +``` +for i in 1 2 3 4 5 6 7; do + scripts/dev/screenshot.sh /tmp/taa-shots/a$i.png; sleep 1 + scripts/dev/screenshot.sh /tmp/taa-shots/b$i.png; sleep 1 +done +scripts/dev/luma-diff.py --median /tmp/taa-shots/*.png +``` + +Reference from the TAA round: Vulkan 1.84 vs OpenGL 1.87 (medians 1.74 / 1.72). **Above ~3 on +one backend only is a real bug**; equal-but-high on both backends means the scene is still +moving, so go back to section 0. A screenshot pair cannot see one-frame alternation (P4 +addendum): for shimmer suspicions read the validation log with +`OPTIMUM_VULKAN_VALIDATION=1`, not the client log. + +Record per row: backend, TAA on/off, the seven diffs, the median, and the screenshot paths. + +Debug views (`TaaDebugView` in `optimum.json`, read by `taa-debug.fsh`) are the diagnosis tool +when a row fails, not the pass criterion: `1` motion as colour, `2` reactive mask, `3` validity +(green written-and-matching, red rejected, black never written), `4` scene with motion overlay. + +## 2. Acceptance rows + +Each row: the save/scene to reach, the exact commands, what "pass" looks like, and the +measurement to record. "TAA off byte-identical" is checked once, in row A18, not per row. + +### A1. moving silhouettes on contrast +- Scene: a dark tree line or a player silhouette against bright sky, camera panning slowly. +- Commands: `RENDERER=vulkan scripts/dev/run-client.sh "serene cave world"`; section 0; pan with the mouse at a constant rate; `scripts/dev/screenshot.sh /tmp/taa-shots/a1--.png`. +- Pass: the silhouette edge is smooth while moving and shows no trailing smear behind it; the edge is not softer with TAA on than FXAA gives when still. +- Record: still-frame luminance diff median (section 1) plus one in-motion screenshot per backend for the smear check. + +### A2. transparent foreground and background motion +- Scene: glass blocks or a waterfall in front of moving terrain, then the same with the transparent surface itself moving past a static background. +- Commands: section 0; place glass in creative in front of a waterfall; pan; screenshot pairs; `TaaDebugView=2` to read the reactive mask from the OIT merge. +- Pass: no ghost of the background through the transparent surface and no ghost of the transparent surface on the background; reactive is non-zero where the transparent surface is (view 2) except where a later writer legitimately claims the pixel (finding (y)). +- Record: luminance diff median; a `TaaDebugView=2` screenshot per backend. + +### A3. thin fences +- Scene: a long fence run against sky, viewed at a shallow angle so posts are sub-pixel. +- Commands: section 0; build or find a fence line; still camera for the diff, then a slow strafe. +- Pass: posts stay continuous while strafing, no dropouts and no crawling; sub-pixel rails do not flicker between frames. +- Record: luminance diff median (still) plus a 2 s strafe capture per backend. + +### A4. hand/world FOV +- Scene: first-person hands with an item held, world geometry behind, camera turning. +- Commands: section 0; hold a torch or a tool; turn; also toggle the hand-FOV setting in the settings GUI mid-run. +- Pass: the held item does not ghost against the turning world and the world does not ghost against the held item; the hand keeps its own previous projection (P3 hand-FOV rule). +- Record: luminance diff median; `TaaDebugView=3` screenshot (hands must be green, not black). + +### A5. quern/gear +- Scene: a running quern and a mechanical-power gear network (windmill or a hand-cranked line). +- Commands: section 0; power a quern; stand so both the quern top and several gears are on screen; still camera. +- Pass: the rotating quern top and every instanced gear are sharp while turning, no smear ring, no stutter in the instance transforms. +- Record: luminance diff median with the mechanism running (this row is expected to be above the static baseline - compare the two backends to each other, not to 1.74); `TaaDebugView=1` screenshot showing motion on the moving parts only. + +### A6. dropped items +- Scene: a pile of dropped items bobbing, plus one item thrown past the camera. +- Commands: section 0; `/giveblock` or drop a stack; watch the bob for 10 s. +- Pass: the bobbing items do not leave a vertical smear; a thrown item has a clean leading edge. +- Record: luminance diff median with items on screen; one in-motion screenshot per backend. + +### A7. fire +- Scene: a firepit or a torch cluster, still camera. +- Commands: section 0; light a firepit; frame it centre-screen. +- Pass: flame particles stay crisp, no accumulation haze around the flame, the geometry behind the flame keeps its own anti-aliasing (the cube-particle reactive-1 question carried from P4). +- Record: luminance diff median with the fire in the crop and, for comparison, one with the fire outside the crop. + +### A8. rain +- Scene: falling rain over terrain. +- Commands: section 0 except `/weather setprecip 1`; then restore `-1` afterwards. +- Pass: raindrops do not smear into streaks beyond their real length and the terrain behind them stays anti-aliased. +- Record: luminance diff median (expected high on both backends - compare backends to each other). + +### A9. clouds +- Scene: volumetric clouds overhead, camera pointed up, then a slow pan along the horizon. +- Commands: section 0; look up; also set `TaaDebugView=2` to read the cloud reactive value. +- Pass: cloud edges do not shimmer and do not smear when panning; the sky's own anti-aliasing survives (the reactive-1 cost question and the `mix(coverage, 1, coverage)` curve, carried from P4). +- Record: luminance diff median looking up; `TaaDebugView=2` screenshot per backend. + +### A10. aurora +- Scene: night sky with aurora active. +- Commands: `/time set 0:00`; face north; still camera. +- Pass: the aurora ribbons move without leaving a persistent trail and without stepping. +- Record: luminance diff median (expected above the static baseline; compare backends). + +### A11. underwater transitions +- Scene: swim from above water to below and back, repeatedly. +- Commands: section 0; find water at least three deep; cross the surface slowly, then quickly. +- Pass: the frame after the transition shows no history from the other medium - no blue haze above water, no sky in the underwater frame; the liquid velocity pass does not break block outlines on submerged blocks or SSAO near water (carried from P4). +- Record: a screenshot of the first frame after each crossing per backend; luminance diff median while fully submerged and still. + +### A12. camera modes (shake, third person, mounted) +- Scene: the same spot in first person, third person (`F5`), on a mount, and with camera shake active (mining, or an explosion). +- Commands: section 0; cycle `F5`; ride a mount; mine a block for the shake. +- Pass: every mode converges - no permanent blur in third person, no ghost of the player model, shake does not smear the world. +- Record: luminance diff median per mode, still camera, per backend. + +### A13. reference rebase +- Scene: walk far enough for the camera reference position to rebase (`PlayerCamera.cs:74-76`). +- Commands: section 0; `/tp ~5000 ~ ~5000`, then walk across the rebase boundary on foot. +- Pass: the rebase frame shows a clean reset - one frame of aliasing at worst - and never a whole-screen smear; the log records a reset reason. +- Record: a screenshot of the rebase frame per backend; whether the reset reason appears. + +### A14. chunk replacement +- Scene: a chunk remeshing under the camera (place and break blocks; or fly to the edge of loaded terrain and back). +- Commands: section 0; break and place a wall of blocks while looking at it. +- Pass: newly meshed geometry is anti-aliased within a few frames, with no stale history bleeding from the old mesh. +- Record: luminance diff median after the mesh settles; one screenshot of the replacement frame. + +### A15. shader reload +- Scene: any world scene; reload shaders from the settings GUI (or the debug command). +- Commands: section 0; trigger a shader reload; then again with TAA on and render scale < 1. +- Pass: the reload raises a temporal reset, the image recovers within a few frames, and no NaN or black frame survives. +- Record: screenshot immediately after the reload and 2 s later, per backend. + +### A16. missing-resource fallback +- Scene: TAA requested but the resolve cannot run. +- Commands: with the client closed, move `taa-resolve.fsh` out of `.vanilla/win-x64/vintagestory/assets/game/shaders/`, launch with `Taa: true`, then restore the file and `make deploy` afterwards. +- Pass: the client starts, falls back to FXAA, logs the reason, sets `TaaRuntimeDisabled`, and never renders a black or garbage frame; the settings row reflects the runtime disable. +- Record: the log lines proving the fallback, per backend; one screenshot. + +### A17. normal/scaled/mega screenshots +- Scene: any settled scene. +- Commands: a normal screenshot (`F12` or the in-game binding); a scaled screenshot; a mega screenshot. +- Pass: normal and scaled screenshots match the screen; the mega capture is not corrupted by the temporal window (it uses warm-up or a spatial-only path per the plan) and shows no tile seams from stale history. +- Record: the three files per backend and whether the mega capture took the warm-up or the spatial-only path. + +### A18. TAA off is byte-identical +- Scene: any fixed scene, camera parked and not touched between the two runs. +- Commands: run once with `Taa: false` on the current build and once with `Taa: false` on the pre-TAA commit, same save, same settings, same window size; compare the screenshots byte for byte (`cmp`) and with `scripts/dev/luma-diff.py`. +- Pass: `cmp` reports identical files, or the luminance diff is exactly 0.000. +- Record: the `cmp` result and the diff value, per backend. + +## 3. Performance + +### P1. Performance on the Arc 140V +- Scene: a fixed, repeatable walk or a parked camera in a busy scene; the same scene for all four runs. +- Commands: + +``` +scripts/dev/perf-capture.sh --renderer vulkan --taa off --label vk-off +scripts/dev/perf-capture.sh --renderer vulkan --taa on --label vk-on +scripts/dev/perf-capture.sh --renderer opengl --taa off --label gl-off +scripts/dev/perf-capture.sh --renderer opengl --taa on --label gl-on +``` + + Each run confirms the renderer from the log before it reports anything, warms up 8 s after + the `[Client Chat] Welcome` line, records 30 s and closes the client itself. Per-run output + lands in `/tmp/optimum-perf/ + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) - { ++ { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1860,7 +1860,7 @@ index 6edf0c9..9e9f889 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) -+ { + { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. @@ -2208,7 +2208,7 @@ index 6edf0c9..9e9f889 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,41 +3313,294 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +3313,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -2222,8 +2222,9 @@ index 6edf0c9..9e9f889 100644 CurrentFrameBufferKeepVw = frameBuffers[0]; return; } - CurrentFrameBufferKeepVw = null; +- CurrentFrameBufferKeepVw = null; - GL.DrawBuffer((DrawBufferMode)1029); ++ CurrentFrameBufferKeepVw = null; + // Selecting GL_BACK has no device equivalent: binding the default target + // already means the swapchain image. + if (Vintagestory.API.Config.OptimumRender.Device == null) @@ -2235,13 +2236,13 @@ index 6edf0c9..9e9f889 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) +@@ -1793,22 +3338,262 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + CurrentFrameBufferKeepVw = frameBuffers[0]; + } + else { -- CurrentFrameBufferKeepVw = frameBuffers[0]; -+ CurrentFrameBufferKeepVw = frameBuffers[0]; -+ } -+ else -+ { -+ CurrentFrameBufferKeepVw = null; + CurrentFrameBufferKeepVw = null; +- GL.DrawBuffer((DrawBufferMode)1029); + // Mono.Cecil transplant. + // Selecting GL_BACK has no device counterpart - the default target is + // already the swapchain image. @@ -2284,14 +2285,17 @@ index 6edf0c9..9e9f889 100644 + if (optimumMotionWrite) + { + ApplyOptimumMotionAccumulateBlendState(); -+ } -+ ShaderProgramTransparentcompose transparentcompose = ShaderPrograms.Transparentcompose; -+ transparentcompose.Use(); -+ transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; -+ transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; -+ transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; -+ RenderFullscreenTriangle(screenQuad); -+ transparentcompose.Stop(); + } +- GL.Disable((EnableCap)2929); +- GL.Enable((EnableCap)3042); +- GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); + ShaderProgramTransparentcompose transparentcompose = ShaderPrograms.Transparentcompose; + transparentcompose.Use(); + transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; + transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; + transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; + RenderFullscreenTriangle(screenQuad); + transparentcompose.Stop(); + if (optimumMotionWrite) + { + // Back to replace before the window closes, so no later pass can @@ -2313,16 +2317,9 @@ index 6edf0c9..9e9f889 100644 + /// the same thing, but the per-attachment blend seam already exists on both + /// backends and a per-attachment colour mask does not. + /// -+ private void ApplyOptimumMotionAccumulateBlendState() ++ public override void ApplyOptimumMotionAccumulateBlendState() + { + if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); -+ optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1); -+ return; -+ } + GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774); + GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)1); + } @@ -2347,25 +2344,14 @@ index 6edf0c9..9e9f889 100644 + { + _taaHistoryValid = false; + return false; - } -- else ++ } + FrameBufferRef write = TaaHistory(_taaFrameParity); + FrameBufferRef read = TaaHistory(_taaFrameParity + 1); + if (write == null || read == null) - { -- CurrentFrameBufferKeepVw = null; -- GL.DrawBuffer((DrawBufferMode)1029); ++ { + _taaHistoryValid = false; + return false; - } -- GL.Disable((EnableCap)2929); -- GL.Enable((EnableCap)3042); -- GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); -- ShaderProgramTransparentcompose transparentcompose = ShaderPrograms.Transparentcompose; -- transparentcompose.Use(); -- transparentcompose.Revealage2D = frameBuffers[1].ColorTextureIds[1]; -- transparentcompose.Accumulation2D = frameBuffers[1].ColorTextureIds[0]; -- transparentcompose.InGlow2D = frameBuffers[1].ColorTextureIds[2]; ++ } + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + float[] projection = frame.GetProjection(EnumTemporalView.World); @@ -2415,8 +2401,7 @@ index 6edf0c9..9e9f889 100644 + resolve.Uniform("resetHistory", reset ? 1 : 0); + resolve.Uniform("blendAlpha", 0.1f); + resolve.Uniform("varianceGamma", 1.25f); - RenderFullscreenTriangle(screenQuad); -- transparentcompose.Stop(); ++ RenderFullscreenTriangle(screenQuad); + resolve.Stop(); + + taaResolvedColorTexture = write.ColorTextureIds[0]; @@ -2518,7 +2503,7 @@ index 6edf0c9..9e9f889 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3615,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3608,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2555,7 +2540,7 @@ index 6edf0c9..9e9f889 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3654,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3647,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2617,7 +2602,7 @@ index 6edf0c9..9e9f889 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3731,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3724,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2665,7 +2650,7 @@ index 6edf0c9..9e9f889 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3781,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3774,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2701,7 +2686,7 @@ index 6edf0c9..9e9f889 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3828,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3821,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2742,7 +2727,7 @@ index 6edf0c9..9e9f889 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,19 +3879,476 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3872,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2861,23 +2846,7 @@ index 6edf0c9..9e9f889 100644 + // pass outside Primary has to do anyway. + if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false; + -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); -+ } -+ else -+ { -+ if (optimumMotionDrawBuffersOn == null || optimumMotionDrawBuffersOn.Length != MotionAttachmentIndex + 1) -+ { -+ optimumMotionDrawBuffersOn = new DrawBuffersEnum[MotionAttachmentIndex + 1]; -+ for (int optimumDb = 0; optimumDb <= MotionAttachmentIndex; optimumDb++) -+ { -+ optimumMotionDrawBuffersOn[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); -+ } -+ } -+ GL.DrawBuffers(optimumMotionDrawBuffersOn.Length, optimumMotionDrawBuffersOn); -+ } ++ EnableMotionDrawBuffers(); + optimumMotionWriteActive = true; + // Blending is per-attachment state that GlToggleBlend re-applies whenever + // a pass turns blending on; motion must never blend, so the mask change @@ -2887,6 +2856,24 @@ index 6edf0c9..9e9f889 100644 + } + + /// ++ /// Optimum TAA (P3): the GL half of - Primary's ++ /// default colour set plus the motion attachment. VulkanClientPlatform sets the ++ /// same set as a device mask. ++ /// ++ public override void EnableMotionDrawBuffers() ++ { ++ if (optimumMotionDrawBuffersOn == null || optimumMotionDrawBuffersOn.Length != MotionAttachmentIndex + 1) ++ { ++ optimumMotionDrawBuffersOn = new DrawBuffersEnum[MotionAttachmentIndex + 1]; ++ for (int optimumDb = 0; optimumDb <= MotionAttachmentIndex; optimumDb++) ++ { ++ optimumMotionDrawBuffersOn[optimumDb] = (DrawBuffersEnum)(36064 + optimumDb); ++ } ++ } ++ GL.DrawBuffers(optimumMotionDrawBuffersOn.Length, optimumMotionDrawBuffersOn); ++ } ++ ++ /// + /// Optimum TAA (P3): restores Primary's default draw-buffer set (the 2 or 4 + /// colour attachments the setup left bound), taking the motion attachment + /// back out. Safe to call when returned false. @@ -2898,15 +2885,17 @@ index 6edf0c9..9e9f889 100644 + if (MotionAttachmentIndex < 0) return; + if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return; + -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // MotionAttachmentIndex is also the size of the default set (2 without -+ // the SSAO G-buffer, 4 with it), because the attachment was appended -+ // after it. -+ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); -+ return; -+ } ++ RestorePrimaryDrawBuffers(); ++ } ++ ++ /// ++ /// Optimum TAA (P3): the GL half of - Primary's ++ /// default colour set, whose size is (2 ++ /// without the SSAO G-buffer, 4 with it), because the attachment was appended ++ /// after it. ++ /// ++ public override void RestorePrimaryDrawBuffers() ++ { + if (optimumMotionDrawBuffersOff == null || optimumMotionDrawBuffersOff.Length != MotionAttachmentIndex) + { + optimumMotionDrawBuffersOff = new DrawBuffersEnum[MotionAttachmentIndex]; @@ -2924,16 +2913,9 @@ index 6edf0c9..9e9f889 100644 + /// is a weighted average of two surfaces' displacements, which belongs to + /// neither. Mirrors what the GL path already does for the SSAO G-buffer. + /// -+ private void ApplyOptimumMotionBlendState() ++ public override void ApplyOptimumMotionBlendState() + { + if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774); -+ optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0); -+ return; -+ } + GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774); + GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0); + } @@ -2979,29 +2961,30 @@ index 6edf0c9..9e9f889 100644 + if (frameBuffers == null || frameBuffers.Count == 0 || frameBuffers[0] == null) return false; + if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false; + -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, 1 << MotionAttachmentIndex); -+ } -+ else ++ EnableMotionOnlyDrawBuffers(); ++ optimumMotionWriteActive = true; ++ ApplyOptimumMotionBlendState(); ++ return true; ++ } ++ ++ /// ++ /// Optimum TAA (P4): the GL half of - the ++ /// motion attachment alone, GL_NONE in every other slot. ++ /// ++ public override void EnableMotionOnlyDrawBuffers() ++ { ++ if (optimumMotionOnlyDrawBuffers == null || optimumMotionOnlyDrawBuffers.Length != MotionAttachmentIndex + 1) + { -+ if (optimumMotionOnlyDrawBuffers == null || optimumMotionOnlyDrawBuffers.Length != MotionAttachmentIndex + 1) ++ optimumMotionOnlyDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1]; ++ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) + { -+ optimumMotionOnlyDrawBuffers = new DrawBuffersEnum[MotionAttachmentIndex + 1]; -+ for (int optimumDb = 0; optimumDb < MotionAttachmentIndex; optimumDb++) -+ { -+ // GL_NONE: a fragment output bound to this draw buffer is -+ // discarded, which is what keeps the shaded image intact. -+ optimumMotionOnlyDrawBuffers[optimumDb] = (DrawBuffersEnum)0; -+ } -+ optimumMotionOnlyDrawBuffers[MotionAttachmentIndex] = (DrawBuffersEnum)(36064 + MotionAttachmentIndex); ++ // GL_NONE: a fragment output bound to this draw buffer is ++ // discarded, which is what keeps the shaded image intact. ++ optimumMotionOnlyDrawBuffers[optimumDb] = (DrawBuffersEnum)0; + } -+ GL.DrawBuffers(optimumMotionOnlyDrawBuffers.Length, optimumMotionOnlyDrawBuffers); ++ optimumMotionOnlyDrawBuffers[MotionAttachmentIndex] = (DrawBuffersEnum)(36064 + MotionAttachmentIndex); + } -+ optimumMotionWriteActive = true; -+ ApplyOptimumMotionBlendState(); -+ return true; ++ GL.DrawBuffers(optimumMotionOnlyDrawBuffers.Length, optimumMotionOnlyDrawBuffers); + } + + /// @@ -3181,14 +3164,7 @@ index 6edf0c9..9e9f889 100644 + CurrentFrameBufferKeepVw = optimumFsrFramebuffer; + // Mono.Cecil transplant. + GlViewport(0, 0, optimumFsrFramebuffer.Width, optimumFsrFramebuffer.Height); -+ if (Vintagestory.API.Config.OptimumRender.Device != null) -+ { -+ Vintagestory.API.Config.OptimumRender.Device.SetDrawBuffers(optimumFsrFramebuffer.FboId, 1); -+ } -+ else -+ { -+ GL.DrawBuffer((DrawBufferMode)36064); -+ } ++ SelectFsrDrawBuffer(optimumFsrFramebuffer); + fsrEasu.Use(); + fsrEasu.BindTexture2D("inputScene", scene2D, 0); + fsrEasu.Uniform("inputTexelSize", 1f / (float)frameBuffers[0].Width, 1f / (float)frameBuffers[0].Height); @@ -3220,7 +3196,24 @@ index 6edf0c9..9e9f889 100644 blit.Scene2D = scene2D; RenderFullscreenTriangle(screenQuad); blit.Stop(); -@@ -2083,10 +4396,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + } + } + ++ /// ++ /// Optimum FSR: the GL half of the EASU pass's target selection in ++ /// ; glDrawBuffer applies to the bound target. ++ /// ++ public override void SelectFsrDrawBuffer(FrameBufferRef target) ++ { ++ GL.DrawBuffer((DrawBufferMode)36064); ++ } ++ + private void CheckFboStatus(FramebufferTarget target, EnumFrameBuffer fbtype) + { + //IL_0001: Unknown result type (might be due to invalid IL or missing references) + CheckFboStatus(target, fbtype.ToString() ?? ""); + } +@@ -2083,10 +4389,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_000e: Unknown result type (might be due to invalid IL or missing references) @@ -3246,7 +3239,7 @@ index 6edf0c9..9e9f889 100644 OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); if ((int)error != 0) { -@@ -2101,10 +4429,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2101,10 +4422,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0005: Unknown result type (might be due to invalid IL or missing references) //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0050: Unknown result type (might be due to invalid IL or missing references) @@ -3268,7 +3261,7 @@ index 6edf0c9..9e9f889 100644 { string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +4458,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2119,48 +4451,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public unsafe override string GlGetError() { //IL_0000: Unknown result type (might be due to invalid IL or missing references) @@ -3366,7 +3359,7 @@ index 6edf0c9..9e9f889 100644 GL.Enable((EnableCap)3089); } else -@@ -2169,97 +4557,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2169,97 +4550,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3569,7 +3562,7 @@ index 6edf0c9..9e9f889 100644 if (on) { GL.Enable((EnableCap)2848); -@@ -2273,74 +4766,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2273,74 +4759,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3736,7 +3729,7 @@ index 6edf0c9..9e9f889 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4936,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4929,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3760,7 +3753,7 @@ index 6edf0c9..9e9f889 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4965,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4958,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3798,7 +3791,7 @@ index 6edf0c9..9e9f889 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2395,15 +5020,47 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2395,15 +5013,47 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (bmp == null) { @@ -3851,7 +3844,7 @@ index 6edf0c9..9e9f889 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +5078,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +5071,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3910,7 +3903,7 @@ index 6edf0c9..9e9f889 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +5193,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +5186,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3984,7 +3977,7 @@ index 6edf0c9..9e9f889 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +5291,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +5284,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -4015,7 +4008,7 @@ index 6edf0c9..9e9f889 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +5328,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +5321,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -4050,7 +4043,7 @@ index 6edf0c9..9e9f889 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +5375,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,15 +5368,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -4079,7 +4072,7 @@ index 6edf0c9..9e9f889 100644 { GL.GetInteger((GetPName)3379, out result); } -@@ -2581,10 +5406,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +5399,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -4105,7 +4098,7 @@ index 6edf0c9..9e9f889 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,22 +5433,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,22 +5426,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -4196,7 +4189,7 @@ index 6edf0c9..9e9f889 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +5559,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5552,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -4241,7 +4234,7 @@ index 6edf0c9..9e9f889 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5596,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5589,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4262,7 +4255,7 @@ index 6edf0c9..9e9f889 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5615,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5608,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4283,7 +4276,7 @@ index 6edf0c9..9e9f889 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5634,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5627,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4304,7 +4297,7 @@ index 6edf0c9..9e9f889 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5653,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5646,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4325,7 +4318,7 @@ index 6edf0c9..9e9f889 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5676,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5669,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -4346,7 +4339,7 @@ index 6edf0c9..9e9f889 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5719,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5712,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -4372,7 +4365,7 @@ index 6edf0c9..9e9f889 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5961,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5954,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -4394,7 +4387,7 @@ index 6edf0c9..9e9f889 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +6159,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +6152,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -4417,7 +4410,7 @@ index 6edf0c9..9e9f889 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +6233,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +6226,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -4466,7 +4459,7 @@ index 6edf0c9..9e9f889 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +6303,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +6296,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -4498,7 +4491,7 @@ index 6edf0c9..9e9f889 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +6333,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +6326,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -4524,7 +4517,7 @@ index 6edf0c9..9e9f889 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6681,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6674,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -4853,7 +4846,7 @@ index 6edf0c9..9e9f889 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +7034,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +7027,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } From 2f7de381001d9e647764baf0d0ffecd40ac40d6f Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:43:21 +0200 Subject: [PATCH 088/226] wip(phase1a-step4): fixed-function state, diagnostics and capability reporting move to VulkanClientPlatform The device branches of GlDebugMode, GlScissorFlag(Enabled), GetGraphicsCardRenderer, LogAndTestHardwareInfosStage2 (now virtualized in place), GetGraphicCardInfos, CheckGlError(Always), GlGetError, GetGLShaderVersionString, GenSampler, the Gl* state setters, BindTexture2d/CubeMap, UnBindTextureCubeMap, GlToggleBlend, SmoothLines, GlGenerateTex2DMipmaps and GlGetMaxTextureSize are overrides in VulkanClientPlatform.State.cs (text moved; scissor flag, bound texture and debug flag remembered on the platform). Every one of those bodies except GlToggleBlend is byte-identical to the vanilla decompile again, so their Program.cs body targets are dropped. ClientPlatformWindows gains the OptimumRenderSsao accessor; optimumScissorEnabled/optimumBoundTexture2d are gone. The renderer references cairo-sharp and OpenTK.Audio.OpenAL compile-only. Verified: renderer + donor build 0 errors; extract + check-patches 0 conflict, 0 pending; Cecil patch (output bin/patch-check) 221/221 required methods, Virtual dispatch verifier ok (0 call/ldftn). --- Optimum.Patcher/Program.cs | 49 +- .../Optimum.Render.Vulkan.csproj | 11 + .../Platform/VulkanClientPlatform.State.cs | 293 ++++++ .../Platform/VulkanClientPlatform.cs | 1 + .../ClientPlatformWindows.cs.patch | 978 +++--------------- 5 files changed, 458 insertions(+), 874 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 9d6aea05..0e9770c1 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -219,10 +219,10 @@ "optimumClearG", "optimumClearB", "optimumClearA", - "optimumBoundTexture2d", - "optimumScissorEnabled", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). + // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). + "OptimumRenderSsao", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumGlR32f", @@ -745,37 +745,11 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisableOptimumFsr", 1), // R4: pass the configured god-rays sample limit to the post-process shader. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderPostprocessingEffects", 1), - // Vulkan backend: fixed-function state routes to OptimumRender.Device when a - // device is installed, and runs the untouched vanilla GL body when it is not. - // See VULKAN-BACKEND-PLAN.md section 3. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLWireframes", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlViewport", 4), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlScissor", 4), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlScissorFlag", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlEnableDepthTest", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDisableDepthTest", 0), + // TAA P3: GlToggleBlend re-applies the motion attachment's replace blending. The other + // fixed-function bodies are vanilla again (Phase 1A step 4): VulkanClientPlatform + // overrides them, so they are no longer transplanted. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlToggleBlend", 2), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDisableCullFace", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlEnableCullFace", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLLineWidth", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDepthMask", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDepthFunc", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlCullFaceBack", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlCullFaceFront", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlEnableStencilTest", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlDisableStencilTest", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlStencilMask", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlStencilFunc", 3), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlStencilOp", 3), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlColorMask", 4), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlClearStencil", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGLShaderVersionString", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GenSampler", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BindTexture2d", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BindTextureCubeMap", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLDeleteTexture", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGetMaxTextureSize", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGraphicsCardRenderer", 0), // Vulkan backend: shader staging and linking. CompileShader only stages a // stage on the device path, because GL resolves uniforms and varyings by name // across the whole program and nothing is final until link time. @@ -844,10 +818,8 @@ // are the seam for every render target the client selects. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBuffer", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBufferKeepVw", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_GlDebugMode", 1), // The scissor flag is read back by the runtime atlas upload; the device // keeps no queryable state, so the routed setter remembers it. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "get_GlScissorFlagEnabled", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateFramebuffer", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffer", 2), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffers", 1), @@ -858,13 +830,7 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UnloadFrameBuffer", 1, new[] { "Vintagestory.API.Client.EnumFrameBuffer" }), // Vulkan backend: startup capability reporting, which cannot ask GL. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LogAndTestHardwareInfosStage2", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGraphicCardInfos", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Start", 0), - // Vulkan backend: error reporting comes from the validation layer. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CheckGlError", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CheckGlErrorAlways", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGetError", 0), // Vulkan backend: texture creation, upload and mipmapping. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadCairoTexture", 2), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateCairoTexture", 3), @@ -879,10 +845,7 @@ // menu reaches it, and TextureAtlas.Upload only runs once a world loads. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateTextureFromPixels", 6), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Load3DTextureCube", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlGenerateTex2DMipmaps", 0), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UnBindTextureCubeMap", 0), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlClearColorRgbaf", 4), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "SmoothLines", 1), // Vulkan backend: uniform buffers, whose handles UBO carries across. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateUBO", 4), new("Vintagestory.Client.NoObf.UBO", "Bind", 0), @@ -1102,6 +1065,8 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffers", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFullscreenTriangle", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetGraphicsCardRenderer", 0), + // Phase 1A step 4: VulkanClientPlatform logs the device facts instead. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LogAndTestHardwareInfosStage2", 0), }; int total = ILPatcher.PatchWithInjection( diff --git a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj index 02b17e85..e46563c7 100644 --- a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -57,6 +57,17 @@ ..\.vanilla\win-x64\vintagestory\VintagestoryAPI.dll false + + + ..\.vanilla\win-x64\vintagestory\Lib\cairo-sharp.dll + false + + + ..\.vanilla\win-x64\vintagestory\Lib\OpenTK.Audio.OpenAL.dll + false + diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs new file mode 100644 index 00000000..85bae0fb --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -0,0 +1,293 @@ +using System; +using System.Runtime.InteropServices; +using Cairo; +using OpenTK.Audio.OpenAL; +using Vintagestory.API.Client; +using Vintagestory.Client.NoObf; +using Vintagestory.Common.Convert; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: fixed-function state, diagnostics and capability +// reporting. Each body is the device branch that used to open the same method in +// ClientPlatformWindows, moved unchanged; ClientPlatformWindows keeps the GL body. +public partial class VulkanClientPlatform +{ + // The device takes these as call arguments and keeps no queryable state, so the + // platform remembers what the GL driver would have: the debug flag for its getter, + // the scissor flag the runtime atlas upload reads back, and the texture last bound to + // unit 0 for the argument-less GlGenerateTex2DMipmaps. + private bool debugMode; + private bool scissorEnabled; + private int boundTexture2d; + + public override bool GlDebugMode + { + get + { + return debugMode; + } + set + { + // The device's equivalent is the validation layer, which it enables + // itself; the supportsGlDebugMode check does not apply. + device.DebugMode = value; + debugMode = value; + } + } + + public override bool GlScissorFlagEnabled + { + get + { + return scissorEnabled; + } + } + + public override string GetGraphicsCardRenderer() + { + return device.RendererString; + } + + /// + /// The same facts the GL body logs, from the device. The GL extension test has no + /// meaning here: the device already refused to initialize if it lacked what it needs, + /// and supportsGlDebugMode/supportsPersistentMapping stay false because only GL + /// bodies read them. + /// + public override void LogAndTestHardwareInfosStage2() + { + Logger.Notification("Graphics Backend: " + device.BackendName); + Logger.Notification("Graphics Card Vendor: " + device.VendorString); + Logger.Notification("Graphics Card Version: " + device.VersionString); + Logger.Notification("Graphics Card Renderer: " + device.RendererString); + Logger.Notification("Graphics Card ShadingLanguageVersion: " + device.ShaderVersionString); + Logger.Notification("Max texture size: " + device.MaxTextureSize); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // ClientPlatformWindows.LogFrameworkVersions, which is private. + Logger.Notification("C# Framework: " + GetFrameworkInfos()); + Logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); + } + Logger.Notification("OpenAL Version: " + AL.Get((ALGetString)45058)); + Logger.Notification("Zstd Version: " + ZstdNative.Version); + CheckGlError("loghwinfo"); + } + + /// + /// This text goes into crash reports, so it names the backend too - a crash on + /// Vulkan reads very differently from the same crash on GL. + /// + public override string GetGraphicCardInfos() + { + return "GC Backend: " + device.BackendName + "\nGC Vendor: " + device.VendorString + "\nGC Version: " + device.VersionString + "\nGC Renderer: " + device.RendererString + "\nGC ShaderVersion: " + device.ShaderVersionString; + } + + /// The device drains validation-layer messages instead of a GL error code. + public override void CheckGlError(string errmsg = null) + { + if (GlErrorChecking) + { + string optimumError = device.GetError(); + if (optimumError != null) + { + throw new Exception(((errmsg == null) ? "" : (errmsg + " ")) + "- the graphics backend reported: " + optimumError); + } + } + } + + public override void CheckGlErrorAlways(string errmsg = null) + { + string optimumError = device.GetError(); + if (optimumError != null) + { + Logger.Error(((errmsg == null) ? "" : (errmsg + " ")) + "- the graphics backend reported: " + optimumError); + } + } + + public override string GlGetError() + { + return device.GetError(); + } + + public override string GetGLShaderVersionString() + { + // The client parses this to decide whether a shader's #version is + // supported, so it has to keep reading as a GLSL version number. + return device.ShaderVersionString; + } + + public override int GenSampler(bool linear) + { + return device.CreateSampler(linear); + } + + public override void GLWireframes(bool toggle) + { + device.SetWireframe(toggle); + } + + public override void GlViewport(int x, int y, int width, int height) + { + device.SetViewport(x, y, width, height); + } + + public override void GlScissor(int x, int y, int width, int height) + { + device.SetScissor(x, y, width, height); + } + + public override void GlScissorFlag(bool enable) + { + scissorEnabled = enable; + device.SetScissorEnabled(enable); + } + + public override void GlEnableDepthTest() + { + device.SetDepthTest(true); + } + + public override void GlDisableDepthTest() + { + device.SetDepthTest(false); + } + + public override void BindTexture2d(int texture) + { + // The GL body activates unit 0 first, so this binds to unit 0 too. + device.BindTexture(0, texture); + // Remembered for GlGenerateTex2DMipmaps, whose GL form acts on + // whatever is bound and so has no argument to route. + boundTexture2d = texture; + } + + public override void BindTextureCubeMap(int texture) + { + device.BindTextureCube(0, texture); + } + + public override void UnBindTextureCubeMap() + { + // Mirrors BindTextureCubeMap above, which binds to unit 0. + device.BindTextureCube(0, 0); + } + + public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendMode.Standard) + { + device.SetBlend(on, blendMode); + if (on && OptimumRenderSsao) + { + // SSAO writes its position and normal attachments unblended, and + // the GL path expresses that by overriding attachments 2 and 3 + // after the global mode is set. + device.SetBlendEquation(2, 32774); + device.SetBlendFuncSeparate(2, 1, 0, 1, 0); + device.SetBlendEquation(3, 32774); + device.SetBlendFuncSeparate(3, 1, 0, 1, 0); + } + // Optimum TAA (P3): the motion attachment never blends. A blended + // motion vector averages two surfaces' displacements and belongs to + // neither; the per-attachment override has to be re-applied after + // every global blend change, exactly like the SSAO one above. + if (on) + { + ApplyOptimumMotionBlendState(); + } + } + + public override void GlDisableCullFace() + { + device.SetCullFace(false); + } + + public override void GlEnableCullFace() + { + device.SetCullFace(true); + } + + public override void GLLineWidth(float width) + { + device.SetLineWidth(width); + } + + /// + /// GL_LINE_SMOOTH has no Vulkan equivalent - smooth lines there are a + /// rasterization-mode on the pipeline, not toggleable state - and it is + /// purely cosmetic, so the device path ignores it rather than pretending. + /// + public override void SmoothLines(bool on) + { + } + + public override void GlDepthMask(bool flag) + { + device.SetDepthMask(flag); + } + + public override void GlDepthFunc(EnumDepthFunction depthFunc) + { + // EnumDepthFunction's values are the GL constants, which is the form + // the seam takes: it cannot reference this enum, since it lives in + // VintagestoryLib and the contracts assembly does not depend on it. + device.SetDepthFunc((int)depthFunc); + } + + public override void GlCullFaceBack() + { + device.SetCullFaceMode(true); + } + + public override void GlCullFaceFront() + { + device.SetCullFaceMode(false); + } + + public override void GlEnableStencilTest() + { + device.SetStencilTest(true); + } + + public override void GlDisableStencilTest() + { + device.SetStencilTest(false); + } + + public override void GlStencilMask(int mask) + { + device.SetStencilMask(mask); + } + + public override void GlStencilFunc(int func, int refVal, int mask) + { + device.SetStencilFunc(func, refVal, mask); + } + + public override void GlStencilOp(int sfail, int dpfail, int dppass) + { + device.SetStencilOp(sfail, dpfail, dppass); + } + + public override void GlColorMask(bool r, bool g, bool b, bool a) + { + device.SetColorMask(r, g, b, a); + } + + public override void GlClearStencil() + { + device.ClearStencil(); + } + + public override void GlGenerateTex2DMipmaps() + { + if (boundTexture2d != 0) + { + device.GenerateMipmaps(boundTexture2d); + } + } + + public override int GlGetMaxTextureSize() + { + return device.MaxTextureSize; + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 46416b9f..929f9cb6 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -51,6 +51,7 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(false, "DisposeFrameBuffers", new[] { "List`1" }), new(false, "RenderFullscreenTriangle", new[] { "MeshRef" }), new(false, "GetGraphicsCardRenderer", Array.Empty()), + new(false, "LogAndTestHardwareInfosStage2", Array.Empty()), // Phase 1A step 4: TAA motion windows and FSR target selection. new(true, "EnableMotionDrawBuffers", Array.Empty()), new(true, "RestorePrimaryDrawBuffers", Array.Empty()), diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index b6ded1b8..93eeccd8 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..2fe7142 100644 +index 6edf0c9..9cc335b 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -115,7 +115,7 @@ index 6edf0c9..2fe7142 100644 private Logger logger; private int doResize; -@@ -93,10 +182,134 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,129 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -239,18 +239,32 @@ index 6edf0c9..2fe7142 100644 + private float optimumClearB; + + private float optimumClearA; -+ -+ // Optimum: the last texture bound to unit 0 through BindTexture2d. The -+ // mod-facing GlGenerateTex2DMipmaps acts on the bound texture and takes no -+ // argument, so the device path needs somewhere to read the target from. -+ private int optimumBoundTexture2d; + private MeshRef screenQuad; private bool serverRunning; private bool gamepause; -@@ -256,10 +469,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -109,10 +317,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + + private bool RenderFXAA; + + private bool RenderSSAO; + ++ /// ++ /// Optimum (Vulkan-native plan, Phase 1A step 4): whether this frame renders SSAO, ++ /// as window_RenderFrame computed it. VulkanClientPlatform's GlToggleBlend and ++ /// Primary clear read it to override the G-buffer attachments exactly as the GL ++ /// bodies do. ++ /// ++ public bool OptimumRenderSsao => RenderSSAO; ++ + private bool SetupSSAO; + + private int ShadowMapQuality; + + private float ssaaLevel; +@@ -256,10 +472,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -274,7 +288,7 @@ index 6edf0c9..2fe7142 100644 get { return serverRunning; -@@ -278,11 +504,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,11 +507,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -302,7 +316,7 @@ index 6edf0c9..2fe7142 100644 GL.BindFramebuffer((FramebufferTarget)36160, 0); return; } -@@ -297,11 +539,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -297,11 +542,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -326,57 +340,7 @@ index 6edf0c9..2fe7142 100644 } public override bool GlErrorChecking { get; set; } -@@ -314,10 +568,20 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - set - { - //IL_001d: Unknown result type (might be due to invalid IL or missing references) - //IL_0027: Expected O, but got Unknown -+ // Mono.Cecil transplant. -+ // The device's equivalent is the validation layer, which it enables -+ // itself; the supportsGlDebugMode check does not apply. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DebugMode = value; -+ glDebugMode = value; -+ return; -+ } - if (value) - { - if (!supportsGlDebugMode) - { - throw new NotSupportedException("Your graphics card does not seem to support gl debug mode (neither GL_ARB_debug_output nor GL_KHR_debug was found)"); -@@ -335,11 +599,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - glDebugMode = value; - } - } - -- public override bool GlScissorFlagEnabled => GL.IsEnabled((EnableCap)3089); -+ // Mono.Cecil transplant (member injection + get_GlScissorFlagEnabled). -+ // The device takes the scissor flag as a call argument and keeps no -+ // queryable state, so the routed setter remembers it here for the getter, -+ // which the runtime atlas upload reads to restore the flag afterwards. -+ private bool optimumScissorEnabled; -+ -+ public override bool GlScissorFlagEnabled -+ { -+ get -+ { -+ if (Vintagestory.API.Config.OptimumRender.Device != null) -+ { -+ return optimumScissorEnabled; -+ } -+ return GL.IsEnabled((EnableCap)3089); -+ } -+ } - - public override string CurrentMouseCursor { get; protected set; } - - public override bool MouseGrabbed - { -@@ -478,41 +758,153 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +735,147 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -525,74 +489,24 @@ index 6edf0c9..2fe7142 100644 - public string GetGraphicsCardRenderer() + public virtual string GetGraphicsCardRenderer() { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return optimumDevice.RendererString; -+ } return GL.GetString((StringName)7937); } public void LogAndTestHardwareInfosStage1() +@@ -531,11 +895,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { -@@ -533,10 +925,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); } - public void LogAndTestHardwareInfosStage2() +- public void LogAndTestHardwareInfosStage2() ++ public virtual void LogAndTestHardwareInfosStage2() { -+ // Mono.Cecil transplant. -+ // Everything below the framework-version block is API-neutral, so the -+ // device path logs the same facts from the device and then rejoins. The -+ // GL extension test at the end has no meaning here: the device already -+ // refused to initialize if it lacked what it needs. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ logger.Notification("Graphics Backend: " + optimumDevice.BackendName); -+ logger.Notification("Graphics Card Vendor: " + optimumDevice.VendorString); -+ logger.Notification("Graphics Card Version: " + optimumDevice.VersionString); -+ logger.Notification("Graphics Card Renderer: " + optimumDevice.RendererString); -+ logger.Notification("Graphics Card ShadingLanguageVersion: " + optimumDevice.ShaderVersionString); -+ logger.Notification("Max texture size: " + optimumDevice.MaxTextureSize); -+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) -+ { -+ LogFrameworkVersions(); -+ } -+ logger.Notification("OpenAL Version: " + AL.Get((ALGetString)45058)); -+ logger.Notification("Zstd Version: " + ZstdNative.Version); -+ // Both describe GL extensions and are only read by GL bodies that the -+ // device path returns before reaching, so false is the honest value. -+ supportsGlDebugMode = false; -+ supportsPersistentMapping = false; -+ CheckGlError("loghwinfo"); -+ return; -+ } logger.Notification("Graphics Card Vendor: " + GL.GetString((StringName)7936)); logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); - logger.Notification("GL.MaxVertexUniformComponents: " + GL.GetInteger((GetPName)35658)); -@@ -576,10 +995,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - CheckGlError("testhwinfo"); - } - - public override string GetGraphicCardInfos() - { -+ // Mono.Cecil transplant. -+ // This text goes into crash reports, so it names the backend too - a -+ // crash on Vulkan reads very differently from the same crash on GL. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return "GC Backend: " + optimumDevice.BackendName + "\nGC Vendor: " + optimumDevice.VendorString + "\nGC Version: " + optimumDevice.VersionString + "\nGC Renderer: " + optimumDevice.RendererString + "\nGC ShaderVersion: " + optimumDevice.ShaderVersionString; -+ } - return "GC Vendor: " + GL.GetString((StringName)7936) + "\nGC Version: " + GL.GetString((StringName)7938) + "\nGC Renderer: " + GL.GetString((StringName)7937) + "\nGC ShaderVersion: " + GL.GetString((StringName)35724); - } - - public override string GetFrameworkInfos() - { -@@ -702,24 +1129,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1066,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -635,7 +549,7 @@ index 6edf0c9..2fe7142 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1241,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1178,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -659,7 +573,7 @@ index 6edf0c9..2fe7142 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1474,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1411,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -698,7 +612,7 @@ index 6edf0c9..2fe7142 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1517,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1454,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -718,7 +632,7 @@ index 6edf0c9..2fe7142 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1548,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1485,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -735,7 +649,7 @@ index 6edf0c9..2fe7142 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1593,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1103,15 +1530,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract if (frameBuffer.DepthTextureId > 0) { GLDeleteTexture(frameBuffer.DepthTextureId); @@ -768,7 +682,7 @@ index 6edf0c9..2fe7142 100644 FboId = GL.GenFramebuffer(), Width = fbAttrs.Width, Height = fbAttrs.Height -@@ -1150,122 +1657,844 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,12 +1594,695 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -786,49 +700,22 @@ index 6edf0c9..2fe7142 100644 + /// render systems assume when they index FrameBuffers by EnumFrameBuffer. + /// + private List SetupOptimumFrameBuffers(Vintagestory.API.Config.IOptimumGraphicsDevice device) - { -- //IL_001b: Unknown result type (might be due to invalid IL or missing references) -- //IL_0072: Unknown result type (might be due to invalid IL or missing references) -- //IL_008c: Unknown result type (might be due to invalid IL or missing references) -- //IL_00ad: Unknown result type (might be due to invalid IL or missing references) -- //IL_0211: Unknown result type (might be due to invalid IL or missing references) -- //IL_02af: Unknown result type (might be due to invalid IL or missing references) -- //IL_0358: Unknown result type (might be due to invalid IL or missing references) -- //IL_0576: Unknown result type (might be due to invalid IL or missing references) -- //IL_0665: Unknown result type (might be due to invalid IL or missing references) -- //IL_0b13: Unknown result type (might be due to invalid IL or missing references) -- //IL_0b88: Unknown result type (might be due to invalid IL or missing references) -- //IL_0bfe: Unknown result type (might be due to invalid IL or missing references) -- //IL_0c62: Unknown result type (might be due to invalid IL or missing references) -- //IL_0ccf: Unknown result type (might be due to invalid IL or missing references) -- //IL_0d44: Unknown result type (might be due to invalid IL or missing references) -- //IL_0db2: Unknown result type (might be due to invalid IL or missing references) -- //IL_0a8c: Unknown result type (might be due to invalid IL or missing references) - SetupSSAO = ClientSettings.SSAOQuality > 0; -- if (ClientSettings.IsNewSettingsFile && ((NativeWindow)window).ClientSize.X > 1920) -- { -- ClientSettings.SSAA = 0.5f; -- } - List list = new List(31); - for (int i = 0; i <= 24; i++) - { - list.Add(null); - } - ShadowMapQuality = ClientSettings.ShadowMapQuality; - ssaaLevel = ClientSettings.SSAA; -- int num = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); -- int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); -- if (num == 0 || num2 == 0) ++ { ++ SetupSSAO = ClientSettings.SSAOQuality > 0; ++ List list = new List(31); ++ for (int i = 0; i <= 24; i++) ++ { ++ list.Add(null); ++ } ++ ShadowMapQuality = ClientSettings.ShadowMapQuality; ++ ssaaLevel = ClientSettings.SSAA; + + int width = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); + int height = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); + if (width == 0 || height == 0) - { - return list; - } -- PixelFormat val = (PixelFormat)6408; -- CheckGlError("sdfb-begin"); -- FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef ++ { ++ return list; ++ } + + // Optimum: TAA. Read once per (re-)build; a mid-session config change + // only takes effect on the next RebuildFrameBuffers. optimumTaaDisabled @@ -855,14 +742,7 @@ index 6edf0c9..2fe7142 100644 + primary.ColorTextureIds[1] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + if (SetupSSAO) - { -- FboId = GL.GenFramebuffer(), -- Width = num, -- Height = num2 -- }); -- FrameBufferRef frameBufferRef3 = frameBufferRef; -- frameBufferRef3.DepthTextureId = GL.GenTexture(); -- if (frameBufferRef3.FboId == 0) ++ { + primary.ColorTextureIds[2] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + primary.ColorTextureIds[3] = device.CreateTexture2D(width, height, @@ -871,54 +751,14 @@ index 6edf0c9..2fe7142 100644 + // Match the GL Primary filters, including linear G-buffer sampling and + // the white border used when SSAO projects a sample off screen. + for (int attachment = 0; attachment < primaryAttachments; attachment++) - { -- base.XPlatInterface.ShowMessageBox("Fatal error", "Unable to generate a new framebuffer. This shouldn't happen, ever. Maybe a restart resolves the problem?"); ++ { + int textureId = primary.ColorTextureIds[attachment]; + SetupOptimumTextureSampler(device, textureId, + attachment >= 2 || ssaaLevel > 1f ? 9729 : 9728, attachment >= 2 ? 33069 : 10497); + if (attachment >= 2) device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); - } -- CurrentFrameBufferKeepVw = frameBufferRef3; -- GL.BindTexture((TextureTarget)3553, frameBufferRef3.DepthTextureId); -- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33191, num, num2, 0, (PixelFormat)6402, (PixelType)5126, (IntPtr)IntPtr.Zero); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, frameBufferRef3.DepthTextureId, 0); -- GL.DepthFunc((DepthFunction)513); -- frameBufferRef3.ColorTextureIds = ArrayUtil.CreateFilled(SetupSSAO ? 4 : 2, (int n) => GL.GenTexture()); -- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); -- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[1]); -- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); -- if (SetupSSAO) ++ } + if (taaRequested) - { -- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); -- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); -- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[3]); -- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); -- DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; -- GL.DrawBuffers(4, array2); ++ { + // Optimum: TAA motion attachment, appended after the SSAO G-buffer + // so every existing attachment index is unchanged. Deliberately not + // folded into the draw-buffer mask below - it stays out of every @@ -938,8 +778,7 @@ index 6edf0c9..2fe7142 100644 + DisableOptimumTaa("Primary motion attachment (device): " + error.Message); + motionAttachmentIndex = -1; + } - } -- else ++ } + for (int attachment = 0; attachment < primary.ColorTextureIds.Length; attachment++) + { + device.AttachTexture(primary.FboId, @@ -975,8 +814,7 @@ index 6edf0c9..2fe7142 100644 + list[1] = transparent; + + if (SetupSSAO) - { -- DrawBuffersEnum[] array3 = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] ++ { + int ssaoWidth = (int)((float)width * 0.5f); + int ssaoHeight = (int)((float)height * 0.5f); + @@ -998,11 +836,7 @@ index 6edf0c9..2fe7142 100644 + float[] noise = new float[noiseSize * noiseSize * 4]; + Vec3f direction = new Vec3f(); + for (int texel = 0; texel < noiseSize * noiseSize; texel++) - { -- (DrawBuffersEnum)36064, -- (DrawBuffersEnum)36065 -- }; -- GL.DrawBuffers(2, array3); ++ { + direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize(); + noise[texel * 4] = direction.X; + noise[texel * 4 + 1] = direction.Y; @@ -1038,9 +872,7 @@ index 6edf0c9..2fe7142 100644 + + list[14] = CreateOptimumColorTarget(device, ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); + list[15] = CreateOptimumColorTarget(device, ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); - } -- CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); -- frameBufferRef = (list[1] = new FrameBufferRef ++ } + + list[2] = CreateOptimumColorTarget(device, width / 2, height / 2, EnumTextureInternalFormat.Rgba8); + list[3] = CreateOptimumColorTarget(device, width / 2, height / 2, EnumTextureInternalFormat.Rgba8); @@ -1054,13 +886,7 @@ index 6edf0c9..2fe7142 100644 + // resolve reads last frame's parity while writing this frame's; never + // cleared per frame (ClearFrameBuffer(Primary) only touches Primary). + if (taaRequested) - { -- FboId = GL.GenFramebuffer(), -- Width = num, -- Height = num2 -- }); -- frameBufferRef3 = frameBufferRef; -- frameBufferRef3.ColorTextureIds = new int[3] ++ { + try + { + list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTarget(device, width, height); @@ -1134,8 +960,7 @@ index 6edf0c9..2fe7142 100644 + quadData.Rgba = null; + quadData.Uv = null; + if (screenQuad != null) - { -- GL.GenTexture(), ++ { + screenQuad.Dispose(); + } + screenQuad = UploadMesh(quadData); @@ -1543,47 +1368,23 @@ index 6edf0c9..2fe7142 100644 + } + + public virtual List SetupDefaultFrameBuffers() -+ { + { + Vintagestory.API.Config.IOptimumGraphicsDevice optimumSetupDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumSetupDevice != null) + { + return SetupOptimumFrameBuffers(optimumSetupDevice); + } -+ //IL_001b: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0072: Unknown result type (might be due to invalid IL or missing references) -+ //IL_008c: Unknown result type (might be due to invalid IL or missing references) -+ //IL_00ad: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0211: Unknown result type (might be due to invalid IL or missing references) -+ //IL_02af: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0358: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0576: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0665: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0b13: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0b88: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0bfe: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0c62: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0ccf: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0d44: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0db2: Unknown result type (might be due to invalid IL or missing references) -+ //IL_0a8c: Unknown result type (might be due to invalid IL or missing references) -+ SetupSSAO = ClientSettings.SSAOQuality > 0; -+ if (ClientSettings.IsNewSettingsFile && ((NativeWindow)window).ClientSize.X > 1920) -+ { -+ ClientSettings.SSAA = 0.5f; -+ } -+ List list = new List(31); -+ for (int i = 0; i <= 24; i++) -+ { -+ list.Add(null); -+ } -+ ShadowMapQuality = ClientSettings.ShadowMapQuality; -+ ssaaLevel = ClientSettings.SSAA; -+ int num = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); -+ int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); -+ if (num == 0 || num2 == 0) -+ { -+ return list; -+ } + //IL_001b: Unknown result type (might be due to invalid IL or missing references) + //IL_0072: Unknown result type (might be due to invalid IL or missing references) + //IL_008c: Unknown result type (might be due to invalid IL or missing references) + //IL_00ad: Unknown result type (might be due to invalid IL or missing references) + //IL_0211: Unknown result type (might be due to invalid IL or missing references) +@@ -1187,10 +2314,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); + if (num == 0 || num2 == 0) + { + return list; + } + // Optimum: TAA. Read once per (re-)build; a mid-session config change + // only takes effect on the next RebuildFrameBuffers. optimumTaaDisabled + // is checked as well as EffectiveTaa: DisableOptimumTaa sets both, and @@ -1591,74 +1392,36 @@ index 6edf0c9..2fe7142 100644 + // even if the process-wide config flag is ever reset. + bool taaRequested = !optimumTaaDisabled && Vintagestory.API.Config.OptimumConfig.EffectiveTaa; + int motionAttachmentIndex = -1; -+ PixelFormat val = (PixelFormat)6408; -+ CheckGlError("sdfb-begin"); -+ FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef -+ { -+ FboId = GL.GenFramebuffer(), -+ Width = num, -+ Height = num2 -+ }); -+ FrameBufferRef frameBufferRef3 = frameBufferRef; -+ frameBufferRef3.DepthTextureId = GL.GenTexture(); -+ if (frameBufferRef3.FboId == 0) -+ { -+ base.XPlatInterface.ShowMessageBox("Fatal error", "Unable to generate a new framebuffer. This shouldn't happen, ever. Maybe a restart resolves the problem?"); -+ } -+ CurrentFrameBufferKeepVw = frameBufferRef3; -+ GL.BindTexture((TextureTarget)3553, frameBufferRef3.DepthTextureId); -+ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33191, num, num2, 0, (PixelFormat)6402, (PixelType)5126, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, frameBufferRef3.DepthTextureId, 0); -+ GL.DepthFunc((DepthFunction)513); + PixelFormat val = (PixelFormat)6408; + CheckGlError("sdfb-begin"); + FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), +@@ -1210,11 +2344,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, frameBufferRef3.DepthTextureId, 0); + GL.DepthFunc((DepthFunction)513); +- frameBufferRef3.ColorTextureIds = ArrayUtil.CreateFilled(SetupSSAO ? 4 : 2, (int n) => GL.GenTexture()); + frameBufferRef3.ColorTextureIds = new int[SetupSSAO ? 4 : 2]; + for (int j = 0; j < frameBufferRef3.ColorTextureIds.Length; j++) + { + frameBufferRef3.ColorTextureIds[j] = GL.GenTexture(); + } -+ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); -+ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -+ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[1]); -+ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); -+ if (SetupSSAO) -+ { -+ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); -+ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); -+ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[3]); -+ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); -+ DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; -+ GL.DrawBuffers(4, array2); -+ } -+ else -+ { -+ DrawBuffersEnum[] array3 = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] -+ { -+ (DrawBuffersEnum)36064, -+ (DrawBuffersEnum)36065 -+ }; -+ GL.DrawBuffers(2, array3); -+ } + GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); + GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); +@@ -1251,12 +2389,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + (DrawBuffersEnum)36064, + (DrawBuffersEnum)36065 + }; + GL.DrawBuffers(2, array3); + } +- CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); +- frameBufferRef = (list[1] = new FrameBufferRef + if (taaRequested) + { + // Optimum: TAA motion attachment, appended after the SSAO G-buffer @@ -1689,21 +1452,12 @@ index 6edf0c9..2fe7142 100644 + optimumMotionAttachmentIndex = motionAttachmentIndex; + CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); + frameBufferRef = (list[1] = new FrameBufferRef -+ { -+ FboId = GL.GenFramebuffer(), -+ Width = num, -+ Height = num2 -+ }); -+ frameBufferRef3 = frameBufferRef; -+ frameBufferRef3.ColorTextureIds = new int[3] -+ { -+ GL.GenTexture(), - GL.GenTexture(), - GL.GenTexture() - }; - CurrentFrameBufferKeepVw = frameBufferRef3; - GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); -@@ -1436,10 +2665,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + { + FboId = GL.GenFramebuffer(), + Width = num, + Height = num2 + }); +@@ -1436,10 +2602,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1779,7 +1533,7 @@ index 6edf0c9..2fe7142 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2842,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2779,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1794,7 +1548,7 @@ index 6edf0c9..2fe7142 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2865,115 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2802,115 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1808,7 +1562,7 @@ index 6edf0c9..2fe7142 100644 + /// member for it) attachments. + /// + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) -+ { + { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1860,7 +1614,7 @@ index 6edf0c9..2fe7142 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) - { ++ { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. @@ -1913,7 +1667,7 @@ index 6edf0c9..2fe7142 100644 } } } -@@ -1591,11 +2983,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2920,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1942,7 +1696,7 @@ index 6edf0c9..2fe7142 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,38 +3017,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1608,38 +2954,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -2078,7 +1832,7 @@ index 6edf0c9..2fe7142 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +3176,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,20 +3113,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2119,7 +1873,7 @@ index 6edf0c9..2fe7142 100644 GL.Enable((EnableCap)3042); GL.BlendEquation(0, (BlendEquationMode)32774); GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +3219,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1693,48 +3156,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); break; } @@ -2186,7 +1940,7 @@ index 6edf0c9..2fe7142 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +3289,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,16 +3226,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2208,7 +1962,7 @@ index 6edf0c9..2fe7142 100644 case (EnumFrameBuffer)6: break; } -@@ -1774,18 +3313,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1774,18 +3250,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -2222,9 +1976,8 @@ index 6edf0c9..2fe7142 100644 CurrentFrameBufferKeepVw = frameBuffers[0]; return; } -- CurrentFrameBufferKeepVw = null; + CurrentFrameBufferKeepVw = null; - GL.DrawBuffer((DrawBufferMode)1029); -+ CurrentFrameBufferKeepVw = null; + // Selecting GL_BACK has no device equivalent: binding the default target + // already means the swapchain image. + if (Vintagestory.API.Config.OptimumRender.Device == null) @@ -2236,7 +1989,7 @@ index 6edf0c9..2fe7142 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3338,262 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3275,262 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2503,7 +2256,7 @@ index 6edf0c9..2fe7142 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3608,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3545,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2540,7 +2293,7 @@ index 6edf0c9..2fe7142 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3647,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3584,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2602,7 +2355,7 @@ index 6edf0c9..2fe7142 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3724,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,31 +3661,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2650,7 +2403,7 @@ index 6edf0c9..2fe7142 100644 } public override void RenderFinalComposition() -@@ -1953,19 +3774,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3711,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2686,7 +2439,7 @@ index 6edf0c9..2fe7142 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3821,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,23 +3758,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2727,7 +2480,7 @@ index 6edf0c9..2fe7142 100644 } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,27 +3872,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3809,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3213,246 +2966,7 @@ index 6edf0c9..2fe7142 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2083,10 +4389,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - //IL_0009: Unknown result type (might be due to invalid IL or missing references) - //IL_000e: Unknown result type (might be due to invalid IL or missing references) - //IL_000f: Unknown result type (might be due to invalid IL or missing references) - //IL_002c: Unknown result type (might be due to invalid IL or missing references) -+ // Mono.Cecil transplant. -+ // The device drains validation-layer messages instead of a GL error code. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (GlErrorChecking) -+ { -+ string optimumError = optimumDevice.GetError(); -+ if (optimumError != null) -+ { -+ throw new Exception(((errmsg == null) ? "" : (errmsg + " ")) + "- the graphics backend reported: " + optimumError); -+ } -+ } -+ return; -+ } - if (GlErrorChecking) - { - OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); - if ((int)error != 0) - { -@@ -2101,10 +4422,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - //IL_0005: Unknown result type (might be due to invalid IL or missing references) - //IL_0006: Unknown result type (might be due to invalid IL or missing references) - //IL_0050: Unknown result type (might be due to invalid IL or missing references) - //IL_0056: Invalid comparison between Unknown and I4 - //IL_0037: Unknown result type (might be due to invalid IL or missing references) -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ string optimumError = optimumDevice.GetError(); -+ if (optimumError != null) -+ { -+ Logger.Error(((errmsg == null) ? "" : (errmsg + " ")) + "- the graphics backend reported: " + optimumError); -+ } -+ return; -+ } - OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); - if ((int)error != 0) - { - string arg = (ClientSettings.GlDebugMode ? "" : ". Enable Gl Debug Mode in the settings or clientsettings.json to track this error"); - string message = string.Format("{0} - OpenGL threw an error: {1}{2}", (errmsg == null) ? "" : (errmsg + " "), error, arg); -@@ -2119,48 +4451,97 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - public unsafe override string GlGetError() - { - //IL_0000: Unknown result type (might be due to invalid IL or missing references) - //IL_0005: Unknown result type (might be due to invalid IL or missing references) - //IL_0006: Unknown result type (might be due to invalid IL or missing references) -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return optimumDevice.GetError(); -+ } - OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); - if ((int)error != 0) - { - return ((object)(*(OpenTK.Graphics.OpenGL.ErrorCode*)(&error))/*cast due to constrained. prefix*/).ToString(); - } - return null; - } - - public override string GetGLShaderVersionString() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The client parses this to decide whether a shader's #version is -+ // supported, so it has to keep reading as a GLSL version number. -+ return optimumDevice.ShaderVersionString; -+ } - return GL.GetString((StringName)35724); - } - - public override int GenSampler(bool linear) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return optimumDevice.CreateSampler(linear); -+ } - int num = GL.GenSampler(); - GL.SamplerParameter(num, (SamplerParameterName)10240, linear ? 9729 : 9728); - GL.SamplerParameter(num, (SamplerParameterName)10241, 9986); - return num; - } - -+ // Optimum: every graphics method takes the same shape - if a device is -+ // installed, route to it and return; otherwise run the untouched vanilla GL -+ // body below. OptimumRender.Device is null on the OpenGL path, so an OpenGL -+ // session pays one null check and executes exactly what it always did. -+ // -+ // These bodies are Cecil transplant targets, so they stay lambda-free. - public override void GLWireframes(bool toggle) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetWireframe(toggle); -+ return; -+ } - GL.PolygonMode((TriangleFace)1032, (PolygonMode)(toggle ? 6913 : 6914)); - } - - public override void GlViewport(int x, int y, int width, int height) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetViewport(x, y, width, height); -+ return; -+ } - GL.Viewport(x, y, width, height); - } - - public override void GlScissor(int x, int y, int width, int height) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetScissor(x, y, width, height); -+ return; -+ } - GL.Scissor(x, y, width, height); - } - - public override void GlScissorFlag(bool enable) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumScissorEnabled = enable; -+ optimumDevice.SetScissorEnabled(enable); -+ return; -+ } - if (enable) - { - GL.Enable((EnableCap)3089); - } - else -@@ -2169,97 +4550,202 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - } - - public override void GlEnableDepthTest() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDepthTest(true); -+ return; -+ } - GL.Enable((EnableCap)2929); - } - - public override void GlDisableDepthTest() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDepthTest(false); -+ return; -+ } - GL.Disable((EnableCap)2929); - } - - public override void BindTexture2d(int texture) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The GL body activates unit 0 first, so this binds to unit 0 too. -+ optimumDevice.BindTexture(0, texture); -+ // Remembered for GlGenerateTex2DMipmaps, whose GL form acts on -+ // whatever is bound and so has no argument to route. -+ optimumBoundTexture2d = texture; -+ return; -+ } - GL.ActiveTexture((TextureUnit)33984); - GL.BindTexture((TextureTarget)3553, texture); - } - - public override void BindTextureCubeMap(int texture) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.BindTextureCube(0, texture); -+ return; -+ } - GL.BindTexture((TextureTarget)34067, texture); - } - - public override void UnBindTextureCubeMap() - { -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // Mirrors BindTextureCubeMap above, which binds to unit 0. -+ optimumDevice.BindTextureCube(0, 0); -+ return; -+ } - GL.BindTexture((TextureTarget)34067, 0); - } - - public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendMode.Standard) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetBlend(on, blendMode); -+ if (on && RenderSSAO) -+ { -+ // SSAO writes its position and normal attachments unblended, and -+ // the GL path expresses that by overriding attachments 2 and 3 -+ // after the global mode is set. -+ optimumDevice.SetBlendEquation(2, 32774); -+ optimumDevice.SetBlendFuncSeparate(2, 1, 0, 1, 0); -+ optimumDevice.SetBlendEquation(3, 32774); -+ optimumDevice.SetBlendFuncSeparate(3, 1, 0, 1, 0); -+ } -+ // Optimum TAA (P3): the motion attachment never blends. A blended -+ // motion vector averages two surfaces' displacements and belongs to -+ // neither; the per-attachment override has to be re-applied after -+ // every global blend change, exactly like the SSAO one above. -+ if (on) -+ { -+ ApplyOptimumMotionBlendState(); -+ } -+ return; -+ } - if (on) - { +@@ -2202,32 +4445,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3492,27 +3006,7 @@ index 6edf0c9..2fe7142 100644 { GL.Disable((EnableCap)3042); } - } - - public override void GlDisableCullFace() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetCullFace(false); -+ return; -+ } - GL.Disable((EnableCap)2884); - } - - public override void GlEnableCullFace() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetCullFace(true); -+ return; -+ } +@@ -2243,10 +4493,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)2884); } @@ -3535,177 +3029,7 @@ index 6edf0c9..2fe7142 100644 public override void GLLineWidth(float width) { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetLineWidth(width); -+ return; -+ } - if (RuntimeEnv.OS != OS.Mac) - { - GL.LineWidth(width); - } - } - - public override void SmoothLines(bool on) - { -+ // Mono.Cecil transplant. -+ // GL_LINE_SMOOTH has no Vulkan equivalent - smooth lines there are a -+ // rasterization-mode on the pipeline, not toggleable state - and it is -+ // purely cosmetic, so the device path ignores it rather than pretending. -+ if (Vintagestory.API.Config.OptimumRender.Device != null) -+ { -+ return; -+ } - if (RuntimeEnv.OS != OS.Mac) - { - if (on) - { - GL.Enable((EnableCap)2848); -@@ -2273,74 +4759,166 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - } - - public override void GlDepthMask(bool flag) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDepthMask(flag); -+ return; -+ } - GL.DepthMask(flag); - } - - public override void GlDepthFunc(EnumDepthFunction depthFunc) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // EnumDepthFunction's values are the GL constants, which is the form -+ // the seam takes: it cannot reference this enum, since it lives in -+ // VintagestoryLib and the contracts assembly does not depend on it. -+ optimumDevice.SetDepthFunc((int)depthFunc); -+ return; -+ } - GL.DepthFunc((DepthFunction)depthFunc); - } - - public override void GlCullFaceBack() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetCullFaceMode(true); -+ return; -+ } - GL.CullFace((TriangleFace)1029); - } - - public override void GlCullFaceFront() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetCullFaceMode(false); -+ return; -+ } - GL.CullFace((TriangleFace)1028); - } - - public override void GlEnableStencilTest() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetStencilTest(true); -+ return; -+ } - GL.Enable((EnableCap)2960); - } - - public override void GlDisableStencilTest() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetStencilTest(false); -+ return; -+ } - GL.Disable((EnableCap)2960); - } - - public override void GlStencilMask(int mask) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetStencilMask(mask); -+ return; -+ } - GL.StencilMask(mask); - } - - public override void GlStencilFunc(int func, int refVal, int mask) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetStencilFunc(func, refVal, mask); -+ return; -+ } - GL.StencilFunc((StencilFunction)func, refVal, mask); - } - - public override void GlStencilOp(int sfail, int dpfail, int dppass) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetStencilOp(sfail, dpfail, dppass); -+ return; -+ } - GL.StencilOp((StencilOp)sfail, (StencilOp)dpfail, (StencilOp)dppass); - } - - public override void GlColorMask(bool r, bool g, bool b, bool a) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetColorMask(r, g, b, a); -+ return; -+ } - GL.ColorMask(r, g, b, a); - } - - public override void GlClearStencil() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.ClearStencil(); -+ return; -+ } - GL.Clear((ClearBufferMask)1024); - } - - public override void GlGenerateTex2DMipmaps() - { -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (optimumBoundTexture2d != 0) -+ { -+ optimumDevice.GenerateMipmaps(optimumBoundTexture2d); -+ } -+ return; -+ } - GL.GenerateMipmap((GenerateMipmapTarget)3553); - } - - public override int LoadCairoTexture(ImageSurface surface, bool linearMag) +@@ -2337,10 +4599,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3729,7 +3053,7 @@ index 6edf0c9..2fe7142 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4929,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4626,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3753,7 +3077,7 @@ index 6edf0c9..2fe7142 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4958,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4655,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3791,7 +3115,7 @@ index 6edf0c9..2fe7142 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2395,15 +5013,47 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2395,15 +4710,47 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (bmp == null) { @@ -3844,7 +3168,7 @@ index 6edf0c9..2fe7142 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +5071,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4768,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3903,7 +3227,7 @@ index 6edf0c9..2fe7142 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +5186,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4883,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3977,7 +3301,7 @@ index 6edf0c9..2fe7142 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +5284,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4981,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -4008,7 +3332,7 @@ index 6edf0c9..2fe7142 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +5321,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +5018,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -4043,7 +3367,7 @@ index 6edf0c9..2fe7142 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,15 +5368,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,10 +5065,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -4062,17 +3386,7 @@ index 6edf0c9..2fe7142 100644 public override int GlGetMaxTextureSize() { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return optimumDevice.MaxTextureSize; -+ } - int result = 1024; - try - { - GL.GetInteger((GetPName)3379, out result); - } -@@ -2581,10 +5399,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +5091,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -4098,7 +3412,7 @@ index 6edf0c9..2fe7142 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,22 +5426,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,22 +5118,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -4189,7 +3503,7 @@ index 6edf0c9..2fe7142 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +5552,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5244,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -4234,7 +3548,7 @@ index 6edf0c9..2fe7142 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5589,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5281,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4255,7 +3569,7 @@ index 6edf0c9..2fe7142 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5608,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5300,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4276,7 +3590,7 @@ index 6edf0c9..2fe7142 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5627,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5319,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4297,7 +3611,7 @@ index 6edf0c9..2fe7142 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5646,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5338,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -4318,7 +3632,7 @@ index 6edf0c9..2fe7142 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5669,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5361,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -4339,7 +3653,7 @@ index 6edf0c9..2fe7142 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5712,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +5404,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -4365,7 +3679,7 @@ index 6edf0c9..2fe7142 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5954,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5646,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -4387,7 +3701,7 @@ index 6edf0c9..2fe7142 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +6152,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5844,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -4410,7 +3724,7 @@ index 6edf0c9..2fe7142 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +6226,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5918,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -4459,7 +3773,7 @@ index 6edf0c9..2fe7142 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +6296,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5988,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -4491,7 +3805,7 @@ index 6edf0c9..2fe7142 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +6326,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +6018,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -4517,7 +3831,7 @@ index 6edf0c9..2fe7142 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6674,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +6366,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -4846,7 +4160,7 @@ index 6edf0c9..2fe7142 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +7027,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6719,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } From 4e62391e334c0706e5af03d84e5e0b4dff5a85a4 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:43:30 +0200 Subject: [PATCH 089/226] wip(phase1-review): occlusion queries survive scope ends and partial submits; readback offsets align to the texel size A GL query counts across framebuffer binds; a Vulkan query was left active across vkCmdEndRendering and vkEndCommandBuffer whenever a bind, layout transition, readback or upload closed the scope mid-query (VUID-vkCmdEndRendering-None-06999, vkEndCommandBuffer-00061). QueryRing now suspends a running query before the scope ends, resets any pool its continuation needs between scopes, resumes it after the next vkCmdBeginRendering and reports the sum of its segments. The readback arena aligned offsets to 8 bytes; an RGBA32F copy after an RGBA8 one landed on an illegal offset (-07975). Offsets now align to lcm(8, texel), and the reservation covers whole texels of the image format. Verified: Release build 0 errors; Optimum.Tests 1069 passed, 34 skipped; Optimum.Render.Vulkan.Tests 421 passed under sync,best, no SYNC- lines. With the scope hooks unwired, the new query test fails with VUIDs 06999, 00061 and 01923. --- Optimum.Render.Vulkan.Tests/QueryRingTests.cs | 73 ++++++ .../ReadbackMidFrameTests.cs | 46 ++++ .../Core/RenderTargetManager.cs | 16 ++ Optimum.Render.Vulkan/Frame/QueryRing.cs | 244 +++++++++++++----- .../Transfer/ReadbackManager.cs | 26 +- Optimum.Render.Vulkan/VulkanDevice.cs | 37 ++- .../vulkan-backend-integration-tests.cs | 14 + 7 files changed, 372 insertions(+), 84 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/QueryRingTests.cs b/Optimum.Render.Vulkan.Tests/QueryRingTests.cs index a3933c2e..7b5afe0c 100644 --- a/Optimum.Render.Vulkan.Tests/QueryRingTests.cs +++ b/Optimum.Render.Vulkan.Tests/QueryRingTests.cs @@ -227,6 +227,79 @@ public void ResultsSurviveTheirSlotBeingRecycledAndSpanSeveralPools() } } + /// + /// Review fix: a GL query counts across framebuffer binds and mid-frame + /// readbacks, a Vulkan query only inside one scope and one command buffer. + /// Thirty-one one-sample probes fill the first pool up to its last index, then + /// one query spans a draw into A, a bind to B (scope end; its continuation + /// needs a second pool, reset between the scopes), a draw into B, a readback + /// (partial submit) and a third draw. It reports the sum of all three draws, + /// every probe still reports its own sample, and validation stays clean + /// (before the fix: vkCmdEndRendering and vkEndCommandBuffer with an active query). + /// + [SkippableFact] + public unsafe void AQuerySpanningScopeEndsAndAPartialSubmitCountsEveryDraw() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, WhiteFragment, "query-probe"); + int targetA = CreateTarget(seam); + int targetB = CreateTarget(seam); + bool precise = device!.PreciseOcclusionForTests; + + var probes = new int[QueryRing.QueriesPerPool - 1]; + for (int i = 0; i < probes.Length; i++) probes[i] = seam.CreateOcclusionQuery(); + int spanning = seam.CreateOcclusionQuery(); + + seam.BeginFrame(); + seam.Present(); + + seam.BeginFrame(); + foreach (int probe in probes) Probe(seam, targetA, program, probe, 1); + + seam.BindFramebuffer(targetA); + seam.UseProgram(program); + seam.SetViewport(0, 0, 4, 4); + seam.SetColorMask(false, false, false, false); + seam.BeginOcclusionQuery(spanning); + seam.DrawFullscreenTriangle(); + seam.BindFramebuffer(targetB); + seam.DrawFullscreenTriangle(); + var pixel = new byte[4]; + fixed (byte* destination = pixel) seam.ReadDefaultFramebuffer(0, 0, 1, 1, (IntPtr)destination); + seam.BindFramebuffer(targetB); + seam.DrawFullscreenTriangle(); + seam.EndOcclusionQuery(spanning); + seam.SetColorMask(true, true, true, true); + seam.Present(); + + for (int frame = 0; frame < 4 && !seam.IsQueryResultAvailable(spanning); frame++) + { + seam.BeginFrame(); + seam.Present(); + } + + Assert.True(seam.IsQueryResultAvailable(spanning), "the spanning query never became available"); + int samples = seam.GetQueryResult(spanning); + _output.WriteLine("spanning samples " + samples + ", precise " + precise + ", pools " + + device.OcclusionQueryPoolsForTests); + Assert.NotEqual(int.MaxValue, samples); + if (precise) Assert.Equal(3 * 16, samples); + else Assert.True(samples > 0); + + foreach (int probe in probes) + { + Assert.True(seam.IsQueryResultAvailable(probe)); + if (precise) Assert.Equal(1, seam.GetQueryResult(probe)); + else Assert.True(seam.GetQueryResult(probe) > 0); + } + + GpuTest.AssertClean(seam); + } + } + private static int Coverage(int round, int index) => round == 0 ? index % Size + 1 : Size - index % Size; } diff --git a/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs index c1f769d3..d6d89299 100644 --- a/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs +++ b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs @@ -279,6 +279,52 @@ public void ReadbacksLargerThanTheArenaGrowItAndStayExact() } } + /// + /// Review fix: an RGBA8 readback leaves the arena cursor at 4, which the old + /// 8-byte alignment rounded to 8, an illegal offset for the RGBA32F copy that + /// follows (a multiple of the 16-byte texel is required). Both reads are + /// exact, the second one lands on a legal offset and validation stays clean. + /// + [SkippableFact] + public unsafe void ReadbacksOfDifferentTexelSizesInOneFrameUseLegalOffsets() + { + Assert.Equal(8UL, ReadbackManager.OffsetAlignmentFor(1)); + Assert.Equal(8UL, ReadbackManager.OffsetAlignmentFor(4)); + Assert.Equal(16UL, ReadbackManager.OffsetAlignmentFor(16)); + Assert.Equal(24UL, ReadbackManager.OffsetAlignmentFor(12)); + + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + byte[] rgba = { 11, 22, 33, 44 }; + var floats = new float[2 * 2 * 4]; + for (int i = 0; i < floats.Length; i++) floats[i] = i * 0.25f - 1.5f; + var floatBytes = new byte[floats.Length * sizeof(float)]; + System.Buffer.BlockCopy(floats, 0, floatBytes, 0, floatBytes.Length); + + int small; + fixed (byte* pixels = rgba) + small = seam.CreateTexture2D(1, 1, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, + (IntPtr)pixels, false); + int wide; + fixed (byte* pixels = floatBytes) + wide = seam.CreateTexture2DRaw(2, 2, 0x8814, (IntPtr)pixels, 16); + + seam.BeginFrame(); + seam.Present(); + + seam.BeginFrame(); + byte[] first = device!.ReadBackLevel0ForTests(small); + byte[] second = device.ReadBackLevel0ForTests(wide); + seam.Present(); + + Assert.Equal(rgba, first); + Assert.Equal(floatBytes, second); + GpuTest.AssertClean(seam); + } + } + /// /// A texture upload on the render thread in the middle of a frame submits /// the frame's recorded part first (the clear of A) and recording continues diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index defba4da..72be6a8e 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -65,6 +65,19 @@ internal sealed unsafe class RenderTargetManager : IDisposable /// How many rendering scopes have been opened, for diagnostics. public long ScopesOpened { get; private set; } + /// + /// Runs right after vkCmdBeginRendering, inside the new scope. The + /// occlusion query ring resumes a query the previous scope's end suspended: + /// GL counts samples across framebuffer changes, Vulkan only within a scope. + /// + public Action? ScopeOpened; + + /// Runs right before vkCmdEndRendering, still inside the scope (ends a running query). + public Action? ScopeClosing; + + /// Runs right after vkCmdEndRendering, outside any scope (where a query pool may be reset). + public Action? ScopeClosed; + public RenderTargetManager(VulkanContext context, TextureManager textures, GlStateTracker state) { _context = context; @@ -328,6 +341,7 @@ public void EnsureRendering(CommandBuffer commandBuffer) _needsRestart = false; ScopesOpened++; VulkanStats.NoteScopeOpened(); + ScopeOpened?.Invoke(commandBuffer); } /// @@ -337,8 +351,10 @@ public void EnsureRendering(CommandBuffer commandBuffer) public void EndRendering(CommandBuffer commandBuffer) { if (!_renderingActive) return; + ScopeClosing?.Invoke(commandBuffer); _context.Api.CmdEndRendering(commandBuffer); _renderingActive = false; + ScopeClosed?.Invoke(commandBuffer); } // ------------------------------------------------------------------- clears diff --git a/Optimum.Render.Vulkan/Frame/QueryRing.cs b/Optimum.Render.Vulkan/Frame/QueryRing.cs index f886c38f..5089ac61 100644 --- a/Optimum.Render.Vulkan/Frame/QueryRing.cs +++ b/Optimum.Render.Vulkan/Frame/QueryRing.cs @@ -12,7 +12,7 @@ namespace Optimum.Render.Vulkan.Core; /// Each frame slot owns its own occlusion query pools, 32 queries apiece, reset /// wholesale when the slot starts a frame (the first commands of its command /// buffer, outside any rendering scope). A GL query object is a small record -/// pointing at the slot, index and Frame timeline value of its most recently +/// pointing at the slot, indices and Frame timeline value of its most recently /// ended query. Results are read with vkGetQueryPoolResults without the /// wait bit, with availability, into the slot's host buffer, and only once the /// timeline says the command buffer carrying the query has finished: either when @@ -20,6 +20,14 @@ namespace Optimum.Render.Vulkan.Core; /// just before its reset. The result therefore appears a frame or two after the /// query, like GL's availability polling, and nothing ever blocks on it. /// +/// A GL query counts every sample between begin and end, across framebuffer +/// binds; a Vulkan query has to begin and end inside one rendering scope and one +/// command buffer. So a running query is suspended when its scope closes (a +/// target change, a layout transition, a readback or upload that submits the +/// frame partially, present) and resumed on a fresh index when the next scope +/// opens. Its result is the sum of those segments. The pool a resumed segment +/// needs is created and reset between the two scopes. +/// /// The plan named vkCmdCopyQueryPoolResults for the copy. That command is /// not part of Vulkan 1.3 core and needs an extension the hardware floor does not /// include; a host read gated on the timeline gives the same no-wait guarantee. @@ -37,6 +45,10 @@ internal sealed unsafe class QueryRing : IDisposable private readonly Dictionary _objects = new(); private int _nextId = 1; private int _currentSlot = -1; + // At most one occlusion query is active at a time, as in GL: either running + // inside the open scope, or suspended until the next scope opens. + private QueryRecord? _running; + private QueryRecord? _suspended; private bool _disposed; public QueryRing(VulkanContext context, ITimelineClock clock, int framesInFlight) @@ -50,7 +62,7 @@ public QueryRing(VulkanContext context, ITimelineClock clock, int framesInFlight private sealed class SlotQueries { public readonly List Pools = new(); - /// Queries handed out in the current generation. + /// Query indices handed out in the current generation. public uint Used; /// Bumped at every frame start of the slot; a record of an older generation was harvested. public ulong Generation; @@ -64,17 +76,21 @@ private sealed class QueryRecord { public readonly int Slot; public readonly ulong Generation; - public readonly uint Index; + /// One index per segment: a new one each time the query resumed in a new scope. + public readonly List Indices = new(1); public bool Ended; + /// A segment could not be resumed; the result reports every sample passed. + public bool Lost; + /// The query object was deleted while the query was running. + public bool Abandoned; public ulong FrameValue; public bool Resolved; public ulong Samples; - public QueryRecord(int slot, ulong generation, uint index) + public QueryRecord(int slot, ulong generation) { Slot = slot; Generation = generation; - Index = index; } } @@ -106,9 +122,15 @@ public int Create() /// /// Forgets the query object. Its records stay with their slot until that slot - /// is recycled; the pools are shared, so there is nothing to destroy. + /// is recycled; the pools are shared, so there is nothing to destroy. A query + /// still running ends with its scope and is not resumed. /// - public void Delete(int id) => _objects.Remove(id); + public void Delete(int id) + { + if (!_objects.Remove(id, out QueryObject? query) || query.Active == null) return; + if (ReferenceEquals(query.Active, _suspended)) _suspended = null; + else if (ReferenceEquals(query.Active, _running)) query.Active.Abandoned = true; + } /// /// The slot is starting a frame and the timeline has passed its previous one: @@ -129,69 +151,89 @@ public void BeginSlot(int slotIndex, CommandBuffer commandBuffer) slot.Used = 0; slot.Generation++; _currentSlot = slotIndex; + // Present closed the last scope, so nothing is running; a query the + // previous frame never ended is reported lost by its own slot's harvest. + _running = null; + _suspended = null; } + /// Whether can begin now: known, in a frame, and no other query active. + public bool CanBegin(int id) => + _currentSlot >= 0 && _running == null && _suspended == null && _objects.ContainsKey(id); + + /// The next query index lives in a pool that does not exist yet. + public bool NextNeedsPool => _currentSlot >= 0 && NeedsPool(_slots[_currentSlot]); + + private static bool NeedsPool(SlotQueries slot) => slot.Used / QueriesPerPool >= (uint)slot.Pools.Count; + /// - /// Hands out the next query of the current slot for . - /// is true when the pool was created just now: - /// the caller has to reset it before beginning the query, outside a rendering - /// scope. Every later frame resets it with the others. + /// Creates the pool the next index needs and records its reset. The caller is + /// outside any rendering scope. Every later frame of the slot resets it with + /// the others. /// - public bool TryBegin(int id, out QueryPool pool, out uint index, out bool freshPool) + public void AddPool(CommandBuffer commandBuffer) { - pool = default; - index = 0; - freshPool = false; - if (_currentSlot < 0 || !_objects.TryGetValue(id, out QueryObject? query)) return false; - SlotQueries slot = _slots[_currentSlot]; - int poolIndex = (int)(slot.Used / QueriesPerPool); - if (poolIndex == slot.Pools.Count) + var createInfo = new QueryPoolCreateInfo { - var createInfo = new QueryPoolCreateInfo - { - SType = StructureType.QueryPoolCreateInfo, - QueryType = QueryType.Occlusion, - QueryCount = QueriesPerPool, - }; - QueryPool created; - VulkanResult.Check(_context.Api.CreateQueryPool(_context.Device, &createInfo, null, &created), - "vkCreateQueryPool for the occlusion query ring"); - slot.Pools.Add(created); - - var host = new ulong[slot.Pools.Count * QueriesPerPool * 2]; - Array.Copy(slot.Host, host, slot.Host.Length); - slot.Host = host; - freshPool = true; - } + SType = StructureType.QueryPoolCreateInfo, + QueryType = QueryType.Occlusion, + QueryCount = QueriesPerPool, + }; + QueryPool created; + VulkanResult.Check(_context.Api.CreateQueryPool(_context.Device, &createInfo, null, &created), + "vkCreateQueryPool for the occlusion query ring"); + slot.Pools.Add(created); + + var host = new ulong[slot.Pools.Count * QueriesPerPool * 2]; + Array.Copy(slot.Host, host, slot.Host.Length); + slot.Host = host; + + _context.Api.CmdResetQueryPool(commandBuffer, created, 0, QueriesPerPool); + } - pool = slot.Pools[poolIndex]; - index = slot.Used % QueriesPerPool; + /// + /// Begins a query for . Inside an open scope it starts + /// at once; outside one it starts when the next scope opens. The caller + /// checked and added a pool if . + /// + public void Begin(int id, CommandBuffer commandBuffer, bool scopeOpen) + { + if (!CanBegin(id) || NextNeedsPool) return; - var record = new QueryRecord(_currentSlot, slot.Generation, slot.Used); - slot.Used++; + SlotQueries slot = _slots[_currentSlot]; + var record = new QueryRecord(_currentSlot, slot.Generation); slot.Pending.Add(record); - query.Active = record; - return true; + _objects[id].Active = record; + + if (scopeOpen) Start(record, commandBuffer); + else _suspended = record; } /// - /// Ends the query begun for in this frame, recorded in - /// the command buffer that will signal . It - /// becomes the query whose result the object reports. + /// Ends the query begun for in this frame, whose last + /// segment is recorded in the command buffer that will signal + /// . It becomes the query whose result the object reports. /// - public bool TryEnd(int id, ulong frameValue, out QueryPool pool, out uint index) + public void End(int id, ulong frameValue, CommandBuffer commandBuffer) { - pool = default; - index = 0; - if (!_objects.TryGetValue(id, out QueryObject? query) || query.Active == null) return false; + if (!_objects.TryGetValue(id, out QueryObject? query) || query.Active == null) return; QueryRecord record = query.Active; query.Active = null; - SlotQueries slot = _slots[record.Slot]; + if (ReferenceEquals(record, _running)) + { + _running = null; + Stop(record, commandBuffer); + } + else if (ReferenceEquals(record, _suspended)) + { + _suspended = null; + } + // A query begun in an earlier frame cannot be ended in this one; Vulkan // requires both in the same command buffer. Harvest reports it as lost. - if (record.Slot != _currentSlot || record.Generation != slot.Generation) return false; + if (record.Slot != _currentSlot || record.Generation != _slots[record.Slot].Generation) return; if (query.Latest != null) { @@ -202,10 +244,58 @@ public bool TryEnd(int id, ulong frameValue, out QueryPool pool, out uint index) record.Ended = true; record.FrameValue = frameValue; query.Latest = record; + } + + /// Scope hook, before vkCmdEndRendering: a running query ends its segment and waits for the next scope. + public void OnScopeClosing(CommandBuffer commandBuffer) + { + QueryRecord? record = _running; + if (record == null) return; + _running = null; + Stop(record, commandBuffer); + if (!record.Abandoned) _suspended = record; + } + + /// Scope hook, after vkCmdEndRendering: the pool a resumed segment will need is reset now, outside the scope. + public void OnScopeClosed(CommandBuffer commandBuffer) + { + if (_suspended != null && NextNeedsPool) AddPool(commandBuffer); + } + + /// Scope hook, after vkCmdBeginRendering: a suspended query resumes on a fresh index. + public void OnScopeOpened(CommandBuffer commandBuffer) + { + QueryRecord? record = _suspended; + if (record == null) return; + _suspended = null; + if (record.Slot != _currentSlot || record.Generation != _slots[record.Slot].Generation) return; + + // Unreachable when every scope end ran OnScopeClosed; a query that cannot + // resume reports every sample passed rather than a partial count. + if (NextNeedsPool) + { + record.Lost = true; + return; + } + Start(record, commandBuffer); + } + + private void Start(QueryRecord record, CommandBuffer commandBuffer) + { + SlotQueries slot = _slots[record.Slot]; + uint index = slot.Used++; + record.Indices.Add(index); + // GL_SAMPLES_PASSED is an exact count (sun glare divides it by 1500). + _context.Api.CmdBeginQuery(commandBuffer, slot.Pools[(int)(index / QueriesPerPool)], index % QueriesPerPool, + _context.Capabilities.OcclusionQueryPrecise ? QueryControlFlags.PreciseBit : default(QueryControlFlags)); + _running = record; + } - pool = slot.Pools[(int)(record.Index / QueriesPerPool)]; - index = record.Index % QueriesPerPool; - return true; + private void Stop(QueryRecord record, CommandBuffer commandBuffer) + { + SlotQueries slot = _slots[record.Slot]; + uint index = record.Indices[record.Indices.Count - 1]; + _context.Api.CmdEndQuery(commandBuffer, slot.Pools[(int)(index / QueriesPerPool)], index % QueriesPerPool); } /// GL_QUERY_RESULT_AVAILABLE: the latest ended query's command buffer has finished. @@ -243,23 +333,33 @@ private void Refresh(QueryRecord record) if (record.Generation != slot.Generation) return; if (_clock.FrameCompleted < record.FrameValue) return; - uint base2 = record.Index * 2; + if (record.Lost || record.Indices.Count == 0) + { + record.Resolved = true; + record.Samples = ulong.MaxValue; + return; + } + + ulong samples = 0; fixed (ulong* host = slot.Host) { - Result status = _context.Api.GetQueryPoolResults(_context.Device, - slot.Pools[(int)(record.Index / QueriesPerPool)], record.Index % QueriesPerPool, 1, - (nuint)Stride, host + base2, (ulong)Stride, ReadFlags); - if (status != Result.Success && status != Result.NotReady) + foreach (uint index in record.Indices) { - VulkanResult.Check(status, "vkGetQueryPoolResults for an occlusion query"); + uint base2 = index * 2; + Result status = _context.Api.GetQueryPoolResults(_context.Device, + slot.Pools[(int)(index / QueriesPerPool)], index % QueriesPerPool, 1, + (nuint)Stride, host + base2, (ulong)Stride, ReadFlags); + if (status != Result.Success && status != Result.NotReady) + { + VulkanResult.Check(status, "vkGetQueryPoolResults for an occlusion query"); + } + if (host[base2 + 1] == 0) return; + samples += host[base2]; } } - if (slot.Host[base2 + 1] != 0) - { - record.Resolved = true; - record.Samples = slot.Host[base2]; - } + record.Resolved = true; + record.Samples = samples; } private void Harvest(SlotQueries slot) @@ -287,13 +387,25 @@ private void Harvest(SlotQueries slot) foreach (QueryRecord record in slot.Pending) { if (record.Resolved) continue; - uint base2 = record.Index * 2; record.Resolved = true; - record.Samples = record.Ended && slot.Host[base2 + 1] != 0 ? slot.Host[base2] : ulong.MaxValue; + record.Samples = SumOfHarvested(slot, record); } slot.Pending.Clear(); } + private static ulong SumOfHarvested(SlotQueries slot, QueryRecord record) + { + if (!record.Ended || record.Lost || record.Indices.Count == 0) return ulong.MaxValue; + ulong samples = 0; + foreach (uint index in record.Indices) + { + uint base2 = index * 2; + if (slot.Host[base2 + 1] == 0) return ulong.MaxValue; + samples += slot.Host[base2]; + } + return samples; + } + /// The caller has waited for every signalled frame first. public void Dispose() { diff --git a/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs index 3627dfd1..6ddf24b3 100644 --- a/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs +++ b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs @@ -64,7 +64,11 @@ public ReadbackTicket CopyToHost(VulkanTexture texture, int x, int y, uint width { FrameSlot slot = _frames.Current; CommandBuffer commandBuffer = slot.CommandBuffer; - VulkanBuffer arena = Reserve(slot.Index, bytes, out ulong offset); + // The copy writes whole texels whatever the caller asked for, so the + // reservation covers them all; only the requested bytes are handed out. + ulong texel = (ulong)TextureDump.BytesPerTexel(texture.Format); + ulong copied = (ulong)width * height * texel; + VulkanBuffer arena = Reserve(slot.Index, Math.Max(bytes, copied), OffsetAlignmentFor(texel), out ulong offset); ImageLayout restore = texture.Layout; _textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); @@ -81,7 +85,21 @@ public ReadbackTicket CopyToHost(VulkanTexture texture, int x, int y, uint width if (restore != ImageLayout.Undefined) _textures.TransitionTexture(commandBuffer, texture, restore); - return new ReadbackTicket(arena, offset, bytes, slot.FrameValue); + return new ReadbackTicket(arena, offset, Math.Min(bytes, copied), slot.FrameValue); + } + + /// + /// A buffer offset legal for a copy of texels of : + /// a multiple of the texel size (VUID-vkCmdCopyImageToBuffer-srcImage-07975, + /// 16 for RGBA32F) and of 4 for depth (-04053). Eight alone put an RGBA32F copy + /// that followed an RGBA8 one at an illegal offset. + /// + internal static ulong OffsetAlignmentFor(ulong texelBytes) + { + ulong alignment = OffsetAlignment; + if (texelBytes == 0) return alignment; + while (alignment % texelBytes != 0) alignment += OffsetAlignment; + return alignment; } /// Whether the copy has run, without waiting. @@ -98,10 +116,10 @@ public void WaitAndCopy(ReadbackTicket ticket, IntPtr destination) (void*)destination, (long)ticket.Size, (long)ticket.Size); } - private VulkanBuffer Reserve(int slotIndex, ulong bytes, out ulong offset) + private VulkanBuffer Reserve(int slotIndex, ulong bytes, ulong alignment, out ulong offset) { VulkanBuffer? arena = _arenas[slotIndex]; - ulong aligned = (_cursors[slotIndex] + OffsetAlignment - 1) / OffsetAlignment * OffsetAlignment; + ulong aligned = (_cursors[slotIndex] + alignment - 1) / alignment * alignment; if (arena == null || aligned + bytes > arena.Size) { diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index b57fc093..d4a1e5b0 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -337,6 +337,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _frames = new FrameRing(_context); _queryRing = new QueryRing(_context, _frames.Timeline, _frames.FramesInFlight); _readbacks = new ReadbackManager(_context, _textures, _frames); + // A GL query counts across scope ends; a Vulkan one must not be active + // across vkCmdEndRendering, so the ring suspends and resumes it. + _targets.ScopeClosing = _queryRing.OnScopeClosing; + _targets.ScopeClosed = _queryRing.OnScopeClosed; + _targets.ScopeOpened = _queryRing.OnScopeOpened; _shaderCompiler = new ShaderCompiler(); CreateDefaultAttributeBuffer(); CreatePlaceholderTexture(); @@ -2458,31 +2463,27 @@ private VulkanBuffer AllocateIndirect(int groupCount, out ulong offset) public void BeginOcclusionQuery(int queryId) { - if (!_frameActive || !_queryRing.TryBegin(queryId, out QueryPool pool, out uint index, out bool freshPool)) return; + if (!_frameActive || !_queryRing.CanBegin(queryId)) return; // The slot's pools are reset at frame start, before any scope opens, so // the query begins inside the scope the covered draw uses. Only a pool // created just now needs a reset here, and a reset has to happen outside - // a scope: one restart per pool ever, never in steady state. + // a scope: one restart per pool ever, never in steady state. If the + // scope later closes before the query ends, the ring's scope hooks + // suspend it and resume it in the next scope. CommandBuffer commandBuffer = Commands; - if (freshPool) + if (_queryRing.NextNeedsPool) { _targets.EndRendering(commandBuffer); - _context.Api.CmdResetQueryPool(commandBuffer, pool, 0, QueryRing.QueriesPerPool); + _queryRing.AddPool(commandBuffer); } _targets.EnsureRendering(commandBuffer); - // GL_SAMPLES_PASSED is an exact count (sun glare divides it by 1500). - _context.Api.CmdBeginQuery(commandBuffer, pool, index, - _context.Capabilities.OcclusionQueryPrecise ? QueryControlFlags.PreciseBit : default(QueryControlFlags)); + _queryRing.Begin(queryId, commandBuffer, _targets.RenderingActive); } public void EndOcclusionQuery(int queryId) { - if (_frameActive && - _queryRing.TryEnd(queryId, _frames.Current.FrameValue, out QueryPool pool, out uint index)) - { - _context.Api.CmdEndQuery(Commands, pool, index); - } + if (_frameActive) _queryRing.End(queryId, _frames.Current.FrameValue, Commands); } /// @@ -2551,6 +2552,10 @@ private void DumpRequestedTextures() /// a frame it goes through , so the frame stays open. /// Depth images are copied through their depth aspect. /// + /// Level 0 of a texture through the dump path's readback. Tests only. + internal byte[] ReadBackLevel0ForTests(int textureId) => + ReadBackLevel0(_textures.Get(textureId) ?? throw new ArgumentException("no texture " + textureId)); + private byte[] ReadBackLevel0(VulkanTexture texture) { int width = (int)texture.Width; @@ -2591,7 +2596,11 @@ private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint heig return; } - using var readback = new VulkanBuffer(_context, bytes, + // The copy writes whole texels of the image's format whatever the caller + // sized its destination for; the buffer holds them all, the caller gets its bytes. + ulong copied = (ulong)width * height * (ulong)BytesPerPixel(texture.Format); + ulong handed = Math.Min(bytes, copied); + using var readback = new VulkanBuffer(_context, Math.Max(bytes, copied), BufferUsageFlags.TransferDstBit, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); @@ -2612,7 +2621,7 @@ private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint heig if (restore != ImageLayout.Undefined) _textures.TransitionTexture(commandBuffer, texture, restore); }, WaitSite.Readback); - System.Buffer.MemoryCopy((void*)readback.Mapped, (void*)destination, (long)bytes, (long)bytes); + System.Buffer.MemoryCopy((void*)readback.Mapped, (void*)destination, (long)bytes, (long)handed); } private void RecordGlInternalFormat(int textureId, int glInternalFormat) diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index f24fa9b9..94b3b277 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -505,7 +505,21 @@ public void ReadbacksAndOcclusionQueriesNeverFlushTheFrameOrWaitForTheDevice() Assert.DoesNotContain("ResultWaitBit", queries); Assert.DoesNotContain("WaitForFrame(", queries); + // Review fix: a query survives scope ends (suspend before vkCmdEndRendering, + // resume after vkCmdBeginRendering), so no query is active across a + // restart, a partial submit or present. + string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); + Assert.Contains("ScopeClosing?.Invoke(commandBuffer);", targets); + Assert.Contains("ScopeClosed?.Invoke(commandBuffer);", targets); + Assert.Contains("ScopeOpened?.Invoke(commandBuffer);", targets); + Assert.Contains("_targets.ScopeClosing = _queryRing.OnScopeClosing;", device); + Assert.Contains("_targets.ScopeClosed = _queryRing.OnScopeClosed;", device); + Assert.Contains("_targets.ScopeOpened = _queryRing.OnScopeOpened;", device); + Assert.Contains("public void OnScopeClosing(CommandBuffer commandBuffer)", queries); + string readbacks = Read("Optimum.Render.Vulkan/Transfer/ReadbackManager.cs"); + // Review fix: buffer offsets are multiples of the texel size (RGBA32F needs 16). + Assert.Contains("OffsetAlignmentFor(texel)", readbacks); Assert.Contains("CmdCopyImageToBuffer(", readbacks); Assert.Contains("WaitSite.Readback", readbacks); Assert.DoesNotContain("WaitDeviceIdle", readbacks); From 0d1ce0602a23276bd8f413df6428fa6b5909d8c9 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:48:40 +0200 Subject: [PATCH 090/226] wip(phase1b-step3): UploadManager - uploads record into a per-slot upload command buffer, no SubmitAndWait Texture uploads, mip generation and bulk mesh uploads record into the frame slot's upload command buffer (staging ring, oversized uploads on dedicated staging retired on the timeline) and are submitted before the frame command buffer; a destination already used this frame records inline outside a rendering scope to keep GL ordering. VulkanCommands.SubmitAndWait is gone. Buffer barriers cover the upload copies (fixed a SYNC-HAZARD-READ-AFTER-WRITE on an index buffer). Verified in this worktree: Release build 0 errors; Optimum.Tests 1070 passed; full GPU suite 422 passed, 0 failed with sync,best validation; AsyncTransferTests 3/3 on three repeated runs. Agent session ended before committing; finished and verified by the main session. --- Optimum.Render.Vulkan.Tests/AllocatorTests.cs | 4 +- .../AsyncTransferTests.cs | 455 ++++++++++++++++ .../AttachmentSemanticsTests.cs | 22 +- .../ChunkRenderPathTests.cs | 18 +- .../MeshManagerTests.cs | 6 +- .../PacingStatsTests.cs | 67 ++- .../PoisonModeTests.cs | 10 +- .../ReadbackMidFrameTests.cs | 13 +- .../RenderTargetTests.cs | 22 +- Optimum.Render.Vulkan.Tests/SetupQueue.cs | 91 ++++ .../SyncValidationControlTests.cs | 4 +- .../TaaResolveTests.cs | 42 +- .../TaaSharpenTests.cs | 18 +- .../TextureManagerTests.cs | 36 +- .../VulkanDeviceTests.cs | 2 +- .../WorldRenderPathTests.cs | 24 +- Optimum.Render.Vulkan/Core/FrameRing.cs | 124 +++-- Optimum.Render.Vulkan/Core/MeshManager.cs | 61 ++- Optimum.Render.Vulkan/Core/TextureManager.cs | 125 +++-- Optimum.Render.Vulkan/Core/VulkanResources.cs | 173 +----- Optimum.Render.Vulkan/Core/VulkanStats.cs | 26 +- Optimum.Render.Vulkan/Frame/FrameTimeline.cs | 11 +- .../Transfer/UploadManager.cs | 503 ++++++++++++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 65 ++- .../vulkan-backend-integration-tests.cs | 53 +- 25 files changed, 1561 insertions(+), 414 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/AsyncTransferTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SetupQueue.cs create mode 100644 Optimum.Render.Vulkan/Transfer/UploadManager.cs diff --git a/Optimum.Render.Vulkan.Tests/AllocatorTests.cs b/Optimum.Render.Vulkan.Tests/AllocatorTests.cs index 2de069ee..3ece2c4b 100644 --- a/Optimum.Render.Vulkan.Tests/AllocatorTests.cs +++ b/Optimum.Render.Vulkan.Tests/AllocatorTests.cs @@ -232,8 +232,8 @@ public void ImagesAndBuffersNeverShareABlock() using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var buffer = new VulkanBuffer(context!, 4096, BufferUsageFlags.VertexBufferBit, diff --git a/Optimum.Render.Vulkan.Tests/AsyncTransferTests.cs b/Optimum.Render.Vulkan.Tests/AsyncTransferTests.cs new file mode 100644 index 00000000..ddf65177 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AsyncTransferTests.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 1B step 3: no upload waits. Uploads, mip chains and staged buffer writes +/// are recorded (from any thread) into an upload batch the next frame submission +/// carries first, or inline into the frame command buffer when that already used +/// the destination, so GL's call order holds. Nothing in the upload path waits. +/// +public class AsyncTransferTests +{ + private readonly ITestOutputHelper _output; + + public AsyncTransferTests(ITestOutputHelper output) => _output = output; + + private const string FullscreenVertex = """ + #version 330 core + void main() { + gl_Position = vec4(-1 + ((gl_VertexID & 1) << 2), + -1 + ((gl_VertexID & 2) << 1), 0, 1); + } + """; + + private static int CreateTarget(IOptimumGraphicsDevice seam, int size, out int texture) + { + texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + return framebuffer; + } + + /// Binds and reads a target; inside a frame, so the bind takes effect. + private static unsafe byte[] Read(IOptimumGraphicsDevice seam, int framebuffer, int size) + { + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + return pixels; + } + + private static byte[] Fill(int size, byte r, byte g, byte b, byte a = 255) + { + var pixels = new byte[size * size * 4]; + for (int i = 0; i < pixels.Length; i += 4) + { + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = a; + } + return pixels; + } + + private static unsafe void Upload(IOptimumGraphicsDevice seam, int texture, int size, byte[] pixels) + { + fixed (byte* source = pixels) + { + seam.UploadTexture2D(texture, 0, 0, 0, size, size, EnumTexturePixelFormat.Rgba, (IntPtr)source); + } + } + + private static void AssertEvery(byte[] pixels, byte r, byte g, byte b, byte a, string what) + { + for (int i = 0; i < pixels.Length; i += 4) + { + Assert.True(pixels[i] == r && pixels[i + 1] == g && pixels[i + 2] == b && pixels[i + 3] == a, + what + ": pixel " + i / 4 + " is " + pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + + pixels[i + 3] + ", expected " + r + "," + g + "," + b + "," + a); + } + } + + /// A quad from x = -1 to , full height. + private static MeshData Quad(float right) => new(4, 6) + { + xyz = new[] { -1f, -1f, 0f, right, -1f, 0f, right, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + + /// + /// The plan's gate. A worker thread uploads into its own textures the whole + /// time the render thread records 60 frames with Present between them; every + /// frame inserts a mipmapped texture, regenerates its chain, and creates and + /// draws a static mesh on device-local memory (its vertices and indices staged + /// through the same batches). Over all of it: zero blocking uploads, zero waits + /// at the upload, flush and readback sites. The worker's last uploads are read + /// back on frame N+2, the last mesh's geometry and the last inserted texture's + /// smallest mip are checked, every reserved Transfer value was signalled, and + /// the retire queue drains. + /// + [SkippableFact] + public unsafe void WorkerUploadsWhileFramesRecordNeverBlockAndLandByFrameNPlus2() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + device!.DeviceLocalStaticMeshesForTests = true; + const int size = 8; + const int frames = 60; + const int workerTextureCount = 4; + + int meshProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, """ + #version 330 core + layout(location = 0) in vec3 position; + void main() { gl_Position = vec4(position, 1); } + """, """ + #version 330 core + out vec4 color; + void main() { color = vec4(1); } + """, "async-mesh"); + int mipProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D source; + out vec4 color; + void main() { color = textureLod(source, vec2(0.5), 3.0); } + """, "async-mip"); + int mipSampler = seam.CreateSampler(false); + + var workerTextures = new int[workerTextureCount]; + var workerTargets = new int[workerTextureCount]; + for (int i = 0; i < workerTextureCount; i++) workerTargets[i] = CreateTarget(seam, size, out workerTextures[i]); + int meshTarget = CreateTarget(seam, size, out _); + int mipTarget = CreateTarget(seam, size, out _); + + // Warm up: placeholder uploads and the first pipelines are not what is measured. + seam.BeginFrame(); + seam.Present(); + + long blockingBefore = VulkanStats.BlockingUploads; + long uploadWaitsBefore = VulkanStats.WaitCount(WaitSite.UploadSubmit); + long flushWaitsBefore = VulkanStats.WaitCount(WaitSite.FlushFrame); + long readbackWaitsBefore = VulkanStats.WaitCount(WaitSite.Readback); + long idleWaitsBefore = VulkanStats.WaitCount(WaitSite.DeviceWaitIdle); + long requestsBefore = VulkanStats.UploadRequests; + + int stop = 0; + int rounds = 0; + Exception? workerError = null; + using var started = new ManualResetEventSlim(false); + var worker = new Thread(() => + { + try + { + started.Set(); + while (Volatile.Read(ref stop) == 0) + { + byte value = (byte)(rounds * 7); + Upload(seam, workerTextures[rounds % workerTextureCount], size, Fill(size, value, value, value)); + rounds++; + Thread.Sleep(1); + } + for (int i = 0; i < workerTextureCount; i++) + { + Upload(seam, workerTextures[i], size, Fill(size, (byte)(10 + i * 40), (byte)(200 - i * 30), (byte)(5 * i))); + } + } + catch (Exception error) + { + workerError = error; + } + }) { IsBackground = true, Name = "async-transfer-worker" }; + worker.Start(); + started.Wait(); + + int previousTexture = 0; + int previousMesh = 0; + int lastTexture = 0; + for (int frame = 0; frame < frames; frame++) + { + seam.BeginFrame(); + + // A texture insert with its mip chain, then the chain again. + byte[] colour = Fill(size, (byte)(frame * 4), (byte)(255 - frame * 4), 90); + fixed (byte* pixels = colour) + { + lastTexture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, (IntPtr)pixels, true); + } + seam.GenerateMipmaps(lastTexture); + + // A static mesh: device-local buffers, filled through staging, drawn this frame. + int mesh = seam.CreateMesh(Quad(frame % 2 == 1 ? 0f : 1f), true); + seam.BindFramebuffer(meshTarget); + seam.UseProgram(meshProgram); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.DrawMesh(mesh); + + seam.Present(); + + if (previousTexture != 0) seam.DeleteTexture(previousTexture); + if (previousMesh != 0) seam.DeleteMesh(previousMesh); + previousTexture = lastTexture; + previousMesh = mesh; + } + + Volatile.Write(ref stop, 1); + Assert.True(worker.Join(TimeSpan.FromSeconds(30)), "the worker did not finish"); + Assert.Null(workerError); + _output.WriteLine("worker upload rounds during " + frames + " frames: " + rounds + + "; batches " + device.UploadsForTests.BatchCount + + "; staging overflows so far " + VulkanStats.StagingOverflows); + Assert.True(rounds > 0, "the worker never uploaded while frames recorded"); + + Assert.Equal(0, VulkanStats.BlockingUploads - blockingBefore); + Assert.Equal(0, VulkanStats.WaitCount(WaitSite.UploadSubmit) - uploadWaitsBefore); + Assert.Equal(0, VulkanStats.WaitCount(WaitSite.FlushFrame) - flushWaitsBefore); + Assert.Equal(0, VulkanStats.WaitCount(WaitSite.Readback) - readbackWaitsBefore); + Assert.Equal(0, VulkanStats.WaitCount(WaitSite.DeviceWaitIdle) - idleWaitsBefore); + // Per frame: upload + mip chain, the explicit chain, and the mesh's staged parts. + Assert.True(VulkanStats.UploadRequests - requestsBefore >= frames * 4L); + + // Frame N+1 carries the worker's last batch; frame N+2 reads. + seam.BeginFrame(); + seam.Present(); + FrameTimeline timeline = device.TimelineForTests; + timeline.WaitForFrame(timeline.FrameSignalled, WaitSite.DeviceWaitIdle); + Assert.Equal(timeline.TransferRecorded, timeline.TransferSignalled); + + seam.BeginFrame(); + Assert.Equal(0, device.PendingRetirementsForTests); + + for (int i = 0; i < workerTextureCount; i++) + { + AssertEvery(Read(seam, workerTargets[i], size), (byte)(10 + i * 40), (byte)(200 - i * 30), (byte)(5 * i), 255, + "worker texture " + i); + } + + // The last quad covered the left half only (frame 59 is odd). + byte[] meshPixels = Read(seam, meshTarget, size); + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + int at = (y * size + x) * 4; + byte expected = x < size / 2 ? (byte)255 : (byte)0; + Assert.True(meshPixels[at] == expected && meshPixels[at + 3] == 255, + "mesh pixel " + x + "," + y + " is " + meshPixels[at] + ", expected " + expected); + } + } + + // The last inserted texture's 1x1 level holds its uniform colour. + seam.BindFramebuffer(mipTarget); + seam.UseProgram(mipProgram); + seam.SetViewport(0, 0, size, size); + seam.SetSamplerUnit(mipProgram, "source", 0); + seam.BindTexture(0, lastTexture); + seam.BindSampler(0, mipSampler); + seam.DrawFullscreenTriangle(); + AssertEvery(Read(seam, mipTarget, size), (byte)((frames - 1) * 4), (byte)(255 - (frames - 1) * 4), 90, 255, + "smallest mip of the last inserted texture"); + seam.Present(); + + GpuTest.AssertClean(seam); + } + } + + /// + /// GL order for a re-upload. A texture sampled by a draw in this frame and + /// then uploaded again (level 0 and its chain) has to reach only the draws + /// recorded after the upload. The batch runs before the whole frame command + /// buffer, so those two go inline; the earlier upload of the same frame, before + /// any use, stays batched. Draw A sees red, draw B green, and nothing waits. + /// + [SkippableFact] + public unsafe void AReuploadOfATextureSampledThisFrameKeepsGlOrdering() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 4; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D source; + out vec4 color; + void main() { color = textureLod(source, vec2(0.5), 2.0); } + """, "reupload-order"); + int source = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, true); + int sampler = seam.CreateSampler(false); + int targetA = CreateTarget(seam, size, out _); + int targetB = CreateTarget(seam, size, out _); + int targetC = CreateTarget(seam, size, out _); + + seam.BeginFrame(); + seam.Present(); + + long blockingBefore = VulkanStats.BlockingUploads; + long inlineBefore = VulkanStats.InlineUploads; + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); + + seam.BeginFrame(); + Upload(seam, source, size, Fill(size, 255, 0, 0)); + seam.GenerateMipmaps(source); + Assert.Equal(0, VulkanStats.InlineUploads - inlineBefore); + + seam.BindFramebuffer(targetA); + seam.UseProgram(program); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, source); + seam.BindSampler(0, sampler); + seam.DrawFullscreenTriangle(); + + Upload(seam, source, size, Fill(size, 0, 255, 0)); + seam.GenerateMipmaps(source); + Assert.Equal(2, VulkanStats.InlineUploads - inlineBefore); + + seam.BindFramebuffer(targetB); + seam.DrawFullscreenTriangle(); + // Both draws, the batch and the inline copies are still one submission. + Assert.Equal(0, VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore); + + AssertEvery(Read(seam, targetA, size), 255, 0, 0, 255, "draw before the re-upload"); + AssertEvery(Read(seam, targetB, size), 0, 255, 0, 255, "draw after the re-upload"); + seam.Present(); + + // The inline upload persists into later frames like any other. + seam.BeginFrame(); + seam.BindFramebuffer(targetC); + seam.UseProgram(program); + seam.BindTexture(0, source); + seam.BindSampler(0, sampler); + seam.DrawFullscreenTriangle(); + AssertEvery(Read(seam, targetC, size), 0, 255, 0, 255, "next frame"); + seam.Present(); + + Assert.Equal(0, VulkanStats.BlockingUploads - blockingBefore); + GpuTest.AssertClean(seam); + } + } + + /// + /// The step-1 review's rule at ring level. An upload between frames opens a + /// batch and reserves a Transfer value; a resource retired while it is open is + /// keyed on that value and survives the next frame start, because no submission + /// signalled it yet. The frame's submission carries the batch and signals it, + /// and the retire queue then drains. An upload larger than the batch's staging + /// region takes a dedicated staging buffer, counted, retired the same way. The + /// staged texels read back exactly. + /// + [SkippableFact] + public unsafe void AnUploadBetweenFramesRidesTheNextFrameAndItsTransferValueReleasesRetiredResources() + { + var messages = new List(); + Skip.IfNot(GpuTest.TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20, stagingPerSlot: 64 * 1024); + using var textures = new TextureManager(context!, ring.Uploads); + + const uint smallSize = 8; + const uint largeSize = 256; + int small = textures.Create(smallSize, smallSize, Format.R8G8B8A8Unorm); + int large = textures.Create(largeSize, largeSize, Format.R8G8B8A8Unorm); + + var smallPixels = new byte[smallSize * smallSize * 4]; + for (int i = 0; i < smallPixels.Length; i++) smallPixels[i] = (byte)(i * 13 % 251); + var largePixels = new byte[largeSize * largeSize * 4]; + for (int i = 0; i < largePixels.Length; i++) largePixels[i] = (byte)(i * 7 % 253); + + long overflowsBefore = VulkanStats.StagingOverflows; + fixed (byte* pixels = smallPixels) textures.Upload(small, 0, 0, 0, smallSize, smallSize, (IntPtr)pixels, 4); + Assert.Equal(0, VulkanStats.StagingOverflows - overflowsBefore); + fixed (byte* pixels = largePixels) textures.Upload(large, 0, 0, 0, largeSize, largeSize, (IntPtr)pixels, 4); + Assert.Equal(1, VulkanStats.StagingOverflows - overflowsBefore); + + Assert.True(ring.Uploads.HasOpenBatch); + ulong reserved = ring.Timeline.TransferRecorded; + Assert.True(reserved > ring.Timeline.TransferSignalled, "the open batch has no unsignalled Transfer value"); + // The dedicated staging buffer, then one resource retired while the batch is open. + Assert.Equal(1, ring.PendingDeletionCount); + ring.DeferDeletion(new VulkanBuffer(context!, 16, BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit)); + + ring.BeginFrame(); + Assert.Equal(2, ring.PendingDeletionCount); + ring.EndFrame(); + Assert.Equal(reserved, ring.Timeline.TransferSignalled); + Assert.False(ring.Uploads.HasOpenBatch); + + for (int frame = 0; frame < 3; frame++) + { + ring.BeginFrame(); + ring.EndFrame(); + } + ring.Timeline.WaitForFrame(ring.Timeline.FrameSignalled, WaitSite.DeviceWaitIdle); + ring.BeginFrame(); + Assert.Equal(0, ring.PendingDeletionCount); + ring.EndFrame(); + + Assert.Equal(smallPixels, ReadTexture(context!, ring, textures, small, smallSize)); + Assert.Equal(largePixels, ReadTexture(context!, ring, textures, large, largeSize)); + Assert.Equal(ring.Timeline.TransferRecorded, ring.Timeline.TransferSignalled); + + VulkanStats.WaitDeviceIdle(context!.Api, context.Device); + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + } + + /// Between frames: a copy appended to the open batch, submitted on its own, waited for. + private static unsafe byte[] ReadTexture(VulkanContext context, FrameRing ring, TextureManager textures, int id, uint size) + { + VulkanTexture texture = textures.Get(id)!; + ulong bytes = (ulong)size * size * 4; + using var readback = new VulkanBuffer(context, bytes, BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + CommandBuffer commandBuffer = ring.Uploads.BeginRecording(inlineInFrame: false); + try + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageExtent = new Extent3D(size, size, 1), + }; + context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, ImageLayout.TransferSrcOptimal, + readback.Handle, 1, ®ion); + } + finally + { + ring.Uploads.EndRecording(); + } + ring.Timeline.WaitForTransfer(ring.Uploads.SubmitStandalone(), WaitSite.Readback); + + var result = new byte[bytes]; + System.Runtime.InteropServices.Marshal.Copy(readback.Mapped, result, 0, result.Length); + return result; + } +} diff --git a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs index 0a478cf7..c6cc9680 100644 --- a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -57,8 +57,8 @@ public unsafe void OnlyTheDeclaredLocationsAmongFiveAttachmentsAreWritten() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -131,8 +131,8 @@ public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContents() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -214,8 +214,8 @@ public unsafe void EachAttachmentBlendsWithItsOwnFactorsRegardlessOfSharedAlpha( using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -287,8 +287,8 @@ public unsafe void TheBoundFramebuffersDepthCanBeSampledWithWritesOff() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -381,7 +381,7 @@ private static TranslatedProgram Translate(ShaderCompiler compiler, string verte }, compiler); private static unsafe void RenderFullscreen( - VulkanContext context, VulkanCommands commands, RenderTargetManager targets, + VulkanContext context, SetupQueue commands, RenderTargetManager targets, GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, int framebuffer, uint size, bool depthTest = false) { @@ -446,7 +446,7 @@ private static unsafe void RenderFullscreen( /// depth test/write stay off throughout. /// private static unsafe void RenderFullscreenSamplingDepth( - VulkanContext context, VulkanCommands commands, TextureManager textures, RenderTargetManager targets, + VulkanContext context, SetupQueue commands, TextureManager textures, RenderTargetManager targets, GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, int framebuffer, int sampledDepthTextureId, uint size) { @@ -534,7 +534,7 @@ private static unsafe void FillTexture(TextureManager textures, int textureId, u } private static unsafe byte[] ReadTexture( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)size * size * 4; diff --git a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs index 7c46bce7..36532809 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -50,8 +50,8 @@ public void TheRealChunkProgramTranslatesAndBuildsAPipeline() using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -179,8 +179,8 @@ public void AWorldProgramBuildsAPipelineAgainstItsMeshLayout(string programName) using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -269,8 +269,8 @@ public unsafe void TheSsboChunkPathUploadsFaceRecordsAndDraws() using (context) { const uint size = 16; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -431,8 +431,8 @@ public unsafe void InstancedDrawsRenderEveryInstance() using (context) { const uint size = 16; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -573,7 +573,7 @@ private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) } private static unsafe byte[] ReadTexture( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)size * size * 4; diff --git a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs index 0195d7fd..158ec7c3 100644 --- a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs @@ -369,8 +369,8 @@ public unsafe void AnIndexedMeshRendersWithItsVertexColours() using (context) { const uint size = 16; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -510,7 +510,7 @@ private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) } private static unsafe byte[] ReadTexture( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)size * size * 4; diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 418c9853..4a5ac396 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -14,8 +14,8 @@ namespace Optimum.Render.Vulkan.Tests; /// /// The pacing measurement the Phase 0 plan asks for: the frame-interval ring, the /// stats lines' stable tokens, the pacing gate reading them, every wait site being -/// counted, and one GPU case that pins today's blocking upload so Phase 1B can -/// flip it. +/// counted, and the GPU case Phase 1B step 3 flipped: an upload inside a frame +/// never blocks. /// public class PacingStatsTests { @@ -283,8 +283,11 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() if (text.Contains("WaitForFences(")) Assert.Contains("VulkanStats.NoteWait(", text); // So does every timeline semaphore wait. if (text.Contains("WaitSemaphores(")) Assert.Contains("VulkanStats.NoteWait(", text); - // So does every queue submission (the queue lock is held through upload fence waits). + // So does every queue submission (the queue lock is shared with present). if (text.Contains("QueueSubmit(")) Assert.Contains("VulkanStats.NoteWait(", text); + // Phase 1B step 3: no synchronous upload submit exists anywhere. + Assert.DoesNotContain("SubmitAndWait", text); + Assert.DoesNotContain("BeforeSynchronousSubmit", text); } // Phase 1B step 1: the frame ring paces on the Frame timeline, never on a fence. @@ -310,10 +313,21 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() Assert.True(submitStart >= 0 && queueLock > submitStart && submitNoted > queueLock, "the frame submit must be timed from before the queue lock to after the submit"); - string resources = Source("Core/VulkanResources.cs"); - string submit = Body(resources, "public void SubmitAndWait("); - Assert.Contains("VulkanStats.NoteWait(site, start);", submit); - Assert.Contains("if (site == WaitSite.UploadSubmit) VulkanStats.NoteBlockingUpload();", submit); + // Phase 1B step 3: the upload batch rides the frame submission, first, and + // nothing in the upload path waits; its own between-frames submission is + // counted like the frame's. + Assert.Contains("_uploads.TakeOpenBatchLocked(out CommandBuffer uploadCommands, out ulong transferValue);", frameSubmit); + Assert.True(frameSubmit.IndexOf("commandBuffers[commandBufferCount++] = uploadCommands;", StringComparison.Ordinal) < + frameSubmit.IndexOf("commandBuffers[commandBufferCount++] = commandBuffer;", StringComparison.Ordinal), + "the upload batch must precede the frame command buffer in the submission"); + string uploads = Source("Transfer/UploadManager.cs"); + Assert.DoesNotContain("WaitForFences(", uploads); + Assert.DoesNotContain("WaitSemaphores(", uploads); + Assert.DoesNotContain("WaitForTransfer(", uploads); + Assert.DoesNotContain("WaitForFrame(", uploads); + Assert.Contains("VulkanStats.NoteWait(WaitSite.QueueSubmit, submitStart);", Body(uploads, "public ulong SubmitStandalone()")); + Assert.DoesNotContain("NoteBlockingUpload", uploads); + Assert.DoesNotContain("WaitSite.UploadSubmit", Source("Core/TextureManager.cs")); string swapchain = Source("Core/Swapchain.cs"); Assert.Contains("WaitSite.SwapchainAcquire", Body(swapchain, "public bool TryAcquire(")); @@ -331,8 +345,9 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() Assert.Contains("_readbacks.WaitAndCopy(ticket, destination);", readBack); Assert.Contains("_frames.Timeline.WaitForFrame(ticket.FrameValue, WaitSite.Readback);", Source("Transfer/ReadbackManager.cs")); - // A between-frames readback waits on a setup fence, but it is not an upload. - Assert.Equal(Count(device, "_setupCommands.SubmitAndWait("), Count(device, "WaitSite.Readback);")); + // A between-frames readback waits on its upload batch's Transfer value, at the readback site. + Assert.Contains("_frames.Timeline.WaitForTransfer(transferValue, WaitSite.Readback);", readBack); + Assert.DoesNotContain("WaitSite.UploadSubmit", device); // The per-draw dynamic-state count matches the commands actually recorded. string dynamicState = Body(device, "private void ApplyDynamicState("); @@ -347,15 +362,16 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() // ------------------------------------------------------------------ GPU /// - /// Today a texture upload inside a frame submits a setup command buffer and - /// waits for its fence: one blocking upload, one wait at the upload site. - /// Phase 1B (non-blocking transfer) flips both deltas to zero - rename this - /// test then, keep the readback half as it is. The readback that verifies the - /// pixels waits too, but at the readback site, and must never count as an - /// upload. + /// Phase 1B step 3 flipped this test (it was + /// TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B, pinning one + /// blocking upload and one wait at the upload site). A texture upload inside a + /// frame is recorded into the upload batch that the frame's one submission + /// carries first: no blocking upload, no wait at the upload site, no extra + /// submission. The readback that verifies the pixels waits, at the readback + /// site, and never counts as an upload. /// [SkippableFact] - public unsafe void TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B() + public unsafe void TextureUploadInsideAFrameNeverBlocks() { Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) @@ -383,6 +399,7 @@ public unsafe void TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B() long blockingBefore = VulkanStats.BlockingUploads; long requestsBefore = VulkanStats.UploadRequests; long uploadWaitsBefore = VulkanStats.WaitCount(WaitSite.UploadSubmit); + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); fixed (byte* pixels = data) seam.UploadTexture2D(texture, 0, 0, 0, size, size, EnumTexturePixelFormat.Rgba, (IntPtr)pixels); long blockingDelta = VulkanStats.BlockingUploads - blockingBefore; @@ -390,6 +407,7 @@ public unsafe void TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B() long uploadWaitsDelta = VulkanStats.WaitCount(WaitSite.UploadSubmit) - uploadWaitsBefore; seam.Present(); + long submitsDelta = VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore; long blockingBeforeReadback = VulkanStats.BlockingUploads; long readbackWaitsBefore = VulkanStats.WaitCount(WaitSite.Readback); @@ -400,9 +418,10 @@ public unsafe void TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B() Assert.Equal(data, pixelsOut); Assert.Equal(1, requestsDelta); - // Phase 1B: both of these become Assert.Equal(0, ...). - Assert.Equal(1, blockingDelta); - Assert.Equal(1, uploadWaitsDelta); + Assert.Equal(0, blockingDelta); + Assert.Equal(0, uploadWaitsDelta); + // The upload rode the frame's own submission. + Assert.Equal(1, submitsDelta); Assert.Equal(0, VulkanStats.BlockingUploads - blockingBeforeReadback); Assert.True(VulkanStats.WaitCount(WaitSite.Readback) - readbackWaitsBefore >= 1); @@ -411,10 +430,10 @@ public unsafe void TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B() } } /// - /// A frame's vkQueueSubmit is a counted wait: it takes the queue lock that a - /// worker's synchronous upload holds through its fence wait. One frame, one - /// submit at the queue_submit site; the readback that checks the frame's - /// pixels goes through the setup queue path and adds none. + /// A frame's vkQueueSubmit is a counted wait: it takes the queue lock the + /// swapchain's present shares. One frame, one submit at the queue_submit site; + /// the between-frames readback that checks the frame's pixels submits its + /// upload batch on its own, which is exactly one more. /// [SkippableFact] public unsafe void AFrameSubmitIsCountedAtTheQueueSubmitSite() @@ -448,7 +467,7 @@ public unsafe void AFrameSubmitIsCountedAtTheQueueSubmitSite() Assert.Equal(new byte[] { 64, 51, 191, 255 }, pixelsOut[i..(i + 4)]); } Assert.Equal(1, submitsAfterFrame - submitsBefore); - Assert.Equal(submitsAfterFrame, VulkanStats.WaitCount(WaitSite.QueueSubmit)); + Assert.Equal(submitsAfterFrame + 1, VulkanStats.WaitCount(WaitSite.QueueSubmit)); GpuTest.AssertClean(seam); } diff --git a/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs b/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs index c824fc25..601fe8d5 100644 --- a/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs +++ b/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs @@ -78,8 +78,8 @@ public void ARenderTargetNeverClearedOrDrawnReadsThePoisonValue() { Assert.True(context!.PoisonFreshResources); const uint size = 8; - using var commands = new VulkanCommands(context); - using var textures = new TextureManager(context, commands); + using var commands = new SetupQueue(context); + using var textures = new TextureManager(context, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context, textures, state); @@ -153,8 +153,8 @@ public void AClearedTargetReadsItsClearValueWithPoisonOn() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -218,7 +218,7 @@ public void FreshHostVisibleBuffersHoldDeadBeefOnlyInPoisonMode() } private static unsafe byte[] Read( - VulkanContext context, VulkanCommands commands, TextureManager textures, + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size, int bytesPerTexel, ImageAspectFlags aspect) { VulkanTexture texture = textures.Get(textureId)!; diff --git a/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs index c1f769d3..a80c5695 100644 --- a/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs +++ b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs @@ -280,13 +280,14 @@ public void ReadbacksLargerThanTheArenaGrowItAndStayExact() } /// - /// A texture upload on the render thread in the middle of a frame submits - /// the frame's recorded part first (the clear of A) and recording continues - /// (the clear of B): both clears and the uploaded texels land, with two - /// frame submissions and no flush. + /// A texture upload on the render thread in the middle of a frame (between + /// the clear of A and the clear of B) no longer splits the frame (Phase 1B + /// step 3): it lands in the upload batch the frame's one submission carries + /// first. Both clears and the uploaded texels land, with one frame submission + /// and no flush. /// [SkippableFact] - public unsafe void AnUploadInsideAFrameSubmitsTheRecordedPartAndRecordingContinues() + public unsafe void AnUploadInsideAFrameRidesTheFramesOneSubmission() { Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) @@ -321,7 +322,7 @@ public unsafe void AnUploadInsideAFrameSubmitsTheRecordedPartAndRecordingContinu seam.ClearColor(0, 0f, 0f, 1f, 1f); seam.Present(); - Assert.Equal(2, VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore); + Assert.Equal(1, VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore); AssertUnchanged(NeverSites, neverBefore); // Binding is a no-op between frames: read all three in the next frame. diff --git a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs index 68cb418b..06847a1e 100644 --- a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs +++ b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs @@ -53,8 +53,8 @@ public unsafe void AnAttachmentLeftOutOfDrawBuffersIsNotWritten() using (context) { const uint size = 16; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -113,8 +113,8 @@ public unsafe void SelectedAttachmentsAllReceiveTheirMatchingOutput() using (context) { const uint size = 16; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -174,8 +174,8 @@ public unsafe void ChangingTheDrawBufferMaskRestartsTheRenderingScope() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -223,8 +223,8 @@ public void DisabledAttachmentsReportAnUndefinedFormatToThePipeline() using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -258,7 +258,7 @@ private static TranslatedProgram Translate(ShaderCompiler compiler, string verte }, compiler); private static unsafe void RenderFullscreen( - VulkanContext context, VulkanCommands commands, RenderTargetManager targets, + VulkanContext context, SetupQueue commands, RenderTargetManager targets, GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, int framebuffer, uint size) { @@ -315,7 +315,7 @@ private static unsafe void RenderFullscreen( } private static unsafe void FillTexture( - VulkanContext context, VulkanCommands commands, TextureManager textures, + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size, byte value) { var pixels = new byte[size * size * 4]; @@ -327,7 +327,7 @@ private static unsafe void FillTexture( } private static unsafe byte[] ReadTexture( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)size * size * 4; diff --git a/Optimum.Render.Vulkan.Tests/SetupQueue.cs b/Optimum.Render.Vulkan.Tests/SetupQueue.cs new file mode 100644 index 00000000..32baa5c4 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SetupQueue.cs @@ -0,0 +1,91 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Component tests' stand-in for the synchronous setup submit the renderer no +/// longer has (Phase 1B step 3 deleted VulkanCommands.SubmitAndWait). +/// +/// It owns a Transfer timeline and an for managers +/// used without a frame ring. appends the test's +/// commands to the open upload batch, after every upload recorded so far, submits +/// the batch on its own and waits for its Transfer value: the order a test wrote +/// its calls in is the order the GPU runs them. Test code only; the renderer +/// itself never waits for an upload. +/// +internal sealed unsafe class SetupQueue : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + public FrameTimeline Timeline { get; } + public RetireQueue Retired { get; } + public UploadManager Uploads { get; } + + public SetupQueue(VulkanContext context, ulong stagingPerSlot = 4UL << 20) + { + _context = context; + Timeline = new FrameTimeline(context); + Retired = new RetireQueue(Timeline); + Uploads = new UploadManager(context, Timeline, Retired, framesInFlight: 2, stagingPerSlot); + } + + /// Records into the open upload batch, submits it and waits for it. + public void SubmitAndWait(Action record) + { + CommandBuffer commandBuffer = Uploads.BeginRecording(inlineInFrame: false); + try + { + record(commandBuffer); + } + finally + { + Uploads.EndRecording(); + } + + ulong transferValue = Uploads.SubmitStandalone(); + Timeline.WaitForTransfer(transferValue, WaitSite.Readback); + Retired.Collect(); + } + + /// + /// Moves a standalone image between layouts with a broad synchronization2 + /// barrier (all commands on both sides), for tests that drive raw images. + /// + public void TransitionImage(CommandBuffer commandBuffer, VulkanImage image, ImageLayout target, ImageAspectFlags aspect) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.AllCommandsBit, + SrcAccessMask = AccessFlags2.MemoryWriteBit, + DstStageMask = PipelineStageFlags2.AllCommandsBit, + DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + OldLayout = image.Layout, + NewLayout = target, + Image = image.Handle, + SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1), + }; + + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + + _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); + image.Layout = target; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Uploads.Dispose(); + Retired.DisposeAll(); + Timeline.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs b/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs index 906dd6c2..ee72a8a1 100644 --- a/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs +++ b/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs @@ -32,8 +32,8 @@ public unsafe void AnUnsynchronisedWriteAfterWriteIsReportedUnderASyncId() { Skip.IfNot(context!.ValidationEnabled, "Validation layer not installed."); const uint size = 16; - using var commands = new VulkanCommands(context); - using var textures = new TextureManager(context, commands); + using var commands = new SetupQueue(context); + using var textures = new TextureManager(context, commands.Uploads); VulkanTexture a = textures.Get(textures.Create(size, size, Format.R8G8B8A8Unorm))!; VulkanTexture b = textures.Get(textures.Create(size, size, Format.R8G8B8A8Unorm))!; VulkanTexture c = textures.Get(textures.Create(size, size, Format.R8G8B8A8Unorm))!; diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index 6865d0a4..ede68698 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -64,8 +64,8 @@ public unsafe void ResetHistoryIgnoresTheHistoryEntirely() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -122,8 +122,8 @@ public unsafe void StaticSceneConvergesToTheCurrentColour() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -202,8 +202,8 @@ public unsafe void UniformMotionReprojectsTheHistoryByThatOffset() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -273,8 +273,8 @@ public unsafe void AnOutlierHistoryValueIsClippedTowardTheNeighbourhood() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -350,8 +350,8 @@ public unsafe void SkyDoesNotMoveUnderCameraTranslation() double finiteShift = cameraDeltaX / far * (Size / 2.0) / Math.Tan(fov / 2.0); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -428,8 +428,8 @@ public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -475,7 +475,7 @@ public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() /// . /// private static unsafe float ResolveEdgeCentroid( - VulkanContext context, VulkanCommands commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, DescriptorCache descriptors, (float x, float y) jitterPx, Func sceneAt) { @@ -525,8 +525,8 @@ public unsafe void LinearHistorySamplingSpreadsAOnePixelLineOverTwoColumns() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -595,8 +595,8 @@ public unsafe void NanInHistoryIsTreatedAsAReset() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -693,8 +693,8 @@ public unsafe void SkyStaysPutWhenTheCameraSitsAboveTheOrigin(double eyeHeight) _output.WriteLine($"eye {eyeHeight}: far-point-as-direction would drift the sky by ~{predictedBias:F2} px per frame"); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -855,7 +855,7 @@ private static TaaAttachmentSet CreateAttachmentSet(TextureManager textures, Ren /// draw path follows, scoped to a single named-uniform, named-sampler pass. /// private static unsafe void ResolveOnce( - VulkanContext context, VulkanCommands commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, DescriptorCache descriptors, TaaInputSet inputs, TaaUniforms uniforms, TaaAttachmentSet output) { @@ -1091,7 +1091,7 @@ private static unsafe void UploadFlatR32F(TextureManager textures, int textureId // --------------------------------------------------------------- readback private static unsafe byte[] ReadTextureBytes( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, int bytesPerPixel) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, int bytesPerPixel) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)Size * Size * (ulong)bytesPerPixel; diff --git a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs index c844a6b9..d976c202 100644 --- a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs @@ -53,8 +53,8 @@ public unsafe void SharpnessZeroIsBitForBitIdenticalToTheInput() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -92,8 +92,8 @@ public unsafe void SharpenIncreasesContrastAcrossAKnownEdge() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -150,8 +150,8 @@ public unsafe void SharpnessScalesTheEffectMonotonically() Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) - using (var commands = new VulkanCommands(context!)) - using (var textures = new TextureManager(context!, commands)) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) { var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); @@ -182,7 +182,7 @@ public unsafe void SharpnessScalesTheEffectMonotonically() } private unsafe float EdgeStep( - VulkanContext context, VulkanCommands commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, DescriptorCache descriptors, int input, float sharpness) { @@ -234,7 +234,7 @@ private static SharpenTarget CreateTarget(TextureManager textures, RenderTargetM // ------------------------------------------------------------------- draw private static unsafe void SharpenOnce( - VulkanContext context, VulkanCommands commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, DescriptorCache descriptors, int input, float sharpness, SharpenTarget output) { @@ -422,7 +422,7 @@ private static unsafe void UploadEdge(TextureManager textures, int textureId) // --------------------------------------------------------------- readback private static unsafe byte[] ReadTextureBytes( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, int bytesPerPixel) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, int bytesPerPixel) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)Size * Size * (ulong)bytesPerPixel; diff --git a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs index 93b0715b..0372fe3c 100644 --- a/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs @@ -43,8 +43,8 @@ public void TextureIdsBehaveLikeGlNamesIncludingReuse() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); int first = textures.Create(16, 16, Format.R8G8B8A8Unorm); int second = textures.Create(16, 16, Format.R8G8B8A8Unorm); @@ -75,8 +75,8 @@ public void TextureParametersUpdateSamplerStateWithoutCreatingObjects() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); int id = textures.Create(16, 16, Format.R8G8B8A8Unorm); @@ -118,8 +118,8 @@ public void OnlyAMipmappingFilterLetsTheSamplerLeaveLevelZero() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); int id = textures.Create(16, 16, Format.R8G8B8A8Unorm, generateMipmaps: true); Assert.True(textures.Get(id)!.MipLevels > 1, "the texture should own a chain to sample"); @@ -184,8 +184,8 @@ public unsafe void UploadedPixelsSurviveARoundTripThroughTheGpu() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); const uint size = 8; int id = textures.Create(size, size, Format.R8G8B8A8Unorm); @@ -238,8 +238,8 @@ public unsafe void MipmapGenerationBuildsTheWholeChainCleanly() using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); const uint size = 64; int id = textures.Create(size, size, Format.R8G8B8A8Unorm, generateMipmaps: true); @@ -281,8 +281,8 @@ public void CubeAndArrayTexturesReportTheirLayers() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); int cube = textures.Create(32, 32, Format.R8G8B8A8Unorm, cube: true); Assert.Equal(6u, textures.Get(cube)!.Layers); @@ -299,8 +299,8 @@ public void DepthFormatsGetADepthAspectAndAttachmentUsage() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); int depth = textures.Create(64, 64, Format.D32Sfloat); Assert.Equal(ImageAspectFlags.DepthBit, textures.Get(depth)!.Aspect); @@ -320,8 +320,8 @@ public void BorderColoursRoundToTheNearestFixedVulkanValue() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); int id = textures.Create(4, 4, Format.R8G8B8A8Unorm); @@ -346,8 +346,8 @@ public void DeletionThroughTheFrameRingIsDeferred() Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); int id = textures.Create(16, 16, Format.R8G8B8A8Unorm); diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs index 7243609a..ba1184f0 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs @@ -176,7 +176,7 @@ void main(void) Assert.True(program.Success, string.Join("; ", program.Errors)); Vk api = context!.Api; - using var commands = new VulkanCommands(context); + using var commands = new SetupQueue(context); using var target = new VulkanImage(context, width, height, format, ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit, ImageAspectFlags.ColorBit); diff --git a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs index 5f3b64de..3fb1fee7 100644 --- a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs @@ -58,8 +58,8 @@ public unsafe void EachOitAccumulationLayerIsWrittenSeparately() { const uint size = 8; const uint layers = 3; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -118,8 +118,8 @@ public unsafe void AttachmentsKeepIndependentBlendFactors() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -191,8 +191,8 @@ public unsafe void ADepthOnlyTargetStoresWhatWasDrawn() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -265,8 +265,8 @@ public unsafe void AnOcclusionQueryCountsTheSamplesThatPassed() using (context) { const uint size = 8; - using var commands = new VulkanCommands(context!); - using var textures = new TextureManager(context!, commands); + using var commands = new SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); var state = new GlStateTracker(); using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); @@ -330,7 +330,7 @@ private static TranslatedProgram Translate(ShaderCompiler compiler, string verte }, compiler); private static unsafe void RenderFullscreen( - VulkanContext context, VulkanCommands commands, RenderTargetManager targets, + VulkanContext context, SetupQueue commands, RenderTargetManager targets, GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, int framebuffer, uint size, bool depthTest = false, QueryPool queryPool = default) { @@ -411,7 +411,7 @@ private static unsafe void FillTexture(TextureManager textures, int textureId, u } private static unsafe byte[] ReadTexture( - VulkanContext context, VulkanCommands commands, TextureManager textures, + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size, uint layer = 0) { VulkanTexture texture = textures.Get(textureId)!; @@ -439,12 +439,12 @@ private static unsafe byte[] ReadTexture( } private static byte[] FirstPixel( - VulkanContext context, VulkanCommands commands, TextureManager textures, + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size, uint layer) => ReadTexture(context, commands, textures, textureId, size, layer).Take(3).ToArray(); private static unsafe float ReadDepth( - VulkanContext context, VulkanCommands commands, TextureManager textures, int textureId, uint size) + VulkanContext context, SetupQueue commands, TextureManager textures, int textureId, uint size) { VulkanTexture texture = textures.Get(textureId)!; ulong bytes = (ulong)size * size * sizeof(float); diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 1ad11d28..16aee330 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -18,6 +18,11 @@ namespace Optimum.Render.Vulkan.Core; /// slot is only reused once the Frame timeline says the GPU has finished the /// last submission that used it ( waits for that). /// +/// The slot's frame command buffer is submitted together with the open upload +/// batch (), batch first, in one SubmitInfo that +/// signals both timelines: uploads recorded since the last submission run before +/// the frame that uses them, and nothing waits for them. +/// /// A frame may be submitted in parts (, for a /// readback that has to see the frame's work so far). Every command buffer /// carries its own Frame timeline value, and all of them stay in this slot: the @@ -28,6 +33,7 @@ internal sealed unsafe class FrameSlot : IDisposable { private readonly VulkanContext _context; private readonly FrameTimeline _timeline; + private readonly UploadManager _uploads; private readonly ulong _alignment; private readonly ulong _regionStart; private readonly ulong _regionSize; @@ -57,11 +63,12 @@ internal sealed unsafe class FrameSlot : IDisposable /// Partial submissions in the current frame. public int PartialSubmits { get; private set; } - public FrameSlot(VulkanContext context, FrameTimeline timeline, VulkanBuffer uniformRing, + public FrameSlot(VulkanContext context, FrameTimeline timeline, UploadManager uploads, VulkanBuffer uniformRing, ulong regionStart, ulong regionSize, int index = 0) { _context = context; _timeline = timeline; + _uploads = uploads; _uniformRing = uniformRing; _regionStart = regionStart; _regionSize = regionSize; @@ -124,6 +131,7 @@ private void StartCommandBuffer() VulkanResult.Check(api.BeginCommandBuffer(commandBuffer, &begin), "vkBeginCommandBuffer for a frame slot"); CommandBuffer = commandBuffer; + _uploads.OnFrameCommandsStarted(commandBuffer); } /// @@ -194,50 +202,77 @@ private void Submit(Semaphore waitSemaphore, Semaphore signalSemaphore, Pipeline PipelineStageFlags stage = waitStage; uint waitCount = wait.Handle == 0 ? 0u : 1u; - // Binary present semaphore first (its value is ignored), then the timeline. - Semaphore* signals = stackalloc Semaphore[2]; - ulong* signalValues = stackalloc ulong[2]; - uint signalCount = 0; - if (signalSemaphore.Handle != 0) + // Binary present semaphore first (its value is ignored), then the Frame + // timeline, then the Transfer timeline when an upload batch rides along. + Semaphore* signals = stackalloc Semaphore[3]; + ulong* signalValues = stackalloc ulong[3]; + CommandBuffer* commandBuffers = stackalloc CommandBuffer[2]; + + // The queue is shared with the swapchain's present and between-frames + // upload submissions; see QueueLock. Counted as a wait like any other. + long submitStart = VulkanStats.WaitStart(); + // The upload lock is held from taking the batch to the submit, so no + // upload can land in a batch that is already closed, and Transfer values + // reach the queue in the order they were reserved. + _uploads.EnterSubmit(); + try { - signals[signalCount] = signalSemaphore; - signalValues[signalCount] = 0; + uint signalCount = 0; + if (signalSemaphore.Handle != 0) + { + signals[signalCount] = signalSemaphore; + signalValues[signalCount] = 0; + signalCount++; + } + signals[signalCount] = _timeline.Frame; + signalValues[signalCount] = FrameValue; signalCount++; - } - signals[signalCount] = _timeline.Frame; - signalValues[signalCount] = FrameValue; - signalCount++; - var timelineInfo = new TimelineSemaphoreSubmitInfo - { - SType = StructureType.TimelineSemaphoreSubmitInfo, - WaitSemaphoreValueCount = waitCount, - PWaitSemaphoreValues = waitCount == 0 ? null : &waitValue, - SignalSemaphoreValueCount = signalCount, - PSignalSemaphoreValues = signalValues, - }; + uint commandBufferCount = 0; + bool uploads = _uploads.TakeOpenBatchLocked(out CommandBuffer uploadCommands, out ulong transferValue); + if (uploads) + { + // First: it runs before the frame command buffer that samples what it wrote. + commandBuffers[commandBufferCount++] = uploadCommands; + signals[signalCount] = _timeline.Transfer; + signalValues[signalCount] = transferValue; + signalCount++; + } + commandBuffers[commandBufferCount++] = commandBuffer; + + var timelineInfo = new TimelineSemaphoreSubmitInfo + { + SType = StructureType.TimelineSemaphoreSubmitInfo, + WaitSemaphoreValueCount = waitCount, + PWaitSemaphoreValues = waitCount == 0 ? null : &waitValue, + SignalSemaphoreValueCount = signalCount, + PSignalSemaphoreValues = signalValues, + }; - var submit = new SubmitInfo - { - SType = StructureType.SubmitInfo, - PNext = &timelineInfo, - CommandBufferCount = 1, - PCommandBuffers = &commandBuffer, - WaitSemaphoreCount = waitCount, - PWaitSemaphores = waitCount == 0 ? null : &wait, - PWaitDstStageMask = waitCount == 0 ? null : &stage, - SignalSemaphoreCount = signalCount, - PSignalSemaphores = signals, - }; + var submit = new SubmitInfo + { + SType = StructureType.SubmitInfo, + PNext = &timelineInfo, + CommandBufferCount = commandBufferCount, + PCommandBuffers = commandBuffers, + WaitSemaphoreCount = waitCount, + PWaitSemaphores = waitCount == 0 ? null : &wait, + PWaitDstStageMask = waitCount == 0 ? null : &stage, + SignalSemaphoreCount = signalCount, + PSignalSemaphores = signals, + }; - // Shares the queue with off-thread setup submissions; see QueueLock. A - // worker's synchronous upload holds that lock through its fence wait, so - // this is a CPU wait on the GPU like any other and is counted as one. - long submitStart = VulkanStats.WaitStart(); - lock (_context.QueueLock) + lock (_context.QueueLock) + { + VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, default(Fence)), + "vkQueueSubmit for a frame"); + } + if (uploads) _timeline.NoteTransferSubmitted(transferValue); + _uploads.OnFrameCommandsSubmittedLocked(); + } + finally { - VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, default(Fence)), - "vkQueueSubmit for a frame"); + _uploads.ExitSubmit(); } VulkanStats.NoteWait(WaitSite.QueueSubmit, submitStart); _timeline.NoteFrameSubmitted(FrameValue); @@ -281,13 +316,16 @@ internal sealed class FrameRing : IDisposable private readonly VulkanBuffer _uniformRing; private readonly FrameTimeline _timeline; private readonly RetireQueue _retired; + private readonly UploadManager _uploads; private int _index = -1; private bool _disposed; - public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRingSize = 32 * 1024 * 1024) + public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRingSize = 32 * 1024 * 1024, + ulong stagingPerSlot = UploadManager.DefaultStagingPerSlot) { _timeline = new FrameTimeline(context); _retired = new RetireQueue(_timeline); + _uploads = new UploadManager(context, _timeline, _retired, framesInFlight, stagingPerSlot); _uniformRing = new VulkanBuffer(context, uniformRingSize, BufferUsageFlags.UniformBufferBit, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); @@ -299,7 +337,7 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin _slots = new FrameSlot[framesInFlight]; for (int i = 0; i < framesInFlight; i++) { - _slots[i] = new FrameSlot(context, _timeline, _uniformRing, regionSize * (ulong)i, regionSize, i); + _slots[i] = new FrameSlot(context, _timeline, _uploads, _uniformRing, regionSize * (ulong)i, regionSize, i); } } @@ -308,6 +346,9 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin /// The Frame and Transfer timelines every submission signals. public FrameTimeline Timeline => _timeline; + /// The upload batches every submission of this ring carries first. + public UploadManager Uploads => _uploads; + /// The buffer every uniform descriptor points at. public Buffer UniformBuffer => _uniformRing.Handle; @@ -370,6 +411,7 @@ public void Dispose() // does); this covers the ones that did not, such as a test unwinding from a // failed assert. The last submission may still name everything below. _timeline.WaitForSignalledFramesAtTeardown(); + _uploads.Dispose(); _retired.DisposeAll(); foreach (FrameSlot slot in _slots) slot.Dispose(); _uniformRing.Dispose(); diff --git a/Optimum.Render.Vulkan/Core/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs index c5859540..051ad687 100644 --- a/Optimum.Render.Vulkan/Core/MeshManager.cs +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -79,6 +79,7 @@ internal sealed unsafe class MeshManager : IDisposable private readonly VulkanContext _context; private readonly GlStateTracker _state; + private readonly UploadManager? _uploads; private readonly Interner _layouts = new(); private readonly List _meshes = new(); private readonly Stack _freeIds = new(); @@ -94,10 +95,18 @@ internal sealed unsafe class MeshManager : IDisposable /// public const int EmptyLayoutId = 0; - public MeshManager(VulkanContext context, GlStateTracker state) + /// + /// Static meshes on device-local memory, filled through the upload manager's + /// staging instead of a host mapping. Off until Phase 1B step 5 moves static + /// meshes off ReBAR; tests turn it on to drive the staged path. + /// + internal bool DeviceLocalStaticBuffers { get; set; } + + public MeshManager(VulkanContext context, GlStateTracker state, UploadManager? uploads = null) { _context = context; _state = state; + _uploads = uploads; _meshes.Add(null); // 0 is never a real mesh int emptyId = _layouts.Intern(VertexLayoutDescription.Empty); @@ -298,6 +307,12 @@ private VulkanBuffer CreateBuffer(int byteSize, BufferUsageFlags usage, bool per // writes straight through the pointer while the GPU may still be // reading - the same lack of synchronisation GL allowed and the chunk // tesselator relies on. + if (!persistent && DeviceLocalStaticBuffers && _uploads != null) + { + return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.DeviceLocalBit); + } + MemoryPropertyFlags properties = persistent ? MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit : MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit @@ -341,12 +356,26 @@ private int Register(VulkanMesh mesh) /// on that route, so this fill is the only source of them here, as it is /// there. /// - private static void FillQuadIndices(VulkanBuffer indices) + private void FillQuadIndices(VulkanBuffer indices) { - if (indices.Mapped == IntPtr.Zero) return; - int count = (int)(indices.Size / sizeof(int)); - int* destination = (int*)indices.Mapped; + if (indices.Mapped == IntPtr.Zero) + { + if (_uploads == null) return; + var pattern = new int[count]; + fixed (int* source = pattern) + { + FillQuadPattern(source, count); + _uploads.UploadToBuffer(indices, 0, (IntPtr)source, (ulong)count * sizeof(int)); + } + return; + } + + FillQuadPattern((int*)indices.Mapped, count); + } + + private static void FillQuadPattern(int* destination, int count) + { for (int i = 0; i + 5 < count; i += 6) { int quad = i / 6 * 4; @@ -395,7 +424,7 @@ public void Write(int meshId, int slot, int byteOffset, IntPtr source, int byteC string? problem = mesh == null ? "no such mesh" : buffer == null ? "mesh has no buffer in that slot" : - buffer.Mapped == IntPtr.Zero ? "buffer is not host mapped" : + buffer.Mapped == IntPtr.Zero && _uploads == null ? "buffer is not host mapped" : byteOffset < 0 ? "negative offset" : (ulong)byteOffset + (ulong)byteCount > buffer.Size ? "write ends past the buffer (" + buffer.Size + " bytes)" @@ -412,8 +441,15 @@ public void Write(int meshId, int slot, int byteOffset, IntPtr source, int byteC return; } + if (buffer!.Mapped == IntPtr.Zero) + { + // Device-local: staged and copied, never waited on. + _uploads!.UploadToBuffer(buffer, (ulong)byteOffset, source, (ulong)byteCount); + return; + } + System.Buffer.MemoryCopy( - (void*)source, (void*)(buffer!.Mapped + byteOffset), byteCount, byteCount); + (void*)source, (void*)(buffer.Mapped + byteOffset), byteCount, byteCount); } public void Delete(int meshId, FrameRing? ring = null) @@ -435,6 +471,17 @@ public void Bind(CommandBuffer commandBuffer, VulkanMesh mesh) { Vk api = _context.Api; + // A staged write to a buffer this frame command buffer already drew from + // has to go inline to keep GL's order; see UploadManager. + if (_uploads != null) + { + foreach (VulkanBuffer? buffer in mesh.Buffers) + { + if (buffer != null) _uploads.NoteUse(commandBuffer, buffer); + } + if (mesh.Indices != null) _uploads.NoteUse(commandBuffer, mesh.Indices); + } + if (mesh.BindingOrder.Count > 0) { var buffers = new Buffer[mesh.BindingOrder.Count]; diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index e8ae4fc9..ae998fa8 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -92,6 +92,13 @@ internal sealed unsafe class VulkanTexture : IDisposable /// Tracked because Vulkan offers no way to query it. public ImageLayout Layout { get; set; } = ImageLayout.Undefined; + /// + /// The frame command buffer generation that last used this texture; an + /// upload to a texture the frame being recorded already used goes inline. + /// See . + /// + internal long FrameUse; + /// /// Single-layer views, created on demand and keyed by layer. /// @@ -243,17 +250,17 @@ public void UploadNormalizedShorts(int id, int level, int x, int y, } private readonly VulkanContext _context; - private readonly VulkanCommands _commands; + private readonly UploadManager _uploads; private readonly List _textures = new(); private readonly Stack _freeIds = new(); private bool _disposed; public SamplerCache Samplers { get; } - public TextureManager(VulkanContext context, VulkanCommands commands) + public TextureManager(VulkanContext context, UploadManager uploads) { _context = context; - _commands = commands; + _uploads = uploads; Samplers = new SamplerCache(context); // Index 0 is reserved so a zero id never names a real texture. @@ -278,15 +285,25 @@ public int Count private int Register(VulkanTexture texture) { - if (_freeIds.Count > 0) + // Under the upload lock, like Delete: an upload from another thread + // looks its texture up again under the same lock. + _uploads.EnterLock(); + try { - int reused = _freeIds.Pop(); - _textures[reused] = texture; - return reused; - } + if (_freeIds.Count > 0) + { + int reused = _freeIds.Pop(); + _textures[reused] = texture; + return reused; + } - _textures.Add(texture); - return _textures.Count - 1; + _textures.Add(texture); + return _textures.Count - 1; + } + finally + { + _uploads.ExitLock(); + } } /// @@ -392,13 +409,15 @@ public int Create( /// Poison mode: fills every level and layer of a new image with /// 's value for its format, so a read of content /// nobody wrote is loud instead of whatever the allocator's memory held. - /// Synchronous on purpose; poison mode is a diagnostic, not a fast path. + /// Recorded into the upload batch like any upload: a fresh texture has no use + /// yet, so the clear runs before anything that could read it. /// private void Poison(VulkanTexture texture) { if (VulkanPoison.IsCompressed(texture.Format)) return; - _commands.SubmitAndWait(commandBuffer => + CommandBuffer commandBuffer = _uploads.BeginRecording(inlineInFrame: false); + try { TransitionTexture(commandBuffer, texture, ImageLayout.TransferDstOptimal); var range = new ImageSubresourceRange(texture.Aspect, 0, texture.MipLevels, 0, texture.Layers); @@ -414,14 +433,19 @@ private void Poison(VulkanTexture texture) _context.Api.CmdClearColorImage(commandBuffer, texture.Image, ImageLayout.TransferDstOptimal, &color, 1, &range); } - }); + } + finally + { + _uploads.EndRecording(); + } } /// - /// Uploads pixels into a region. Staging plus a copy, then back to a - /// shader-readable layout, submitted and waited on. That is stronger - /// ordering than GL guarantees, which makes it correct; recording the copy - /// inline in the frame's command buffer is the later optimisation. + /// Uploads pixels into a region: staged, copied, and back to a shader-readable + /// layout, recorded into the upload batch that the next frame submission runs + /// first. Nothing waits. When the frame command buffer being recorded already + /// used the texture, the copy goes inline into it instead, so a draw recorded + /// before the upload still sees the old texels, as on GL. /// public void Upload( int textureId, int level, int x, int y, uint width, uint height, @@ -433,15 +457,18 @@ public void Upload( ulong size = (ulong)width * height * (ulong)bytesPerPixel; if (size == 0) return; - using var staging = new VulkanBuffer(_context, size, - BufferUsageFlags.TransferSrcBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); - - System.Buffer.MemoryCopy((void*)pixels, (void*)staging.Mapped, (long)size, (long)size); - VulkanStats.NoteUploadRequest(); - _commands.SubmitAndWait(commandBuffer => + CommandBuffer commandBuffer = _uploads.BeginRecording(_uploads.UsedByPendingFrame(texture.FrameUse)); + try { + // Again under the lock: a delete on another thread either came first + // (nothing to upload to) or retires the texture against this batch's + // Transfer value, so the batch never names a destroyed image. + if (!ReferenceEquals(Get(textureId), texture)) return; + + StagingSlice staging = _uploads.Stage(size); + System.Buffer.MemoryCopy((void*)pixels, (void*)staging.Pointer, (long)size, (long)size); + if (_context.CheckpointsAvailable) { _context.CmdSetCheckpoint(commandBuffer, CheckpointMarker.Upload(textureId, width, height)); @@ -451,20 +478,26 @@ public void Upload( var region = new BufferImageCopy { + BufferOffset = staging.Offset, ImageSubresource = new ImageSubresourceLayers(texture.Aspect, (uint)level, layer, 1), ImageOffset = new Offset3D(x, y, 0), ImageExtent = new Extent3D(width, height, 1), }; - _context.Api.CmdCopyBufferToImage(commandBuffer, staging.Handle, texture.Image, + _context.Api.CmdCopyBufferToImage(commandBuffer, staging.Buffer, texture.Image, ImageLayout.TransferDstOptimal, 1, ®ion); TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); - }); + } + finally + { + _uploads.EndRecording(); + } } /// /// Builds the mip chain by successive blits, which is how every Vulkan - /// implementation of glGenerateMipmap works. + /// implementation of glGenerateMipmap works. Batched or inline by the same + /// rule as , so it follows the uploads it is built from. /// public void GenerateMipmaps(int textureId) { @@ -472,8 +505,11 @@ public void GenerateMipmaps(int textureId) if (texture == null || texture.MipLevels <= 1) return; VulkanStats.NoteUploadRequest(); - _commands.SubmitAndWait(commandBuffer => + CommandBuffer commandBuffer = _uploads.BeginRecording(_uploads.UsedByPendingFrame(texture.FrameUse)); + try { + if (!ReferenceEquals(Get(textureId), texture)) return; + Vk api = _context.Api; int mipWidth = (int)texture.Width; int mipHeight = (int)texture.Height; @@ -517,7 +553,11 @@ public void GenerateMipmaps(int textureId) texture.Layout = ImageLayout.TransferSrcOptimal; TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); - }); + } + finally + { + _uploads.EndRecording(); + } } /// @@ -582,21 +622,34 @@ public void SetBorderColor(int textureId, float r, float g, float b, float a) public void Delete(int textureId, FrameRing? ring = null) { - VulkanTexture? texture = Get(textureId); - if (texture == null) return; + // Under the upload lock; see Upload. Retiring inside it keys the entry on + // the Transfer value of any batch that recorded this texture already. + _uploads.EnterLock(); + try + { + VulkanTexture? texture = Get(textureId); + if (texture == null) return; - _textures[textureId] = null; - _freeIds.Push(textureId); + _textures[textureId] = null; + _freeIds.Push(textureId); - // Handing it to the ring means it outlives any frame still referencing it. - if (ring != null) ring.DeferDeletion(texture); - else texture.Dispose(); + // Handing it to the ring means it outlives any frame still referencing it. + if (ring != null) ring.DeferDeletion(texture); + else texture.Dispose(); + } + finally + { + _uploads.ExitLock(); + } } // ------------------------------------------------------------------ barriers public void TransitionTexture(CommandBuffer commandBuffer, VulkanTexture texture, ImageLayout target) { + // Every path that records a texture into a command buffer goes through + // here (attachments, reads, copies, blits), even when no barrier is due. + _uploads.NoteUse(commandBuffer, texture); if (texture.Layout == target) return; TransitionRange(commandBuffer, texture, 0, texture.MipLevels, texture.Layout, target); texture.Layout = target; diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index 833dc344..b39310f9 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -47,6 +47,12 @@ internal sealed unsafe class VulkanBuffer : IDisposable /// Non-zero when the allocation is host visible and mapped. public IntPtr Mapped { get; private set; } + /// + /// The frame command buffer generation that last used this buffer; see + /// . + /// + internal long FrameUse; + public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, MemoryPropertyFlags properties) { _context = context; @@ -295,170 +301,3 @@ public static uint FindMemoryType(VulkanContext context, uint typeBits, MemoryPr throw new InvalidOperationException($"no memory type with {properties}"); } } - -/// -/// A command pool plus a synchronous submit helper. -/// -/// The renderer records into per-frame buffers, but setup work - uploads, layout -/// transitions, readback - wants a one-shot submit that waits. Keeping that in -/// one place stops each call site from inventing its own fence handling. -/// -internal sealed unsafe class VulkanCommands : IDisposable -{ - private readonly VulkanContext _context; - private bool _disposed; - - public CommandPool Pool { get; } - - public VulkanCommands(VulkanContext context) - { - _context = context; - - var createInfo = new CommandPoolCreateInfo - { - SType = StructureType.CommandPoolCreateInfo, - QueueFamilyIndex = context.GraphicsQueueFamily, - Flags = CommandPoolCreateFlags.ResetCommandBufferBit, - }; - - if (context.Api.CreateCommandPool(context.Device, &createInfo, null, out CommandPool pool) != Result.Success) - { - throw new InvalidOperationException("vkCreateCommandPool failed"); - } - Pool = pool; - } - - public CommandBuffer Allocate() - { - var allocateInfo = new CommandBufferAllocateInfo - { - SType = StructureType.CommandBufferAllocateInfo, - CommandPool = Pool, - Level = CommandBufferLevel.Primary, - CommandBufferCount = 1, - }; - - CommandBuffer buffer; - _context.Api.AllocateCommandBuffers(_context.Device, &allocateInfo, &buffer); - return buffer; - } - - /// - /// Runs before every synchronous submit, outside the queue lock. The device - /// uses it to flush a frame it is in the middle of recording: a synchronous - /// submit executes before that frame does, so any layout transition the - /// frame has already recorded is not yet true on the GPU, and a setup - /// command that assumed it would corrupt the image or fail the submit. - /// - public Action? BeforeSynchronousSubmit; - - /// - /// Records, submits and waits. For setup and readback, not frames. - /// - /// The whole body is serialised, not just the submit: the command pool is - /// shared, and Vulkan requires external synchronisation for allocating from - /// and freeing to a pool as much as for submitting to a queue. Texture - /// uploads reach this from asset-loading worker threads while the render - /// thread is submitting frames. - /// - public void SubmitAndWait(Action record, WaitSite site = WaitSite.UploadSubmit) - { - BeforeSynchronousSubmit?.Invoke(); - - // Timed from before the lock: waiting for the render thread to release - // the queue is as much a part of an upload's cost as the GPU work. - long start = System.Diagnostics.Stopwatch.GetTimestamp(); - lock (_context.QueueLock) - { - SubmitAndWaitLocked(record); - } - VulkanStats.NoteUpload(System.Diagnostics.Stopwatch.GetTimestamp() - start); - // Uploads and readbacks are told apart by the caller: only an upload - // that waited here is a blocking upload (the pacing gate's first rule). - VulkanStats.NoteWait(site, start); - if (site == WaitSite.UploadSubmit) VulkanStats.NoteBlockingUpload(); - } - - private void SubmitAndWaitLocked(Action record) - { - Vk api = _context.Api; - CommandBuffer commandBuffer = Allocate(); - - Fence fence = default; - bool fenceCreated = false; - try - { - var begin = new CommandBufferBeginInfo - { - SType = StructureType.CommandBufferBeginInfo, - Flags = CommandBufferUsageFlags.OneTimeSubmitBit, - }; - VulkanResult.Check(api.BeginCommandBuffer(commandBuffer, &begin), - "vkBeginCommandBuffer for a setup command buffer"); - record(commandBuffer); - VulkanResult.Check(api.EndCommandBuffer(commandBuffer), - "vkEndCommandBuffer for a setup command buffer"); - - var fenceInfo = new FenceCreateInfo { SType = StructureType.FenceCreateInfo }; - VulkanResult.Check(api.CreateFence(_context.Device, &fenceInfo, null, out fence), - "vkCreateFence for a setup command buffer"); - fenceCreated = true; - - var submit = new SubmitInfo - { - SType = StructureType.SubmitInfo, - CommandBufferCount = 1, - PCommandBuffers = &commandBuffer, - }; - VulkanResult.Check(api.QueueSubmit(_context.GraphicsQueue, 1, &submit, fence), - "vkQueueSubmit for a setup command buffer"); - VulkanResult.Check(api.WaitForFences(_context.Device, 1, &fence, true, ulong.MaxValue), - "vkWaitForFences for a setup command buffer"); - } - finally - { - if (fenceCreated) api.DestroyFence(_context.Device, fence, null); - api.FreeCommandBuffers(_context.Device, Pool, 1, &commandBuffer); - } - } - - /// - /// Moves an image between layouts with a synchronization2 barrier, widening - /// the stage and access masks to "all commands" rather than deriving tight - /// ones. Setup paths are not hot, and a correct broad barrier beats a clever - /// narrow one that is subtly wrong. - /// - public void TransitionImage(CommandBuffer commandBuffer, VulkanImage image, ImageLayout target, ImageAspectFlags aspect) - { - var barrier = new ImageMemoryBarrier2 - { - SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = PipelineStageFlags2.AllCommandsBit, - SrcAccessMask = AccessFlags2.MemoryWriteBit, - DstStageMask = PipelineStageFlags2.AllCommandsBit, - DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, - OldLayout = image.Layout, - NewLayout = target, - Image = image.Handle, - SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1), - }; - - var dependency = new DependencyInfo - { - SType = StructureType.DependencyInfo, - ImageMemoryBarrierCount = 1, - PImageMemoryBarriers = &barrier, - }; - - _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); - VulkanStats.NoteImageBarriers(1); - image.Layout = target; - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _context.Api.DestroyCommandPool(_context.Device, Pool, null); - } -} diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index f963d5a5..4fc60b62 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -19,7 +19,11 @@ internal enum WaitSite /// start of frame n; exactly one per frame start, the ring's only steady-state wait. /// FramePacing = 0, - /// A setup command buffer that uploads data and waits for its fence. + /// + /// An upload that waited for the GPU. Retired in Phase 1B step 3: uploads ride + /// the next frame submission (UploadManager) and never wait, so this stays + /// zero; the token stays for log compatibility and the pacing gate. + /// UploadSubmit = 1, /// /// The Frame timeline wait inside a mid-frame flush. Retired in Phase 1B: @@ -159,6 +163,26 @@ public static void NoteUpload(long elapsedTicks) public static long BlockingUploads => Interlocked.Read(ref _blockingUploads); public static long UploadRequests => Interlocked.Read(ref _uploadRequests); + private static long _inlineUploads; + private static long _stagingOverflows; + private static long _uploadBatchGrowths; + + /// + /// An upload recorded into the frame command buffer because that command + /// buffer already used its destination (GL order), instead of the upload batch. + /// + public static void NoteInlineUpload() => Interlocked.Increment(ref _inlineUploads); + + /// An upload that did not fit its batch's staging region and took a dedicated staging buffer. + public static void NoteStagingOverflow() => Interlocked.Increment(ref _stagingOverflows); + + /// An upload batch created beyond the staging ring's regions (more batches in flight than frames). + public static void NoteUploadBatchGrowth() => Interlocked.Increment(ref _uploadBatchGrowths); + + public static long InlineUploads => Interlocked.Read(ref _inlineUploads); + public static long StagingOverflows => Interlocked.Read(ref _stagingOverflows); + public static long UploadBatchGrowths => Interlocked.Read(ref _uploadBatchGrowths); + /// One vkCmdBeginRendering. public static void NoteScopeOpened() => Interlocked.Increment(ref _scopesOpened); diff --git a/Optimum.Render.Vulkan/Frame/FrameTimeline.cs b/Optimum.Render.Vulkan/Frame/FrameTimeline.cs index 10782717..f7f42042 100644 --- a/Optimum.Render.Vulkan/Frame/FrameTimeline.cs +++ b/Optimum.Render.Vulkan/Frame/FrameTimeline.cs @@ -114,10 +114,15 @@ public void WaitForFrame(ulong value, WaitSite site) => /// unwinding past the caller's own idle wait must not turn into a driver crash /// on destroying objects a queued command buffer still uses). /// - public void WaitForSignalledFramesAtTeardown() + public void WaitForSignalledFramesAtTeardown() => WaitAtTeardown(Frame, FrameSignalled); + + /// The Transfer timeline's counterpart of . + public void WaitForSignalledTransfersAtTeardown() => WaitAtTeardown(Transfer, TransferSignalled); + + private void WaitAtTeardown(Semaphore semaphore, ulong signalled) { - Semaphore handle = Frame; - ulong target = FrameSignalled; + Semaphore handle = semaphore; + ulong target = signalled; var info = new SemaphoreWaitInfo { SType = StructureType.SemaphoreWaitInfo, diff --git a/Optimum.Render.Vulkan/Transfer/UploadManager.cs b/Optimum.Render.Vulkan/Transfer/UploadManager.cs new file mode 100644 index 00000000..648fc214 --- /dev/null +++ b/Optimum.Render.Vulkan/Transfer/UploadManager.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Silk.NET.Vulkan; + +using Buffer = Silk.NET.Vulkan.Buffer; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +// The Transfer/ folder follows the plan's layout; the namespace stays Core until +// the renderer is reorganised. +namespace Optimum.Render.Vulkan.Core; + +/// Where staged bytes landed: the buffer, the offset inside it, and its host mapping. +internal readonly record struct StagingSlice(Buffer Buffer, ulong Offset, IntPtr Pointer); + +/// +/// Uploads that never wait (transfer backend A of the Vulkan-native plan). +/// +/// Uploads, mip chains and buffer copies are recorded into an open upload batch: +/// a command buffer from the batch's own graphics-family pool plus a bump +/// allocated slice of the staging ring. Any thread may record, under this +/// manager's lock. The next frame submission (a frame end or a partial submit) +/// closes the batch and submits it first, in the same vkQueueSubmit and the same +/// SubmitInfo as the frame's command buffer, so the upload executes before the +/// frame that samples it and nothing waits for it. +/// +/// Each batch reserves a Transfer timeline value when it opens and that +/// submission signals it, alongside the Frame value. Every resource retired +/// while a batch is open is keyed on that Transfer value by the +/// , so a worker's upload can never name a texture or +/// staging buffer that was already destroyed. Every reserved value is signalled +/// by exactly one submission: the frame's, or +/// between frames. +/// +/// GL executes calls in order. The batch runs before the whole frame command +/// buffer, so an upload to a texture or buffer the frame command buffer already +/// used since its last submission would travel back in time past those uses. +/// Those uploads (render thread only; the frame command buffer is the render +/// thread's) are recorded inline into the frame command buffer instead, outside +/// any rendering scope: still no wait, and GL's order holds. +/// +/// Staging: one persistently mapped ring of FramesInFlight x +/// , one region per batch. A batch is reused +/// once the Transfer timeline passed its value. An upload larger than the region's +/// free space, or any upload of a batch beyond the ring's (created when more +/// batches are in flight than the ring has regions), takes a dedicated staging +/// buffer retired on the timelines and counted. +/// +internal sealed unsafe class UploadManager : IDisposable +{ + public const ulong DefaultStagingPerSlot = 32UL << 20; + + // Buffer-to-image copies of depth need offsets that are multiples of 4, and + // wider texels want their own size; 16 covers every format the client uploads. + private const ulong StagingAlignment = 16; + + private sealed class Batch + { + public CommandPool Pool; + public CommandBuffer CommandBuffer; + public ulong RegionStart; + public ulong RegionSize; + public ulong Cursor; + public ulong TransferValue; + public bool Submitted; + public bool EverOpened; + } + + private readonly VulkanContext _context; + private readonly FrameTimeline _timeline; + private readonly RetireQueue _retired; + private readonly int _ringBatches; + private readonly ulong _stagingPerSlot; + private readonly object _lock = new(); + private readonly List _batches = new(); + private VulkanBuffer? _stagingRing; + private Batch? _open; + private bool _disposed; + + // The frame command buffer being recorded, set by the frame slot: the handle + // (0 between submissions), the thread recording it, and a generation that + // changes with every new frame command buffer. Written by the render thread, + // read by any. + private long _frameCommandsHandle; + private int _frameThreadId = -1; + private long _frameGeneration; + + /// + /// Closes the device's open rendering scope on the frame command buffer before + /// an inline upload records transfer commands into it. Null outside a device. + /// + public Action? CloseRenderingScope { get; set; } + + public UploadManager(VulkanContext context, FrameTimeline timeline, RetireQueue retired, + int framesInFlight, ulong stagingPerSlot = DefaultStagingPerSlot) + { + _context = context; + _timeline = timeline; + _retired = retired; + _ringBatches = Math.Max(1, framesInFlight); + _stagingPerSlot = Math.Max(StagingAlignment, stagingPerSlot / StagingAlignment * StagingAlignment); + } + + /// Upload batches created so far (the ring's plus any beyond it). Tests only. + internal int BatchCount + { + get { lock (_lock) return _batches.Count; } + } + + /// Whether a batch is open and will ride the next submission. Tests only. + internal bool HasOpenBatch + { + get { lock (_lock) return _open != null; } + } + + // ------------------------------------------------------------ frame hooks + + /// + /// The frame slot began a new command buffer on the calling thread. Uses + /// noted against the previous one stop counting. + /// + public void OnFrameCommandsStarted(CommandBuffer commandBuffer) + { + Volatile.Write(ref _frameThreadId, Environment.CurrentManagedThreadId); + Interlocked.Increment(ref _frameGeneration); + Volatile.Write(ref _frameCommandsHandle, (long)commandBuffer.Handle); + } + + /// Whether the calling thread is recording a frame command buffer right now. + public bool IsFrameRecordingThread => + Volatile.Read(ref _frameCommandsHandle) != 0 && + Volatile.Read(ref _frameThreadId) == Environment.CurrentManagedThreadId; + + /// Records that , if it is the frame's, uses the texture. + public void NoteUse(CommandBuffer commandBuffer, VulkanTexture texture) + { + if (IsFrameCommands(commandBuffer)) texture.FrameUse = Volatile.Read(ref _frameGeneration); + } + + /// Records that , if it is the frame's, uses the buffer. + public void NoteUse(CommandBuffer commandBuffer, VulkanBuffer buffer) + { + if (IsFrameCommands(commandBuffer)) buffer.FrameUse = Volatile.Read(ref _frameGeneration); + } + + private bool IsFrameCommands(CommandBuffer commandBuffer) => + commandBuffer.Handle != 0 && (long)commandBuffer.Handle == Volatile.Read(ref _frameCommandsHandle); + + /// + /// Whether a resource whose last noted use is is + /// used by the frame command buffer the calling thread is recording, which + /// makes an upload to it an inline one. + /// + public bool UsedByPendingFrame(long frameUse) => + frameUse != 0 && frameUse == Volatile.Read(ref _frameGeneration) && IsFrameRecordingThread; + + // --------------------------------------------------------------- recording + + /// + /// Takes the lock and returns the command buffer to record an upload into: + /// the frame command buffer (scope closed) when + /// holds and the calling thread records the frame, the open batch's otherwise. + /// Pair with in a finally. Reentrant. + /// + public CommandBuffer BeginRecording(bool inlineInFrame) + { + Monitor.Enter(_lock); + try + { + if (inlineInFrame && IsFrameRecordingThread) + { + var frameCommands = new CommandBuffer((nint)Volatile.Read(ref _frameCommandsHandle)); + CloseRenderingScope?.Invoke(frameCommands); + VulkanStats.NoteInlineUpload(); + return frameCommands; + } + return EnsureOpenLocked().CommandBuffer; + } + catch + { + Monitor.Exit(_lock); + throw; + } + } + + public void EndRecording() => Monitor.Exit(_lock); + + /// + /// Bump-allocates staging bytes for the upload being + /// recorded (inside ). Valid until the batch's + /// submission, which carries every command recorded in the meantime, completed. + /// + public StagingSlice Stage(ulong size) + { + if (!Monitor.IsEntered(_lock)) throw new InvalidOperationException("Stage outside BeginRecording"); + + Batch batch = EnsureOpenLocked(); + if (batch.RegionSize > 0) + { + ulong aligned = (batch.Cursor + StagingAlignment - 1) / StagingAlignment * StagingAlignment; + if (aligned + size <= batch.RegionSize) + { + VulkanBuffer ring = StagingRing(); + batch.Cursor = aligned + size; + ulong absolute = batch.RegionStart + aligned; + return new StagingSlice(ring.Handle, absolute, ring.Mapped + (nint)absolute); + } + } + + // Oversized, overflowing, or a batch beyond the ring: its own buffer. The + // open batch's Transfer value (and the newest Frame value, for an inline + // copy) is what the retire entry is keyed on, so it outlives the copy. + var dedicated = new VulkanBuffer(_context, Math.Max(size, 1), BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + _retired.Retire(dedicated); + VulkanStats.NoteStagingOverflow(); + return new StagingSlice(dedicated.Handle, 0, dedicated.Mapped); + } + + /// + /// Copies bytes into a buffer the host cannot map. Inline when the frame + /// command buffer being recorded already uses the buffer, batched otherwise. + /// + public void UploadToBuffer(VulkanBuffer destination, ulong offset, IntPtr source, ulong size) + { + if (source == IntPtr.Zero || size == 0) return; + + VulkanStats.NoteUploadRequest(); + CommandBuffer commandBuffer = BeginRecording(UsedByPendingFrame(destination.FrameUse)); + try + { + StagingSlice staging = Stage(size); + System.Buffer.MemoryCopy((void*)source, (void*)staging.Pointer, (long)size, (long)size); + + // Buffers have no layouts, so nothing else orders this copy against the + // draws around it: a frame that read the buffer before, and the frame + // command buffer (a later one in the same submission) that reads it + // after. Synchronization validation reports both as hazards without an + // explicit buffer barrier on each side (2026-09-11, the staged index + // buffer of AsyncTransferTests). + BufferBarrier(commandBuffer, destination, + PipelineStageFlags2.AllCommandsBit, AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + PipelineStageFlags2.CopyBit, AccessFlags2.TransferWriteBit); + var copy = new BufferCopy { SrcOffset = staging.Offset, DstOffset = offset, Size = size }; + _context.Api.CmdCopyBuffer(commandBuffer, staging.Buffer, destination.Handle, 1, ©); + BufferBarrier(commandBuffer, destination, + PipelineStageFlags2.CopyBit, AccessFlags2.TransferWriteBit, + PipelineStageFlags2.AllCommandsBit, AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit); + NoteUse(commandBuffer, destination); + } + finally + { + EndRecording(); + } + } + + /// A synchronization2 barrier on a whole buffer; no queue family change. + private void BufferBarrier(CommandBuffer commandBuffer, VulkanBuffer buffer, + PipelineStageFlags2 sourceStage, AccessFlags2 sourceAccess, + PipelineStageFlags2 destinationStage, AccessFlags2 destinationAccess) + { + var barrier = new BufferMemoryBarrier2 + { + SType = StructureType.BufferMemoryBarrier2, + SrcStageMask = sourceStage, + SrcAccessMask = sourceAccess, + DstStageMask = destinationStage, + DstAccessMask = destinationAccess, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Buffer = buffer.Handle, + Offset = 0, + Size = Vk.WholeSize, + }; + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + BufferMemoryBarrierCount = 1, + PBufferMemoryBarriers = &barrier, + }; + _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); + } + + private VulkanBuffer StagingRing() => + // Allocated on first use: most frame rings in tests never stage anything. + _stagingRing ??= new VulkanBuffer(_context, _stagingPerSlot * (ulong)_ringBatches, + BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + + private Batch EnsureOpenLocked() + { + if (_disposed) throw new ObjectDisposedException(nameof(UploadManager)); + if (_open != null) return _open; + + Batch? free = null; + ulong completed = 0; + bool completedRead = false; + for (int i = 0; i < _batches.Count; i++) + { + Batch candidate = _batches[i]; + if (!candidate.Submitted) + { + free = candidate; + break; + } + if (!completedRead) + { + completed = _timeline.TransferCompleted; + completedRead = true; + } + if (completed >= candidate.TransferValue) + { + free = candidate; + break; + } + } + + free ??= CreateBatch(); + + Vk api = _context.Api; + if (free.EverOpened) + { + // The Transfer timeline passed the batch's submission: every command + // buffer of the pool has completed, so resetting it is legal. + VulkanResult.Check(api.ResetCommandPool(_context.Device, free.Pool, 0), + "vkResetCommandPool for an upload batch"); + } + + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanResult.Check(api.BeginCommandBuffer(free.CommandBuffer, &begin), + "vkBeginCommandBuffer for an upload batch"); + + free.Cursor = 0; + free.Submitted = false; + free.EverOpened = true; + free.TransferValue = _timeline.ReserveTransfer(); + _open = free; + return free; + } + + private Batch CreateBatch() + { + Vk api = _context.Api; + var poolInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = _context.GraphicsQueueFamily, + Flags = CommandPoolCreateFlags.TransientBit, + }; + VulkanResult.Check(api.CreateCommandPool(_context.Device, &poolInfo, null, out CommandPool pool), + "vkCreateCommandPool for an upload batch"); + + var allocateInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = pool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + CommandBuffer commandBuffer; + VulkanResult.Check(api.AllocateCommandBuffers(_context.Device, &allocateInfo, &commandBuffer), + "vkAllocateCommandBuffers for an upload batch"); + + int index = _batches.Count; + var batch = new Batch + { + Pool = pool, + CommandBuffer = commandBuffer, + RegionStart = index < _ringBatches ? _stagingPerSlot * (ulong)index : 0, + RegionSize = index < _ringBatches ? _stagingPerSlot : 0, + }; + _batches.Add(batch); + if (index >= _ringBatches) VulkanStats.NoteUploadBatchGrowth(); + return batch; + } + + // -------------------------------------------------------------- submission + + /// + /// Takes the recording lock without recording, for owners whose table changes + /// must be ordered against uploads: a texture deleted under it either was + /// recorded into the open batch first (so its retirement is keyed on that + /// batch's Transfer value) or is not found by an upload that looks it up after. + /// + public void EnterLock() => Monitor.Enter(_lock); + + public void ExitLock() => Monitor.Exit(_lock); + + /// Holds the lock across a frame submission; see . + public void EnterSubmit() => Monitor.Enter(_lock); + + public void ExitSubmit() => Monitor.Exit(_lock); + + /// + /// Inside : closes the open batch, if any, for the + /// submission being built, which must put its command buffer first and signal + /// the Transfer timeline to . + /// + public bool TakeOpenBatchLocked(out CommandBuffer commandBuffer, out ulong transferValue) + { + Batch? batch = _open; + if (batch == null) + { + commandBuffer = default; + transferValue = 0; + return false; + } + + VulkanResult.Check(_context.Api.EndCommandBuffer(batch.CommandBuffer), + "vkEndCommandBuffer for an upload batch"); + batch.Submitted = true; + _open = null; + commandBuffer = batch.CommandBuffer; + transferValue = batch.TransferValue; + return true; + } + + /// + /// Inside , after the queue accepted the submission: + /// the frame command buffer is closed, so nothing can be recorded inline until + /// the slot starts the next one. + /// + public void OnFrameCommandsSubmittedLocked() => Volatile.Write(ref _frameCommandsHandle, 0); + + /// + /// Between frames: submits the open batch on its own (opening an empty one if + /// none is open, so a caller always gets a value to wait on) and returns its + /// Transfer value. Never waits; a readback that needs the bytes waits on the + /// returned value. Refused while a frame is recording: its submission carries + /// the batch, and the batch may hold staging an inline copy reads. + /// + public ulong SubmitStandalone() + { + lock (_lock) + { + if (Volatile.Read(ref _frameCommandsHandle) != 0) + { + throw new InvalidOperationException( + "a frame is being recorded; its submission carries the open upload batch"); + } + + EnsureOpenLocked(); + TakeOpenBatchLocked(out CommandBuffer commandBuffer, out ulong transferValue); + + Semaphore transfer = _timeline.Transfer; + ulong value = transferValue; + var timelineInfo = new TimelineSemaphoreSubmitInfo + { + SType = StructureType.TimelineSemaphoreSubmitInfo, + SignalSemaphoreValueCount = 1, + PSignalSemaphoreValues = &value, + }; + var submit = new SubmitInfo + { + SType = StructureType.SubmitInfo, + PNext = &timelineInfo, + CommandBufferCount = 1, + PCommandBuffers = &commandBuffer, + SignalSemaphoreCount = 1, + PSignalSemaphores = &transfer, + }; + + // Timed like a frame submission: the lock is shared with the swapchain. + long submitStart = VulkanStats.WaitStart(); + lock (_context.QueueLock) + { + VulkanResult.Check(_context.Api.QueueSubmit(_context.GraphicsQueue, 1, &submit, default(Fence)), + "vkQueueSubmit for an upload batch"); + } + VulkanStats.NoteWait(WaitSite.QueueSubmit, submitStart); + _timeline.NoteTransferSubmitted(transferValue); + return transferValue; + } + } + + /// + /// Waits for every signalled Transfer value (teardown only, never throws), + /// then destroys the pools and the staging ring. Retired dedicated staging + /// buffers belong to the retire queue. + /// + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + _timeline.WaitForSignalledTransfersAtTeardown(); + foreach (Batch batch in _batches) + { + _context.Api.DestroyCommandPool(_context.Device, batch.Pool, null); + } + _batches.Clear(); + _open = null; + _stagingRing?.Dispose(); + _stagingRing = null; + } + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index b57fc093..8f40c801 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -26,7 +26,7 @@ namespace Optimum.Render.Vulkan; public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice { private VulkanContext _context = null!; - private VulkanCommands _setupCommands = null!; + private UploadManager _uploads = null!; private GlStateTracker _state = null!; private TextureManager _textures = null!; private MeshManager _meshes = null!; @@ -49,7 +49,6 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice private uint _frameCounter; private uint _uniformExhaustionReportedFrame = uint.MaxValue; - private int _renderThreadId = -1; private readonly Dictionary _stagedStages = new(); private readonly List _diagnostics = new(); @@ -319,22 +318,20 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; GPU checkpoints " + (_context.CheckpointsAvailable ? "ENABLED" : "NOT AVAILABLE") + "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE") + "; poison " + (_context.PoisonFreshResources ? "ON" : "off")); - _setupCommands = new VulkanCommands(_context); - // Only the render thread records frames, so only its synchronous submits - // can race one; a worker's upload is ordered by the queue lock alone. The - // frame's recorded part is submitted first and recording continues in the - // same slot: queue order is all the setup command needs, so nothing waits. - _setupCommands.BeforeSynchronousSubmit = () => - { - if (_frameActive && Environment.CurrentManagedThreadId == _renderThreadId) SubmitPartial(); - }; _state = new GlStateTracker(); - _textures = new TextureManager(_context, _setupCommands); - _meshes = new MeshManager(_context, _state); + // Uploads never wait: they ride the next frame submission, recorded from + // any thread into the ring's upload batch (or inline into the frame when + // it already used the destination; see UploadManager). + _frames = new FrameRing(_context); + _uploads = _frames.Uploads; + _textures = new TextureManager(_context, _uploads); + _meshes = new MeshManager(_context, _state, _uploads); _targets = new RenderTargetManager(_context, _textures, _state); + // An inline upload records transfer commands into the frame command + // buffer, which no rendering scope may enclose. + _uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer); _pipelines = new GraphicsPipelineCache(_context); _descriptors = new DescriptorCache(_context); - _frames = new FrameRing(_context); _queryRing = new QueryRing(_context, _frames.Timeline, _frames.FramesInFlight); _readbacks = new ReadbackManager(_context, _textures, _frames); _shaderCompiler = new ShaderCompiler(); @@ -618,7 +615,6 @@ public void BeginFrame() FrameSlot slot = _frames.BeginFrame(); _frameActive = true; - _renderThreadId = Environment.CurrentManagedThreadId; _frameCounter++; _indirectFrameUsage = 0; Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); @@ -658,6 +654,15 @@ public void BeginFrame() /// The frame ring's timelines. Tests only. internal FrameTimeline TimelineForTests => _frames.Timeline; + /// The frame ring's upload manager. Tests only. + internal UploadManager UploadsForTests => _uploads; + + /// Static meshes on device-local memory through staging (Phase 1B step 5's default). Tests only. + internal bool DeviceLocalStaticMeshesForTests + { + set => _meshes.DeviceLocalStaticBuffers = value; + } + /// Where per-second backend counters go, when asked for. private static readonly string? StatsLogPath = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_STATS"); @@ -1940,6 +1945,10 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra continue; } + // Sampled by this frame command buffer: a later upload to it this + // frame must go inline, after this draw, as it would on GL. + _uploads.NoteUse(commandBuffer, texture); + // The bound depth attachment read with writes off: EnsureRendering // puts it in the read-only layout, which serves both uses at once. if (_targets.DepthReadOnly && _targets.IsBoundDepth(_boundTextures[unit])) continue; @@ -2501,9 +2510,9 @@ public void EndOcclusionQuery(int queryId) /// /// Submits everything the frame has recorded so far and keeps recording it in - /// the same slot, so a readback or a synchronous setup command queued next - /// sees work the frame already issued. No wait, no new slot, no frame counter - /// increment: arena cursors and uniform snapshots carry on. + /// the same slot, so a readback queued next sees work the frame already issued. + /// The open upload batch rides along, first. No wait, no new slot, no frame + /// counter increment: arena cursors and uniform snapshots carry on. /// private ulong SubmitPartial() { @@ -2575,9 +2584,11 @@ private byte[] ReadBackLevel0(VulkanTexture texture) /// itself (into the slot's readback arena), the recorded part is submitted /// with and the caller waits on that single Frame /// timeline value; the frame carries on in the same slot, so every draw after - /// the read still reaches the screen. Between frames a setup submission - /// serves: the queue runs it after every frame already submitted, so its own - /// fence wait is enough. Neither path waits for the whole device. + /// the read still reaches the screen. Between frames the copy is appended to + /// the open upload batch (after every upload recorded so far), which is + /// submitted on its own; the queue runs it after every frame already submitted, + /// so waiting on its Transfer value is enough. Neither path waits for the + /// whole device. /// private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint height, ImageAspectFlags aspect, ulong bytes, IntPtr destination) @@ -2596,7 +2607,8 @@ private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint heig MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); ImageLayout restore = texture.Layout; - _setupCommands.SubmitAndWait(commandBuffer => + CommandBuffer commandBuffer = _uploads.BeginRecording(inlineInFrame: false); + try { _textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); @@ -2610,7 +2622,13 @@ private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint heig ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); if (restore != ImageLayout.Undefined) _textures.TransitionTexture(commandBuffer, texture, restore); - }, WaitSite.Readback); + } + finally + { + _uploads.EndRecording(); + } + ulong transferValue = _uploads.SubmitStandalone(); + _frames.Timeline.WaitForTransfer(transferValue, WaitSite.Readback); System.Buffer.MemoryCopy((void*)readback.Mapped, (void*)destination, (long)bytes, (long)bytes); } @@ -2705,7 +2723,6 @@ public void Dispose() _targets?.Dispose(); _meshes?.Dispose(); _textures?.Dispose(); - _setupCommands?.Dispose(); _context?.Dispose(); } } diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index f24fa9b9..37a2d20f 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -488,7 +488,7 @@ public void ReadbacksAndOcclusionQueriesNeverFlushTheFrameOrWaitForTheDevice() string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); Assert.DoesNotContain("FlushFrame", device); Assert.DoesNotContain("Thread.Yield", device); - Assert.Contains("if (_frameActive && Environment.CurrentManagedThreadId == _renderThreadId) SubmitPartial();", device); + Assert.DoesNotContain("BeforeSynchronousSubmit", device); Assert.Contains("ReadbackTicket ticket = _readbacks.CopyToHost(", device); Assert.Contains("_queryRing.BeginSlot(slot.Index, slot.CommandBuffer);", device); Assert.Contains("public int GetQueryResult(int queryId) => _queryRing.GetResult(queryId);", device); @@ -511,6 +511,57 @@ public void ReadbacksAndOcclusionQueriesNeverFlushTheFrameOrWaitForTheDevice() Assert.DoesNotContain("WaitDeviceIdle", readbacks); } + /// + /// Phase 1B step 3: no upload waits. Texture uploads, mip chains, poison + /// clears and staged buffer writes record into an upload batch that the next + /// frame submission carries first (one SubmitInfo, Frame and Transfer + /// timelines signalled together), or inline into the frame command buffer when + /// that already used the destination; the synchronous setup submit is deleted. + /// + [Fact] + public void UploadsRideTheFrameSubmissionAndNeverWait() + { + string resources = Read("Optimum.Render.Vulkan/Core/VulkanResources.cs"); + Assert.DoesNotContain("class VulkanCommands", resources); + Assert.DoesNotContain("SubmitAndWait", resources); + + string uploads = Read("Optimum.Render.Vulkan/Transfer/UploadManager.cs"); + Assert.Contains("free.TransferValue = _timeline.ReserveTransfer();", uploads); + Assert.Contains("if (completed >= candidate.TransferValue)", uploads); + Assert.Contains("_retired.Retire(dedicated);", uploads); + Assert.Contains("VulkanStats.NoteStagingOverflow();", uploads); + Assert.Contains("_timeline.NoteTransferSubmitted(transferValue);", uploads); + Assert.Contains("CloseRenderingScope?.Invoke(frameCommands);", uploads); + Assert.DoesNotContain("WaitForFences(", uploads); + Assert.DoesNotContain("WaitSemaphores(", uploads); + // A staged buffer copy is ordered against the draws around it by buffer barriers. + Assert.Contains("SType = StructureType.BufferMemoryBarrier2,", uploads); + Assert.Contains("PipelineStageFlags2.CopyBit, AccessFlags2.TransferWriteBit);", uploads); + + string ring = Read("Optimum.Render.Vulkan/Core/FrameRing.cs"); + Assert.Contains("_uploads.TakeOpenBatchLocked(out CommandBuffer uploadCommands, out ulong transferValue);", ring); + Assert.Contains("signals[signalCount] = _timeline.Transfer;", ring); + Assert.Contains("if (uploads) _timeline.NoteTransferSubmitted(transferValue);", ring); + Assert.Contains("_uploads.OnFrameCommandsStarted(commandBuffer);", ring); + + string textures = Read("Optimum.Render.Vulkan/Core/TextureManager.cs"); + Assert.Contains("_uploads.BeginRecording(_uploads.UsedByPendingFrame(texture.FrameUse));", textures); + Assert.Contains("_uploads.NoteUse(commandBuffer, texture);", textures); + Assert.Contains("_uploads.BeginRecording(inlineInFrame: false);", textures); + // A worker's upload and a delete on the render thread are ordered by the upload lock. + Assert.Contains("if (!ReferenceEquals(Get(textureId), texture)) return;", textures); + Assert.Contains("_uploads.EnterLock();", textures); + + string meshes = Read("Optimum.Render.Vulkan/Core/MeshManager.cs"); + Assert.Contains("_uploads!.UploadToBuffer(buffer, (ulong)byteOffset, source, (ulong)byteCount);", meshes); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.DoesNotContain("_setupCommands", device); + Assert.Contains("_uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer);", device); + Assert.Contains("ulong transferValue = _uploads.SubmitStandalone();", device); + Assert.Contains("_frames.Timeline.WaitForTransfer(transferValue, WaitSite.Readback);", device); + } + [Fact] public void ValidationMessagesAlwaysReachAFileAndExtraFeaturesCanBeRequested() { From e7d3e99fdd06613d22f0ec241b61acd2e5e9f7b8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:51:08 +0200 Subject: [PATCH 091/226] wip(phase1a-step4): framebuffers and post chain - device setup, factory, disposal and pass state live in VulkanClientPlatform SetupOptimumFrameBuffers and its helpers moved (text unchanged apart from the device field) into VulkanClientPlatform.FrameBuffers.cs as the SetupDefaultFrameBuffers override; the private platform state it touched is reached through injected ClientPlatformWindows members at the same points (OptimumAdoptFrameBufferSettings, OptimumTaaRequested, OptimumSsaoKernel, SetOptimumMotionAttachmentIndex, OptimumAdoptTaaTargets, OptimumFinishDeviceFrameBufferSetup). CreateFramebuffer, DisposeFrameBuffer(s), LoadFrameBuffer(ref,int) and GlClearColorRgbaf are whole overrides; their base bodies are vanilla again (targets dropped). The CurrentFrameBuffer/KeepVw setters, both ClearFrameBuffer overloads, LoadFrameBuffer(enum), UnloadFrameBuffer(enum), the OIT merge, RenderPostprocessingEffects and RenderFinalComposition keep their logic in the base and call the new virtuals (BindCurrentFrameBuffer[KeepViewport], ClearBoundFrameBuffer, ClearFrameBufferPass, ApplyTransparentPassBlendState, SelectBackDrawBuffer, SetBlendEnabled, ApplyTransparentMergeBlendState, ClearSsaoTarget, Begin/RestoreWorld draw buffers): GL lines in ClientPlatformWindows overrides, device lines in VulkanClientPlatform. VerifyHost also checks the injected accessors. Verified: renderer + donor build 0 errors, 0 warnings; extract + check-patches 0 conflict, 0 pending. --- Optimum.Patcher/Program.cs | 36 +- .../Optimum.Render.Vulkan.csproj | 16 + .../VulkanClientPlatform.FrameBuffers.cs | 692 +++++++++ .../Platform/VulkanClientPlatform.cs | 39 + .../ClientPlatformWindows.cs.patch | 1264 +++++------------ 5 files changed, 1127 insertions(+), 920 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 0e9770c1..2431a61b 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -206,23 +206,28 @@ "_optimumFocusLostStopwatch", "optimumFsrDisabled", "DisableOptimumFsr", - // Vulkan backend: the device-path framebuffer setup and its helpers. - "SetupOptimumFrameBuffers", - "CreateOptimumColorTarget", - "SetupOptimumTextureSampler", - "CreateOptimumDepthTarget", - "CreateOptimumPlaceholderTarget", - "CreateOptimumFramebuffer", - // Vulkan backend: GL state the device takes as call arguments instead, - // so the routed bodies need somewhere to remember it. - "optimumClearR", - "optimumClearG", - "optimumClearB", - "optimumClearA", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). "OptimumRenderSsao", + "OptimumAdoptFrameBufferSettings", + "OptimumTaaRequested", + "OptimumSsaoKernel", + "SetOptimumMotionAttachmentIndex", + "OptimumAdoptTaaTargets", + "OptimumFinishDeviceFrameBufferSetup", + // Phase 1A step 4: GL halves of the framebuffer binding, clears and post-chain pass state. + "BindCurrentFrameBuffer", + "BindCurrentFrameBufferKeepViewport", + "ClearBoundFrameBuffer", + "ClearFrameBufferPass", + "ApplyTransparentPassBlendState", + "SelectBackDrawBuffer", + "SetBlendEnabled", + "ApplyTransparentMergeBlendState", + "ClearSsaoTarget", + "BeginFinalCompositionDrawBuffers", + "RestoreWorldDrawBuffers", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumGlR32f", @@ -236,7 +241,6 @@ "optimumMotionWriteActive", "optimumTaaDisabled", "TaaHistory", - "CreateOptimumHistoryTarget", "CreateOptimumHistoryTargetGl", "DisableOptimumTaa", "optimumTaaShaderReloadPending", @@ -820,12 +824,9 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_CurrentFrameBufferKeepVw", 1), // The scissor flag is read back by the runtime atlas upload; the device // keeps no queryable state, so the routed setter remembers it. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateFramebuffer", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffer", 2), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DisposeFrameBuffers", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "ClearFrameBuffer", 4), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "ClearFrameBuffer", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadFrameBuffer", 2), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadFrameBuffer", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UnloadFrameBuffer", 1, new[] { "Vintagestory.API.Client.EnumFrameBuffer" }), @@ -845,7 +846,6 @@ // menu reaches it, and TextureAtlas.Upload only runs once a world loads. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateTextureFromPixels", 6), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Load3DTextureCube", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlClearColorRgbaf", 4), // Vulkan backend: uniform buffers, whose handles UBO carries across. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateUBO", 4), new("Vintagestory.Client.NoObf.UBO", "Bind", 0), diff --git a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj index e46563c7..01ae75c3 100644 --- a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -68,6 +68,22 @@ ..\.vanilla\win-x64\vintagestory\Lib\OpenTK.Audio.OpenAL.dll false + + ..\.vanilla\win-x64\vintagestory\Lib\OpenTK.Windowing.Desktop.dll + false + + + ..\.vanilla\win-x64\vintagestory\Lib\OpenTK.Windowing.Common.dll + false + + + ..\.vanilla\win-x64\vintagestory\Lib\OpenTK.Mathematics.dll + false + + + ..\.vanilla\win-x64\vintagestory\Lib\OpenTK.Graphics.dll + false + diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs new file mode 100644 index 00000000..40eb6384 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -0,0 +1,692 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using OpenTK.Windowing.Desktop; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: framebuffers and the post chain. The framebuffer +// set, the mod-facing framebuffer factory and disposal are whole overrides (moved from +// ClientPlatformWindows.SetupOptimumFrameBuffers, CreateOptimumFramebuffer and the device +// branches). The binding, clear and pass-state fragments are overrides of the virtuals the +// base's shared post-chain logic calls. +public partial class VulkanClientPlatform +{ + // ClientPlatformWindows' private slot constants; the parity dump and the post chain + // index FrameBuffers by the same numbers. + private const int OptimumFsrFramebufferIndex = 18; + private const int OptimumTaaHistoryIndexA = 19; + private const int OptimumTaaHistoryIndexB = 20; + private const int OptimumTaaSharpenIndex = 21; + private const int OptimumGlR32f = 0x822E; + + // GL keeps the clear colour in driver state and applies it at glClear; the device + // takes it as an argument, so GlClearColorRgbaf records it here and the Default clear + // passes it on. All-zero default, which is what GL_COLOR_CLEAR_VALUE starts as. + private float clearR; + private float clearG; + private float clearB; + private float clearA; + + /// + /// Builds the same framebuffer set as the GL path, through the device. + /// + /// A separate body rather than routed calls inside the GL one, because that body is + /// four hundred lines of raw GL with no seam to route through - it generates its own + /// names and attaches its own textures. Mirroring the layout keeps every index, size + /// and format identical, which is what the render systems assume when they index + /// FrameBuffers by EnumFrameBuffer. + /// + public override List SetupDefaultFrameBuffers() + { + OptimumAdoptFrameBufferSettings(); + bool setupSsao = ClientSettings.SSAOQuality > 0; + List list = new List(31); + for (int i = 0; i <= 24; i++) + { + list.Add(null); + } + int shadowMapQuality = ClientSettings.ShadowMapQuality; + float ssaaLevel = ClientSettings.SSAA; + + int width = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); + int height = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); + if (width == 0 || height == 0) + { + return list; + } + + bool taaRequested = OptimumTaaRequested; + int motionAttachmentIndex = -1; + + // Primary: depth, colour, glow, and the SSAO position/normal G-buffer. + FrameBufferRef primary = new FrameBufferRef(); + primary.Width = width; + primary.Height = height; + primary.FboId = device.CreateFramebuffer(width, height); + primary.DepthTextureId = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + SetupOptimumTextureSampler(primary.DepthTextureId, 9728, 33071); + device.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + + int primaryAttachments = (setupSsao ? 4 : 2); + primary.ColorTextureIds = new int[primaryAttachments]; + primary.ColorTextureIds[0] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + primary.ColorTextureIds[1] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + if (setupSsao) + { + primary.ColorTextureIds[2] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + primary.ColorTextureIds[3] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + } + // Match the GL Primary filters, including linear G-buffer sampling and + // the white border used when SSAO projects a sample off screen. + for (int attachment = 0; attachment < primaryAttachments; attachment++) + { + int textureId = primary.ColorTextureIds[attachment]; + SetupOptimumTextureSampler(textureId, + attachment >= 2 || ssaaLevel > 1f ? 9729 : 9728, attachment >= 2 ? 33069 : 10497); + if (attachment >= 2) device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); + } + if (taaRequested) + { + // Optimum: TAA motion attachment, appended after the SSAO G-buffer + // so every existing attachment index is unchanged. Deliberately not + // folded into the draw-buffer mask below - it stays out of every + // pass's output set until a writer opts in (P3+). + try + { + int motionTextureId = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int[] extendedColorIds = new int[primary.ColorTextureIds.Length + 1]; + Array.Copy(primary.ColorTextureIds, extendedColorIds, primary.ColorTextureIds.Length); + motionAttachmentIndex = primary.ColorTextureIds.Length; + extendedColorIds[motionAttachmentIndex] = motionTextureId; + primary.ColorTextureIds = extendedColorIds; + } + catch (Exception error) + { + DisableOptimumTaa("Primary motion attachment (device): " + error.Message); + motionAttachmentIndex = -1; + } + } + for (int attachment = 0; attachment < primary.ColorTextureIds.Length; attachment++) + { + device.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), + primary.ColorTextureIds[attachment], 0); + } + device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1); + list[0] = primary; + SetOptimumMotionAttachmentIndex(motionAttachmentIndex); + + // Transparent: OIT accumulation, revealage, glow. Shares Primary's depth. + FrameBufferRef transparent = new FrameBufferRef(); + transparent.Width = width; + transparent.Height = height; + transparent.FboId = device.CreateFramebuffer(width, height); + transparent.ColorTextureIds = new int[3]; + transparent.ColorTextureIds[0] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + transparent.ColorTextureIds[1] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.R16f, EnumTexturePixelFormat.Red, IntPtr.Zero, false); + transparent.ColorTextureIds[2] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + for (int attachment = 0; attachment < 3; attachment++) + { + SetupOptimumTextureSampler(transparent.ColorTextureIds[attachment], 9729, 10497); + device.AttachTexture(transparent.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), + transparent.ColorTextureIds[attachment], 0); + } + device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + device.SetDrawBuffers(transparent.FboId, 7); + transparent.DepthTextureId = primary.DepthTextureId; + list[1] = transparent; + + if (setupSsao) + { + int ssaoWidth = (int)((float)width * 0.5f); + int ssaoHeight = (int)((float)height * 0.5f); + + FrameBufferRef ssao = new FrameBufferRef(); + ssao.Width = ssaoWidth; + ssao.Height = ssaoHeight; + ssao.FboId = device.CreateFramebuffer(ssaoWidth, ssaoHeight); + ssao.ColorTextureIds = new int[2]; + // GL_RGB in the vanilla path; the device promotes it, because RGB is + // not a guaranteed colour-attachment format in Vulkan. + ssao.ColorTextureIds[0] = device.CreateTexture2DRaw(ssaoWidth, ssaoHeight, 6407, IntPtr.Zero, 0); + device.AttachTexture(ssao.FboId, EnumFramebufferAttachment.ColorAttachment0, ssao.ColorTextureIds[0], 0); + device.SetDrawBuffers(ssao.FboId, 1); + + // Rotation noise, and the sample kernel that goes with it. Same seed + // and draw order as the GL path, so the pattern matches exactly. + Random random = new Random(5); + int noiseSize = 16; + float[] noise = new float[noiseSize * noiseSize * 4]; + Vec3f direction = new Vec3f(); + for (int texel = 0; texel < noiseSize * noiseSize; texel++) + { + direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize(); + noise[texel * 4] = direction.X; + noise[texel * 4 + 1] = direction.Y; + noise[texel * 4 + 2] = direction.Z; + noise[texel * 4 + 3] = 0f; + } + GCHandle noiseHandle = GCHandle.Alloc(noise, GCHandleType.Pinned); + // RGBA32F rather than the GL path's RGB32F: the fourth channel is + // padding, and a three-channel float format is not guaranteed. + ssao.ColorTextureIds[1] = device.CreateTexture2DRaw( + noiseSize, noiseSize, 34836, noiseHandle.AddrOfPinnedObject(), 16); + noiseHandle.Free(); + device.SetTextureParameter(ssao.ColorTextureIds[1], + Vintagestory.API.Config.OptimumGlConstants.TextureWrapS, + Vintagestory.API.Config.OptimumGlConstants.Repeat); + device.SetTextureParameter(ssao.ColorTextureIds[1], + Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, + Vintagestory.API.Config.OptimumGlConstants.Repeat); + + float[] ssaoKernel = OptimumSsaoKernel; + for (int sample = 0; sample < 64; sample++) + { + Vec3f kernel = new Vec3f((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, (float)random.NextDouble()); + kernel.Normalize(); + kernel *= (float)random.NextDouble(); + float scale = (float)sample / 64f; + scale = GameMath.Lerp(0.1f, 1f, scale * scale); + kernel *= scale; + ssaoKernel[sample * 3] = kernel.X; + ssaoKernel[sample * 3 + 1] = kernel.Y; + ssaoKernel[sample * 3 + 2] = kernel.Z; + } + list[13] = ssao; + + list[14] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); + list[15] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); + } + + list[2] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8); + list[3] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8); + list[9] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8); + list[8] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8); + list[4] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f); + list[7] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba16f); + list[10] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f); + + // Optimum: TAA history, render-resolution like Primary. Two slots so the + // resolve reads last frame's parity while writing this frame's; never + // cleared per frame (ClearFrameBuffer(Primary) only touches Primary). + if (taaRequested) + { + try + { + list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTarget(width, height); + list[OptimumTaaHistoryIndexB] = CreateOptimumHistoryTarget(width, height); + } + catch (Exception error) + { + DisableOptimumTaa("history targets (device): " + error.Message); + list[OptimumTaaHistoryIndexA] = null; + list[OptimumTaaHistoryIndexB] = null; + } + // Optimum TAA (P5): the sharpen target. Its own try - a sharpen + // target that cannot be allocated costs the sharpening, not TAA, + // so it nulls the slot instead of calling DisableOptimumTaa. + try + { + list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(width, height, + EnumTextureInternalFormat.Rgba16f); + } + catch (Exception error) + { + Logger.Error("Optimum disabled the TAA sharpen pass: {0}", error.Message); + list[OptimumTaaSharpenIndex] = null; + } + } + OptimumAdoptTaaTargets(list, taaRequested); + + // FSR renders at a reduced scale and resolves into a native-sized target. + if (ClientSettings.OptimumRenderScale < 1.0f) + { + list[OptimumFsrFramebufferIndex] = CreateOptimumColorTarget( + ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y, + EnumTextureInternalFormat.Rgba8); + } + + list[5] = CreateOptimumDepthTarget(width / 4, height / 4); + + // Both shadow slots always hold a FrameBufferRef, exactly as the GL path + // does: vanilla constructs the objects unconditionally and only allocates + // their textures when the quality setting reaches each level. + // + // The distinction matters because ShaderProgramBase.Use dereferences both + // FrameBuffers[11] and FrameBuffers[12] whenever shadowmapQuality > 0, + // and every shader including fogandlight.fsh - sky.fsh among them - takes + // that branch. Leaving slot 12 null at quality 1 is a null reference on + // the first sky draw, which is what it was. + int shadowSize = Math.Max(4, shadowMapQuality + 2) * 1024; + list[11] = shadowMapQuality > 0 + ? CreateOptimumDepthTarget(shadowSize, shadowSize) + : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); + list[12] = shadowMapQuality > 1 + ? CreateOptimumDepthTarget(shadowSize, shadowSize) + : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); + + for (int shadow = 11; shadow <= 12; shadow++) + { + int textureId = list[shadow].DepthTextureId; + if (textureId == 0) continue; + SetupOptimumTextureSampler(textureId, 9729, 33069); + device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, OptimumGlConstants.CompareRefToTexture); + } + + OptimumFinishDeviceFrameBufferSetup(list); + return list; + } + + /// + /// The mod-facing framebuffer factory. Same shape as the GL body: create the target, + /// create or adopt a texture per attachment, attach it, then select the colour + /// attachments as draw buffers. + /// + /// The GL body interleaves texture creation with framebuffer attachment through the + /// bound texture unit, and there is no seam to route call-by-call. The attachment order + /// is preserved because the draw-buffer mask is positional: bit N means + /// ColorAttachmentN, and the shaders' output locations depend on it. + /// + public override FrameBufferRef CreateFramebuffer(FramebufferAttrs fbAttrs) + { + FrameBufferRef target = new FrameBufferRef(); + target.Width = fbAttrs.Width; + target.Height = fbAttrs.Height; + target.FboId = device.CreateFramebuffer(fbAttrs.Width, fbAttrs.Height); + + List colorTextureIds = new List(); + int drawBufferMask = 0; + FramebufferAttrsAttachment[] attachments = fbAttrs.Attachments; + for (int i = 0; i < attachments.Length; i++) + { + FramebufferAttrsAttachment attachment = attachments[i]; + RawTexture texture = attachment.Texture; + int textureId = texture.TextureId; + if (textureId == 0) + { + textureId = device.CreateTexture2D(texture.Width, texture.Height, + texture.PixelInternalFormat, texture.PixelFormat, IntPtr.Zero, false); + // EnumTextureFilter and EnumTextureWrap carry the GL token values, + // which is exactly what SetTextureParameter expects. + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, (int)texture.MinFilter); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, (int)texture.MagFilter); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapS, (int)texture.WrapS); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, (int)texture.WrapT); + texture.TextureId = textureId; + } + device.AttachTexture(target.FboId, attachment.AttachmentType, textureId, 0); + if (attachment.AttachmentType == EnumFramebufferAttachment.DepthAttachment) + { + target.DepthTextureId = textureId; + } + else + { + colorTextureIds.Add(textureId); + drawBufferMask |= 1 << ((int)attachment.AttachmentType - (int)EnumFramebufferAttachment.ColorAttachment0); + } + } + + target.ColorTextureIds = colorTextureIds.ToArray(); + device.SetDrawBuffers(target.FboId, drawBufferMask); + + string status; + if (!device.CheckFramebufferComplete(target.FboId, out status)) + { + throw new Exception("FBO " + fbAttrs.Name + ": " + status); + } + return target; + } + + /// Mirror the GL framebuffer texture's filtering and edge policy. + private void SetupOptimumTextureSampler(int textureId, int filter, int wrap) + { + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, filter); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, filter); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapS, wrap); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, wrap); + } + + /// A single-colour-attachment target, as the post chain uses. + private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextureInternalFormat format) + { + FrameBufferRef target = new FrameBufferRef(); + target.Width = width; + target.Height = height; + target.FboId = device.CreateFramebuffer(width, height); + target.ColorTextureIds = new int[1]; + target.ColorTextureIds[0] = device.CreateTexture2D(width, height, format, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + // setupAttachment uses linear filtering and edge clamping. FXAA and + // the reduced-resolution blur passes require fractional texel samples. + SetupOptimumTextureSampler(target.ColorTextureIds[0], 9729, 33071); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + device.SetDrawBuffers(target.FboId, 1); + return target; + } + + /// A depth-only target, as the shadow maps and liquid depth use. + private FrameBufferRef CreateOptimumDepthTarget(int width, int height) + { + FrameBufferRef target = new FrameBufferRef(); + target.Width = width; + target.Height = height; + target.FboId = device.CreateFramebuffer(width, height); + target.ColorTextureIds = new int[0]; + target.DepthTextureId = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + SetupOptimumTextureSampler(target.DepthTextureId, 9729, 33071); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0); + device.SetDrawBuffers(target.FboId, 0); + return target; + } + + /// + /// A TAA history slot - colour (RGBA16F), aux (RGBA8: glow.rg, ssao.b) and linear + /// depth (R32F), MRT-written by the resolve pass and read back next frame. R32F has no + /// entry, so it goes through + /// CreateTexture2DRaw with the raw GL token, the same way the SSAO noise + /// texture does above. + /// + private FrameBufferRef CreateOptimumHistoryTarget(int width, int height) + { + FrameBufferRef target = new FrameBufferRef(); + target.Width = width; + target.Height = height; + target.FboId = device.CreateFramebuffer(width, height); + target.ColorTextureIds = new int[3]; + target.ColorTextureIds[0] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + target.ColorTextureIds[1] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + target.ColorTextureIds[2] = device.CreateTexture2DRaw(width, height, OptimumGlR32f, IntPtr.Zero, 4); + // Optimum TAA: the resolve reprojects the history by a fractional pixel + // offset, so colour (Catmull-Rom taps) and glow (a plain bilinear fetch) + // must filter LINEAR; sampling them NEAREST snaps the reprojection to + // whole pixels and the history never converges. Linear depth stays + // NEAREST - interpolating across a silhouette invents a depth that is on + // neither surface and defeats the disocclusion test. Clamp to edge on + // all three, matching CreateOptimumHistoryTargetGl. + SetupOptimumTextureSampler(target.ColorTextureIds[0], 9729, 33071); + SetupOptimumTextureSampler(target.ColorTextureIds[1], 9729, 33071); + SetupOptimumTextureSampler(target.ColorTextureIds[2], 9728, 33071); + for (int attachment = 0; attachment < 3; attachment++) + { + device.AttachTexture(target.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), + target.ColorTextureIds[attachment], 0); + } + device.SetDrawBuffers(target.FboId, 7); + if (!device.CheckFramebufferComplete(target.FboId, out string status)) + { + throw new Exception("Optimum TAA history FBO: " + status); + } + return target; + } + + /// + /// A framebuffer slot that exists but owns nothing, for a quality level whose + /// resources are not allocated. + /// + /// The GL path leaves such a slot holding a FrameBufferRef whose ids are zero; callers + /// read its Width and Height and bind its texture id, and binding zero is a no-op + /// there. The device treats texture 0 as unbound and substitutes its placeholder, so + /// the same read is equally harmless here. + /// + private static FrameBufferRef CreateOptimumPlaceholderTarget(int width, int height) + { + FrameBufferRef target = new FrameBufferRef(); + target.Width = width; + target.Height = height; + target.ColorTextureIds = new int[0]; + return target; + } + + public override void DisposeFrameBuffer(FrameBufferRef frameBuffer, bool disposeTextures = true) + { + if (frameBuffer == null) + { + return; + } + if (disposeTextures) + { + for (int i = 0; i < frameBuffer.ColorTextureIds.Length; i++) + { + GLDeleteTexture(frameBuffer.ColorTextureIds[i]); + } + if (frameBuffer.DepthTextureId > 0) + { + GLDeleteTexture(frameBuffer.DepthTextureId); + } + } + // GLDeleteTexture above already routes, so only the target itself is left. + device.DeleteFramebuffer(frameBuffer.FboId); + } + + /// + /// SetupDefaultFrameBuffers shares one depth texture between Primary and Transparent, + /// so the same handle appears in more than one FrameBufferRef. Deleting it twice + /// double-frees and makes VulkanStats.NoteTextureDeleted over-count, so every handle + /// is deleted once. + /// + public override void DisposeFrameBuffers(List buffers) + { + HashSet deletedTextures = new HashSet(); + for (int k = 0; k < buffers.Count; k++) + { + if (buffers[k] != null) + { + device.DeleteFramebuffer(buffers[k].FboId); + if (deletedTextures.Add(buffers[k].DepthTextureId)) + { + device.DeleteTexture(buffers[k].DepthTextureId); + } + for (int n = 0; n < buffers[k].ColorTextureIds.Length; n++) + { + if (deletedTextures.Add(buffers[k].ColorTextureIds[n])) + { + device.DeleteTexture(buffers[k].ColorTextureIds[n]); + } + } + buffers[k].Disposed = true; + } + } + } + + /// + /// Swaps the colour attachment on an already-created target, which mods use to render + /// into a texture they own. + /// + public override void LoadFrameBuffer(FrameBufferRef frameBuffer, int textureId) + { + CurrentFrameBuffer = frameBuffer; + device.AttachTexture(frameBuffer.FboId, EnumFramebufferAttachment.ColorAttachment0, textureId, 0); + } + + /// + /// GL keeps a clear colour in its state; the device takes it at clear time, so this + /// only records what the next Default clear should use. + /// + public override void GlClearColorRgbaf(float r, float g, float b, float a) + { + clearR = r; + clearG = g; + clearB = b; + clearA = a; + } + + /// + /// FboId carries the device's render-target handle on this path, the same way + /// VAO.VaoId carries a mesh handle, so FrameBufferRef stays the type mods already hold. + /// + public override void BindCurrentFrameBuffer(FrameBufferRef value) + { + if (value == null) + { + device.BindDefaultFramebuffer(); + return; + } + device.BindFramebuffer(value.FboId); + device.SetViewport(0, 0, value.Width, value.Height); + } + + public override void BindCurrentFrameBufferKeepViewport(FrameBufferRef value) + { + if (value == null) + { + device.BindDefaultFramebuffer(); + return; + } + device.BindFramebuffer(value.FboId); + } + + public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] clearColor, bool clearDepthBuffer, bool clearColorBuffers) + { + if (clearColorBuffers) + { + for (int k = 0; k < framebuffer.ColorTextureIds.Length; k++) + { + device.ClearColor(k, clearColor[0], clearColor[1], clearColor[2], clearColor[3]); + } + } + if (clearDepthBuffer) + { + device.ClearDepth(1f); + } + } + + /// + /// Same clear values per pass as the GL body. Default clears the swapchain image with + /// the colour GlClearColorRgbaf recorded, since the device has no GL clear-colour state + /// of its own. + /// + public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) + { + switch (framebuffer) + { + case EnumFrameBuffer.Default: + device.ClearColor(0, clearR, clearG, clearB, clearA); + device.ClearDepth(1f); + break; + case EnumFrameBuffer.Primary: + device.ClearColor(0, 0f, 0f, 0f, 1f); + device.ClearColor(1, 0f, 0f, 0f, 1f); + if (OptimumRenderSsao) + { + device.ClearColor(2, 0f, 0f, 0f, 1f); + device.ClearColor(3, 0f, 0f, 0f, 1f); + } + if (MotionAttachmentIndex >= 0) + { + // ClearColor honours the draw-buffer mask on the device too. + // Motion is excluded until a writer opts in, so temporarily + // enable it just as the GL branch does. Otherwise stale + // motion/reactivity survives and can reject all TAA history. + device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); + device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f); + device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + } + device.ClearDepth(1f); + break; + case EnumFrameBuffer.LiquidDepth: + case EnumFrameBuffer.ShadowmapFar: + case EnumFrameBuffer.ShadowmapNear: + { + FrameBufferRef optimumTarget = FrameBuffers[(int)framebuffer]; + device.SetViewport(0, 0, optimumTarget.Width, optimumTarget.Height); + device.ClearDepth(1f); + break; + } + case EnumFrameBuffer.Transparent: + // Weighted-blended OIT: accumulation starts at zero, revealage at + // one, and the third attachment is the opaque-depth copy. + device.ClearColor(0, 0f, 0f, 0f, 0f); + device.ClearColor(1, 1f, 0f, 0f, 0f); + device.ClearColor(2, 0f, 0f, 0f, 0f); + break; + } + } + + /// + /// Weighted-blended OIT: accumulation adds, revealage multiplies, and the third + /// attachment uses ordinary source-alpha blending. + /// + public override void ApplyTransparentPassBlendState() + { + device.SetDrawBuffers(FrameBuffers[1].FboId, 7); + device.SetBlend(true, EnumBlendMode.Standard); + device.SetBlendEquation(0, 32774); + device.SetBlendFuncSeparate(0, 1, 1, 1, 1); + device.SetBlendEquation(1, 32774); + device.SetBlendFuncSeparate(1, 0, 769, 0, 769); + device.SetBlendEquation(2, 32774); + device.SetBlendFuncSeparate(2, 770, 771, 770, 771); + } + + /// + /// Selecting GL_BACK has no device equivalent: binding the default target already + /// means the swapchain image. + /// + public override void SelectBackDrawBuffer() + { + } + + public override void SetBlendEnabled(bool enabled) + { + device.SetBlendEnabled(enabled); + } + + /// + /// The OIT merge's three state calls, spelled out rather than routed through + /// GlToggleBlend, because that helper also overrides the SSAO attachments and this + /// pass deliberately sets only the global mode. + /// + public override void ApplyTransparentMergeBlendState() + { + device.SetDepthTest(false); + device.SetBlend(true, EnumBlendMode.Standard); + device.SetBlendFuncSeparate(0, 770, 771, 770, 771); + } + + public override void ClearSsaoTarget() + { + device.ClearColor(0, 1f, 1f, 1f, 1f); + } + + /// + /// The device takes the target explicitly (the bound one, Primary here), and its mask + /// is positional - bit N selects ColorAttachmentN. + /// + public override void BeginFinalCompositionDrawBuffers() + { + device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); + device.SetDepthTest(false); + } + + public override void RestoreWorldDrawBuffers(bool ssaoAttachments) + { + if (ssaoAttachments) + { + device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); + } + else + { + device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 3); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 929f9cb6..dad79823 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -59,6 +59,34 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "ApplyOptimumMotionBlendState", Array.Empty()), new(true, "ApplyOptimumMotionAccumulateBlendState", Array.Empty()), new(true, "SelectFsrDrawBuffer", new[] { "FrameBufferRef" }), + // Phase 1A step 4: framebuffer binding, clears and post-chain pass state. + new(true, "BindCurrentFrameBuffer", new[] { "FrameBufferRef" }), + new(true, "BindCurrentFrameBufferKeepViewport", new[] { "FrameBufferRef" }), + new(true, "ClearBoundFrameBuffer", new[] { "FrameBufferRef", "Single[]", "Boolean", "Boolean" }), + new(true, "ClearFrameBufferPass", new[] { "EnumFrameBuffer" }), + new(true, "ApplyTransparentPassBlendState", Array.Empty()), + new(true, "SelectBackDrawBuffer", Array.Empty()), + new(true, "SetBlendEnabled", new[] { "Boolean" }), + new(true, "ApplyTransparentMergeBlendState", Array.Empty()), + new(true, "ClearSsaoTarget", Array.Empty()), + new(true, "BeginFinalCompositionDrawBuffers", Array.Empty()), + new(true, "RestoreWorldDrawBuffers", new[] { "Boolean" }), + }; + + /// + /// Non-virtual members injected into that the + /// overrides read or call (the platform state behind the device framebuffer setup and + /// the SSAO flag). A lib without them would fail with MissingMethodException mid-frame. + /// + internal static readonly string[] ExpectedWindowsMembers = + { + "OptimumRenderSsao", + "OptimumAdoptFrameBufferSettings", + "OptimumTaaRequested", + "OptimumSsaoKernel", + "SetOptimumMotionAttachmentIndex", + "OptimumAdoptTaaTargets", + "OptimumFinishDeviceFrameBufferSetup", }; /// Test seam: the device to bring up (tests add validation capture). @@ -102,6 +130,17 @@ internal static bool VerifyHost(Type abstractType, Type windowsType, out string? } } + const BindingFlags memberFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (string name in ExpectedWindowsMembers) + { + if (windowsType.GetMember(name, memberFlags).Length == 0) + { + reason = "the loaded VintagestoryLib lacks " + windowsType.Name + "." + name + + " (not patched for this renderer)"; + return false; + } + } + reason = null; return true; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 93eeccd8..db733655 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..9cc335b 100644 +index 6edf0c9..af5856c 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -115,7 +115,7 @@ index 6edf0c9..9cc335b 100644 private Logger logger; private int doResize; -@@ -93,10 +182,129 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,115 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -225,27 +225,13 @@ index 6edf0c9..9cc335b 100644 + private bool optimumTaaResolvedThisFrame; + + private bool optimumTaaDisabled; -+ -+ // Optimum: GL keeps the clear colour in driver state and applies it at -+ // glClear; the device takes it as an argument, so GlClearColorRgbaf records -+ // it here and ClearFrameBuffer passes it on. Deliberately left at the -+ // all-zero default, which is what GL_COLOR_CLEAR_VALUE starts as - and it -+ // has to be, because ClientPlatformWindows..ctor is not a transplant target, -+ // so a field initializer here would never run in the shipped assembly. -+ private float optimumClearR; -+ -+ private float optimumClearG; -+ -+ private float optimumClearB; -+ -+ private float optimumClearA; + private MeshRef screenQuad; private bool serverRunning; private bool gamepause; -@@ -109,10 +317,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -109,10 +303,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private bool RenderFXAA; @@ -264,7 +250,7 @@ index 6edf0c9..9cc335b 100644 private int ShadowMapQuality; private float ssaaLevel; -@@ -256,10 +472,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +458,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -288,35 +274,43 @@ index 6edf0c9..9cc335b 100644 get { return serverRunning; -@@ -278,11 +507,27 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,34 +493,54 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } set { + // Mono.Cecil transplant. -+ // FboId carries the device's render-target handle on this path, the -+ // same way VAO.VaoId carries a mesh handle, so FrameBufferRef stays -+ // the type mods already hold. curFb = value; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (value == null) -+ { -+ optimumDevice.BindDefaultFramebuffer(); -+ return; -+ } -+ optimumDevice.BindFramebuffer(value.FboId); -+ optimumDevice.SetViewport(0, 0, value.Width, value.Height); -+ return; -+ } - if (value == null) - { - GL.BindFramebuffer((FramebufferTarget)36160, 0); - return; - } -@@ -297,11 +542,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +- if (value == null) +- { +- GL.BindFramebuffer((FramebufferTarget)36160, 0); +- return; +- } +- GL.BindFramebuffer((FramebufferTarget)36160, value.FboId); +- GL.Viewport(0, 0, value.Width, value.Height); ++ BindCurrentFrameBuffer(value); ++ } ++ } ++ ++ /// ++ /// Optimum (Vulkan-native plan, Phase 1A step 4): the GL half of the ++ /// setter - bind, and size the viewport to the target. ++ /// ++ public override void BindCurrentFrameBuffer(FrameBufferRef value) ++ { ++ if (value == null) ++ { ++ GL.BindFramebuffer((FramebufferTarget)36160, 0); ++ return; + } ++ GL.BindFramebuffer((FramebufferTarget)36160, value.FboId); ++ GL.Viewport(0, 0, value.Width, value.Height); + } + + private FrameBufferRef CurrentFrameBufferKeepVw + { + get { return curFb; } @@ -324,23 +318,26 @@ index 6edf0c9..9cc335b 100644 { + // Mono.Cecil transplant. curFb = value; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (value == null) -+ { -+ optimumDevice.BindDefaultFramebuffer(); -+ return; -+ } -+ optimumDevice.BindFramebuffer(value.FboId); -+ return; -+ } - GL.BindFramebuffer((FramebufferTarget)36160, value?.FboId ?? 0); +- GL.BindFramebuffer((FramebufferTarget)36160, value?.FboId ?? 0); ++ BindCurrentFrameBufferKeepViewport(value); } } ++ /// ++ /// Optimum (Phase 1A step 4): the GL half of the CurrentFrameBufferKeepVw setter - ++ /// bind only, the viewport stays. ++ /// ++ public override void BindCurrentFrameBufferKeepViewport(FrameBufferRef value) ++ { ++ GL.BindFramebuffer((FramebufferTarget)36160, value?.FboId ?? 0); ++ } ++ public override bool GlErrorChecking { get; set; } -@@ -478,40 +735,147 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + + public override bool GlDebugMode + { + get +@@ -478,40 +713,147 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -493,7 +490,7 @@ index 6edf0c9..9cc335b 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +895,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +873,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -506,7 +503,7 @@ index 6edf0c9..9cc335b 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1066,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1044,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -549,7 +546,7 @@ index 6edf0c9..9cc335b 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1178,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1156,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -573,7 +570,7 @@ index 6edf0c9..9cc335b 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1411,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1016,20 +1389,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); } @@ -612,7 +609,7 @@ index 6edf0c9..9cc335b 100644 GL.BindVertexArray(0); } -@@ -1042,10 +1454,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1042,10 +1432,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Unknown result type (might be due to invalid IL or missing references) @@ -632,7 +629,7 @@ index 6edf0c9..9cc335b 100644 { GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1485,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1064,10 +1463,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) @@ -649,40 +646,7 @@ index 6edf0c9..9cc335b 100644 GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); -@@ -1103,15 +1530,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - if (frameBuffer.DepthTextureId > 0) - { - GLDeleteTexture(frameBuffer.DepthTextureId); - } - } -+ // Mono.Cecil transplant. -+ // GLDeleteTexture above already routes, so only the target itself is left. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DeleteFramebuffer(frameBuffer.FboId); -+ return; -+ } - GL.DeleteFramebuffer(frameBuffer.FboId); - } - - public override FrameBufferRef CreateFramebuffer(FramebufferAttrs fbAttrs) - { -+ // Mono.Cecil transplant. -+ // The mod-facing framebuffer factory. Same shape as the GL body: create -+ // the target, create or adopt a texture per attachment, attach it, then -+ // select the colour attachments as draw buffers. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return CreateOptimumFramebuffer(optimumDevice, fbAttrs); -+ } - FrameBufferRef frameBufferRef = (CurrentFrameBufferKeepVw = new FrameBufferRef - { - FboId = GL.GenFramebuffer(), - Width = fbAttrs.Width, - Height = fbAttrs.Height -@@ -1150,12 +1594,695 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,11 +1555,300 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -690,463 +654,6 @@ index 6edf0c9..9cc335b 100644 - public List SetupDefaultFrameBuffers() + /// -+ /// Optimum: builds the same framebuffer set as the GL path, through the -+ /// device. -+ /// -+ /// A separate method rather than a branch inside the GL body, because that -+ /// body is four hundred lines of raw GL with no seam to route through - it -+ /// generates its own names and attaches its own textures. Mirroring the -+ /// layout keeps every index, size and format identical, which is what the -+ /// render systems assume when they index FrameBuffers by EnumFrameBuffer. -+ /// -+ private List SetupOptimumFrameBuffers(Vintagestory.API.Config.IOptimumGraphicsDevice device) -+ { -+ SetupSSAO = ClientSettings.SSAOQuality > 0; -+ List list = new List(31); -+ for (int i = 0; i <= 24; i++) -+ { -+ list.Add(null); -+ } -+ ShadowMapQuality = ClientSettings.ShadowMapQuality; -+ ssaaLevel = ClientSettings.SSAA; -+ -+ int width = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); -+ int height = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); -+ if (width == 0 || height == 0) -+ { -+ return list; -+ } -+ -+ // Optimum: TAA. Read once per (re-)build; a mid-session config change -+ // only takes effect on the next RebuildFrameBuffers. optimumTaaDisabled -+ // is checked as well as EffectiveTaa: DisableOptimumTaa sets both, and -+ // the local flag keeps this platform from retrying a failed allocation -+ // even if the process-wide config flag is ever reset. -+ bool taaRequested = !optimumTaaDisabled && Vintagestory.API.Config.OptimumConfig.EffectiveTaa; -+ int motionAttachmentIndex = -1; -+ -+ // Primary: depth, colour, glow, and the SSAO position/normal G-buffer. -+ FrameBufferRef primary = new FrameBufferRef(); -+ primary.Width = width; -+ primary.Height = height; -+ primary.FboId = device.CreateFramebuffer(width, height); -+ primary.DepthTextureId = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); -+ SetupOptimumTextureSampler(device, primary.DepthTextureId, 9728, 33071); -+ device.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); -+ -+ int primaryAttachments = (SetupSSAO ? 4 : 2); -+ primary.ColorTextureIds = new int[primaryAttachments]; -+ primary.ColorTextureIds[0] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ primary.ColorTextureIds[1] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ if (SetupSSAO) -+ { -+ primary.ColorTextureIds[2] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ primary.ColorTextureIds[3] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ } -+ // Match the GL Primary filters, including linear G-buffer sampling and -+ // the white border used when SSAO projects a sample off screen. -+ for (int attachment = 0; attachment < primaryAttachments; attachment++) -+ { -+ int textureId = primary.ColorTextureIds[attachment]; -+ SetupOptimumTextureSampler(device, textureId, -+ attachment >= 2 || ssaaLevel > 1f ? 9729 : 9728, attachment >= 2 ? 33069 : 10497); -+ if (attachment >= 2) device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); -+ } -+ if (taaRequested) -+ { -+ // Optimum: TAA motion attachment, appended after the SSAO G-buffer -+ // so every existing attachment index is unchanged. Deliberately not -+ // folded into the draw-buffer mask below - it stays out of every -+ // pass's output set until a writer opts in (P3+). -+ try -+ { -+ int motionTextureId = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ int[] extendedColorIds = new int[primary.ColorTextureIds.Length + 1]; -+ Array.Copy(primary.ColorTextureIds, extendedColorIds, primary.ColorTextureIds.Length); -+ motionAttachmentIndex = primary.ColorTextureIds.Length; -+ extendedColorIds[motionAttachmentIndex] = motionTextureId; -+ primary.ColorTextureIds = extendedColorIds; -+ } -+ catch (Exception error) -+ { -+ DisableOptimumTaa("Primary motion attachment (device): " + error.Message); -+ motionAttachmentIndex = -1; -+ } -+ } -+ for (int attachment = 0; attachment < primary.ColorTextureIds.Length; attachment++) -+ { -+ device.AttachTexture(primary.FboId, -+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), -+ primary.ColorTextureIds[attachment], 0); -+ } -+ device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1); -+ list[0] = primary; -+ optimumMotionAttachmentIndex = motionAttachmentIndex; -+ -+ // Transparent: OIT accumulation, revealage, glow. Shares Primary's depth. -+ FrameBufferRef transparent = new FrameBufferRef(); -+ transparent.Width = width; -+ transparent.Height = height; -+ transparent.FboId = device.CreateFramebuffer(width, height); -+ transparent.ColorTextureIds = new int[3]; -+ transparent.ColorTextureIds[0] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ transparent.ColorTextureIds[1] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.R16f, EnumTexturePixelFormat.Red, IntPtr.Zero, false); -+ transparent.ColorTextureIds[2] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ for (int attachment = 0; attachment < 3; attachment++) -+ { -+ SetupOptimumTextureSampler(device, transparent.ColorTextureIds[attachment], 9729, 10497); -+ device.AttachTexture(transparent.FboId, -+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), -+ transparent.ColorTextureIds[attachment], 0); -+ } -+ device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); -+ device.SetDrawBuffers(transparent.FboId, 7); -+ transparent.DepthTextureId = primary.DepthTextureId; -+ list[1] = transparent; -+ -+ if (SetupSSAO) -+ { -+ int ssaoWidth = (int)((float)width * 0.5f); -+ int ssaoHeight = (int)((float)height * 0.5f); -+ -+ FrameBufferRef ssao = new FrameBufferRef(); -+ ssao.Width = ssaoWidth; -+ ssao.Height = ssaoHeight; -+ ssao.FboId = device.CreateFramebuffer(ssaoWidth, ssaoHeight); -+ ssao.ColorTextureIds = new int[2]; -+ // GL_RGB in the vanilla path; the device promotes it, because RGB is -+ // not a guaranteed colour-attachment format in Vulkan. -+ ssao.ColorTextureIds[0] = device.CreateTexture2DRaw(ssaoWidth, ssaoHeight, 6407, IntPtr.Zero, 0); -+ device.AttachTexture(ssao.FboId, EnumFramebufferAttachment.ColorAttachment0, ssao.ColorTextureIds[0], 0); -+ device.SetDrawBuffers(ssao.FboId, 1); -+ -+ // Rotation noise, and the sample kernel that goes with it. Same seed -+ // and draw order as the GL path, so the pattern matches exactly. -+ Random random = new Random(5); -+ int noiseSize = 16; -+ float[] noise = new float[noiseSize * noiseSize * 4]; -+ Vec3f direction = new Vec3f(); -+ for (int texel = 0; texel < noiseSize * noiseSize; texel++) -+ { -+ direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize(); -+ noise[texel * 4] = direction.X; -+ noise[texel * 4 + 1] = direction.Y; -+ noise[texel * 4 + 2] = direction.Z; -+ noise[texel * 4 + 3] = 0f; -+ } -+ GCHandle noiseHandle = GCHandle.Alloc(noise, GCHandleType.Pinned); -+ // RGBA32F rather than the GL path's RGB32F: the fourth channel is -+ // padding, and a three-channel float format is not guaranteed. -+ ssao.ColorTextureIds[1] = device.CreateTexture2DRaw( -+ noiseSize, noiseSize, 34836, noiseHandle.AddrOfPinnedObject(), 16); -+ noiseHandle.Free(); -+ device.SetTextureParameter(ssao.ColorTextureIds[1], -+ Vintagestory.API.Config.OptimumGlConstants.TextureWrapS, -+ Vintagestory.API.Config.OptimumGlConstants.Repeat); -+ device.SetTextureParameter(ssao.ColorTextureIds[1], -+ Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, -+ Vintagestory.API.Config.OptimumGlConstants.Repeat); -+ -+ for (int sample = 0; sample < 64; sample++) -+ { -+ Vec3f kernel = new Vec3f((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, (float)random.NextDouble()); -+ kernel.Normalize(); -+ kernel *= (float)random.NextDouble(); -+ float scale = (float)sample / 64f; -+ scale = GameMath.Lerp(0.1f, 1f, scale * scale); -+ kernel *= scale; -+ ssaoKernel[sample * 3] = kernel.X; -+ ssaoKernel[sample * 3 + 1] = kernel.Y; -+ ssaoKernel[sample * 3 + 2] = kernel.Z; -+ } -+ list[13] = ssao; -+ -+ list[14] = CreateOptimumColorTarget(device, ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); -+ list[15] = CreateOptimumColorTarget(device, ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); -+ } -+ -+ list[2] = CreateOptimumColorTarget(device, width / 2, height / 2, EnumTextureInternalFormat.Rgba8); -+ list[3] = CreateOptimumColorTarget(device, width / 2, height / 2, EnumTextureInternalFormat.Rgba8); -+ list[9] = CreateOptimumColorTarget(device, width / 4, height / 4, EnumTextureInternalFormat.Rgba8); -+ list[8] = CreateOptimumColorTarget(device, width / 4, height / 4, EnumTextureInternalFormat.Rgba8); -+ list[4] = CreateOptimumColorTarget(device, width, height, EnumTextureInternalFormat.Rgba16f); -+ list[7] = CreateOptimumColorTarget(device, width / 2, height / 2, EnumTextureInternalFormat.Rgba16f); -+ list[10] = CreateOptimumColorTarget(device, width, height, EnumTextureInternalFormat.Rgba16f); -+ -+ // Optimum: TAA history, render-resolution like Primary. Two slots so the -+ // resolve reads last frame's parity while writing this frame's; never -+ // cleared per frame (ClearFrameBuffer(Primary) only touches Primary). -+ if (taaRequested) -+ { -+ try -+ { -+ list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTarget(device, width, height); -+ list[OptimumTaaHistoryIndexB] = CreateOptimumHistoryTarget(device, width, height); -+ } -+ catch (Exception error) -+ { -+ DisableOptimumTaa("history targets (device): " + error.Message); -+ list[OptimumTaaHistoryIndexA] = null; -+ list[OptimumTaaHistoryIndexB] = null; -+ } -+ // Optimum TAA (P5): the sharpen target. Its own try - a sharpen -+ // target that cannot be allocated costs the sharpening, not TAA, -+ // so it nulls the slot instead of calling DisableOptimumTaa. -+ try -+ { -+ list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(device, width, height, -+ EnumTextureInternalFormat.Rgba16f); -+ } -+ catch (Exception error) -+ { -+ logger.Error("Optimum disabled the TAA sharpen pass: {0}", error.Message); -+ list[OptimumTaaSharpenIndex] = null; -+ } -+ } -+ optimumTaaTargetsReady = taaRequested && MotionAttachmentIndex >= 0 -+ && list[OptimumTaaHistoryIndexA] != null && list[OptimumTaaHistoryIndexB] != null; -+ InstallOptimumMotionWriteHooks(); -+ -+ // FSR renders at a reduced scale and resolves into a native-sized target. -+ if (ClientSettings.OptimumRenderScale < 1.0f) -+ { -+ list[OptimumFsrFramebufferIndex] = CreateOptimumColorTarget(device, -+ ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y, -+ EnumTextureInternalFormat.Rgba8); -+ } -+ -+ list[5] = CreateOptimumDepthTarget(device, width / 4, height / 4); -+ -+ // Both shadow slots always hold a FrameBufferRef, exactly as the GL path -+ // does: vanilla constructs the objects unconditionally and only allocates -+ // their textures when the quality setting reaches each level. -+ // -+ // The distinction matters because ShaderProgramBase.Use dereferences both -+ // FrameBuffers[11] and FrameBuffers[12] whenever shadowmapQuality > 0, -+ // and every shader including fogandlight.fsh - sky.fsh among them - takes -+ // that branch. Leaving slot 12 null at quality 1 is a null reference on -+ // the first sky draw, which is what it was. -+ int shadowSize = Math.Max(4, ShadowMapQuality + 2) * 1024; -+ list[11] = ShadowMapQuality > 0 -+ ? CreateOptimumDepthTarget(device, shadowSize, shadowSize) -+ : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); -+ list[12] = ShadowMapQuality > 1 -+ ? CreateOptimumDepthTarget(device, shadowSize, shadowSize) -+ : CreateOptimumPlaceholderTarget(shadowSize, shadowSize); -+ -+ for (int shadow = 11; shadow <= 12; shadow++) -+ { -+ int textureId = list[shadow].DepthTextureId; -+ if (textureId == 0) continue; -+ SetupOptimumTextureSampler(device, textureId, 9729, 33069); -+ device.SetTextureBorderColor(textureId, 1f, 1f, 1f, 1f); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, OptimumGlConstants.CompareRefToTexture); -+ } -+ -+ // The fullscreen quad. The device generates its three vertices in the -+ // shader and binds nothing, but the field is public enough that other -+ // code holds it, so it is kept in step with the GL path. -+ MeshData quadData = QuadMeshUtil.GetCustomQuadModelData(-1f, -1f, 0f, 2f, 2f); -+ quadData.Normals = null; -+ quadData.Rgba = null; -+ quadData.Uv = null; -+ if (screenQuad != null) -+ { -+ screenQuad.Dispose(); -+ } -+ screenQuad = UploadMesh(quadData); -+ -+ CurrentFrameBufferKeepVw = (OffscreenBuffer ? list[0] : null); -+ // Optimum TAA: the history slots this build just allocated hold -+ // undefined contents, and the old ones are about to be disposed. Both -+ // the local flag and the temporal contract have to know, because the -+ // flag only guards our own resolve while the reset reason is what every -+ // other temporal consumer (vendor upscalers later) reads. -+ _taaHistoryValid = false; -+ OptimumTemporal.RequestReset(EnumTemporalResetReason.Resize); -+ logger.Notification("(Re-)loaded frame buffers on the Optimum device"); -+ return list; -+ } -+ -+ /// Optimum: a single-colour-attachment target, as the post chain uses. -+ /// -+ /// Optimum: the device-path body of . -+ /// -+ /// Split out for the same reason as SetupOptimumFrameBuffers - the GL body -+ /// interleaves texture creation with framebuffer attachment through the bound -+ /// texture unit, and there is no seam to route call-by-call. The attachment -+ /// order is preserved because the draw-buffer mask is positional: bit N means -+ /// ColorAttachmentN, and the shaders' output locations depend on it. -+ /// -+ private FrameBufferRef CreateOptimumFramebuffer( -+ Vintagestory.API.Config.IOptimumGraphicsDevice device, FramebufferAttrs fbAttrs) -+ { -+ FrameBufferRef target = new FrameBufferRef(); -+ target.Width = fbAttrs.Width; -+ target.Height = fbAttrs.Height; -+ target.FboId = device.CreateFramebuffer(fbAttrs.Width, fbAttrs.Height); -+ -+ List colorTextureIds = new List(); -+ int drawBufferMask = 0; -+ FramebufferAttrsAttachment[] attachments = fbAttrs.Attachments; -+ for (int i = 0; i < attachments.Length; i++) -+ { -+ FramebufferAttrsAttachment attachment = attachments[i]; -+ RawTexture texture = attachment.Texture; -+ int textureId = texture.TextureId; -+ if (textureId == 0) -+ { -+ textureId = device.CreateTexture2D(texture.Width, texture.Height, -+ texture.PixelInternalFormat, texture.PixelFormat, IntPtr.Zero, false); -+ // EnumTextureFilter and EnumTextureWrap carry the GL token values, -+ // which is exactly what SetTextureParameter expects. -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, (int)texture.MinFilter); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, (int)texture.MagFilter); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapS, (int)texture.WrapS); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, (int)texture.WrapT); -+ texture.TextureId = textureId; -+ } -+ device.AttachTexture(target.FboId, attachment.AttachmentType, textureId, 0); -+ if (attachment.AttachmentType == EnumFramebufferAttachment.DepthAttachment) -+ { -+ target.DepthTextureId = textureId; -+ } -+ else -+ { -+ colorTextureIds.Add(textureId); -+ drawBufferMask |= 1 << ((int)attachment.AttachmentType - (int)EnumFramebufferAttachment.ColorAttachment0); -+ } -+ } -+ -+ target.ColorTextureIds = colorTextureIds.ToArray(); -+ device.SetDrawBuffers(target.FboId, drawBufferMask); -+ -+ string status; -+ if (!device.CheckFramebufferComplete(target.FboId, out status)) -+ { -+ throw new Exception("FBO " + fbAttrs.Name + ": " + status); -+ } -+ return target; -+ } -+ -+ /// Mirror the GL framebuffer texture's filtering and edge policy. -+ private void SetupOptimumTextureSampler( -+ Vintagestory.API.Config.IOptimumGraphicsDevice device, int textureId, int filter, int wrap) -+ { -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, filter); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, filter); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapS, wrap); -+ device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, wrap); -+ } -+ -+ private FrameBufferRef CreateOptimumColorTarget( -+ Vintagestory.API.Config.IOptimumGraphicsDevice device, int width, int height, -+ EnumTextureInternalFormat format) -+ { -+ FrameBufferRef target = new FrameBufferRef(); -+ target.Width = width; -+ target.Height = height; -+ target.FboId = device.CreateFramebuffer(width, height); -+ target.ColorTextureIds = new int[1]; -+ target.ColorTextureIds[0] = device.CreateTexture2D(width, height, format, -+ EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ // setupAttachment uses linear filtering and edge clamping. FXAA and -+ // the reduced-resolution blur passes require fractional texel samples. -+ SetupOptimumTextureSampler(device, target.ColorTextureIds[0], 9729, 33071); -+ device.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); -+ device.SetDrawBuffers(target.FboId, 1); -+ return target; -+ } -+ -+ /// Optimum: a depth-only target, as the shadow maps and liquid depth use. -+ private FrameBufferRef CreateOptimumDepthTarget( -+ Vintagestory.API.Config.IOptimumGraphicsDevice device, int width, int height) -+ { -+ FrameBufferRef target = new FrameBufferRef(); -+ target.Width = width; -+ target.Height = height; -+ target.FboId = device.CreateFramebuffer(width, height); -+ target.ColorTextureIds = new int[0]; -+ target.DepthTextureId = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); -+ SetupOptimumTextureSampler(device, target.DepthTextureId, 9729, 33071); -+ device.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0); -+ device.SetDrawBuffers(target.FboId, 0); -+ return target; -+ } -+ -+ /// -+ /// Optimum: a TAA history slot - colour (RGBA16F), aux (RGBA8: glow.rg, -+ /// ssao.b) and linear depth (R32F), MRT-written by the resolve pass and -+ /// read back next frame. R32F has no -+ /// entry, so it goes through CreateTexture2DRaw with the raw GL token, -+ /// the same way the SSAO noise texture does above. -+ /// -+ private FrameBufferRef CreateOptimumHistoryTarget( -+ Vintagestory.API.Config.IOptimumGraphicsDevice device, int width, int height) -+ { -+ FrameBufferRef target = new FrameBufferRef(); -+ target.Width = width; -+ target.Height = height; -+ target.FboId = device.CreateFramebuffer(width, height); -+ target.ColorTextureIds = new int[3]; -+ target.ColorTextureIds[0] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ target.ColorTextureIds[1] = device.CreateTexture2D(width, height, -+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); -+ target.ColorTextureIds[2] = device.CreateTexture2DRaw(width, height, OptimumGlR32f, IntPtr.Zero, 4); -+ // Optimum TAA: the resolve reprojects the history by a fractional pixel -+ // offset, so colour (Catmull-Rom taps) and glow (a plain bilinear fetch) -+ // must filter LINEAR; sampling them NEAREST snaps the reprojection to -+ // whole pixels and the history never converges. Linear depth stays -+ // NEAREST - interpolating across a silhouette invents a depth that is on -+ // neither surface and defeats the disocclusion test. Clamp to edge on -+ // all three, matching CreateOptimumHistoryTargetGl. -+ SetupOptimumTextureSampler(device, target.ColorTextureIds[0], 9729, 33071); -+ SetupOptimumTextureSampler(device, target.ColorTextureIds[1], 9729, 33071); -+ SetupOptimumTextureSampler(device, target.ColorTextureIds[2], 9728, 33071); -+ for (int attachment = 0; attachment < 3; attachment++) -+ { -+ device.AttachTexture(target.FboId, -+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), -+ target.ColorTextureIds[attachment], 0); -+ } -+ device.SetDrawBuffers(target.FboId, 7); -+ if (!device.CheckFramebufferComplete(target.FboId, out string status)) -+ { -+ throw new Exception("Optimum TAA history FBO: " + status); -+ } -+ return target; -+ } -+ -+ /// -+ /// A framebuffer slot that exists but owns nothing, for a quality level whose -+ /// resources are not allocated. -+ /// -+ /// The GL path leaves such a slot holding a FrameBufferRef whose ids are zero; -+ /// callers read its Width and Height and bind its texture id, and binding zero -+ /// is a no-op there. The device treats texture 0 as unbound and substitutes -+ /// its placeholder, so the same read is equally harmless here. -+ /// -+ private FrameBufferRef CreateOptimumPlaceholderTarget(int width, int height) -+ { -+ FrameBufferRef target = new FrameBufferRef(); -+ target.Width = width; -+ target.Height = height; -+ target.ColorTextureIds = new int[0]; -+ return target; -+ } -+ -+ /// + /// Optimum: frames rendered while the player is in the world, counted for the + /// parity dump. No initializer: an injected field starts at the CLR default. + /// @@ -1367,19 +874,81 @@ index 6edf0c9..9cc335b 100644 + return readback; + } + -+ public virtual List SetupDefaultFrameBuffers() - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumSetupDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumSetupDevice != null) ++ /// ++ /// Optimum (Vulkan-native plan, Phase 1A step 4): the platform state ++ /// VulkanClientPlatform.SetupDefaultFrameBuffers reads and writes at the points the ++ /// device-path setup used to touch these private fields directly. Called by nothing ++ /// on the OpenGL path. ++ /// ++ public void OptimumAdoptFrameBufferSettings() ++ { ++ SetupSSAO = ClientSettings.SSAOQuality > 0; ++ ShadowMapQuality = ClientSettings.ShadowMapQuality; ++ ssaaLevel = ClientSettings.SSAA; ++ } ++ ++ /// ++ /// Optimum TAA: read once per (re-)build; a mid-session config change only takes ++ /// effect on the next RebuildFrameBuffers. optimumTaaDisabled is checked as well as ++ /// EffectiveTaa: DisableOptimumTaa sets both, and the local flag keeps this platform ++ /// from retrying a failed allocation even if the process-wide config flag is ever reset. ++ /// ++ public bool OptimumTaaRequested => !optimumTaaDisabled && Vintagestory.API.Config.OptimumConfig.EffectiveTaa; ++ ++ /// Optimum: the SSAO sample kernel the setup fills and the SSAO pass uploads. ++ public float[] OptimumSsaoKernel => ssaoKernel; ++ ++ /// Optimum TAA: publishes Primary's motion attachment index as soon as Primary exists. ++ public void SetOptimumMotionAttachmentIndex(int index) ++ { ++ optimumMotionAttachmentIndex = index; ++ } ++ ++ /// Optimum TAA: the targets are ready once the motion attachment and both history slots exist. ++ public void OptimumAdoptTaaTargets(List list, bool taaRequested) ++ { ++ optimumTaaTargetsReady = taaRequested && MotionAttachmentIndex >= 0 ++ && list[OptimumTaaHistoryIndexA] != null && list[OptimumTaaHistoryIndexB] != null; ++ InstallOptimumMotionWriteHooks(); ++ } ++ ++ /// ++ /// Optimum: the tail of the device-path framebuffer setup - the fullscreen quad, the ++ /// bound target, the TAA history reset and the log line. ++ /// ++ public void OptimumFinishDeviceFrameBufferSetup(List list) ++ { ++ // The fullscreen quad. The device generates its three vertices in the ++ // shader and binds nothing, but the field is public enough that other ++ // code holds it, so it is kept in step with the GL path. ++ MeshData quadData = QuadMeshUtil.GetCustomQuadModelData(-1f, -1f, 0f, 2f, 2f); ++ quadData.Normals = null; ++ quadData.Rgba = null; ++ quadData.Uv = null; ++ if (screenQuad != null) + { -+ return SetupOptimumFrameBuffers(optimumSetupDevice); ++ screenQuad.Dispose(); + } ++ screenQuad = UploadMesh(quadData); ++ ++ CurrentFrameBufferKeepVw = (OffscreenBuffer ? list[0] : null); ++ // Optimum TAA: the history slots this build just allocated hold ++ // undefined contents, and the old ones are about to be disposed. Both ++ // the local flag and the temporal contract have to know, because the ++ // flag only guards our own resolve while the reset reason is what every ++ // other temporal consumer (vendor upscalers later) reads. ++ _taaHistoryValid = false; ++ OptimumTemporal.RequestReset(EnumTemporalResetReason.Resize); ++ logger.Notification("(Re-)loaded frame buffers on the Optimum device"); ++ } ++ ++ public virtual List SetupDefaultFrameBuffers() + { //IL_001b: Unknown result type (might be due to invalid IL or missing references) //IL_0072: Unknown result type (might be due to invalid IL or missing references) //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) - //IL_0211: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2314,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +1881,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -1397,7 +966,7 @@ index 6edf0c9..9cc335b 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +2344,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +1911,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -1414,14 +983,12 @@ index 6edf0c9..9cc335b 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,12 +2389,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +1956,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; GL.DrawBuffers(2, array3); } -- CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); -- frameBufferRef = (list[1] = new FrameBufferRef + if (taaRequested) + { + // Optimum: TAA motion attachment, appended after the SSAO G-buffer @@ -1450,14 +1017,12 @@ index 6edf0c9..9cc335b 100644 + } + } + optimumMotionAttachmentIndex = motionAttachmentIndex; -+ CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); -+ frameBufferRef = (list[1] = new FrameBufferRef + CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); + frameBufferRef = (list[1] = new FrameBufferRef { FboId = GL.GenFramebuffer(), Width = num, - Height = num2 - }); -@@ -1436,10 +2602,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2169,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1533,7 +1098,7 @@ index 6edf0c9..9cc335b 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2779,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2346,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1548,7 +1113,7 @@ index 6edf0c9..9cc335b 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2802,115 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2369,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1562,7 +1127,7 @@ index 6edf0c9..9cc335b 100644 + /// member for it) attachments. + /// + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) - { ++ { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1614,37 +1179,13 @@ index 6edf0c9..9cc335b 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) -+ { + { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. + // Deleting it twice double-frees on the device path and makes + // VulkanStats.NoteTextureDeleted over-count, so every handle is deleted once. + HashSet deletedTextures = new HashSet(); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ for (int k = 0; k < buffers.Count; k++) -+ { -+ if (buffers[k] != null) -+ { -+ optimumDevice.DeleteFramebuffer(buffers[k].FboId); -+ if (deletedTextures.Add(buffers[k].DepthTextureId)) -+ { -+ optimumDevice.DeleteTexture(buffers[k].DepthTextureId); -+ } -+ for (int n = 0; n < buffers[k].ColorTextureIds.Length; n++) -+ { -+ if (deletedTextures.Add(buffers[k].ColorTextureIds[n])) -+ { -+ optimumDevice.DeleteTexture(buffers[k].ColorTextureIds[n]); -+ } -+ } -+ buffers[k].Disposed = true; -+ } -+ } -+ return; -+ } for (int i = 0; i < buffers.Count; i++) { if (buffers[i] != null) @@ -1667,7 +1208,7 @@ index 6edf0c9..9cc335b 100644 } } } -@@ -1591,11 +2920,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2463,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1675,125 +1216,66 @@ index 6edf0c9..9cc335b 100644 { + // Mono.Cecil transplant. CurrentFrameBufferKeepVw = framebuffer; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (clearColorBuffers) -+ { -+ for (int k = 0; k < framebuffer.ColorTextureIds.Length; k++) -+ { -+ optimumDevice.ClearColor(k, clearColor[0], clearColor[1], clearColor[2], clearColor[3]); -+ } -+ } -+ if (clearDepthBuffer) -+ { -+ optimumDevice.ClearDepth(1f); -+ } -+ return; -+ } ++ ClearBoundFrameBuffer(framebuffer, clearColor, clearDepthBuffer, clearColorBuffers); ++ } ++ ++ /// Optimum (Phase 1A step 4): the GL half of ClearFrameBuffer(FrameBufferRef, float[], bool, bool). ++ public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] clearColor, bool clearDepthBuffer, bool clearColorBuffers) ++ { if (clearColorBuffers) { for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1608,38 +2954,135 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - } - - public override void LoadFrameBuffer(FrameBufferRef frameBuffer, int textureId) - { -+ // Mono.Cecil transplant. -+ // Swaps the colour attachment on an already-created target, which mods -+ // use to render into a texture they own. - CurrentFrameBuffer = frameBuffer; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.AttachTexture(frameBuffer.FboId, EnumFramebufferAttachment.ColorAttachment0, textureId, 0); -+ return; -+ } - GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, textureId, 0); - } - - public override void UnloadFrameBuffer(FrameBufferRef frameBuffer) - { +@@ -1619,27 +2498,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } public override void ClearFrameBuffer(EnumFrameBuffer framebuffer) { + // Mono.Cecil transplant. -+ // Same clear values per pass as the GL body. Default clears the swapchain -+ // image with the colour GlClearColorRgbaf recorded, since the device has -+ // no GL clear-colour state of its own. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ switch (framebuffer) -+ { -+ case EnumFrameBuffer.Default: -+ CurrentFrameBufferKeepVw = null; -+ optimumDevice.ClearColor(0, optimumClearR, optimumClearG, optimumClearB, optimumClearA); -+ optimumDevice.ClearDepth(1f); -+ CurrentFrameBufferKeepVw = frameBuffers[0]; -+ break; -+ case EnumFrameBuffer.Primary: -+ // Optimum TAA (P3): the frame starts with no motion window open. -+ // Primary is cleared exactly once per frame (ScreenManager), and -+ // the draw-buffer set is restored below, so this makes a pass that -+ // failed to reach EndMotionWrite heal at the next frame instead of -+ // leaving the attachment enabled for every later pass. -+ optimumMotionWriteActive = false; -+ optimumDevice.ClearColor(0, 0f, 0f, 0f, 1f); -+ optimumDevice.ClearColor(1, 0f, 0f, 0f, 1f); -+ if (RenderSSAO) -+ { -+ optimumDevice.ClearColor(2, 0f, 0f, 0f, 1f); -+ optimumDevice.ClearColor(3, 0f, 0f, 0f, 1f); -+ } -+ if (MotionAttachmentIndex >= 0) -+ { -+ // ClearColor honours the draw-buffer mask on the device too. -+ // Motion is excluded until a writer opts in, so temporarily -+ // enable it just as the GL branch does below. Otherwise stale -+ // motion/reactivity survives and can reject all TAA history. -+ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); -+ optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f); -+ optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); -+ } -+ optimumDevice.ClearDepth(1f); -+ break; -+ case EnumFrameBuffer.LiquidDepth: -+ case EnumFrameBuffer.ShadowmapFar: -+ case EnumFrameBuffer.ShadowmapNear: -+ { -+ FrameBufferRef optimumTarget = FrameBuffers[(int)framebuffer]; -+ optimumDevice.SetViewport(0, 0, optimumTarget.Width, optimumTarget.Height); -+ optimumDevice.ClearDepth(1f); -+ break; -+ } -+ case EnumFrameBuffer.Transparent: -+ // Weighted-blended OIT: accumulation starts at zero, revealage at -+ // one, and the third attachment is the opaque-depth copy. -+ optimumDevice.ClearColor(0, 0f, 0f, 0f, 0f); -+ optimumDevice.ClearColor(1, 1f, 0f, 0f, 0f); -+ optimumDevice.ClearColor(2, 0f, 0f, 0f, 0f); -+ break; -+ } -+ return; -+ } ++ // The clears themselves are ClearFrameBufferPass (GL below, the device in ++ // VulkanClientPlatform); the target selection and the TAA window state stay here. switch (framebuffer) { case EnumFrameBuffer.Default: CurrentFrameBufferKeepVw = null; ++ ClearFrameBufferPass(framebuffer); ++ CurrentFrameBufferKeepVw = frameBuffers[0]; ++ break; ++ case EnumFrameBuffer.Primary: ++ // Optimum TAA (P3): the frame starts with no motion window open. ++ // Primary is cleared exactly once per frame (ScreenManager), and ++ // the draw-buffer set is restored by the clear, so this makes a pass ++ // that failed to reach EndMotionWrite heal at the next frame instead of ++ // leaving the attachment enabled for every later pass. ++ optimumMotionWriteActive = false; ++ ClearFrameBufferPass(framebuffer); ++ break; ++ case EnumFrameBuffer.LiquidDepth: ++ case EnumFrameBuffer.ShadowmapFar: ++ case EnumFrameBuffer.ShadowmapNear: ++ case EnumFrameBuffer.Transparent: ++ ClearFrameBufferPass(framebuffer); ++ break; ++ } ++ } ++ ++ /// ++ /// Optimum (Phase 1A step 4): the GL calls of , ++ /// per pass. The target is already bound by the caller. ++ /// ++ public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) ++ { ++ switch (framebuffer) ++ { ++ case EnumFrameBuffer.Default: GL.DrawBuffer((DrawBufferMode)1029); GL.Clear((ClearBufferMask)16640); - CurrentFrameBufferKeepVw = frameBuffers[0]; +- CurrentFrameBufferKeepVw = frameBuffers[0]; break; case EnumFrameBuffer.Primary: { -+ // Optimum TAA (P3): see the device branch above. -+ optimumMotionWriteActive = false; GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 0f, 0f, 0f, 1f }); GL.ClearBuffer((ClearBuffer)6144, 1, new float[4] { 0f, 0f, 0f, 1f }); if (RenderSSAO) @@ -1832,18 +1314,17 @@ index 6edf0c9..9cc335b 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,20 +3113,40 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +2607,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) //IL_0351: Unknown result type (might be due to invalid IL or missing references) //IL_036a: Unknown result type (might be due to invalid IL or missing references) + // Mono.Cecil transplant. -+ // Viewport, blend and draw-buffer selection go through the routed helpers -+ // so both backends share this switch. Only the calls with no routed -+ // equivalent - the multi-attachment blend setup for OIT and the -+ // GL_BACK draw-buffer selection - branch on the device. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ // Viewport, blend and draw-buffer selection go through platform virtuals ++ // so both backends share this switch: the multi-attachment blend setup for ++ // OIT, the GL_BACK draw-buffer selection and the Luma blend toggle are ++ // overridden by VulkanClientPlatform. switch (framebuffer) { case EnumFrameBuffer.Transparent: @@ -1854,27 +1335,16 @@ index 6edf0c9..9cc335b 100644 GlDepthMask(flag: false); GlEnableDepthTest(); ScreenManager.FrameProfiler.Mark("rendTransp-dbset"); -+ if (optimumDevice != null) -+ { -+ // Weighted-blended OIT: accumulation adds, revealage multiplies, -+ // and the third attachment uses ordinary source-alpha blending. -+ optimumDevice.SetDrawBuffers(frameBuffers[1].FboId, 7); -+ optimumDevice.SetBlend(true, EnumBlendMode.Standard); -+ optimumDevice.SetBlendEquation(0, 32774); -+ optimumDevice.SetBlendFuncSeparate(0, 1, 1, 1, 1); -+ optimumDevice.SetBlendEquation(1, 32774); -+ optimumDevice.SetBlendFuncSeparate(1, 0, 769, 0, 769); -+ optimumDevice.SetBlendEquation(2, 32774); -+ optimumDevice.SetBlendFuncSeparate(2, 770, 771, 770, 771); -+ break; -+ } - DrawBuffersEnum[] array2 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; - GL.DrawBuffers(3, array2); - GL.Enable((EnableCap)3042); - GL.BlendEquation(0, (BlendEquationMode)32774); - GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -@@ -1693,48 +3156,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); +- DrawBuffersEnum[] array2 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; +- GL.DrawBuffers(3, array2); +- GL.Enable((EnableCap)3042); +- GL.BlendEquation(0, (BlendEquationMode)32774); +- GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); +- GL.BlendEquation(1, (BlendEquationMode)32774); +- GL.BlendFunc(1, (BlendingFactorSrc)0, (BlendingFactorDest)769); +- GL.BlendEquation(2, (BlendEquationMode)32774); +- GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); ++ ApplyTransparentPassBlendState(); break; } case EnumFrameBuffer.Default: @@ -1882,10 +1352,7 @@ index 6edf0c9..9cc335b 100644 - GL.Viewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); - GL.DrawBuffer((DrawBufferMode)1029); + GlViewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); -+ if (optimumDevice == null) -+ { -+ GL.DrawBuffer((DrawBufferMode)1029); -+ } ++ SelectBackDrawBuffer(); break; case EnumFrameBuffer.BlurHorizontalMedRes: case EnumFrameBuffer.BlurVerticalMedRes: @@ -1920,14 +1387,7 @@ index 6edf0c9..9cc335b 100644 break; case EnumFrameBuffer.Luma: - GL.Disable((EnableCap)3042); -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetBlendEnabled(false); -+ } -+ else -+ { -+ GL.Disable((EnableCap)3042); -+ } ++ SetBlendEnabled(false); CurrentFrameBufferKeepVw = frameBuffers[(int)framebuffer]; break; case EnumFrameBuffer.SSAO: @@ -1940,7 +1400,7 @@ index 6edf0c9..9cc335b 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,16 +3226,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +2687,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1953,16 +1413,58 @@ index 6edf0c9..9cc335b 100644 { CurrentFrameBufferKeepVw = null; - GL.DrawBuffer((DrawBufferMode)1029); -+ if (optimumDevice == null) -+ { -+ GL.DrawBuffer((DrawBufferMode)1029); -+ } ++ SelectBackDrawBuffer(); } break; case (EnumFrameBuffer)6: break; } -@@ -1774,18 +3250,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + } + ++ /// ++ /// Optimum (Phase 1A step 4): the GL half of LoadFrameBuffer(Transparent) - ++ /// weighted-blended OIT: accumulation adds, revealage multiplies, and the third ++ /// attachment uses ordinary source-alpha blending. ++ /// ++ public override void ApplyTransparentPassBlendState() ++ { ++ DrawBuffersEnum[] array2 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; ++ GL.DrawBuffers(3, array2); ++ GL.Enable((EnableCap)3042); ++ GL.BlendEquation(0, (BlendEquationMode)32774); ++ GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); ++ GL.BlendEquation(1, (BlendEquationMode)32774); ++ GL.BlendFunc(1, (BlendingFactorSrc)0, (BlendingFactorDest)769); ++ GL.BlendEquation(2, (BlendEquationMode)32774); ++ GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); ++ } ++ ++ /// ++ /// Optimum (Phase 1A step 4): selecting GL_BACK on the default framebuffer. ++ /// VulkanClientPlatform has nothing to do: binding the default target already ++ /// means the swapchain image. ++ /// ++ public override void SelectBackDrawBuffer() ++ { ++ GL.DrawBuffer((DrawBufferMode)1029); ++ } ++ ++ /// Optimum (Phase 1A step 4): glEnable/glDisable(GL_BLEND) without touching the mode. ++ public override void SetBlendEnabled(bool enabled) ++ { ++ if (enabled) ++ { ++ GL.Enable((EnableCap)3042); ++ } ++ else ++ { ++ GL.Disable((EnableCap)3042); ++ } ++ } ++ + public override void UnloadFrameBuffer(EnumFrameBuffer framebuffer) + { + //IL_0013: Unknown result type (might be due to invalid IL or missing references) //IL_002c: Unknown result type (might be due to invalid IL or missing references) if (framebuffer == EnumFrameBuffer.Transparent) { @@ -1978,18 +1480,13 @@ index 6edf0c9..9cc335b 100644 } CurrentFrameBufferKeepVw = null; - GL.DrawBuffer((DrawBufferMode)1029); -+ // Selecting GL_BACK has no device equivalent: binding the default target -+ // already means the swapchain image. -+ if (Vintagestory.API.Config.OptimumRender.Device == null) -+ { -+ GL.DrawBuffer((DrawBufferMode)1029); -+ } ++ SelectBackDrawBuffer(); } public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3275,262 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +2769,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1997,29 +1494,12 @@ index 6edf0c9..9cc335b 100644 CurrentFrameBufferKeepVw = null; - GL.DrawBuffer((DrawBufferMode)1029); + // Mono.Cecil transplant. -+ // Selecting GL_BACK has no device counterpart - the default target is -+ // already the swapchain image. -+ if (Vintagestory.API.Config.OptimumRender.Device == null) -+ { -+ GL.DrawBuffer((DrawBufferMode)1029); -+ } -+ } -+ // The three state calls are spelled out on the device rather than routed -+ // through GlToggleBlend, because that helper also overrides the SSAO -+ // attachments and this pass deliberately sets only the global mode. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumMergeDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumMergeDevice != null) -+ { -+ optimumMergeDevice.SetDepthTest(false); -+ optimumMergeDevice.SetBlend(true, EnumBlendMode.Standard); -+ optimumMergeDevice.SetBlendFuncSeparate(0, 770, 771, 770, 771); -+ } -+ else -+ { -+ GL.Disable((EnableCap)2929); -+ GL.Enable((EnableCap)3042); -+ GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); ++ SelectBackDrawBuffer(); + } ++ // The three state calls are spelled out rather than routed through ++ // GlToggleBlend, because that helper also overrides the SSAO attachments ++ // and this pass deliberately sets only the global mode. ++ ApplyTransparentMergeBlendState(); + // Optimum TAA (P4): everything drawn into the Transparent target - quad + // particles, OIT entities, liquid shading, the cloud layers - writes six + // oit.fsh outputs and cannot reach Primary's motion attachment. The plan @@ -2059,6 +1539,17 @@ index 6edf0c9..9cc335b 100644 + } + + /// ++ /// Optimum (Phase 1A step 4): the GL half of the OIT merge's state - depth test off, ++ /// blending on with the global source-alpha mode. ++ /// ++ public override void ApplyTransparentMergeBlendState() ++ { ++ GL.Disable((EnableCap)2929); ++ GL.Enable((EnableCap)3042); ++ GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); ++ } ++ ++ /// + /// Optimum TAA (P4): additive blending on the motion attachment alone, for + /// the one pass that contributes to a channel of it rather than owning the + /// pixel - the OIT merge, which adds the transparent layer's coverage into @@ -2256,7 +1747,7 @@ index 6edf0c9..9cc335b 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3545,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3033,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2264,9 +1755,9 @@ index 6edf0c9..9cc335b 100644 } + // Mono.Cecil transplant. + // The pass structure is API-neutral - it is framebuffer selection, a -+ // fullscreen triangle and uniforms, all of which route already. Only the -+ // handful of direct GL calls left in this body branch on the device. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumPostDevice = Vintagestory.API.Config.OptimumRender.Device; ++ // fullscreen triangle and uniforms, all of which are platform virtuals. ++ // The SSAO clear and the final blend enable are too (ClearSsaoTarget, ++ // SetBlendEnabled). int x = ((NativeWindow)window).ClientSize.X; int y = ((NativeWindow)window).ClientSize.Y; + // Optimum TAA: resolve first, so bloom, god rays and the final input read @@ -2293,7 +1784,7 @@ index 6edf0c9..9cc335b 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3584,55 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3072,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2342,20 +1833,13 @@ index 6edf0c9..9cc335b 100644 GlToggleBlend(on: false); LoadFrameBuffer(EnumFrameBuffer.SSAO); - GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); -+ if (optimumPostDevice != null) -+ { -+ optimumPostDevice.ClearColor(0, 1f, 1f, 1f, 1f); -+ } -+ else -+ { -+ GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); -+ } ++ ClearSsaoTarget(); ShaderProgramSsao ssao = ShaderPrograms.Ssao; ssao.Use(); ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,31 +3661,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,35 +3142,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2388,45 +1872,38 @@ index 6edf0c9..9cc335b 100644 blit.Stop(); } - GL.Enable((EnableCap)3042); -+ if (optimumPostDevice != null) -+ { -+ // Re-enabling blend only; the mode is whatever the last GlToggleBlend -+ // left, which is what glEnable(GL_BLEND) does here too. -+ optimumPostDevice.SetBlendEnabled(true); -+ } -+ else -+ { -+ GL.Enable((EnableCap)3042); -+ } ++ // Re-enabling blend only; the mode is whatever the last GlToggleBlend ++ // left, which is what glEnable(GL_BLEND) does here too. ++ SetBlendEnabled(true); LoadFrameBuffer(EnumFrameBuffer.Primary); ScreenManager.Platform.CheckGlError(); } ++ /// Optimum (Phase 1A step 4): the GL half of the SSAO target's white clear. ++ public override void ClearSsaoTarget() ++ { ++ GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); ++ } ++ public override void RenderFinalComposition() -@@ -1953,19 +3711,32 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + { + //IL_0149: Unknown result type (might be due to invalid IL or missing references) + //IL_0167: Unknown result type (might be due to invalid IL or missing references) + if (OffscreenBuffer) +@@ -1953,19 +3191,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { _ = frameBuffers[8].ColorTextureIds[0]; } -+ // Mono.Cecil transplant. -+ // glDrawBuffers applies to whatever framebuffer is bound, which here -+ // is Primary; the device takes the target explicitly, and its mask is -+ // positional - bit N selects ColorAttachmentN. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; - DrawBuffersEnum[] array = (DrawBuffersEnum[])(object)new DrawBuffersEnum[1] { (DrawBuffersEnum)36064 }; +- DrawBuffersEnum[] array = (DrawBuffersEnum[])(object)new DrawBuffersEnum[1] { (DrawBuffersEnum)36064 }; - GL.DrawBuffers(1, array); - GL.Disable((EnableCap)2929); -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); -+ optimumDevice.SetDepthTest(false); -+ } -+ else -+ { -+ GL.DrawBuffers(1, array); -+ GL.Disable((EnableCap)2929); -+ } ++ // Mono.Cecil transplant. ++ // The draw-buffer selection on both sides of the pass is a platform ++ // virtual: glDrawBuffers applies to whatever framebuffer is bound, which ++ // here is Primary, while the device takes the target explicitly. ++ BeginFinalCompositionDrawBuffers(); GlToggleBlend(on: true); ShaderProgramFinal final = ShaderPrograms.Final; final.Use(); @@ -2439,48 +1916,64 @@ index 6edf0c9..9cc335b 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,23 +3758,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3227,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; RenderFullscreenTriangle(screenQuad); final.Stop(); -+ // Restores the multi-attachment selection the world passes expect. - if (RenderSSAO) - { - array = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; +- if (RenderSSAO) +- { +- array = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; - GL.DrawBuffers(4, array); -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); -+ } -+ else -+ { -+ GL.DrawBuffers(4, array); -+ } - } - else +- } +- else ++ // Restores the multi-attachment selection the world passes expect. ++ RestoreWorldDrawBuffers(RenderSSAO); ++ } ++ } ++ ++ /// Optimum (Phase 1A step 4): the GL half of the final composition's pass state. ++ public override void BeginFinalCompositionDrawBuffers() ++ { ++ DrawBuffersEnum[] array = (DrawBuffersEnum[])(object)new DrawBuffersEnum[1] { (DrawBuffersEnum)36064 }; ++ GL.DrawBuffers(1, array); ++ GL.Disable((EnableCap)2929); ++ } ++ ++ /// ++ /// Optimum (Phase 1A step 4): the GL half of the draw-buffer restore after the final ++ /// composition - four attachments with the SSAO G-buffer, two without. ++ /// ++ public override void RestoreWorldDrawBuffers(bool ssaoAttachments) ++ { ++ DrawBuffersEnum[] array; ++ if (ssaoAttachments) ++ { ++ array = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; ++ GL.DrawBuffers(4, array); ++ } ++ else ++ { ++ array = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] { - array = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] - { - (DrawBuffersEnum)36064, - (DrawBuffersEnum)36065 - }; +- array = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] +- { +- (DrawBuffersEnum)36064, +- (DrawBuffersEnum)36065 +- }; - GL.DrawBuffers(2, array); -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 3); -+ } -+ else -+ { -+ GL.DrawBuffers(2, array); -+ } - } +- } ++ (DrawBuffersEnum)36064, ++ (DrawBuffersEnum)36065 ++ }; ++ GL.DrawBuffers(2, array); } } private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) -@@ -2023,27 +3809,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + { +@@ -2023,27 +3282,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2966,7 +2459,7 @@ index 6edf0c9..9cc335b 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4445,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +3918,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3006,30 +2499,7 @@ index 6edf0c9..9cc335b 100644 { GL.Disable((EnableCap)3042); } -@@ -2243,10 +4493,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - GL.Enable((EnableCap)2884); - } - - public override void GlClearColorRgbaf(float r, float g, float b, float a) - { -+ // Mono.Cecil transplant. -+ // GL keeps a clear colour in its state; the device takes it at clear time, -+ // so this only records what the next clear should use. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumClearR = r; -+ optimumClearG = g; -+ optimumClearB = b; -+ optimumClearA = a; -+ return; -+ } - GL.ClearColor(r, g, b, a); - } - - public override void GLLineWidth(float width) - { -@@ -2337,10 +4599,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2337,10 +4060,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3053,7 +2523,7 @@ index 6edf0c9..9cc335b 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4626,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2351,10 +4087,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3077,7 +2547,7 @@ index 6edf0c9..9cc335b 100644 GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4655,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2367,10 +4116,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3115,22 +2585,12 @@ index 6edf0c9..9cc335b 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2395,15 +4710,47 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2400,10 +4176,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); + } + + public override void LoadIntoTexture(IBitmap srcBmp, int targetTextureId, int destX, int destY, bool generateMipmaps = false) { - if (bmp == null) - { - throw new ArgumentNullException("bmp", "Trying to load texture from null bitmap"); - } -- return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); -- } -- -- public override void LoadIntoTexture(IBitmap srcBmp, int targetTextureId, int destX, int destY, bool generateMipmaps = false) -- { -+ return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); -+ } -+ -+ public override void LoadIntoTexture(IBitmap srcBmp, int targetTextureId, int destX, int destY, bool generateMipmaps = false) -+ { + // Mono.Cecil transplant. + Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + if (optimumDevice != null) @@ -3168,7 +2628,7 @@ index 6edf0c9..9cc335b 100644 { GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); } -@@ -2421,10 +4768,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2421,10 +4229,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3227,7 +2687,7 @@ index 6edf0c9..9cc335b 100644 if (ENABLE_ANISOTROPICFILTERING) { float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4883,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2488,10 +4344,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_00db: Unknown result type (might be due to invalid IL or missing references) if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) { @@ -3301,7 +2761,7 @@ index 6edf0c9..9cc335b 100644 if (intoTexture.TextureId != 0) { GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4981,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2523,10 +4442,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3332,7 +2792,7 @@ index 6edf0c9..9cc335b 100644 GL.BindTexture((TextureTarget)3553, textureId); int num = default(int); GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +5018,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2540,10 +4479,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -3367,7 +2827,7 @@ index 6edf0c9..9cc335b 100644 GL.BindTexture((TextureTarget)34067, num); for (int i = 0; i < 6; i++) { -@@ -2563,10 +5065,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2563,10 +4526,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); } @@ -3386,7 +2846,7 @@ index 6edf0c9..9cc335b 100644 public override int GlGetMaxTextureSize() { -@@ -2581,10 +5091,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4552,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -3412,7 +2872,7 @@ index 6edf0c9..9cc335b 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,22 +5118,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,22 +4579,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3503,7 +2963,7 @@ index 6edf0c9..9cc335b 100644 { val = (BufferAccessMask)((int)val | 0x50); } -@@ -2651,29 +5244,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4705,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3548,7 +3008,7 @@ index 6edf0c9..9cc335b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5281,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4742,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3569,7 +3029,7 @@ index 6edf0c9..9cc335b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5300,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4761,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3590,7 +3050,7 @@ index 6edf0c9..9cc335b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5319,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4780,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3611,7 +3071,7 @@ index 6edf0c9..9cc335b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5338,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4799,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3632,7 +3092,7 @@ index 6edf0c9..9cc335b 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5361,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4822,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3653,7 +3113,7 @@ index 6edf0c9..9cc335b 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +5404,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2801,10 +4865,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0199: Unknown result type (might be due to invalid IL or missing references) //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_0255: Unknown result type (might be due to invalid IL or missing references) @@ -3679,7 +3139,7 @@ index 6edf0c9..9cc335b 100644 GL.BindVertexArray(num); int xyzVboId = 0; int normalsVboId = 0; -@@ -3028,10 +5646,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3028,10 +5107,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0471: Unknown result type (might be due to invalid IL or missing references) //IL_04f9: Unknown result type (might be due to invalid IL or missing references) //IL_0529: Unknown result type (might be due to invalid IL or missing references) @@ -3701,7 +3161,7 @@ index 6edf0c9..9cc335b 100644 GL.BindVertexArray(num); int num3 = 0; int num4 = 0; -@@ -3215,10 +5844,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3215,10 +5305,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract }; } @@ -3724,7 +3184,7 @@ index 6edf0c9..9cc335b 100644 ((VAO)modelref).Dispose(); } } -@@ -3277,14 +5918,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3277,14 +5379,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract rotateUV = false; } } @@ -3773,7 +3233,7 @@ index 6edf0c9..9cc335b 100644 updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); } if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5988,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3316,20 +5449,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } if (data.CustomBytes != null && data.CustomBytes.Count > 0) { @@ -3805,7 +3265,7 @@ index 6edf0c9..9cc335b 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +6018,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3342,10 +5479,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0085: Unknown result type (might be due to invalid IL or missing references) //IL_0087: Unknown result type (might be due to invalid IL or missing references) //IL_01fc: Unknown result type (might be due to invalid IL or missing references) @@ -3831,7 +3291,7 @@ index 6edf0c9..9cc335b 100644 GL.BindVertexArray(num); int num2 = 0; int rgbaVboId = 0; -@@ -3675,15 +6366,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5827,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -4160,7 +3620,7 @@ index 6edf0c9..9cc335b 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6719,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +6180,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } From 52adef16e05cbc3d3c3cf69951ebd63166712c01 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:54:37 +0200 Subject: [PATCH 092/226] wip(phase1a-step4): meshes and textures - device bodies live in VulkanClientPlatform RenderMesh (both), RenderFullscreenTriangle, RenderMeshInstanced, UpdateMesh, AllocateEmptyMesh, UploadMesh, DeleteMesh, UpdateSSBOMesh, AllocateEmptySSBOMesh, LoadCairoTexture, GenTexture, LoadOrUpdateCairoTexture, LoadIntoTexture, LoadTexture(IBitmap), the three LoadOrUpdateTextureFrom* wrappers over the atlas upload, BuildMipMaps, Load3DTextureCube and GLDeleteTexture are overrides in VulkanClientPlatform.Meshes.cs/Textures.cs: the device branches moved unchanged, plus the API-neutral lines the base ran on both paths (draw-call accounting and the DebugDrawCalls stack list, argument checks, the main-thread check, the SSBO face packing, DrawModeToPrimiteType). Every one of those base bodies is byte-identical to vanilla again except UpdateMesh and UpdateSSBOMesh (params-span-free CheckGlError format), so the other body targets are dropped. The renderer references the vanilla OpenTK Graphics/Mathematics/Windowing assemblies compile-only. Verified: renderer + donor build 0 errors, 0 warnings; extract + check-patches 0 conflict, 0 pending. --- Optimum.Patcher/Program.cs | 27 +- .../Platform/VulkanClientPlatform.Meshes.cs | 229 ++++++ .../Platform/VulkanClientPlatform.Textures.cs | 294 ++++++++ .../ClientPlatformWindows.cs.patch | 659 ++---------------- 4 files changed, 567 insertions(+), 642 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Textures.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 2431a61b..7cc3eaa7 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -753,7 +753,6 @@ // fixed-function bodies are vanilla again (Phase 1A step 4): VulkanClientPlatform // overrides them, so they are no longer transplanted. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlToggleBlend", 2), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GLDeleteTexture", 1), // Vulkan backend: shader staging and linking. CompileShader only stages a // stage on the device path, because GL resolves uniforms and varyings by name // across the whole program and nothing is final until link time. @@ -802,17 +801,9 @@ new("Vintagestory.Client.NoObf.ShaderProgramBase", "Use", 0), new("Vintagestory.Client.NoObf.ShaderProgramBase", "Stop", 0), new("Vintagestory.Client.NoObf.ShaderProgramBase", "Dispose", 0), - // Vulkan backend: mesh allocation, upload and draw. VAO.VaoId carries the - // device's mesh handle so MeshRef, which mods hold, stays unchanged. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMesh", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFullscreenTriangle", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMesh", 5), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMeshInstanced", 2), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UploadMesh", 1), + // Mesh update: the params-span-free CheckGlError format (Cecil constraint). The device + // halves of the mesh methods live in VulkanClientPlatform (Phase 1A step 4). new("Vintagestory.Client.NoObf.ClientPlatformWindows", "UpdateMesh", 2), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "DeleteMesh", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "AllocateEmptyMesh", 12), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "AllocateEmptySSBOMesh", 12), new("Vintagestory.Client.NoObf.VAO", "Dispose", 0), // Vulkan backend: the window's own clear-and-swap has no GL binding to call // when the window was opened with no graphics API. @@ -832,20 +823,6 @@ new[] { "Vintagestory.API.Client.EnumFrameBuffer" }), // Vulkan backend: startup capability reporting, which cannot ask GL. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Start", 0), - // Vulkan backend: texture creation, upload and mipmapping. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadCairoTexture", 2), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateCairoTexture", 3), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GenTexture", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadIntoTexture", 5), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadTexture", 4, - new[] { "Vintagestory.API.Common.IBitmap", "System.Boolean", "System.Int32", "System.Boolean" }), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "BuildMipMaps", 1), - // The texture atlas upload path. Private, so it only reaches the shipped - // assembly as an explicit target - its three public wrappers delegate here - // and carry no GL of their own, which is how it was missed: nothing on the - // menu reaches it, and TextureAtlas.Upload only runs once a world loads. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "LoadOrUpdateTextureFromPixels", 6), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Load3DTextureCube", 1), // Vulkan backend: uniform buffers, whose handles UBO carries across. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateUBO", 4), new("Vintagestory.Client.NoObf.UBO", "Bind", 0), diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs new file mode 100644 index 00000000..b2e7ad05 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using OpenTK.Graphics.OpenGL; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: mesh allocation, upload, update and draw. Each body +// is the device branch that opened the same ClientPlatformWindows method, moved unchanged, +// plus the API-neutral lines around it the base method ran on both paths (draw-call +// accounting, argument checks, the SSBO face packing). +// +// On this path VAO.VaoId is the device's mesh handle rather than a GL vertex-array name. +// Reusing the field keeps MeshRef - which is public API that mods hold - unchanged. +public partial class VulkanClientPlatform +{ + private bool debugDrawCalls; + + private readonly List drawCallStacks = new List(); + + [ThreadStatic] + private static FaceData[] facedataBuffer; + + public override bool DebugDrawCalls + { + get + { + return debugDrawCalls; + } + set + { + debugDrawCalls = value; + if (!value) + { + Logger.Notification("Call stacks:"); + int num = 0; + foreach (string drawCallStack in drawCallStacks) + { + Logger.Notification("{0}: {1}", num++, drawCallStack.Substring(0, 600)); + } + } + drawCallStacks.Clear(); + } + } + + public override void RenderMesh(MeshRef modelRef) + { + RuntimeStats.drawCallsCount++; + if (debugDrawCalls) + { + drawCallStacks.Add(Environment.StackTrace); + } + VAO vAO = (VAO)modelRef; + if (vAO.VaoId == 0 || vAO.Disposed) + { + if (vAO.VaoId == 0) + { + throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); + } + throw new ArgumentException("Fatal: Trying to render a disposed mesh"); + } + device.DrawMesh(vAO.VaoId); + } + + public override void RenderFullscreenTriangle(MeshRef modelRef) + { + RuntimeStats.drawCallsCount++; + // The post passes generate their three vertices in the shader, so the + // mesh carries no buffers and none are bound. + device.DrawFullscreenTriangle(); + } + + public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSizes, int groupCount, bool useSSBOs) + { + RuntimeStats.drawCallsCount++; + VAO vAO = (VAO)modelRef; + // The chunk renderer's one multidraw per pool. GL takes byte offsets + // into the index buffer; the device converts them to index counts and + // issues a single indirect draw. + device.DrawMeshMulti(vAO.VaoId, indices, indicesSizes, groupCount, useSSBOs); + } + + public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) + { + RuntimeStats.drawCallsCount++; + VAO vAO = (VAO)modelRef; + device.DrawMeshInstanced(vAO.VaoId, quantity); + } + + public override void UpdateMesh(MeshRef modelRef, MeshData data) + { + VAO vAO = (VAO)modelRef; + device.UpdateMesh(vAO.VaoId, data); + } + + /// + /// The device allocates the per-attribute buffers and derives the vertex layout from + /// which parts are present, matching the slot ordering the GL body assigns. + /// + public override MeshRef AllocateEmptyMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) + { + VAO vAO = new VAO(); + vAO.VaoId = device.CreateEmptyMesh( + xyzSize, normalsSize, uvSize, rgbaSize, flagsSize, indicesSize, + customFloats, customShorts, customBytes, customInts, + drawMode, staticDraw, ssbo: false); + vAO.IndicesCount = indicesSize; + vAO.drawMode = DrawModeToPrimiteType(drawMode); + vAO.Persistent = !staticDraw; + return vAO; + } + + /// The device builds the buffers and returns its own handle. + public override MeshRef UploadMesh(MeshData data) + { + VAO optimumUploadVao = new VAO(); + optimumUploadVao.VaoId = device.CreateMesh(data, true); + optimumUploadVao.IndicesCount = data.IndicesCount; + optimumUploadVao.drawMode = DrawModeToPrimiteType(data.mode); + return optimumUploadVao; + } + + public override void DeleteMesh(MeshRef modelref) + { + if (modelref != null) + { + // Deferred until the GPU is done with the frame that used it; + // GL left that to the driver. + device.DeleteMesh(((VAO)modelref).VaoId); + ((VAO)modelref).Dispose(); + } + } + + /// + /// The face packing is the GL body's, unchanged; the face records go to the same + /// storage buffer, reached through the device's mesh handle instead of the buffer name. + /// + public override void UpdateSSBOMesh(MeshRef modelRef, MeshData data) + { + if (data.xyz == null) + { + return; + } + VAO vAO = (VAO)modelRef; + int verticesCount = data.VerticesCount; + if (facedataBuffer == null || facedataBuffer.Length < verticesCount / 4) + { + facedataBuffer = new FaceData[verticesCount / 4]; + } + float[] xyz = data.xyz; + float[] uv = data.Uv; + int[] flags = data.Flags; + int[] array = ((data.CustomInts != null && data.CustomInts.Count > 0) ? data.CustomInts.Values : null); + int num = ((data.CustomInts == null || data.CustomInts.Count <= 0) ? 1 : (data.CustomInts.InterleaveStride / 4)); + FaceData[] array2 = facedataBuffer; + for (int i = 0; i < verticesCount; i += 4) + { + float num2 = uv[i * 2]; + float num3 = uv[i * 2 + 1]; + float num4 = uv[i * 2 + 3]; + float num5 = uv[i * 2 + 4]; + float num6 = uv[i * 2 + 5]; + if (num2 < -1.5E-05f || num2 > 1.000015f || num3 < -1.5E-05f || num3 > 1.000015f) + { + num2 = 0f; + num3 = 0f; + } + if (num5 < -1.5E-05f || num5 > 1.000015f || num6 < -1.5E-05f || num6 > 1.000015f) + { + num5 = 0f; + num6 = 0f; + } + bool rotateUV; + if (rotateUV = num3 == num4) + { + float num7 = uv[i * 2 + 2]; + if (num5 != num7) + { + rotateUV = false; + } + } + array2[i / 4] = new FaceData(xyz, i * 3, num2, num3, num5 - num2, num6 - num3, flags, i, (array != null) ? array[i * num] : 0, rotateUV); + } + int num8 = data.XyzOffset / 12 * 16; + // Everything the mesh carries as ordinary vertex data goes first, the + // same call UpdateMesh makes. The packed face records follow, because + // they occupy the xyz slot and have to be what remains there. + device.UpdateMesh(vAO.VaoId, data); + + GCHandle optimumFacePin = GCHandle.Alloc(facedataBuffer, GCHandleType.Pinned); + try + { + device.UpdateMeshStorageBuffer(vAO.VaoId, + optimumFacePin.AddrOfPinnedObject(), num8, 16 * verticesCount); + } + finally + { + optimumFacePin.Free(); + } + } + + public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) + { + VAO vAO = new VAO(); + vAO.VaoId = device.CreateEmptyMesh( + xyzSize, normalsSize, uvSize, rgbaSize, flagsSize, indicesSize, + customFloats, customShorts, customBytes, customInts, + drawMode, staticDraw, ssbo: true); + vAO.IndicesCount = indicesSize; + vAO.drawMode = DrawModeToPrimiteType(drawMode); + vAO.Persistent = !staticDraw; + return vAO; + } + + /// ClientPlatformWindows.DrawModeToPrimiteType, which is private. + private static PrimitiveType DrawModeToPrimiteType(EnumDrawMode drawmode) + { + return (PrimitiveType)(drawmode switch + { + EnumDrawMode.Lines => 1, + EnumDrawMode.LineStrip => 3, + _ => 4, + }); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Textures.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Textures.cs new file mode 100644 index 00000000..87e1c739 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Textures.cs @@ -0,0 +1,294 @@ +using System; +using System.Runtime.InteropServices; +using Cairo; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; +using Vintagestory.ClientNative; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: texture creation, upload and mipmapping. Each body is +// the device branch that opened the same ClientPlatformWindows method, moved unchanged, +// behind the same main-thread check. +public partial class VulkanClientPlatform +{ + private const string MainThreadOnly = "Texture uploads must happen in the main thread. We only have one OpenGL context."; + + /// + /// Cairo hands over premultiplied BGRA bytes, which is why the GL body asks for GL_BGRA + /// rather than GL_RGBA; CreateTexture2DRaw takes the same GL internal format token so + /// the device makes the identical image. + /// + public override int LoadCairoTexture(ImageSurface surface, bool linearMag) + { + if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) + { + throw new InvalidOperationException(MainThreadOnly); + } + int optimumTextureId = device.CreateTexture2DRaw(surface.Width, surface.Height, + OptimumGlConstants.Bgra, surface.DataPtr, 4); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMinFilter, 9729); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); + return optimumTextureId; + } + + public override void GenTexture(RawTexture tex) + { + if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) + { + throw new InvalidOperationException(MainThreadOnly); + } + int optimumTextureId = device.CreateTexture2D(tex.Width, tex.Height, + tex.PixelInternalFormat, tex.PixelFormat, IntPtr.Zero, false); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMinFilter, (int)tex.MinFilter); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMagFilter, (int)tex.MagFilter); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapS, (int)tex.WrapS); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapT, (int)tex.WrapT); + tex.TextureId = optimumTextureId; + } + + public override void LoadOrUpdateCairoTexture(ImageSurface surface, bool linearMag, ref LoadedTexture intoTexture) + { + if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) + { + throw new InvalidOperationException(MainThreadOnly); + } + if (intoTexture.TextureId == 0 || intoTexture.Width != surface.Width || intoTexture.Height != surface.Height) + { + if (intoTexture.TextureId != 0) + { + device.DeleteTexture(intoTexture.TextureId); + } + intoTexture.TextureId = device.CreateTexture2DRaw(surface.Width, surface.Height, + OptimumGlConstants.Bgra, surface.DataPtr, 4); + intoTexture.Width = surface.Width; + intoTexture.Height = surface.Height; + device.SetTextureParameter(intoTexture.TextureId, OptimumGlConstants.TextureMinFilter, 9729); + device.SetTextureParameter(intoTexture.TextureId, OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); + } + else + { + // The image is BGRA-ordered; the upload is a byte copy at four + // bytes per pixel, which is what Rgba selects here. + device.UploadTexture2D(intoTexture.TextureId, 0, 0, 0, + surface.Width, surface.Height, EnumTexturePixelFormat.Rgba, surface.DataPtr); + } + CheckGlError("LoadOrUpdateCairoTexture"); + } + + public override unsafe void LoadIntoTexture(IBitmap srcBmp, int targetTextureId, int destX, int destY, bool generateMipmaps = false) + { + if (srcBmp is BitmapExternal optimumExternal) + { + device.UploadTexture2D(targetTextureId, 0, destX, destY, + srcBmp.Width, srcBmp.Height, EnumTexturePixelFormat.Rgba, + (IntPtr)optimumExternal.PixelsPtrAndLock); + } + else + { + // A managed pixel array has to be pinned before the device can + // read it; the GL body relied on the overload doing that. + GCHandle optimumPin = GCHandle.Alloc(srcBmp.Pixels, GCHandleType.Pinned); + try + { + device.UploadTexture2D(targetTextureId, 0, destX, destY, + srcBmp.Width, srcBmp.Height, EnumTexturePixelFormat.Rgba, + optimumPin.AddrOfPinnedObject()); + } + finally + { + optimumPin.Free(); + } + } + if (ENABLE_MIPMAPS && generateMipmaps) + { + BuildMipMaps(targetTextureId); + } + } + + /// + /// The GL body uploads BGRA bytes into a GL_RGBA image; the device gets a BGRA-ordered + /// image instead, which samples the same way without a per-pixel swizzle. Anisotropy is + /// a sampler property the device sets from its own limit, so there is nothing to query. + /// + public override unsafe int LoadTexture(IBitmap bmp, bool linearMag = false, int clampMode = 0, bool generateMipmaps = false) + { + if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) + { + throw new InvalidOperationException(MainThreadOnly); + } + int optimumTextureId; + if (bmp is BitmapExternal optimumExternal) + { + optimumTextureId = device.CreateTexture2DRaw(bmp.Width, bmp.Height, + OptimumGlConstants.Bgra, (IntPtr)optimumExternal.PixelsPtrAndLock, 4, + ENABLE_MIPMAPS && generateMipmaps); + } + else + { + GCHandle optimumPin = GCHandle.Alloc(bmp.Pixels, GCHandleType.Pinned); + try + { + optimumTextureId = device.CreateTexture2DRaw(bmp.Width, bmp.Height, + OptimumGlConstants.Bgra, optimumPin.AddrOfPinnedObject(), 4, + ENABLE_MIPMAPS && generateMipmaps); + } + finally + { + optimumPin.Free(); + } + } + switch (clampMode) + { + case 1: + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapS, 33071); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapT, 33071); + break; + case 2: + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapS, 10497); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapT, 10497); + break; + } + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMinFilter, 9729); + device.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); + if (ENABLE_MIPMAPS && generateMipmaps) + { + BuildMipMaps(optimumTextureId); + } + return optimumTextureId; + } + + public override void LoadOrUpdateTextureFromBgra_DeferMipMap(int[] rgbaPixels, bool linearMag, int clampMode, ref LoadedTexture intoTexture) + { + LoadOrUpdateTextureFromPixels(rgbaPixels, linearMag, clampMode, ref intoTexture, bgra: true, makeMipMap: false); + } + + public override void LoadOrUpdateTextureFromBgra(int[] rgbaPixels, bool linearMag, int clampMode, ref LoadedTexture intoTexture) + { + LoadOrUpdateTextureFromPixels(rgbaPixels, linearMag, clampMode, ref intoTexture, bgra: true, makeMipMap: true); + } + + public override void LoadOrUpdateTextureFromRgba(int[] rgbaPixels, bool linearMag, int clampMode, ref LoadedTexture intoTexture) + { + LoadOrUpdateTextureFromPixels(rgbaPixels, linearMag, clampMode, ref intoTexture, bgra: false, makeMipMap: true); + } + + /// + /// The texture atlas upload path: TextureAtlas.Upload reaches it through + /// LoadOrUpdateTextureFromBgra_DeferMipMap, which is why it only runs once a world + /// starts loading and never on the menu. The pixels are BGRA when + /// says so (the GL body's PixelFormat 32993) and RGBA otherwise, matching the wrappers. + /// + private void LoadOrUpdateTextureFromPixels(int[] rgbaPixels, bool linearMag, int clampMode, ref LoadedTexture intoTexture, bool bgra, bool makeMipMap) + { + if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) + { + throw new InvalidOperationException(MainThreadOnly); + } + int optimumGlFormat = bgra + ? Vintagestory.API.Config.OptimumGlConstants.Bgra + : Vintagestory.API.Config.OptimumGlConstants.Rgba8; + + GCHandle optimumPin = GCHandle.Alloc(rgbaPixels, GCHandleType.Pinned); + try + { + if (intoTexture.TextureId == 0 || intoTexture.Width * intoTexture.Height != rgbaPixels.Length) + { + if (intoTexture.TextureId != 0) + { + device.DeleteTexture(intoTexture.TextureId); + } + // The mip chain has to be requested at creation; asking for + // mipmaps afterwards on a one-level image does nothing. GL + // can grow one at any time, which is what the deferred + // variant relies on: it uploads with makeMipMap false and + // the atlas manager calls BuildMipMaps later, in StageB. So + // the chain is sized whenever mipmapping is on at all, and + // makeMipMap only decides whether to fill it here. + intoTexture.TextureId = device.CreateTexture2DRaw( + intoTexture.Width, intoTexture.Height, optimumGlFormat, + optimumPin.AddrOfPinnedObject(), 4, ENABLE_MIPMAPS); + + if (clampMode == 1) + { + device.SetTextureParameter(intoTexture.TextureId, + Vintagestory.API.Config.OptimumGlConstants.TextureWrapS, 33071); + device.SetTextureParameter(intoTexture.TextureId, + Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, 33071); + } + device.SetTextureParameter(intoTexture.TextureId, + Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9729); + device.SetTextureParameter(intoTexture.TextureId, + Vintagestory.API.Config.OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); + + if (makeMipMap) + { + BuildMipMaps(intoTexture.TextureId); + } + } + else + { + device.UploadTexture2D(intoTexture.TextureId, 0, 0, 0, + intoTexture.Width, intoTexture.Height, + EnumTexturePixelFormat.Rgba, optimumPin.AddrOfPinnedObject()); + } + } + finally + { + optimumPin.Free(); + } + } + + /// + /// The device sizes the mip chain when the image is created, so the generate carries + /// over as it is. The two glTexParameter calls carry over as well, and they are not + /// decoration: GL_LINEAR means "level 0 only" whatever the chain holds, so a texture + /// that is never moved to a MIPMAP filter is never minified through one. Vulkan has no + /// such filter, and the device turns these two into the sampler's LOD clamp. + /// + public override void BuildMipMaps(int textureId) + { + if (ENABLE_MIPMAPS) + { + device.GenerateMipmaps(textureId); + device.SetTextureParameter(textureId, + Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9986); + device.SetTextureParameter(textureId, + Vintagestory.API.Config.OptimumGlConstants.TextureMaxLevel, ClientSettings.MipMapLevel); + } + } + + /// + /// The skybox cubemap. CreateTextureCube takes all six faces at once, so the per-side + /// helper the GL body calls has no counterpart here. + /// + public override unsafe int Load3DTextureCube(BitmapRef[] bmps) + { + IntPtr[] optimumFaces = new IntPtr[6]; + int optimumSize = 0; + for (int k = 0; k < 6; k++) + { + BitmapExternal optimumFace = (BitmapExternal)bmps[k]; + optimumSize = optimumFace.Width; + optimumFaces[k] = (IntPtr)optimumFace.PixelsPtrAndLock; + } + // BGRA like the other bitmap uploads, so the raw overload rather than + // the EnumTextureInternalFormat one. + int optimumCubeId = device.CreateTextureCubeRaw(optimumSize, + OptimumGlConstants.Bgra, optimumFaces, 4); + device.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureMinFilter, 9729); + device.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureMagFilter, 9729); + device.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureWrapS, 33071); + device.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureWrapT, 33071); + return optimumCubeId; + } + + public override void GLDeleteTexture(int id) + { + // The device defers the destruction until the GPU is finished with + // the frame that referenced it; GL left that to the driver. + device.DeleteTexture(id); + } +} diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index db733655..1aca5b3a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..af5856c 100644 +index 6edf0c9..2f72ffd 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -290,9 +290,9 @@ index 6edf0c9..af5856c 100644 - GL.BindFramebuffer((FramebufferTarget)36160, value.FboId); - GL.Viewport(0, 0, value.Width, value.Height); + BindCurrentFrameBuffer(value); -+ } -+ } -+ + } + } + + /// + /// Optimum (Vulkan-native plan, Phase 1A step 4): the GL half of the + /// setter - bind, and size the viewport to the target. @@ -303,11 +303,11 @@ index 6edf0c9..af5856c 100644 + { + GL.BindFramebuffer((FramebufferTarget)36160, 0); + return; - } ++ } + GL.BindFramebuffer((FramebufferTarget)36160, value.FboId); + GL.Viewport(0, 0, value.Width, value.Height); - } - ++ } ++ private FrameBufferRef CurrentFrameBufferKeepVw { get @@ -570,23 +570,7 @@ index 6edf0c9..af5856c 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1016,20 +1389,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - throw new ArgumentException("Fatal: Trying to render an uninitialized mesh"); - } - throw new ArgumentException("Fatal: Trying to render a disposed mesh"); - } -+ // Optimum: on the device path VaoId is the device's mesh handle rather -+ // than a GL vertex-array name. Reusing the field keeps MeshRef - which is -+ // public API that mods hold - unchanged. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DrawMesh(vAO.VaoId); -+ return; -+ } - GL.BindVertexArray(vAO.VaoId); - GL.BindBuffer((BufferTarget)34963, vAO.vboIdIndex); +@@ -1023,11 +1396,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -596,57 +580,10 @@ index 6edf0c9..af5856c 100644 + public virtual void RenderFullscreenTriangle(MeshRef modelRef) { RuntimeStats.drawCallsCount++; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The post passes generate their three vertices in the shader, so the -+ // mesh carries no buffers and none are bound. -+ optimumDevice.DrawFullscreenTriangle(); -+ return; -+ } GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); - } - -@@ -1042,10 +1432,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - //IL_0075: Unknown result type (might be due to invalid IL or missing references) - //IL_0043: Unknown result type (might be due to invalid IL or missing references) - RuntimeStats.drawCallsCount++; - VAO vAO = (VAO)modelRef; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The chunk renderer's one multidraw per pool. GL takes byte offsets -+ // into the index buffer; the device converts them to index counts and -+ // issues a single indirect draw. -+ optimumDevice.DrawMeshMulti(vAO.VaoId, indices, indicesSizes, groupCount, useSSBOs); -+ return; -+ } - GL.BindVertexArray(vAO.VaoId); - if (useSSBOs) - { - GL.BindBuffer((BufferTarget)34963, ClientPlatformAbstract.singleIndexBufferId); - GL.BindBufferBase((BufferRangeTarget)37074, 3, vAO.xyzVboId); -@@ -1064,10 +1463,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) - { - //IL_002f: Unknown result type (might be due to invalid IL or missing references) - RuntimeStats.drawCallsCount++; - VAO vAO = (VAO)modelRef; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DrawMeshInstanced(vAO.VaoId, quantity); -+ return; -+ } - GL.BindVertexArray(vAO.VaoId); - GL.BindBuffer((BufferTarget)34963, vAO.vboIdIndex); - GL.DrawElementsInstanced(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, (IntPtr)IntPtr.Zero, quantity); - GL.BindBuffer((BufferTarget)34963, 0); - GL.BindVertexArray(0); -@@ -1150,11 +1555,300 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,11 +1523,300 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -948,7 +885,7 @@ index 6edf0c9..af5856c 100644 //IL_0072: Unknown result type (might be due to invalid IL or missing references) //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +1881,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +1849,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -966,7 +903,7 @@ index 6edf0c9..af5856c 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +1911,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +1879,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -983,7 +920,7 @@ index 6edf0c9..af5856c 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +1956,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +1924,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1022,7 +959,7 @@ index 6edf0c9..af5856c 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2169,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2137,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1098,7 +1035,7 @@ index 6edf0c9..af5856c 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2346,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2314,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1113,7 +1050,7 @@ index 6edf0c9..af5856c 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2369,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2337,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1208,7 +1145,7 @@ index 6edf0c9..af5856c 100644 } } } -@@ -1591,11 +2463,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2431,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1227,7 +1164,7 @@ index 6edf0c9..af5856c 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2498,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2466,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1314,7 +1251,7 @@ index 6edf0c9..af5856c 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +2607,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +2575,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1400,7 +1337,7 @@ index 6edf0c9..af5856c 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +2687,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +2655,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1486,7 +1423,7 @@ index 6edf0c9..af5856c 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +2769,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +2737,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1747,7 +1684,7 @@ index 6edf0c9..af5856c 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3033,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3001,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1784,7 +1721,7 @@ index 6edf0c9..af5856c 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3072,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3040,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1839,7 +1776,7 @@ index 6edf0c9..af5856c 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,35 +3142,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,35 +3110,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1890,7 +1827,7 @@ index 6edf0c9..af5856c 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,19 +3191,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3159,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1916,18 +1853,20 @@ index 6edf0c9..af5856c 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,24 +3227,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1986,25 +3194,44 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + } final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; RenderFullscreenTriangle(screenQuad); - final.Stop(); +- final.Stop(); - if (RenderSSAO) - { - array = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; - GL.DrawBuffers(4, array); - } - else ++ final.Stop(); + // Restores the multi-attachment selection the world passes expect. + RestoreWorldDrawBuffers(RenderSSAO); + } @@ -1973,7 +1912,7 @@ index 6edf0c9..af5856c 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3282,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3250,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2459,7 +2398,7 @@ index 6edf0c9..af5856c 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +3918,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +3886,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -2499,354 +2438,7 @@ index 6edf0c9..af5856c 100644 { GL.Disable((EnableCap)3042); } -@@ -2337,10 +4060,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) - { - throw new InvalidOperationException("Texture uploads must happen in the main thread. We only have one OpenGL context."); - } -+ // Mono.Cecil transplant. -+ // Cairo hands over premultiplied BGRA bytes, which is why the GL body -+ // asks for GL_BGRA rather than GL_RGBA; CreateTexture2DRaw takes the same -+ // GL internal format token so the device makes the identical image. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ int optimumTextureId = optimumDevice.CreateTexture2DRaw(surface.Width, surface.Height, -+ OptimumGlConstants.Bgra, surface.DataPtr, 4); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMinFilter, 9729); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); -+ return optimumTextureId; -+ } - int num = GL.GenTexture(); - GL.BindTexture((TextureTarget)3553, num); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, linearMag ? 9729 : 9728); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, surface.Width, surface.Height, 0, (PixelFormat)32993, (PixelType)5121, surface.DataPtr); -@@ -2351,10 +4087,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) - { - throw new InvalidOperationException("Texture uploads must happen in the main thread. We only have one OpenGL context."); - } -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ int optimumTextureId = optimumDevice.CreateTexture2D(tex.Width, tex.Height, -+ tex.PixelInternalFormat, tex.PixelFormat, IntPtr.Zero, false); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMinFilter, (int)tex.MinFilter); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMagFilter, (int)tex.MagFilter); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapS, (int)tex.WrapS); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapT, (int)tex.WrapT); -+ tex.TextureId = optimumTextureId; -+ return; -+ } - int num = GL.GenTexture(); - GL.BindTexture((TextureTarget)3553, num); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)tex.PixelInternalFormat, tex.Width, tex.Height, 0, (PixelFormat)tex.PixelFormat, (PixelType)5126, (IntPtr)IntPtr.Zero); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (int)tex.MinFilter); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (int)tex.MagFilter); -@@ -2367,10 +4116,37 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) - { - throw new InvalidOperationException("Texture uploads must happen in the main thread. We only have one OpenGL context."); - } -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (intoTexture.TextureId == 0 || intoTexture.Width != surface.Width || intoTexture.Height != surface.Height) -+ { -+ if (intoTexture.TextureId != 0) -+ { -+ optimumDevice.DeleteTexture(intoTexture.TextureId); -+ } -+ intoTexture.TextureId = optimumDevice.CreateTexture2DRaw(surface.Width, surface.Height, -+ OptimumGlConstants.Bgra, surface.DataPtr, 4); -+ intoTexture.Width = surface.Width; -+ intoTexture.Height = surface.Height; -+ optimumDevice.SetTextureParameter(intoTexture.TextureId, OptimumGlConstants.TextureMinFilter, 9729); -+ optimumDevice.SetTextureParameter(intoTexture.TextureId, OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); -+ } -+ else -+ { -+ // The image is BGRA-ordered; the upload is a byte copy at four -+ // bytes per pixel, which is what Rgba selects here. -+ optimumDevice.UploadTexture2D(intoTexture.TextureId, 0, 0, 0, -+ surface.Width, surface.Height, EnumTexturePixelFormat.Rgba, surface.DataPtr); -+ } -+ CheckGlError("LoadOrUpdateCairoTexture"); -+ return; -+ } - if (intoTexture.TextureId == 0 || intoTexture.Width != surface.Width || intoTexture.Height != surface.Height) - { - if (intoTexture.TextureId != 0) - { - GL.DeleteTexture(intoTexture.TextureId); -@@ -2400,10 +4176,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - return LoadTexture((IBitmap)new BitmapExternal(bmp), linearMag, clampMode, generateMipmaps); - } - - public override void LoadIntoTexture(IBitmap srcBmp, int targetTextureId, int destX, int destY, bool generateMipmaps = false) - { -+ // Mono.Cecil transplant. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (srcBmp is BitmapExternal optimumExternal) -+ { -+ optimumDevice.UploadTexture2D(targetTextureId, 0, destX, destY, -+ srcBmp.Width, srcBmp.Height, EnumTexturePixelFormat.Rgba, -+ (IntPtr)optimumExternal.PixelsPtrAndLock); -+ } -+ else -+ { -+ // A managed pixel array has to be pinned before the device can -+ // read it; the GL body relied on the overload doing that. -+ GCHandle optimumPin = GCHandle.Alloc(srcBmp.Pixels, GCHandleType.Pinned); -+ try -+ { -+ optimumDevice.UploadTexture2D(targetTextureId, 0, destX, destY, -+ srcBmp.Width, srcBmp.Height, EnumTexturePixelFormat.Rgba, -+ optimumPin.AddrOfPinnedObject()); -+ } -+ finally -+ { -+ optimumPin.Free(); -+ } -+ } -+ if (ENABLE_MIPMAPS && generateMipmaps) -+ { -+ BuildMipMaps(targetTextureId); -+ } -+ return; -+ } - GL.BindTexture((TextureTarget)3553, targetTextureId); - if (srcBmp is BitmapExternal bitmapExternal) - { - GL.TexSubImage2D((TextureTarget)3553, 0, destX, destY, srcBmp.Width, srcBmp.Height, (PixelFormat)32993, (PixelType)5121, (IntPtr)bitmapExternal.PixelsPtrAndLock); - } -@@ -2421,10 +4229,58 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - { - if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) - { - throw new InvalidOperationException("Texture uploads must happen in the main thread. We only have one OpenGL context."); - } -+ // Mono.Cecil transplant. -+ // The GL body uploads BGRA bytes into a GL_RGBA image; the device gets a -+ // BGRA-ordered image instead, which samples the same way without a -+ // per-pixel swizzle. Anisotropy is a sampler property the device sets -+ // from its own limit, so there is nothing to query here. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ int optimumTextureId; -+ if (bmp is BitmapExternal optimumExternal) -+ { -+ optimumTextureId = optimumDevice.CreateTexture2DRaw(bmp.Width, bmp.Height, -+ OptimumGlConstants.Bgra, (IntPtr)optimumExternal.PixelsPtrAndLock, 4, -+ ENABLE_MIPMAPS && generateMipmaps); -+ } -+ else -+ { -+ GCHandle optimumPin = GCHandle.Alloc(bmp.Pixels, GCHandleType.Pinned); -+ try -+ { -+ optimumTextureId = optimumDevice.CreateTexture2DRaw(bmp.Width, bmp.Height, -+ OptimumGlConstants.Bgra, optimumPin.AddrOfPinnedObject(), 4, -+ ENABLE_MIPMAPS && generateMipmaps); -+ } -+ finally -+ { -+ optimumPin.Free(); -+ } -+ } -+ switch (clampMode) -+ { -+ case 1: -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapS, 33071); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapT, 33071); -+ break; -+ case 2: -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapS, 10497); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureWrapT, 10497); -+ break; -+ } -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMinFilter, 9729); -+ optimumDevice.SetTextureParameter(optimumTextureId, OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); -+ if (ENABLE_MIPMAPS && generateMipmaps) -+ { -+ BuildMipMaps(optimumTextureId); -+ } -+ return optimumTextureId; -+ } - int num = GL.GenTexture(); - GL.BindTexture((TextureTarget)3553, num); - if (ENABLE_ANISOTROPICFILTERING) - { - float num2 = GL.GetFloat((GetPName)34047); -@@ -2488,10 +4344,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - //IL_00db: Unknown result type (might be due to invalid IL or missing references) - if (Environment.CurrentManagedThreadId != RuntimeEnv.MainThreadId) - { - throw new InvalidOperationException("Texture uploads must happen in the main thread. We only have one OpenGL context."); - } -+ // Mono.Cecil transplant. -+ // This is the texture atlas upload path: TextureAtlas.Upload reaches it -+ // through LoadOrUpdateTextureFromBgra_DeferMipMap, which is why it only -+ // runs once a world starts loading and never on the menu. The pixels are -+ // BGRA when format says so and RGBA otherwise, matching the two public -+ // wrappers. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ int optimumGlFormat = (int)format == 32993 -+ ? Vintagestory.API.Config.OptimumGlConstants.Bgra -+ : Vintagestory.API.Config.OptimumGlConstants.Rgba8; -+ -+ GCHandle optimumPin = GCHandle.Alloc(rgbaPixels, GCHandleType.Pinned); -+ try -+ { -+ if (intoTexture.TextureId == 0 || intoTexture.Width * intoTexture.Height != rgbaPixels.Length) -+ { -+ if (intoTexture.TextureId != 0) -+ { -+ optimumDevice.DeleteTexture(intoTexture.TextureId); -+ } -+ // The mip chain has to be requested at creation; asking for -+ // mipmaps afterwards on a one-level image does nothing. GL -+ // can grow one at any time, which is what the deferred -+ // variant relies on: it uploads with makeMipMap false and -+ // the atlas manager calls BuildMipMaps later, in StageB. So -+ // the chain is sized whenever mipmapping is on at all, and -+ // makeMipMap only decides whether to fill it here. -+ intoTexture.TextureId = optimumDevice.CreateTexture2DRaw( -+ intoTexture.Width, intoTexture.Height, optimumGlFormat, -+ optimumPin.AddrOfPinnedObject(), 4, ENABLE_MIPMAPS); -+ -+ if (clampMode == 1) -+ { -+ optimumDevice.SetTextureParameter(intoTexture.TextureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureWrapS, 33071); -+ optimumDevice.SetTextureParameter(intoTexture.TextureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, 33071); -+ } -+ optimumDevice.SetTextureParameter(intoTexture.TextureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9729); -+ optimumDevice.SetTextureParameter(intoTexture.TextureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureMagFilter, linearMag ? 9729 : 9728); -+ -+ if (makeMipMap) -+ { -+ BuildMipMaps(intoTexture.TextureId); -+ } -+ } -+ else -+ { -+ optimumDevice.UploadTexture2D(intoTexture.TextureId, 0, 0, 0, -+ intoTexture.Width, intoTexture.Height, -+ EnumTexturePixelFormat.Rgba, optimumPin.AddrOfPinnedObject()); -+ } -+ } -+ finally -+ { -+ optimumPin.Free(); -+ } -+ return; -+ } - if (intoTexture.TextureId == 0 || intoTexture.Width * intoTexture.Height != rgbaPixels.Length) - { - if (intoTexture.TextureId != 0) - { - GL.DeleteTexture(intoTexture.TextureId); -@@ -2523,10 +4442,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - } - - public override void BuildMipMaps(int textureId) - { -+ // Mono.Cecil transplant. -+ // The device sizes the mip chain when the image is created, so the -+ // generate carries over as it is. The two glTexParameter calls carry -+ // over as well, and they are not decoration: GL_LINEAR means "level 0 -+ // only" whatever the chain holds, so a texture that is never moved to a -+ // MIPMAP filter is never minified through one. Vulkan has no such -+ // filter, and the device turns these two into the sampler's LOD clamp. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ if (ENABLE_MIPMAPS) -+ { -+ optimumDevice.GenerateMipmaps(textureId); -+ optimumDevice.SetTextureParameter(textureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9986); -+ optimumDevice.SetTextureParameter(textureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureMaxLevel, ClientSettings.MipMapLevel); -+ } -+ return; -+ } - if (ENABLE_MIPMAPS) - { - GL.BindTexture((TextureTarget)3553, textureId); - int num = default(int); - GL.GetTexParameter((TextureTarget)3553, (GetTextureParameter)33085, out num); -@@ -2540,10 +4479,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - } - - public override int Load3DTextureCube(BitmapRef[] bmps) - { -+ // Mono.Cecil transplant. -+ // The skybox cubemap. CreateTextureCube takes all six faces at once, so -+ // the per-side helper the GL body calls has no counterpart here. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ IntPtr[] optimumFaces = new IntPtr[6]; -+ int optimumSize = 0; -+ for (int k = 0; k < 6; k++) -+ { -+ BitmapExternal optimumFace = (BitmapExternal)bmps[k]; -+ optimumSize = optimumFace.Width; -+ optimumFaces[k] = (IntPtr)optimumFace.PixelsPtrAndLock; -+ } -+ // BGRA like the other bitmap uploads, so the raw overload rather than -+ // the EnumTextureInternalFormat one. -+ int optimumCubeId = optimumDevice.CreateTextureCubeRaw(optimumSize, -+ OptimumGlConstants.Bgra, optimumFaces, 4); -+ optimumDevice.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureMinFilter, 9729); -+ optimumDevice.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureMagFilter, 9729); -+ optimumDevice.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureWrapS, 33071); -+ optimumDevice.SetTextureParameter(optimumCubeId, OptimumGlConstants.TextureWrapT, 33071); -+ return optimumCubeId; -+ } - GL.ActiveTexture((TextureUnit)33984); - int num = GL.GenTexture(); - GL.BindTexture((TextureTarget)34067, num); - for (int i = 0; i < 6; i++) - { -@@ -2563,10 +4526,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - GL.TexImage2D(target, 0, (PixelInternalFormat)6408, bmp.Width, bmp.Height, 0, (PixelFormat)32993, (PixelType)5121, (IntPtr)bmp.PixelsPtrAndLock); - } - - public override void GLDeleteTexture(int id) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The device defers the destruction until the GPU is finished with -+ // the frame that referenced it; GL left that to the driver. -+ optimumDevice.DeleteTexture(id); -+ return; -+ } - GL.DeleteTexture(id); - } - - public override int GlGetMaxTextureSize() - { -@@ -2581,10 +4552,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2581,10 +4272,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return result; } @@ -2872,7 +2464,7 @@ index 6edf0c9..af5856c 100644 GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); ScreenManager.Platform.CheckGlError(); int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,22 +4579,90 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4299,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -2950,20 +2542,7 @@ index 6edf0c9..af5856c 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) - //IL_0016: Unknown result type (might be due to invalid IL or missing references) - VAO vAO = (VAO)modelRef; -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumUpdateDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumUpdateDevice != null) -+ { -+ optimumUpdateDevice.UpdateMesh(vAO.VaoId, data); -+ return; -+ } - BufferAccessMask val = (BufferAccessMask)34; - if (vAO.Persistent) - { - val = (BufferAccessMask)((int)val | 0x50); - } -@@ -2651,29 +4705,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4419,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3008,7 +2587,7 @@ index 6edf0c9..af5856c 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4742,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4456,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3029,7 +2608,7 @@ index 6edf0c9..af5856c 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4761,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4475,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3050,7 +2629,7 @@ index 6edf0c9..af5856c 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4780,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4494,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3071,7 +2650,7 @@ index 6edf0c9..af5856c 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4799,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4513,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3092,7 +2671,7 @@ index 6edf0c9..af5856c 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4822,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4536,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3113,137 +2692,9 @@ index 6edf0c9..af5856c 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -2801,10 +4865,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - //IL_0199: Unknown result type (might be due to invalid IL or missing references) - //IL_01ad: Unknown result type (might be due to invalid IL or missing references) - //IL_0255: Unknown result type (might be due to invalid IL or missing references) - //IL_025a: Unknown result type (might be due to invalid IL or missing references) - VAO vAO = new VAO(); -+ // Optimum: the device allocates the per-attribute buffers and derives the -+ // vertex layout from which parts are present, matching the slot ordering -+ // the GL body below assigns. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumAllocDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumAllocDevice != null) -+ { -+ vAO.VaoId = optimumAllocDevice.CreateEmptyMesh( -+ xyzSize, normalsSize, uvSize, rgbaSize, flagsSize, indicesSize, -+ customFloats, customShorts, customBytes, customInts, -+ drawMode, staticDraw, ssbo: false); -+ vAO.IndicesCount = indicesSize; -+ vAO.drawMode = DrawModeToPrimiteType(drawMode); -+ vAO.Persistent = !staticDraw; -+ return vAO; -+ } - int num = GL.GenVertexArray(); - int vaoSlotNumber = 0; - GL.BindVertexArray(num); - int xyzVboId = 0; - int normalsVboId = 0; -@@ -3028,10 +5107,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - //IL_0471: Unknown result type (might be due to invalid IL or missing references) - //IL_04f9: Unknown result type (might be due to invalid IL or missing references) - //IL_0529: Unknown result type (might be due to invalid IL or missing references) - //IL_06d5: Unknown result type (might be due to invalid IL or missing references) - //IL_06da: Unknown result type (might be due to invalid IL or missing references) -+ // Optimum: the device builds the buffers and returns its own handle. VAO -+ // still carries it, because MeshRef is public API that mods hold. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumUploadDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumUploadDevice != null) -+ { -+ VAO optimumUploadVao = new VAO(); -+ optimumUploadVao.VaoId = optimumUploadDevice.CreateMesh(data, true); -+ optimumUploadVao.IndicesCount = data.IndicesCount; -+ optimumUploadVao.drawMode = DrawModeToPrimiteType(data.mode); -+ return optimumUploadVao; -+ } - int num = GL.GenVertexArray(); - int num2 = 0; - GL.BindVertexArray(num); - int num3 = 0; - int num4 = 0; -@@ -3215,10 +5305,22 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - }; - } - - public override void DeleteMesh(MeshRef modelref) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDeleteDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDeleteDevice != null) -+ { -+ if (modelref != null) -+ { -+ // Deferred until the GPU is done with the frame that used it; -+ // GL left that to the driver. -+ optimumDeleteDevice.DeleteMesh(((VAO)modelref).VaoId); -+ ((VAO)modelref).Dispose(); -+ } -+ return; -+ } - if (modelref != null) - { - ((VAO)modelref).Dispose(); - } - } -@@ -3277,14 +5379,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - rotateUV = false; - } - } - array2[i / 4] = new FaceData(xyz, i * 3, num2, num3, num5 - num2, num6 - num3, flags, i, (array != null) ? array[i * num] : 0, rotateUV); - } -+ // Mono.Cecil transplant. -+ // The face records go to the same storage buffer on both paths; only the -+ // way to reach it differs. VaoId is the device's mesh handle, whereas the -+ // GL body addresses the buffer object directly. - int num8 = data.XyzOffset / 12 * 16; -- GL.BindBuffer((BufferTarget)37074, vAO.xyzVboId); -- GL.BufferSubData((BufferTarget)37074, (IntPtr)num8, 16 * verticesCount, facedataBuffer); -- GL.BindBuffer((BufferTarget)37074, 0); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumSsboDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumSsboDevice != null) -+ { -+ // Everything the mesh carries as ordinary vertex data goes first, the -+ // same call UpdateMesh makes. The packed face records follow, because -+ // they occupy the xyz slot and have to be what remains there. -+ // -+ // The device path returns here rather than falling through: the -+ // updateVAO calls below address VBO names directly and would reach -+ // OpenGL, which is not bound on this backend. -+ optimumSsboDevice.UpdateMesh(vAO.VaoId, data); -+ -+ GCHandle optimumFacePin = GCHandle.Alloc(facedataBuffer, GCHandleType.Pinned); -+ try -+ { -+ optimumSsboDevice.UpdateMeshStorageBuffer(vAO.VaoId, -+ optimumFacePin.AddrOfPinnedObject(), num8, 16 * verticesCount); -+ } -+ finally -+ { -+ optimumFacePin.Free(); -+ } -+ return; -+ } -+ else -+ { -+ GL.BindBuffer((BufferTarget)37074, vAO.xyzVboId); -+ GL.BufferSubData((BufferTarget)37074, (IntPtr)num8, 16 * verticesCount, facedataBuffer); -+ GL.BindBuffer((BufferTarget)37074, 0); -+ } - if (data.Rgba != null && data.RgbaCount > 0) - { - updateVAO(data.Rgba, data.RgbaOffset, data.RgbaCount, vAO.rgbaVboId, vAO.rgbaPtr, persistent); - } - if (data.CustomFloats != null && data.CustomFloats.Count > 0) -@@ -3316,20 +5449,24 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } - if (data.CustomBytes != null && data.CustomBytes.Count > 0) - { - updateVAO(data.CustomBytes.Values, data.CustomBytes.BaseOffset, data.CustomBytes.Count, vAO.customDataByteVboId, vAO.customDataBytePtr, persistent); +@@ -3320,16 +5098,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } -- GL.BindBuffer((BufferTarget)34962, 0); -+ if (optimumSsboDevice == null) -+ { -+ GL.BindBuffer((BufferTarget)34962, 0); -+ } + GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; if (GlErrorChecking && GlDebugMode) { @@ -3265,33 +2716,7 @@ index 6edf0c9..af5856c 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3342,10 +5479,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - //IL_0085: Unknown result type (might be due to invalid IL or missing references) - //IL_0087: Unknown result type (might be due to invalid IL or missing references) - //IL_01fc: Unknown result type (might be due to invalid IL or missing references) - //IL_0201: Unknown result type (might be due to invalid IL or missing references) - VAO vAO = new VAO(); -+ // Optimum: the device allocates the per-attribute buffers and derives the -+ // vertex layout from which parts are present, matching the slot ordering -+ // the GL body below assigns. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumAllocDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumAllocDevice != null) -+ { -+ vAO.VaoId = optimumAllocDevice.CreateEmptyMesh( -+ xyzSize, normalsSize, uvSize, rgbaSize, flagsSize, indicesSize, -+ customFloats, customShorts, customBytes, customInts, -+ drawMode, staticDraw, ssbo: true); -+ vAO.IndicesCount = indicesSize; -+ vAO.drawMode = DrawModeToPrimiteType(drawMode); -+ vAO.Persistent = !staticDraw; -+ return vAO; -+ } - int num = GL.GenVertexArray(); - int vaoSlotNumber = 0; - GL.BindVertexArray(num); - int num2 = 0; - int rgbaVboId = 0; -@@ -3675,15 +5827,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3675,15 +5454,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return new MouseEvent((int)mouseX, (int)mouseY, button, 0); } @@ -3620,7 +3045,7 @@ index 6edf0c9..af5856c 100644 if (text != null) { if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +6180,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3715,10 +5807,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return true; } From 97bc3cb95fd3ebd6ec1f9b01fa58a1bc70fa097f Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:56:42 +0200 Subject: [PATCH 093/226] wip(phase1a-step4): shaders, uniforms and UBOs - device bodies live in VulkanClientPlatform CreateUBO, BindUBO, UnbindUBO, UpdateUBO, DeleteUBO, GetUniformLocation, UseShaderProgram, DisposeShaderProgram, BindSampler, the six SetUniform overloads, SetUniformArray1-4, both SetUniformMatrix overloads, SetUniformMatrices(4x3), BindProgramTexture2D/Cube, CompileShader and CreateShaderProgram are overrides in VulkanClientPlatform.Shaders.cs; the device branches moved unchanged and the ClientPlatformWindows overrides keep only the GL lines. GetUniformLocation, CompileShader and CreateShaderProgram are vanilla bodies again (targets dropped); CreateUBO keeps its target for the UBO BlockName/BindingPoint assignment. Verified: renderer + donor build 0 errors, 0 warnings; extract + check-patches 0 conflict, 0 pending. --- Optimum.Patcher/Program.cs | 6 - .../Platform/VulkanClientPlatform.Shaders.cs | 237 ++++++++++++++ .../ClientPlatformWindows.cs.patch | 305 ++---------------- 3 files changed, 256 insertions(+), 292 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 7cc3eaa7..4ad5baff 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -753,12 +753,6 @@ // fixed-function bodies are vanilla again (Phase 1A step 4): VulkanClientPlatform // overrides them, so they are no longer transplanted. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GlToggleBlend", 2), - // Vulkan backend: shader staging and linking. CompileShader only stages a - // stage on the device path, because GL resolves uniforms and varyings by name - // across the whole program and nothing is final until link time. - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "GetUniformLocation", 2), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CompileShader", 1), - new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateShaderProgram", 1), // Vulkan backend: the mod-facing uniform and texture-binding surface. A // uniform location here is a byte offset into the generated block rather than // a GL location, which callers never see. diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs new file mode 100644 index 00000000..1ac7cbc0 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Generic; +using OpenTK.Mathematics; +using Vintagestory.API.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: shader compile and link, programs, uniforms, texture +// binding and uniform buffers. Each body is the device branch that opened the same +// ClientPlatformWindows method (Phase 1A step 3 moved the program/uniform/UBO ones there +// from ShaderProgramBase and UBO), moved unchanged. +// +// A uniform location here is whatever GetUniformLocation handed out - a byte offset into +// the generated block - so the setters read the same uniformLocations dictionary as GL. +// UBO.Handle carries the device's uniform-buffer handle, the convention VAO.VaoId uses for +// meshes, and the binding point survives from CreateUBO. +public partial class VulkanClientPlatform +{ + /// + /// The device resolves the block by name against the program's reflected interface and + /// binds it to the same point, so the four GL steps - allocate, look up the block index, + /// bind the index, bind the buffer - collapse into one call. + /// + public override UBORef CreateUBO(int shaderProgramId, int bindingPoint, string blockName, int size) + { + UBO optimumUbo = new UBO(); + optimumUbo.Handle = device.CreateUniformBuffer(shaderProgramId, bindingPoint, blockName, size); + optimumUbo.Size = size; + optimumUbo.BlockName = blockName; + optimumUbo.BindingPoint = bindingPoint; + return optimumUbo; + } + + public override void BindUBO(UBO ubo) + { + device.BindUniformBuffer(ubo.Handle); + } + + public override void UnbindUBO(UBO ubo) + { + device.UnbindUniformBuffer(ubo.Handle); + } + + /// The device writes a range whether or not the GL path would reallocate. + public override void UpdateUBO(UBO ubo, IntPtr data, int offset, int size, bool reallocate) + { + device.UpdateUniformBuffer(ubo.Handle, data, offset, size); + } + + public override void DeleteUBO(UBO ubo) + { + device.DeleteUniformBuffer(ubo.Handle); + } + + public override int GetUniformLocation(ShaderProgram program, string name) + { + return device.GetUniformLocation(program.ProgramId, name); + } + + public override void UseShaderProgram(int programId) + { + device.UseProgram(programId); + } + + /// + /// The device owns the SPIR-V modules inside the program and frees them with it, so + /// there is nothing matching GL's detach-and-delete of the individual stages. + /// + public override void DisposeShaderProgram(ShaderProgramBase program) + { + foreach (KeyValuePair optimumSampler in program.customSamplers) + { + device.DeleteSampler(optimumSampler.Value); + } + device.DeleteProgram(program.ProgramId); + } + + public override void BindSampler(int unit, int samplerId) + { + device.BindSampler(unit, samplerId); + } + + public override void SetUniform(int programId, int location, float value) + { + device.SetUniform(programId, location, value); + } + + public override void SetUniform(int programId, int location, int value) + { + device.SetUniform(programId, location, value); + } + + public override void SetUniform(int programId, int location, float x, float y) + { + device.SetUniform(programId, location, x, y); + } + + public override void SetUniform(int programId, int location, float x, float y, float z) + { + device.SetUniform(programId, location, x, y, z); + } + + public override void SetUniform(int programId, int location, float x, float y, float z, float w) + { + device.SetUniform(programId, location, x, y, z, w); + } + + public override void SetUniform(int programId, int location, int x, int y, int z) + { + // Unlike the Vec2i overload, which casts to float before it gets here, + // Vec3i keeps integers, so the shader declares an ivec3. The location is + // opaque to this side, so the device lays the three components out itself. + device.SetUniform(programId, location, x, y, z); + } + + public override void SetUniformArray1(int programId, int location, int count, float[] values) + { + device.SetUniformArray1(programId, location, count, values); + } + + public override void SetUniformArray2(int programId, int location, int count, float[] values) + { + device.SetUniformArray2(programId, location, count, values); + } + + public override void SetUniformArray3(int programId, int location, int count, float[] values) + { + device.SetUniformArray3(programId, location, count, values); + } + + public override void SetUniformArray4(int programId, int location, int count, float[] values) + { + device.SetUniformArray4(programId, location, count, values); + } + + public override void SetUniformMatrix(int programId, int location, float[] matrix) + { + device.SetUniformMatrix(programId, location, matrix); + } + + public override void SetUniformMatrix(int programId, int location, ref Matrix4 matrix) + { + // Only the sun and moon renderers use this overload, a handful of + // times per frame, so flattening into an array here is not worth a + // dedicated entry point on the seam. + float[] optimumMatrix = new float[16]; + optimumMatrix[0] = matrix.M11; optimumMatrix[1] = matrix.M12; + optimumMatrix[2] = matrix.M13; optimumMatrix[3] = matrix.M14; + optimumMatrix[4] = matrix.M21; optimumMatrix[5] = matrix.M22; + optimumMatrix[6] = matrix.M23; optimumMatrix[7] = matrix.M24; + optimumMatrix[8] = matrix.M31; optimumMatrix[9] = matrix.M32; + optimumMatrix[10] = matrix.M33; optimumMatrix[11] = matrix.M34; + optimumMatrix[12] = matrix.M41; optimumMatrix[13] = matrix.M42; + optimumMatrix[14] = matrix.M43; optimumMatrix[15] = matrix.M44; + device.SetUniformMatrix(programId, location, optimumMatrix); + } + + public override void SetUniformMatrices(int programId, int location, int count, float[] matrices) + { + device.SetUniformMatrices(programId, location, count, matrices); + } + + public override void SetUniformMatrices4x3(int programId, int location, int count, float[] matrices) + { + device.SetUniformMatrices4x3(programId, location, count, matrices); + } + + /// + /// In GL this is three separate things - point the sampler uniform at a unit, activate + /// that unit, bind the texture. The device keeps the same split so the two halves can + /// be set independently, which the render systems rely on. + /// + public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) + { + device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); + device.BindTexture(textureNumber, textureId); + if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) + { + device.BindSampler(textureNumber, optimumSampler); + } + else + { + // Clear any override left on this unit, or the texture's own + // filtering would be silently ignored. + device.BindSampler(textureNumber, 0); + } + if (program.clampTToEdge) + { + device.SetTextureParameter(textureId, + Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, + Vintagestory.API.Config.OptimumGlConstants.ClampToEdge); + } + } + + public override void BindProgramTextureCube(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) + { + device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); + device.BindTextureCube(textureNumber, textureId); + if (program.clampTToEdge) + { + device.SetTextureParameter(textureId, + Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, + Vintagestory.API.Config.OptimumGlConstants.ClampToEdge); + } + } + + /// + /// The device only stages the stage here. GL resolves uniforms and varyings by name + /// across the whole program, so nothing about a stage is final until its siblings are + /// known, and the real translation happens at link time. + /// + public override bool CompileShader(Shader shader) + { + return device.CompileShader(shader); + } + + /// + /// The device assigns the id, exactly as glCreateProgram did, and the caller stores it - + /// IShaderProgram.ProgramId is read-only on the interface, so it comes back as a + /// return value. + /// + public override bool CreateShaderProgram(ShaderProgram program) + { + int optimumProgramId = device.LinkProgram(program); + if (optimumProgramId == 0) + { + string optimumLinkError = device.GetError(); + Logger.Error("Link error in shader program for pass {0}: {1}", + program.PassName, optimumLinkError == null ? "unknown" : optimumLinkError); + return false; + } + program.ProgramId = optimumProgramId; + Logger.Notification("Loaded Shaderprogramm for render pass {0}.", program.PassName); + return true; + } +} diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 1aca5b3a..0ddb0d7e 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..2f72ffd 100644 +index 6edf0c9..8de2a1d 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -1064,7 +1064,7 @@ index 6edf0c9..2f72ffd 100644 + /// member for it) attachments. + /// + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) -+ { + { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1116,7 +1116,7 @@ index 6edf0c9..2f72ffd 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) - { ++ { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. @@ -1853,20 +1853,18 @@ index 6edf0c9..2f72ffd 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1986,25 +3194,44 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - } +@@ -1987,24 +3195,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; RenderFullscreenTriangle(screenQuad); -- final.Stop(); + final.Stop(); - if (RenderSSAO) - { - array = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; - GL.DrawBuffers(4, array); - } - else -+ final.Stop(); + // Restores the multi-attachment selection the world passes expect. + RestoreWorldDrawBuffers(RenderSSAO); + } @@ -2438,33 +2436,7 @@ index 6edf0c9..2f72ffd 100644 { GL.Disable((EnableCap)3042); } -@@ -2581,10 +4272,25 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - return result; - } - - public override UBORef CreateUBO(int shaderProgramId, int bindingPoint, string blockName, int size) - { -+ // Mono.Cecil transplant. -+ // The device resolves the block by name against the program's reflected -+ // interface and binds it to the same point, so the four GL steps - -+ // allocate, look up the block index, bind the index, bind the buffer - -+ // collapse into one call. UBO.Handle then carries the device's handle. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ UBO optimumUbo = new UBO(); -+ optimumUbo.Handle = optimumDevice.CreateUniformBuffer(shaderProgramId, bindingPoint, blockName, size); -+ optimumUbo.Size = size; -+ optimumUbo.BlockName = blockName; -+ optimumUbo.BindingPoint = bindingPoint; -+ return optimumUbo; -+ } - int num = GL.GenBuffer(); - GL.BindBuffer((BufferTarget)35345, num); - GL.BufferData((BufferTarget)35345, size, (IntPtr)IntPtr.Zero, (BufferUsageHint)35048); - ScreenManager.Platform.CheckGlError(); - int uniformBlockIndex = GL.GetUniformBlockIndex(shaderProgramId, blockName); -@@ -2593,15 +4299,77 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4284,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -2477,30 +2449,17 @@ index 6edf0c9..2f72ffd 100644 return uBO; } -+ // Optimum (Vulkan-native plan, Phase 1A step 3): the uniform-buffer bodies UBO used -+ // to hold, moved here unchanged: the device branch, then the GL lines. UBO.Handle -+ // carries the device's uniform-buffer handle on the device path, the same convention -+ // VAO.VaoId uses for meshes, and the binding point survives from CreateUBO. ++ // Optimum (Vulkan-native plan, Phase 1A step 3): the uniform-buffer GL lines UBO used ++ // to hold, moved here unchanged; VulkanClientPlatform overrides them with the device ++ // calls (step 4). The binding point survives from CreateUBO. + public override void BindUBO(UBO ubo) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.BindUniformBuffer(ubo.Handle); -+ return; -+ } + GL.BindBuffer((BufferTarget)35345, ubo.Handle); + GL.BindBufferBase((BufferRangeTarget)35345, ubo.BindingPoint, ubo.Handle); + } + + public override void UnbindUBO(UBO ubo) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.UnbindUniformBuffer(ubo.Handle); -+ return; -+ } + GL.BindBuffer((BufferTarget)35345, 0); + } + @@ -2508,12 +2467,6 @@ index 6edf0c9..2f72ffd 100644 + // ranged updates write into it (glBufferSubData). The device writes a range either way. + public override void UpdateUBO(UBO ubo, IntPtr data, int offset, int size, bool reallocate) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.UpdateUniformBuffer(ubo.Handle, data, offset, size); -+ return; -+ } + ubo.Bind(); + if (reallocate) + { @@ -2528,12 +2481,6 @@ index 6edf0c9..2f72ffd 100644 + + public override void DeleteUBO(UBO ubo) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DeleteUniformBuffer(ubo.Handle); -+ return; -+ } + GL.DeleteBuffers(1, ref ubo.Handle); + } + @@ -2542,7 +2489,7 @@ index 6edf0c9..2f72ffd 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4419,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4379,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2587,7 +2534,7 @@ index 6edf0c9..2f72ffd 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4456,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4416,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2608,7 +2555,7 @@ index 6edf0c9..2f72ffd 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4475,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4435,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2629,7 +2576,7 @@ index 6edf0c9..2f72ffd 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4494,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4454,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2650,7 +2597,7 @@ index 6edf0c9..2f72ffd 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4513,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4473,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2671,7 +2618,7 @@ index 6edf0c9..2f72ffd 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4536,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4496,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2692,7 +2639,7 @@ index 6edf0c9..2f72ffd 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5098,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5058,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -2716,51 +2663,22 @@ index 6edf0c9..2f72ffd 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3675,15 +5454,328 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - return new MouseEvent((int)mouseX, (int)mouseY, button, 0); - } - +@@ -3678,10 +5417,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ return optimumDevice.GetUniformLocation(program.ProgramId, name); -+ } return GL.GetUniformLocation(program.ProgramId, name); } + // Optimum (Vulkan-native plan, Phase 1A step 3): the program, uniform and texture -+ // binding bodies ShaderProgramBase used to hold, moved here unchanged: the device -+ // branch, then the GL lines. A uniform location here is whatever GetUniformLocation -+ // handed out - a byte offset into the generated block on the device path - so the -+ // setters read the same uniformLocations dictionary on both paths. ++ // binding GL lines ShaderProgramBase used to hold, moved here unchanged; ++ // VulkanClientPlatform overrides them with the device calls (step 4). + public override void UseShaderProgram(int programId) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.UseProgram(programId); -+ return; -+ } + GL.UseProgram(programId); + } + + public override void DisposeShaderProgram(ShaderProgramBase program) + { -+ // The device owns the SPIR-V modules inside the program and frees them -+ // with it, so there is nothing matching GL's detach-and-delete of the -+ // individual stages. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ foreach (KeyValuePair optimumSampler in program.customSamplers) -+ { -+ optimumDevice.DeleteSampler(optimumSampler.Value); -+ } -+ optimumDevice.DeleteProgram(program.ProgramId); -+ return; -+ } + if (program.VertexShader != null) + { + GL.DetachShader(program.ProgramId, program.VertexShader.ShaderId); @@ -2785,213 +2703,81 @@ index 6edf0c9..2f72ffd 100644 + + public override void BindSampler(int unit, int samplerId) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.BindSampler(unit, samplerId); -+ return; -+ } + GL.BindSampler(unit, samplerId); + } + + public override void SetUniform(int programId, int location, float value) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniform(programId, location, value); -+ return; -+ } + GL.Uniform1(location, value); + } + + public override void SetUniform(int programId, int location, int value) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniform(programId, location, value); -+ return; -+ } + GL.Uniform1(location, value); + } + + public override void SetUniform(int programId, int location, float x, float y) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniform(programId, location, x, y); -+ return; -+ } + GL.Uniform2(location, x, y); + } + + public override void SetUniform(int programId, int location, float x, float y, float z) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniform(programId, location, x, y, z); -+ return; -+ } + GL.Uniform3(location, x, y, z); + } + + public override void SetUniform(int programId, int location, float x, float y, float z, float w) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniform(programId, location, x, y, z, w); -+ return; -+ } + GL.Uniform4(location, x, y, z, w); + } + + public override void SetUniform(int programId, int location, int x, int y, int z) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // Unlike the Vec2i overload, which casts to float before it gets here, -+ // Vec3i keeps integers, so the shader declares an ivec3. The location is -+ // opaque to this side, so the device lays the three components out itself. -+ optimumDevice.SetUniform(programId, location, x, y, z); -+ return; -+ } + GL.Uniform3(location, x, y, z); + } + + public override void SetUniformArray1(int programId, int location, int count, float[] values) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformArray1(programId, location, count, values); -+ return; -+ } + GL.Uniform1(location, count, values); + } + + public override void SetUniformArray2(int programId, int location, int count, float[] values) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformArray2(programId, location, count, values); -+ return; -+ } + GL.Uniform2(location, count, values); + } + + public override void SetUniformArray3(int programId, int location, int count, float[] values) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformArray3(programId, location, count, values); -+ return; -+ } + GL.Uniform3(location, count, values); + } + + public override void SetUniformArray4(int programId, int location, int count, float[] values) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformArray4(programId, location, count, values); -+ return; -+ } + GL.Uniform4(location, count, values); + } + + public override void SetUniformMatrix(int programId, int location, float[] matrix) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformMatrix(programId, location, matrix); -+ return; -+ } + GL.UniformMatrix4(location, 1, false, matrix); + } + + public override void SetUniformMatrix(int programId, int location, ref Matrix4 matrix) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // Only the sun and moon renderers use this overload, a handful of -+ // times per frame, so flattening into an array here is not worth a -+ // dedicated entry point on the seam. -+ float[] optimumMatrix = new float[16]; -+ optimumMatrix[0] = matrix.M11; optimumMatrix[1] = matrix.M12; -+ optimumMatrix[2] = matrix.M13; optimumMatrix[3] = matrix.M14; -+ optimumMatrix[4] = matrix.M21; optimumMatrix[5] = matrix.M22; -+ optimumMatrix[6] = matrix.M23; optimumMatrix[7] = matrix.M24; -+ optimumMatrix[8] = matrix.M31; optimumMatrix[9] = matrix.M32; -+ optimumMatrix[10] = matrix.M33; optimumMatrix[11] = matrix.M34; -+ optimumMatrix[12] = matrix.M41; optimumMatrix[13] = matrix.M42; -+ optimumMatrix[14] = matrix.M43; optimumMatrix[15] = matrix.M44; -+ optimumDevice.SetUniformMatrix(programId, location, optimumMatrix); -+ return; -+ } + GL.UniformMatrix4(location, false, ref matrix); + } + + public override void SetUniformMatrices(int programId, int location, int count, float[] matrices) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformMatrices(programId, location, count, matrices); -+ return; -+ } + GL.UniformMatrix4(location, count, false, matrices); + } + + public override void SetUniformMatrices4x3(int programId, int location, int count, float[] matrices) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetUniformMatrices4x3(programId, location, count, matrices); -+ return; -+ } + GL.UniformMatrix4x3(location, count, false, matrices); + } + + public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // In GL this is three separate things - point the sampler uniform at -+ // a unit, activate that unit, bind the texture. The device keeps the -+ // same split so the two halves can be set independently, which the -+ // render systems rely on. -+ optimumDevice.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); -+ optimumDevice.BindTexture(textureNumber, textureId); -+ if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) -+ { -+ optimumDevice.BindSampler(textureNumber, optimumSampler); -+ } -+ else -+ { -+ // Clear any override left on this unit, or the texture's own -+ // filtering would be silently ignored. -+ optimumDevice.BindSampler(textureNumber, 0); -+ } -+ if (program.clampTToEdge) -+ { -+ optimumDevice.SetTextureParameter(textureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, -+ Vintagestory.API.Config.OptimumGlConstants.ClampToEdge); -+ } -+ return; -+ } + GL.Uniform1(program.uniformLocations[samplerName], textureNumber); + GL.ActiveTexture((TextureUnit)(33984 + textureNumber)); + GL.BindTexture((TextureTarget)3553, textureId); @@ -3007,19 +2793,6 @@ index 6edf0c9..2f72ffd 100644 + + public override void BindProgramTextureCube(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); -+ optimumDevice.BindTextureCube(textureNumber, textureId); -+ if (program.clampTToEdge) -+ { -+ optimumDevice.SetTextureParameter(textureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, -+ Vintagestory.API.Config.OptimumGlConstants.ClampToEdge); -+ } -+ return; -+ } + GL.Uniform1(program.uniformLocations[samplerName], textureNumber); + GL.ActiveTexture((TextureUnit)(33984 + textureNumber)); + GL.BindTexture((TextureTarget)34067, textureId); @@ -3031,46 +2804,6 @@ index 6edf0c9..2f72ffd 100644 + public override bool CompileShader(Shader shader) { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The device only stages the stage here. GL resolves uniforms and -+ // varyings by name across the whole program, so nothing about a stage -+ // is final until its siblings are known, and the real translation -+ // happens at link time. -+ return optimumDevice.CompileShader(shader); -+ } int num = (shader.ShaderId = GL.CreateShader((ShaderType)shader.shaderType)); string text = shader.Code; if (text != null) - { - if (text.IndexOfOrdinal("#version") == -1) -@@ -3715,10 +5807,28 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - return true; - } - - public override bool CreateShaderProgram(ShaderProgram program) - { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ // The device assigns the id, exactly as glCreateProgram did, and the -+ // caller stores it - IShaderProgram.ProgramId is read-only on the -+ // interface, so it comes back as a return value. -+ int optimumProgramId = optimumDevice.LinkProgram(program); -+ if (optimumProgramId == 0) -+ { -+ string optimumLinkError = optimumDevice.GetError(); -+ logger.Error("Link error in shader program for pass {0}: {1}", -+ program.PassName, optimumLinkError == null ? "unknown" : optimumLinkError); -+ return false; -+ } -+ program.ProgramId = optimumProgramId; -+ logger.Notification("Loaded Shaderprogramm for render pass {0}.", program.PassName); -+ return true; -+ } - bool result = true; - int num = (program.ProgramId = GL.CreateProgram()); - GL.AttachShader(num, program.VertexShader.ShaderId); - GL.AttachShader(num, program.FragmentShader.ShaderId); - if (program.GeometryShader != null) From 54dc22fc68ed0e1cb0638f96268126e283ceae48 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 19:59:04 +0200 Subject: [PATCH 094/226] wip(phase1a-step4): frame loop, thick-line probe, resize and parity readback are platform virtuals; ClientPlatformWindows has no device branch left window_RenderFrame calls BeginFrame(); frameHandler.OnNewFrame(dt); [parity dump]; EndFrame() (BeginFrame empty on the abstract, EndFrame = SwapBuffers in ClientPlatformWindows); Start sets SupportsThickLines = ProbeThickLineSupport() (GL probe moved into the override); Window_Resize calls OnWindowSizeChanged(width, height) before RebuildFrameBuffers(); OptimumParityDumpAttachment reads through ReadTextureForParity (GL override = OptimumParityReadTextureGl). VulkanClientPlatform.Frame.cs overrides them with device.BeginFrame/Present/SetLineWidth+SupportsThickLines/Resize/ReadTextureForParity, and the self-check lists them. Verified: dotnet build VintageStory.slnx -c Release 0 errors; grep OptimumRender.Device in ClientPlatformWindows.cs = 0; extract + check-patches 0 conflict, 0 pending; Cecil patch (output bin/patch-check) 197/197 required methods patched, Virtual dispatch verifier ok (25 callvirt, 0 call/ldftn). --- Optimum.Patcher/Program.cs | 4 + .../Platform/VulkanClientPlatform.Frame.cs | 48 +++++ .../Platform/VulkanClientPlatform.cs | 16 +- .../ClientPlatformWindows.cs.patch | 172 +++++++++--------- 4 files changed, 146 insertions(+), 94 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 4ad5baff..0ab11ba8 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -228,6 +228,10 @@ "ClearSsaoTarget", "BeginFinalCompositionDrawBuffers", "RestoreWorldDrawBuffers", + // Phase 1A step 4: the GL frame end, thick-line probe and parity readback. + "EndFrame", + "ProbeThickLineSupport", + "ReadTextureForParity", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumGlR32f", diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs new file mode 100644 index 00000000..9bdb6d2e --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -0,0 +1,48 @@ +using Vintagestory.API.Config; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: the frame bracket, the thick-line capability, the +// window-size notification and the parity-dump readback - the device calls that used to +// sit in ClientPlatformWindows.window_RenderFrame, Start, Window_Resize and +// OptimumParityDumpAttachment. The base keeps the frame pacing, the frame handler call and +// the parity dump itself. +public partial class VulkanClientPlatform +{ + /// Recycles the frame slot and opens a command buffer. + public override void BeginFrame() + { + device.BeginFrame(); + } + + /// + /// Closes the rendering scope, submits, and blits the result into the swapchain. Runs + /// after the frame handler and the parity dump, exactly where GL swaps buffers. + /// + public override void EndFrame() + { + device.Present(); + } + + /// GL probes by setting a width; the device answers it as a capability. + public override bool ProbeThickLineSupport() + { + device.SetLineWidth(1.5f); + return device.SupportsThickLines; + } + + /// + /// The device owns the default render target and the swapchain, and neither follows + /// the window on its own. The base calls this before RebuildFrameBuffers. + /// + public override void OnWindowSizeChanged(int width, int height) + { + device.Resize(width, height); + } + + /// The single call site of the device's parity readback. + public override OptimumTextureReadback ReadTextureForParity(int textureId) + { + return device.ReadTextureForParity(textureId); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index dad79823..f3815ab9 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -10,10 +10,12 @@ namespace Optimum.Render.Vulkan.Platform; /// The client platform on the Vulkan path (Vulkan-native plan, Phase 1A). /// /// Created by in place of a -/// plain , so windowing, input, audio and the -/// frame loop are inherited. In this step it only owns graphics bring-up and -/// teardown; the base's existing branches keep -/// rendering until later steps move them into overrides here. +/// plain , so windowing, input, audio, the frame +/// pacing and the API-neutral render logic (post chain, TAA windows) are inherited. +/// It owns graphics bring-up and teardown and, since Phase 1A step 4, every graphics +/// operation: the partial files override each graphics member with calls to the +/// this platform created, and ClientPlatformWindows keeps +/// only the GL path. /// /// Compiled against the donor lib and bound at runtime to the Cecil-patched one, /// so first checks that the loaded lib really @@ -71,6 +73,12 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "ClearSsaoTarget", Array.Empty()), new(true, "BeginFinalCompositionDrawBuffers", Array.Empty()), new(true, "RestoreWorldDrawBuffers", new[] { "Boolean" }), + // Phase 1A step 4: frame bracket, thick-line probe, window size, parity readback. + new(true, "BeginFrame", Array.Empty()), + new(true, "EndFrame", Array.Empty()), + new(true, "ProbeThickLineSupport", Array.Empty()), + new(true, "OnWindowSizeChanged", new[] { "Int32", "Int32" }), + new(true, "ReadTextureForParity", new[] { "Int32" }), }; /// diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 0ddb0d7e..aa89d989 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..8de2a1d 100644 +index 6edf0c9..e7837a0 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -337,7 +337,7 @@ index 6edf0c9..8de2a1d 100644 public override bool GlDebugMode { get -@@ -478,40 +713,147 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +713,140 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -453,44 +453,38 @@ index 6edf0c9..8de2a1d 100644 ShadowMapQuality = ClientSettings.ShadowMapQuality; ShaderProgramBase.shadowmapQuality = ShadowMapQuality; + -+ // Optimum: the device brackets the frame. BeginFrame recycles the frame -+ // slot and opens a command buffer; Present closes the rendering scope, -+ // submits, and blits the result into the swapchain. On the OpenGL path -+ // this is one null check and SwapBuffers, exactly as before. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.BeginFrame(); -+ frameHandler.OnNewFrame(dt); -+ // Optimum: per-attachment parity dump (OPTIMUM_PARITY_DUMP), after the -+ // post chain and the final blit, before presentation. Unset, this is -+ // one static bool check. -+ if (Vintagestory.API.Config.OptimumParityDump.Enabled) -+ { -+ OptimumRunParityDump(); -+ } -+ optimumDevice.Present(); -+ ScreenManager.FrameProfiler.End(); -+ return; -+ } -+ ++ // Optimum (Vulkan-native plan, Phase 1A step 4): the platform brackets the ++ // frame. On OpenGL BeginFrame is empty and EndFrame is SwapBuffers, exactly as ++ // before; VulkanClientPlatform recycles the frame slot and opens a command ++ // buffer in BeginFrame and submits and presents in EndFrame. ++ BeginFrame(); frameHandler.OnNewFrame(dt); +- ((GameWindow)window).SwapBuffers(); ++ // Optimum: per-attachment parity dump (OPTIMUM_PARITY_DUMP), after the ++ // post chain and the final blit, before presentation. Unset, this is ++ // one static bool check. + if (Vintagestory.API.Config.OptimumParityDump.Enabled) + { + OptimumRunParityDump(); + } - ((GameWindow)window).SwapBuffers(); ++ EndFrame(); ScreenManager.FrameProfiler.End(); } - public string GetGraphicsCardRenderer() ++ /// Optimum (Phase 1A step 4): presents the frame on the OpenGL path. ++ public override void EndFrame() ++ { ++ ((GameWindow)window).SwapBuffers(); ++ } ++ + public virtual string GetGraphicsCardRenderer() { return GL.GetString((StringName)7937); } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +873,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +866,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -503,27 +497,29 @@ index 6edf0c9..8de2a1d 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1044,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1037,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); windowsize.Width = ((NativeWindow)window).ClientSize.X; windowsize.Height = ((NativeWindow)window).ClientSize.Y; + // Mono.Cecil transplant. -+ // GL discovers thick-line support by setting a width and seeing whether -+ // that raised an error; the device answers it as a capability instead. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumStartDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumStartDevice != null) -+ { -+ optimumStartDevice.SetLineWidth(1.5f); -+ SupportsThickLines = optimumStartDevice.SupportsThickLines; -+ cpuCoreCount = Environment.ProcessorCount; -+ return; -+ } ++ SupportsThickLines = ProbeThickLineSupport(); ++ cpuCoreCount = Environment.ProcessorCount; ++ } ++ ++ /// ++ /// Optimum (Phase 1A step 4): GL discovers thick-line support by setting a width and ++ /// seeing whether that raised an error; VulkanClientPlatform answers it as a device ++ /// capability instead. ++ /// ++ public override bool ProbeThickLineSupport() ++ { GL.LineWidth(1.5f); OpenTK.Graphics.OpenGL.ErrorCode error = GL.GetError(); - SupportsThickLines = (int)error != 1281; - cpuCoreCount = Environment.ProcessorCount; +- SupportsThickLines = (int)error != 1281; +- cpuCoreCount = Environment.ProcessorCount; ++ return (int)error != 1281; } public override void RebuildFrameBuffers() @@ -546,31 +542,27 @@ index 6edf0c9..8de2a1d 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1156,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1149,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) { logger.Notification("Window was resized to {0} {1}, rebuilding framebuffers...", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); + // Mono.Cecil transplant. -+ // The device owns the default render target and the swapchain, and -+ // neither follows the window on its own. This has to happen before -+ // RebuildFrameBuffers, because that recreates the client's own -+ // targets against the new size and the default one has to agree with -+ // them - a default target left at the old size silently clips every -+ // draw to its top-left corner, since the viewport is set from the -+ // window rather than from the target. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.Resize(((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); -+ } ++ // A platform that owns the default render target and the swapchain ++ // (VulkanClientPlatform) has to follow the window before ++ // RebuildFrameBuffers, because that recreates the client's own targets ++ // against the new size and the default one has to agree with them - a ++ // default target left at the old size silently clips every draw to its ++ // top-left corner, since the viewport is set from the window rather ++ // than from the target. Empty on OpenGL. ++ OnWindowSizeChanged(((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); RebuildFrameBuffers(); windowsize.Width = ((NativeWindow)window).ClientSize.X; windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1396,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1385,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -583,7 +575,7 @@ index 6edf0c9..8de2a1d 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,11 +1523,300 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,11 +1512,300 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -728,16 +720,7 @@ index 6edf0c9..8de2a1d 100644 + string where = slot + " " + attachment; + try + { -+ OptimumTextureReadback readback; -+ Vintagestory.API.Config.IOptimumGraphicsDevice device = Vintagestory.API.Config.OptimumRender.Device; -+ if (device != null) -+ { -+ readback = device.ReadTextureForParity(textureId); -+ } -+ else -+ { -+ readback = OptimumParityReadTextureGl(textureId); -+ } ++ OptimumTextureReadback readback = ReadTextureForParity(textureId); + if (readback == null) + { + logger.Warning("[Optimum] parity dump: slot " + where + " could not be read"); @@ -753,6 +736,15 @@ index 6edf0c9..8de2a1d 100644 + } + + /// ++ /// Optimum (Phase 1A step 4): the parity dump's readback on the OpenGL path; ++ /// VulkanClientPlatform overrides it with the device readback. ++ /// ++ public override OptimumTextureReadback ReadTextureForParity(int textureId) ++ { ++ return OptimumParityReadTextureGl(textureId); ++ } ++ ++ /// + /// Optimum: glGetTexImage of level 0 in the parity dump's representation - + /// RGBA/UNSIGNED_BYTE for 8-bit unsigned-normalised formats, DEPTH_COMPONENT/FLOAT + /// for depth, RGBA/FLOAT otherwise - rows bottom-up as GL returns them. Unbinds @@ -885,7 +877,7 @@ index 6edf0c9..8de2a1d 100644 //IL_0072: Unknown result type (might be due to invalid IL or missing references) //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +1849,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +1838,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -903,7 +895,7 @@ index 6edf0c9..8de2a1d 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +1879,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +1868,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -920,7 +912,7 @@ index 6edf0c9..8de2a1d 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +1924,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +1913,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -959,7 +951,7 @@ index 6edf0c9..8de2a1d 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2137,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2126,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1035,7 +1027,7 @@ index 6edf0c9..8de2a1d 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2314,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2303,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1050,7 +1042,7 @@ index 6edf0c9..8de2a1d 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2337,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2326,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1145,7 +1137,7 @@ index 6edf0c9..8de2a1d 100644 } } } -@@ -1591,11 +2431,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2420,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1164,7 +1156,7 @@ index 6edf0c9..8de2a1d 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2466,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2455,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1251,7 +1243,7 @@ index 6edf0c9..8de2a1d 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +2575,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +2564,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1337,7 +1329,7 @@ index 6edf0c9..8de2a1d 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +2655,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +2644,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1423,7 +1415,7 @@ index 6edf0c9..8de2a1d 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +2737,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +2726,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1684,7 +1676,7 @@ index 6edf0c9..8de2a1d 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3001,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +2990,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1721,7 +1713,7 @@ index 6edf0c9..8de2a1d 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3040,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3029,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1776,7 +1768,7 @@ index 6edf0c9..8de2a1d 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,35 +3110,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,35 +3099,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1827,7 +1819,7 @@ index 6edf0c9..8de2a1d 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,19 +3159,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3148,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1853,7 +1845,7 @@ index 6edf0c9..8de2a1d 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,24 +3195,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3184,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1910,7 +1902,7 @@ index 6edf0c9..8de2a1d 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3250,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3239,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2396,7 +2388,7 @@ index 6edf0c9..8de2a1d 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +3886,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +3875,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -2436,7 +2428,7 @@ index 6edf0c9..8de2a1d 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4284,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4273,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -2489,7 +2481,7 @@ index 6edf0c9..8de2a1d 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4379,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4368,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2534,7 +2526,7 @@ index 6edf0c9..8de2a1d 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4416,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4405,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2555,7 +2547,7 @@ index 6edf0c9..8de2a1d 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4435,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4424,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2576,7 +2568,7 @@ index 6edf0c9..8de2a1d 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4454,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4443,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2597,7 +2589,7 @@ index 6edf0c9..8de2a1d 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4473,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4462,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2618,7 +2610,7 @@ index 6edf0c9..8de2a1d 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4496,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4485,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2639,7 +2631,7 @@ index 6edf0c9..8de2a1d 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5058,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5047,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -2663,7 +2655,7 @@ index 6edf0c9..8de2a1d 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +5417,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +5406,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From d7d35aa9fccf6bb76265a03230c11f9e49cca286 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:10:20 +0200 Subject: [PATCH 095/226] wip(phase1b-step4): split present submission and swapchain retirement without DeviceWaitIdle Present submits the frame (Submit A, no waits), then acquires, then submits the present path (Submit B: the flipped blit, now BlitPresentPath) waiting on the Frame timeline at COLOR_ATTACHMENT_OUTPUT and on the acquire semaphore at TRANSFER, signalling the per-image present semaphore; no ALL_COMMANDS wait stage remains. Swapchain moved to Present/: a SwapchainSlot owns images, views, imageCount+1 acquire semaphores (reused only after the present submission that waited on them completed, VUID-vkAcquireNextImageKHR-semaphore-01779) and per-image present semaphores, and is retired as one unit on the Frame timeline after its last present submission. oldSwapchain is always passed; Resize and SetVSync request a rebuild at the next acquire; SUBOPTIMAL rebuilds before the next acquire, OUT_OF_DATE rebuilds and re-acquires once, zero extent parks. Present modes: FIFO with vsync (FIFO_RELAXED promotion after sustained missed vsyncs, OPTIMUM_VULKAN_FIFO_RELAXED=0 disables), MAILBOX else IMMEDIATE without; minImageCount = max(min+1, mailbox ? 3 : 2) clamped. Swapchain barriers carry TRANSFER stages (a stage-less first barrier was a SYNC-HAZARD-WRITE-AFTER-READ against the acquire). VulkanContextOptions.AcquireDelayForTests added. Verified in this worktree: dotnet build VintageStory.slnx -c Release 0 errors; Optimum.Tests 1070 passed, 34 skipped, 0 failed; Optimum.Render.Vulkan.Tests 456/456 passed with sync,best validation, including new SwapchainRetirementTests, PresentWaitStageTests (pure), PresentDecouplingTests and SwapchainRecreationTests (hidden-window resize and vsync loop: 14 resizes, 14 swapchains, 0 DeviceWaitIdle, all retired slots destroyed) and the existing SwapchainTests. No lib, patch or API change, so no Cecil patch or vanilla-compat run. Not run in game. --- .../PacingStatsTests.cs | 2 +- .../PresentDecouplingTests.cs | 164 +++++ .../PresentWaitStageTests.cs | 61 ++ .../SwapchainRecreationTests.cs | 115 ++++ .../SwapchainRetirementTests.cs | 288 +++++++++ Optimum.Render.Vulkan.Tests/SwapchainTests.cs | 4 +- Optimum.Render.Vulkan/Core/FrameRing.cs | 104 +++- Optimum.Render.Vulkan/Core/Swapchain.cs | 408 ------------ Optimum.Render.Vulkan/Core/VulkanContext.cs | 10 + Optimum.Render.Vulkan/Present/IPresentPath.cs | 139 +++++ Optimum.Render.Vulkan/Present/Swapchain.cs | 588 ++++++++++++++++++ .../Present/SwapchainRetirement.cs | 277 +++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 167 +++-- .../temporal-render-inventory-tests.cs | 11 +- .../vulkan-backend-integration-tests.cs | 42 +- 15 files changed, 1838 insertions(+), 542 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/PresentDecouplingTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/PresentWaitStageTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SwapchainRecreationTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs delete mode 100644 Optimum.Render.Vulkan/Core/Swapchain.cs create mode 100644 Optimum.Render.Vulkan/Present/IPresentPath.cs create mode 100644 Optimum.Render.Vulkan/Present/Swapchain.cs create mode 100644 Optimum.Render.Vulkan/Present/SwapchainRetirement.cs diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 4a5ac396..73d8778d 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -329,7 +329,7 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() Assert.DoesNotContain("NoteBlockingUpload", uploads); Assert.DoesNotContain("WaitSite.UploadSubmit", Source("Core/TextureManager.cs")); - string swapchain = Source("Core/Swapchain.cs"); + string swapchain = Source("Present/Swapchain.cs"); Assert.Contains("WaitSite.SwapchainAcquire", Body(swapchain, "public bool TryAcquire(")); Assert.Contains("WaitSite.Present", Body(swapchain, "public void Present(")); diff --git a/Optimum.Render.Vulkan.Tests/PresentDecouplingTests.cs b/Optimum.Render.Vulkan.Tests/PresentDecouplingTests.cs new file mode 100644 index 00000000..f079f4ea --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PresentDecouplingTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 1B step 4: the CPU blocks on the acquire only after the frame is in +/// flight. An injected acquire delay (a compositor holding images back) must not +/// lengthen frame recording, the frame must reach the queue before the acquire +/// starts, and the GPU must be able to finish the frame while the CPU still +/// waits for the image. +/// +public class PresentDecouplingTests +{ + private const int Width = 256; + private const int Height = 192; + private const int Frames = 24; + private const int Warmup = 4; + private static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(60); + + private readonly ITestOutputHelper _output; + + public PresentDecouplingTests(ITestOutputHelper output) => _output = output; + + private sealed class Measurements + { + public readonly List RecordingMs = new(); + public readonly List SubmitAfterPresentEntryMs = new(); + public readonly List AcquireAfterSubmitMs = new(); + public int RenderCompletedAtAcquire; + public int Presented; + public int Samples; + public long DeviceIdleWaits; + public PipelineStageFlags AcquireStage; + } + + [SkippableFact] + public unsafe void RecordingTimeDoesNotGrowWithTheInjectedAcquireDelay() + { + Skip.IfNot(SwapchainTests.TryCreateWindow(_output, Width, Height, out Window* window), "No usable window system."); + + try + { + Measurements baseline = Run((IntPtr)window, TimeSpan.Zero); + Measurements delayed = Run((IntPtr)window, Delay); + + double baselineRecording = Median(baseline.RecordingMs); + double delayedRecording = Median(delayed.RecordingMs); + _output.WriteLine($"recording median: no delay {baselineRecording:F2} ms, {Delay.TotalMilliseconds} ms delay {delayedRecording:F2} ms"); + _output.WriteLine($"acquire after frame submit median: no delay {Median(baseline.AcquireAfterSubmitMs):F2} ms, delayed {Median(delayed.AcquireAfterSubmitMs):F2} ms"); + _output.WriteLine($"frame finished on the GPU before the acquire returned: {delayed.RenderCompletedAtAcquire}/{delayed.Samples}"); + + Assert.True(delayedRecording < baselineRecording + Delay.TotalMilliseconds / 4, + $"recording grew with the acquire delay: {baselineRecording:F2} -> {delayedRecording:F2} ms"); + + foreach (Measurements run in new[] { baseline, delayed }) + { + Assert.Equal(run.Samples, run.Presented); + Assert.Equal(0, run.DeviceIdleWaits); + Assert.Equal(PipelineStageFlags.TransferBit, run.AcquireStage); + Assert.NotEqual(PipelineStageFlags.AllCommandsBit, run.AcquireStage); + } + + // The frame is submitted before the acquire starts, so the delay lands + // between the two, never before the frame submission. + foreach (double ms in delayed.SubmitAfterPresentEntryMs) + { + Assert.True(ms < Delay.TotalMilliseconds / 3, $"the frame reached the queue {ms:F2} ms into Present"); + } + foreach (double ms in delayed.AcquireAfterSubmitMs) + { + Assert.True(ms >= Delay.TotalMilliseconds * 0.9, $"the acquire returned {ms:F2} ms after the frame submit"); + } + + // With the frame already queued, a 256x192 frame finishes well inside + // the delay. Two stragglers are tolerated for a busy machine. + Assert.True(delayed.RenderCompletedAtAcquire >= delayed.Samples - 2, + $"the GPU finished the frame during the acquire in only {delayed.RenderCompletedAtAcquire} of {delayed.Samples} frames"); + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + private Measurements Run(IntPtr window, TimeSpan delay) + { + VulkanDevice device = GpuTest.NewDevice(); + Action? suite = device.ConfigureContextOptions; + device.ConfigureContextOptions = options => + { + suite?.Invoke(options); + options.AcquireDelayForTests = delay; + }; + + if (!device.Initialize(window, Width, Height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + } + + var result = new Measurements(); + using (device) + { + IOptimumGraphicsDevice seam = device; + int programId = SwapchainTests.LinkFullscreenProgram(seam); + result.AcquireStage = device.PresentAcquireWaitStageForTests; + long idleBefore = VulkanStats.WaitCount(WaitSite.DeviceWaitIdle); + + for (int frame = 0; frame < Frames; frame++) + { + // Recording: from the start of BeginFrame (its pacing wait included) + // to the moment Present is called. + long frameStart = Stopwatch.GetTimestamp(); + seam.BeginFrame(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.1f, 0.2f, 0.3f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, Width, Height); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.DrawFullscreenTriangle(); + long recorded = Stopwatch.GetTimestamp(); + seam.Present(); + + if (frame < Warmup) continue; + VulkanDevice.PresentTimings timings = device.LastPresentTimingsForTests; + result.Samples++; + result.RecordingMs.Add(Ms(recorded - frameStart)); + result.SubmitAfterPresentEntryMs.Add(Ms(timings.FrameSubmitted - timings.PresentEntry)); + result.AcquireAfterSubmitMs.Add(Ms(timings.AcquireReturned - timings.FrameSubmitted)); + if (timings.RenderCompletedAtAcquire) result.RenderCompletedAtAcquire++; + if (timings.Presented) + { + result.Presented++; + Assert.True(timings.PresentValue > timings.RenderValue, + "the present submission must carry a newer Frame value than the frame it waits on"); + } + } + + result.DeviceIdleWaits = VulkanStats.WaitCount(WaitSite.DeviceWaitIdle) - idleBefore; + GpuTest.AssertClean(seam); + } + return result; + } + + private static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency; + + private static double Median(List values) + { + var sorted = new List(values); + sorted.Sort(); + return sorted.Count == 0 ? 0 : sorted[sorted.Count / 2]; + } +} diff --git a/Optimum.Render.Vulkan.Tests/PresentWaitStageTests.cs b/Optimum.Render.Vulkan.Tests/PresentWaitStageTests.cs new file mode 100644 index 00000000..f5d70953 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PresentWaitStageTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The present submission's wait stages, without a device: the frame is waited +/// on at COLOR_ATTACHMENT_OUTPUT, the acquire semaphore at the swapchain image's +/// first use (TRANSFER for the blit, COLOR_ATTACHMENT_OUTPUT for a raster path), +/// and no ALL_COMMANDS wait stage remains anywhere on the submission path. +/// +public class PresentWaitStageTests +{ + [Fact] + public void TheFrameIsWaitedOnAtColorAttachmentOutput() => + Assert.Equal(PipelineStageFlags.ColorAttachmentOutputBit, PresentWaitStages.FrameWait); + + [Fact] + public void TheBlitPathWaitsForTheAcquiredImageAtTransfer() + { + IPresentPath blit = new BlitPresentPath(null!, null!, () => null); + Assert.Equal(PipelineStageFlags.TransferBit, blit.AcquireWaitStage); + Assert.Equal(blit.AcquireWaitStage, PresentWaitStages.RequireAcquireStage(blit.AcquireWaitStage)); + } + + [Theory] + [InlineData(PipelineStageFlags.AllCommandsBit)] + [InlineData(PipelineStageFlags.AllGraphicsBit)] + [InlineData(PipelineStageFlags.TopOfPipeBit)] + [InlineData(PipelineStageFlags.BottomOfPipeBit)] + [InlineData(PipelineStageFlags.FragmentShaderBit)] + [InlineData(PipelineStageFlags.TransferBit | PipelineStageFlags.ColorAttachmentOutputBit)] + public void AnyOtherAcquireWaitStageIsRefused(PipelineStageFlags stage) => + Assert.Throws(() => PresentWaitStages.RequireAcquireStage(stage)); + + [Fact] + public void BothAllowedAcquireStagesAreAccepted() + { + Assert.Equal(PipelineStageFlags.TransferBit, + PresentWaitStages.RequireAcquireStage(PresentWaitStages.BlitAcquireWait)); + Assert.Equal(PipelineStageFlags.ColorAttachmentOutputBit, + PresentWaitStages.RequireAcquireStage(PresentWaitStages.RasterAcquireWait)); + } + + /// The submission code itself: no ALL_COMMANDS wait stage, and both waits come from PresentWaitStages. + [Fact] + public void TheSubmissionPathHasNoAllCommandsWaitStage() + { + string root = Path.Combine(ShaderCorpus.RepositoryRoot, "Optimum.Render.Vulkan"); + string ring = File.ReadAllText(Path.Combine(root, "Core", "FrameRing.cs")); + Assert.DoesNotContain("PipelineStageFlags.AllCommandsBit", ring); + Assert.Contains("waitStages[waitCount] = PresentWaitStages.FrameWait;", ring); + Assert.Contains("PresentWaitStages.RequireAcquireStage(acquireStage);", ring); + + string device = File.ReadAllText(Path.Combine(root, "VulkanDevice.cs")); + Assert.Contains("_presentPath.AcquireWaitStage, renderValue, target.PresentSemaphore);", device); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SwapchainRecreationTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainRecreationTests.cs new file mode 100644 index 00000000..4384899b --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SwapchainRecreationTests.cs @@ -0,0 +1,115 @@ +using System; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 1B step 4 against a real (hidden) window: resizing and vsync toggles +/// rebuild the swapchain with oldSwapchain while frames are in flight, never +/// wait for the device to go idle, retire every replaced slot once the GPU is +/// past it, and stay clean under sync and best-practices validation. +/// +public class SwapchainRecreationTests +{ + private const int Width = 256; + private const int Height = 192; + + private readonly ITestOutputHelper _output; + + public SwapchainRecreationTests(ITestOutputHelper output) => _output = output; + + [SkippableFact] + public unsafe void AHiddenWindowResizeLoopRecreatesWithoutWaitingAndStaysClean() + { + Skip.IfNot(SwapchainTests.TryCreateWindow(_output, Width, Height, out Window* window), "No usable window system."); + + try + { + VulkanDevice device = GpuTest.NewDevice(); + if (!device.Initialize((IntPtr)window, Width, Height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + using (device) + { + IOptimumGraphicsDevice seam = device; + Swapchain swapchain = device.SwapchainForTests!; + int programId = SwapchainTests.LinkFullscreenProgram(seam); + + void RenderFrames(int count, int w, int h) + { + for (int frame = 0; frame < count; frame++) + { + seam.BeginFrame(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, w, h); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + } + + RenderFrames(3, Width, Height); + + (int W, int H)[] sizes = + { + (320, 240), (200, 150), (512, 384), (256, 192), (300, 200), (640, 360), (257, 193), + }; + + long idleBefore = VulkanStats.WaitCount(WaitSite.DeviceWaitIdle); + int creationsBefore = swapchain.Creations; + int iterations = 0; + for (int round = 0; round < 2; round++) + { + foreach ((int w, int h) in sizes) + { + GLFW.SetWindowSize(window, w, h); + GLFW.PollEvents(); + seam.Resize(w, h); + if (iterations % 3 == 2) seam.SetVSync(iterations % 2 == 0); + // A frame is recorded before the rebuild happens at its + // acquire, so the old chain still has work in flight. + RenderFrames(3, w, h); + iterations++; + } + } + + long idleWaits = VulkanStats.WaitCount(WaitSite.DeviceWaitIdle) - idleBefore; + int creations = swapchain.Creations - creationsBefore; + _output.WriteLine($"{iterations} resizes: {creations} swapchains created, {swapchain.RetiredPending} slots pending, " + + $"final extent {swapchain.Extent.Width}x{swapchain.Extent.Height}, mode {swapchain.PresentMode}"); + + Assert.Equal(0, idleWaits); + Assert.True(creations >= iterations, $"{iterations} resizes rebuilt only {creations} swapchains"); + Assert.False(swapchain.Parked); + Assert.Null(swapchain.RebuildFailure); + + // Every replaced slot goes once the frames after it completed. + for (int frame = 0; frame < 8 && swapchain.RetiredPending > 0; frame++) RenderFrames(1, 257, 193); + Assert.Equal(0, swapchain.RetiredPending); + + SwapchainSlot slot = swapchain.CurrentSlotForTests!; + Assert.Equal(AcquireSemaphoreFreeList.CapacityFor(slot.ImageCount), slot.AcquireSemaphoreCount); + // Nothing leaked: every semaphore is free or parked behind a submitted present. + Assert.Equal(slot.AcquireSemaphoreCount, slot.FreeAcquireSemaphores + slot.PendingAcquireSemaphores); + + GpuTest.AssertClean(seam); + } + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs new file mode 100644 index 00000000..231e98ae --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs @@ -0,0 +1,288 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The swapchain's lifetime and recreation rules without a window or a device: +/// a replaced slot dies exactly when its last present submission completed, +/// acquire results map to rebuild decisions, the image count and present mode +/// follow the plan, the acquire-semaphore free list never hands out a semaphore +/// twice, and FIFO_RELAXED promotion needs sustained misses. +/// +public class SwapchainRetirementTests +{ + private sealed class FakeClock : ITimelineClock + { + public ulong FrameRecorded { get; set; } + public ulong TransferRecorded { get; set; } + public ulong FrameCompleted { get; set; } + public ulong TransferCompleted { get; set; } + } + + private sealed class Slot : IDisposable + { + private readonly List? _order; + public string Name { get; } + public int DisposeCount { get; private set; } + + public Slot(string name, List? order = null) + { + Name = name; + _order = order; + } + + public void Dispose() + { + DisposeCount++; + _order?.Add(Name); + } + } + + // ------------------------------------------------------------- retirement + + [Fact] + public void ASlotIsNotDestroyedBeforeItsLastPresentSubmissionCompleted() + { + var clock = new FakeClock(); + var retirement = new SwapchainRetirement(clock); + var slot = new Slot("old"); + retirement.Retire(slot, lastPresentValue: 7); + + for (ulong completed = 0; completed < 7; completed++) + { + clock.FrameCompleted = completed; + Assert.Equal(0, retirement.Collect()); + Assert.Equal(0, slot.DisposeCount); + Assert.Equal(1, retirement.PendingCount); + } + + clock.FrameCompleted = 7; + Assert.Equal(1, retirement.Collect()); + Assert.Equal(1, slot.DisposeCount); + Assert.Equal(0, retirement.PendingCount); + + clock.FrameCompleted = 50; + Assert.Equal(0, retirement.Collect()); + Assert.Equal(1, slot.DisposeCount); + } + + [Fact] + public void ASlotThatNeverPresentedGoesAtTheNextCollect() + { + var retirement = new SwapchainRetirement(new FakeClock()); + var slot = new Slot("unused"); + retirement.Retire(slot, lastPresentValue: 0); + Assert.Equal(1, retirement.Collect()); + Assert.Equal(1, slot.DisposeCount); + } + + [Fact] + public void SlotsRetireIndependentlyAndReadyOnesGoInRetirementOrder() + { + var order = new List(); + var clock = new FakeClock(); + var retirement = new SwapchainRetirement(clock); + var a = new Slot("a", order); + var b = new Slot("b", order); + var c = new Slot("c", order); + retirement.Retire(a, 9); + retirement.Retire(b, 4); + retirement.Retire(c, 6); + + clock.FrameCompleted = 6; + Assert.Equal(2, retirement.Collect()); + Assert.Equal(new[] { "b", "c" }, order); + Assert.Equal(0, a.DisposeCount); + + clock.FrameCompleted = 9; + Assert.Equal(1, retirement.Collect()); + Assert.Equal(new[] { "b", "c", "a" }, order); + } + + [Fact] + public void TeardownDestroysEverySlotRegardlessOfTheTimeline() + { + var retirement = new SwapchainRetirement(new FakeClock()); + var a = new Slot("a"); + var b = new Slot("b"); + retirement.Retire(a, 100); + retirement.Retire(b, 200); + retirement.DisposeAll(); + Assert.Equal(1, a.DisposeCount); + Assert.Equal(1, b.DisposeCount); + Assert.Equal(0, retirement.PendingCount); + } + + // -------------------------------------------------------------- acquiring + + [Fact] + public void AcquireResultsMapToThePlansRebuildRules() + { + Assert.Equal(AcquireAction.Present, SwapchainPolicy.OnAcquire(Result.Success, 0)); + Assert.Equal(AcquireAction.PresentThenRebuild, SwapchainPolicy.OnAcquire(Result.SuboptimalKhr, 0)); + Assert.Equal(AcquireAction.PresentThenRebuild, SwapchainPolicy.OnAcquire(Result.SuboptimalKhr, 1)); + // OUT_OF_DATE rebuilds and re-acquires exactly once. + Assert.Equal(AcquireAction.RebuildAndRetry, SwapchainPolicy.OnAcquire(Result.ErrorOutOfDateKhr, 0)); + Assert.Equal(AcquireAction.SkipFrame, SwapchainPolicy.OnAcquire(Result.ErrorOutOfDateKhr, 1)); + Assert.Equal(AcquireAction.Fail, SwapchainPolicy.OnAcquire(Result.ErrorDeviceLost, 0)); + Assert.Equal(AcquireAction.Fail, SwapchainPolicy.OnAcquire(Result.ErrorSurfaceLostKhr, 0)); + } + + [Fact] + public void AZeroExtentParksPresentation() + { + Assert.True(SwapchainPolicy.IsParked(new Extent2D(0, 0))); + Assert.True(SwapchainPolicy.IsParked(new Extent2D(800, 0))); + Assert.True(SwapchainPolicy.IsParked(new Extent2D(0, 600))); + Assert.False(SwapchainPolicy.IsParked(new Extent2D(1, 1))); + } + + [Theory] + [InlineData(1u, 0u, PresentModeKHR.FifoKhr, 2u)] + [InlineData(2u, 0u, PresentModeKHR.FifoKhr, 3u)] + [InlineData(1u, 0u, PresentModeKHR.MailboxKhr, 3u)] + [InlineData(1u, 0u, PresentModeKHR.ImmediateKhr, 2u)] + [InlineData(3u, 0u, PresentModeKHR.MailboxKhr, 4u)] + [InlineData(2u, 3u, PresentModeKHR.MailboxKhr, 3u)] + [InlineData(3u, 3u, PresentModeKHR.FifoKhr, 3u)] + [InlineData(1u, 2u, PresentModeKHR.MailboxKhr, 2u)] + public void TheImageCountIsMinPlusOneWithAFloorOfTwoOrThreeForMailboxClamped( + uint min, uint max, PresentModeKHR mode, uint expected) => + Assert.Equal(expected, SwapchainPolicy.ChooseImageCount(min, max, mode)); + + [Fact] + public void PresentModesFollowVsyncAndTheRelaxedPromotion() + { + var all = new[] { PresentModeKHR.ImmediateKhr, PresentModeKHR.MailboxKhr, PresentModeKHR.FifoKhr, PresentModeKHR.FifoRelaxedKhr }; + var fifoOnly = new[] { PresentModeKHR.FifoKhr }; + var noMailbox = new[] { PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr }; + + Assert.Equal(PresentModeKHR.FifoKhr, SwapchainPolicy.ChoosePresentMode(true, false, all)); + Assert.Equal(PresentModeKHR.FifoRelaxedKhr, SwapchainPolicy.ChoosePresentMode(true, true, all)); + Assert.Equal(PresentModeKHR.FifoKhr, SwapchainPolicy.ChoosePresentMode(true, true, fifoOnly)); + Assert.Equal(PresentModeKHR.MailboxKhr, SwapchainPolicy.ChoosePresentMode(false, false, all)); + Assert.Equal(PresentModeKHR.MailboxKhr, SwapchainPolicy.ChoosePresentMode(false, true, all)); + Assert.Equal(PresentModeKHR.ImmediateKhr, SwapchainPolicy.ChoosePresentMode(false, false, noMailbox)); + Assert.Equal(PresentModeKHR.FifoKhr, SwapchainPolicy.ChoosePresentMode(false, false, fifoOnly)); + } + + // -------------------------------------------------------- acquire semaphores + + [Fact] + public void TheAcquireFreeListHoldsImageCountPlusOneAndNeverHandsOutASemaphoreTwice() + { + const uint imageCount = 3; + int capacity = AcquireSemaphoreFreeList.CapacityFor(imageCount); + Assert.Equal(4, capacity); + + var handles = new ulong[capacity]; + for (int i = 0; i < capacity; i++) handles[i] = 100UL + (ulong)i; + var list = new AcquireSemaphoreFreeList(handles); + Assert.Equal(capacity, list.FreeCount); + + var taken = new HashSet(); + for (int i = 0; i < capacity; i++) Assert.True(taken.Add(list.Take(0)), "a semaphore was handed out twice"); + Assert.Equal(0, list.FreeCount); + Assert.Throws(() => list.Take(0)); + + // A failed acquire returns its semaphore at once. + ulong returned = 101; + list.Return(returned); + Assert.Equal(returned, list.Take(0)); + + foreach (ulong handle in taken) list.Return(handle); + Assert.Throws(() => list.Return(999)); + Assert.Throws(() => list.ReturnAfter(999, 1)); + } + + /// VUID-vkAcquireNextImageKHR-semaphore-01779: a semaphore an uncompleted submission waits on is not reused. + [Fact] + public void ASemaphoreWaitedOnByAPresentSubmissionIsReusedOnlyAfterThatSubmissionCompleted() + { + var list = new AcquireSemaphoreFreeList(new ulong[] { 1, 2, 3 }); + ulong first = list.Take(0); + ulong second = list.Take(0); + ulong third = list.Take(0); + list.ReturnAfter(first, frameValue: 10); + list.ReturnAfter(second, frameValue: 12); + Assert.Equal(0, list.FreeCount); + Assert.Equal(2, list.PendingCount); + + // Frame 9 completed: neither submission is done, nothing to hand out. + Assert.Throws(() => list.Take(9)); + Assert.Equal(2, list.PendingCount); + + // Frame 10 completed: only the first comes back. + Assert.Equal(first, list.Take(10)); + Assert.Equal(1, list.PendingCount); + Assert.Throws(() => list.Take(11)); + + list.ReturnAfter(third, frameValue: 13); + list.ReturnAfter(first, frameValue: 14); + var reclaimed = new HashSet { list.Take(13), list.Take(13) }; + Assert.Equal(new HashSet { second, third }, reclaimed); + Assert.Equal(1, list.PendingCount); + } + + // ---------------------------------------------------- FIFO_RELAXED promotion + + [Fact] + public void SteadyVsyncedFramesNeverPromote() + { + var detector = new MissedVsyncDetector(); + for (int i = 0; i < MissedVsyncDetector.Window * 5; i++) + { + Assert.False(detector.NoteInterval(16.7)); + } + } + + [Fact] + public void AFewMissesDoNotPromoteButSustainedMissesDo() + { + var detector = new MissedVsyncDetector(); + int few = MissedVsyncDetector.MissesToPromote - 1; + for (int i = 0; i < MissedVsyncDetector.Window * 3; i++) + { + // Misses spaced so any full window holds at most `few` of them. + bool miss = i % (MissedVsyncDetector.Window / few + 1) == 0; + Assert.False(detector.NoteInterval(miss ? 33.4 : 16.7), "promoted at interval " + i); + } + + detector.Reset(); + bool promoted = false; + int at = -1; + for (int i = 0; i < MissedVsyncDetector.Window && !promoted; i++) + { + promoted = detector.NoteInterval(i % 5 == 0 ? 33.4 : 16.7); + at = i; + } + Assert.True(promoted, "24 misses in a window must promote"); + Assert.Equal(MissedVsyncDetector.Window - 1, at); + + // The detector starts over after promoting. + for (int i = 0; i < MissedVsyncDetector.Window - 1; i++) + { + Assert.False(detector.NoteInterval(i % 5 == 0 ? 33.4 : 16.7)); + } + } + + [Fact] + public void BurstIntervalsDoNotBecomeTheRefreshEstimate() + { + var detector = new MissedVsyncDetector(); + bool promoted = false; + for (int i = 0; i < MissedVsyncDetector.Window; i++) + { + // A 1 ms burst after a stall is not a refresh; against a 1 ms period + // every 16.7 ms frame would look like a miss. + promoted |= detector.NoteInterval(i % 10 == 0 ? 1.0 : 16.7); + } + Assert.False(promoted); + Assert.False(detector.NoteInterval(double.NaN)); + Assert.False(detector.NoteInterval(-3)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SwapchainTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs index 79f163a5..021ded53 100644 --- a/Optimum.Render.Vulkan.Tests/SwapchainTests.cs +++ b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs @@ -29,7 +29,7 @@ public class SwapchainTests /// Creates a hidden window with no graphics API attached, the way the /// patched client will. /// - private static unsafe bool TryCreateWindow( + internal static unsafe bool TryCreateWindow( ITestOutputHelper output, int width, int height, out Window* window) { window = null; @@ -236,7 +236,7 @@ private sealed class TestShader : IShader public bool Compile() => true; } - private static int LinkFullscreenProgram(IOptimumGraphicsDevice device) + internal static int LinkFullscreenProgram(IOptimumGraphicsDevice device) { var vertex = new TestShader { diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 16aee330..8107d34d 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -100,7 +100,7 @@ public void Begin(ulong frameValue) StartCommandBuffer(); } - private void StartCommandBuffer() + private void StartCommandBuffer(bool frameCommands = true) { Vk api = _context.Api; CommandBuffer commandBuffer; @@ -131,7 +131,9 @@ private void StartCommandBuffer() VulkanResult.Check(api.BeginCommandBuffer(commandBuffer, &begin), "vkBeginCommandBuffer for a frame slot"); CommandBuffer = commandBuffer; - _uploads.OnFrameCommandsStarted(commandBuffer); + // The present command buffer is not the frame's: an upload recorded while + // it is open goes into the batch, which rides the present submission. + if (frameCommands) _uploads.OnFrameCommandsStarted(commandBuffer); } /// @@ -164,7 +166,7 @@ public bool TryAllocateUniforms(int size, out RingAllocation allocation) public ulong SubmitPartial() { ulong submitted = FrameValue; - Submit(default, default, PipelineStageFlags.AllCommandsBit); + Submit(default, default, default, 0); PartialSubmits++; FrameValue = _timeline.ReserveFrame(); StartCommandBuffer(); @@ -172,35 +174,78 @@ public ulong SubmitPartial() } /// - /// Closes the command buffer and submits it, signalling the Frame timeline to - /// (and the binary present semaphore when given). + /// Closes the frame's command buffer and submits it (Submit A), signalling the + /// Frame timeline to . Nothing waits. Returns the value + /// signalled, which the present submission waits on. /// /// Every frame that begins must end here: a reserved Frame value that is never /// signalled holds back every deferred destruction recorded at or after it. /// - public void EndFrameAndSubmit( - Semaphore waitSemaphore = default, - Semaphore signalSemaphore = default, - // The swapchain image's first use in the frame is the present blit, a - // transfer, which a COLOR_ATTACHMENT_OUTPUT wait does not order: the - // blit could overwrite an image the presentation engine still owns and - // the display would show a stale or torn frame. Wait at every stage. - PipelineStageFlags waitStage = PipelineStageFlags.AllCommandsBit) + public ulong EndFrameAndSubmit() { - Submit(waitSemaphore, signalSemaphore, waitStage); + ulong submitted = FrameValue; + Submit(default, default, default, 0); VulkanStats.NoteUniformRingUse(_cursor, _regionSize); + return submitted; } - private void Submit(Semaphore waitSemaphore, Semaphore signalSemaphore, PipelineStageFlags waitStage) + /// + /// After and a successful acquire: starts the + /// present command buffer (Submit B) in this slot under a newly reserved Frame + /// value. + /// + public CommandBuffer BeginPresentCommands() + { + FrameValue = _timeline.ReserveFrame(); + StartCommandBuffer(frameCommands: false); + return CommandBuffer; + } + + /// + /// Submits the present command buffer (Submit B): waits on the Frame timeline + /// at (COLOR_ATTACHMENT_OUTPUT) and on the + /// acquire semaphore at (TRANSFER or + /// COLOR_ATTACHMENT_OUTPUT, never ALL_COMMANDS); signals the binary present + /// semaphore and the Frame timeline. Returns the Frame value signalled. + /// + public ulong SubmitPresent(Semaphore acquireSemaphore, PipelineStageFlags acquireStage, + ulong renderValue, Semaphore presentSemaphore) + { + PresentWaitStages.RequireAcquireStage(acquireStage); + ulong submitted = FrameValue; + Submit(acquireSemaphore, acquireStage, presentSemaphore, renderValue); + return submitted; + } + + /// A binary semaphore to wait on (the acquire semaphore), or none. + /// The stage is waited on at. + /// A binary semaphore to signal (the present semaphore), or none. + /// A Frame timeline value to wait on at COLOR_ATTACHMENT_OUTPUT, or 0. + private void Submit(Semaphore waitSemaphore, PipelineStageFlags waitStage, Semaphore signalSemaphore, + ulong frameWaitValue) { Vk api = _context.Api; CommandBuffer commandBuffer = CommandBuffer; api.EndCommandBuffer(commandBuffer); - Semaphore wait = waitSemaphore; - ulong waitValue = 0; - PipelineStageFlags stage = waitStage; - uint waitCount = wait.Handle == 0 ? 0u : 1u; + Semaphore* waits = stackalloc Semaphore[2]; + ulong* waitValues = stackalloc ulong[2]; + PipelineStageFlags* waitStages = stackalloc PipelineStageFlags[2]; + uint waitCount = 0; + if (waitSemaphore.Handle != 0) + { + waits[waitCount] = waitSemaphore; + waitValues[waitCount] = 0; + waitStages[waitCount] = waitStage; + waitCount++; + } + if (frameWaitValue != 0) + { + waits[waitCount] = _timeline.Frame; + waitValues[waitCount] = frameWaitValue; + waitStages[waitCount] = PresentWaitStages.FrameWait; + waitCount++; + } // Binary present semaphore first (its value is ignored), then the Frame // timeline, then the Transfer timeline when an upload batch rides along. @@ -244,7 +289,7 @@ private void Submit(Semaphore waitSemaphore, Semaphore signalSemaphore, Pipeline { SType = StructureType.TimelineSemaphoreSubmitInfo, WaitSemaphoreValueCount = waitCount, - PWaitSemaphoreValues = waitCount == 0 ? null : &waitValue, + PWaitSemaphoreValues = waitCount == 0 ? null : waitValues, SignalSemaphoreValueCount = signalCount, PSignalSemaphoreValues = signalValues, }; @@ -256,8 +301,8 @@ private void Submit(Semaphore waitSemaphore, Semaphore signalSemaphore, Pipeline CommandBufferCount = commandBufferCount, PCommandBuffers = commandBuffers, WaitSemaphoreCount = waitCount, - PWaitSemaphores = waitCount == 0 ? null : &wait, - PWaitDstStageMask = waitCount == 0 ? null : &stage, + PWaitSemaphores = waitCount == 0 ? null : waits, + PWaitDstStageMask = waitCount == 0 ? null : waitStages, SignalSemaphoreCount = signalCount, PSignalSemaphores = signals, }; @@ -381,11 +426,16 @@ public FrameSlot BeginFrame() /// public ulong SubmitPartial() => Current.SubmitPartial(); - /// Ends and submits the current frame. Pairs with every BeginFrame. - public void EndFrame( - Semaphore waitSemaphore = default, - Semaphore signalSemaphore = default) => - Current.EndFrameAndSubmit(waitSemaphore, signalSemaphore); + /// Ends and submits the current frame (Submit A). Pairs with every BeginFrame. Returns its last Frame value. + public ulong EndFrame() => Current.EndFrameAndSubmit(); + + /// Starts the present command buffer; see . + public CommandBuffer BeginPresentCommands() => Current.BeginPresentCommands(); + + /// Submits the present command buffer (Submit B); see . + public ulong SubmitPresent(Semaphore acquireSemaphore, PipelineStageFlags acquireStage, + ulong renderValue, Semaphore presentSemaphore) => + Current.SubmitPresent(acquireSemaphore, acquireStage, renderValue, presentSemaphore); /// /// Queues a resource for destruction once the GPU is done with it. diff --git a/Optimum.Render.Vulkan/Core/Swapchain.cs b/Optimum.Render.Vulkan/Core/Swapchain.cs deleted file mode 100644 index 1eda137e..00000000 --- a/Optimum.Render.Vulkan/Core/Swapchain.cs +++ /dev/null @@ -1,408 +0,0 @@ -using System; -using System.Collections.Generic; -using Silk.NET.Vulkan; -using Silk.NET.Vulkan.Extensions.KHR; - -namespace Optimum.Render.Vulkan.Core; - -/// -/// The presentation chain, and the only place in the backend where the image is -/// flipped. -/// -/// Everything upstream renders in OpenGL's orientation, because GL and Vulkan -/// agree on how clip space maps to framebuffer memory and differ only in which -/// corner they name the origin. That keeps every intermediate target, every -/// render-to-texture round trip and every screenshot byte-identical to the GL -/// path. Only scanout disagrees - the display reads row 0 at the top - so the -/// correction happens once, here, as an inverted blit at present time. -/// -internal sealed unsafe class Swapchain : IDisposable -{ - private readonly VulkanContext _context; - private readonly KhrSurface _surfaceApi; - private readonly KhrSwapchain _swapchainApi; - private readonly SurfaceKHR _surface; - - private SwapchainKHR _handle; - private Image[] _images = Array.Empty(); - private ImageView[] _views = Array.Empty(); - private Semaphore[] _imageAvailable = Array.Empty(); - private Semaphore[] _renderFinished = Array.Empty(); - private int _semaphoreIndex; - private bool _disposed; - - public Format Format { get; private set; } = Format.B8G8R8A8Unorm; - public Extent2D Extent { get; private set; } - public PresentModeKHR PresentMode { get; private set; } = PresentModeKHR.FifoKhr; - public uint ImageCount => (uint)_images.Length; - - /// Set when the surface reports the chain is stale and it must be rebuilt. - public bool NeedsRecreation { get; private set; } - - private Swapchain(VulkanContext context, KhrSurface surfaceApi, KhrSwapchain swapchainApi, SurfaceKHR surface) - { - _context = context; - _surfaceApi = surfaceApi; - _swapchainApi = swapchainApi; - _surface = surface; - } - - /// - /// Takes ownership of on entry: on every failure - /// return the surface is destroyed here, and on success the swapchain - /// destroys it in . The caller never destroys it. - /// - public static bool TryCreate( - VulkanContext context, SurfaceKHR surface, uint width, uint height, bool vsync, - out Swapchain? swapchain, out string? failureReason) - { - swapchain = null; - failureReason = null; - - if (!context.Api.TryGetInstanceExtension(context.Instance, out KhrSurface surfaceApi)) - { - failureReason = "VK_KHR_surface unavailable"; - WindowSurface.Destroy(context, surface); - return false; - } - if (!context.Api.TryGetDeviceExtension(context.Instance, context.Device, out KhrSwapchain swapchainApi)) - { - failureReason = "VK_KHR_swapchain unavailable"; - surfaceApi.DestroySurface(context.Instance, surface, null); - surfaceApi.Dispose(); - return false; - } - - // The graphics queue has to be able to present. A separate present queue - // is possible in principle but does not occur on any desktop driver, and - // supporting it would add a queue-ownership transfer to every frame. - surfaceApi.GetPhysicalDeviceSurfaceSupport( - context.PhysicalDevice, context.GraphicsQueueFamily, surface, - out Silk.NET.Core.Bool32 supported); - if (!supported) - { - failureReason = "the graphics queue family cannot present to this surface"; - surfaceApi.DestroySurface(context.Instance, surface, null); - surfaceApi.Dispose(); - swapchainApi.Dispose(); - return false; - } - - var created = new Swapchain(context, surfaceApi, swapchainApi, surface); - if (!created.Build(width, height, vsync, out failureReason)) - { - created.Dispose(); - return false; - } - - swapchain = created; - return true; - } - - private bool Build(uint width, uint height, bool vsync, out string? failureReason) - { - failureReason = null; - - _surfaceApi.GetPhysicalDeviceSurfaceCapabilities( - _context.PhysicalDevice, _surface, out SurfaceCapabilitiesKHR capabilities); - - Extent = ChooseExtent(capabilities, width, height); - if (Extent.Width == 0 || Extent.Height == 0) - { - failureReason = "surface has zero extent"; - return false; - } - - Format = ChooseFormat(out ColorSpaceKHR colorSpace); - PresentMode = ChoosePresentMode(vsync); - - uint imageCount = capabilities.MinImageCount + 1; - if (capabilities.MaxImageCount > 0 && imageCount > capabilities.MaxImageCount) - { - imageCount = capabilities.MaxImageCount; - } - - var createInfo = new SwapchainCreateInfoKHR - { - SType = StructureType.SwapchainCreateInfoKhr, - Surface = _surface, - MinImageCount = imageCount, - ImageFormat = Format, - ImageColorSpace = colorSpace, - ImageExtent = Extent, - ImageArrayLayers = 1, - // Transfer destination because the frame is blitted in rather than - // rendered directly: the game renders into its own targets and the - // last step copies the result across, flipping it on the way. - ImageUsage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit, - ImageSharingMode = SharingMode.Exclusive, - PreTransform = capabilities.CurrentTransform, - CompositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr, - PresentMode = PresentMode, - Clipped = true, - OldSwapchain = default, - }; - - if (_swapchainApi.CreateSwapchain(_context.Device, &createInfo, null, out SwapchainKHR handle) - != Result.Success) - { - failureReason = "vkCreateSwapchainKHR failed"; - return false; - } - _handle = handle; - - uint count = 0; - _swapchainApi.GetSwapchainImages(_context.Device, _handle, ref count, null); - _images = new Image[count]; - fixed (Image* imagesPtr = _images) - { - _swapchainApi.GetSwapchainImages(_context.Device, _handle, ref count, imagesPtr); - } - - _views = new ImageView[count]; - for (int i = 0; i < count; i++) - { - var viewInfo = new ImageViewCreateInfo - { - SType = StructureType.ImageViewCreateInfo, - Image = _images[i], - ViewType = ImageViewType.Type2D, - Format = Format, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), - }; - _context.Api.CreateImageView(_context.Device, &viewInfo, null, out _views[i]); - } - - CreateSemaphores((int)count); - NeedsRecreation = false; - return true; - } - - private void CreateSemaphores(int count) - { - DestroySemaphores(); - - _imageAvailable = new Semaphore[count]; - _renderFinished = new Semaphore[count]; - - var createInfo = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo }; - for (int i = 0; i < count; i++) - { - _context.Api.CreateSemaphore(_context.Device, &createInfo, null, out _imageAvailable[i]); - _context.Api.CreateSemaphore(_context.Device, &createInfo, null, out _renderFinished[i]); - } - _semaphoreIndex = 0; - } - - private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities, uint width, uint height) - { - // A driver that pins the extent wins; otherwise clamp what we asked for. - if (capabilities.CurrentExtent.Width != uint.MaxValue) - { - return capabilities.CurrentExtent; - } - - return new Extent2D( - Math.Clamp(width, capabilities.MinImageExtent.Width, capabilities.MaxImageExtent.Width), - Math.Clamp(height, capabilities.MinImageExtent.Height, capabilities.MaxImageExtent.Height)); - } - - /// - /// Prefers a plain 8-bit BGRA format in sRGB colour space. The game's default - /// framebuffer is linear - it never enables GL_FRAMEBUFFER_SRGB - so an - /// _SRGB image format would apply a conversion the GL path never did and - /// wash the picture out. - /// - private Format ChooseFormat(out ColorSpaceKHR colorSpace) - { - uint count = 0; - _surfaceApi.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _surface, ref count, null); - - var formats = new SurfaceFormatKHR[count]; - fixed (SurfaceFormatKHR* formatsPtr = formats) - { - _surfaceApi.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _surface, ref count, formatsPtr); - } - - foreach (SurfaceFormatKHR candidate in formats) - { - if (candidate.Format is Format.B8G8R8A8Unorm or Format.R8G8B8A8Unorm) - { - colorSpace = candidate.ColorSpace; - return candidate.Format; - } - } - - if (formats.Length > 0) - { - colorSpace = formats[0].ColorSpace; - return formats[0].Format; - } - - colorSpace = ColorSpaceKHR.SpaceSrgbNonlinearKhr; - return Format.B8G8R8A8Unorm; - } - - /// - /// FIFO when vsync is on, since it is the only mode guaranteed present. - /// Otherwise mailbox if the driver has it - it drops frames instead of - /// tearing - and immediate as the fallback. - /// - private PresentModeKHR ChoosePresentMode(bool vsync) - { - if (vsync) return PresentModeKHR.FifoKhr; - - uint count = 0; - _surfaceApi.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _surface, ref count, null); - - var modes = new PresentModeKHR[count]; - fixed (PresentModeKHR* modesPtr = modes) - { - _surfaceApi.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _surface, ref count, modesPtr); - } - - foreach (PresentModeKHR mode in modes) - { - if (mode == PresentModeKHR.MailboxKhr) return mode; - } - foreach (PresentModeKHR mode in modes) - { - if (mode == PresentModeKHR.ImmediateKhr) return mode; - } - return PresentModeKHR.FifoKhr; - } - - /// - /// Acquires the next image. Returns false when the chain is stale, which the - /// caller turns into a rebuild rather than an error - a resize or a monitor - /// change is ordinary. - /// - public bool TryAcquire(out uint imageIndex, out Semaphore waitSemaphore, out Semaphore signalSemaphore) - { - imageIndex = 0; - waitSemaphore = _imageAvailable[_semaphoreIndex]; - - long waitStart = VulkanStats.WaitStart(); - Result result = _swapchainApi.AcquireNextImage( - _context.Device, _handle, ulong.MaxValue, waitSemaphore, default, ref imageIndex); - VulkanStats.NoteWait(WaitSite.SwapchainAcquire, waitStart); - - // The render-finished semaphore belongs to the acquired IMAGE, not to a - // rolling counter: vkQueuePresentKHR keeps waiting on it until that image - // is presented, and the only moment it is provably free again is when - // the same image is re-acquired. A counter-indexed semaphore could be - // re-signalled while an earlier present still waits on it. - signalSemaphore = _renderFinished[(int)imageIndex % Math.Max(_renderFinished.Length, 1)]; - - if (result is Result.ErrorOutOfDateKhr) - { - NeedsRecreation = true; - return false; - } - if (result == Result.SuboptimalKhr) - { - // Usable this frame; rebuilt before the next one. - NeedsRecreation = true; - return true; - } - // Anything else - a lost device above all - is reported rather than - // turned into a quiet "no image this frame", which reads as a freeze. - VulkanResult.Check(result, "vkAcquireNextImageKHR"); - return result == Result.Success; - } - - public Image ImageAt(uint index) => _images[index]; - public ImageView ViewAt(uint index) => _views[index]; - - public void Present(uint imageIndex, Semaphore waitSemaphore) - { - SwapchainKHR handle = _handle; - Semaphore wait = waitSemaphore; - uint index = imageIndex; - - var presentInfo = new PresentInfoKHR - { - SType = StructureType.PresentInfoKhr, - WaitSemaphoreCount = 1, - PWaitSemaphores = &wait, - SwapchainCount = 1, - PSwapchains = &handle, - PImageIndices = &index, - }; - - // Presenting is a queue operation like any other, so it takes the same - // lock as submission. - Result result; - long waitStart = VulkanStats.WaitStart(); - lock (_context.QueueLock) - { - result = _swapchainApi.QueuePresent(_context.GraphicsQueue, &presentInfo); - } - VulkanStats.NoteWait(WaitSite.Present, waitStart); - if (result is Result.ErrorOutOfDateKhr or Result.SuboptimalKhr) - { - NeedsRecreation = true; - } - else - { - VulkanResult.Check(result, "vkQueuePresentKHR"); - } - - _semaphoreIndex = (_semaphoreIndex + 1) % Math.Max(_imageAvailable.Length, 1); - } - - public bool Recreate(uint width, uint height, bool vsync, out string? failureReason) - { - VulkanStats.WaitDeviceIdle(_context.Api, _context.Device); - DestroyChain(); - return Build(width, height, vsync, out failureReason); - } - - private void DestroyChain() - { - foreach (ImageView view in _views) - { - if (view.Handle != 0) _context.Api.DestroyImageView(_context.Device, view, null); - } - _views = Array.Empty(); - _images = Array.Empty(); - - if (_handle.Handle != 0) - { - _swapchainApi.DestroySwapchain(_context.Device, _handle, null); - _handle = default; - } - } - - private void DestroySemaphores() - { - foreach (Semaphore semaphore in _imageAvailable) - { - if (semaphore.Handle != 0) _context.Api.DestroySemaphore(_context.Device, semaphore, null); - } - foreach (Semaphore semaphore in _renderFinished) - { - if (semaphore.Handle != 0) _context.Api.DestroySemaphore(_context.Device, semaphore, null); - } - _imageAvailable = Array.Empty(); - _renderFinished = Array.Empty(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - VulkanStats.WaitDeviceIdle(_context.Api, _context.Device); - DestroyChain(); - DestroySemaphores(); - - if (_surface.Handle != 0) - { - _surfaceApi.DestroySurface(_context.Instance, _surface, null); - } - - _swapchainApi.Dispose(); - _surfaceApi.Dispose(); - } -} diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 5b8bd198..c012b887 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -35,6 +35,12 @@ internal sealed class VulkanContextOptions /// OPTIMUM_VULKAN_POISON once, at context creation. /// public bool? Poison; + + /// + /// Tests only: sleeps this long before every vkAcquireNextImageKHR, standing + /// in for a compositor that holds images back (PresentDecouplingTests). + /// + public TimeSpan AcquireDelayForTests; } /// What the chosen device can do, once it is up. @@ -117,6 +123,9 @@ internal sealed unsafe class VulkanContext : IDisposable /// public bool PoisonFreshResources { get; private set; } + /// Tests only; see . + public TimeSpan AcquireDelayForTests { get; private set; } + /// OPTIMUM_VULKAN_POISON: any value but empty and "0" turns poison mode on. public const string PoisonVariable = "OPTIMUM_VULKAN_POISON"; @@ -164,6 +173,7 @@ public static bool TryCreate( failureReason = null; var created = new VulkanContext(); + created.AcquireDelayForTests = options.AcquireDelayForTests; created.PoisonFreshResources = options.Poison ?? PoisonRequested(Environment.GetEnvironmentVariable(PoisonVariable)); try diff --git a/Optimum.Render.Vulkan/Present/IPresentPath.cs b/Optimum.Render.Vulkan/Present/IPresentPath.cs new file mode 100644 index 00000000..4fe1f356 --- /dev/null +++ b/Optimum.Render.Vulkan/Present/IPresentPath.cs @@ -0,0 +1,139 @@ +using System; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Records the second submission of a frame (Submit B): whatever writes the +/// acquired swapchain image. The frame itself (Submit A) is already in flight +/// when this records, so the CPU only blocks on the acquire once all of the +/// frame's rendering is queued. +/// +internal interface IPresentPath +{ + /// + /// The stage of the image's first use in : the stage at + /// which Submit B waits on the acquire semaphore. Never ALL_COMMANDS. + /// + PipelineStageFlags AcquireWaitStage { get; } + + void Record(CommandBuffer commandBuffer, in PresentTarget target); +} + +/// +/// The wait stages of the present submission, checked in one place. +/// +/// Submit B waits on two things: the Frame timeline at the value Submit A +/// signalled (the frame image it reads is finished), at COLOR_ATTACHMENT_OUTPUT; +/// and the acquire semaphore at the stage where the swapchain image is first +/// touched: TRANSFER for the flipped blit, COLOR_ATTACHMENT_OUTPUT for a raster +/// path (FSR's final pass). ALL_COMMANDS would also block the barrier and every +/// earlier command on the acquire, which is what the split exists to avoid. +/// +internal static class PresentWaitStages +{ + public const PipelineStageFlags FrameWait = PipelineStageFlags.ColorAttachmentOutputBit; + public const PipelineStageFlags BlitAcquireWait = PipelineStageFlags.TransferBit; + public const PipelineStageFlags RasterAcquireWait = PipelineStageFlags.ColorAttachmentOutputBit; + + /// Throws for a wait stage the present submission must not use. + public static PipelineStageFlags RequireAcquireStage(PipelineStageFlags stage) + { + if (stage != BlitAcquireWait && stage != RasterAcquireWait) + { + throw new ArgumentOutOfRangeException(nameof(stage), stage, + "the present submission waits on the acquire semaphore at TRANSFER or COLOR_ATTACHMENT_OUTPUT only"); + } + return stage; + } +} + +/// +/// The default present path (policy BlitFromOwned): the whole frame, GUI +/// included, renders into the owned default image, and this copies it into the +/// acquired swapchain image, flipped. +/// +/// This inverted blit is the entire Y-flip story for the backend. Everything +/// upstream stays in OpenGL's orientation, which is what keeps intermediate +/// targets and screenshots byte-identical to the GL path; the display wants row 0 +/// at the top, so the source rows are read bottom-to-top exactly once, here. +/// +internal sealed unsafe class BlitPresentPath : IPresentPath +{ + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly Func _source; + + public BlitPresentPath(VulkanContext context, TextureManager textures, Func source) + { + _context = context; + _textures = textures; + _source = source; + } + + public PipelineStageFlags AcquireWaitStage => PresentWaitStages.BlitAcquireWait; + + public void Record(CommandBuffer commandBuffer, in PresentTarget target) + { + Image destination = target.Image; + + // Every present leaves the swapchain image in PRESENT_SRC, and nothing + // else writes it, so UNDEFINED discards nothing that matters. + TransitionSwapchainImage(commandBuffer, destination, + ImageLayout.Undefined, ImageLayout.TransferDstOptimal, + PipelineStageFlags2.TransferBit, PipelineStageFlags2.TransferBit); + + VulkanTexture? source = _source(); + if (source != null) + { + _textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal); + + var blit = new ImageBlit + { + SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + }; + // Source Y runs backwards: this is the flip. + blit.SrcOffsets.Element0 = new Offset3D(0, (int)source.Height, 0); + blit.SrcOffsets.Element1 = new Offset3D((int)source.Width, 0, 1); + blit.DstOffsets.Element0 = new Offset3D(0, 0, 0); + blit.DstOffsets.Element1 = new Offset3D((int)target.Extent.Width, (int)target.Extent.Height, 1); + + _context.Api.CmdBlitImage(commandBuffer, + source.Image, ImageLayout.TransferSrcOptimal, + destination, ImageLayout.TransferDstOptimal, + 1, &blit, Filter.Linear); + } + + TransitionSwapchainImage(commandBuffer, destination, + ImageLayout.TransferDstOptimal, ImageLayout.PresentSrcKhr, + PipelineStageFlags2.TransferBit, PipelineStageFlags2.BottomOfPipeBit); + } + + private void TransitionSwapchainImage( + CommandBuffer commandBuffer, Image image, ImageLayout from, ImageLayout to, + PipelineStageFlags2 srcStage, PipelineStageFlags2 dstStage) + { + var barrier = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = srcStage, + SrcAccessMask = from == ImageLayout.TransferDstOptimal ? AccessFlags2.TransferWriteBit : AccessFlags2.None, + DstStageMask = dstStage, + DstAccessMask = to == ImageLayout.TransferDstOptimal ? AccessFlags2.TransferWriteBit : AccessFlags2.None, + OldLayout = from, + NewLayout = to, + Image = image, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = 1, + PImageMemoryBarriers = &barrier, + }; + _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); + VulkanStats.NoteImageBarriers(1); + } +} diff --git a/Optimum.Render.Vulkan/Present/Swapchain.cs b/Optimum.Render.Vulkan/Present/Swapchain.cs new file mode 100644 index 00000000..ec3c7b38 --- /dev/null +++ b/Optimum.Render.Vulkan/Present/Swapchain.cs @@ -0,0 +1,588 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; + +using Semaphore = Silk.NET.Vulkan.Semaphore; + +// The Present/ folder follows the plan's layout; the namespace stays Core until +// the renderer is reorganised, like Frame/. +namespace Optimum.Render.Vulkan.Core; + +/// An acquired swapchain image and the semaphores its present submission uses. +internal readonly struct PresentTarget +{ + public PresentTarget(SwapchainSlot slot, uint imageIndex, Semaphore acquireSemaphore) + { + Slot = slot; + ImageIndex = imageIndex; + AcquireSemaphore = acquireSemaphore; + } + + public SwapchainSlot Slot { get; } + public uint ImageIndex { get; } + + /// Signalled by the acquire; Submit B waits on it. + public Semaphore AcquireSemaphore { get; } + + /// Signalled by Submit B; vkQueuePresentKHR waits on it. + public Semaphore PresentSemaphore => Slot.PresentSemaphoreFor(ImageIndex); + + public Image Image => Slot.Images[ImageIndex]; + public Extent2D Extent => Slot.Extent; +} + +/// +/// One vkCreateSwapchainKHR result and everything that belongs to it: images, +/// views, the acquire-semaphore free list (imageCount + 1) and one present +/// semaphore per image. Created by and retired as one +/// unit through once the last present +/// submission that used it completed, so its semaphores die with it. +/// +internal sealed unsafe class SwapchainSlot : IDisposable +{ + private readonly VulkanContext _context; + private readonly KhrSwapchain _api; + private readonly Semaphore[] _acquireSemaphores; + private readonly Semaphore[] _presentSemaphores; + private readonly AcquireSemaphoreFreeList _freeAcquire; + private bool _disposed; + + public SwapchainSlot(VulkanContext context, KhrSwapchain api, SwapchainKHR handle, + Extent2D extent, Format format, PresentModeKHR presentMode) + { + _context = context; + _api = api; + Handle = handle; + Extent = extent; + Format = format; + PresentMode = presentMode; + + uint count = 0; + api.GetSwapchainImages(context.Device, handle, ref count, null); + Images = new Image[count]; + fixed (Image* imagesPtr = Images) + { + api.GetSwapchainImages(context.Device, handle, ref count, imagesPtr); + } + + Views = new ImageView[count]; + for (int i = 0; i < count; i++) + { + var viewInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = Images[i], + ViewType = ImageViewType.Type2D, + Format = format, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + ImageView view; + VulkanResult.Check(context.Api.CreateImageView(context.Device, &viewInfo, null, &view), + "vkCreateImageView for a swapchain image"); + Views[i] = view; + } + + // The present semaphore belongs to the IMAGE, not to a rolling counter: + // vkQueuePresentKHR keeps waiting on it until that image is presented, and + // the only moment it is provably free again is when the same image is + // re-acquired. A counter-indexed semaphore could be re-signalled while an + // earlier present still waits on it. + _presentSemaphores = CreateSemaphores((int)count); + _acquireSemaphores = CreateSemaphores(AcquireSemaphoreFreeList.CapacityFor(count)); + var handles = new ulong[_acquireSemaphores.Length]; + for (int i = 0; i < handles.Length; i++) handles[i] = _acquireSemaphores[i].Handle; + _freeAcquire = new AcquireSemaphoreFreeList(handles); + } + + public SwapchainKHR Handle { get; } + public Image[] Images { get; } + public ImageView[] Views { get; } + public Extent2D Extent { get; } + public Format Format { get; } + public PresentModeKHR PresentMode { get; } + public uint ImageCount => (uint)Images.Length; + public int AcquireSemaphoreCount => _acquireSemaphores.Length; + public int FreeAcquireSemaphores => _freeAcquire.FreeCount; + + /// Frame timeline value of the newest present submission that used one of this slot's images; 0 before the first. + public ulong LastPresentValue { get; private set; } + + public Semaphore PresentSemaphoreFor(uint imageIndex) => _presentSemaphores[imageIndex]; + + public int PendingAcquireSemaphores => _freeAcquire.PendingCount; + + /// A semaphore for the next acquire; releases those whose present submission finished. + public Semaphore TakeAcquireSemaphore(ulong frameCompleted) => new(_freeAcquire.Take(frameCompleted)); + + /// The acquire failed; the semaphore is untouched. + public void ReturnAcquireSemaphore(Semaphore semaphore) => _freeAcquire.Return(semaphore.Handle); + + /// A submission carrying waits on the semaphore; reusable once it completed. + public void ReturnAcquireSemaphoreAfter(Semaphore semaphore, ulong frameValue) => + _freeAcquire.ReturnAfter(semaphore.Handle, frameValue); + + public void NotePresentSubmitted(ulong frameValue) + { + if (frameValue > LastPresentValue) LastPresentValue = frameValue; + } + + private Semaphore[] CreateSemaphores(int count) + { + var semaphores = new Semaphore[count]; + var createInfo = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo }; + for (int i = 0; i < count; i++) + { + Semaphore semaphore; + VulkanResult.Check(_context.Api.CreateSemaphore(_context.Device, &createInfo, null, &semaphore), + "vkCreateSemaphore for a swapchain slot"); + semaphores[i] = semaphore; + } + return semaphores; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + foreach (ImageView view in Views) + { + if (view.Handle != 0) _context.Api.DestroyImageView(_context.Device, view, null); + } + if (Handle.Handle != 0) _api.DestroySwapchain(_context.Device, Handle, null); + foreach (Semaphore semaphore in _acquireSemaphores) + { + if (semaphore.Handle != 0) _context.Api.DestroySemaphore(_context.Device, semaphore, null); + } + foreach (Semaphore semaphore in _presentSemaphores) + { + if (semaphore.Handle != 0) _context.Api.DestroySemaphore(_context.Device, semaphore, null); + } + } +} + +/// +/// The presentation chain. +/// +/// Recreation follows the Khronos swapchain_recreation sample: the current +/// swapchain is always passed as oldSwapchain, nothing waits for the +/// device to go idle, and the replaced is retired on +/// the Frame timeline after the last present submission that used it. SUBOPTIMAL +/// (from acquire or present) rebuilds before the next acquire; OUT_OF_DATE +/// rebuilds and acquires once more; a zero extent (a minimised window) parks +/// presentation until the surface grows again. +/// +/// The one flip of the image happens in the present path +/// (), not here. +/// +internal sealed unsafe class Swapchain : IDisposable +{ + /// OPTIMUM_VULKAN_FIFO_RELAXED=0 forces plain FIFO (no promotion on missed vsyncs). + public const string FifoRelaxedVariable = "OPTIMUM_VULKAN_FIFO_RELAXED"; + + private readonly VulkanContext _context; + private readonly KhrSurface _surfaceApi; + private readonly KhrSwapchain _swapchainApi; + private readonly SurfaceKHR _surface; + private readonly SwapchainRetirement _retirement; + private readonly ITimelineClock _clock; + private readonly bool _relaxedAllowed; + + private SwapchainSlot? _current; + private uint _width; + private uint _height; + private bool _vsync; + private bool _relaxedPromoted; + private bool _disposed; + + public Format Format { get; private set; } = Format.B8G8R8A8Unorm; + public Extent2D Extent { get; private set; } + public PresentModeKHR PresentMode { get; private set; } = PresentModeKHR.FifoKhr; + public uint ImageCount => _current?.ImageCount ?? 0; + + /// Set when the chain is stale; the next acquire rebuilds it first. + public bool NeedsRecreation { get; private set; } + + /// The surface has zero extent; nothing is acquired or presented until it grows. + public bool Parked { get; private set; } + + /// Swapchains created so far, the first included. + public int Creations { get; private set; } + + /// Replaced slots still waiting for the GPU. + public int RetiredPending => _retirement.PendingCount; + + /// Why the last rebuild failed; null after a successful one. + public string? RebuildFailure { get; private set; } + + /// The slot being acquired from. Tests only. + internal SwapchainSlot? CurrentSlotForTests => _current; + + private Swapchain(VulkanContext context, KhrSurface surfaceApi, KhrSwapchain swapchainApi, SurfaceKHR surface, + ITimelineClock clock) + { + _context = context; + _surfaceApi = surfaceApi; + _swapchainApi = swapchainApi; + _surface = surface; + _clock = clock; + _retirement = new SwapchainRetirement(clock); + _relaxedAllowed = Environment.GetEnvironmentVariable(FifoRelaxedVariable)?.Trim() != "0"; + } + + /// + /// Takes ownership of on entry: on every failure + /// return the surface is destroyed here, and on success the swapchain + /// destroys it in . The caller never destroys it. + /// + public static bool TryCreate( + VulkanContext context, SurfaceKHR surface, uint width, uint height, bool vsync, ITimelineClock clock, + out Swapchain? swapchain, out string? failureReason) + { + swapchain = null; + failureReason = null; + + if (!context.Api.TryGetInstanceExtension(context.Instance, out KhrSurface surfaceApi)) + { + failureReason = "VK_KHR_surface unavailable"; + WindowSurface.Destroy(context, surface); + return false; + } + if (!context.Api.TryGetDeviceExtension(context.Instance, context.Device, out KhrSwapchain swapchainApi)) + { + failureReason = "VK_KHR_swapchain unavailable"; + surfaceApi.DestroySurface(context.Instance, surface, null); + surfaceApi.Dispose(); + return false; + } + + // The graphics queue has to be able to present. A separate present queue + // is possible in principle but does not occur on any desktop driver, and + // supporting it would add a queue-ownership transfer to every frame. + surfaceApi.GetPhysicalDeviceSurfaceSupport( + context.PhysicalDevice, context.GraphicsQueueFamily, surface, + out Silk.NET.Core.Bool32 supported); + if (!supported) + { + failureReason = "the graphics queue family cannot present to this surface"; + surfaceApi.DestroySurface(context.Instance, surface, null); + surfaceApi.Dispose(); + swapchainApi.Dispose(); + return false; + } + + var created = new Swapchain(context, surfaceApi, swapchainApi, surface, clock); + created._width = width; + created._height = height; + created._vsync = vsync; + if (!created.Build(out failureReason)) + { + // A window that starts minimised is not a device the client can use. + if (created.Parked) failureReason = "surface has zero extent"; + created.Dispose(); + return false; + } + + swapchain = created; + return true; + } + + /// + /// Builds a new slot from the current surface state, passing the current one + /// as oldSwapchain and retiring it. Never waits. Returns false when parked (the + /// current slot is kept for the rebuild that unparks) or when creation failed. + /// + private bool Build(out string? failureReason) + { + failureReason = null; + + _surfaceApi.GetPhysicalDeviceSurfaceCapabilities( + _context.PhysicalDevice, _surface, out SurfaceCapabilitiesKHR capabilities); + + Extent2D extent = ChooseExtent(capabilities, _width, _height); + if (SwapchainPolicy.IsParked(extent)) + { + Parked = true; + NeedsRecreation = true; + return false; + } + Parked = false; + + Format = ChooseFormat(out ColorSpaceKHR colorSpace); + PresentModeKHR presentMode = SwapchainPolicy.ChoosePresentMode( + _vsync, _relaxedPromoted && _relaxedAllowed, SupportedPresentModes()); + uint imageCount = SwapchainPolicy.ChooseImageCount( + capabilities.MinImageCount, capabilities.MaxImageCount, presentMode); + + SwapchainSlot? old = _current; + var createInfo = new SwapchainCreateInfoKHR + { + SType = StructureType.SwapchainCreateInfoKhr, + Surface = _surface, + MinImageCount = imageCount, + ImageFormat = Format, + ImageColorSpace = colorSpace, + ImageExtent = extent, + ImageArrayLayers = 1, + // Transfer destination because the frame is blitted in rather than + // rendered directly: the game renders into its own targets and the + // last step copies the result across, flipping it on the way. + ImageUsage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit, + ImageSharingMode = SharingMode.Exclusive, + PreTransform = capabilities.CurrentTransform, + CompositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr, + PresentMode = presentMode, + Clipped = true, + // Always the current chain: images it has not handed out can be freed + // by the driver right away, and presents already queued on it finish. + OldSwapchain = old?.Handle ?? default, + }; + + Result result = _swapchainApi.CreateSwapchain(_context.Device, &createInfo, null, out SwapchainKHR handle); + + // Passing oldSwapchain retires it even when creation fails. + if (old != null) + { + _retirement.Retire(old, old.LastPresentValue); + _current = null; + } + + if (result != Result.Success) + { + failureReason = "vkCreateSwapchainKHR failed with " + result; + RebuildFailure = failureReason; + NeedsRecreation = true; + return false; + } + + _current = new SwapchainSlot(_context, _swapchainApi, handle, extent, Format, presentMode); + Extent = extent; + PresentMode = presentMode; + Creations++; + NeedsRecreation = false; + RebuildFailure = null; + return true; + } + + private Extent2D ChooseExtent(SurfaceCapabilitiesKHR capabilities, uint width, uint height) + { + // A driver that pins the extent wins; otherwise clamp what we asked for. + if (capabilities.CurrentExtent.Width != uint.MaxValue) + { + return capabilities.CurrentExtent; + } + + return new Extent2D( + Math.Clamp(width, capabilities.MinImageExtent.Width, capabilities.MaxImageExtent.Width), + Math.Clamp(height, capabilities.MinImageExtent.Height, capabilities.MaxImageExtent.Height)); + } + + /// + /// Prefers a plain 8-bit BGRA format in sRGB colour space. The game's default + /// framebuffer is linear - it never enables GL_FRAMEBUFFER_SRGB - so an + /// _SRGB image format would apply a conversion the GL path never did and + /// wash the picture out. + /// + private Format ChooseFormat(out ColorSpaceKHR colorSpace) + { + uint count = 0; + _surfaceApi.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _surface, ref count, null); + + var formats = new SurfaceFormatKHR[count]; + fixed (SurfaceFormatKHR* formatsPtr = formats) + { + _surfaceApi.GetPhysicalDeviceSurfaceFormats(_context.PhysicalDevice, _surface, ref count, formatsPtr); + } + + foreach (SurfaceFormatKHR candidate in formats) + { + if (candidate.Format is Format.B8G8R8A8Unorm or Format.R8G8B8A8Unorm) + { + colorSpace = candidate.ColorSpace; + return candidate.Format; + } + } + + if (formats.Length > 0) + { + colorSpace = formats[0].ColorSpace; + return formats[0].Format; + } + + colorSpace = ColorSpaceKHR.SpaceSrgbNonlinearKhr; + return Format.B8G8R8A8Unorm; + } + + private PresentModeKHR[] SupportedPresentModes() + { + uint count = 0; + _surfaceApi.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _surface, ref count, null); + + var modes = new PresentModeKHR[count]; + fixed (PresentModeKHR* modesPtr = modes) + { + _surfaceApi.GetPhysicalDeviceSurfacePresentModes(_context.PhysicalDevice, _surface, ref count, modesPtr); + } + return modes; + } + + /// + /// Asks for a rebuild at the next acquire (a resize, a vsync toggle). Cheap + /// and repeatable: a resize storm costs one rebuild per presented frame at most. + /// + public void RequestRebuild(uint width, uint height, bool vsync) + { + if (vsync != _vsync) _relaxedPromoted = false; + _width = width; + _height = height; + _vsync = vsync; + NeedsRecreation = true; + } + + /// + /// Sustained missed vsyncs under FIFO: rebuild as FIFO_RELAXED when the + /// surface has it and the override allows it. Returns whether a rebuild was requested. + /// + public bool PromoteToRelaxedFifo() + { + if (!_vsync || _relaxedPromoted || !_relaxedAllowed) return false; + if (Array.IndexOf(SupportedPresentModes(), PresentModeKHR.FifoRelaxedKhr) < 0) return false; + _relaxedPromoted = true; + NeedsRecreation = true; + return true; + } + + /// + /// Acquires the next image, rebuilding first when the chain is stale. + /// Returns false when nothing can be presented this frame: parked, the chain + /// was still out of date after one rebuild, or the rebuild failed + /// (). A resize or a monitor change is ordinary, + /// never an error; a lost device throws. + /// + public bool TryAcquire(out PresentTarget target) + { + target = default; + _retirement.Collect(); + + if (NeedsRecreation || _current == null) + { + if (!Build(out _)) return false; + } + + for (int attempt = 0; attempt < 2; attempt++) + { + SwapchainSlot slot = _current!; + Semaphore acquire = slot.TakeAcquireSemaphore( + slot.FreeAcquireSemaphores == 0 ? _clock.FrameCompleted : 0); + uint imageIndex = 0; + + long waitStart = VulkanStats.WaitStart(); + if (_context.AcquireDelayForTests > TimeSpan.Zero) System.Threading.Thread.Sleep(_context.AcquireDelayForTests); + Result result = _swapchainApi.AcquireNextImage( + _context.Device, slot.Handle, ulong.MaxValue, acquire, default, ref imageIndex); + VulkanStats.NoteWait(WaitSite.SwapchainAcquire, waitStart); + + switch (SwapchainPolicy.OnAcquire(result, attempt)) + { + case AcquireAction.Present: + target = new PresentTarget(slot, imageIndex, acquire); + return true; + + case AcquireAction.PresentThenRebuild: + // Usable this frame; rebuilt before the next acquire. + NeedsRecreation = true; + target = new PresentTarget(slot, imageIndex, acquire); + return true; + + case AcquireAction.RebuildAndRetry: + slot.ReturnAcquireSemaphore(acquire); + NeedsRecreation = true; + if (!Build(out _)) return false; + continue; + + case AcquireAction.SkipFrame: + slot.ReturnAcquireSemaphore(acquire); + NeedsRecreation = true; + return false; + + default: + slot.ReturnAcquireSemaphore(acquire); + // Anything else - a lost device above all - is reported rather + // than turned into a quiet "no image this frame", which reads + // as a freeze. + VulkanResult.Check(result, "vkAcquireNextImageKHR"); + NeedsRecreation = true; + return false; + } + } + return false; + } + + /// + /// The present submission waiting on 's acquire + /// semaphore was accepted with Frame value : the + /// semaphore is reusable, and the slot destroyable, once that value completed. + /// + public void NotePresentSubmitted(in PresentTarget target, ulong frameValue) + { + target.Slot.ReturnAcquireSemaphoreAfter(target.AcquireSemaphore, frameValue); + target.Slot.NotePresentSubmitted(frameValue); + } + + public void Present(in PresentTarget target) + { + SwapchainKHR handle = target.Slot.Handle; + Semaphore wait = target.PresentSemaphore; + uint index = target.ImageIndex; + + var presentInfo = new PresentInfoKHR + { + SType = StructureType.PresentInfoKhr, + WaitSemaphoreCount = 1, + PWaitSemaphores = &wait, + SwapchainCount = 1, + PSwapchains = &handle, + PImageIndices = &index, + }; + + // Presenting is a queue operation like any other, so it takes the same + // lock as submission. + Result result; + long waitStart = VulkanStats.WaitStart(); + lock (_context.QueueLock) + { + result = _swapchainApi.QueuePresent(_context.GraphicsQueue, &presentInfo); + } + VulkanStats.NoteWait(WaitSite.Present, waitStart); + if (result is Result.ErrorOutOfDateKhr or Result.SuboptimalKhr) + { + if (ReferenceEquals(target.Slot, _current)) NeedsRecreation = true; + } + else + { + VulkanResult.Check(result, "vkQueuePresentKHR"); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + // Teardown, not recreation: everything this chain ever presented must be + // finished before its slots go. + VulkanStats.WaitDeviceIdle(_context.Api, _context.Device); + _retirement.DisposeAll(); + _current?.Dispose(); + _current = null; + + if (_surface.Handle != 0) + { + _surfaceApi.DestroySurface(_context.Instance, _surface, null); + } + + _swapchainApi.Dispose(); + _surfaceApi.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs b/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs new file mode 100644 index 00000000..b50593cf --- /dev/null +++ b/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +// The Present/ folder follows the plan's layout; the namespace stays Core until +// the renderer is reorganised, like Frame/. +namespace Optimum.Render.Vulkan.Core; + +/// +/// Swapchain slots that were replaced but may still be referenced by work the GPU +/// has not finished. +/// +/// A slot (its swapchain handle, images, views, acquire and present semaphores) +/// is retired as one unit, keyed on the Frame timeline value of the last present +/// submission that used one of its images. It is destroyed at the first +/// that sees the Frame counter at or past that value: the +/// batch that blitted into its image and signalled its present semaphore has +/// completed, and vkQueuePresentKHR, which is synchronous on the CPU, returned +/// before the slot could be replaced. No vkDeviceWaitIdle is involved. +/// +/// Render thread only: slots are created, presented and retired there. +/// +internal sealed class SwapchainRetirement +{ + private readonly record struct Entry(IDisposable Slot, ulong LastPresentValue); + + private readonly ITimelineClock _clock; + private readonly List _entries = new(); + + public SwapchainRetirement(ITimelineClock clock) => _clock = clock; + + public int PendingCount => _entries.Count; + + /// Queues until Frame value completed (0: never presented). + public void Retire(IDisposable slot, ulong lastPresentValue) => + _entries.Add(new Entry(slot, lastPresentValue)); + + /// Destroys, oldest first, every slot whose last present submission completed. Returns how many. + public int Collect() + { + if (_entries.Count == 0) return 0; + + ulong completed = _clock.FrameCompleted; + int destroyed = 0; + int kept = 0; + for (int i = 0; i < _entries.Count; i++) + { + Entry entry = _entries[i]; + if (entry.LastPresentValue <= completed) + { + entry.Slot.Dispose(); + destroyed++; + } + else + { + _entries[kept++] = entry; + } + } + _entries.RemoveRange(kept, _entries.Count - kept); + return destroyed; + } + + /// Teardown only, after the GPU finished every submission. + public void DisposeAll() + { + foreach (Entry entry in _entries) entry.Slot.Dispose(); + _entries.Clear(); + } +} + +/// What the present path does with the result of vkAcquireNextImageKHR. +internal enum AcquireAction +{ + /// The image is good; present it. + Present, + /// SUBOPTIMAL: the image is acquired and usable; rebuild before the next acquire. + PresentThenRebuild, + /// OUT_OF_DATE on the first attempt: rebuild now and acquire once more. + RebuildAndRetry, + /// OUT_OF_DATE again after the rebuild: skip presenting this frame, rebuild next frame. + SkipFrame, + /// Anything else (a lost device above all): reported, never a silent skip. + Fail, +} + +/// +/// The swapchain's decisions, as pure functions so they are tested without a +/// window (SwapchainRetirementTests). +/// +internal static class SwapchainPolicy +{ + /// + /// SUBOPTIMAL rebuilds before the next acquire; OUT_OF_DATE rebuilds and + /// re-acquires once; a second OUT_OF_DATE gives up on this frame. + /// + public static AcquireAction OnAcquire(Result result, int attempt) + { + if (result == Result.Success) return AcquireAction.Present; + if (result == Result.SuboptimalKhr) return AcquireAction.PresentThenRebuild; + if (result == Result.ErrorOutOfDateKhr) return attempt == 0 ? AcquireAction.RebuildAndRetry : AcquireAction.SkipFrame; + return AcquireAction.Fail; + } + + /// + /// max(caps.min + 1, mailbox ? 3 : 2), clamped to the surface maximum + /// (0 means unbounded). Mailbox wants a third image so a finished frame can + /// replace the queued one while another is on screen. + /// + public static uint ChooseImageCount(uint capabilitiesMin, uint capabilitiesMax, PresentModeKHR mode) + { + uint wanted = Math.Max(capabilitiesMin + 1, mode == PresentModeKHR.MailboxKhr ? 3u : 2u); + if (capabilitiesMax > 0 && wanted > capabilitiesMax) wanted = capabilitiesMax; + return wanted; + } + + /// A minimised window reports a zero extent; presentation parks until it grows again. + public static bool IsParked(Extent2D extent) => extent.Width == 0 || extent.Height == 0; + + /// + /// With vsync: FIFO, promoted to FIFO_RELAXED once sustained missed vsyncs + /// were seen and the surface offers it (a late frame tears instead of waiting + /// a whole interval). Without vsync: MAILBOX (drops frames, never tears), then + /// IMMEDIATE, then FIFO, the only mode every driver must have. + /// + public static PresentModeKHR ChoosePresentMode(bool vsync, bool relaxedPromoted, IReadOnlyList supported) + { + if (vsync) + { + return relaxedPromoted && Contains(supported, PresentModeKHR.FifoRelaxedKhr) + ? PresentModeKHR.FifoRelaxedKhr + : PresentModeKHR.FifoKhr; + } + if (Contains(supported, PresentModeKHR.MailboxKhr)) return PresentModeKHR.MailboxKhr; + if (Contains(supported, PresentModeKHR.ImmediateKhr)) return PresentModeKHR.ImmediateKhr; + return PresentModeKHR.FifoKhr; + } + + private static bool Contains(IReadOnlyList modes, PresentModeKHR mode) + { + for (int i = 0; i < modes.Count; i++) + { + if (modes[i] == mode) return true; + } + return false; + } +} + +/// +/// A slot's acquire semaphores: imageCount + 1 binary semaphores, taken for +/// each vkAcquireNextImageKHR. +/// +/// A semaphore whose acquire failed is untouched and returns at once +/// (). One whose signal a present submission waits on stays +/// in use until that submission completed on the GPU: a binary semaphore with an +/// uncompleted wait must not be handed to another acquire +/// (VUID-vkAcquireNextImageKHR-semaphore-01779), so it is parked against the +/// submission's Frame value () and reclaimed by a later +/// once the Frame counter passed it. A semaphore whose acquire +/// succeeded but was never submitted is never returned; it dies with the slot. +/// +/// The ring paces on frame n - FramesInFlight, so at most FramesInFlight - 1 present +/// submissions are uncompleted when an acquire starts; imageCount + 1 covers that. +/// +internal sealed class AcquireSemaphoreFreeList +{ + private readonly Stack _free = new(); + private readonly List<(ulong Handle, ulong FrameValue)> _pending = new(); + + public AcquireSemaphoreFreeList(IReadOnlyList handles) + { + for (int i = handles.Count - 1; i >= 0; i--) _free.Push(handles[i]); + Capacity = handles.Count; + } + + public int Capacity { get; } + public int FreeCount => _free.Count; + public int PendingCount => _pending.Count; + + public static int CapacityFor(uint imageCount) => (int)imageCount + 1; + + /// A free semaphore; parked ones whose submission completed (Frame counter ) are reclaimed first when none is free. + public ulong Take(ulong frameCompleted) + { + if (_free.Count == 0) Reclaim(frameCompleted); + if (_free.Count == 0) + { + throw new InvalidOperationException( + "every acquire semaphore of this swapchain is waited on by an uncompleted present submission " + + "or was signalled by an acquire that was never presented"); + } + return _free.Pop(); + } + + /// The acquire failed; the semaphore was never signalled. + public void Return(ulong handle) + { + CheckRoom(); + _free.Push(handle); + } + + /// A submission carrying Frame value waits on the semaphore. + public void ReturnAfter(ulong handle, ulong frameValue) + { + CheckRoom(); + _pending.Add((handle, frameValue)); + } + + private void CheckRoom() + { + if (_free.Count + _pending.Count >= Capacity) throw new InvalidOperationException("acquire semaphore returned twice"); + } + + private void Reclaim(ulong frameCompleted) + { + int kept = 0; + for (int i = 0; i < _pending.Count; i++) + { + if (_pending[i].FrameValue <= frameCompleted) _free.Push(_pending[i].Handle); + else _pending[kept++] = _pending[i]; + } + _pending.RemoveRange(kept, _pending.Count - kept); + } +} + +/// +/// Decides when FIFO should be promoted to FIFO_RELAXED: once enough frames in a +/// window missed their vsync. The refresh interval is estimated as the shortest +/// frame interval seen in the window (under FIFO nothing presents faster than the +/// display), and a frame counts as a miss when it took more than 1.5 of those. +/// A game that is consistently slower than the display never looks like it missed +/// anything, which is intended: relaxed FIFO only helps occasional late frames. +/// +internal sealed class MissedVsyncDetector +{ + public const int Window = 120; + public const int MissesToPromote = 12; + /// Intervals below this are not display refreshes (a burst after a stall). + public const double ShortestPlausibleIntervalMs = 4.0; + + private readonly double[] _intervals = new double[Window]; + private int _count; + private int _next; + + public void Reset() + { + _count = 0; + _next = 0; + } + + /// Adds one present-to-present interval; true once promotion is warranted (then resets). + public bool NoteInterval(double milliseconds) + { + if (!(milliseconds > 0) || double.IsInfinity(milliseconds)) return false; + + _intervals[_next] = milliseconds; + _next = (_next + 1) % Window; + if (_count < Window) _count++; + if (_count < Window) return false; + + double period = double.MaxValue; + for (int i = 0; i < Window; i++) + { + if (_intervals[i] >= ShortestPlausibleIntervalMs && _intervals[i] < period) period = _intervals[i]; + } + if (period == double.MaxValue) return false; + + int misses = 0; + for (int i = 0; i < Window; i++) + { + if (_intervals[i] > period * 1.5) misses++; + } + if (misses < MissesToPromote) return false; + + Reset(); + return true; + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 076f1f99..c0e00e92 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -352,7 +352,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa return false; } - if (!Swapchain.TryCreate(_context, surface, (uint)width, (uint)height, _vsync, + if (!Swapchain.TryCreate(_context, surface, (uint)width, (uint)height, _vsync, _frames.Timeline, out Swapchain? swapchain, out string? swapchainError)) { failureReason = swapchainError ?? "could not create a swapchain"; @@ -360,6 +360,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa } _swapchain = swapchain; + _presentPath = new BlitPresentPath(_context, _textures, DefaultColorTexture); CreateDefaultFramebuffer((uint)width, (uint)height); } @@ -368,7 +369,13 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa } private Swapchain? _swapchain; + private IPresentPath? _presentPath; + private readonly MissedVsyncDetector _missedVsyncs = new(); + private long _lastPresentReturn; + private string? _reportedRebuildFailure; private bool _vsync = true; + + private VulkanTexture? DefaultColorTexture() => _textures.Get(_defaultColor); private int _defaultFramebuffer; private int _defaultColor; private int _defaultDepth; @@ -741,6 +748,14 @@ private void Checkpoint(CommandBuffer commandBuffer, nint marker) _ => stage.ToString(), }; + /// + /// Ends the frame in two submissions. Submit A carries the upload batch and + /// the frame and signals the Frame timeline; only then does the CPU block on + /// vkAcquireNextImageKHR, with the whole frame already in flight. Submit B + /// (the present path: the flipped blit) waits on the frame at + /// COLOR_ATTACHMENT_OUTPUT and on the acquire semaphore at the image's first + /// use, and signals the image's present semaphore; then the image is presented. + /// public void Present() { if (!_frameActive) return; @@ -748,133 +763,95 @@ public void Present() TextureDump.NoteFrame(); if (TextureDump.Wanted) DumpRequestedTextures(); - CommandBuffer commandBuffer = _frames.Current.CommandBuffer; - // Any open rendering scope has to close before the command buffer ends. - _targets.EndRendering(commandBuffer); + _targets.EndRendering(_frames.Current.CommandBuffer); - if (_swapchain == null) - { - // Headless: nothing to present, but the frame still has to be - // submitted or the slot's fence would never signal. - _frames.EndFrame(); - _frameActive = false; - return; - } + long presentEntry = System.Diagnostics.Stopwatch.GetTimestamp(); + ulong renderValue = _frames.EndFrame(); + _frameActive = false; + long frameSubmitted = System.Diagnostics.Stopwatch.GetTimestamp(); + + // Headless: nothing to present; the frame is submitted all the same. + if (_swapchain == null || _presentPath == null) return; - if (!_swapchain.TryAcquire(out uint imageIndex, - out Semaphore imageAvailable, out Semaphore renderFinished)) + bool acquired = _swapchain.TryAcquire(out PresentTarget target); + long acquireReturned = System.Diagnostics.Stopwatch.GetTimestamp(); + bool renderCompletedAtAcquire = _frames.Timeline.FrameCompleted >= renderValue; + ReportRebuildFailure(); + if (!acquired) { - _frames.EndFrame(); - _frameActive = false; - RecreateSwapchain(); + LastPresentTimingsForTests = new PresentTimings(presentEntry, frameSubmitted, acquireReturned, 0, + renderValue, 0, renderCompletedAtAcquire, false); return; } - Checkpoint(commandBuffer, CheckpointMarker.PresentBlit(imageIndex, _frameCounter)); - BlitToSwapchain(commandBuffer, imageIndex); + CommandBuffer presentCommands = _frames.BeginPresentCommands(); + Checkpoint(presentCommands, CheckpointMarker.PresentBlit(target.ImageIndex, _frameCounter)); + _presentPath.Record(presentCommands, target); + ulong presentValue = _frames.SubmitPresent( + target.AcquireSemaphore, _presentPath.AcquireWaitStage, renderValue, target.PresentSemaphore); + _swapchain.NotePresentSubmitted(target, presentValue); + long presentSubmitted = System.Diagnostics.Stopwatch.GetTimestamp(); - _frames.EndFrame(imageAvailable, renderFinished); - _frameActive = false; + _swapchain.Present(target); + LastPresentTimingsForTests = new PresentTimings(presentEntry, frameSubmitted, acquireReturned, presentSubmitted, + renderValue, presentValue, renderCompletedAtAcquire, true); - _swapchain.Present(imageIndex, renderFinished); - if (_swapchain.NeedsRecreation) RecreateSwapchain(); + long presentReturn = System.Diagnostics.Stopwatch.GetTimestamp(); + if (_lastPresentReturn != 0 && _vsync && + _missedVsyncs.NoteInterval((presentReturn - _lastPresentReturn) * 1000.0 / System.Diagnostics.Stopwatch.Frequency) && + _swapchain.PromoteToRelaxedFifo()) + { + MirrorValidationMessage("--- sustained missed vsyncs: swapchain promoted to FIFO_RELAXED"); + } + _lastPresentReturn = presentReturn; } - /// - /// Copies the rendered frame into the acquired swapchain image, flipped. - /// - /// This inverted blit is the entire Y-flip story for the backend. Everything - /// upstream stays in OpenGL's orientation, which is what keeps intermediate - /// targets and screenshots byte-identical to the GL path; the display wants - /// row 0 at the top, so the source rows are read bottom-to-top exactly once, - /// here. - /// - private void BlitToSwapchain(CommandBuffer commandBuffer, uint imageIndex) - { - VulkanTexture? source = _textures.Get(_defaultColor); - if (source == null || _swapchain == null) return; - - Vk api = _context.Api; - Image destination = _swapchain.ImageAt(imageIndex); + /// Stopwatch timestamps of one Present, for PresentDecouplingTests. + internal readonly record struct PresentTimings( + long PresentEntry, long FrameSubmitted, long AcquireReturned, long PresentSubmitted, + ulong RenderValue, ulong PresentValue, bool RenderCompletedAtAcquire, bool Presented); - _textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal); - TransitionSwapchainImage(commandBuffer, destination, - ImageLayout.Undefined, ImageLayout.TransferDstOptimal); + /// The last Present's timings. Tests only. + internal PresentTimings LastPresentTimingsForTests { get; private set; } - var blit = new ImageBlit - { - SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), - DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), - }; - // Source Y runs backwards: this is the flip. - blit.SrcOffsets.Element0 = new Offset3D(0, (int)source.Height, 0); - blit.SrcOffsets.Element1 = new Offset3D((int)source.Width, 0, 1); - blit.DstOffsets.Element0 = new Offset3D(0, 0, 0); - blit.DstOffsets.Element1 = new Offset3D((int)_swapchain.Extent.Width, (int)_swapchain.Extent.Height, 1); - - api.CmdBlitImage(commandBuffer, - source.Image, ImageLayout.TransferSrcOptimal, - destination, ImageLayout.TransferDstOptimal, - 1, &blit, Filter.Linear); - - TransitionSwapchainImage(commandBuffer, destination, - ImageLayout.TransferDstOptimal, ImageLayout.PresentSrcKhr); - } - - private void TransitionSwapchainImage( - CommandBuffer commandBuffer, Image image, ImageLayout from, ImageLayout to) - { - var barrier = new ImageMemoryBarrier2 - { - SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = PipelineStageFlags2.AllCommandsBit, - SrcAccessMask = TextureManager.AccessForLayout(from, writer: true), - DstStageMask = PipelineStageFlags2.AllCommandsBit, - DstAccessMask = TextureManager.AccessForLayout(to, writer: false), - OldLayout = from, - NewLayout = to, - Image = image, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), - }; + /// The swapchain, null when headless. Tests only. + internal Swapchain? SwapchainForTests => _swapchain; - var dependency = new DependencyInfo - { - SType = StructureType.DependencyInfo, - ImageMemoryBarrierCount = 1, - PImageMemoryBarriers = &barrier, - }; - _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); - VulkanStats.NoteImageBarriers(1); - } + /// The present path's acquire wait stage. Tests only. + internal PipelineStageFlags PresentAcquireWaitStageForTests => + _presentPath?.AcquireWaitStage ?? PresentWaitStages.BlitAcquireWait; - private void RecreateSwapchain() + private void ReportRebuildFailure() { - if (_swapchain == null || _windowWidth == 0 || _windowHeight == 0) return; - - if (!_swapchain.Recreate(_windowWidth, _windowHeight, _vsync, out string? failureReason)) + string? failure = _swapchain?.RebuildFailure; + if (failure != null && failure != _reportedRebuildFailure) { - _diagnostics.Add("swapchain recreation failed: " + failureReason); + _diagnostics.Add("swapchain recreation failed: " + failure); } + _reportedRebuildFailure = failure; } + /// + /// A new window size: the default framebuffer is rebuilt now (its old images + /// retire on the timelines), the swapchain at the next acquire. Nothing waits. + /// public void Resize(int width, int height) { if (_swapchain == null || width <= 0 || height <= 0) return; if ((uint)width == _windowWidth && (uint)height == _windowHeight) return; - VulkanStats.WaitDeviceIdle(_context.Api, _context.Device); - DestroyDefaultFramebuffer(); CreateDefaultFramebuffer((uint)width, (uint)height); - RecreateSwapchain(); + _swapchain.RequestRebuild(_windowWidth, _windowHeight, _vsync); } public void SetVSync(bool enabled) { if (_vsync == enabled) return; _vsync = enabled; - RecreateSwapchain(); + _missedVsyncs.Reset(); + _swapchain?.RequestRebuild(_windowWidth, _windowHeight, _vsync); } private CommandBuffer Commands => _frames.Current.CommandBuffer; diff --git a/Optimum.Tests/temporal-render-inventory-tests.cs b/Optimum.Tests/temporal-render-inventory-tests.cs index de362174..6b8de27f 100644 --- a/Optimum.Tests/temporal-render-inventory-tests.cs +++ b/Optimum.Tests/temporal-render-inventory-tests.cs @@ -196,10 +196,17 @@ public void TheLateStageRowsAreRefusedTheMotionWindow() [Fact] public void VulkanPresentBlitIsTheOnlyYFlip() { + // Phase 1B step 4 moved the blit into the present path (Submit B); the + // flip itself is unchanged and still happens exactly once. + string presentPath = Read("Optimum.Render.Vulkan/Present/IPresentPath.cs"); string vulkanDevice = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); - Assert.Contains("This inverted blit is the entire Y-flip story for the backend.", vulkanDevice); - Assert.Contains("// Source Y runs backwards: this is the flip.", vulkanDevice); + Assert.Contains("This inverted blit is the entire Y-flip story for the backend.", presentPath); + Assert.Contains("// Source Y runs backwards: this is the flip.", presentPath); + Assert.Contains("blit.SrcOffsets.Element0 = new Offset3D(0, (int)source.Height, 0);", presentPath); + Assert.DoesNotContain("this is the flip", vulkanDevice); + Assert.DoesNotContain("CmdBlitImage", vulkanDevice.Substring(vulkanDevice.IndexOf(" public void Present()", System.StringComparison.Ordinal))); + Assert.Contains("_presentPath = new BlitPresentPath(", vulkanDevice); } private static string Read(string relativePath) diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 9488cb56..cfc40308 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -433,17 +433,45 @@ private static string AddedLines(string patch) => private static string Read(string relativePath) => File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + /// + /// Phase 1B step 4: the frame and the present are two submissions. The CPU + /// acquires only after the frame is in flight, the present submission waits on + /// the acquire semaphore at the image's first use (never ALL_COMMANDS) and on + /// the frame at COLOR_ATTACHMENT_OUTPUT, present semaphores stay per image, and + /// recreation passes oldSwapchain and never waits for the device. + /// [Fact] - public void ThePresentPathWaitsForTheSwapchainImageAtEveryStageAndOwnsSemaphoresPerImage() + public void ThePresentPathSplitsTheSubmissionAndRecreatesWithoutWaiting() { string ring = Read("Optimum.Render.Vulkan/Core/FrameRing.cs"); - // The first use of the acquired image is the present blit (transfer); - // a COLOR_ATTACHMENT_OUTPUT wait would not order it. - Assert.Contains("PipelineStageFlags waitStage = PipelineStageFlags.AllCommandsBit)", ring); + Assert.DoesNotContain("PipelineStageFlags.AllCommandsBit", ring); + Assert.Contains("PresentWaitStages.RequireAcquireStage(acquireStage);", ring); + Assert.Contains("waitStages[waitCount] = PresentWaitStages.FrameWait;", ring); - string swapchain = Read("Optimum.Render.Vulkan/Core/Swapchain.cs"); - Assert.Contains("signalSemaphore = _renderFinished[(int)imageIndex %", swapchain); - Assert.DoesNotContain("signalSemaphore = _renderFinished[_semaphoreIndex];", swapchain); + string stages = Read("Optimum.Render.Vulkan/Present/IPresentPath.cs"); + Assert.Contains("FrameWait = PipelineStageFlags.ColorAttachmentOutputBit;", stages); + Assert.Contains("BlitAcquireWait = PipelineStageFlags.TransferBit;", stages); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + int present = device.IndexOf(" public void Present()", StringComparison.Ordinal); + int frameSubmit = device.IndexOf("ulong renderValue = _frames.EndFrame();", present, StringComparison.Ordinal); + int acquire = device.IndexOf("_swapchain.TryAcquire(out PresentTarget target)", present, StringComparison.Ordinal); + int presentSubmit = device.IndexOf("_frames.SubmitPresent(", present, StringComparison.Ordinal); + int queuePresent = device.IndexOf("_swapchain.Present(target);", present, StringComparison.Ordinal); + Assert.True(present >= 0 && frameSubmit > present && acquire > frameSubmit && + presentSubmit > acquire && queuePresent > presentSubmit, + "Present must submit the frame, then acquire, then submit the present path, then present"); + int resizeStart = device.IndexOf(" public void Resize(", StringComparison.Ordinal); + int resizeEnd = device.IndexOf(" public void SetVSync(", resizeStart, StringComparison.Ordinal); + Assert.True(resizeStart >= 0 && resizeEnd > resizeStart); + Assert.DoesNotContain("WaitDeviceIdle", device.Substring(resizeStart, resizeEnd - resizeStart)); + + string swapchain = Read("Optimum.Render.Vulkan/Present/Swapchain.cs"); + Assert.Contains("OldSwapchain = old?.Handle ?? default,", swapchain); + Assert.Contains("_retirement.Retire(old, old.LastPresentValue);", swapchain); + Assert.Contains("public Semaphore PresentSemaphoreFor(uint imageIndex) => _presentSemaphores[imageIndex];", swapchain); + // vkDeviceWaitIdle only at teardown (Dispose), never in a rebuild. + Assert.Equal(1, swapchain.Split("WaitDeviceIdle").Length - 1); } /// From f682f4f9580668e60a1a4f58409677b3cc0e935d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:11:02 +0200 Subject: [PATCH 096/226] wip(phase1a-step4): coverage re-pointed at VulkanClientPlatform; gate tests for the moved device branches Re-pointed (pure text moves of the assertions, same checks): the device-side lines the parity-dump, program/UBO virtuals, TAA entity/liquid/particle/pipeline/sharpen/terrain, temporal-contract (formats unchanged, contract v1 untouched) and backend-integration tests pinned in ClientPlatformWindows now read VulkanClientPlatform*.cs through Optimum.Tests/vulkan-platform-source.cs; GL-side assertions stay on ClientPlatformWindows. Tests that asserted transplant targets for bodies that are vanilla again now assert the Vulkan override instead. VulkanClientPlatform.ExpectedVirtuals also lists the step-3 program/uniform/UBO virtuals it overrides. New: platform-device-branch-move-coverage-tests (ClientPlatformWindows has no OptimumRender.Device / IOptimumGraphicsDevice / optimumDevice; every VulkanClientPlatform override is abstract, an injected virtual listed in the patcher and the self-check, or virtualized in place and listed; the three base edits; the injected accessors shipped and self-checked). GPU PlatformDeviceRoutingTests: reflection check that every override of a patched virtual is in the self-check, and a readback that drives CreateFramebuffer, CurrentFrameBuffer, state setters, ClearFrameBuffer, UseShaderProgram, GlScissor/Flag, RenderFullscreenTriangle, BeginFrame/EndFrame and DisposeFrameBuffer through the platform with no GL context (left half draw colour, right half clear colour, exact UNORM8). Verified: dotnet build VintageStory.slnx -c Release 0 errors; Optimum.Tests 1087 passed, 0 failed; Optimum.Render.Vulkan.Tests 422 passed, 0 failed (sync,best, no unlisted SYNC-); Cecil patch (output bin/patch-check) 197/197 required methods, Virtual dispatch verifier ok (25 callvirt, 0 call/ldftn); check-vanilla-compat ok; extract + check-patches 0 conflict, 0 pending. --- .../PlatformDeviceRoutingTests.cs | 183 ++++++++++++++++++ .../Platform/VulkanClientPlatform.cs | 24 +++ Optimum.Tests/parity-dump-coverage-tests.cs | 55 ++++-- .../platform-client-program-coverage-tests.cs | 5 +- ...tform-device-branch-move-coverage-tests.cs | 149 ++++++++++++++ ...orm-program-ubo-virtuals-coverage-tests.cs | 24 ++- .../taa-entity-motion-coverage-tests.cs | 10 +- .../taa-liquid-motion-coverage-tests.cs | 11 +- .../taa-particle-motion-coverage-tests.cs | 12 +- Optimum.Tests/taa-pipeline-coverage-tests.cs | 55 ++++-- Optimum.Tests/taa-sharpen-coverage-tests.cs | 23 ++- .../taa-terrain-motion-coverage-tests.cs | 41 ++-- Optimum.Tests/temporal-contract-tests.cs | 26 +-- .../vulkan-backend-integration-tests.cs | 140 ++++++++------ Optimum.Tests/vulkan-platform-source.cs | 37 ++++ 15 files changed, 641 insertions(+), 154 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs create mode 100644 Optimum.Tests/platform-device-branch-move-coverage-tests.cs create mode 100644 Optimum.Tests/vulkan-platform-source.cs diff --git a/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs new file mode 100644 index 00000000..2b84af5f --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 4: ClientPlatformWindows keeps only the GL path, and +/// VulkanClientPlatform overrides every graphics member with device calls. These drive the +/// moved overrides through the platform with no GL context: an override that is missing +/// falls into a GL call and throws, one that reaches the wrong device call changes the pixels. +/// +public class PlatformDeviceRoutingTests +{ + private readonly ITestOutputHelper _output; + + public PlatformDeviceRoutingTests(ITestOutputHelper output) => _output = output; + + /// + /// The runtime self-check (VerifyHost) has to cover every override whose base member + /// only exists in the patched lib - an injected ClientPlatformAbstract virtual or a + /// member virtualized in place on ClientPlatformWindows - or an unpatched lib would + /// bypass it mid-frame instead of failing the install. + /// + [Fact] + public void EveryOverrideOfAPatchedVirtualIsInTheSelfCheck() + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + int checkedOverrides = 0; + foreach (MethodInfo method in typeof(VulkanClientPlatform).GetMethods(flags)) + { + MethodInfo definition = method.GetBaseDefinition(); + if (definition == method || method.IsSpecialName) continue; + Type owner = definition.DeclaringType!; + bool onAbstract = owner == typeof(ClientPlatformAbstract); + if (!(onAbstract && !definition.IsAbstract) && owner != typeof(ClientPlatformWindows)) continue; + + ParameterInfo[] parameters = method.GetParameters(); + bool listed = false; + foreach (VulkanClientPlatform.ExpectedVirtual expected in VulkanClientPlatform.ExpectedVirtuals) + { + if (expected.OnAbstract != onAbstract || expected.Name != method.Name || expected.ParameterTypeNames.Length != parameters.Length) continue; + bool same = true; + for (int i = 0; i < parameters.Length && same; i++) + same = expected.ParameterTypeNames[i] == parameters[i].ParameterType.Name; + listed |= same; + } + Assert.True(listed, owner.Name + "." + method.Name + " is overridden but not in VulkanClientPlatform.ExpectedVirtuals"); + checkedOverrides++; + } + _output.WriteLine("patched virtuals overridden: " + checkedOverrides); + Assert.True(checkedOverrides >= 40); + } + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + /// + /// Frame 1: CreateFramebuffer, the CurrentFrameBuffer setter (BindCurrentFrameBuffer), + /// the state setters and ClearFrameBuffer (ClearBoundFrameBuffer) through the platform. + /// Frame 2: a program drawn with RenderFullscreenTriangle under GlScissor/GlScissorFlag + /// over the left half only. Frame 3 reads back: left half the draw colour, right half the + /// clear colour. BeginFrame/EndFrame are the platform's bracket; DisposeFrameBuffer and + /// GLDeleteTexture release everything, and validation (sync, best) stays clean. + /// + [SkippableFact] + public unsafe void FramebufferClearScissorAndDrawReachTheDeviceThroughThePlatform() + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-device-routing-test-" + Guid.NewGuid().ToString("N")); + var platform = new VulkanClientPlatform(null!) + { + DeviceFactory = GpuTest.NewDevice, + CrashMarkerDataPath = dataPath, + }; + try + { + bool installed = platform.InitializeGraphics(IntPtr.Zero, 0, 0, out string reason); + if (!installed) _output.WriteLine("Vulkan unavailable: " + reason); + Skip.IfNot(installed, "No usable Vulkan device."); + IOptimumGraphicsDevice seam = OptimumRender.Device!; + Assert.Same(seam, platform.GraphicsDevice); + const int size = 16; + + var attrs = new FramebufferAttrs("routed", size, size) + { + Attachments = new[] + { + new FramebufferAttrsAttachment + { + AttachmentType = EnumFramebufferAttachment.ColorAttachment0, + Texture = new RawTexture + { + Width = size, + Height = size, + PixelInternalFormat = EnumTextureInternalFormat.Rgba8, + PixelFormat = EnumTexturePixelFormat.Rgba, + MinFilter = EnumTextureFilter.Nearest, + MagFilter = EnumTextureFilter.Nearest, + }, + }, + }, + }; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4(200.0 / 255.0, 40.0 / 255.0, 90.0 / 255.0, 1.0); } + """, "routed-draw"); + + platform.BeginFrame(); + FrameBufferRef target = platform.CreateFramebuffer(attrs); + Assert.True(target.FboId > 0); + Assert.Single(target.ColorTextureIds); + platform.CurrentFrameBuffer = target; + Assert.Same(target, platform.CurrentFrameBuffer); + platform.GlDisableDepthTest(); + platform.GlDisableCullFace(); + platform.GlToggleBlend(false); + platform.ClearFrameBuffer(target, new[] { 20f / 255f, 140f / 255f, 220f / 255f, 1f }, clearDepthBuffer: false); + platform.EndFrame(); + + platform.BeginFrame(); + platform.CurrentFrameBuffer = target; + platform.GlDisableDepthTest(); + platform.GlDisableCullFace(); + platform.GlToggleBlend(false); + platform.UseShaderProgram(program); + platform.GlScissor(0, 0, size / 2, size); + platform.GlScissorFlag(true); + Assert.True(platform.GlScissorFlagEnabled); + platform.RenderFullscreenTriangle(null!); + platform.GlScissorFlag(false); + Assert.False(platform.GlScissorFlagEnabled); + platform.UseShaderProgram(0); + platform.EndFrame(); + + var pixels = new byte[size * size * 4]; + platform.BeginFrame(); + platform.CurrentFrameBuffer = target; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + platform.EndFrame(); + + int row = size / 2 * size; + int left = (row + 2) * 4; + int right = (row + size - 3) * 4; + _output.WriteLine($"left RGBA = {pixels[left]}, {pixels[left + 1]}, {pixels[left + 2]}, {pixels[left + 3]}"); + _output.WriteLine($"right RGBA = {pixels[right]}, {pixels[right + 1]}, {pixels[right + 2]}, {pixels[right + 3]}"); + Assert.Equal(new byte[] { 200, 40, 90, 255 }, pixels[left..(left + 4)]); + Assert.Equal(new byte[] { 20, 140, 220, 255 }, pixels[right..(right + 4)]); + + platform.DisposeFrameBuffer(target); + // Linked on the device directly above, so freed there too. + seam.DeleteProgram(program); + GpuTest.AssertClean(seam); + } + finally + { + platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index f3815ab9..fda39783 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -54,6 +54,30 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(false, "RenderFullscreenTriangle", new[] { "MeshRef" }), new(false, "GetGraphicsCardRenderer", Array.Empty()), new(false, "LogAndTestHardwareInfosStage2", Array.Empty()), + // Phase 1A step 3: program, uniform and UBO operations (overridden since step 4). + new(true, "UseShaderProgram", new[] { "Int32" }), + new(true, "DisposeShaderProgram", new[] { "ShaderProgramBase" }), + new(true, "BindSampler", new[] { "Int32", "Int32" }), + new(true, "SetUniform", new[] { "Int32", "Int32", "Single" }), + new(true, "SetUniform", new[] { "Int32", "Int32", "Int32" }), + new(true, "SetUniform", new[] { "Int32", "Int32", "Single", "Single" }), + new(true, "SetUniform", new[] { "Int32", "Int32", "Single", "Single", "Single" }), + new(true, "SetUniform", new[] { "Int32", "Int32", "Single", "Single", "Single", "Single" }), + new(true, "SetUniform", new[] { "Int32", "Int32", "Int32", "Int32", "Int32" }), + new(true, "SetUniformArray1", new[] { "Int32", "Int32", "Int32", "Single[]" }), + new(true, "SetUniformArray2", new[] { "Int32", "Int32", "Int32", "Single[]" }), + new(true, "SetUniformArray3", new[] { "Int32", "Int32", "Int32", "Single[]" }), + new(true, "SetUniformArray4", new[] { "Int32", "Int32", "Int32", "Single[]" }), + new(true, "SetUniformMatrix", new[] { "Int32", "Int32", "Single[]" }), + new(true, "SetUniformMatrix", new[] { "Int32", "Int32", "Matrix4&" }), + new(true, "SetUniformMatrices", new[] { "Int32", "Int32", "Int32", "Single[]" }), + new(true, "SetUniformMatrices4x3", new[] { "Int32", "Int32", "Int32", "Single[]" }), + new(true, "BindProgramTexture2D", new[] { "ShaderProgramBase", "String", "Int32", "Int32" }), + new(true, "BindProgramTextureCube", new[] { "ShaderProgramBase", "String", "Int32", "Int32" }), + new(true, "BindUBO", new[] { "UBO" }), + new(true, "UnbindUBO", new[] { "UBO" }), + new(true, "UpdateUBO", new[] { "UBO", "IntPtr", "Int32", "Int32", "Boolean" }), + new(true, "DeleteUBO", new[] { "UBO" }), // Phase 1A step 4: TAA motion windows and FSR target selection. new(true, "EnableMotionDrawBuffers", Array.Empty()), new(true, "RestorePrimaryDrawBuffers", Array.Empty()), diff --git a/Optimum.Tests/parity-dump-coverage-tests.cs b/Optimum.Tests/parity-dump-coverage-tests.cs index e4dfc7e4..efb67e75 100644 --- a/Optimum.Tests/parity-dump-coverage-tests.cs +++ b/Optimum.Tests/parity-dump-coverage-tests.cs @@ -35,7 +35,14 @@ public void DumpedSlotListMatchesTheFramebuffersBothSetupsCreate() Dictionary constants = Constants(platform); string glBody = MethodBody(platform, "public virtual List SetupDefaultFrameBuffers()"); - string deviceBody = MethodBody(platform, "private List SetupOptimumFrameBuffers("); + // Phase 1A step 4: the device-path setup is VulkanClientPlatform's SetupDefaultFrameBuffers + // override, indexing the same slot constants. + string vulkan = VulkanPlatformSource.Read(); + string deviceBody = MethodBody(vulkan, "public override List SetupDefaultFrameBuffers()"); + foreach (string slotConstant in new[] { "OptimumFsrFramebufferIndex", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumTaaSharpenIndex" }) + { + Assert.Contains("private const int " + slotConstant + " = " + constants[slotConstant] + ";", vulkan); + } string namesBody = MethodBody(platform, "private string OptimumParitySlotName(int slot)"); SortedSet glSlots = AssignedSlots(glBody, constants); @@ -84,11 +91,18 @@ public void GlAndVulkanWriteThroughOneFileNameFormat() string platform = ReadSourceOrPatched(PlatformPatch, PlatformSource); string attachment = MethodBody(platform, "private int OptimumParityDumpAttachment("); - int deviceRead = attachment.IndexOf("readback = device.ReadTextureForParity(textureId);", StringComparison.Ordinal); - int glRead = attachment.IndexOf("readback = OptimumParityReadTextureGl(textureId);", StringComparison.Ordinal); + // Phase 1A step 4: the readback is the platform virtual ReadTextureForParity - glGetTexImage + // in ClientPlatformWindows, the device readback in VulkanClientPlatform. + int read = attachment.IndexOf("OptimumTextureReadback readback = ReadTextureForParity(textureId);", StringComparison.Ordinal); int write = attachment.IndexOf("OptimumParityDump.Write(directory, slot, slotName, attachment, readback)", StringComparison.Ordinal); - Assert.True(deviceRead >= 0 && glRead > deviceRead && write > glRead, "both backends must reach the one shared writer"); - Assert.Equal(1, Count(platform, "ReadTextureForParity(")); + Assert.True(read >= 0 && write > read, "both backends must reach the one shared writer"); + Assert.Contains("return OptimumParityReadTextureGl(textureId);", + MethodBody(platform, "public override OptimumTextureReadback ReadTextureForParity(int textureId)")); + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("return device.ReadTextureForParity(textureId);", + MethodBody(vulkan, "public override OptimumTextureReadback ReadTextureForParity(int textureId)")); + Assert.Equal(1, Count(vulkan, "device.ReadTextureForParity(")); + Assert.DoesNotContain("device.ReadTextureForParity(", platform); Assert.Equal(1, Count(platform, "OptimumParityDump.Write(")); Assert.DoesNotContain("FileStream", MethodBody(platform, "private OptimumTextureReadback OptimumParityReadTextureGl(int textureId)")); @@ -114,23 +128,22 @@ public void EnvUnsetCostsOneStaticBoolCheckPerFrame() string platform = ReadSourceOrPatched(PlatformPatch, PlatformSource); string frame = MethodBody(platform, "private void window_RenderFrame(FrameEventArgs e)"); - const string guard = "if (Vintagestory.API.Config.OptimumParityDump.Enabled)\n\t\t\t{\n\t\t\t\tOptimumRunParityDump();"; - const string glGuard = "if (Vintagestory.API.Config.OptimumParityDump.Enabled)\n\t\t{\n\t\t\tOptimumRunParityDump();"; + const string guard = "if (Vintagestory.API.Config.OptimumParityDump.Enabled)\n\t\t{\n\t\t\tOptimumRunParityDump();"; string normalized = frame.Replace("\r\n", "\n"); - Assert.Equal(2, Count(normalized, "OptimumRunParityDump();")); - Assert.Equal(2, Count(platform, "OptimumRunParityDump();")); - - // Device path: after the frame (post chain and final blit), before Present. - int deviceFrame = normalized.IndexOf("frameHandler.OnNewFrame(dt);", StringComparison.Ordinal); - int deviceGuard = normalized.IndexOf(guard, StringComparison.Ordinal); - int present = normalized.IndexOf("optimumDevice.Present();", StringComparison.Ordinal); - Assert.True(deviceFrame >= 0 && deviceGuard > deviceFrame && present > deviceGuard); - - // GL path: after the frame, before SwapBuffers. - int glFrame = normalized.IndexOf("frameHandler.OnNewFrame(dt);", present, StringComparison.Ordinal); - int glGuardIndex = normalized.IndexOf(glGuard, present, StringComparison.Ordinal); - int swap = normalized.IndexOf("((GameWindow)window).SwapBuffers();", present, StringComparison.Ordinal); - Assert.True(glFrame > present && glGuardIndex > glFrame && swap > glGuardIndex); + // Phase 1A step 4: one frame body for both backends, bracketed by the platform. + Assert.Equal(1, Count(normalized, "OptimumRunParityDump();")); + Assert.Equal(1, Count(platform, "OptimumRunParityDump();")); + + // After the frame (post chain and final blit), before the platform ends it. + int begin = normalized.IndexOf("BeginFrame();", StringComparison.Ordinal); + int frameCall = normalized.IndexOf("frameHandler.OnNewFrame(dt);", StringComparison.Ordinal); + int guardIndex = normalized.IndexOf(guard, StringComparison.Ordinal); + int end = normalized.IndexOf("EndFrame();", StringComparison.Ordinal); + Assert.True(begin >= 0 && frameCall > begin && guardIndex > frameCall && end > guardIndex); + + // EndFrame is SwapBuffers on OpenGL and Present on Vulkan. + Assert.Contains("((GameWindow)window).SwapBuffers();", MethodBody(platform, "public override void EndFrame()")); + Assert.Contains("device.Present();", MethodBody(VulkanPlatformSource.Read(), "public override void EndFrame()")); // The final blit happens inside OnNewFrame, so the dump sees the finished frame. string screenManager = ReadSourceOrPatched( diff --git a/Optimum.Tests/platform-client-program-coverage-tests.cs b/Optimum.Tests/platform-client-program-coverage-tests.cs index ac610c0b..653939c5 100644 --- a/Optimum.Tests/platform-client-program-coverage-tests.cs +++ b/Optimum.Tests/platform-client-program-coverage-tests.cs @@ -243,12 +243,13 @@ public void VulkanClientPlatformDerivesFromClientPlatformWindows() string platform = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); Assert.Contains("namespace Optimum.Render.Vulkan.Platform;", platform); - Assert.Contains("public class VulkanClientPlatform : ClientPlatformWindows", platform); + Assert.Contains("public partial class VulkanClientPlatform : ClientPlatformWindows", platform); Assert.Contains("public VulkanClientPlatform(Logger logger) : base(logger)", platform); Assert.Contains("public override bool InitializeGraphics(IntPtr windowHandle, int width, int height, out string reason)", platform); Assert.Contains("public override void ShutdownGraphics()", platform); Assert.Contains("\"OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE\"", platform); - // Step 1 overrides nothing else: the base's device branches keep rendering. + // The main file holds bring-up and teardown only; the graphics overrides (Phase 1A + // step 4) live in the VulkanClientPlatform.*.cs partial files. Assert.Equal(2, Regex.Matches(platform, @"^\s*(public|protected|internal)\s+override\s", RegexOptions.Multiline).Count); } diff --git a/Optimum.Tests/platform-device-branch-move-coverage-tests.cs b/Optimum.Tests/platform-device-branch-move-coverage-tests.cs new file mode 100644 index 00000000..e0f839ee --- /dev/null +++ b/Optimum.Tests/platform-device-branch-move-coverage-tests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 4: every OptimumRender.Device branch left +/// ClientPlatformWindows and lives in VulkanClientPlatform. The base keeps only the GL path +/// and calls platform virtuals where logic both backends share (post chain, TAA windows, +/// frame loop) meets the graphics API. An override whose base member is not virtual in the +/// patched lib would be bypassed silently; one the runtime self-check does not list would +/// fail mid-frame instead of falling back to OpenGL at install. +/// +public class PlatformDeviceBranchMoveCoverageTests +{ + private const string AbstractSource = "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"; + + [Fact] + public void ClientPlatformWindowsHasNoDeviceBranch() + { + string code = StripComments(VulkanPlatformSource.ReadClientPlatformWindows()); + + Assert.DoesNotContain("OptimumRender.Device", code); + Assert.DoesNotContain("IOptimumGraphicsDevice", code); + Assert.DoesNotContain("optimumDevice", code); + } + + [Fact] + public void EveryVulkanPlatformOverrideIsVirtualInThePatchedBaseAndSelfChecked() + { + string vulkan = StripComments(VulkanPlatformSource.Read()); + string abstractPlatform = StripComments(Read(AbstractSource)); + string windows = StripComments(VulkanPlatformSource.ReadClientPlatformWindows()); + string patcher = Read("Optimum.Patcher/Program.cs"); + string injectedAbstract = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()", "},"); + string virtualized = Block(patcher, "var methodsToVirtualize = new List", "};"); + string selfCheck = Block(Read(VulkanPlatformSource.MainFile), "internal static readonly ExpectedVirtual[] ExpectedVirtuals", "};"); + + var names = new SortedSet(StringComparer.Ordinal); + foreach (Match match in Regex.Matches(vulkan, @"public\s+override\s+(?:unsafe\s+)?[\w<>\[\].]+\s+(\w+)\s*(?:\(|$|\{)", RegexOptions.Multiline)) + { + names.Add(match.Groups[1].Value); + } + Assert.True(names.Count > 90, "expected the whole graphics surface to be overridden, found " + names.Count); + + foreach (string name in names) + { + string member = @"\b" + Regex.Escape(name) + @"\s*(?:\(|\{|$|=>)"; + bool abstractMember = Regex.IsMatch(abstractPlatform, @"public\s+abstract\s+[^;{}=]*?" + member, RegexOptions.Multiline); + bool injectedVirtual = Regex.IsMatch(abstractPlatform, @"public\s+virtual\s+[^;{}=]*?" + member, RegexOptions.Multiline); + bool virtualizedInPlace = Regex.IsMatch(windows, @"public\s+virtual\s+[^;{}=]*?" + member, RegexOptions.Multiline); + Assert.True(abstractMember || injectedVirtual || virtualizedInPlace, + name + " is overridden by VulkanClientPlatform but is neither abstract nor virtual in the base"); + + if (injectedVirtual) + { + Assert.True(injectedAbstract.Contains("\"" + name + "\",", StringComparison.Ordinal), + name + " is an injected ClientPlatformAbstract virtual the patcher does not inject"); + Assert.True(selfCheck.Contains("new(true, \"" + name + "\"", StringComparison.Ordinal), + name + " is missing from VulkanClientPlatform.ExpectedVirtuals"); + } + if (virtualizedInPlace && !abstractMember && !injectedVirtual) + { + Assert.True(virtualized.Contains("\"" + name + "\"", StringComparison.Ordinal), + name + " is virtual in the donor ClientPlatformWindows but not in methodsToVirtualize"); + Assert.True(selfCheck.Contains("new(false, \"" + name + "\"", StringComparison.Ordinal), + name + " is missing from VulkanClientPlatform.ExpectedVirtuals"); + } + } + } + + [Fact] + public void TheThreeBaseEditsAreInPlace() + { + string windows = VulkanPlatformSource.ReadClientPlatformWindows(); + string patcher = Read("Optimum.Patcher/Program.cs"); + + string frame = Body(windows, "private void window_RenderFrame(FrameEventArgs e)"); + int begin = frame.IndexOf("BeginFrame();", StringComparison.Ordinal); + int handler = frame.IndexOf("frameHandler.OnNewFrame(dt);", StringComparison.Ordinal); + int end = frame.IndexOf("EndFrame();", StringComparison.Ordinal); + Assert.True(begin >= 0 && handler > begin && end > handler); + Assert.Contains("((GameWindow)window).SwapBuffers();", Body(windows, "public override void EndFrame()")); + + Assert.Contains("SupportsThickLines = ProbeThickLineSupport();", Body(windows, "public void Start()")); + Assert.Contains("GL.LineWidth(1.5f);", Body(windows, "public override bool ProbeThickLineSupport()")); + + string resize = Body(windows, "private void Window_Resize()"); + int notify = resize.IndexOf("OnWindowSizeChanged(((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y);", StringComparison.Ordinal); + int rebuild = resize.IndexOf("RebuildFrameBuffers();", StringComparison.Ordinal); + Assert.True(notify >= 0 && rebuild > notify, "the window size has to reach the platform before the rebuild"); + + foreach (string target in new[] { "\"window_RenderFrame\", 1)", "\"Start\", 0)", "\"Window_Resize\", 0)" }) + { + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", " + target, patcher); + } + + string abstractPlatform = Read(AbstractSource); + Assert.Equal("{ }", Regex.Replace(Body(abstractPlatform, "public virtual void BeginFrame()"), @"\s+", " ").Trim()); + Assert.Equal("{ }", Regex.Replace(Body(abstractPlatform, "public virtual void OnWindowSizeChanged(int width, int height)"), @"\s+", " ").Trim()); + } + + [Fact] + public void TheInjectedPlatformStateAccessorsAreShippedAndSelfChecked() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + string injectedWindows = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformWindows\"] = new()", "},"); + string selfCheck = Block(Read(VulkanPlatformSource.MainFile), "internal static readonly string[] ExpectedWindowsMembers", "};"); + string windows = VulkanPlatformSource.ReadClientPlatformWindows(); + + foreach (Match match in Regex.Matches(selfCheck, "\"(\\w+)\",")) + { + string name = match.Groups[1].Value; + Assert.Contains("\"" + name + "\",", injectedWindows); + Assert.Matches(new Regex(@"public\s+[\w<>\[\]]+\s+" + name + @"\b"), windows); + } + } + + private static string StripComments(string source) => + Regex.Replace(source, @"//[^\n]*", string.Empty); + + private static string Block(string source, string header, string terminator) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf(terminator, start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } + + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + signature); + int open = source.IndexOf('{', start + signature.Length); + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced body: " + signature); + } + + private static string Read(string relativePath) => + System.IO.File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} diff --git a/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs b/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs index 23ea0b61..287a2bcf 100644 --- a/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs +++ b/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs @@ -134,31 +134,37 @@ public void TheAbstractPlatformDeclaresEveryOperationWithAnEmptyBody() } } + /// + /// Phase 1A step 4: ClientPlatformWindows overrides every operation with the GL lines only, + /// and VulkanClientPlatform overrides the same operation with the device call that used + /// to be the branch in front of them. + /// [Fact] - public void ClientPlatformWindowsOverridesEveryOperationWithTheDeviceBranchThenTheGlLines() + public void ClientPlatformWindowsOverridesEveryOperationWithTheGlLinesAndVulkanClientPlatformWithTheDeviceCall() { string platform = ReadLib(WindowsPath); + string vulkan = VulkanPlatformSource.Read(); foreach ((string name, string parameters, string device, string gl) in Members) { string signature = "public override void " + name + "(" + parameters + ")"; Assert.Single(Regex.Matches(platform, Regex.Escape(signature))); string body = Body(platform, signature); + Assert.True(body.Contains(gl, StringComparison.Ordinal), signature + " does not issue " + gl); + Assert.False(body.Contains("optimumDevice", StringComparison.Ordinal), signature + " still has a device branch"); - int branch = body.IndexOf("if (optimumDevice != null)", StringComparison.Ordinal); - int deviceCall = body.IndexOf(device, StringComparison.Ordinal); - int glCall = body.IndexOf(gl, StringComparison.Ordinal); - Assert.True(branch >= 0, signature + " has no device branch"); - Assert.True(deviceCall > branch, signature + " does not call " + device + " in its device branch"); - Assert.True(glCall > deviceCall, signature + " does not issue " + gl + " after the device branch"); + Assert.Single(Regex.Matches(vulkan, Regex.Escape(signature))); + string deviceCall = device.Replace("optimumDevice.", "device."); + Assert.True(Body(vulkan, signature).Contains(deviceCall, StringComparison.Ordinal), + "VulkanClientPlatform." + name + " does not call " + deviceCall); } // The whole-buffer update keeps glBufferData on GL. Assert.Contains("GL.BufferData((BufferTarget)35345, size, data, (BufferUsageHint)35048);", Body(platform, "public override void UpdateUBO(UBO ubo, IntPtr data, int offset, int size, bool reallocate)")); // A unit with no custom sampler has any override cleared on the device path. - Assert.Contains("optimumDevice.BindSampler(textureNumber, 0);", - Body(platform, "public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber)")); + Assert.Contains("device.BindSampler(textureNumber, 0);", + Body(vulkan, "public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber)")); } [Fact] diff --git a/Optimum.Tests/taa-entity-motion-coverage-tests.cs b/Optimum.Tests/taa-entity-motion-coverage-tests.cs index e88d8e4c..64f3a7c2 100644 --- a/Optimum.Tests/taa-entity-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-entity-motion-coverage-tests.cs @@ -158,9 +158,13 @@ public void TheUniformBufferRemembersItsBlockAndBindingPoint() Assert.Contains("GL.BindBufferBase((BufferRangeTarget)35345, ubo.BindingPoint, ubo.Handle);", platform); - // Both backends record it, or the GL path binds to point 0 regardless. - Assert.Equal(2, Count(platform, "BlockName = blockName;")); - Assert.Equal(2, Count(platform, "BindingPoint = bindingPoint;")); + // Both backends record it, or the GL path binds to point 0 regardless. Phase 1A + // step 4: the device CreateUBO is VulkanClientPlatform's override. + Assert.Equal(1, Count(platform, "BlockName = blockName;")); + Assert.Equal(1, Count(platform, "BindingPoint = bindingPoint;")); + string vulkan = VulkanPlatformSource.Read(); + Assert.Equal(1, Count(vulkan, "optimumUbo.BlockName = blockName;")); + Assert.Equal(1, Count(vulkan, "optimumUbo.BindingPoint = bindingPoint;")); } // ---------------------------------------------------- the per-draw history diff --git a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs index cce0be77..763969ed 100644 --- a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -144,8 +144,11 @@ public void ThePlatformOpensAMotionOnlyWindowOnBothBackends() Assert.Contains("public override bool BeginMotionOnlyWrite()", platform); Assert.Contains("public override void EndMotionOnlyWrite()", platform); - // Device path: the mask is the single motion bit, not the prefix mask. - Assert.Contains("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, 1 << MotionAttachmentIndex);", platform); + // Device path (VulkanClientPlatform since Phase 1A step 4): the mask is the single + // motion bit, not the prefix mask. + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("device.SetDrawBuffers(FrameBuffers[0].FboId, 1 << MotionAttachmentIndex);", + vulkan.Substring(vulkan.IndexOf("public override void EnableMotionOnlyDrawBuffers()", StringComparison.Ordinal))); // GL path: GL_NONE in every slot below the motion attachment, and a // cached array - the pass runs once a frame, but the P3 window's rule @@ -160,9 +163,7 @@ public void ThePlatformOpensAMotionOnlyWindowOnBothBackends() // The same guards as the P3 window, including the one that keeps the two // backends from disagreeing about which framebuffer the mask belongs to. int begin = platform.IndexOf("public override bool BeginMotionOnlyWrite()", StringComparison.Ordinal); - int drawBuffers = platform.IndexOf( - "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, 1 << MotionAttachmentIndex);", - begin, StringComparison.Ordinal); + int drawBuffers = platform.IndexOf("EnableMotionOnlyDrawBuffers();", begin, StringComparison.Ordinal); Assert.True(drawBuffers > begin); string guards = platform.Substring(begin, drawBuffers - begin); Assert.Contains("if (OptimumMotionWriteActive) return false;", guards); diff --git a/Optimum.Tests/taa-particle-motion-coverage-tests.cs b/Optimum.Tests/taa-particle-motion-coverage-tests.cs index 57a36a96..f0006412 100644 --- a/Optimum.Tests/taa-particle-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-particle-motion-coverage-tests.cs @@ -257,7 +257,8 @@ public void TheMergeOpensTheWindowAndPutsTheMotionAttachmentOnAdditiveBlending() // would overwrite the per-attachment factors; and the attachment is put // back on replace before the window closes, so no later pass inherits // the accumulating factors. - int mode = merge.IndexOf("SetBlend(true, EnumBlendMode.Standard);", StringComparison.Ordinal); + // Phase 1A step 4: the global mode is the ApplyTransparentMergeBlendState virtual. + int mode = merge.IndexOf("ApplyTransparentMergeBlendState();", StringComparison.Ordinal); int begin = merge.IndexOf("BeginMotionWrite();", StringComparison.Ordinal); int accumulate = merge.IndexOf("ApplyOptimumMotionAccumulateBlendState();", StringComparison.Ordinal); int draw = merge.IndexOf("RenderFullscreenTriangle(screenQuad);", StringComparison.Ordinal); @@ -268,12 +269,15 @@ public void TheMergeOpensTheWindowAndPutsTheMotionAttachmentOnAdditiveBlending() Assert.True(restore > draw && end > restore, "replace blending must be restored before the window closes"); // The blend state itself, on both backends: FUNC_ADD with (ONE, ONE). - string state = MethodBodyAfter(platform, "private void ApplyOptimumMotionAccumulateBlendState()"); + // Phase 1A step 4: a platform virtual - GL override here, device override in VulkanClientPlatform. + string state = MethodBodyAfter(platform, "public override void ApplyOptimumMotionAccumulateBlendState()"); Assert.Contains("if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return;", state); - Assert.Contains("optimumDevice.SetBlendEquation(MotionAttachmentIndex, 32774);", state); - Assert.Contains("optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1);", state); Assert.Contains("GL.BlendEquation(MotionAttachmentIndex, (BlendEquationMode)32774);", state); Assert.Contains("GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)1);", state); + string deviceState = MethodBodyAfter(VulkanPlatformSource.Read(), "public override void ApplyOptimumMotionAccumulateBlendState()"); + Assert.Contains("if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return;", deviceState); + Assert.Contains("device.SetBlendEquation(MotionAttachmentIndex, 32774);", deviceState); + Assert.Contains("device.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1);", deviceState); string? patch = TryFind("patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"); Assert.True(patch != null, "ClientPlatformWindows has no patch, so the change never ships"); diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index b0322e69..dfa65183 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -35,7 +35,9 @@ public void CecilPatcherShipsEveryTaaMethodAndMember() Assert.Contains("\"MotionAttachmentIndex\"", patcher); Assert.Contains("\"TaaHistory\"", patcher); Assert.Contains("\"DisableOptimumTaa\"", patcher); - Assert.Contains("\"CreateOptimumHistoryTarget\"", patcher); + // Phase 1A step 4: the device history target is a VulkanClientPlatform member (the + // renderer assembly ships as is); only the GL one is transplanted. + Assert.Contains("private FrameBufferRef CreateOptimumHistoryTarget(int width, int height)", VulkanPlatformSource.Read()); Assert.Contains("\"CreateOptimumHistoryTargetGl\"", patcher); // Both new vanilla-type member-injection dictionaries exist. @@ -62,9 +64,8 @@ public void MotionAttachmentIndexIsTwoWithoutSsaoAndFourWithIt() [Fact] public void DefaultDrawBufferMasksAreUnchangedByTaa() { - string platform = ReadPatchedOrSource( - "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", - "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + // Phase 1A step 4: the device framebuffer setup is VulkanClientPlatform's. + string platform = VulkanPlatformSource.Read(); // Primary's draw-buffer mask is still derived only from // primaryAttachments (2 or 4 colour targets), never including the new @@ -82,20 +83,22 @@ public void ClearFrameBufferClearsTheMotionAttachmentOnBothPaths() "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); - // Device path. - Assert.Contains("optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", platform); + // Device path (VulkanClientPlatform.ClearFrameBufferPass since Phase 1A step 4). + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", vulkan); // An excluded attachment is not cleared on either backend. Checking // only that ClearColor exists missed Vulkan's silent masked-out no-op. - int enable = platform.IndexOf("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", StringComparison.Ordinal); - int clear = platform.IndexOf("optimumDevice.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", StringComparison.Ordinal); - int restore = platform.IndexOf("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", clear, StringComparison.Ordinal); + int enable = vulkan.IndexOf("device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", StringComparison.Ordinal); + int clear = vulkan.IndexOf("device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", StringComparison.Ordinal); + int restore = vulkan.IndexOf("device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", clear, StringComparison.Ordinal); Assert.True(enable >= 0 && enable < clear && restore > clear); Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"ClearFrameBuffer\", 1", Read("Optimum.Patcher/Program.cs")); // GL path. Assert.Contains("GL.ClearBuffer((ClearBuffer)6144, MotionAttachmentIndex, new float[4]);", platform); // Both are guarded so a failed/absent motion attachment leaves the // clear untouched (MotionAttachmentIndex stays -1 via DisableOptimumTaa). - Assert.Equal(2, Count(platform, "if (MotionAttachmentIndex >= 0)")); + Assert.Equal(1, Count(platform, "if (MotionAttachmentIndex >= 0)")); + Assert.Equal(1, Count(vulkan, "if (MotionAttachmentIndex >= 0)")); } [Fact] @@ -283,8 +286,10 @@ public void DisposeFrameBuffersDeletesTheSharedDepthTextureOnlyOnce() // Transparent shares Primary's depth texture, so the same handle sits // in two FrameBufferRefs and a naive loop deletes it twice - a double - // free on the device path and a double count in VulkanStats. - Assert.Contains("transparent.DepthTextureId = primary.DepthTextureId;", platform); + // free on the device path and a double count in VulkanStats. Phase 1A + // step 4: the device setup and disposal are VulkanClientPlatform overrides. + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("transparent.DepthTextureId = primary.DepthTextureId;", vulkan); // Virtual since platform substitution (VulkanClientPlatform overrides it). int dispose = platform.IndexOf("public virtual void DisposeFrameBuffers(", StringComparison.Ordinal); @@ -292,15 +297,23 @@ public void DisposeFrameBuffersDeletesTheSharedDepthTextureOnlyOnce() int end = platform.IndexOf("public override void ClearFrameBuffer(", dispose, StringComparison.Ordinal); string body = end > dispose ? platform.Substring(dispose, end - dispose) : platform.Substring(dispose); - Assert.Contains("HashSet deletedTextures = new HashSet();", body); + int deviceDispose = vulkan.IndexOf("public override void DisposeFrameBuffers(", StringComparison.Ordinal); + Assert.True(deviceDispose >= 0); + int deviceEnd = vulkan.IndexOf("public override void LoadFrameBuffer(", deviceDispose, StringComparison.Ordinal); + Assert.True(deviceEnd > deviceDispose); + string deviceBody = vulkan.Substring(deviceDispose, deviceEnd - deviceDispose); + // Device path and GL path both gate every texture delete on the set. - Assert.Contains("if (deletedTextures.Add(buffers[k].DepthTextureId))", body); + Assert.Contains("HashSet deletedTextures = new HashSet();", body); + Assert.Contains("HashSet deletedTextures = new HashSet();", deviceBody); + Assert.Contains("if (deletedTextures.Add(buffers[k].DepthTextureId))", deviceBody); Assert.Contains("if (deletedTextures.Add(buffers[i].DepthTextureId))", body); - Assert.Contains("if (deletedTextures.Add(buffers[k].ColorTextureIds[n]))", body); + Assert.Contains("if (deletedTextures.Add(buffers[k].ColorTextureIds[n]))", deviceBody); Assert.Contains("if (deletedTextures.Add(buffers[i].ColorTextureIds[j]))", body); // No unguarded delete is left behind on either path. - Assert.Equal(4, Count(body, "deletedTextures.Add(")); - Assert.Equal(1, Count(body, "optimumDevice.DeleteTexture(buffers[k].DepthTextureId);")); + Assert.Equal(2, Count(body, "deletedTextures.Add(")); + Assert.Equal(2, Count(deviceBody, "deletedTextures.Add(")); + Assert.Equal(1, Count(deviceBody, "device.DeleteTexture(buffers[k].DepthTextureId);")); Assert.Equal(1, Count(body, "GL.DeleteTexture(buffers[i].DepthTextureId);")); } @@ -334,9 +347,13 @@ public void BothFrameBufferSetupPathsHonourTheRuntimeTaaDisable() "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); - // The device path and the GL path, both guarded. - Assert.Equal(2, Count(platform, + // The device path and the GL path, both guarded. Phase 1A step 4: the device setup + // (VulkanClientPlatform) reads the same guard through OptimumTaaRequested. + Assert.Equal(1, Count(platform, "bool taaRequested = !optimumTaaDisabled && Vintagestory.API.Config.OptimumConfig.EffectiveTaa;")); + Assert.Equal(1, Count(platform, + "public bool OptimumTaaRequested => !optimumTaaDisabled && Vintagestory.API.Config.OptimumConfig.EffectiveTaa;")); + Assert.Contains("bool taaRequested = OptimumTaaRequested;", VulkanPlatformSource.Read()); Assert.DoesNotContain( "bool taaRequested = Vintagestory.API.Config.OptimumConfig.EffectiveTaa;", platform); diff --git a/Optimum.Tests/taa-sharpen-coverage-tests.cs b/Optimum.Tests/taa-sharpen-coverage-tests.cs index 9c467a26..d520a04f 100644 --- a/Optimum.Tests/taa-sharpen-coverage-tests.cs +++ b/Optimum.Tests/taa-sharpen-coverage-tests.cs @@ -94,17 +94,19 @@ public void SharpenTargetIsCreatedWithTheHistoryTargetsOnBothPaths() "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); Assert.Contains("private const int OptimumTaaSharpenIndex = 21;", platform); - // Device path: RGBA16F, render resolution. + // Device path (VulkanClientPlatform since Phase 1A step 4): RGBA16F, render resolution. + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("private const int OptimumTaaSharpenIndex = 21;", vulkan); Assert.Contains( - "list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(device, width, height,", - platform); - Assert.Contains("EnumTextureInternalFormat.Rgba16f);", platform); + "list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(width, height,", + vulkan); + Assert.Contains("EnumTextureInternalFormat.Rgba16f);", vulkan); // GL path: the same format token (GL_RGBA16F) through setupAttachment. Assert.Contains("setupAttachment(optimumSharpen, num, num2, 0, val, (PixelInternalFormat)34842);", platform); // Both live inside the taaRequested block, i.e. they are allocated and // released with the history slots (DisposeFrameBuffers walks the list). - int historyDevice = platform.IndexOf("list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTarget(", StringComparison.Ordinal); - int sharpenDevice = platform.IndexOf("list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(", StringComparison.Ordinal); + int historyDevice = vulkan.IndexOf("list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTarget(", StringComparison.Ordinal); + int sharpenDevice = vulkan.IndexOf("list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(", StringComparison.Ordinal); Assert.True(historyDevice >= 0 && sharpenDevice > historyDevice); int historyGl = platform.IndexOf("list[OptimumTaaHistoryIndexA] = CreateOptimumHistoryTargetGl(", StringComparison.Ordinal); int sharpenGl = platform.IndexOf("FrameBufferRef optimumSharpen = (list[OptimumTaaSharpenIndex]", StringComparison.Ordinal); @@ -119,9 +121,12 @@ public void ASharpenTargetFailureCostsTheSharpeningNotTaa() "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); // Neither failure path may call DisableOptimumTaa - TAA without the - // sharpen pass is a working configuration. - Assert.Equal(2, Count(platform, "Optimum disabled the TAA sharpen pass")); - Assert.Equal(2, Count(platform, "list[OptimumTaaSharpenIndex] = null;")); + // sharpen pass is a working configuration. GL here, device in VulkanClientPlatform. + string vulkan = VulkanPlatformSource.Read(); + Assert.Equal(1, Count(platform, "Optimum disabled the TAA sharpen pass")); + Assert.Equal(1, Count(platform, "list[OptimumTaaSharpenIndex] = null;")); + Assert.Equal(1, Count(vulkan, "Optimum disabled the TAA sharpen pass")); + Assert.Equal(1, Count(vulkan, "list[OptimumTaaSharpenIndex] = null;")); } // --- the pass ----------------------------------------------------------- diff --git a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs index 88d7bdf6..b0a0d4f1 100644 --- a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -221,14 +221,18 @@ public void ThePlatformOpensAndClosesTheMotionDrawBufferOnBothBackends() Assert.Contains("if (!Vintagestory.API.Config.OptimumConfig.EffectiveTaa) return false;", platform); Assert.Contains("if (OptimumMotionWriteActive) return false;", platform); - // Device path: mask including the motion attachment, then back to the - // default set (whose size is the attachment's own index). + // Device path (VulkanClientPlatform since Phase 1A step 4): mask including the + // motion attachment, then back to the default set (whose size is the attachment's + // own index). + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("EnableMotionDrawBuffers();", platform); + Assert.Contains("RestorePrimaryDrawBuffers();", platform); Assert.Contains( - "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", - platform); + "device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", + vulkan); Assert.Contains( - "optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", - platform); + "device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", + vulkan); // GL path: the same two sets, as DrawBuffers arrays - built once and // kept, not allocated per window. The narrow windows open per draw (every @@ -247,7 +251,7 @@ public void ThePlatformOpensAndClosesTheMotionDrawBufferOnBothBackends() // And nothing inside the window allocates. int begin = platform.IndexOf("public override bool BeginMotionWrite()", StringComparison.Ordinal); - int end = platform.IndexOf("private void ApplyOptimumMotionBlendState()", begin, StringComparison.Ordinal); + int end = platform.IndexOf("public override void ApplyOptimumMotionBlendState()", begin, StringComparison.Ordinal); Assert.True(begin >= 0 && end > begin); string window = platform.Substring(begin, end - begin); Assert.Equal(2, Count(window, "new DrawBuffersEnum[")); @@ -276,7 +280,7 @@ public void TheMotionWindowOnlyOpensWhilePrimaryIsTheBoundTarget() int begin = platform.IndexOf("public override bool BeginMotionWrite()", StringComparison.Ordinal); Assert.True(begin >= 0); - int drawBuffers = platform.IndexOf("optimumDevice.SetDrawBuffers(frameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", begin, StringComparison.Ordinal); + int drawBuffers = platform.IndexOf("EnableMotionDrawBuffers();", begin, StringComparison.Ordinal); Assert.True(drawBuffers > begin); string guards = platform.Substring(begin, drawBuffers - begin); @@ -296,15 +300,26 @@ public void TheMotionAttachmentNeverBlends() "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); - Assert.Contains("private void ApplyOptimumMotionBlendState()", platform); - Assert.Contains("optimumDevice.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0);", platform); + // Phase 1A step 4: a platform virtual - GL override here, device override in + // VulkanClientPlatform, whose GlToggleBlend re-applies it the same way. + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("public override void ApplyOptimumMotionBlendState()", platform); + Assert.Contains("device.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0);", vulkan); Assert.Contains("GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0);", platform); + int deviceToggle = vulkan.IndexOf("public override void GlToggleBlend(bool on, EnumBlendMode blendMode", StringComparison.Ordinal); + Assert.True(deviceToggle >= 0); + Assert.Contains("ApplyOptimumMotionBlendState();", vulkan.Substring(deviceToggle, vulkan.IndexOf("public override void GlDisableCullFace()", deviceToggle, StringComparison.Ordinal) - deviceToggle)); // GlToggleBlend re-applies it, including on the early-returning blend - // modes, because glBlendFunc resets every attachment's function. - int toggle = platform.IndexOf("public override void GlToggleBlend(bool on, EnumBlendMode blendMode", StringComparison.Ordinal); + // modes, because glBlendFunc resets every attachment's function. Read from the + // full source: with the device branch gone the patch hunks no longer carry the + // signature line. + string source = VulkanPlatformSource.ReadClientPlatformWindows(); + int toggle = source.IndexOf("public override void GlToggleBlend(bool on, EnumBlendMode blendMode", StringComparison.Ordinal); Assert.True(toggle >= 0); - string body = platform.Substring(toggle); + int toggleEnd = source.IndexOf("public override void GlDisableCullFace()", toggle, StringComparison.Ordinal); + Assert.True(toggleEnd > toggle); + string body = source.Substring(toggle, toggleEnd - toggle); Assert.True(Count(body, "ApplyOptimumMotionBlendState();") >= 6, "every blend-mode branch has to re-apply the motion attachment's replace blending"); Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"GlToggleBlend\", 2", Read("Optimum.Patcher/Program.cs")); diff --git a/Optimum.Tests/temporal-contract-tests.cs b/Optimum.Tests/temporal-contract-tests.cs index fd9b5d4a..062cb518 100644 --- a/Optimum.Tests/temporal-contract-tests.cs +++ b/Optimum.Tests/temporal-contract-tests.cs @@ -441,11 +441,14 @@ public void TheHistoryAndSharpenSlotIndicesAreFrozen() public void TheAttachmentFormatsAreFrozenOnBothBackends() { string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + // Vulkan-native plan, Phase 1A step 4: the device-path framebuffer setup moved, text + // unchanged apart from the device field, into VulkanClientPlatform. Same formats. + string device = VulkanPlatformSource.Read(); // Motion attachment: RGBA16F on Primary, appended after every existing // attachment, on both paths. - Assert.True(platform.Contains("int motionTextureId = device.CreateTexture2D(width, height,", StringComparison.Ordinal) - && platform.Contains("EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);", StringComparison.Ordinal), + Assert.True(device.Contains("int motionTextureId = device.CreateTexture2D(width, height,", StringComparison.Ordinal) + && device.Contains("EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);", StringComparison.Ordinal), $"The device path no longer creates the motion attachment as RGBA16F; {Doc} section 3.1 freezes the format."); // 34842 = GL_RGBA16F, the GL path's raw token for the same thing. Assert.True(platform.Contains("GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero);", StringComparison.Ordinal), @@ -456,22 +459,23 @@ public void TheAttachmentFormatsAreFrozenOnBothBackends() // Primary depth: DepthComponent32 on the device path, 33191 = GL_DEPTH_COMPONENT32 on GL, // NEAREST + CLAMP_TO_EDGE on both. - Assert.True(platform.Contains("EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false);", StringComparison.Ordinal), + Assert.True(device.Contains("EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false);", StringComparison.Ordinal), $"Primary's depth format changed; {Doc} section 3.1 freezes it at 32-bit, 0 = near."); - Assert.True(platform.Contains("SetupOptimumTextureSampler(device, primary.DepthTextureId, 9728, 33071);", StringComparison.Ordinal), + Assert.True(device.Contains("SetupOptimumTextureSampler(primary.DepthTextureId, 9728, 33071);", StringComparison.Ordinal), $"Primary's depth sampler changed; {Doc} section 3.1 freezes NEAREST + CLAMP_TO_EDGE."); Assert.True(platform.Contains("GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33191, num, num2, 0, (PixelFormat)6402, (PixelType)5126, (IntPtr)IntPtr.Zero);", StringComparison.Ordinal), $"The GL path's Primary depth format changed; {Doc} section 3.1 freezes GL_DEPTH_COMPONENT32."); // History slot: RGBA16F colour (LINEAR), RGBA8 aux (LINEAR), R32F linear // depth (NEAREST), CLAMP_TO_EDGE throughout - device path. - Assert.True(platform.Contains("private const int OptimumGlR32f = 0x822E;", StringComparison.Ordinal), + Assert.True(platform.Contains("private const int OptimumGlR32f = 0x822E;", StringComparison.Ordinal) + && device.Contains("private const int OptimumGlR32f = 0x822E;", StringComparison.Ordinal), $"The R32F token used for the linear depth history moved; {Doc} section 3.3 freezes the format."); - Assert.True(platform.Contains("target.ColorTextureIds[2] = device.CreateTexture2DRaw(width, height, OptimumGlR32f, IntPtr.Zero, 4);", StringComparison.Ordinal), + Assert.True(device.Contains("target.ColorTextureIds[2] = device.CreateTexture2DRaw(width, height, OptimumGlR32f, IntPtr.Zero, 4);", StringComparison.Ordinal), $"The device path's linear depth history is no longer R32F; {Doc} section 3.3 freezes it."); - Assert.True(platform.Contains("SetupOptimumTextureSampler(device, target.ColorTextureIds[0], 9729, 33071);", StringComparison.Ordinal) - && platform.Contains("SetupOptimumTextureSampler(device, target.ColorTextureIds[1], 9729, 33071);", StringComparison.Ordinal) - && platform.Contains("SetupOptimumTextureSampler(device, target.ColorTextureIds[2], 9728, 33071);", StringComparison.Ordinal), + Assert.True(device.Contains("SetupOptimumTextureSampler(target.ColorTextureIds[0], 9729, 33071);", StringComparison.Ordinal) + && device.Contains("SetupOptimumTextureSampler(target.ColorTextureIds[1], 9729, 33071);", StringComparison.Ordinal) + && device.Contains("SetupOptimumTextureSampler(target.ColorTextureIds[2], 9728, 33071);", StringComparison.Ordinal), $"The history slot's sampler state changed on the device path; {Doc} section 3.3 freezes " + "LINEAR colour, LINEAR glow, NEAREST linear depth, all CLAMP_TO_EDGE."); @@ -485,8 +489,8 @@ public void TheAttachmentFormatsAreFrozenOnBothBackends() $"The GL linear depth history is no longer R32F; {Doc} section 3.3 freezes it."); // Sharpen target: RGBA16F at render resolution, both paths. - Assert.True(platform.Contains("list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(device, width, height,", StringComparison.Ordinal) - && platform.Contains("EnumTextureInternalFormat.Rgba16f);", StringComparison.Ordinal), + Assert.True(device.Contains("list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(width, height,", StringComparison.Ordinal) + && device.Contains("EnumTextureInternalFormat.Rgba16f);", StringComparison.Ordinal), $"The sharpen target is no longer RGBA16F on the device path; {Doc} section 3.4 freezes it."); Assert.True(platform.Contains("setupAttachment(optimumSharpen, num, num2, 0, val, (PixelInternalFormat)34842);", StringComparison.Ordinal), $"The sharpen target is no longer RGBA16F on the GL path; {Doc} section 3.4 freezes it."); diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 133e8168..ec4551f3 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -155,13 +155,13 @@ public void ClientProgramRemainsCecilOwned() "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"; /// - /// The guarantee the whole design rests on: with no device installed, the - /// client runs the vanilla GL body. Each branch is added in front of - /// the original code rather than replacing it, so an OpenGL session costs one - /// null check and behaves exactly as it always did. + /// The guarantee the whole design rests on: an OpenGL session runs the vanilla GL + /// body. Since Phase 1A step 4 the device call is not a branch in front of that body + /// any more but VulkanClientPlatform's override of the same method, so the GL body is + /// untouched vanilla and an OpenGL session pays nothing at all. /// - /// Checked by confirming the vanilla GL call is still present alongside the - /// device call for a representative spread of the routed methods. + /// Checked for a representative spread of the routed methods: the vanilla GL call is + /// in ClientPlatformWindows, the device call in VulkanClientPlatform. /// [Theory] [InlineData("SetViewport", "GL.Viewport(x, y, width, height);")] @@ -173,23 +173,21 @@ public void ClientProgramRemainsCecilOwned() [InlineData("DeleteTexture", "GL.DeleteTexture(id);")] public void RoutedMethodsKeepTheirVanillaOpenGlBody(string deviceCall, string vanillaCall) { - string patch = Read(PlatformPatch); - - Assert.Contains("optimumDevice." + deviceCall, patch); - // The vanilla line survives, either as untouched context or as an added - // line where the branch was inserted above it. - Assert.Contains(vanillaCall, patch); + Assert.Contains("device." + deviceCall + "(", VulkanPlatformSource.Read()); + Assert.Contains(vanillaCall, VulkanPlatformSource.ReadClientPlatformWindows()); } /// - /// A branch that is not registered as a transplant target compiles into the - /// donor and then ships nothing, because Optimum patches the vanilla - /// assembly rather than replacing it. That failure is silent. + /// Phase 1A step 4: the fixed-function methods VulkanClientPlatform overrides have + /// vanilla bodies again, so they are no longer transplant targets - only GlToggleBlend + /// (TAA's motion-attachment blend override) still is. Each must be overridden, or a + /// Vulkan session would reach a GL call with no context. /// [Fact] - public void EveryRoutedPlatformMethodIsRegisteredAsATransplantTarget() + public void EveryRoutedPlatformMethodIsOverriddenByTheVulkanPlatform() { string patcher = Read("Optimum.Patcher/Program.cs"); + string vulkan = VulkanPlatformSource.Read(); string[] routed = { @@ -206,31 +204,40 @@ public void EveryRoutedPlatformMethodIsRegisteredAsATransplantTarget() foreach (string method in routed) { Assert.True( - patcher.Contains($"\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"{method}\"", - StringComparison.Ordinal), - $"{method} is routed to the device but is not a Cecil transplant target"); + System.Text.RegularExpressions.Regex.IsMatch(vulkan, @"public override \w+ " + method + @"\("), + $"{method} is not overridden by VulkanClientPlatform"); + bool target = patcher.Contains($"new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"{method}\"", StringComparison.Ordinal); + Assert.True(target == (method == "GlToggleBlend") || method == "GetGraphicsCardRenderer", + $"{method}: only GlToggleBlend keeps a non-vanilla body and a transplant target"); } + // GetGraphicsCardRenderer is virtualized in place, not transplanted. + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"GlToggleBlend\", 2)", patcher); } /// - /// The frame is bracketed by the device, and the OpenGL path still reaches - /// SwapBuffers. Losing either end would either never present or present - /// twice. + /// The frame is bracketed by the platform, and the OpenGL path still reaches + /// SwapBuffers. Losing either end would either never present or present twice. /// [Fact] public void TheDeviceBracketsTheFrameAndOpenGlStillSwaps() { - string added = AddedLines(Read(PlatformPatch)); - - Assert.Contains("optimumDevice.BeginFrame();", added); - Assert.Contains("optimumDevice.Present();", added); - - int begin = added.IndexOf("optimumDevice.BeginFrame();", StringComparison.Ordinal); - int present = added.IndexOf("optimumDevice.Present();", StringComparison.Ordinal); - Assert.True(begin < present, "the frame must be opened before it is presented"); - - // The vanilla swap survives for the OpenGL path. - Assert.Contains("SwapBuffers();", Read(PlatformPatch)); + string platform = VulkanPlatformSource.ReadClientPlatformWindows(); + int frame = platform.IndexOf("private void window_RenderFrame(FrameEventArgs e)", StringComparison.Ordinal); + Assert.True(frame >= 0); + int begin = platform.IndexOf("BeginFrame();", frame, StringComparison.Ordinal); + int onNewFrame = platform.IndexOf("frameHandler.OnNewFrame(dt);", frame, StringComparison.Ordinal); + int end = platform.IndexOf("EndFrame();", frame, StringComparison.Ordinal); + Assert.True(begin > frame && onNewFrame > begin && end > onNewFrame, + "the frame must be opened before the frame handler runs and ended after it"); + + // The vanilla swap survives for the OpenGL path, as the EndFrame override. + int swapOverride = platform.IndexOf("public override void EndFrame()", StringComparison.Ordinal); + Assert.True(swapOverride >= 0); + Assert.Contains("((GameWindow)window).SwapBuffers();", platform.Substring(swapOverride, 200)); + + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("device.BeginFrame();", vulkan); + Assert.Contains("device.Present();", vulkan); } /// @@ -269,11 +276,12 @@ public void UniformSettersUseTheLocationTheDeviceHandedOut() Assert.Contains("ScreenManager.Platform.SetUniformArray1(ProgramId, uniformLocations[uniformName]", added); Assert.Contains("ScreenManager.Platform.SetUniformMatrix(ProgramId, uniformLocations[uniformName]", added); - string platform = AddedLines(Read(PlatformPatch)); - Assert.Contains("optimumDevice.SetUniform(programId, location, value)", platform); - Assert.Contains("optimumDevice.SetUniformArray1(programId, location, count, values)", platform); - Assert.Contains("optimumDevice.SetUniformMatrix(programId, location, matrix)", platform); - Assert.Contains("optimumDevice.GetUniformLocation(program.ProgramId, name)", platform); + // Phase 1A step 4: the device calls are VulkanClientPlatform's overrides. + string platform = VulkanPlatformSource.Read(); + Assert.Contains("device.SetUniform(programId, location, value)", platform); + Assert.Contains("device.SetUniformArray1(programId, location, count, values)", platform); + Assert.Contains("device.SetUniformMatrix(programId, location, matrix)", platform); + Assert.Contains("device.GetUniformLocation(program.ProgramId, name)", platform); } /// @@ -302,12 +310,12 @@ public void IntegerVectorUniformsKeepTheirIntegerRepresentation() [Fact] public void TextureBindingAimsTheSamplerAndClearsAnyStaleOverride() { - // Phase 1A step 3: the body lives in ClientPlatformWindows.BindProgramTexture2D. - string added = AddedLines(Read(PlatformPatch)); + // Phase 1A step 4: the device body is VulkanClientPlatform.BindProgramTexture2D. + string added = VulkanPlatformSource.Read(); - Assert.Contains("optimumDevice.SetSamplerUnit(program.ProgramId, samplerName, textureNumber)", added); - Assert.Contains("optimumDevice.BindTexture(textureNumber, textureId)", added); - Assert.Contains("optimumDevice.BindSampler(textureNumber, 0)", added); + Assert.Contains("device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber)", added); + Assert.Contains("device.BindTexture(textureNumber, textureId)", added); + Assert.Contains("device.BindSampler(textureNumber, 0)", added); } /// @@ -319,10 +327,11 @@ public void TextureBindingAimsTheSamplerAndClearsAnyStaleOverride() [Fact] public void ShaderStagesAreStagedAtCompileAndTranslatedAtLink() { - string added = AddedLines(Read(PlatformPatch)); + // Phase 1A step 4: VulkanClientPlatform.CompileShader / CreateShaderProgram. + string added = VulkanPlatformSource.Read(); - Assert.Contains("optimumDevice.CompileShader(shader)", added); - Assert.Contains("optimumDevice.LinkProgram(program)", added); + Assert.Contains("device.CompileShader(shader)", added); + Assert.Contains("device.LinkProgram(program)", added); Assert.Contains("program.ProgramId = optimumProgramId;", added); // A link failure is reported the same way the GL path reports one. Assert.Contains("Link error in shader program for pass", added); @@ -363,7 +372,8 @@ public void ShaderProgramBaseIsDeclaredCecilOwned() [InlineData("list[13]", "SSAO")] public void TheDevicePathPopulatesEveryFramebufferSlot(string slot, string name) { - string added = AddedLines(Read(PlatformPatch)); + // Phase 1A step 4: VulkanClientPlatform.SetupDefaultFrameBuffers. + string added = VulkanPlatformSource.Read(); Assert.True(added.Contains(slot + " =", StringComparison.Ordinal), $"the device framebuffer setup never assigns {slot} ({name})"); } @@ -376,7 +386,7 @@ public void TheDevicePathPopulatesEveryFramebufferSlot(string slot, string name) [Fact] public void TheTransparentTargetSharesPrimaryDepth() { - string added = AddedLines(Read(PlatformPatch)); + string added = VulkanPlatformSource.Read(); Assert.Contains("transparent.DepthTextureId = primary.DepthTextureId;", added); Assert.Contains( @@ -392,9 +402,10 @@ public void TheTransparentTargetSharesPrimaryDepth() [Fact] public void SsaoWidensPrimaryToFourAttachments() { - string added = AddedLines(Read(PlatformPatch)); + string added = VulkanPlatformSource.Read(); - Assert.Contains("int primaryAttachments = (SetupSSAO ? 4 : 2);", added); + Assert.Contains("bool setupSsao = ClientSettings.SSAOQuality > 0;", added); + Assert.Contains("int primaryAttachments = (setupSsao ? 4 : 2);", added); Assert.Contains("device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", added); } @@ -406,7 +417,7 @@ public void SsaoWidensPrimaryToFourAttachments() [Fact] public void SsaoNoiseAndKernelKeepTheirSeedAndOrder() { - string added = AddedLines(Read(PlatformPatch)); + string added = VulkanPlatformSource.Read(); Assert.Contains("new Random(5)", added); @@ -417,18 +428,31 @@ public void SsaoNoiseAndKernelKeepTheirSeedAndOrder() } /// - /// The device path's helpers are injected members, not just donor code. An - /// unregistered one compiles and then is missing at runtime. + /// Phase 1A step 4: the device path's framebuffer helpers are private members of + /// VulkanClientPlatform, which ships in the renderer assembly as is - so they are not + /// patcher entries any more. The platform state they need is reached through members + /// injected into ClientPlatformWindows, and those have to be registered, or they + /// compile into the donor and are missing at runtime. /// [Fact] - public void TheFramebufferHelpersAreInjectedMembers() + public void TheFramebufferHelpersLiveInTheVulkanPlatformAndTheirStateAccessorsAreInjected() { string patcher = Read("Optimum.Patcher/Program.cs"); + string vulkan = VulkanPlatformSource.Read(); + + foreach (string helper in new[] { "SetupOptimumFrameBuffers", "CreateOptimumColorTarget", "SetupOptimumTextureSampler", "CreateOptimumDepthTarget", "CreateOptimumFramebuffer" }) + { + Assert.DoesNotContain("\"" + helper + "\"", patcher); + } + Assert.Contains("private void SetupOptimumTextureSampler(int textureId, int filter, int wrap)", vulkan); + Assert.Contains("private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextureInternalFormat format)", vulkan); + Assert.Contains("private FrameBufferRef CreateOptimumDepthTarget(int width, int height)", vulkan); - Assert.Contains("\"SetupOptimumFrameBuffers\"", patcher); - Assert.Contains("\"CreateOptimumColorTarget\"", patcher); - Assert.Contains("\"SetupOptimumTextureSampler\"", patcher); - Assert.Contains("\"CreateOptimumDepthTarget\"", patcher); + foreach (string accessor in new[] { "OptimumAdoptFrameBufferSettings", "OptimumTaaRequested", "OptimumSsaoKernel", "SetOptimumMotionAttachmentIndex", "OptimumAdoptTaaTargets", "OptimumFinishDeviceFrameBufferSetup", "OptimumRenderSsao" }) + { + Assert.Contains("\"" + accessor + "\",", patcher); + Assert.Contains("\"" + accessor + "\",", vulkan); + } } private static string AddedLines(string patch) => diff --git a/Optimum.Tests/vulkan-platform-source.cs b/Optimum.Tests/vulkan-platform-source.cs new file mode 100644 index 00000000..e7370dda --- /dev/null +++ b/Optimum.Tests/vulkan-platform-source.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using System.Text; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 4: every device call that used to sit in a +/// ClientPlatformWindows branch lives in VulkanClientPlatform, split over +/// Optimum.Render.Vulkan/Platform/VulkanClientPlatform*.cs. Source-coverage tests that +/// pin a device-side line read it here; the GL side stays in ClientPlatformWindows.cs. +/// +internal static class VulkanPlatformSource +{ + public const string MainFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"; + + public const string ClientPlatformWindowsSource = "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"; + + /// All VulkanClientPlatform partial files, in file-name order, concatenated. + public static string Read() + { + string main = PatchReader.FindRepositoryFile(MainFile); + string directory = Path.GetDirectoryName(main)!; + string[] files = Directory.GetFiles(directory, "VulkanClientPlatform*.cs"); + Array.Sort(files, StringComparer.Ordinal); + var text = new StringBuilder(); + foreach (string file in files) + { + text.Append(File.ReadAllText(file)).Append('\n'); + } + return text.ToString(); + } + + /// The donor ClientPlatformWindows source (the full file, not patch hunks). + public static string ReadClientPlatformWindows() => + File.ReadAllText(PatchReader.FindRepositoryFile(ClientPlatformWindowsSource)); +} From a41e6cc6f9b9654b97117f61790867cb5af75e38 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:22:53 +0200 Subject: [PATCH 097/226] wip(phase1b-step5): allocator pool classes, dedicated requirements, memory budget, ReBAR cap, empty-block hysteresis (compiles; tests not yet run) --- .../AllocatorPolicyTests.cs | 446 +++++++++++++ .../PacingStatsTests.cs | 16 +- Optimum.Render.Vulkan/Core/FrameRing.cs | 9 +- Optimum.Render.Vulkan/Core/MeshManager.cs | 39 +- Optimum.Render.Vulkan/Core/TextureManager.cs | 4 +- Optimum.Render.Vulkan/Core/VulkanAllocator.cs | 611 ++++++++++++++++-- Optimum.Render.Vulkan/Core/VulkanContext.cs | 14 + Optimum.Render.Vulkan/Core/VulkanResources.cs | 16 +- Optimum.Render.Vulkan/Core/VulkanStats.cs | 12 +- .../Transfer/ReadbackManager.cs | 2 +- .../Transfer/UploadManager.cs | 4 +- Optimum.Render.Vulkan/VulkanDevice.cs | 20 +- docs/taa-acceptance.md | 19 +- 13 files changed, 1120 insertions(+), 92 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs diff --git a/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs b/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs new file mode 100644 index 00000000..1ba5397c --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs @@ -0,0 +1,446 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 1B step 5: pool classes and budget. A static mesh lands in device-local +/// memory that is not host visible (when the device has such a type) and never in +/// ReBAR; the driver's dedicated requirement is honoured; the ReBAR class is capped +/// and a miss is a counted, logged fall-through that still renders; empty blocks +/// survive 120 frames (or go at once under budget pressure); the heap report +/// carries used/budget per heap. +/// +public class AllocatorPolicyTests +{ + private const ulong MiB = 1024UL * 1024; + + private readonly ITestOutputHelper _output; + + public AllocatorPolicyTests(ITestOutputHelper output) => _output = output; + + private static int CreateTarget(IOptimumGraphicsDevice seam, int size) + { + int texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + return framebuffer; + } + + private static unsafe byte[] Read(IOptimumGraphicsDevice seam, int framebuffer, int size) + { + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + return pixels; + } + + /// A quad from x = -1 to , full height. + private static MeshData Quad(float right) => new(4, 6) + { + xyz = new[] { -1f, -1f, 0f, right, -1f, 0f, right, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + + private static void AssertLeftHalf(byte[] pixels, int size, string what) + { + for (int y = 0; y < size; y++) + { + for (int x = 0; x < size; x++) + { + int at = (y * size + x) * 4; + byte expected = x < size / 2 ? (byte)255 : (byte)0; + Assert.True(pixels[at] == expected && pixels[at + 3] == 255, + what + ": pixel " + x + "," + y + " is " + pixels[at] + ", expected " + expected); + } + } + } + + private const string SolidVertex = """ + #version 330 core + layout(location = 0) in vec3 position; + void main() { gl_Position = vec4(position, 1); } + """; + + private const string SolidFragment = """ + #version 330 core + out vec4 color; + void main() { color = vec4(1); } + """; + + private static void PrepareDraw(IOptimumGraphicsDevice seam, int target, int program, int size) + { + seam.BindFramebuffer(target); + seam.UseProgram(program); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + } + + /// + /// The named defect: static meshes sat in ReBAR. Through the device (upload + /// manager present) every buffer of a static mesh is DeviceBuffers-class, + /// device-local, in a type that is not host visible when the device has one, + /// and the ReBAR class does not grow with them. Drawn over several frames with + /// Present between them and read back after, the geometry is right. + /// + [SkippableFact] + public void AStaticMeshLandsInDeviceLocalMemoryOffReBarAndStillDraws() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 8; + VulkanContext context = device!.ContextForTests; + VulkanAllocator allocator = context.Allocator; + + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, SolidVertex, SolidFragment, "policy-static"); + int target = CreateTarget(seam, size); + + seam.BeginFrame(); + seam.Present(); + ulong reBarBefore = allocator.ReBarUsed; + + var meshes = new List(); + for (int i = 0; i < 16; i++) meshes.Add(seam.CreateMesh(Quad(0f), true)); + int dynamicMesh = seam.CreateMesh(Quad(0f), false); + + Assert.Equal(reBarBefore, allocator.ReBarUsed); + + foreach (int mesh in meshes) + { + foreach (int slot in new[] { MeshManager.BufferXyz, -1 }) + { + VulkanBuffer buffer = device.MeshesForTests.BufferOf(mesh, slot)!; + MemoryBlock block = buffer.Allocation.Block!; + MemoryPropertyFlags flags = allocator.FlagsOf(block.TypeIndex); + MemoryRequirements requirements = VulkanAllocator.BufferRequirements(context, buffer.Handle, out _); + + Assert.Equal(MemoryPoolClass.DeviceBuffers, block.Class); + Assert.True((flags & MemoryPropertyFlags.DeviceLocalBit) != 0, "static mesh memory is not device local: " + flags); + if (allocator.HasMemoryType(requirements.MemoryTypeBits, MemoryPropertyFlags.DeviceLocalBit, + MemoryPropertyFlags.HostVisibleBit)) + { + Assert.True((flags & MemoryPropertyFlags.HostVisibleBit) == 0, + "a static mesh took host-visible memory although a device-local-only type exists: " + flags); + Assert.Equal(IntPtr.Zero, buffer.Mapped); + } + } + } + + // Dynamic meshes stay host mapped (the chunk tesselator writes through + // the pointer) and are not ReBAR either. + VulkanBuffer dynamicXyz = device.MeshesForTests.BufferOf(dynamicMesh, MeshManager.BufferXyz)!; + Assert.NotEqual(IntPtr.Zero, dynamicXyz.Mapped); + Assert.Equal(MemoryPoolClass.DeviceBuffers, dynamicXyz.Allocation.Block!.Class); + + // Several frames, Present between them, then read back. + for (int frame = 0; frame < 4; frame++) + { + seam.BeginFrame(); + PrepareDraw(seam, target, program, size); + seam.DrawMesh(meshes[frame]); + seam.Present(); + } + + seam.BeginFrame(); + AssertLeftHalf(Read(seam, target, size), size, "static device-local quad"); + seam.Present(); + + _output.WriteLine(VulkanAllocator.FormatMemoryLine(allocator.Snapshot())); + foreach (int mesh in meshes) seam.DeleteMesh(mesh); + seam.DeleteMesh(dynamicMesh); + GpuTest.AssertClean(seam); + } + } + + /// + /// VkMemoryDedicatedRequirements is honoured: a request marked dedicated gets + /// its own block naming the resource (validation checks the size and handle + /// match), binds, holds its bytes and gives the block back on free. The size + /// rule is per class: at or above a quarter of the class's block. + /// + [SkippableFact] + public unsafe void TheDedicatedRequirementAndTheQuarterBlockRuleGiveOwnBlocks() + { + var messages = new List(); + Skip.IfNot(GpuTest.TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + VulkanAllocator allocator = context!.Allocator; + Vk api = context.Api; + + var createInfo = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = 4096, + Usage = BufferUsageFlags.VertexBufferBit, + SharingMode = SharingMode.Exclusive, + }; + Assert.Equal(Result.Success, api.CreateBuffer(context.Device, &createInfo, null, out Buffer handle)); + + MemoryRequirements requirements = VulkanAllocator.BufferRequirements(context, handle, out bool driverWants); + _output.WriteLine("driver requires or prefers dedicated for a 4 KiB vertex buffer: " + driverWants); + + int before = allocator.BlockCount; + MemoryAllocation allocation = allocator.Allocate(requirements, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, linear: true, + "a dedicated-requirement probe", MemoryPoolClass.DeviceBuffers, requiresDedicated: true, handle, default); + + Assert.True(allocation.Block!.Dedicated); + Assert.Equal(requirements.Size, allocation.Block.Size); + Assert.Equal(before + 1, allocator.BlockCount); + Assert.Equal(Result.Success, api.BindBufferMemory(context.Device, handle, allocation.Memory, allocation.Offset)); + Assert.NotEqual(IntPtr.Zero, allocation.Mapped); + *(int*)allocation.Mapped = 0x5EED; + Assert.Equal(0x5EED, *(int*)allocation.Mapped); + + api.DestroyBuffer(context.Device, handle, null); + allocator.Free(allocation); + Assert.Equal(before, allocator.BlockCount); + + // Quarter-block rule per class: DeviceBuffers blocks are 64 MiB, Staging 32. + using (var pooled = new VulkanBuffer(context, 8 * MiB, BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit, MemoryPoolClass.DeviceBuffers)) + using (var ownBuffers = new VulkanBuffer(context, 16 * MiB, BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit, MemoryPoolClass.DeviceBuffers)) + using (var ownStaging = new VulkanBuffer(context, 8 * MiB, BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit, MemoryPoolClass.Staging)) + { + Assert.False(pooled.Allocation.Block!.Dedicated && !DriverWantsDedicated(context, pooled)); + Assert.True(ownBuffers.Allocation.Block!.Dedicated); + Assert.True(ownStaging.Allocation.Block!.Dedicated); + Assert.Equal(MemoryPoolClass.Staging, ownStaging.Allocation.Block.Class); + } + + ValidationAssert.NoErrors(messages); + } + } + + private static bool DriverWantsDedicated(VulkanContext context, VulkanBuffer buffer) + { + VulkanAllocator.BufferRequirements(context, buffer.Handle, out bool dedicated); + return dedicated; + } + + /// + /// The ReBAR class stays under its cap. Past it (or on a device with no ReBAR + /// type at all) a request falls through to host-visible Staging memory, is + /// counted in the allocator and in VulkanStats.RebarFallbacks, and is logged; + /// the fallen-through memory is mapped and holds its bytes. + /// + [SkippableFact] + public unsafe void TheReBarCapHoldsAndAMissIsACountedLoggedFallThrough() + { + Skip.IfNot(GpuTest.TryCreateContext(_output, null, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + VulkanAllocator allocator = context!.Allocator; + var logged = new List(); + allocator.Log = line => { lock (logged) logged.Add(line); }; + allocator.ReBarCapOverrideForTests = 16 * MiB; + + bool hasReBar = allocator.HasMemoryType(uint.MaxValue, + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit, 0); + long statsBefore = VulkanStats.RebarFallbacks; + + var buffers = new List(); + try + { + for (int i = 0; i < 40; i++) + { + buffers.Add(new VulkanBuffer(context, MiB, BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit + | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.ReBar)); + } + + Assert.True(allocator.ReBarUsed <= 16 * MiB, "ReBAR use " + allocator.ReBarUsed + " passed the cap"); + Assert.True(allocator.ReBarMisses > 0, "40 MiB of ReBAR requests under a 16 MiB cap never missed"); + Assert.True(VulkanStats.RebarFallbacks - statsBefore >= allocator.ReBarMisses, + "misses were not counted in VulkanStats"); + Assert.NotEmpty(logged); + Assert.Contains("ReBAR miss", logged[0]); + + int inReBar = 0; + int fellThrough = 0; + for (int i = 0; i < buffers.Count; i++) + { + MemoryBlock block = buffers[i].Allocation.Block!; + if (block.Class == MemoryPoolClass.ReBar) + { + inReBar++; + MemoryPropertyFlags flags = allocator.FlagsOf(block.TypeIndex); + Assert.True((flags & MemoryPropertyFlags.DeviceLocalBit) != 0 + && (flags & MemoryPropertyFlags.HostVisibleBit) != 0); + } + else + { + fellThrough++; + Assert.Equal(MemoryPoolClass.Staging, block.Class); + } + + Assert.NotEqual(IntPtr.Zero, buffers[i].Mapped); + *(int*)buffers[i].Mapped = i * 31; + } + for (int i = 0; i < buffers.Count; i++) Assert.Equal(i * 31, *(int*)buffers[i].Mapped); + + _output.WriteLine("ReBAR type present: " + hasReBar + "; in ReBAR " + inReBar + ", fell through " + + fellThrough + "; " + VulkanAllocator.FormatMemoryLine(allocator.Snapshot())); + Assert.True(fellThrough > 0); + if (hasReBar) Assert.True(inReBar > 0, "a ReBAR type exists but nothing landed in it under the cap"); + else Assert.Equal(0, inReBar); + } + finally + { + foreach (VulkanBuffer buffer in buffers) buffer.Dispose(); + } + } + } + + /// + /// A miss is a fall-through, not a failure: with the ReBAR cap at zero the + /// device's indirect ring (per-frame dynamic data, created at the first + /// multi-draw) lands in host staging memory, the miss is counted, and the + /// multi-draw still renders the right pixels across frames. + /// + [SkippableFact] + public void AMultiDrawWhoseIndirectRingMissedReBarStillRenders() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int size = 8; + VulkanAllocator allocator = device!.ContextForTests.Allocator; + allocator.ReBarCapOverrideForTests = 0; + long missesBefore = allocator.ReBarMisses; + + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, SolidVertex, SolidFragment, "policy-rebar-miss"); + int target = CreateTarget(seam, size); + int mesh = seam.CreateMesh(Quad(0f), false); + + for (int frame = 0; frame < 3; frame++) + { + seam.BeginFrame(); + PrepareDraw(seam, target, program, size); + seam.DrawMeshMulti(mesh, new[] { 0, 0 }, new[] { 6 }, 1, false); + seam.Present(); + } + + Assert.True(allocator.ReBarMisses > missesBefore, "the indirect ring did not ask for ReBAR"); + + seam.BeginFrame(); + AssertLeftHalf(Read(seam, target, size), size, "multi-draw through a fallen-through indirect ring"); + seam.Present(); + + seam.DeleteMesh(mesh); + GpuTest.AssertClean(seam); + } + } + + /// + /// No general defrag: an emptied pooled block survives 119 frames and is freed + /// on the 120th; a refill in between restarts the count. With a heap over its + /// budget, empty blocks go at the next frame boundary. + /// + [SkippableFact] + public void AnEmptyBlockIsFreedAfter120EmptyFramesOrAtOnceUnderPressure() + { + Skip.IfNot(GpuTest.TryCreateContext(_output, null, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + VulkanAllocator allocator = context!.Allocator; + const MemoryPropertyFlags host = MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit; + + int baseline = allocator.BlockCount; + var first = new VulkanBuffer(context, MiB, BufferUsageFlags.VertexBufferBit, host, MemoryPoolClass.DeviceBuffers); + Assert.Equal(baseline + 1, allocator.BlockCount); + first.Dispose(); + Assert.Equal(baseline + 1, allocator.BlockCount); + + for (int i = 0; i < 60; i++) allocator.AdvanceFrame(); + + // Refilled and emptied again: the count starts over. + var refill = new VulkanBuffer(context, MiB, BufferUsageFlags.VertexBufferBit, host, MemoryPoolClass.DeviceBuffers); + Assert.Equal(baseline + 1, allocator.BlockCount); + refill.Dispose(); + + long freedBefore = allocator.Snapshot().EmptyBlocksFreed; + for (int i = 0; i < VulkanAllocator.EmptyBlockFrames - 1; i++) allocator.AdvanceFrame(); + Assert.Equal(baseline + 1, allocator.BlockCount); + + allocator.AdvanceFrame(); + Assert.Equal(baseline, allocator.BlockCount); + Assert.Equal(freedBefore + 1, allocator.Snapshot().EmptyBlocksFreed); + + // Budget pressure: every heap's budget at one byte. + var pressured = new VulkanBuffer(context, MiB, BufferUsageFlags.VertexBufferBit, host, MemoryPoolClass.DeviceBuffers); + pressured.Dispose(); + Assert.Equal(baseline + 1, allocator.BlockCount); + allocator.HeapBudgetOverrideForTests = 1; + allocator.AdvanceFrame(); + Assert.Equal(baseline, allocator.BlockCount); + } + } + + /// + /// The heap report: one used/budget pair per memory heap, budgets from + /// VK_EXT_memory_budget when enabled (never above the heap) or heap x 0.7, and + /// a pooled block's bytes show on its heap and its class. + /// + [SkippableFact] + public void TheHeapReportCarriesUsedAndBudgetPerHeap() + { + Skip.IfNot(GpuTest.TryCreateContext(_output, null, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + VulkanAllocator allocator = context!.Allocator; + context.Api.GetPhysicalDeviceMemoryProperties(context.PhysicalDevice, out PhysicalDeviceMemoryProperties memory); + + using var buffer = new VulkanBuffer(context, MiB, BufferUsageFlags.VertexBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.DeviceBuffers); + MemoryBlock block = buffer.Allocation.Block!; + + MemorySnapshot snapshot = allocator.Snapshot(); + string line = VulkanAllocator.FormatMemoryLine(snapshot); + _output.WriteLine("VK_EXT_memory_budget: " + allocator.BudgetExtension + "; " + line); + + Assert.Equal((int)memory.MemoryHeapCount, snapshot.HeapUsed.Length); + Assert.Equal((int)memory.MemoryHeapCount, snapshot.HeapBudget.Length); + for (int heap = 0; heap < memory.MemoryHeapCount; heap++) + { + Assert.True(snapshot.HeapBudget[heap] > 0, "heap " + heap + " has no budget"); + if (!allocator.BudgetExtension) + { + Assert.Equal((ulong)(memory.MemoryHeaps[heap].Size * VulkanAllocator.FallbackBudgetShare), + snapshot.HeapBudget[heap]); + } + } + + Assert.True(snapshot.HeapUsed[block.HeapIndex] >= block.Size); + Assert.True(snapshot.ClassBytes[(int)MemoryPoolClass.DeviceBuffers] >= block.Size); + Assert.StartsWith("stats.memory blocks=", line); + Assert.Contains(" heaps=", line); + Assert.Contains(" budget_ext=" + (allocator.BudgetExtension ? "1" : "0"), line); + Assert.Equal((int)memory.MemoryHeapCount - 1, line.Substring(line.IndexOf(" heaps=", StringComparison.Ordinal)).Split(',').Length - 1); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 73d8778d..a3342ecd 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -144,7 +144,7 @@ public void NewStatsLinesCarryStableKeyValueTokens() } [Fact] - public void SampleIsTheOriginalLineFollowedByThreeTokenLines() + public void SampleIsTheOriginalLineFollowedByFourTokenLines() { // The first call may only arm the interval clock. VulkanStats.SampleIfDue(TimeSpan.Zero); @@ -152,7 +152,9 @@ public void SampleIsTheOriginalLineFollowedByThreeTokenLines() Assert.NotNull(sample); string[] lines = sample!.Split('\n'); - Assert.Equal(4, lines.Length); + Assert.Equal(5, lines.Length); + // Phase 1B step 5: pool classes, ReBAR use and misses, used/budget per heap. + Assert.StartsWith("stats.memory blocks=", lines[4]); Assert.Matches(new Regex( @"^stats [\d.]+s: \d+ frames \([\d.]+ ms/frame\), \d+ allocations \(\d+ live\), " + @"\d+ blocking uploads costing \d+ ms \(\S+% of the interval\), textures \+\d+/-\d+, " + @@ -171,6 +173,7 @@ public void AcceptanceDocumentNamesEveryStatsToken() { VulkanStats.FormatPacingLine(default), VulkanStats.FormatCountersLine(default), + VulkanAllocator.FormatMemoryLine(default), }) { foreach (Match token in Regex.Matches(line, @"([a-z0-9_]+)=")) @@ -185,6 +188,7 @@ public void AcceptanceDocumentNamesEveryStatsToken() Assert.Contains("stats.pacing", doc); Assert.Contains("stats.waits", doc); Assert.Contains("stats.counters", doc); + Assert.Contains("stats.memory", doc); } [Fact] @@ -355,7 +359,13 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() Assert.Contains("VulkanStats.NoteDynamicStateCommands(VulkanStats.DynamicStateCommandsPerDraw);", dynamicState); Assert.Contains("VulkanStats.NoteScopeOpened();", Source("Core/RenderTargetManager.cs")); - Assert.Contains("VulkanStats.NoteRebarFallback();", Source("Core/MeshManager.cs")); + // Phase 1B step 5: ReBAR misses are counted where the ReBAR class falls + // through, and a static mesh never asks for ReBAR. + Assert.Contains("VulkanStats.NoteRebarFallback();", Body(Source("Core/VulkanAllocator.cs"), "private MemoryAllocation AllocateReBarLocked(")); + string meshCreateBuffer = Body(Source("Core/MeshManager.cs"), "private VulkanBuffer CreateBuffer("); + Assert.DoesNotContain("MemoryPoolClass.ReBar", meshCreateBuffer); + Assert.DoesNotContain("MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit", meshCreateBuffer); + Assert.Contains("_allocator.AdvanceFrame();", ringBegin); Assert.Equal(Count(device, "CmdPipelineBarrier2("), Count(device, "VulkanStats.NoteImageBarriers(1);")); } diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 8107d34d..8bf9921e 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -362,6 +362,7 @@ internal sealed class FrameRing : IDisposable private readonly FrameTimeline _timeline; private readonly RetireQueue _retired; private readonly UploadManager _uploads; + private readonly VulkanAllocator _allocator; private int _index = -1; private bool _disposed; @@ -371,9 +372,13 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin _timeline = new FrameTimeline(context); _retired = new RetireQueue(_timeline); _uploads = new UploadManager(context, _timeline, _retired, framesInFlight, stagingPerSlot); + _allocator = context.Allocator; + // Per-frame dynamic data: the ReBAR class, falling through to host memory + // (counted and logged) when the cap or the device says no. _uniformRing = new VulkanBuffer(context, uniformRingSize, BufferUsageFlags.UniformBufferBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + MemoryPoolClass.ReBar); // Each region must start on a uniform-offset boundary, otherwise every // dynamic offset handed out from slot 1 onwards inherits the misalignment. @@ -414,6 +419,8 @@ public FrameSlot BeginFrame() ulong frameValue = _timeline.ReserveFrame(); _timeline.WaitForFrame(slot.LastSignalledValue, WaitSite.FramePacing); _retired.Collect(); + // After the retirements: blocks they emptied start their empty-frame count. + _allocator.AdvanceFrame(); _index = index; slot.Begin(frameValue); diff --git a/Optimum.Render.Vulkan/Core/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs index 051ad687..8d84a4f0 100644 --- a/Optimum.Render.Vulkan/Core/MeshManager.cs +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -97,10 +97,10 @@ internal sealed unsafe class MeshManager : IDisposable /// /// Static meshes on device-local memory, filled through the upload manager's - /// staging instead of a host mapping. Off until Phase 1B step 5 moves static - /// meshes off ReBAR; tests turn it on to drive the staged path. + /// staging instead of a host mapping. On since Phase 1B step 5 moved static + /// meshes off ReBAR; it only takes effect with an upload manager. /// - internal bool DeviceLocalStaticBuffers { get; set; } + internal bool DeviceLocalStaticBuffers { get; set; } = true; public MeshManager(VulkanContext context, GlStateTracker state, UploadManager? uploads = null) { @@ -303,32 +303,23 @@ private void AddCustom( private VulkanBuffer CreateBuffer(int byteSize, BufferUsageFlags usage, bool persistent) { - // A dynamic mesh is host visible and stays mapped, because the game - // writes straight through the pointer while the GPU may still be - // reading - the same lack of synchronisation GL allowed and the chunk - // tesselator relies on. + // Phase 1B step 5: a static mesh lives in device-local memory (a type + // that is not host visible, when the device has one), filled through the + // upload manager's staging. It never takes ReBAR, which holds only + // per-frame dynamic data. if (!persistent && DeviceLocalStaticBuffers && _uploads != null) { return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, - MemoryPropertyFlags.DeviceLocalBit); + MemoryPropertyFlags.DeviceLocalBit, MemoryPoolClass.DeviceBuffers); } - MemoryPropertyFlags properties = persistent - ? MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit - : MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit - | MemoryPropertyFlags.HostCoherentBit; - - try - { - return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, properties); - } - catch (InvalidOperationException) - { - // No resizable BAR: fall back to a plain host-visible allocation. - if (!persistent) VulkanStats.NoteRebarFallback(); - return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); - } + // A dynamic mesh is host visible and stays mapped, because the game + // writes straight through the pointer while the GPU may still be + // reading - the same lack of synchronisation GL allowed and the chunk + // tesselator relies on. A static mesh with no upload manager to stage + // through (component tests) is host visible too, still off ReBAR. + return new VulkanBuffer(_context, (ulong)byteSize, usage | BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.DeviceBuffers); } private int Register(VulkanMesh mesh) diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index ae998fa8..d8142a32 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -357,10 +357,10 @@ public int Create( throw new InvalidOperationException("vkCreateImage failed"); } - api.GetImageMemoryRequirements(_context.Device, image, out MemoryRequirements requirements); + MemoryRequirements requirements = VulkanAllocator.ImageRequirements(_context, image, out bool dedicated); MemoryAllocation allocation = _context.Allocator.Allocate( requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, - $"a {width}x{height} {format} image"); + $"a {width}x{height} {format} image", MemoryPoolClass.DeviceImages, dedicated, default, image); if (api.BindImageMemory(_context.Device, image, allocation.Memory, allocation.Offset) != Result.Success) { api.DestroyImage(_context.Device, image, null); diff --git a/Optimum.Render.Vulkan/Core/VulkanAllocator.cs b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs index 4578f705..a7686ba7 100644 --- a/Optimum.Render.Vulkan/Core/VulkanAllocator.cs +++ b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs @@ -1,9 +1,47 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Text; using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + namespace Optimum.Render.Vulkan.Core; +/// +/// What a piece of memory is for. Each class pools separately with its own block +/// size, so a long-lived static mesh never shares a block with per-frame data and +/// the cap on ReBAR can be enforced by class rather than guessed from flags. +/// +internal enum MemoryPoolClass +{ + /// Sampled textures and attachments: optimally tiled images, 128 MiB blocks. + DeviceImages = 0, + + /// Long-lived buffers (static meshes device-local, dynamic meshes host-visible): 64 MiB blocks. + DeviceBuffers = 1, + + /// Host-side transfer memory (staging, readback arenas, a ReBAR miss's fall-through): 32 MiB blocks. + Staging = 2, + + /// + /// Device-local and host-visible memory for per-frame dynamic data only + /// (uniform ring, indirect ring): 16 MiB blocks, capped at + /// min(192 MiB, budget x 0.25). A miss falls through to . + /// + ReBar = 3, + + /// Frame-graph transient attachments (reserved for Phase 2): 64 MiB blocks. + Transient = 4, + + /// + /// One allocation per resource: the driver asked for it + /// (VkMemoryDedicatedRequirements) or the resource is at least a quarter of + /// its class's block size. + /// + Dedicated = 5, +} + /// A region of a memory block handed to one resource. internal readonly struct MemoryAllocation { @@ -49,6 +87,16 @@ public FreeRange(ulong offset, ulong size) public ulong Size { get; } public uint TypeIndex { get; } + /// The heap the block's memory type draws from. + public uint HeapIndex { get; } + + /// + /// The pool class the block belongs to. A dedicated block keeps the class of + /// the resource it backs, so a dedicated ReBAR resource still counts against + /// the ReBAR cap. + /// + public MemoryPoolClass Class { get; } + /// /// Whether this block holds linear resources (buffers) or optimally tiled /// ones (images). They are never mixed, which is what makes @@ -57,7 +105,7 @@ public FreeRange(ulong offset, ulong size) /// public bool Linear { get; } - /// Set when the block backs exactly one oversized resource. + /// Set when the block backs exactly one resource. public bool Dedicated { get; } /// Base host pointer when the memory type is host visible. @@ -67,24 +115,52 @@ public FreeRange(ulong offset, ulong size) public bool IsEmpty => Used == 0; + /// + /// The allocator frame at which a pooled block last became empty, or -1 while + /// it holds anything. Empty blocks are freed after + /// frames. + /// + internal long EmptySinceFrame = -1; + public MemoryBlock( VulkanContext context, ulong size, uint typeIndex, bool linear, bool dedicated, bool hostVisible) + : this(context, size, typeIndex, 0, MemoryPoolClass.DeviceBuffers, linear, dedicated, hostVisible, + default, default) + { + } + + public MemoryBlock( + VulkanContext context, ulong size, uint typeIndex, uint heapIndex, MemoryPoolClass poolClass, + bool linear, bool dedicated, bool hostVisible, Buffer dedicatedBuffer, Image dedicatedImage) { _context = context; Size = size; TypeIndex = typeIndex; + HeapIndex = heapIndex; + Class = poolClass; Linear = linear; Dedicated = dedicated; + // A dedicated block names its resource, which lets the driver place it + // (and is mandatory when the resource reported requiresDedicatedAllocation). + var dedicatedInfo = new MemoryDedicatedAllocateInfo + { + SType = StructureType.MemoryDedicatedAllocateInfo, + Buffer = dedicatedBuffer, + Image = dedicatedImage, + }; + bool namesResource = dedicated && (dedicatedBuffer.Handle != 0 || dedicatedImage.Handle != 0); + var allocateInfo = new MemoryAllocateInfo { SType = StructureType.MemoryAllocateInfo, + PNext = namesResource ? &dedicatedInfo : null, AllocationSize = size, MemoryTypeIndex = typeIndex, }; Memory = VulkanMemory.Allocate(context, allocateInfo, - $"a {size} byte {(dedicated ? "dedicated" : "pooled")} memory block"); + $"a {size} byte {(dedicated ? "dedicated" : "pooled")} {poolClass} memory block"); if (hostVisible) { @@ -180,6 +256,19 @@ public void Dispose() } } +/// The allocator's state at one moment, for the stats.memory line and tests. +internal readonly record struct MemorySnapshot( + int Blocks, + int DedicatedBlocks, + ulong ReBarUsed, + ulong ReBarCap, + long ReBarMisses, + long EmptyBlocksFreed, + bool BudgetExtension, + ulong[] ClassBytes, + ulong[] HeapUsed, + ulong[] HeapBudget); + /// /// Hands resources memory out of a few large blocks instead of giving each its /// own allocation. @@ -193,16 +282,31 @@ public void Dispose() /// the spec exposes - commonly 4096 - is the same problem stated as a hard cap, /// and NVIDIA not enforcing one is why this degraded instead of failing. /// -/// Buffers and images are kept in separate blocks so bufferImageGranularity -/// never applies, and anything large enough to waste a block gets its own. +/// Phase 1B step 5: memory is pooled per and +/// memory type. Buffers and images are kept in separate blocks so +/// bufferImageGranularity never applies; anything the driver wants dedicated, or +/// large enough to waste a quarter of its class's block, gets its own. Heap +/// budgets come from VK_EXT_memory_budget when the device has it (heap x 0.7 +/// otherwise). Empty blocks are freed after +/// frames, or at once when a heap is over its budget. /// internal sealed unsafe class VulkanAllocator : IDisposable { - /// Size of a pooled block. - private const ulong BlockSize = 64UL * 1024 * 1024; + public const int PoolClassCount = 6; + + private const ulong MiB = 1024UL * 1024; + + /// Frames a pooled block stays empty before it is freed. + public const int EmptyBlockFrames = 120; - /// Above this, a resource gets its own allocation rather than a slice. - private const ulong DedicatedThreshold = BlockSize / 4; + /// The ReBAR cap's ceiling; the cap is min(this, budget x 0.25). + public const ulong ReBarCapCeiling = 192 * MiB; + + /// Budget as a share of heap size when VK_EXT_memory_budget is absent. + public const double FallbackBudgetShare = 0.7; + + /// ReBAR misses reported through ; the counter keeps counting past it. + private const int LoggedMissLimit = 32; /// /// Set by OPTIMUM_VULKAN_DEDICATED_MEMORY=1 to give every resource its own @@ -217,19 +321,50 @@ internal sealed unsafe class VulkanAllocator : IDisposable private static readonly bool AlwaysDedicated = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_DEDICATED_MEMORY") == "1"; + /// OPTIMUM_VULKAN_NO_REBAR=1 forces every ReBAR request down the fall-through path. + private static readonly bool ReBarDisabled = + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_NO_REBAR") == "1"; + private readonly VulkanContext _context; private readonly object _gate = new(); - private readonly Dictionary<(uint TypeIndex, bool Linear), List> _pools = new(); + private readonly Dictionary<(MemoryPoolClass Class, uint TypeIndex, bool Linear), List> _pools = new(); private readonly List _dedicated = new(); private readonly PhysicalDeviceMemoryProperties _memoryProperties; + private readonly ulong[] _heapUsed; + private readonly ulong[] _heapBudget; + private readonly ulong[] _classBytes = new ulong[PoolClassCount]; + private ulong _reBarUsed; + private long _reBarMisses; + private long _emptyBlocksFreed; + private long _frame; + private int _emptyBlocks; private bool _disposed; public VulkanAllocator(VulkanContext context) { _context = context; context.Api.GetPhysicalDeviceMemoryProperties(context.PhysicalDevice, out _memoryProperties); + _heapUsed = new ulong[_memoryProperties.MemoryHeapCount]; + _heapBudget = new ulong[_memoryProperties.MemoryHeapCount]; + BudgetExtension = context.MemoryBudgetAvailable; + RefreshBudgetLocked(); } + /// + /// Receives a line for each logged event (ReBAR misses). The device points it + /// at the validation mirror; never at GetError, since a miss is not an error. + /// + public Action? Log { get; set; } + + /// Whether heap budgets come from VK_EXT_memory_budget. + public bool BudgetExtension { get; } + + /// Replaces the ReBAR cap. Tests only. + internal ulong? ReBarCapOverrideForTests { get; set; } + + /// Replaces every heap's budget. Tests only. + internal ulong? HeapBudgetOverrideForTests { get; set; } + /// Blocks currently held, which is the real vkAllocateMemory count. public int BlockCount { @@ -237,62 +372,230 @@ public int BlockCount { lock (_gate) { - int count = _dedicated.Count; - foreach (List blocks in _pools.Values) count += blocks.Count; - return count; + return BlockCountLocked(); } } } + private int BlockCountLocked() + { + int count = _dedicated.Count; + foreach (List blocks in _pools.Values) count += blocks.Count; + return count; + } + + public long ReBarMisses + { + get + { + lock (_gate) return _reBarMisses; + } + } + + /// Block bytes of the given class on ReBAR memory types, dedicated ones included. + public ulong ReBarUsed + { + get + { + lock (_gate) return _reBarUsed; + } + } + + public static ulong BlockSizeOf(MemoryPoolClass poolClass) => poolClass switch + { + MemoryPoolClass.DeviceImages => 128 * MiB, + MemoryPoolClass.DeviceBuffers => 64 * MiB, + MemoryPoolClass.Staging => 32 * MiB, + MemoryPoolClass.ReBar => 16 * MiB, + MemoryPoolClass.Transient => 64 * MiB, + _ => 64 * MiB, + }; + + /// + /// The class a request lands in when the caller does not say: images are + /// DeviceImages; a buffer asking for device-local and host-visible memory is + /// ReBar; every other buffer is DeviceBuffers. + /// + public static MemoryPoolClass InferClass(MemoryPropertyFlags properties, bool linear) + { + if (!linear) return MemoryPoolClass.DeviceImages; + const MemoryPropertyFlags reBar = MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit; + return (properties & reBar) == reBar ? MemoryPoolClass.ReBar : MemoryPoolClass.DeviceBuffers; + } + public MemoryAllocation Allocate( - MemoryRequirements requirements, MemoryPropertyFlags properties, bool linear, string what) + MemoryRequirements requirements, MemoryPropertyFlags properties, bool linear, string what) => + Allocate(requirements, properties, linear, what, InferClass(properties, linear), false, default, default); + + /// + /// Allocates for one resource. is the + /// resource's VkMemoryDedicatedRequirements (required or preferred), and the + /// buffer or image handle, when given, is named in a dedicated allocation. + /// + public MemoryAllocation Allocate( + MemoryRequirements requirements, MemoryPropertyFlags properties, bool linear, string what, + MemoryPoolClass poolClass, bool requiresDedicated, Buffer buffer, Image image) { - uint typeIndex = FindMemoryType(requirements.MemoryTypeBits, properties); - bool hostVisible = (_memoryProperties.MemoryTypes[(int)typeIndex].PropertyFlags - & MemoryPropertyFlags.HostVisibleBit) != 0; + if (poolClass == MemoryPoolClass.Dedicated) + { + requiresDedicated = true; + poolClass = InferClass(properties, linear); + } lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); - if (AlwaysDedicated || requirements.Size >= DedicatedThreshold) + if (poolClass == MemoryPoolClass.ReBar) { - var block = new MemoryBlock( - _context, requirements.Size, typeIndex, linear, dedicated: true, hostVisible); - _dedicated.Add(block); + return AllocateReBarLocked(requirements, properties, linear, what, requiresDedicated, buffer, image); + } + + uint typeIndex = FindMemoryType(requirements.MemoryTypeBits, properties, Avoided(properties)); + return AllocateLocked(requirements, typeIndex, poolClass, linear, what, requiresDedicated, buffer, image); + } + } + + /// + /// ReBAR holds only per-frame dynamic data and is capped. A request that finds + /// no ReBAR type, or would take the class past its cap, is counted, logged and + /// served from host-visible staging memory instead. + /// + private MemoryAllocation AllocateReBarLocked( + MemoryRequirements requirements, MemoryPropertyFlags properties, bool linear, string what, + bool requiresDedicated, Buffer buffer, Image image) + { + MemoryPropertyFlags wanted = properties | MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit; + string? miss = null; - if (!block.TryAllocate(requirements.Size, requirements.Alignment, out ulong dedicatedOffset)) + if (ReBarDisabled) + { + miss = "OPTIMUM_VULKAN_NO_REBAR=1"; + } + else if (!TryFindMemoryType(requirements.MemoryTypeBits, wanted, 0, out uint typeIndex)) + { + miss = "no device-local host-visible memory type"; + } + else + { + bool dedicated = AlwaysDedicated || requiresDedicated + || requirements.Size >= BlockSizeOf(MemoryPoolClass.ReBar) / 4; + var key = (MemoryPoolClass.ReBar, typeIndex, linear); + + if (!dedicated && _pools.TryGetValue(key, out List? pool)) + { + foreach (MemoryBlock candidate in pool) { - throw new InvalidOperationException("a dedicated block could not satisfy " + what); + if (candidate.TryAllocate(requirements.Size, requirements.Alignment, out ulong offset)) + { + NoteFilled(candidate); + return Describe(candidate, offset, requirements.Size); + } } - return Describe(block, dedicatedOffset, requirements.Size); } - var key = (typeIndex, linear); - if (!_pools.TryGetValue(key, out List? pool)) + ulong growth = dedicated ? requirements.Size : BlockSizeOf(MemoryPoolClass.ReBar); + ulong cap = ReBarCapLocked(typeIndex); + if (_reBarUsed + growth > cap) + { + miss = "cap " + cap + " bytes reached (" + _reBarUsed + " used, " + growth + " more needed)"; + } + else { - pool = new List(); - _pools[key] = pool; + return AllocateLocked(requirements, typeIndex, MemoryPoolClass.ReBar, linear, what, + requiresDedicated, buffer, image); } + } - foreach (MemoryBlock candidate in pool) + _reBarMisses++; + VulkanStats.NoteRebarFallback(); + if (_reBarMisses <= LoggedMissLimit) + { + string line = "[Optimum] ReBAR miss for " + what + ": " + miss + + "; falling through to host-visible staging memory (miss " + _reBarMisses + ")"; + Log?.Invoke(line); + if (RenderTrace.Enabled) RenderTrace.Write(line); + } + + MemoryPropertyFlags host = (properties & ~MemoryPropertyFlags.DeviceLocalBit) + | MemoryPropertyFlags.HostVisibleBit; + uint hostType = FindMemoryType(requirements.MemoryTypeBits, host, MemoryPropertyFlags.DeviceLocalBit); + return AllocateLocked(requirements, hostType, MemoryPoolClass.Staging, linear, what, requiresDedicated, + buffer, image); + } + + private MemoryAllocation AllocateLocked( + MemoryRequirements requirements, uint typeIndex, MemoryPoolClass poolClass, bool linear, string what, + bool requiresDedicated, Buffer buffer, Image image) + { + MemoryType type = _memoryProperties.MemoryTypes[(int)typeIndex]; + bool hostVisible = (type.PropertyFlags & MemoryPropertyFlags.HostVisibleBit) != 0; + ulong blockSize = BlockSizeOf(poolClass); + + if (AlwaysDedicated || requiresDedicated || requirements.Size >= blockSize / 4) + { + var block = new MemoryBlock( + _context, requirements.Size, typeIndex, type.HeapIndex, poolClass, linear, dedicated: true, + hostVisible, buffer, image); + _dedicated.Add(block); + NoteBlockCreated(block); + + if (!block.TryAllocate(requirements.Size, requirements.Alignment, out ulong dedicatedOffset)) { - if (candidate.TryAllocate(requirements.Size, requirements.Alignment, out ulong offset)) - { - return Describe(candidate, offset, requirements.Size); - } + throw new InvalidOperationException("a dedicated block could not satisfy " + what); } + return Describe(block, dedicatedOffset, requirements.Size); + } - var fresh = new MemoryBlock( - _context, BlockSize, typeIndex, linear, dedicated: false, hostVisible); - pool.Add(fresh); + var key = (poolClass, typeIndex, linear); + if (!_pools.TryGetValue(key, out List? pool)) + { + pool = new List(); + _pools[key] = pool; + } - if (!fresh.TryAllocate(requirements.Size, requirements.Alignment, out ulong freshOffset)) + foreach (MemoryBlock candidate in pool) + { + if (candidate.TryAllocate(requirements.Size, requirements.Alignment, out ulong offset)) { - throw new InvalidOperationException("a fresh block could not satisfy " + what); + NoteFilled(candidate); + return Describe(candidate, offset, requirements.Size); } - return Describe(fresh, freshOffset, requirements.Size); } + + var fresh = new MemoryBlock( + _context, blockSize, typeIndex, type.HeapIndex, poolClass, linear, dedicated: false, hostVisible, + default, default); + pool.Add(fresh); + NoteBlockCreated(fresh); + + if (!fresh.TryAllocate(requirements.Size, requirements.Alignment, out ulong freshOffset)) + { + throw new InvalidOperationException("a fresh block could not satisfy " + what); + } + return Describe(fresh, freshOffset, requirements.Size); + } + + private void NoteBlockCreated(MemoryBlock block) + { + _heapUsed[block.HeapIndex] += block.Size; + _classBytes[(int)(block.Dedicated ? MemoryPoolClass.Dedicated : block.Class)] += block.Size; + if (block.Class == MemoryPoolClass.ReBar) _reBarUsed += block.Size; + } + + private void NoteBlockReleased(MemoryBlock block) + { + _heapUsed[block.HeapIndex] -= Math.Min(_heapUsed[block.HeapIndex], block.Size); + int index = (int)(block.Dedicated ? MemoryPoolClass.Dedicated : block.Class); + _classBytes[index] -= Math.Min(_classBytes[index], block.Size); + if (block.Class == MemoryPoolClass.ReBar) _reBarUsed -= Math.Min(_reBarUsed, block.Size); + } + + private void NoteFilled(MemoryBlock block) + { + if (block.EmptySinceFrame < 0) return; + block.EmptySinceFrame = -1; + _emptyBlocks--; } private static MemoryAllocation Describe(MemoryBlock block, ulong offset, ulong size) => @@ -319,36 +622,248 @@ public void Free(in MemoryAllocation allocation) if (block.Dedicated) { _dedicated.Remove(block); + NoteBlockReleased(block); block.Dispose(); return; } - // An emptied block is kept if it is its pool's last one, so a pool - // that is repeatedly drained and refilled - which chunk streaming - // does - is not paying for an allocation each time. - if (!block.IsEmpty) return; + // An emptied block is kept for EmptyBlockFrames frames, so a pool that + // is repeatedly drained and refilled - which chunk streaming does - is + // not paying for an allocation each time. + if (!block.IsEmpty || block.EmptySinceFrame >= 0) return; + block.EmptySinceFrame = _frame; + _emptyBlocks++; + } + } + + /// + /// One frame boundary (the frame ring calls it at BeginFrame): refreshes the + /// heap budgets now and then, and frees pooled blocks that stayed empty for + /// frames, or every empty block at once while a + /// heap is over its budget. + /// + public void AdvanceFrame() + { + lock (_gate) + { + if (_disposed) return; + _frame++; + + if (_frame % 60 == 0) RefreshBudgetLocked(); + if (_emptyBlocks == 0) return; + + bool pressure = false; + for (int heap = 0; heap < _heapUsed.Length; heap++) + { + if (_heapUsed[heap] > HeapBudgetLocked(heap)) pressure = true; + } + + foreach (List pool in _pools.Values) + { + for (int i = pool.Count - 1; i >= 0; i--) + { + MemoryBlock block = pool[i]; + if (block.EmptySinceFrame < 0 || !block.IsEmpty) continue; + if (!pressure && _frame - block.EmptySinceFrame < EmptyBlockFrames) continue; + + pool.RemoveAt(i); + _emptyBlocks--; + _emptyBlocksFreed++; + NoteBlockReleased(block); + block.Dispose(); + } + } + } + } + + private ulong HeapBudgetLocked(int heap) => HeapBudgetOverrideForTests ?? _heapBudget[heap]; + + private ulong ReBarCapLocked(uint typeIndex) + { + if (ReBarCapOverrideForTests is { } forced) return forced; + uint heap = _memoryProperties.MemoryTypes[(int)typeIndex].HeapIndex; + return Math.Min(ReBarCapCeiling, HeapBudgetLocked((int)heap) / 4); + } + + private void RefreshBudgetLocked() + { + int heaps = (int)_memoryProperties.MemoryHeapCount; + if (BudgetExtension) + { + var budget = new PhysicalDeviceMemoryBudgetPropertiesEXT + { + SType = StructureType.PhysicalDeviceMemoryBudgetPropertiesExt, + }; + var properties = new PhysicalDeviceMemoryProperties2 + { + SType = StructureType.PhysicalDeviceMemoryProperties2, + PNext = &budget, + }; + _context.Api.GetPhysicalDeviceMemoryProperties2(_context.PhysicalDevice, &properties); + for (int i = 0; i < heaps; i++) + { + ulong reported = budget.HeapBudget[i]; + _heapBudget[i] = reported > 0 + ? reported + : (ulong)(_memoryProperties.MemoryHeaps[i].Size * FallbackBudgetShare); + } + return; + } + + for (int i = 0; i < heaps; i++) + { + _heapBudget[i] = (ulong)(_memoryProperties.MemoryHeaps[i].Size * FallbackBudgetShare); + } + } + + public MemorySnapshot Snapshot() + { + lock (_gate) + { + var heapBudget = new ulong[_heapBudget.Length]; + for (int i = 0; i < heapBudget.Length; i++) heapBudget[i] = HeapBudgetLocked(i); - var key = (block.TypeIndex, block.Linear); - if (!_pools.TryGetValue(key, out List? pool) || pool.Count <= 1) return; + ulong cap = 0; + if (TryFindMemoryType(uint.MaxValue, + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit, 0, out uint reBarType)) + { + cap = ReBarCapLocked(reBarType); + } - pool.Remove(block); - block.Dispose(); + return new MemorySnapshot( + BlockCountLocked(), _dedicated.Count, _reBarUsed, cap, _reBarMisses, _emptyBlocksFreed, + BudgetExtension, (ulong[])_classBytes.Clone(), (ulong[])_heapUsed.Clone(), heapBudget); } } - private uint FindMemoryType(uint typeBits, MemoryPropertyFlags properties) + /// + /// Flags a request should avoid when it can: device-local-only memory keeps off + /// host-visible types (so ReBAR is left for per-frame data), and host memory + /// keeps off device-local types (so it does not eat the BAR either). + /// + private static MemoryPropertyFlags Avoided(MemoryPropertyFlags properties) + { + bool deviceLocal = (properties & MemoryPropertyFlags.DeviceLocalBit) != 0; + bool hostVisible = (properties & MemoryPropertyFlags.HostVisibleBit) != 0; + if (deviceLocal && !hostVisible) return MemoryPropertyFlags.HostVisibleBit; + if (hostVisible && !deviceLocal) return MemoryPropertyFlags.DeviceLocalBit; + return 0; + } + + private bool TryFindMemoryType(uint typeBits, MemoryPropertyFlags properties, MemoryPropertyFlags avoid, + out uint typeIndex) { for (uint i = 0; i < _memoryProperties.MemoryTypeCount; i++) { if ((typeBits & (1u << (int)i)) == 0) continue; MemoryPropertyFlags flags = _memoryProperties.MemoryTypes[(int)i].PropertyFlags; - if ((flags & properties) == properties) return i; + if ((flags & properties) == properties && (flags & avoid) == 0) + { + typeIndex = i; + return true; + } } + typeIndex = 0; + return false; + } + + /// + /// The first type with every requested property, preferring one without the + /// avoided flags; on a unified-memory device nothing can be avoided and the + /// first match is taken. + /// + private uint FindMemoryType(uint typeBits, MemoryPropertyFlags properties, MemoryPropertyFlags avoid) + { + if (avoid != 0 && TryFindMemoryType(typeBits, properties, avoid, out uint preferred)) return preferred; + if (TryFindMemoryType(typeBits, properties, 0, out uint any)) return any; throw new InvalidOperationException($"no memory type with {properties}"); } + /// The property flags of a memory type. Tests and diagnostics. + public MemoryPropertyFlags FlagsOf(uint typeIndex) => _memoryProperties.MemoryTypes[(int)typeIndex].PropertyFlags; + + /// Whether any type the mask allows has the properties and none of the avoided flags. + public bool HasMemoryType(uint typeBits, MemoryPropertyFlags properties, MemoryPropertyFlags avoid) + { + lock (_gate) return TryFindMemoryType(typeBits, properties, avoid, out _); + } + + /// A buffer's requirements plus whether the driver requires or prefers a dedicated allocation. + public static MemoryRequirements BufferRequirements(VulkanContext context, Buffer buffer, out bool dedicated) + { + var dedicatedRequirements = new MemoryDedicatedRequirements + { + SType = StructureType.MemoryDedicatedRequirements, + }; + var requirements = new MemoryRequirements2 + { + SType = StructureType.MemoryRequirements2, + PNext = &dedicatedRequirements, + }; + var info = new BufferMemoryRequirementsInfo2 + { + SType = StructureType.BufferMemoryRequirementsInfo2, + Buffer = buffer, + }; + context.Api.GetBufferMemoryRequirements2(context.Device, &info, &requirements); + dedicated = dedicatedRequirements.RequiresDedicatedAllocation || dedicatedRequirements.PrefersDedicatedAllocation; + return requirements.MemoryRequirements; + } + + /// An image's requirements plus whether the driver requires or prefers a dedicated allocation. + public static MemoryRequirements ImageRequirements(VulkanContext context, Image image, out bool dedicated) + { + var dedicatedRequirements = new MemoryDedicatedRequirements + { + SType = StructureType.MemoryDedicatedRequirements, + }; + var requirements = new MemoryRequirements2 + { + SType = StructureType.MemoryRequirements2, + PNext = &dedicatedRequirements, + }; + var info = new ImageMemoryRequirementsInfo2 + { + SType = StructureType.ImageMemoryRequirementsInfo2, + Image = image, + }; + context.Api.GetImageMemoryRequirements2(context.Device, &info, &requirements); + dedicated = dedicatedRequirements.RequiresDedicatedAllocation || dedicatedRequirements.PrefersDedicatedAllocation; + return requirements.MemoryRequirements; + } + + /// The stats.memory line: blocks, ReBAR use and misses, bytes per class, used/budget per heap. + public static string FormatMemoryLine(MemorySnapshot snapshot) + { + var line = new StringBuilder("stats.memory"); + line.Append(" blocks=").Append(snapshot.Blocks.ToString(CultureInfo.InvariantCulture)); + line.Append(" dedicated=").Append(snapshot.DedicatedBlocks.ToString(CultureInfo.InvariantCulture)); + line.Append(" rebar_used=").Append(snapshot.ReBarUsed.ToString(CultureInfo.InvariantCulture)); + line.Append(" rebar_cap=").Append(snapshot.ReBarCap.ToString(CultureInfo.InvariantCulture)); + line.Append(" rebar_misses=").Append(snapshot.ReBarMisses.ToString(CultureInfo.InvariantCulture)); + line.Append(" empty_blocks_freed=").Append(snapshot.EmptyBlocksFreed.ToString(CultureInfo.InvariantCulture)); + line.Append(" budget_ext=").Append(snapshot.BudgetExtension ? '1' : '0'); + line.Append(" class_bytes="); + for (int i = 0; i < PoolClassCount; i++) + { + if (i > 0) line.Append(','); + ulong bytes = snapshot.ClassBytes != null && i < snapshot.ClassBytes.Length ? snapshot.ClassBytes[i] : 0; + line.Append(bytes.ToString(CultureInfo.InvariantCulture)); + } + line.Append(" heaps="); + int heaps = snapshot.HeapUsed?.Length ?? 0; + for (int i = 0; i < heaps; i++) + { + if (i > 0) line.Append(','); + line.Append(snapshot.HeapUsed![i].ToString(CultureInfo.InvariantCulture)).Append('/'); + ulong budget = snapshot.HeapBudget != null && i < snapshot.HeapBudget.Length ? snapshot.HeapBudget[i] : 0; + line.Append(budget.ToString(CultureInfo.InvariantCulture)); + } + return line.ToString(); + } + public void Dispose() { lock (_gate) diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index c012b887..fb724f8b 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -107,6 +107,13 @@ internal sealed unsafe class VulkanContext : IDisposable /// an option. /// public VulkanAllocator Allocator { get; private set; } = null!; + + /// + /// VK_EXT_memory_budget is enabled, so the allocator reads per-heap budgets + /// from the driver. Off when the device lacks it or OPTIMUM_VULKAN_NO_MEMORY_BUDGET=1 + /// forces the heap x 0.7 fallback. + /// + public bool MemoryBudgetAvailable { get; private set; } public VulkanCapabilities Capabilities { get; private set; } = new(); /// @@ -700,6 +707,12 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso if (wantCheckpoints) deviceExtensions.Add("VK_NV_device_diagnostic_checkpoints"); if (wantDeviceFault) deviceExtensions.Add("VK_EXT_device_fault"); + // Optional tier: per-heap budgets from the driver; without it the + // allocator budgets heap x 0.7. The env override forces the fallback. + bool wantMemoryBudget = deviceExtensionsAvailable.Contains("VK_EXT_memory_budget") + && Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_NO_MEMORY_BUDGET") != "1"; + if (wantMemoryBudget) deviceExtensions.Add("VK_EXT_memory_budget"); + nint extensionsPtr = deviceExtensions.Count > 0 ? SilkMarshal.StringArrayToPtr(deviceExtensions) : 0; @@ -732,6 +745,7 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso GraphicsQueue = Api.GetDeviceQueue(Device, family, 0); LoadDiagnosticExtensions(wantCheckpoints, wantDeviceFault); Capabilities = ReadCapabilities(); + MemoryBudgetAvailable = wantMemoryBudget; Allocator = new VulkanAllocator(this); return true; } diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index b39310f9..7eeba33f 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -54,6 +54,13 @@ internal sealed unsafe class VulkanBuffer : IDisposable internal long FrameUse; public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, MemoryPropertyFlags properties) + : this(context, size, usage, properties, VulkanAllocator.InferClass(properties, linear: true)) + { + } + + /// A buffer in an explicit pool class; see . + public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, MemoryPropertyFlags properties, + MemoryPoolClass poolClass) { _context = context; Size = size; @@ -73,11 +80,11 @@ public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, M } Handle = buffer; - api.GetBufferMemoryRequirements(context.Device, buffer, out MemoryRequirements requirements); + MemoryRequirements requirements = VulkanAllocator.BufferRequirements(context, buffer, out bool dedicated); // A buffer is linear, so it shares blocks only with other buffers. _allocation = context.Allocator.Allocate( - requirements, properties, linear: true, $"a {size} byte buffer"); + requirements, properties, linear: true, $"a {size} byte buffer", poolClass, dedicated, buffer, default); api.BindBufferMemory(context.Device, buffer, _allocation.Memory, _allocation.Offset); Mapped = _allocation.Mapped; @@ -151,11 +158,12 @@ public VulkanImage( } Handle = image; - api.GetImageMemoryRequirements(context.Device, image, out MemoryRequirements requirements); + MemoryRequirements requirements = VulkanAllocator.ImageRequirements(context, image, out bool dedicated); // Optimally tiled, so it never shares a block with a buffer. _allocation = context.Allocator.Allocate( - requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, "an image"); + requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, "an image", + MemoryPoolClass.DeviceImages, dedicated, default, image); api.BindImageMemory(context.Device, image, _allocation.Memory, _allocation.Offset); var viewInfo = new ImageViewCreateInfo diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 4fc60b62..cbddb15d 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -297,13 +297,23 @@ public static Result WaitDeviceIdle(Vk api, Device device) double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; + VulkanAllocator? memory = MemorySource; + MemorySnapshot memorySnapshot = memory == null ? default : memory.Snapshot(); + return FormatIntervalLine(elapsed, frames, allocations, VulkanMemory.LiveAllocations, uploads, uploadMs, created, deleted, dropped, overflows) + "\n" + FormatPacingLine(FrameIntervals.Snapshot()) + "\n" + FormatWaitsLine(waitCounts, waitMs) + "\n" + - FormatCountersLine(counters); + FormatCountersLine(counters) + "\n" + + VulkanAllocator.FormatMemoryLine(memorySnapshot); } + /// + /// The allocator whose pool classes and heaps the stats.memory line + /// reports; the device sets it at init and clears it at dispose. + /// + public static volatile VulkanAllocator? MemorySource; + /// The original stats line. Its format must not change. public static string FormatIntervalLine(double elapsed, long frames, long allocations, int liveAllocations, long uploads, double uploadMs, long created, long deleted, long dropped, long overflows) diff --git a/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs index 6ddf24b3..28751c5b 100644 --- a/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs +++ b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs @@ -130,7 +130,7 @@ private VulkanBuffer Reserve(int slotIndex, ulong bytes, ulong alignment, out ul // still name it; the timeline retires it after both. if (arena != null) _frames.DeferDeletion(arena); arena = new VulkanBuffer(_context, size, BufferUsageFlags.TransferDstBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.Staging); _arenas[slotIndex] = arena; aligned = 0; } diff --git a/Optimum.Render.Vulkan/Transfer/UploadManager.cs b/Optimum.Render.Vulkan/Transfer/UploadManager.cs index 648fc214..cde23781 100644 --- a/Optimum.Render.Vulkan/Transfer/UploadManager.cs +++ b/Optimum.Render.Vulkan/Transfer/UploadManager.cs @@ -211,7 +211,7 @@ public StagingSlice Stage(ulong size) // open batch's Transfer value (and the newest Frame value, for an inline // copy) is what the retire entry is keyed on, so it outlives the copy. var dedicated = new VulkanBuffer(_context, Math.Max(size, 1), BufferUsageFlags.TransferSrcBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.Staging); _retired.Retire(dedicated); VulkanStats.NoteStagingOverflow(); return new StagingSlice(dedicated.Handle, 0, dedicated.Mapped); @@ -285,7 +285,7 @@ private VulkanBuffer StagingRing() => // Allocated on first use: most frame rings in tests never stage anything. _stagingRing ??= new VulkanBuffer(_context, _stagingPerSlot * (ulong)_ringBatches, BufferUsageFlags.TransferSrcBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.Staging); private Batch EnsureOpenLocked() { diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index c0e00e92..8e42f9dd 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -318,6 +318,10 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; GPU checkpoints " + (_context.CheckpointsAvailable ? "ENABLED" : "NOT AVAILABLE") + "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE") + "; poison " + (_context.PoisonFreshResources ? "ON" : "off")); + // A ReBAR miss is logged, not an error: the validation mirror and the + // trace, never GetError. The stats sample reads this allocator's heaps. + _context.Allocator.Log = MirrorValidationMessage; + VulkanStats.MemorySource = _context.Allocator; _state = new GlStateTracker(); // Uploads never wait: they ride the next frame submission, recorded from // any thread into the ring's upload batch (or inline into the frame when @@ -675,6 +679,12 @@ internal bool DeviceLocalStaticMeshesForTests set => _meshes.DeviceLocalStaticBuffers = value; } + /// The mesh store. Tests only. + internal MeshManager MeshesForTests => _meshes; + + /// The context (and its allocator). Tests only. + internal VulkanContext ContextForTests => _context; + /// Where per-second backend counters go, when asked for. private static readonly string? StatsLogPath = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_STATS"); @@ -2421,9 +2431,11 @@ private VulkanBuffer AllocateIndirect(int groupCount, out ulong offset) { if (_indirectScratch != null) _frames.DeferDeletion(_indirectScratch); + // Per-frame dynamic data, so the ReBAR class (a miss falls through, counted). _indirectScratch = new VulkanBuffer(_context, required, BufferUsageFlags.IndirectBufferBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + MemoryPoolClass.ReBar); _indirectCursor = 0; } @@ -2590,7 +2602,7 @@ private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint heig ulong handed = Math.Min(bytes, copied); using var readback = new VulkanBuffer(_context, Math.Max(bytes, copied), BufferUsageFlags.TransferDstBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.Staging); ImageLayout restore = texture.Layout; CommandBuffer commandBuffer = _uploads.BeginRecording(inlineInFrame: false); @@ -2709,6 +2721,10 @@ public void Dispose() _targets?.Dispose(); _meshes?.Dispose(); _textures?.Dispose(); + if (_context != null && ReferenceEquals(VulkanStats.MemorySource, _context.Allocator)) + { + VulkanStats.MemorySource = null; + } _context?.Dispose(); } } diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index a81d0880..5bc144d3 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -254,14 +254,15 @@ before it lack the field and still parse, but cannot be compared against a basel [Optimum] fps window= frames= mean= min= max= p99= stddev= ``` -`OPTIMUM_VULKAN_STATS`, Vulkan only, one sample per second of four lines. The first line is -unchanged from earlier builds; the other three carry stable `key=value` tokens: +`OPTIMUM_VULKAN_STATS`, Vulkan only, one sample per second of five lines. The first line is +unchanged from earlier builds; the other four carry stable `key=value` tokens: ``` stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= +stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... ``` - The first line's "blocking uploads" counts every synchronous setup submission (uploads and @@ -277,9 +278,19 @@ stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fa frame including the queue lock, which a worker's synchronous upload holds through its fence wait). - `stats.counters`, per interval: `scopes` (vkCmdBeginRendering), `barriers` (image barriers - recorded), `rebar_fallbacks` (static mesh buffers that asked for ReBAR and got plain host - memory), `dynamic_state` (dynamic-state commands), `uniform_ring_used` (peak bytes one frame + recorded), `rebar_fallbacks` (per-frame dynamic buffers - uniform ring, indirect ring - that + asked for the ReBAR pool class and fell through to host staging memory because no ReBAR type + exists, the cap was reached or `OPTIMUM_VULKAN_NO_REBAR=1`; each is also logged), + `dynamic_state` (dynamic-state commands), `uniform_ring_used` (peak bytes one frame slot used) and `uniform_ring_capacity` (bytes per slot). +- `stats.memory`, a snapshot at sample time (Phase 1B step 5): `blocks` (live device + allocations the allocator holds), `dedicated` (of them, one-resource blocks), `rebar_used` and + `rebar_cap` (ReBAR class bytes and its cap, min(192 MiB, heap budget x 0.25)), `rebar_misses` + and `empty_blocks_freed` (cumulative; empty pooled blocks are freed after 120 frames, or at once + while a heap is over budget), `budget_ext` (1 when `VK_EXT_memory_budget` supplies the budgets, + 0 for heap x 0.7; `OPTIMUM_VULKAN_NO_MEMORY_BUDGET=1` forces 0), `class_bytes` (block bytes per + pool class in the order DeviceImages, DeviceBuffers, Staging, ReBar, Transient, Dedicated) and + `heaps` (this allocator's bytes and the budget, per memory heap). ## 4. Still owed from P4, to be closed in this matrix From c9d1314b94dc0a92ac93f72ccf36e5ee83b737ab Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:35:23 +0200 Subject: [PATCH 098/226] wip(phase1b-step5): pool classes + budget verified - Release solution build 0 errors, Optimum.Tests 1070/0 failed, GPU suite 462/462 under sync,best, AllocatorPolicyTests 6/6 (static mesh device-local off ReBAR, dedicated requirement, ReBAR cap and counted fall-through, 120-frame hysteresis, heap report) --- Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs b/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs index 1ba5397c..95a5eb3e 100644 --- a/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs +++ b/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs @@ -232,6 +232,7 @@ public unsafe void TheDedicatedRequirementAndTheQuarterBlockRuleGiveOwnBlocks() } ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); } } From 76766436806ed22970bafc8bc8e9b79396b6345a Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:43:21 +0200 Subject: [PATCH 099/226] wip(phase1a-step5): last seam sites are platform virtuals; IOptimumGraphicsDevice and OptimumRender.Device deleted Leaf virtuals injected on ClientPlatformAbstract (neutral), GL overrides in ClientPlatformWindows (the verbatim lines the sites issued), device overrides in VulkanClientPlatform.Leaf.cs, all in the patcher's inject lists and in ExpectedVirtuals: SetDepthRange, ClearDefaultDepth, DeleteMeshHandle, DeleteVertexArrayHandles, SetTextureLodBias, SetSamplerLodBias, SetTextureDepthCompare, ClearTextureRegion, LoadTextureFromRgbaPointer, SetProgramSamplerUnit, CreateOitTargets, BeginOitAccumulation, BindOitTextures, Gen/Begin/End/TryGetResult/Delete OcclusionQuery, ReadDefaultFramebuffer, GraphicsBackendName. ScreenManager, ClientMain, VAO, DisposeIndexBuffer, ChunkRenderer, ShaderRegistry, SystemRenderFrameBufferDebug (injected helper removed), SvgLoader, InventoryItemRenderer, ClientSystemStartup, Screenshot, SystemRenderSunMoon and SystemRenderOITLayers (SetOptimumOitSampling removed; RestoreVanillaTransparentState uses ApplyTransparentPassBlendState) call the platform. Contracts: the interface and OptimumRender.Device are gone; OptimumRender keeps ActiveBackend, FallbackReason, IsVulkan (now ActiveBackend == Vulkan), FallBackToOpenGL, NoGraphicsApiWindow. The forks (clouds, world map, boat water mask) reference only API + contracts, so they reach the device through OptimumForkGraphics.Active, a bridge limited to the 18 calls they make, published by VulkanClientPlatform (VulkanForkGraphics) and withdrawn before teardown. VulkanDevice implements IDisposable only; every GPU test uses VulkanDevice / platform.GraphicsDevice. Tests: platform-seam-deletion-coverage-tests (no seam name under build/VintagestoryLib, forks, API, contracts, sources, patches; each leaf site calls the platform; each virtual has GL + device bodies, is injected on both types and self-checked), client-platform-windows-vanilla-regions-tests (member-level diff against _ref limited to the listed owned regions), GPU PlatformLeafRoutingTests (texture upload + region clear + fork-bridge framebuffer readback, depth clear clamp, multi-frame occlusion queries, OIT targets and accumulation clears, backend/bridge install lifecycle). Ten existing pins re-pointed at the platform overrides. Verified: dotnet build VintageStory.slnx -c Release 0 errors; Optimum.Tests 1125 passed, 0 failed; Optimum.Render.Vulkan.Tests 427 passed, 0 failed (sync,best); extract + check-patches 0 conflict, 0 pending, runtime donors ok; Cecil patch (output bin/patch-check) 197/197 required methods, Virtual dispatch verifier ok (25 callvirt, 0 call/ldftn); API patch ok; check-vanilla-compat ok. --- Optimum.Patcher/Program.cs | 57 ++- .../ChunkTerrainRenderTests.cs | 28 +- .../FrameTimelinePacingTests.cs | 2 +- Optimum.Render.Vulkan.Tests/GpuTest.cs | 4 +- .../PacingStatsTests.cs | 4 +- .../ParityDumpTests.cs | 4 +- .../PlatformDeviceRoutingTests.cs | 3 +- .../PlatformLeafRoutingTests.cs | 375 +++++++++++++++ .../PlatformProgramRoutingTests.cs | 14 +- .../PlatformSubstitutionTests.cs | 6 +- Optimum.Render.Vulkan.Tests/SwapchainTests.cs | 10 +- .../TaaEntityMotionWriterTests.cs | 30 +- .../TaaInstancedMotionWriterTests.cs | 26 +- .../TaaLiquidMotionTests.cs | 22 +- .../TaaMotionWriterTests.cs | 26 +- .../TaaMoverMotionTests.cs | 28 +- .../TaaParticleMotionTests.cs | 32 +- .../TaaResolveTests.cs | 2 +- .../TaaSkyMotionTests.cs | 20 +- .../TaaStandardMotionWriterTests.cs | 28 +- .../VulkanDeviceIntegrationTests.cs | 54 +-- .../Optimum.Render.Vulkan.csproj | 6 +- .../Platform/VulkanClientPlatform.Leaf.cs | 206 ++++++++ .../Platform/VulkanClientPlatform.cs | 43 +- .../Platform/VulkanForkGraphics.cs | 67 +++ Optimum.Render.Vulkan/VulkanDevice.cs | 5 +- ...-platform-windows-vanilla-regions-tests.cs | 238 ++++++++++ Optimum.Tests/fsr-pipeline-coverage-tests.cs | 11 +- .../oit-framebuffer-rebuild-coverage-tests.cs | 34 +- Optimum.Tests/parity-dump-coverage-tests.cs | 3 +- ...tform-device-branch-move-coverage-tests.cs | 3 +- .../platform-seam-deletion-coverage-tests.cs | 253 ++++++++++ Optimum.Tests/taa-sharpen-coverage-tests.cs | 21 +- .../temporal-render-inventory-tests.cs | 13 +- VintageStory.slnx | 2 +- .../Newclouds/CloudRendererMap.cs.patch | 82 ++-- .../CloudRendererVolumetric.cs.patch | 12 +- .../BehaviorHideWaterSurface.cs.patch | 12 +- .../ChunkRenderer.cs.patch | 94 ++-- .../ClientMain.cs.patch | 23 +- .../ClientPlatformAbstract.cs.patch | 116 ++++- .../ClientPlatformWindows.cs.patch | 271 +++++++++-- .../ClientSystemStartup.cs.patch | 12 +- .../InventoryItemRenderer.cs.patch | 32 +- .../ShaderRegistry.cs.patch | 31 +- .../SvgLoader.cs.patch | 38 +- .../SystemRenderFrameBufferDebug.cs.patch | 44 +- .../SystemRenderOITLayers.cs.patch | 183 ++----- .../SystemRenderSunMoon.cs.patch | 95 +--- .../Vintagestory.Client.NoObf/VAO.cs.patch | 72 ++- .../ScreenManager.cs.patch | 19 +- .../Screenshot.cs.patch | 17 +- .../WorldMap/ChunkLayer/OptimumBc7Support.cs | 4 +- .../ChunkLayer/OptimumMapPageRenderer.cs | 12 +- .../ChunkLayer/OptimumMapTextureArray.cs | 24 +- .../Client/optimum-render-bootstrap.cs | 4 +- .../Client/optimum-render-device.cs | 448 +++--------------- 57 files changed, 2157 insertions(+), 1168 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs create mode 100644 Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs create mode 100644 Optimum.Tests/platform-seam-deletion-coverage-tests.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 0ab11ba8..cd26d8ca 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -132,6 +132,31 @@ "ApplyOptimumMotionAccumulateBlendState", "SelectFsrDrawBuffer", "ReadTextureForParity", + // Phase 1A step 5: the leaf operations the render systems outside the platform + // issued (ScreenManager, ClientMain, VAO, ChunkRenderer, ShaderRegistry, the debug + // overlay, SvgLoader, InventoryItemRenderer, the OIT layers, the sun occlusion + // probe, Screenshot, ClientSystemStartup). Neutral bodies; ClientPlatformWindows + // overrides them with the GL lines and VulkanClientPlatform with device calls. + "SetDepthRange", + "ClearDefaultDepth", + "DeleteMeshHandle", + "DeleteVertexArrayHandles", + "SetTextureLodBias", + "SetSamplerLodBias", + "SetTextureDepthCompare", + "ClearTextureRegion", + "LoadTextureFromRgbaPointer", + "SetProgramSamplerUnit", + "CreateOitTargets", + "BeginOitAccumulation", + "BindOitTextures", + "GenOcclusionQuery", + "BeginOcclusionQuery", + "EndOcclusionQuery", + "TryGetOcclusionQueryResult", + "DeleteOcclusionQuery", + "ReadDefaultFramebuffer", + "GraphicsBackendName", }, ["Vintagestory.Client.ClientProgram"] = new() { @@ -232,6 +257,27 @@ "EndFrame", "ProbeThickLineSupport", "ReadTextureForParity", + // Phase 1A step 5: the GL halves of the leaf operations. + "SetDepthRange", + "ClearDefaultDepth", + "DeleteMeshHandle", + "DeleteVertexArrayHandles", + "SetTextureLodBias", + "SetSamplerLodBias", + "SetTextureDepthCompare", + "ClearTextureRegion", + "LoadTextureFromRgbaPointer", + "SetProgramSamplerUnit", + "CreateOitTargets", + "BeginOitAccumulation", + "BindOitTextures", + "GenOcclusionQuery", + "BeginOcclusionQuery", + "EndOcclusionQuery", + "TryGetOcclusionQueryResult", + "DeleteOcclusionQuery", + "ReadDefaultFramebuffer", + "GraphicsBackendName", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumGlR32f", @@ -364,17 +410,6 @@ "RestoreVanillaTransparentState", "DisableOptimumOit", }, - // Vulkan backend: the shared sampling setup for the two OIT targets. - ["Vintagestory.Client.NoObf.SystemRenderOITLayers/BeforeOIT"] = new() - { - "SetOptimumOitSampling", - }, - // Vulkan backend: shadow maps are sampled as plain depth by the debug - // overlay, which means toggling the compare mode off and back on. - ["Vintagestory.Client.NoObf.SystemRenderFrameBufferDebug"] = new() - { - "SetOptimumDepthCompare", - }, // Settings tab: inject the field, callbacks, and hook helper ["Vintagestory.Client.NoObf.GuiCompositeSettings"] = new() { diff --git a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs index 087ad378..6df17d3d 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs @@ -141,7 +141,7 @@ public unsafe void TheTopsoilShaderSamplesTheGrassTileWithPackedSecondaryUvs(boo Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var variant = ShaderCorpus.Variants().First(); variant.UseSsbo = ssbo ? 1 : 0; int program = LinkFromCorpus(seam, ShaderCorpus.BuildProgram("chunktopsoil", @@ -230,7 +230,7 @@ public unsafe void TheRealChunkShaderDrawsTesselatedTerrain() using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -324,7 +324,7 @@ public void TheShadowMapProgramDrawsTerrainIntoADepthOnlyTarget() using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -393,7 +393,7 @@ public unsafe void TheSsboChunkPathDrawsAFaceFromAPackedRecordWithCullingOn() using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -533,7 +533,7 @@ public unsafe void AFaceRecordSamplesTheAtlasWhereItsPackedUvPointsTo() using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -679,7 +679,7 @@ public unsafe void AFaceRecordWithANegativeUvSpanStaysOnItsOwnBlockTexture() using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -817,7 +817,7 @@ public unsafe void RebindingAnAtlasBetweenDrawsChangesWhatTheSecondDrawSamples() using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -918,7 +918,7 @@ int SolidPage(byte r, byte g, byte b) } } - private static unsafe byte[] ReadTarget(IOptimumGraphicsDevice seam, int framebuffer) + private static unsafe byte[] ReadTarget(VulkanDevice seam, int framebuffer) { var pixels = new byte[Size * Size * 4]; fixed (byte* destination = pixels) @@ -933,7 +933,7 @@ private static bool IsClearColour(byte[] pixels, int offset) => pixels[offset] >= 250 && pixels[offset + 1] <= 5 && pixels[offset + 2] >= 250; private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new Program { PassName = name }; @@ -967,7 +967,7 @@ private static int LinkFromCorpus( /// /// The first texture unit the program did not claim. private static unsafe int BindEveryDeclaredSampler( - VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + VulkanDevice device, VulkanDevice seam, int programId) { var white = new byte[] { 255, 255, 255, 255 }; int unit = 0; @@ -998,7 +998,7 @@ private static unsafe int BindEveryDeclaredSampler( /// The client sets them each frame; a test that does not is testing a /// configuration the game never runs. /// - private static void SetViewUniforms(IOptimumGraphicsDevice seam, int programId) + private static void SetViewUniforms(VulkanDevice seam, int programId) { SetFloat(seam, programId, "viewDistance", 1024f); SetFloat(seam, programId, "viewDistanceLod0", 1024f); @@ -1017,7 +1017,7 @@ private static void SetViewUniforms(IOptimumGraphicsDevice seam, int programId) if (frameSize >= 0) seam.SetUniform(programId, frameSize, (float)Size, (float)Size); } - private static void SetFloat(IOptimumGraphicsDevice seam, int programId, string name, float value) + private static void SetFloat(VulkanDevice seam, int programId, string name, float value) { int location = seam.GetUniformLocation(programId, name); if (location >= 0) seam.SetUniform(programId, location, value); @@ -1027,7 +1027,7 @@ private static void SetFloat(IOptimumGraphicsDevice seam, int programId, string /// The matrices every chunk program multiplies by. Identity leaves the mesh's /// clip-space positions alone, which is what makes the output checkable. /// - private static void SetIdentityMatrices(IOptimumGraphicsDevice seam, int programId) + private static void SetIdentityMatrices(VulkanDevice seam, int programId) { float[] identity = { @@ -1048,5 +1048,5 @@ private static void SetIdentityMatrices(IOptimumGraphicsDevice seam, int program } } - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); } diff --git a/Optimum.Render.Vulkan.Tests/FrameTimelinePacingTests.cs b/Optimum.Render.Vulkan.Tests/FrameTimelinePacingTests.cs index 1cf1eb0e..e2afcbdf 100644 --- a/Optimum.Render.Vulkan.Tests/FrameTimelinePacingTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameTimelinePacingTests.cs @@ -31,7 +31,7 @@ public unsafe void HundredFramesWithDeferredDeletesPaceOnceEachAndStayClean() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 4; const int frames = 100; diff --git a/Optimum.Render.Vulkan.Tests/GpuTest.cs b/Optimum.Render.Vulkan.Tests/GpuTest.cs index 4442f3f1..bd37648e 100644 --- a/Optimum.Render.Vulkan.Tests/GpuTest.cs +++ b/Optimum.Render.Vulkan.Tests/GpuTest.cs @@ -95,7 +95,7 @@ public static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? d } /// The layer messages a device from has recorded so far. - public static List MessagesOf(IOptimumGraphicsDevice seam) => + public static List MessagesOf(VulkanDevice seam) => seam is VulkanDevice device && DeviceMessages.TryGetValue(device, out List? messages) ? messages : new List(); @@ -107,7 +107,7 @@ public static List MessagesOf(IOptimumGraphicsDevice seam) => /// reports as an error through GetError (failed Vulkan calls, rejected /// shaders) fails too. /// - public static void AssertClean(IOptimumGraphicsDevice seam, [CallerFilePath] string callerFile = "") + public static void AssertClean(VulkanDevice seam, [CallerFilePath] string callerFile = "") { List messages = MessagesOf(seam); ValidationAssert.NoErrors(messages); diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index ca8304f2..ab381871 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -350,7 +350,7 @@ public unsafe void TextureUploadInsideAFrameBlocksOnTheUploadSiteUntilPhase1B() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 4; int texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); @@ -412,7 +412,7 @@ public unsafe void AFrameSubmitIsCountedAtTheQueueSubmitSite() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 4; int texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); diff --git a/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs b/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs index ea2921ae..482aa6ef 100644 --- a/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs +++ b/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs @@ -15,7 +15,7 @@ namespace Optimum.Render.Vulkan.Tests; /// The Vulkan half of the per-attachment parity dump (OPTIMUM_PARITY_DUMP): /// known patterns rendered into the formats the framebuffer list actually uses - /// RGBA8, RGBA16F, R32F and depth - are read back through -/// , written by the +/// , written by the /// shared writer, and decoded from the files. /// /// Every value is checked against the fragment that produced it, in GL row @@ -67,7 +67,7 @@ public void RenderedPatternsDumpAndDecodeBackInGlRowOrder() var files = new Dictionary(); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = VulkanDeviceIntegrationTests.LinkProgram(seam, Vertex, Fragment, "parity-dump"); int rgba8 = seam.CreateTexture2D(Width, Height, EnumTextureInternalFormat.Rgba8, diff --git a/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs index 2b84af5f..cd6b2daa 100644 --- a/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs +++ b/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs @@ -90,8 +90,7 @@ public unsafe void FramebufferClearScissorAndDrawReachTheDeviceThroughThePlatfor bool installed = platform.InitializeGraphics(IntPtr.Zero, 0, 0, out string reason); if (!installed) _output.WriteLine("Vulkan unavailable: " + reason); Skip.IfNot(installed, "No usable Vulkan device."); - IOptimumGraphicsDevice seam = OptimumRender.Device!; - Assert.Same(seam, platform.GraphicsDevice); + VulkanDevice seam = platform.GraphicsDevice!; const int size = 16; var attrs = new FramebufferAttrs("routed", size, size) diff --git a/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs new file mode 100644 index 00000000..3eb138ce --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs @@ -0,0 +1,375 @@ +using System; +using System.IO; +using Optimum.Render.Vulkan.Platform; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 5: the leaf operations the render systems outside the +/// platform used to send through the static device seam are platform virtuals, and the seam +/// is gone. These drive them through VulkanClientPlatform with no GL context - an override +/// that is missing lands in the ClientPlatformWindows GL body and throws - and read back what +/// the device made of them. The fork bridge the forked mods use is exercised the same way. +/// +public class PlatformLeafRoutingTests +{ + private readonly ITestOutputHelper _output; + + public PlatformLeafRoutingTests(ITestOutputHelper output) => _output = output; + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + private sealed class Session : IDisposable + { + private readonly string _dataPath; + + public VulkanClientPlatform Platform { get; } + + public VulkanDevice Seam => Platform.GraphicsDevice!; + + private Session(VulkanClientPlatform platform, string dataPath) + { + Platform = platform; + _dataPath = dataPath; + } + + public static Session? TryOpen(ITestOutputHelper output) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-leaf-routing-test-" + Guid.NewGuid().ToString("N")); + var platform = new VulkanClientPlatform(null!) + { + DeviceFactory = GpuTest.NewDevice, + CrashMarkerDataPath = dataPath, + }; + if (!platform.InitializeGraphics(IntPtr.Zero, 0, 0, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + return new Session(platform, dataPath); + } + + public void Dispose() + { + Platform.ShutdownGraphics(); + try + { + Directory.Delete(_dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + } + + /// + /// The backend state and the fork bridge follow the platform's graphics: published by + /// InitializeGraphics, withdrawn by ShutdownGraphics. + /// + [SkippableFact] + public void BackendNameAndForkBridgeFollowTheInstall() + { + Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + try + { + Assert.Equal("Vulkan", session!.Platform.GraphicsBackendName); + Assert.True(OptimumRender.IsVulkan); + Assert.NotNull(OptimumForkGraphics.Active); + } + finally + { + session!.Dispose(); + } + Assert.Null(OptimumForkGraphics.Active); + Assert.False(OptimumRender.IsVulkan); + Assert.Null(session.Platform.GraphicsDevice); + } + + /// + /// LoadTextureFromRgbaPointer uploads a solid colour, ClearTextureRegion blanks its left + /// half, the fork bridge builds and binds a framebuffer over it, and ReadDefaultFramebuffer + /// reads the bound target back: left half zero, right half the uploaded colour. The LOD, + /// sampler-LOD and depth-compare setters and the depth range run on the same texture with + /// no GL context, so a missing override would throw. + /// + [SkippableFact] + public unsafe void TextureUploadRegionClearAndReadbackReachTheDevice() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanClientPlatform platform = session!.Platform; + OptimumForkGraphics fork = OptimumForkGraphics.Active!; + const int size = 16; + + var rgba = new byte[size * size * 4]; + for (int i = 0; i < rgba.Length; i += 4) + { + rgba[i] = 10; + rgba[i + 1] = 200; + rgba[i + 2] = 30; + rgba[i + 3] = 255; + } + int texture; + fixed (byte* pixels = rgba) + { + texture = platform.LoadTextureFromRgbaPointer(size, size, (IntPtr)pixels); + } + Assert.True(texture > 0); + platform.ClearTextureRegion(texture, 0, 0, size / 2, size, new int[size / 2 * size]); + + platform.SetTextureLodBias(new[] { texture }, -0.5f); + platform.SetTextureDepthCompare(texture, 0); + int sampler = session.Seam.CreateSampler(true); + platform.SetSamplerLodBias(sampler, 0.25f); + platform.SetDepthRange(0f, 20000f); + + int framebuffer = fork.CreateFramebuffer(size, size); + fork.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + fork.SetDrawBuffers(framebuffer, 1); + + var read = new byte[size * size * 4]; + platform.BeginFrame(); + fork.BindFramebuffer(framebuffer); + fork.SetViewport(0, 0, size, size); + fixed (byte* destination = read) + { + platform.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + platform.EndFrame(); + + int row = size / 2 * size; + int left = (row + 2) * 4; + int right = (row + size - 3) * 4; + _output.WriteLine($"left RGBA = {read[left]}, {read[left + 1]}, {read[left + 2]}, {read[left + 3]}"); + _output.WriteLine($"right RGBA = {read[right]}, {read[right + 1]}, {read[right + 2]}, {read[right + 3]}"); + Assert.Equal(new byte[] { 0, 0, 0, 0 }, read[left..(left + 4)]); + Assert.Equal(new byte[] { 10, 200, 30, 255 }, read[right..(right + 4)]); + + fork.DeleteFramebuffer(framebuffer); + platform.GLDeleteTexture(texture); + session.Seam.DeleteSampler(sampler); + GpuTest.AssertClean(session.Seam); + } + + /// + /// ClearDefaultDepth clears the bound target's depth like glClearBuffer: 0.25 stays 0.25, + /// and ScreenManager's 20000 clamps to 1. + /// + [SkippableFact] + public void ClearDefaultDepthClampsLikeGl() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanClientPlatform platform = session!.Platform; + VulkanDevice seam = session.Seam; + const int size = 8; + + int colour = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int depth = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 1); + + float Cleared(float value) + { + platform.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, size, size); + seam.SetDepthMask(true); + platform.ClearDefaultDepth(value); + platform.EndFrame(); + // The parity readback only runs inside a frame; the next one sees the clear presented. + platform.BeginFrame(); + OptimumTextureReadback? readback = seam.ReadTextureForParity(depth); + platform.EndFrame(); + Assert.NotNull(readback); + Assert.NotNull(readback!.Floats); + return readback.Floats![size / 2 * size + size / 2]; + } + + float quarter = Cleared(0.25f); + float clamped = Cleared(20000f); + _output.WriteLine("depth after 0.25 = " + quarter + ", after 20000 = " + clamped); + Assert.Equal(0.25f, quarter, 5); + Assert.Equal(1f, clamped, 5); + + seam.DeleteFramebuffer(framebuffer); + seam.DeleteTexture(colour); + seam.DeleteTexture(depth); + GpuTest.AssertClean(seam); + } + + /// + /// The sun probe's query protocol through the platform over several presented frames: + /// GenOcclusionQuery, Begin, a fullscreen draw, End, then TryGetOcclusionQueryResult polled + /// each later frame until it reports - and it reports samples. A second query around no + /// draw reports zero. Present runs between frames and nothing reads back inside the loop. + /// + [SkippableFact] + public void OcclusionQueriesCountSamplesAcrossFrames() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanClientPlatform platform = session!.Platform; + VulkanDevice seam = session.Seam; + const int size = 16; + + int colour = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.SetDrawBuffers(framebuffer, 1); + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4(1.0); } + """, "leaf-occlusion"); + + int drawn = platform.GenOcclusionQuery(); + int empty = platform.GenOcclusionQuery(); + Assert.True(drawn > 0 && empty > 0 && drawn != empty); + + platform.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, size, size); + platform.GlDisableDepthTest(); + platform.GlDisableCullFace(); + platform.GlToggleBlend(false); + platform.GlColorMask(false, false, false, false); + platform.UseShaderProgram(program); + platform.BeginOcclusionQuery(drawn); + platform.RenderFullscreenTriangle(null!); + platform.EndOcclusionQuery(drawn); + platform.BeginOcclusionQuery(empty); + platform.EndOcclusionQuery(empty); + platform.UseShaderProgram(0); + platform.GlColorMask(true, true, true, true); + platform.EndFrame(); + + bool drawnReported = false; + bool emptyReported = false; + int drawnSamples = 0; + int emptySamples = -1; + for (int frame = 0; frame < 60 && !(drawnReported && emptyReported); frame++) + { + platform.BeginFrame(); + if (!drawnReported) drawnReported = platform.TryGetOcclusionQueryResult(drawn, out drawnSamples); + if (!emptyReported) emptyReported = platform.TryGetOcclusionQueryResult(empty, out emptySamples); + platform.EndFrame(); + } + + _output.WriteLine("drawn: " + drawnReported + " " + drawnSamples + ", empty: " + emptyReported + " " + emptySamples); + Assert.True(drawnReported, "the drawn query never reported within 60 frames"); + Assert.True(emptyReported, "the empty query never reported within 60 frames"); + Assert.True(drawnSamples > 0); + Assert.Equal(0, emptySamples); + + platform.DeleteOcclusionQuery(drawn); + platform.DeleteOcclusionQuery(empty); + seam.DeleteProgram(program); + seam.DeleteFramebuffer(framebuffer); + seam.DeleteTexture(colour); + GpuTest.AssertClean(seam); + } + + /// + /// The OIT targets and pass state through the platform: CreateOitTargets attaches the + /// reveal texture at 0 and the three-layer accumulation array at 3-5 of a three-attachment + /// transparent framebuffer, BeginOitAccumulation enables all six draw buffers and clears + /// 0 and 1 to one and 3-5 to zero, BindOitTextures binds units 6 and 7. The reveal target + /// and the framebuffer's own attachment 1 read back as one; its attachment 2, outside the + /// clear set, keeps its earlier clear colour. + /// + [SkippableFact] + public void OitTargetsAndAccumulationClearsReachTheDevice() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanClientPlatform platform = session!.Platform; + VulkanDevice seam = session.Seam; + const int size = 8; + + RawTexture Colour() => new() + { + Width = size, + Height = size, + PixelInternalFormat = EnumTextureInternalFormat.Rgba8, + PixelFormat = EnumTexturePixelFormat.Rgba, + MinFilter = EnumTextureFilter.Nearest, + MagFilter = EnumTextureFilter.Nearest, + }; + var attrs = new FramebufferAttrs("transparent", size, size) + { + Attachments = new[] + { + new FramebufferAttrsAttachment { AttachmentType = EnumFramebufferAttachment.ColorAttachment0, Texture = Colour() }, + new FramebufferAttrsAttachment { AttachmentType = EnumFramebufferAttachment.ColorAttachment1, Texture = Colour() }, + new FramebufferAttrsAttachment { AttachmentType = EnumFramebufferAttachment.ColorAttachment2, Texture = Colour() }, + }, + }; + int oitProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D OITreveal; + uniform sampler2DArray OITaccumulation; + out vec4 outColor; + void main(void) { outColor = texture(OITreveal, vec2(0.5)) + texture(OITaccumulation, vec3(0.5, 0.5, 0.0)); } + """, "leaf-oit"); + + platform.BeginFrame(); + FrameBufferRef transparent = platform.CreateFramebuffer(attrs); + platform.CurrentFrameBuffer = transparent; + platform.ClearFrameBuffer(transparent, new[] { 40f / 255f, 80f / 255f, 120f / 255f, 1f }, clearDepthBuffer: false); + platform.EndFrame(); + + platform.CreateOitTargets(transparent, 3, out int reveal, out int accum); + Assert.True(reveal > 0 && accum > 0 && reveal != accum); + + platform.BeginFrame(); + platform.CurrentFrameBuffer = transparent; + platform.SetProgramSamplerUnit(oitProgram, "OITaccumulation", 7); + platform.BeginOitAccumulation(transparent); + platform.BindOitTextures(reveal, accum); + platform.EndFrame(); + + byte[] Centre(int textureId) + { + OptimumTextureReadback? readback = seam.ReadTextureForParity(textureId); + Assert.NotNull(readback); + Assert.NotNull(readback!.Bytes); + int offset = (size / 2 * size + size / 2) * 4; + return readback.Bytes![offset..(offset + 4)]; + } + + // The parity readback only runs inside a frame; this one follows the presented clears. + platform.BeginFrame(); + byte[] revealTexel = Centre(reveal); + byte[] second = Centre(transparent.ColorTextureIds[1]); + byte[] third = Centre(transparent.ColorTextureIds[2]); + platform.EndFrame(); + _output.WriteLine("reveal = " + string.Join(",", revealTexel) + "; colour1 = " + string.Join(",", second) + "; colour2 = " + string.Join(",", third)); + Assert.Equal(new byte[] { 255, 255, 255, 255 }, revealTexel); + Assert.Equal(new byte[] { 255, 255, 255, 255 }, second); + Assert.Equal(new byte[] { 40, 80, 120, 255 }, third); + + platform.GLDeleteTexture(accum); + platform.GLDeleteTexture(reveal); + platform.DisposeFrameBuffer(transparent); + seam.DeleteProgram(oitProgram); + GpuTest.AssertClean(seam); + } +} diff --git a/Optimum.Render.Vulkan.Tests/PlatformProgramRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformProgramRoutingTests.cs index 02c1f959..7590e524 100644 --- a/Optimum.Render.Vulkan.Tests/PlatformProgramRoutingTests.cs +++ b/Optimum.Render.Vulkan.Tests/PlatformProgramRoutingTests.cs @@ -47,7 +47,7 @@ private sealed class Session : IDisposable private readonly string _dataPath; public VulkanClientPlatform Platform { get; } - public IOptimumGraphicsDevice Seam => OptimumRender.Device!; + public VulkanDevice Seam => Platform.GraphicsDevice!; private Session(VulkanClientPlatform platform, ClientPlatformAbstract? previous, string dataPath) { @@ -97,7 +97,7 @@ private static void TryDelete(string path) } } - private static int ColourTarget(IOptimumGraphicsDevice seam, int size) + private static int ColourTarget(VulkanDevice seam, int size) { int texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); @@ -107,7 +107,7 @@ private static int ColourTarget(IOptimumGraphicsDevice seam, int size) return framebuffer; } - private static void BeginDraw(IOptimumGraphicsDevice seam, int framebuffer, int size) + private static void BeginDraw(VulkanDevice seam, int framebuffer, int size) { seam.BeginFrame(); seam.BindFramebuffer(framebuffer); @@ -118,7 +118,7 @@ private static void BeginDraw(IOptimumGraphicsDevice seam, int framebuffer, int seam.SetBlend(false, EnumBlendMode.Standard); } - private static unsafe byte[] ReadCentre(IOptimumGraphicsDevice seam, int framebuffer, int size, bool openFrame) + private static unsafe byte[] ReadCentre(VulkanDevice seam, int framebuffer, int size, bool openFrame) { var pixels = new byte[size * size * 4]; if (openFrame) seam.BeginFrame(); @@ -142,7 +142,7 @@ public void UniformsSetOnAShaderProgramReachTheShaderThroughThePlatform() { using Session? session = Session.TryOpen(_output); Skip.If(session == null, "No usable Vulkan device."); - IOptimumGraphicsDevice seam = session!.Seam; + VulkanDevice seam = session!.Seam; const int size = 16; var program = new RoutedProgram { PassName = "routed-uniforms" }; @@ -207,7 +207,7 @@ public void EveryUboUpdatePathReachesTheBlockThroughThePlatform() { using Session? session = Session.TryOpen(_output); Skip.If(session == null, "No usable Vulkan device."); - IOptimumGraphicsDevice seam = session!.Seam; + VulkanDevice seam = session!.Seam; const int size = 16; var program = new RoutedProgram { PassName = "routed-ubo" }; @@ -271,7 +271,7 @@ public unsafe void ATextureBoundOnAShaderProgramIsSampledThroughThePlatform() { using Session? session = Session.TryOpen(_output); Skip.If(session == null, "No usable Vulkan device."); - IOptimumGraphicsDevice seam = session!.Seam; + VulkanDevice seam = session!.Seam; const int size = 16; var program = new RoutedProgram { PassName = "routed-texture" }; diff --git a/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs b/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs index 3ff46862..da786526 100644 --- a/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs +++ b/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs @@ -89,7 +89,7 @@ public void AForcedInstallFailureReturnsFalseWithTheReason() Assert.False(installed); Assert.Equal("forced by OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE", reason); Assert.Equal(0, created); - Assert.Null(OptimumRender.Device); + Assert.Null(platform.GraphicsDevice); } finally { @@ -119,7 +119,7 @@ public unsafe void InitializeGraphicsPublishesARenderingDeviceAndShutdownRetires if (!installed) _output.WriteLine("Vulkan unavailable: " + reason); Skip.IfNot(installed, "No usable Vulkan device."); - IOptimumGraphicsDevice? seam = OptimumRender.Device; + VulkanDevice? seam = platform.GraphicsDevice; Assert.IsType(seam); Assert.Equal(EnumRenderBackend.Vulkan, OptimumRender.ActiveBackend); Assert.True(File.Exists(marker), "the crash marker is written before the driver is touched"); @@ -147,7 +147,7 @@ public unsafe void InitializeGraphicsPublishesARenderingDeviceAndShutdownRetires GpuTest.AssertClean(seam); platform.ShutdownGraphics(); - Assert.Null(OptimumRender.Device); + Assert.Null(platform.GraphicsDevice); Assert.Equal(EnumRenderBackend.OpenGL, OptimumRender.ActiveBackend); Assert.False(File.Exists(marker), "a clean shutdown clears the crash marker"); } diff --git a/Optimum.Render.Vulkan.Tests/SwapchainTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs index 79f163a5..444c9861 100644 --- a/Optimum.Render.Vulkan.Tests/SwapchainTests.cs +++ b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs @@ -85,7 +85,7 @@ public unsafe void ADeviceComesUpAgainstARealWindowAndPresentsFrames() using (device) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; _output.WriteLine($"presenting on {seam.RendererString}"); int programId = LinkFullscreenProgram(seam); @@ -141,7 +141,7 @@ public unsafe void ResizingRebuildsTheChainAndKeepsPresenting() using (device) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; int programId = LinkFullscreenProgram(seam); void RenderFrames(int count, int w, int h) @@ -200,7 +200,7 @@ public unsafe void TogglingVsyncRebuildsTheChainCleanly() using (device) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; int programId = LinkFullscreenProgram(seam); foreach (bool vsync in new[] { false, true, false }) @@ -236,7 +236,7 @@ private sealed class TestShader : IShader public bool Compile() => true; } - private static int LinkFullscreenProgram(IOptimumGraphicsDevice device) + private static int LinkFullscreenProgram(VulkanDevice device) { var vertex = new TestShader { @@ -311,5 +311,5 @@ public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { public bool HasUniform(string uniformName) => false; } - private static void AssertClean(IOptimumGraphicsDevice device) => GpuTest.AssertClean(device); + private static void AssertClean(VulkanDevice device) => GpuTest.AssertClean(device); } diff --git a/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs index aee5d5f4..570d5201 100644 --- a/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs @@ -276,7 +276,7 @@ private unsafe Decoded RenderEntityMotion( float cameraDeltaY, float previousGlobalWarp) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -385,7 +385,7 @@ private unsafe Decoded RenderEntityMotion( decoded[offset + 2] / 255f); } - private static unsafe void WriteBone(IOptimumGraphicsDevice seam, int ubo, float[] matrix) + private static unsafe void WriteBone(VulkanDevice seam, int ubo, float[] matrix) { // Only joint 0 is referenced by the mesh below; the rest of the block // stays zero, which is what a shader that read the wrong joint would show. @@ -399,7 +399,7 @@ private static unsafe void WriteBone(IOptimumGraphicsDevice seam, int ubo, float /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because /// the seam's readback is fixed at four bytes per pixel from attachment 0. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -519,7 +519,7 @@ private static MeshData BuildSkinnedQuad() /// motion vector, and the test would read the cleared attachment instead. /// alphaTest is pushed below zero so nothing can discard at all. /// - private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) + private static void SetSceneUniforms(VulkanDevice seam, int program) { SetFloat(seam, program, "alphaTest", -1f); SetFloat(seam, program, "viewDistance", 1024f); @@ -551,7 +551,7 @@ private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) /// Both halves of the warp state, pinned so this frame's warp is a no-op and /// only the previous one moves anything. /// - private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, float previousGlobalWarp) + private static void SetWarpUniforms(VulkanDevice seam, int program, float previousGlobalWarp) { SetFloat(seam, program, "timeCounter", 0f); SetFloat(seam, program, "windWaveCounter", 0f); @@ -580,44 +580,44 @@ private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, fl SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } private static void SetFloat4( - IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + VulkanDevice seam, int program, string name, float x, float y, float z, float w) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z, w); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } - private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + private static unsafe int CreateWhiteTexture(VulkanDevice seam) { var white = new byte[] { 255, 255, 255, 255 }; fixed (byte* pixels = white) @@ -628,7 +628,7 @@ private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) } private static int BindEveryDeclaredSampler( - VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + VulkanDevice device, VulkanDevice seam, int programId) { int unit = 0; foreach (string samplerName in device.SamplerNamesOf(programId)) @@ -642,7 +642,7 @@ private static int BindEveryDeclaredSampler( } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -669,7 +669,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs index 05bbabd5..8a45804b 100644 --- a/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs @@ -250,7 +250,7 @@ public Decoded(float motionX, float motionY, float reactive, float writerDepth) private unsafe Decoded[] RenderInstancedMotion( VulkanDevice device, Instance[] instances, float cameraDeltaX, float cameraDeltaY) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -347,7 +347,7 @@ private unsafe Decoded[] RenderInstancedMotion( /// Unlike the other writers' harnesses this one carries the reactive channel /// too, because "no history" and "reactive" are one decision here. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -471,7 +471,7 @@ private static MeshData BuildInstancedQuad(Instance[] instances) /// a fragment below alphaTest is discarded before it can write a motion /// vector, and the test would read the cleared attachment instead. /// - private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) + private static void SetSceneUniforms(VulkanDevice seam, int program) { SetFloat(seam, program, "alphaTest", -1f); SetFloat(seam, program, "viewDistance", 1024f); @@ -504,44 +504,44 @@ private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) SetFloat(seam, program, "glitchWaviness", 0f); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } private static void SetFloat4( - IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + VulkanDevice seam, int program, string name, float x, float y, float z, float w) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z, w); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } - private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + private static unsafe int CreateWhiteTexture(VulkanDevice seam) { var white = new byte[] { 255, 255, 255, 255 }; fixed (byte* pixels = white) @@ -552,7 +552,7 @@ private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) } private static int BindEveryDeclaredSampler( - VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + VulkanDevice device, VulkanDevice seam, int programId) { int unit = 0; foreach (string samplerName in device.SamplerNamesOf(programId)) @@ -566,7 +566,7 @@ private static int BindEveryDeclaredSampler( } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -593,7 +593,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs index fcef7e44..6546bd97 100644 --- a/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs @@ -399,7 +399,7 @@ private unsafe Result RenderLiquidMotion( float previousWaterWaveIntensity = 0f, float[]? previousView = null) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -507,7 +507,7 @@ private unsafe Result RenderLiquidMotion( }; /// Colour attachment 0 of the scene target, read inside the frame. - private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) + private static unsafe byte[] ReadColour(VulkanDevice seam, int scene) { var pixels = new byte[Size * Size * 4]; fixed (byte* destination = pixels) @@ -524,7 +524,7 @@ private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) /// With the blue channel is put in red at full /// scale, so the 0.3 can be checked without the mv quantisation. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture, bool reactive) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture, bool reactive) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -664,7 +664,7 @@ private static MeshData BuildLiquidQuad(int waterFlags) /// whatever the block happens to hold on the device path. /// private static void SetWarpUniforms( - IOptimumGraphicsDevice seam, int program, float previousWaterWaveIntensity) + VulkanDevice seam, int program, float previousWaterWaveIntensity) { SetFloat(seam, program, "timeCounter", 0f); SetFloat(seam, program, "windWaveCounter", 0f); @@ -696,38 +696,38 @@ private static void SetWarpUniforms( SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -754,7 +754,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs index a7c0ed23..ad61cd54 100644 --- a/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs @@ -207,7 +207,7 @@ private unsafe Decoded RenderMotion( float previousGlobalWarp, bool sampleCorner = false) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -307,7 +307,7 @@ private unsafe Decoded RenderMotion( /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because /// the seam's readback is fixed at four bytes per pixel from attachment 0. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -407,7 +407,7 @@ private static MeshData BuildBlockFace() /// to be in the block on the device path, and this test's whole point is the /// difference between the two states. /// - private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, float previousGlobalWarp) + private static void SetWarpUniforms(VulkanDevice seam, int program, float previousGlobalWarp) { SetFloat(seam, program, "timeCounter", 0f); SetFloat(seam, program, "windWaveCounter", 0f); @@ -437,7 +437,7 @@ private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, fl SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); } - private static void SetViewUniforms(IOptimumGraphicsDevice seam, int program) + private static void SetViewUniforms(VulkanDevice seam, int program) { SetFloat(seam, program, "viewDistance", 1024f); SetFloat(seam, program, "viewDistanceLod0", 1024f); @@ -455,37 +455,37 @@ private static void SetViewUniforms(IOptimumGraphicsDevice seam, int program) SetFloat2(seam, program, "frameSize", Size, Size); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } - private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + private static unsafe int CreateWhiteTexture(VulkanDevice seam) { var white = new byte[] { 255, 255, 255, 255 }; fixed (byte* pixels = white) @@ -496,7 +496,7 @@ private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) } private static unsafe int BindEveryDeclaredSampler( - VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + VulkanDevice device, VulkanDevice seam, int programId) { int unit = 0; foreach (string samplerName in device.SamplerNamesOf(programId)) @@ -510,7 +510,7 @@ private static unsafe int BindEveryDeclaredSampler( } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -537,7 +537,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs index 14dba044..00a89e6a 100644 --- a/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs @@ -285,7 +285,7 @@ private unsafe Decoded RenderMoverMotion( float cameraDeltaX, float cameraDeltaY) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -385,7 +385,7 @@ private unsafe Decoded RenderMoverMotion( /// the seam's readback is fixed at four bytes per pixel from attachment 0, and /// reads it back inside the same frame. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -490,7 +490,7 @@ private static MeshData BuildQuad() /// cleared attachment instead. alphaTest is pushed below zero so nothing can /// discard at all, and dontWarpVertices is the block-entity value. /// - private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) + private static void SetSceneUniforms(VulkanDevice seam, int program) { SetInt(seam, program, "dontWarpVertices", WarpNone); SetInt(seam, program, "fadeFromSpheresFog", 0); @@ -532,7 +532,7 @@ private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program) /// file's expectations comes from vertex animation - a block-entity model /// passes "no warp" anyway, and P3 already covers the warp branches. /// - private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program) + private static void SetWarpUniforms(VulkanDevice seam, int program) { foreach (string prefix in new[] { "", "prev" }) { @@ -557,44 +557,44 @@ private static string Name(string prefix, string uniform) return prefix + char.ToUpperInvariant(uniform[0]) + uniform.Substring(1); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } private static void SetFloat4( - IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + VulkanDevice seam, int program, string name, float x, float y, float z, float w) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z, w); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } - private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + private static unsafe int CreateWhiteTexture(VulkanDevice seam) { var white = new byte[] { 255, 255, 255, 255 }; fixed (byte* pixels = white) @@ -605,7 +605,7 @@ private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) } private static int BindEveryDeclaredSampler( - VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + VulkanDevice device, VulkanDevice seam, int programId) { int unit = 0; foreach (string samplerName in device.SamplerNamesOf(programId)) @@ -619,7 +619,7 @@ private static int BindEveryDeclaredSampler( } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -646,7 +646,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs index d758c3f2..73397823 100644 --- a/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs @@ -347,7 +347,7 @@ private unsafe Result RenderCubeParticles( float jitterX = 0f, float jitterY = 0f) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -439,7 +439,7 @@ private unsafe Result RenderTransparentCompose( float seedReactive, float seedDepth) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -527,7 +527,7 @@ private unsafe Result RenderTransparentCompose( /// Colour, glow and an RGBA16F motion attachment at index 2 - what /// SetupDefaultFrameBuffers builds without the SSAO G-buffer. - private static (int Scene, int Motion) CreatePrimaryStandIn(IOptimumGraphicsDevice seam, out int colour) + private static (int Scene, int Motion) CreatePrimaryStandIn(VulkanDevice seam, out int colour) { colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); @@ -552,7 +552,7 @@ private static (int Scene, int Motion) CreatePrimaryStandIn(IOptimumGraphicsDevi } private static unsafe int SolidTexture( - IOptimumGraphicsDevice seam, float r, float g, float b, float a) + VulkanDevice seam, float r, float g, float b, float a) { var pixels = new byte[Size * Size * 4]; byte[] value = @@ -576,7 +576,7 @@ private static unsafe int SolidTexture( /// The opaque writer's stand-in: writes the motion attachment and /// nothing else, the way chunkliquidmotion does. - private static int SeedMotionProgram(IOptimumGraphicsDevice seam) + private static int SeedMotionProgram(VulkanDevice seam) { const string vertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -597,7 +597,7 @@ private static int SeedMotionProgram(IOptimumGraphicsDevice seam) }, "taa-particle-seed"); } - private static int FullscreenQuad(IOptimumGraphicsDevice seam) + private static int FullscreenQuad(VulkanDevice seam) { var quad = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) { @@ -612,7 +612,7 @@ private static int FullscreenQuad(IOptimumGraphicsDevice seam) } /// Colour attachment 0 of the scene target, read inside the frame. - private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) + private static unsafe byte[] ReadColour(VulkanDevice seam, int scene) { var pixels = new byte[Size * Size * 4]; fixed (byte* destination = pixels) @@ -629,7 +629,7 @@ private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) /// With the blue channel is put in red at full /// scale, so reactive can be checked without the mv quantisation. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture, bool reactive) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture, bool reactive) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -766,7 +766,7 @@ private static MeshData BuildParticleCube() /// path. Every intensity is zero, so neither the current nor the previous /// position is warped and the motion is the camera's alone. /// - private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program) + private static void SetWarpUniforms(VulkanDevice seam, int program) { SetFloat(seam, program, "timeCounter", 0f); SetFloat(seam, program, "windWaveCounter", 0f); @@ -795,38 +795,38 @@ private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program) SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -853,7 +853,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index 6865d0a4..5d1e301e 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -21,7 +21,7 @@ namespace Optimum.Render.Vulkan.Tests; /// MRT framebuffer (colour history RGBA16F, aux/glow RGBA8, linear depth R32F), /// draw the fullscreen triangle and read back inside the frame. /// -/// This goes one level lower than the seam (IOptimumGraphicsDevice): the +/// This goes one level lower than the device (VulkanDevice): the /// public seam's EnumTextureInternalFormat has no R32F, and /// ReadDefaultFramebuffer always assumes 4 bytes per pixel, neither of /// which fits an HDR history or a float depth target. So this talks to diff --git a/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs index 1fa16904..ace38217 100644 --- a/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs @@ -420,7 +420,7 @@ private unsafe Result RenderSkyMotion( float[]? invViewProjJittered = null, float[]? prevViewProj = null) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -541,7 +541,7 @@ private unsafe Result RenderSkyMotion( /// the attachment. Location 2 is hard-coded because this program is not /// built through the corpus and so has no TAAMOTIONLOCATION define. /// - private static int seamSeedProgram(IOptimumGraphicsDevice seam) + private static int seamSeedProgram(VulkanDevice seam) { const string vertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -571,7 +571,7 @@ private static MeshData BuildQuad(float minX, float maxX, float z) } /// Colour attachment 0 of the scene target, read inside the frame. - private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) + private static unsafe byte[] ReadColour(VulkanDevice seam, int scene) { var pixels = new byte[Size * Size * 4]; fixed (byte* destination = pixels) @@ -586,7 +586,7 @@ private static unsafe byte[] ReadColour(IOptimumGraphicsDevice seam, int scene) /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because /// the seam's readback is fixed at four bytes per pixel from attachment 0. /// - private static unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture, bool reactive) + private static unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture, bool reactive) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -649,32 +649,32 @@ void main(void) return pixels; } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -701,7 +701,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs index 35f97039..afacbd41 100644 --- a/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs @@ -260,7 +260,7 @@ private unsafe Decoded RenderStandardMotion( float cameraDeltaY, float previousGlobalWarp) { - IOptimumGraphicsDevice seam = device; + VulkanDevice seam = device; var files = ShaderCorpus.LoadShaderFiles(); var includes = ShaderCorpus.LoadIncludes(); @@ -357,7 +357,7 @@ private unsafe Decoded RenderStandardMotion( /// Reads the RGBA16F motion attachment through an RGBA8 decode pass, because /// the seam's readback is fixed at four bytes per pixel from attachment 0. /// - private unsafe byte[] DecodeMotion(IOptimumGraphicsDevice seam, int motionTexture) + private unsafe byte[] DecodeMotion(VulkanDevice seam, int motionTexture) { const string decodeVertex = @"#version 330 core layout(location = 0) in vec3 xyz; @@ -461,7 +461,7 @@ private static MeshData BuildQuad() /// vector, and the test would read the cleared attachment instead. alphaTest /// is pushed below zero so nothing can discard at all. /// - private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program, int dontWarpVertices) + private static void SetSceneUniforms(VulkanDevice seam, int program, int dontWarpVertices) { SetInt(seam, program, "dontWarpVertices", dontWarpVertices); SetInt(seam, program, "fadeFromSpheresFog", 0); @@ -502,7 +502,7 @@ private static void SetSceneUniforms(IOptimumGraphicsDevice seam, int program, i /// Both halves of the warp state, pinned so this frame's warp is a no-op and /// only the previous one moves anything. /// - private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, float previousGlobalWarp) + private static void SetWarpUniforms(VulkanDevice seam, int program, float previousGlobalWarp) { SetFloat(seam, program, "timeCounter", 0f); SetFloat(seam, program, "windWaveCounter", 0f); @@ -531,44 +531,44 @@ private static void SetWarpUniforms(IOptimumGraphicsDevice seam, int program, fl SetFloat3(seam, program, "prevPlayerpos", 0f, 0f, 0f); } - private static void SetFloat(IOptimumGraphicsDevice seam, int program, string name, float value) + private static void SetFloat(VulkanDevice seam, int program, string name, float value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetInt(IOptimumGraphicsDevice seam, int program, string name, int value) + private static void SetInt(VulkanDevice seam, int program, string name, int value) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, value); } - private static void SetFloat2(IOptimumGraphicsDevice seam, int program, string name, float x, float y) + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y); } - private static void SetFloat3(IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z) + private static void SetFloat3(VulkanDevice seam, int program, string name, float x, float y, float z) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z); } private static void SetFloat4( - IOptimumGraphicsDevice seam, int program, string name, float x, float y, float z, float w) + VulkanDevice seam, int program, string name, float x, float y, float z, float w) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniform(program, location, x, y, z, w); } - private static void SetMatrix(IOptimumGraphicsDevice seam, int program, string name, float[] matrix) + private static void SetMatrix(VulkanDevice seam, int program, string name, float[] matrix) { int location = seam.GetUniformLocation(program, name); if (location >= 0) seam.SetUniformMatrix(program, location, matrix); } - private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) + private static unsafe int CreateWhiteTexture(VulkanDevice seam) { var white = new byte[] { 255, 255, 255, 255 }; fixed (byte* pixels = white) @@ -579,7 +579,7 @@ private static unsafe int CreateWhiteTexture(IOptimumGraphicsDevice seam) } private static int BindEveryDeclaredSampler( - VulkanDevice device, IOptimumGraphicsDevice seam, int programId) + VulkanDevice device, VulkanDevice seam, int programId) { int unit = 0; foreach (string samplerName in device.SamplerNamesOf(programId)) @@ -593,7 +593,7 @@ private static int BindEveryDeclaredSampler( } private static int LinkFromCorpus( - IOptimumGraphicsDevice seam, List stages, string name) + VulkanDevice seam, List stages, string name) { var program = new CorpusProgram { PassName = name }; @@ -620,7 +620,7 @@ private static int LinkFromCorpus( private static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) => GpuTest.TryCreateDevice(output, out device); - private static void AssertClean(IOptimumGraphicsDevice seam) => GpuTest.AssertClean(seam); + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); private sealed class CorpusShader : IShader { diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index 3f95bec7..c2b78cc4 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -10,7 +10,7 @@ namespace Optimum.Render.Vulkan.Tests; /// /// Drives the backend the way the client will: through -/// and nothing else. +/// and nothing else. /// /// Every other test in this project reaches past the seam into a specific /// manager. This one deliberately does not, because the seam is the contract that @@ -49,7 +49,7 @@ public unsafe void TemporalHistorySurvivesFramesInFlightWithoutIntermediateReadb Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 64, frames = 16; string vertex = ShaderCorpus.LoadShaderFiles()["taa-resolve.vsh"]; int accumulate = LinkProgram(seam, vertex, """ @@ -172,7 +172,7 @@ private unsafe void RunTaaResolve(float distance, bool disoccluded, Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 8; var files = ShaderCorpus.LoadShaderFiles(); int resolve = LinkProgram(seam, files["taa-resolve.vsh"], files["taa-resolve.fsh"], "taa-resolve"); @@ -339,7 +339,7 @@ public unsafe void TerrainSamplerUsesNearestTexelsAndBlendsMipLevels(bool linear Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = LinkProgram(seam, """ #version 330 core void main() { @@ -443,7 +443,7 @@ public unsafe void ALodBiasWrittenToAnAlreadyBoundSamplerChangesTheMipTheGpuRead Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 4; int program = LinkProgram(seam, """ #version 330 core @@ -532,7 +532,7 @@ public unsafe void IndexedLineMeshesDrawOnlyEdgesAndRestoreTriangleTopology(Enum Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 32; int program = LinkProgram(seam, """ #version 330 core @@ -607,7 +607,7 @@ public unsafe void CloudMapShortUploadsKeepFullDensityAndBrightness() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = LinkProgram(seam, """ #version 330 core void main() { @@ -662,7 +662,7 @@ public unsafe void AtlasCopiesWithinTheSameTextureReadTheContentsBeforeEachDraw( Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = LinkProgram(seam, """ #version 330 core void main() { @@ -773,7 +773,7 @@ public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } internal static int LinkProgram( - IOptimumGraphicsDevice device, string vertexCode, string fragmentCode, string name = "test") + VulkanDevice device, string vertexCode, string fragmentCode, string name = "test") { var vertex = new TestShader { Type = EnumShaderType.VertexShader, Code = vertexCode }; var fragment = new TestShader { Type = EnumShaderType.FragmentShader, Code = fragmentCode }; @@ -793,7 +793,7 @@ public void TheDeviceReportsItsCapabilitiesThroughTheSeam() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; _output.WriteLine($"backend : {seam.BackendName}"); _output.WriteLine($"renderer : {seam.RendererString}"); @@ -822,7 +822,7 @@ public unsafe void AFrameCanBeRenderedEntirelyThroughTheSeam() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 32; int programId = LinkProgram(seam, """ @@ -898,7 +898,7 @@ public unsafe void UniformsPersistAcrossDrawsAndFrames() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; int programId = LinkProgram(seam, """ @@ -962,7 +962,7 @@ public void TextureAndFramebufferIdsBehaveLikeGlNames() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int first = seam.CreateTexture2D(8, 8, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); @@ -990,7 +990,7 @@ public unsafe void ATextureBoundToAUnitIsSampledByTheShader() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; int programId = LinkProgram(seam, """ @@ -1075,7 +1075,7 @@ public unsafe void ATextureRenderedIntoIsSampledCorrectlyByALaterPass() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; const string fullscreenVertex = """ @@ -1167,7 +1167,7 @@ public unsafe void AnAttachmentMaskedOutOfTheDrawCanBeSampledByIt() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; const string fullscreenVertex = """ @@ -1261,7 +1261,7 @@ public unsafe void AClientUniformBufferSuppliesTheBlockTheShaderDeclares() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; int programId = LinkProgram(seam, """ @@ -1336,7 +1336,7 @@ public unsafe void TwoDrawsInOneFrameEachSeeTheBlockContentsTheyWereGiven() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; int program = LinkProgram(seam, """ @@ -1414,7 +1414,7 @@ public unsafe void ConsecutiveFramesEachSeeTheirOwnBlockContents() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; // Big enough, with a long enough fragment loop, that the first frame // is still running on the GPU while the second is recorded: that is // the window the buffer-per-block design got wrong. @@ -1521,7 +1521,7 @@ void main(void) } /// A quad spanning the full height between two x coordinates. - private static int HalfScreenQuad(IOptimumGraphicsDevice device, float x0, float x1) + private static int HalfScreenQuad(VulkanDevice device, float x0, float x1) { var data = new MeshData(4, 6) { @@ -1534,7 +1534,7 @@ private static int HalfScreenQuad(IOptimumGraphicsDevice device, float x0, float return device.CreateMesh(data, true); } - private static unsafe void SetTint(IOptimumGraphicsDevice device, int ubo, byte r, byte g, byte b) + private static unsafe void SetTint(VulkanDevice device, int ubo, byte r, byte g, byte b) { var tint = new[] { r / 255f, g / 255f, b / 255f, 1f }; fixed (float* values = tint) @@ -1547,7 +1547,7 @@ private static unsafe void SetTint(IOptimumGraphicsDevice device, int ubo, byte /// Drains the device's diagnostics and fails on anything the layers reported /// at error severity, or on an unpinned synchronization hazard. /// - private static void AssertNoValidationErrors(IOptimumGraphicsDevice device) => GpuTest.AssertClean(device); + private static void AssertNoValidationErrors(VulkanDevice device) => GpuTest.AssertClean(device); /// /// The loading-screen crash. A texture is deleted and a new one takes its @@ -1564,7 +1564,7 @@ public unsafe void ADeletedTextureTakesItsDescriptorSetsWithIt() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 8; int program = LinkProgram(seam, """ @@ -1643,7 +1643,7 @@ void main(void) } } - private static unsafe int SolidTexture(IOptimumGraphicsDevice seam, int size, byte r, byte g, byte b) + private static unsafe int SolidTexture(VulkanDevice seam, int size, byte r, byte g, byte b) { var pixels = new byte[size * size * 4]; for (int i = 0; i < pixels.Length; i += 4) @@ -1675,7 +1675,7 @@ public unsafe void AMeshUpdateWritesEachPartAtItsOwnDestinationOffset() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int verticesPerSlice = 3; const int slices = 4; @@ -1743,7 +1743,7 @@ public void DeletingTheSameTextureTwiceCountsAndFreesItOnce() Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 8; int texture = seam.CreateTexture2D(size, size, @@ -1765,5 +1765,5 @@ public void DeletingTheSameTextureTwiceCountsAndFreesItOnce() } } - private static void AssertClean(IOptimumGraphicsDevice device) => GpuTest.AssertClean(device); + private static void AssertClean(VulkanDevice device) => GpuTest.AssertClean(device); } diff --git a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj index 01ae75c3..fecb39c8 100644 --- a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -1,9 +1,9 @@ + launcher loads it reflectively and OptimumRenderBootstrap.CreatePlatform + hands the client a VulkanClientPlatform, so no vanilla assembly ever gains + an assembly reference to a renderer implementation. --> net10.0 diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs new file mode 100644 index 00000000..25f9e2d1 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -0,0 +1,206 @@ +using System; +using System.Runtime.InteropServices; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 5: the leaf operations the render systems outside the +// platform used to route through the (now deleted) static device seam - ScreenManager's GUI depth clear, +// ClientMain's depth range, mesh handle deletion, ChunkRenderer's and ShaderRegistry's LOD +// bias, the framebuffer debug overlay's depth compare, SvgLoader's upload, +// InventoryItemRenderer's atlas slot clear, the OIT targets and pass state, the sun +// occlusion probe, Screenshot's readback and the backend name. Each body is the device +// branch the call site had, moved unchanged; ClientPlatformWindows holds the GL lines. +public partial class VulkanClientPlatform +{ + /// + /// glDepthRange clamps both bounds to [0, 1], and every caller passes a range that + /// clamps to the default (0, 20000) or restates it (0, 1). The device's viewport + /// depth range is that default, so there is nothing to do. + /// + public override void SetDepthRange(float near, float far) + { + } + + /// glClearBuffer clamps a depth clear value to [0, 1]; the device takes the clamped value. + public override void ClearDefaultDepth(float depth) + { + device.ClearDepth(Math.Clamp(depth, 0f, 1f)); + } + + /// The shared index buffer is a device mesh handle on this path. + public override void DeleteMeshHandle(int bufferId) + { + device.DeleteMesh(bufferId); + } + + /// + /// On this path VaoId is the device's mesh handle and the per-attribute buffer fields + /// are zero: released the mesh through the device's deferred + /// deletion before disposing the VAO, so there is nothing left to free here. VAO.Dispose + /// can also run from a finalizer, which is why nothing is destroyed inline. + /// + public override void DeleteVertexArrayHandles(VAO vao) + { + } + + /// The device addresses each texture directly; nothing to bind or restore. + public override void SetTextureLodBias(int[] textureIds, float bias) + { + for (int k = 0; k < textureIds.Length; k++) + { + device.SetTextureParameter(textureIds[k], OptimumGlConstants.TextureLodBias, bias); + } + } + + public override void SetSamplerLodBias(int samplerId, float bias) + { + device.SetSamplerParameter(samplerId, OptimumGlConstants.TextureLodBias, bias); + } + + /// The device takes the texture itself rather than whatever is bound. + public override void SetTextureDepthCompare(int textureId, int mode) + { + device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, mode); + } + + /// + /// The device takes the atlas texture by id. The pixels the caller passes are all + /// zero, so channel order does not matter. + /// + public override void ClearTextureRegion(int textureId, int x, int y, int width, int height, int[] pixels) + { + GCHandle pin = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + device.UploadTexture2D(textureId, 0, x, y, width, height, EnumTexturePixelFormat.Rgba, pin.AddrOfPinnedObject()); + } + finally + { + pin.Free(); + } + } + + public override int LoadTextureFromRgbaPointer(int width, int height, IntPtr pixels) + { + int textureId = device.CreateTexture2DRaw(width, height, OptimumGlConstants.Rgba8, pixels, 4); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, 9729); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, 9729); + return textureId; + } + + public override void SetProgramSamplerUnit(int programId, string samplerName, int unit) + { + device.SetSamplerUnit(programId, samplerName, unit); + } + + /// + /// The reveal target and the accumulation array, one layer per OIT weight bucket, + /// attached layer by layer at colour attachments 3, 4 and 5. The device addresses + /// textures directly, so there is no framebuffer or texture to bind first. + /// + public override void CreateOitTargets(FrameBufferRef transparent, int layers, out int revealTexture, out int accumTexture) + { + int width = transparent.Width; + int height = transparent.Height; + revealTexture = device.CreateTexture2DRaw(width, height, OptimumGlConstants.Rgba8, IntPtr.Zero, 0); + SetOitSampling(revealTexture); + + accumTexture = device.CreateTexture2DArray(width, height, layers, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba); + SetOitSampling(accumTexture); + + device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.ColorAttachment0, revealTexture, 0); + device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.ColorAttachment3, accumTexture, 0); + device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.ColorAttachment4, accumTexture, 1); + device.AttachTexture(transparent.FboId, (EnumFramebufferAttachment)36069, accumTexture, 2); + } + + /// + /// Nearest filtering and clamped wrapping: both OIT targets are read back per fragment + /// at exactly the coordinate that produced them. + /// + private void SetOitSampling(int textureId) + { + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMinFilter, 9728); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureMagFilter, 9728); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapS, 33071); + device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, 33071); + } + + /// + /// Attachments 0 and 1 hold revealage and multiply down from one; 3, 4 and 5 + /// accumulate additively from zero. Attachment 2 is written by the pass itself and + /// keeps vanilla blending. + /// + public override void BeginOitAccumulation(FrameBufferRef transparent) + { + device.SetDrawBuffers(transparent.FboId, 0x3F); + device.SetBlendFuncSeparate(0, 774, 0, 774, 0); + device.SetBlendFuncSeparate(1, 774, 0, 774, 0); + device.SetBlendFuncSeparate(3, 1, 1, 1, 1); + device.SetBlendFuncSeparate(4, 1, 1, 1, 1); + device.SetBlendFuncSeparate(5, 1, 1, 1, 1); + device.ClearColor(0, 1f, 1f, 1f, 1f); + device.ClearColor(1, 1f, 1f, 1f, 1f); + device.ClearColor(3, 0f, 0f, 0f, 0f); + device.ClearColor(4, 0f, 0f, 0f, 0f); + device.ClearColor(5, 0f, 0f, 0f, 0f); + } + + /// Units 6 and 7; the device binds by unit whatever the texture's dimensionality. + public override void BindOitTextures(int revealTexture, int accumTexture) + { + device.BindTexture(6, revealTexture); + device.BindTexture(7, accumTexture); + } + + public override int GenOcclusionQuery() + { + return device.CreateOcclusionQuery(); + } + + public override void BeginOcclusionQuery(int queryId) + { + device.BeginOcclusionQuery(queryId); + } + + public override void EndOcclusionQuery(int queryId) + { + device.EndOcclusionQuery(queryId); + } + + /// + /// GL's form polls availability and reads the sample count only when it is there; the + /// device's reads the same way (Phase 1B replaces the flush inside GetQueryResult). + /// + public override bool TryGetOcclusionQueryResult(int queryId, out int samples) + { + if (device.IsQueryResultAvailable(queryId)) + { + samples = device.GetQueryResult(queryId); + return true; + } + samples = 0; + return false; + } + + public override void DeleteOcclusionQuery(int queryId) + { + device.DeleteQuery(queryId); + } + + /// + /// The device reads back the colour target it has bound, which is the same image GL + /// would read from the bound framebuffer and in the same orientation - the one flip + /// happens at present, after this. + /// + public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) + { + device.ReadDefaultFramebuffer(x, y, width, height, destination); + } + + public override string GraphicsBackendName => device.BackendName; +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index fda39783..e2ac88b5 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -103,6 +103,27 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "ProbeThickLineSupport", Array.Empty()), new(true, "OnWindowSizeChanged", new[] { "Int32", "Int32" }), new(true, "ReadTextureForParity", new[] { "Int32" }), + // Phase 1A step 5: the leaf operations the render systems outside the platform issued. + new(true, "SetDepthRange", new[] { "Single", "Single" }), + new(true, "ClearDefaultDepth", new[] { "Single" }), + new(true, "DeleteMeshHandle", new[] { "Int32" }), + new(true, "DeleteVertexArrayHandles", new[] { "VAO" }), + new(true, "SetTextureLodBias", new[] { "Int32[]", "Single" }), + new(true, "SetSamplerLodBias", new[] { "Int32", "Single" }), + new(true, "SetTextureDepthCompare", new[] { "Int32", "Int32" }), + new(true, "ClearTextureRegion", new[] { "Int32", "Int32", "Int32", "Int32", "Int32", "Int32[]" }), + new(true, "LoadTextureFromRgbaPointer", new[] { "Int32", "Int32", "IntPtr" }), + new(true, "SetProgramSamplerUnit", new[] { "Int32", "String", "Int32" }), + new(true, "CreateOitTargets", new[] { "FrameBufferRef", "Int32", "Int32&", "Int32&" }), + new(true, "BeginOitAccumulation", new[] { "FrameBufferRef" }), + new(true, "BindOitTextures", new[] { "Int32", "Int32" }), + new(true, "GenOcclusionQuery", Array.Empty()), + new(true, "BeginOcclusionQuery", new[] { "Int32" }), + new(true, "EndOcclusionQuery", new[] { "Int32" }), + new(true, "TryGetOcclusionQueryResult", new[] { "Int32", "Int32&" }), + new(true, "DeleteOcclusionQuery", new[] { "Int32" }), + new(true, "ReadDefaultFramebuffer", new[] { "Int32", "Int32", "Int32", "Int32", "IntPtr" }), + new(true, "get_GraphicsBackendName", Array.Empty()), }; /// @@ -203,9 +224,9 @@ internal static bool IsInstallFailureForced() => Environment.GetEnvironmentVariable(ForceInstallFailureVariable) == "1"; /// - /// Creates the Vulkan device for the window and publishes it as - /// . False leaves the caller holding a window - /// with no graphics API, which it reopens for OpenGL with a base platform. + /// Creates the Vulkan device for the window, marks the backend Vulkan and publishes + /// the fork graphics bridge. False leaves the caller holding a window with no graphics + /// API, which it reopens for OpenGL with a base platform. /// public override bool InitializeGraphics(IntPtr windowHandle, int width, int height, out string reason) { @@ -224,12 +245,11 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig reason = null!; - // Installing is a single transition: a device that is already published - // stays, so a second call cannot displace and leak the one the client - // is drawing with. - if (OptimumRender.Device != null) + // Installing is a single transition: a device this platform already brought + // up stays, so a second call cannot displace and leak the one the client is + // drawing with. + if (this.device != null) { - this.device ??= OptimumRender.Device as VulkanDevice; return true; } @@ -253,8 +273,8 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig } this.device = device; - OptimumRender.Device = device; OptimumRender.ActiveBackend = EnumRenderBackend.Vulkan; + OptimumForkGraphics.Active = new VulkanForkGraphics(device); return true; } catch (Exception error) @@ -279,9 +299,11 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig /// public override void ShutdownGraphics() { + // The bridge goes first: nothing may reach a device that is being torn down. + OptimumForkGraphics.Active = null; try { - OptimumRender.Device?.Dispose(); + device?.Dispose(); } catch (Exception) { @@ -289,7 +311,6 @@ public override void ShutdownGraphics() } device = null; - OptimumRender.Device = null; OptimumRender.ActiveBackend = EnumRenderBackend.OpenGL; OptimumRender.NoGraphicsApiWindow = false; OptimumRenderBootstrap.ClearCrashMarker(); diff --git a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs new file mode 100644 index 00000000..d30433de --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs @@ -0,0 +1,67 @@ +using System; +using Vintagestory.API.Client; +using Vintagestory.API.Config; + +namespace Optimum.Render.Vulkan.Platform; + +/// +/// The the Vulkan platform publishes while its graphics +/// are up: the handful of operations the forked VSEssentials and VSSurvivalMod renderers +/// need beyond IRenderAPI, forwarded unchanged to the platform's device. The forks +/// reference only the API and the contracts, so this is how they reach the device until +/// Phase 5 ports them. +/// +internal sealed class VulkanForkGraphics : OptimumForkGraphics +{ + private readonly VulkanDevice device; + + public VulkanForkGraphics(VulkanDevice device) + { + this.device = device; + } + + public override int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPtr pixels, int bytesPerPixel) => + device.CreateTexture2DRaw(width, height, glInternalFormat, pixels, bytesPerPixel); + + public override int CreateTexture2DArray(int width, int height, int layers, + EnumTextureInternalFormat internalFormat, EnumTexturePixelFormat pixelFormat) => + device.CreateTexture2DArray(width, height, layers, internalFormat, pixelFormat); + + public override void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, int width, int height, IntPtr pixels) => + device.UploadTexture2DArrayLayer(textureId, layer, x, y, width, height, pixels); + + public override void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, int width, int height, short[] pixels) => + device.UploadTexture2DNormalizedShorts(textureId, level, x, y, width, height, pixels); + + public override void SetTextureParameter(int textureId, int parameterName, int value) => + device.SetTextureParameter(textureId, parameterName, value); + + public override void BindTexture(int unit, int textureId) => device.BindTexture(unit, textureId); + + public override void DeleteTexture(int textureId) => device.DeleteTexture(textureId); + + public override int CreateFramebuffer(int width, int height) => device.CreateFramebuffer(width, height); + + public override void AttachTexture(int framebufferId, EnumFramebufferAttachment attachment, int textureId, int layer) => + device.AttachTexture(framebufferId, attachment, textureId, layer); + + public override void SetDrawBuffers(int framebufferId, int attachmentMask) => + device.SetDrawBuffers(framebufferId, attachmentMask); + + public override void BindFramebuffer(int framebufferId) => device.BindFramebuffer(framebufferId); + + public override void BindDefaultFramebuffer() => device.BindDefaultFramebuffer(); + + public override void DeleteFramebuffer(int framebufferId) => device.DeleteFramebuffer(framebufferId); + + public override void SetViewport(int x, int y, int width, int height) => device.SetViewport(x, y, width, height); + + public override void SetDepthTest(bool enabled) => device.SetDepthTest(enabled); + + public override void SetBlendEnabled(bool enabled) => device.SetBlendEnabled(enabled); + + public override int GetUniformLocation(int programId, string name) => device.GetUniformLocation(programId, name); + + public override void SetUniformArray3(int programId, int location, int count, float[] values) => + device.SetUniformArray3(programId, location, count, values); +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 77fe5768..2fe7f365 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -11,7 +11,8 @@ namespace Optimum.Render.Vulkan; /// -/// The Vulkan implementation of Optimum's graphics backend seam. +/// The Vulkan renderer behind , which owns it +/// and calls it from every graphics override (Vulkan-native plan, Phase 1A). /// /// It presents the OpenGL protocol the game and its mods were written against - /// set state, set named uniforms on the active program, bind textures to units, @@ -23,7 +24,7 @@ namespace Optimum.Render.Vulkan; /// out integer ids, because the game's public API exposes raw GL names as fields /// that mods read and pass back. /// -public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice +public sealed unsafe class VulkanDevice : IDisposable { private VulkanContext _context = null!; private VulkanCommands _setupCommands = null!; diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs new file mode 100644 index 00000000..8331b3cc --- /dev/null +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A: "OFF is vanilla" is a test. ClientPlatformWindows.cs may +/// differ from the decompiled vanilla source in _ref/ only in the members listed here. +/// The comparison is per class member (fields, properties, methods, nested types), with +/// comment lines dropped and whitespace collapsed, and ignores member order. A member that +/// changes and is not listed fails, and so does a listed member that no longer differs, so +/// the list stays the exact set of Optimum-owned regions. +/// +public class ClientPlatformWindowsVanillaRegionsTests +{ + private const string VanillaSource = "_ref/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"; + + private static readonly string[] OwnedRegions = + { + // Members Optimum adds (injected by the patcher): the TAA/FSR state and GL halves, the + // step 3-5 graphics virtuals' GL overrides, frame pacing, the parity dump. + "ApplyOptimumMotionAccumulateBlendState", "ApplyOptimumMotionBlendState", + "ApplyTransparentMergeBlendState", "ApplyTransparentPassBlendState", + "BeginFinalCompositionDrawBuffers", "BeginMotionOnlyWrite", "BeginMotionWrite", + "BeginOcclusionQuery", "BeginOitAccumulation", "BindCurrentFrameBuffer", + "BindCurrentFrameBufferKeepViewport", "BindOitTextures", "BindProgramTexture2D", + "BindProgramTextureCube", "BindSampler", "BindUBO", "ClearBoundFrameBuffer", "ClearDefaultDepth", + "ClearFrameBufferPass", "ClearSsaoTarget", "ClearTextureRegion", "CreateOitTargets", + "CreateOptimumHistoryTargetGl", "DeleteMeshHandle", "DeleteOcclusionQuery", "DeleteUBO", + "DeleteVertexArrayHandles", "DisableOptimumFsr", "DisableOptimumTaa", "DisposeShaderProgram", + "EnableMotionDrawBuffers", "EnableMotionOnlyDrawBuffers", "EndFrame", "EndMotionOnlyWrite", + "EndMotionWrite", "EndOcclusionQuery", "EnsureOptimumDefaults", "EnsureOptimumTimerResolution", + "GenOcclusionQuery", "GraphicsBackendName", "InstallOptimumMotionWriteHooks", + "LoadTextureFromRgbaPointer", "MotionAttachmentIndex", "OptimumAdoptFrameBufferSettings", + "OptimumAdoptTaaTargets", "OptimumBgFpsFocusDebounceMs", "OptimumBgMaxFps", "OptimumCloudReactive", + "OptimumFinishDeviceFrameBufferSetup", "OptimumFsrBlitActive", "OptimumFsrFramebufferIndex", + "OptimumGlR32f", "OptimumMotionWriteActive", "OptimumOnProcessExit", "OptimumParityDumpAttachment", + "OptimumParityReadTextureGl", "OptimumParitySlotName", "OptimumRenderSsao", "OptimumRunParityDump", + "OptimumRunPendingTaaShaderReload", "OptimumSpinIterations", "OptimumSpinTailMinProcessorCount", + "OptimumSsaoKernel", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumTaaRequested", + "OptimumTaaSharpenIndex", "OptimumTimeBeginPeriod", "OptimumTimeEndPeriod", + "OptimumUndershootPercent", "OptimumYieldThresholdMs", "ProbeThickLineSupport", + "ReadDefaultFramebuffer", "ReadTextureForParity", "RenderOptimumSkyMotion", + "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "RestorePrimaryDrawBuffers", + "RestoreWorldDrawBuffers", "SelectBackDrawBuffer", "SelectFsrDrawBuffer", "SetBlendEnabled", + "SetDepthRange", "SetOptimumMotionAttachmentIndex", "SetProgramSamplerUnit", "SetSamplerLodBias", + "SetTextureDepthCompare", "SetTextureLodBias", "SetUniform", "SetUniformArray1", "SetUniformArray2", + "SetUniformArray3", "SetUniformArray4", "SetUniformMatrices", "SetUniformMatrices4x3", + "SetUniformMatrix", "TaaHistory", "TaaResolvedThisFrame", "TaaTargetsReady", + "TryGetOcclusionQueryResult", "UnbindUBO", "UpdateUBO", "UseShaderProgram", + "_optimumFocusLostStopwatch", "_optimumSettingsInitialized", "_optimumTimerResolutionRaised", + "_taaFrameParity", "_taaHistoryValid", "optimumFsrDisabled", "optimumMotionAttachmentIndex", + "optimumMotionDrawBuffersOff", "optimumMotionDrawBuffersOn", "optimumMotionOnlyDrawBuffers", + "optimumMotionWriteActive", "optimumParityDumpDone", "optimumParityWorldFrames", + "optimumTaaDisabled", "optimumTaaResolvedThisFrame", "optimumTaaShaderReloadPending", + "optimumTaaTargetsReady", "taaResolvedColorTexture", "taaResolvedGlowTexture", + + // Vanilla members with an Optimum edit (the patcher transplant targets and the members + // it virtualizes in place: base edits, FSR/TAA/post chain, frame pacing, mesh bulk copy), + // some of which also carry the compile fix-ups below. + "BlitPrimaryToDefault", "BuildMipMaps", "CheckFboStatus", "ClearFrameBuffer", "CompileShader", + "CreateShaderProgram", "CreateUBO", "CurrentFrameBuffer", "CurrentFrameBufferKeepVw", + "DisposeFrameBuffers", "GetGraphicsCardRenderer", "GlGetMaxTextureSize", "GlToggleBlend", + "LoadFrameBuffer", "LogAndTestHardwareInfosStage2", "MergeTransparentRenderPass", "MouseGrabbed", + "Mouse_WheelChanged", "RebuildFrameBuffers", "RenderFinalComposition", "RenderFullscreenTriangle", + "RenderPostprocessingEffects", "SetupDefaultFrameBuffers", "Start", "UnloadFrameBuffer", + "UpdateMesh", "UpdateSSBOMesh", "Window_Resize", "updateIndices", "updateVAO", "window_RenderFrame", + + // Compile fix-ups only: decompiler artefacts the donor tree rewrites to build + // (((T)(ref e)).X becomes e.X, explicit OpenTK qualification, int casts). Not + // transplanted; the vanilla IL of these stays in the patched DLL. + "CheckGlError", "CheckGlErrorAlways", "GlGetError", "LoadMouseCursor", "Mouse_ButtonDown", + "Mouse_ButtonUp", "Mouse_Move", "Window_FileDrop", "game_KeyDown", "game_KeyPress", "game_KeyUp", + }; + + [Fact] + public void ClientPlatformWindowsDiffersFromVanillaOnlyInTheOwnedRegions() + { + string? vanillaPath = TryFind(VanillaSource); + if (vanillaPath == null) + { + // _ref/ is the decompiled vanilla client, present wherever the lib is built. + Assert.False(File.Exists(PatchReader.FindRepositoryFile(VulkanPlatformSource.ClientPlatformWindowsSource)), + "build/ is materialised but _ref/ is not; the vanilla comparison cannot run"); + return; + } + + List vanilla = ClassMembers(File.ReadAllText(vanillaPath), "ClientPlatformWindows"); + List patched = ClassMembers(VulkanPlatformSource.ReadClientPlatformWindows(), "ClientPlatformWindows"); + // Vanilla 1.22.7 splits into 262 members; far fewer means the splitter lost the class body. + Assert.True(vanilla.Count > 200, "the vanilla member split found only " + vanilla.Count + " members"); + + var differing = new SortedSet(StringComparer.Ordinal); + CollectUnmatched(patched, vanilla, differing); + CollectUnmatched(vanilla, patched, differing); + + var owned = new SortedSet(OwnedRegions, StringComparer.Ordinal); + var unexpected = new SortedSet(differing, StringComparer.Ordinal); + unexpected.ExceptWith(owned); + var stale = new SortedSet(owned, StringComparer.Ordinal); + stale.ExceptWith(differing); + + Assert.True(unexpected.Count == 0, + "ClientPlatformWindows differs from vanilla outside the owned regions:\n" + string.Join("\n", unexpected)); + Assert.True(stale.Count == 0, + "listed as owned but identical to vanilla (remove from the list):\n" + string.Join("\n", stale)); + } + + private static void CollectUnmatched(List members, List against, SortedSet into) + { + var remaining = new Dictionary(StringComparer.Ordinal); + foreach (Member member in against) + { + remaining.TryGetValue(member.Text, out int count); + remaining[member.Text] = count + 1; + } + foreach (Member member in members) + { + if (remaining.TryGetValue(member.Text, out int count) && count > 0) + { + remaining[member.Text] = count - 1; + } + else + { + into.Add(member.Name); + } + } + } + + private readonly record struct Member(string Name, string Text); + + /// + /// Splits the body of the first class named into its + /// brace-depth-1 members. String and character literals are skipped so braces inside + /// them do not count; a closing brace followed by ;, ,, ) or + /// . continues the member (initialisers). + /// + private static List ClassMembers(string source, string className) + { + var lines = new StringBuilder(); + foreach (string line in source.Split('\n')) + { + if (line.TrimStart().StartsWith("//", StringComparison.Ordinal)) continue; + lines.Append(line).Append('\n'); + } + string text = lines.ToString(); + + Match declaration = Regex.Match(text, @"\bclass\s+" + className + @"\b"); + Assert.True(declaration.Success, "class " + className + " not found"); + int open = text.IndexOf('{', declaration.Index); + var members = new List(); + int depth = 1; + int start = open + 1; + for (int i = open + 1; i < text.Length; i++) + { + char c = text[i]; + if (c == '"') + { + bool verbatim = i > 0 && (text[i - 1] == '@' || (text[i - 1] == '$' && i > 1 && text[i - 2] == '@')); + i++; + while (i < text.Length) + { + if (verbatim && text[i] == '"' && i + 1 < text.Length && text[i + 1] == '"') { i += 2; continue; } + if (!verbatim && text[i] == '\\') { i += 2; continue; } + if (text[i] == '"') break; + i++; + } + continue; + } + if (c == '\'') + { + i++; + while (i < text.Length && text[i] != '\'') + { + if (text[i] == '\\') i++; + i++; + } + continue; + } + if (c == '{') + { + depth++; + continue; + } + if (c == '}') + { + depth--; + if (depth == 0) break; + if (depth == 1) + { + int next = i + 1; + while (next < text.Length && char.IsWhiteSpace(text[next])) next++; + if (next < text.Length && ";,).".IndexOf(text[next]) >= 0) continue; + AddMember(members, text.Substring(start, i - start + 1)); + start = i + 1; + } + continue; + } + if (c == ';' && depth == 1) + { + AddMember(members, text.Substring(start, i - start + 1)); + start = i + 1; + } + } + return members; + } + + private static void AddMember(List members, string raw) + { + string normalized = Regex.Replace(raw, @"\s+", " ").Trim(); + if (normalized.Length == 0 || normalized == ";") return; + string header = Regex.Replace(normalized, @"^(\[[^\]]*\]\s*)+", string.Empty); + int cut = header.Length; + foreach (char stop in new[] { '(', '{', '=', ';' }) + { + int index = header.IndexOf(stop); + if (index >= 0 && index < cut) cut = index; + } + Match name = Regex.Match(header.Substring(0, cut), @"(\w+)\s*(<[^>]*>)?\s*$"); + members.Add(new Member(name.Success ? name.Groups[1].Value : header, normalized)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/Optimum.Tests/fsr-pipeline-coverage-tests.cs b/Optimum.Tests/fsr-pipeline-coverage-tests.cs index 3c040e90..d6241f1b 100644 --- a/Optimum.Tests/fsr-pipeline-coverage-tests.cs +++ b/Optimum.Tests/fsr-pipeline-coverage-tests.cs @@ -103,8 +103,10 @@ public void TerrainBiasCoversTextureObjectsAndCustomSamplers() // which routes to the device and keeps the GL call as its fallback. The // caller still computes the value; only the application moved. Assert.Contains("SetOptimumTextureLodBias(textureLodBias)", chunkRenderer); - Assert.Contains("(TextureParameterName)34049, bias", chunkRenderer); - Assert.Contains("OptimumGlConstants.TextureLodBias, bias", chunkRenderer); + // Phase 1A step 5: applied by the platform virtual SetTextureLodBias. + Assert.Contains("game.Platform.SetTextureLodBias(textureIds, bias);", chunkRenderer); + Assert.Contains("(TextureParameterName)34049, bias", VulkanPlatformSource.ReadClientPlatformWindows()); + Assert.Contains("OptimumGlConstants.TextureLodBias, bias", VulkanPlatformSource.Read()); Assert.Contains("float terrainLodBias = OptimumConfig.EffectiveTerrainLodBias;", shaderRegistry); Assert.Contains("if (terrainLodBias != 0f)", shaderRegistry); // P5 review: the four SamplerParameter calls moved behind @@ -120,8 +122,9 @@ public void TerrainBiasCoversTextureObjectsAndCustomSamplers() Assert.DoesNotContain("!= 0f", samplerEntry); Assert.Equal(4, Count(samplerEntry, "ApplyOptimumSamplerLodBias(")); Assert.Equal(4, Count(samplerEntry, ", bias);")); - Assert.Contains("(SamplerParameterName)34049, bias", shaderRegistry); - Assert.Contains("OptimumGlConstants.TextureLodBias, bias", shaderRegistry); + Assert.Contains("platform.SetSamplerLodBias(sampler, bias);", shaderRegistry); + Assert.Contains("(SamplerParameterName)34049, bias", VulkanPlatformSource.ReadClientPlatformWindows()); + Assert.Contains("OptimumGlConstants.TextureLodBias, bias", VulkanPlatformSource.Read()); Assert.Contains("terrainTexLinear", shaderRegistry); } diff --git a/Optimum.Tests/oit-framebuffer-rebuild-coverage-tests.cs b/Optimum.Tests/oit-framebuffer-rebuild-coverage-tests.cs index 5b62cd47..55cad1a3 100644 --- a/Optimum.Tests/oit-framebuffer-rebuild-coverage-tests.cs +++ b/Optimum.Tests/oit-framebuffer-rebuild-coverage-tests.cs @@ -43,7 +43,10 @@ public void OitRebuildComparesPreviousFramebufferToCurrentBeforeReplacingIt() [Fact] public void OitRevealAttachesToColorAttachment0_NotOverwritingVanilla() { - string source = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); + // Phase 1A step 5: SystemRenderOITLayers calls the platform; the GL lines are the + // ClientPlatformWindows override bodies. + Assert.Contains("CreateOitTargets(transparentfb, layers, out revealTextureId, out accumTextureId);", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs")); + string source = OitPlatformBodies(); // OIT reveal texture attaches to ColorAttachment0 (36064). This is by design: // the oit.fsh shader writes to layout(location = 0) which IS ColorAttachment0. @@ -56,7 +59,10 @@ public void OitRevealAttachesToColorAttachment0_NotOverwritingVanilla() [Fact] public void OitAccumulationLayersAttachToSlots3Through5() { - string source = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); + // Phase 1A step 5: SystemRenderOITLayers calls the platform; the GL lines are the + // ClientPlatformWindows override bodies. + Assert.Contains("CreateOitTargets(transparentfb, layers, out revealTextureId, out accumTextureId);", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs")); + string source = OitPlatformBodies(); // Accumulation layers attach to ColorAttachment3-5 (36067, 36068, 36069) // matching oit.fsh layout(location = 3/4/5). @@ -68,7 +74,10 @@ public void OitAccumulationLayersAttachToSlots3Through5() [Fact] public void OitDrawBuffersMatchesShaderOutputLocations() { - string source = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); + // Phase 1A step 5: SystemRenderOITLayers calls the platform; the GL lines are the + // ClientPlatformWindows override bodies. + Assert.Contains("BeginOitAccumulation(currentTransparentfb);", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs")); + string source = OitPlatformBodies(); // DrawBuffers must declare 6 attachments (0-5) matching oit.fsh outputs. // Using 6, not 7: attachment 6 would be unused by shaders. @@ -90,7 +99,10 @@ public void OitDisablesFlagPreventsFurtherRenderCalls() [Fact] public void OitBlendFuncPreservesVanillaAttachments0And1() { - string source = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); + // Phase 1A step 5: SystemRenderOITLayers calls the platform; the GL lines are the + // ClientPlatformWindows override bodies. + Assert.Contains("BeginOitAccumulation(currentTransparentfb);", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs")); + string source = OitPlatformBodies(); // Attachments 0 and 1 use DST_COLOR * ZERO blend (774 = GL_DST_COLOR, 0 = GL_ZERO). // This multiplies existing content by incoming fragment, preserving reveal semantics. @@ -101,7 +113,10 @@ public void OitBlendFuncPreservesVanillaAttachments0And1() [Fact] public void OitAccumulationBlendFuncUsesAdditiveBlend() { - string source = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); + // Phase 1A step 5: SystemRenderOITLayers calls the platform; the GL lines are the + // ClientPlatformWindows override bodies. + Assert.Contains("BeginOitAccumulation(currentTransparentfb);", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs")); + string source = OitPlatformBodies(); // Attachments 3-5 use ONE + ONE additive blend (1 = GL_ONE). Assert.Contains("GL.BlendFunc(3, (BlendingFactorSrc)1, (BlendingFactorDest)1)", source); @@ -109,6 +124,15 @@ public void OitAccumulationBlendFuncUsesAdditiveBlend() Assert.Contains("GL.BlendFunc(5, (BlendingFactorSrc)1, (BlendingFactorDest)1)", source); } + private static string OitPlatformBodies() + { + string windows = VulkanPlatformSource.ReadClientPlatformWindows(); + int start = windows.IndexOf("public override void CreateOitTargets(", StringComparison.Ordinal); + int end = windows.IndexOf("public override int GenOcclusionQuery()", StringComparison.Ordinal); + Assert.True(start >= 0 && end > start, "the GL OIT overrides are missing from ClientPlatformWindows"); + return windows.Substring(start, end - start); + } + private static string Read(string relativePath) { return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); diff --git a/Optimum.Tests/parity-dump-coverage-tests.cs b/Optimum.Tests/parity-dump-coverage-tests.cs index efb67e75..29f38c88 100644 --- a/Optimum.Tests/parity-dump-coverage-tests.cs +++ b/Optimum.Tests/parity-dump-coverage-tests.cs @@ -87,7 +87,8 @@ public void GlAndVulkanWriteThroughOneFileNameFormat() string api = ReadApi(); Assert.Equal(1, Count(api, "public const string FileNameFormat = \"{0}-{1}-{2}-{3}.{4}\";")); Assert.Contains("CultureInfo.InvariantCulture, FileNameFormat,", api); - Assert.Contains("OptimumTextureReadback ReadTextureForParity(int textureId);", api); + Assert.Contains("public virtual Vintagestory.API.Config.OptimumTextureReadback ReadTextureForParity(int textureId)", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs")); string platform = ReadSourceOrPatched(PlatformPatch, PlatformSource); string attachment = MethodBody(platform, "private int OptimumParityDumpAttachment("); diff --git a/Optimum.Tests/platform-device-branch-move-coverage-tests.cs b/Optimum.Tests/platform-device-branch-move-coverage-tests.cs index e0f839ee..ad2e1c40 100644 --- a/Optimum.Tests/platform-device-branch-move-coverage-tests.cs +++ b/Optimum.Tests/platform-device-branch-move-coverage-tests.cs @@ -58,7 +58,8 @@ public void EveryVulkanPlatformOverrideIsVirtualInThePatchedBaseAndSelfChecked() { Assert.True(injectedAbstract.Contains("\"" + name + "\",", StringComparison.Ordinal), name + " is an injected ClientPlatformAbstract virtual the patcher does not inject"); - Assert.True(selfCheck.Contains("new(true, \"" + name + "\"", StringComparison.Ordinal), + Assert.True(selfCheck.Contains("new(true, \"" + name + "\"", StringComparison.Ordinal) + || selfCheck.Contains("new(true, \"get_" + name + "\"", StringComparison.Ordinal), name + " is missing from VulkanClientPlatform.ExpectedVirtuals"); } if (virtualizedInPlace && !abstractMember && !injectedVirtual) diff --git a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs new file mode 100644 index 00000000..8852d509 --- /dev/null +++ b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 5: the last graphics-API leaf sites outside the platform +/// call ClientPlatformAbstract virtuals, and the static device seam (IOptimumGraphicsDevice, +/// OptimumRender.Device) is gone. Every leaf virtual carries the GL line the site issued in its +/// ClientPlatformWindows override and the device call in VulkanClientPlatform, is injected by +/// the patcher on both types and is in the runtime self-check. +/// +public class PlatformSeamDeletionCoverageTests +{ + private const string AbstractSource = "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"; + + private static readonly string[] SeamNames = { "OptimumRender.Device", "IOptimumGraphicsDevice" }; + + /// The trees that ship into the game: the lib donor, the mod forks, the API fork and the contracts. + private static readonly string[] ShippedTrees = + { + "build/VintagestoryLib", + "VSEssentials", + "VSSurvivalMod", + "VSCreativeMod", + "VintagestoryApi", + "optimum-api-contracts", + "sources", + "patches", + }; + + [Fact] + public void NoShippedSourceNamesTheDeletedSeam() + { + string root = RepositoryRoot(); + Assert.True(Directory.Exists(Path.Combine(root, "build", "VintagestoryLib")), "build/VintagestoryLib is not materialised"); + + var offenders = new List(); + int scanned = 0; + foreach (string tree in ShippedTrees) + { + string directory = Path.Combine(root, tree); + if (!Directory.Exists(directory)) continue; + foreach (string file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)) + { + if (!file.EndsWith(".cs", StringComparison.Ordinal) && !file.EndsWith(".patch", StringComparison.Ordinal)) continue; + string relative = Path.GetRelativePath(root, file).Replace('\\', '/'); + if (relative.Contains("/bin/", StringComparison.Ordinal) || relative.Contains("/obj/", StringComparison.Ordinal)) continue; + scanned++; + string text = File.ReadAllText(file); + foreach (string name in SeamNames) + { + if (text.Contains(name, StringComparison.Ordinal)) offenders.Add(relative + ": " + name); + } + } + } + + Assert.True(scanned > 1000, "expected to scan the shipped trees, scanned " + scanned + " files"); + Assert.True(offenders.Count == 0, "the deleted device seam is still named:\n" + string.Join("\n", offenders)); + } + + [Fact] + public void TheContractsKeepOnlyTheBackendDecisionAndTheForkBridge() + { + string contracts = Read("VintagestoryApi/Client/optimum-render-device.cs"); + + Assert.Contains("public static EnumRenderBackend ActiveBackend = EnumRenderBackend.OpenGL;", contracts); + Assert.Contains("public static string FallbackReason;", contracts); + Assert.Contains("public static bool IsVulkan => ActiveBackend == EnumRenderBackend.Vulkan;", contracts); + Assert.Contains("public static bool NoGraphicsApiWindow;", contracts); + Assert.Contains("public static void FallBackToOpenGL(string reason)", contracts); + Assert.Contains("public abstract class OptimumForkGraphics", contracts); + Assert.DoesNotContain("interface ", contracts); + + // GameWindowNative's pre-window clear keys on the window flag, not on a device. + string window = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/GameWindowNative.cs"); + Assert.Contains("if (!OptimumRender.NoGraphicsApiWindow)", window); + + // The fork bridge is for the forks only: the lib never reaches for it, and the + // Vulkan platform publishes it with its graphics and withdraws it before teardown. + foreach (string file in Directory.EnumerateFiles(Path.Combine(RepositoryRoot(), "build", "VintagestoryLib"), "*.cs", SearchOption.AllDirectories)) + { + Assert.DoesNotContain("OptimumForkGraphics", File.ReadAllText(file)); + } + string vulkan = VulkanPlatformSource.Read(); + Assert.Contains("OptimumForkGraphics.Active = new VulkanForkGraphics(device);", vulkan); + string shutdown = Body(vulkan, "public override void ShutdownGraphics()"); + Assert.True(shutdown.IndexOf("OptimumForkGraphics.Active = null;", StringComparison.Ordinal) + < shutdown.IndexOf("device?.Dispose();", StringComparison.Ordinal)); + + foreach (string fork in new[] + { + "VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs", + "VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs", + "VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs", + "VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs", + "VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs", + }) + { + Assert.Contains("OptimumForkGraphics.Active;", Read(fork)); + } + Assert.Contains("if (OptimumRender.IsVulkan)", Read("VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs")); + } + + /// Each former seam site calls the platform and no longer issues the GL call itself. + [Theory] + [InlineData("Vintagestory.Client/ScreenManager.cs", "Platform.ClearDefaultDepth(num);|Platform.SetDepthRange(0f, 20000f);", "GL.ClearBuffer|GL.DepthRange")] + [InlineData("Vintagestory.Client.NoObf/ClientMain.cs", "Platform.SetDepthRange(0f, 20000f);|Platform.SetDepthRange(0f, 1f);", "GL.DepthRange")] + [InlineData("Vintagestory.Client.NoObf/VAO.cs", "platform.DeleteVertexArrayHandles(this);", "GL.")] + [InlineData("Vintagestory.Client.NoObf/ChunkRenderer.cs", "game.Platform.SetTextureLodBias(textureIds, bias);|game.Platform.BindSampler(8, 0);", "GL.BindSampler|(TextureParameterName)34049")] + [InlineData("Vintagestory.Client.NoObf/ShaderRegistry.cs", "platform.SetSamplerLodBias(sampler, bias);", "GL.SamplerParameter")] + [InlineData("Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs", "game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 0);|game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 34894);", "(TextureParameterName)34892|SetOptimumDepthCompare")] + [InlineData("Vintagestory.Client.NoObf/SvgLoader.cs", "num = ScreenManager.Platform.LoadTextureFromRgbaPointer(textureWidth, textureHeight, (IntPtr)(nint)ptr);", "GL.")] + [InlineData("Vintagestory.Client.NoObf/InventoryItemRenderer.cs", "game.Platform.ClearTextureRegion(task.TexPos.atlasTextureId, (int)num, (int)num2, size, size, clearPixels);", "GL.TexSubImage2D")] + [InlineData("Vintagestory.Client.NoObf/ClientSystemStartup.cs", "if (game.Platform.GraphicsBackendName == \"OpenGL\" && GL.GetString((StringName)7937).Contains(\"Arc(TM)\")", "optimumRendererName")] + [InlineData("Vintagestory.ClientNative/Screenshot.cs", "Vintagestory.Client.ScreenManager.Platform.ReadDefaultFramebuffer(0, 0, size.Width, size.Height, val.GetPixels());", "GL.ReadPixels")] + [InlineData("Vintagestory.Client.NoObf/SystemRenderSunMoon.cs", "occlQueryId = game.Platform.GenOcclusionQuery();|platform.TryGetOcclusionQueryResult(occlQueryId, out num2)|platform.BeginOcclusionQuery(occlQueryId);|platform.EndOcclusionQuery(occlQueryId);|game.Platform.DeleteOcclusionQuery(occlQueryId);|platform.GlColorMask(false, false, false, false);|platform.GlColorMask(true, true, true, true);", "GL.GenQueries|GL.GetQueryObject|GL.BeginQuery|GL.EndQuery|GL.DeleteQuery|GL.ColorMask")] + [InlineData("Vintagestory.Client.NoObf/SystemRenderOITLayers.cs", "ScreenManager.Platform.SetProgramSamplerUnit(program.ProgramId, \"OITaccumulation\", 7);|ScreenManager.Platform.BeginOitAccumulation(currentTransparentfb);|ScreenManager.Platform.CreateOitTargets(transparentfb, layers, out revealTextureId, out accumTextureId);|ScreenManager.Platform.BindOitTextures(revealTextureId, accumTextureId);|ScreenManager.Platform.GLDeleteTexture(accumTextureId);|platform.ApplyTransparentPassBlendState();", "GL.DrawBuffers|GL.BlendFunc|GL.ClearBuffer|GL.GenTexture|GL.Uniform1|GL.BindTexture|GL.DeleteTexture|SetOptimumOitSampling")] + public void TheLeafSiteCallsThePlatform(string file, string calls, string forbidden) + { + string code = StripComments(Read("build/VintagestoryLib/" + file)); + foreach (string call in calls.Split('|')) + { + Assert.True(code.Contains(call, StringComparison.Ordinal), file + " does not call " + call); + } + foreach (string token in forbidden.Split('|')) + { + Assert.False(code.Contains(token, StringComparison.Ordinal), file + " still contains " + token); + } + } + + [Fact] + public void TheSharedIndexBufferIsDeletedByThePlatform() + { + string body = Body(StripComments(Read(AbstractSource)), "public static void DisposeIndexBuffer()"); + Assert.Contains("platform.DeleteMeshHandle(singleIndexBufferId);", body); + Assert.DoesNotContain("GL.", body); + } + + [Fact] + public void TheOitLayersKeepOnlyTheirFailurePathUnitReset() + { + string code = StripComments(Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs")); + int glCalls = Regex.Matches(code, @"\bGL\.").Count; + int unitResets = Regex.Matches(code, Regex.Escape("try { GL.ActiveTexture((TextureUnit)33984); } catch { }")).Count; + Assert.Equal(unitResets, glCalls); + } + + /// (member name, ClientPlatformWindows signature, GL line, VulkanClientPlatform line or null for a documented no-op). + public static IEnumerable LeafVirtuals() + { + yield return new object?[] { "SetDepthRange", "public override void SetDepthRange(float near, float far)", "GL.DepthRange(near, far);", null }; + yield return new object?[] { "ClearDefaultDepth", "public override void ClearDefaultDepth(float depth)", "GL.ClearBuffer((ClearBuffer)6145, 0, ref depth);", "device.ClearDepth(Math.Clamp(depth, 0f, 1f));" }; + yield return new object?[] { "DeleteMeshHandle", "public override void DeleteMeshHandle(int bufferId)", "GL.DeleteBuffer(bufferId);", "device.DeleteMesh(bufferId);" }; + yield return new object?[] { "DeleteVertexArrayHandles", "public override void DeleteVertexArrayHandles(VAO vao)", "GL.DeleteVertexArray(vao.VaoId);", null }; + yield return new object?[] { "SetTextureLodBias", "public override void SetTextureLodBias(int[] textureIds, float bias)", "GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias);", "device.SetTextureParameter(textureIds[k], OptimumGlConstants.TextureLodBias, bias);" }; + yield return new object?[] { "SetSamplerLodBias", "public override void SetSamplerLodBias(int samplerId, float bias)", "GL.SamplerParameter(samplerId, (SamplerParameterName)34049, bias);", "device.SetSamplerParameter(samplerId, OptimumGlConstants.TextureLodBias, bias);" }; + yield return new object?[] { "SetTextureDepthCompare", "public override void SetTextureDepthCompare(int textureId, int mode)", "GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, mode);", "device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, mode);" }; + yield return new object?[] { "ClearTextureRegion", "public override void ClearTextureRegion(int textureId, int x, int y, int width, int height, int[] pixels)", "GL.TexSubImage2D((TextureTarget)3553, 0, x, y, width, height, (PixelFormat)32993, (PixelType)5121, pixels);", "device.UploadTexture2D(textureId, 0, x, y, width, height, EnumTexturePixelFormat.Rgba, pin.AddrOfPinnedObject());" }; + yield return new object?[] { "LoadTextureFromRgbaPointer", "public override int LoadTextureFromRgbaPointer(int width, int height, IntPtr pixels)", "GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, width, height, 0, (PixelFormat)6408, (PixelType)5121, pixels);", "device.CreateTexture2DRaw(width, height, OptimumGlConstants.Rgba8, pixels, 4);" }; + yield return new object?[] { "SetProgramSamplerUnit", "public override void SetProgramSamplerUnit(int programId, string samplerName, int unit)", "GL.Uniform1(GL.GetUniformLocation(programId, samplerName), unit);", "device.SetSamplerUnit(programId, samplerName, unit);" }; + yield return new object?[] { "CreateOitTargets", "public override void CreateOitTargets(FrameBufferRef transparent, int layers, out int revealTexture, out int accumTexture)", "GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36069, accumTexture, 0, 2);", "device.AttachTexture(transparent.FboId, (EnumFramebufferAttachment)36069, accumTexture, 2);" }; + yield return new object?[] { "BeginOitAccumulation", "public override void BeginOitAccumulation(FrameBufferRef transparent)", "GL.ClearBuffer((ClearBuffer)6144, 5, array3);", "device.SetDrawBuffers(transparent.FboId, 0x3F);" }; + yield return new object?[] { "BindOitTextures", "public override void BindOitTextures(int revealTexture, int accumTexture)", "GL.BindTexture((TextureTarget)35866, accumTexture);", "device.BindTexture(7, accumTexture);" }; + yield return new object?[] { "GenOcclusionQuery", "public override int GenOcclusionQuery()", "GL.GenQueries(1, out queryId);", "return device.CreateOcclusionQuery();" }; + yield return new object?[] { "BeginOcclusionQuery", "public override void BeginOcclusionQuery(int queryId)", "GL.BeginQuery((QueryTarget)35092, queryId);", "device.BeginOcclusionQuery(queryId);" }; + yield return new object?[] { "EndOcclusionQuery", "public override void EndOcclusionQuery(int queryId)", "GL.EndQuery((QueryTarget)35092);", "device.EndOcclusionQuery(queryId);" }; + yield return new object?[] { "TryGetOcclusionQueryResult", "public override bool TryGetOcclusionQueryResult(int queryId, out int samples)", "GL.GetQueryObject(queryId, (GetQueryObjectParam)34918, out samples);", "samples = device.GetQueryResult(queryId);" }; + yield return new object?[] { "DeleteOcclusionQuery", "public override void DeleteOcclusionQuery(int queryId)", "GL.DeleteQuery(queryId);", "device.DeleteQuery(queryId);" }; + yield return new object?[] { "ReadDefaultFramebuffer", "public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)", "GL.ReadPixels(x, y, width, height, (PixelFormat)32993, (PixelType)5121, destination);", "device.ReadDefaultFramebuffer(x, y, width, height, destination);" }; + yield return new object?[] { "GraphicsBackendName", "public override string GraphicsBackendName", "return \"OpenGL\";", "public override string GraphicsBackendName => device.BackendName;" }; + } + + [Theory] + [MemberData(nameof(LeafVirtuals))] + public void EveryLeafVirtualHasBothBodiesAndIsShippedAndSelfChecked(string name, string signature, string gl, string? vulkanLine) + { + string abstractPlatform = StripComments(Read(AbstractSource)); + Assert.Matches(new Regex(@"public\s+virtual\s+[\w<>\[\].]+\s+" + name + @"\b"), abstractPlatform); + + string windows = VulkanPlatformSource.ReadClientPlatformWindows(); + Assert.Contains(gl, Body(windows, signature)); + + string vulkan = VulkanPlatformSource.Read(); + string boundary = char.IsLetterOrDigit(signature[signature.Length - 1]) ? @"\b" : string.Empty; + Assert.Single(Regex.Matches(vulkan, Regex.Escape(signature) + boundary)); + if (vulkanLine != null) + { + Assert.Contains(vulkanLine, vulkan); + } + else + { + Assert.Equal("{ }", Regex.Replace(Body(vulkan, signature), @"\s+", " ").Trim()); + } + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"" + name + "\",", Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()", "},")); + Assert.Contains("\"" + name + "\",", Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformWindows\"] = new()", "},")); + + string selfCheck = Block(Read(VulkanPlatformSource.MainFile), "internal static readonly ExpectedVirtual[] ExpectedVirtuals", "};"); + Assert.True(selfCheck.Contains("new(true, \"" + name + "\"", StringComparison.Ordinal) + || selfCheck.Contains("new(true, \"get_" + name + "\"", StringComparison.Ordinal), name + " is not in the self-check"); + } + + [Fact] + public void TheRemovedInjectedHelpersAreNoLongerShipped() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.DoesNotContain("\"SetOptimumDepthCompare\"", patcher); + Assert.DoesNotContain("\"SetOptimumOitSampling\"", patcher); + } + + private static string RepositoryRoot() => + Path.GetDirectoryName(PatchReader.FindRepositoryFile("VintageStory.slnx"))!; + + private static string StripComments(string source) => + Regex.Replace(source, @"//[^\n]*", string.Empty); + + private static string Block(string source, string header, string terminator) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf(terminator, start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } + + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + signature); + int cursor = start + signature.Length; + while (cursor < source.Length && char.IsWhiteSpace(source[cursor])) cursor++; + if (string.CompareOrdinal(source, cursor, "=>", 0, 2) == 0) + { + return source.Substring(cursor, source.IndexOf(';', cursor) - cursor + 1); + } + int open = source.IndexOf('{', cursor); + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced body: " + signature); + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} diff --git a/Optimum.Tests/taa-sharpen-coverage-tests.cs b/Optimum.Tests/taa-sharpen-coverage-tests.cs index d520a04f..6369270b 100644 --- a/Optimum.Tests/taa-sharpen-coverage-tests.cs +++ b/Optimum.Tests/taa-sharpen-coverage-tests.cs @@ -286,9 +286,11 @@ public void BothLodBiasCallSitesReadTheSharedValue() // ...and the branch really is just that branch: the nonzero path below // it is outside it. Assert.DoesNotContain("SetOptimumTextureLodBias(textureLodBias)", zeroBranch); - // Both backends keep getting the same value through the same setter. - Assert.Contains("optimumDevice.SetTextureParameter(textureIds[k],", chunkRenderer); - Assert.Contains("GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias);", chunkRenderer); + // Both backends keep getting the same value through the same setter: the platform + // virtual SetTextureLodBias (Phase 1A step 5). + Assert.Contains("game.Platform.SetTextureLodBias(textureIds, bias);", chunkRenderer); + Assert.Contains("device.SetTextureParameter(textureIds[k], OptimumGlConstants.TextureLodBias, bias);", VulkanPlatformSource.Read()); + Assert.Contains("GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias);", VulkanPlatformSource.ReadClientPlatformWindows()); string registry = ReadPatchedOrSource( "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", @@ -323,11 +325,12 @@ public void ALiveMipBiasChangeReachesTheTerrainSamplerObjectsAsWell() // The sampler write is a reusable entry point, not inlined into the load. Assert.Contains("public static void ApplyOptimumTerrainSamplerLodBias(float bias)", registry); Assert.Contains("ApplyOptimumTerrainSamplerLodBias(terrainLodBias);", registry); - // Both backends, through the same per-sampler helper. + // Both backends, through the same per-sampler helper and the platform virtual. + Assert.Contains("platform.SetSamplerLodBias(sampler, bias);", registry); Assert.Contains( - "optimumDevice.SetSamplerParameter(sampler, OptimumGlConstants.TextureLodBias, bias);", - registry); - Assert.Contains("GL.SamplerParameter(sampler, (SamplerParameterName)34049, bias);", registry); + "device.SetSamplerParameter(samplerId, OptimumGlConstants.TextureLodBias, bias);", + VulkanPlatformSource.Read()); + Assert.Contains("GL.SamplerParameter(samplerId, (SamplerParameterName)34049, bias);", VulkanPlatformSource.ReadClientPlatformWindows()); // Callable before the samplers exist: ChunkRenderer runs a frame before // the first shader load has created them. Assert.Contains("program == null || !program.customSamplers.TryGetValue(samplerName, out var sampler)", registry); @@ -337,10 +340,10 @@ public void ALiveMipBiasChangeReachesTheTerrainSamplerObjectsAsWell() "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); string setter = MethodBody(chunkRenderer, "private void SetOptimumTextureLodBias(float bias)"); Assert.Contains("ShaderRegistry.ApplyOptimumTerrainSamplerLodBias(bias);", setter); - // Before the device/GL split, so both paths reach it. + // Before the texture half, and on every backend: both go through the platform. Assert.True( setter.IndexOf("ShaderRegistry.ApplyOptimumTerrainSamplerLodBias(bias);", StringComparison.Ordinal) - < setter.IndexOf("if (optimumDevice != null)", StringComparison.Ordinal)); + < setter.IndexOf("game.Platform.SetTextureLodBias(textureIds, bias);", StringComparison.Ordinal)); // And the Cecil transplant carries both new members. string patcher = Read("Optimum.Patcher/Program.cs"); diff --git a/Optimum.Tests/temporal-render-inventory-tests.cs b/Optimum.Tests/temporal-render-inventory-tests.cs index 4d252f96..b407ebb5 100644 --- a/Optimum.Tests/temporal-render-inventory-tests.cs +++ b/Optimum.Tests/temporal-render-inventory-tests.cs @@ -54,10 +54,15 @@ public void SystemRenderParticlesRegistersOpaqueAndOitRenderers() public void SystemRenderOITLayersUsesSixDrawBuffers() { string oitLayers = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs"); - - Assert.Contains("new DrawBuffersEnum[6]", oitLayers); - Assert.Contains("DrawBuffersEnum.ColorAttachment0", oitLayers); - Assert.Contains("DrawBuffersEnum.ColorAttachment5", oitLayers); + // Phase 1A step 5: the OIT pass state is the platform virtual BeginOitAccumulation; + // its GL body in ClientPlatformWindows holds the draw buffers. + Assert.Contains("ScreenManager.Platform.BeginOitAccumulation(currentTransparentfb);", oitLayers); + string accumulation = VulkanPlatformSource.ReadClientPlatformWindows(); + accumulation = accumulation.Substring(accumulation.IndexOf("public override void BeginOitAccumulation(FrameBufferRef transparent)", System.StringComparison.Ordinal)); + + Assert.Contains("new DrawBuffersEnum[6]", accumulation); + Assert.Contains("DrawBuffersEnum.ColorAttachment0", accumulation); + Assert.Contains("DrawBuffersEnum.ColorAttachment5", accumulation); } [Fact] diff --git a/VintageStory.slnx b/VintageStory.slnx index d1924e4b..46c86dd8 100644 --- a/VintageStory.slnx +++ b/VintageStory.slnx @@ -20,7 +20,7 @@ diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch index 76417309..1d3dcec6 100644 --- a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs.patch @@ -1,5 +1,5 @@ diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs -index 4252128..120824f 100644 +index 4252128..802791c 100644 --- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs +++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererMap.cs @@ -219,10 +219,22 @@ namespace FluffyClouds { @@ -10,14 +10,14 @@ index 4252128..120824f 100644 void FreeGlResources(){ + // Optimum: the device owns these on its own backend, where the GL + // binding does not exist and the raw calls throw. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; ++ if (optimumGraphics != null) + { -+ optimumDevice.DeleteTexture(TextureData1); -+ optimumDevice.DeleteTexture(TextureData2); -+ optimumDevice.DeleteTexture(TextureMap); -+ optimumDevice.DeleteTexture(TextureCol); -+ optimumDevice.DeleteFramebuffer(Framebuffer); ++ optimumGraphics.DeleteTexture(TextureData1); ++ optimumGraphics.DeleteTexture(TextureData2); ++ optimumGraphics.DeleteTexture(TextureMap); ++ optimumGraphics.DeleteTexture(TextureCol); ++ optimumGraphics.DeleteFramebuffer(Framebuffer); + return; + } @@ -36,12 +36,12 @@ index 4252128..120824f 100644 - + // Optimum: the device keeps no queryable binding to read back, so the + // target the API considers current is what gets restored afterwards. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; + FrameBufferRef optimumSavedTarget = null; + int fb = 0; int[] vp = new int[4]; - GL.GetInteger(GetPName.Viewport, vp); -+ if (optimumDevice != null) ++ if (optimumGraphics != null) + { + optimumSavedTarget = capi.Render.CurrentFrameBuffer; + } @@ -63,10 +63,10 @@ index 4252128..120824f 100644 if(capi.Render.ShaderUniforms.PointLightsCount > 0){ - GL.Uniform3(GL.GetUniformLocation(programId, "pointLights"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLights3); - GL.Uniform3(GL.GetUniformLocation(programId, "pointLightColors"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLightColors3); -+ if (optimumDevice != null) ++ if (optimumGraphics != null) + { -+ optimumDevice.SetUniformArray3(programId, optimumDevice.GetUniformLocation(programId, "pointLights"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLights3); -+ optimumDevice.SetUniformArray3(programId, optimumDevice.GetUniformLocation(programId, "pointLightColors"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLightColors3); ++ optimumGraphics.SetUniformArray3(programId, optimumGraphics.GetUniformLocation(programId, "pointLights"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLights3); ++ optimumGraphics.SetUniformArray3(programId, optimumGraphics.GetUniformLocation(programId, "pointLightColors"), capi.Render.ShaderUniforms.PointLightsCount, capi.Render.ShaderUniforms.PointLightColors3); + } + else + { @@ -75,26 +75,26 @@ index 4252128..120824f 100644 + } + } + -+ if (optimumDevice != null) ++ if (optimumGraphics != null) + { -+ optimumDevice.BindFramebuffer(Framebuffer); -+ optimumDevice.SetViewport(0, 0, CloudTileLength, CloudTileLength); -+ optimumDevice.SetBlendEnabled(false); -+ optimumDevice.SetDepthTest(false); ++ optimumGraphics.BindFramebuffer(Framebuffer); ++ optimumGraphics.SetViewport(0, 0, CloudTileLength, CloudTileLength); ++ optimumGraphics.SetBlendEnabled(false); ++ optimumGraphics.SetDepthTest(false); + + capi.Render.RenderMesh(quad); + -+ optimumDevice.SetDepthTest(true); -+ optimumDevice.SetBlendEnabled(true); ++ optimumGraphics.SetDepthTest(true); ++ optimumGraphics.SetBlendEnabled(true); + if (optimumSavedTarget != null) + { -+ optimumDevice.BindFramebuffer(optimumSavedTarget.FboId); -+ optimumDevice.SetViewport(0, 0, optimumSavedTarget.Width, optimumSavedTarget.Height); ++ optimumGraphics.BindFramebuffer(optimumSavedTarget.FboId); ++ optimumGraphics.SetViewport(0, 0, optimumSavedTarget.Width, optimumSavedTarget.Height); + } + else + { -+ optimumDevice.BindDefaultFramebuffer(); -+ optimumDevice.SetViewport(0, 0, capi.Render.FrameWidth, capi.Render.FrameHeight); ++ optimumGraphics.BindDefaultFramebuffer(); ++ optimumGraphics.SetViewport(0, 0, capi.Render.FrameWidth, capi.Render.FrameHeight); + } + prog.Stop(); + return; @@ -111,11 +111,11 @@ index 4252128..120824f 100644 + // GL converts the signed-short source to normalized RGBA16 storage. + // Copying the bits directly would turn full density (32767) into 0.5. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; ++ if (optimumGraphics != null) + { -+ optimumDevice.UploadTexture2DNormalizedShorts(TextureData1, 0, 0, 0, CloudTileLength, CloudTileLength, TextureDataBuffer1); -+ optimumDevice.UploadTexture2DNormalizedShorts(TextureData2, 0, 0, 0, CloudTileLength, CloudTileLength, TextureDataBuffer2); ++ optimumGraphics.UploadTexture2DNormalizedShorts(TextureData1, 0, 0, 0, CloudTileLength, CloudTileLength, TextureDataBuffer1); ++ optimumGraphics.UploadTexture2DNormalizedShorts(TextureData2, 0, 0, 0, CloudTileLength, CloudTileLength, TextureDataBuffer2); + return; + } + @@ -129,14 +129,14 @@ index 4252128..120824f 100644 int makeTexture(int width, PixelInternalFormat internalFormat, PixelFormat format, PixelType type){ + // Optimum: the device takes the GL internal-format token directly and + // maps it; the sampling parameters are the GL tokens as well. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; ++ if (optimumGraphics != null) + { -+ int optimumTexture = optimumDevice.CreateTexture2DRaw(width, width, (int)internalFormat, IntPtr.Zero, 0); -+ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); -+ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); -+ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); -+ optimumDevice.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); ++ int optimumTexture = optimumGraphics.CreateTexture2DRaw(width, width, (int)internalFormat, IntPtr.Zero, 0); ++ optimumGraphics.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); ++ optimumGraphics.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Nearest); ++ optimumGraphics.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); ++ optimumGraphics.SetTextureParameter(optimumTexture, (int)TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); + return optimumTexture; + } + @@ -153,16 +153,16 @@ index 4252128..120824f 100644 + // Optimum: the device's framebuffer objects are created and attached + // without binding anything, so nothing needs saving or restoring. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; ++ if (optimumGraphics != null) + { + TextureMap = makeTexture(CloudTileLength, PixelInternalFormat.Rgba32f, PixelFormat.Rgba, PixelType.Float); + TextureCol = makeTexture(CloudTileLength, PixelInternalFormat.Rgba32f, PixelFormat.Rgba, PixelType.Float); + -+ Framebuffer = optimumDevice.CreateFramebuffer(CloudTileLength, CloudTileLength); -+ optimumDevice.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment0, TextureMap, 0); -+ optimumDevice.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment1, TextureCol, 0); -+ optimumDevice.SetDrawBuffers(Framebuffer, 0b11); ++ Framebuffer = optimumGraphics.CreateFramebuffer(CloudTileLength, CloudTileLength); ++ optimumGraphics.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment0, TextureMap, 0); ++ optimumGraphics.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment1, TextureCol, 0); ++ optimumGraphics.SetDrawBuffers(Framebuffer, 0b11); + return; + } + diff --git a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch index c059dcfd..0494aa17 100644 --- a/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch +++ b/patches/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs.patch @@ -1,5 +1,5 @@ diff --git a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs -index ce8dd81..8ab1d9c 100644 +index ce8dd81..e0e2a80 100644 --- a/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs +++ b/VSEssentials/Systems/Weather/Newclouds/CloudRendererVolumetric.cs @@ -73,10 +73,26 @@ namespace FluffyClouds { @@ -10,16 +10,16 @@ index ce8dd81..8ab1d9c 100644 + // Optimum: state goes through the device on its own backend, where + // the GL binding does not exist. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; ++ if (optimumGraphics != null) + { -+ optimumDevice.SetDepthTest(false); -+ optimumDevice.SetBlendEnabled(true); ++ optimumGraphics.SetDepthTest(false); ++ optimumGraphics.SetBlendEnabled(true); + + capi.Render.RenderMesh(quad); + + // GL leaves blending enabled for the remaining OIT draws. -+ optimumDevice.SetDepthTest(true); ++ optimumGraphics.SetDepthTest(true); + program.Stop(); + return; + } diff --git a/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch b/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch index 4ddcd1c6..18bcdf35 100644 --- a/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch +++ b/patches/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs.patch @@ -1,5 +1,5 @@ diff --git a/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs b/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs -index 1c83a88..a33ef42 100644 +index 1c83a88..1d0be3e 100644 --- a/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs +++ b/VSSurvivalMod/Entity/Behavior/BehaviorHideWaterSurface.cs @@ -109,11 +109,22 @@ namespace Vintagestory.GameContent @@ -9,13 +9,13 @@ index 1c83a88..a33ef42 100644 // This clears the drawbuffers set up by RenderOITLayers and then restores them after the render // Otherwise we render a big black surface inside the boat - OpenTK.Graphics.OpenGL.GL.DrawBuffers(0, new OpenTK.Graphics.OpenGL.DrawBuffersEnum[0]); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; ++ Vintagestory.API.Config.OptimumForkGraphics optimumGraphics = Vintagestory.API.Config.OptimumForkGraphics.Active; + int oitFboId = capi.Render.FrameBuffers[(int)EnumFrameBuffer.Transparent].FboId; -+ if (optimumDevice != null) ++ if (optimumGraphics != null) + { + // Vulkan path: the window has no GL context, so the draw-buffer + // mask has to change through the device. -+ optimumDevice.SetDrawBuffers(oitFboId, 0); ++ optimumGraphics.SetDrawBuffers(oitFboId, 0); + } + else + { @@ -33,9 +33,9 @@ index 1c83a88..a33ef42 100644 }; - OpenTK.Graphics.OpenGL.GL.DrawBuffers(buffers.Length, buffers); -+ if (optimumDevice != null) ++ if (optimumGraphics != null) + { -+ optimumDevice.SetDrawBuffers(oitFboId, (1 << buffers.Length) - 1); ++ optimumGraphics.SetDrawBuffers(oitFboId, (1 << buffers.Length) - 1); + } + else + { diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch index fecbde39..782db59e 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs -index 431e51a..a0c3c42 100644 +index 431e51a..2e2e836 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs @@ -1,9 +1,11 @@ @@ -55,7 +55,7 @@ index 431e51a..a0c3c42 100644 this.textureIds = textureIds; platform = game.Platform; this.game = game; -@@ -105,10 +137,74 @@ public class ChunkRenderer +@@ -105,10 +137,57 @@ public class ChunkRenderer foreach (EnumChunkRenderPass item in values) { AddPoolsForAtlasAndPass(atlas, item, modelDataPoolMaxVertexSize, modelDataPoolMaxIndexSize, maxPartsPerPool); @@ -96,41 +96,24 @@ index 431e51a..a0c3c42 100644 + /// + /// Optimum: applies a texture LOD bias to every block atlas. + /// -+ /// The GL form binds each texture to unit 0 and sets the parameter on the -+ /// binding; the device addresses the texture directly, so there is nothing to -+ /// bind and nothing to restore afterwards. ++ /// The platform applies it (ClientPlatformWindows.SetTextureLodBias binds each ++ /// texture to unit 0 and sets the parameter on the binding). + /// + private void SetOptimumTextureLodBias(float bias) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; + // The chunkopaque/chunktopsoil sampler objects override the texture + // parameter on the units they are bound to, so setting it here alone + // would move the mip selection of every OTHER terrain pass and leave the + // two that carry the bias in their name untouched until the next shader + // reload. Both halves move together or neither does. + ShaderRegistry.ApplyOptimumTerrainSamplerLodBias(bias); -+ if (optimumDevice != null) -+ { -+ for (int k = 0; k < textureIds.Length; k++) -+ { -+ optimumDevice.SetTextureParameter(textureIds[k], -+ Vintagestory.API.Config.OptimumGlConstants.TextureLodBias, bias); -+ } -+ return; -+ } -+ GL.ActiveTexture((TextureUnit)33984); -+ for (int i = 0; i < textureIds.Length; i++) -+ { -+ GL.BindTexture((TextureTarget)3553, textureIds[i]); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias); -+ } -+ GL.BindTexture((TextureTarget)3553, 0); ++ game.Platform.SetTextureLodBias(textureIds, bias); } private void AddPoolsForAtlasAndPass(int atlas, EnumChunkRenderPass pass, int maxVertices, int maxIndices, int maxPartsPerPool) { switch (pass) -@@ -142,10 +238,16 @@ public class ChunkRenderer +@@ -142,10 +221,16 @@ public class ChunkRenderer culler.CullInvisibleChunks(); } @@ -147,7 +130,7 @@ index 431e51a..a0c3c42 100644 subPixelPaddingX = game.BlockAtlasManager.SubPixelPaddingX; subPixelPaddingY = game.BlockAtlasManager.SubPixelPaddingY; Vec3d cameraPos = game.EntityPlayer.CameraPos; -@@ -170,10 +272,11 @@ public class ChunkRenderer +@@ -170,10 +255,11 @@ public class ChunkRenderer game.Platform.LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -159,7 +142,7 @@ index 431e51a..a0c3c42 100644 RuntimeStats.availableTriangles = 0; accum += dt; if (accum > 5f) -@@ -208,23 +311,59 @@ public class ChunkRenderer +@@ -208,23 +294,59 @@ public class ChunkRenderer chunkshadowmap.Tex2d2D = textureIds[j]; poolsByRenderPass[5][j].Render(cameraPos, "origin", frustumCullMode); } @@ -222,7 +205,7 @@ index 431e51a..a0c3c42 100644 Vec3d cameraPos = game.EntityPlayer.CameraPos; ScreenManager.FrameProfiler.Mark("rend3D-ret-begin"); platform.GlDepthMask(flag: true); -@@ -232,105 +371,145 @@ public class ChunkRenderer +@@ -232,105 +354,132 @@ public class ChunkRenderer platform.GlToggleBlend(on: true); platform.GlEnableCullFace(); game.GlMatrixModeModelView(); @@ -284,13 +267,7 @@ index 431e51a..a0c3c42 100644 - chunkopaque.HaxyFade = 0; - platform.GlToggleBlend(on: true); - for (int k = 0; k < textureIds.Length; k++) -+ // Optimum TAA (P3): every chunk draw in this method writes motion -+ // vectors, so the motion attachment joins Primary's draw-buffer mask for -+ // the whole pass and leaves it again below. A no-op when TAA is off. -+ ClientPlatformAbstract optimumPlatform = platform; -+ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); -+ try - { +- { - chunkopaque.TerrainTex2D = textureIds[k]; - chunkopaque.TerrainTexLinear2D = textureIds[k]; - poolsByRenderPass[2][k].Render(cameraPos, "origin"); @@ -299,7 +276,13 @@ index 431e51a..a0c3c42 100644 - chunkopaque.AlphaTest = 0.42f; - chunkopaque.HaxyFade = 1; - for (int l = 0; l < textureIds.Length; l++) -- { ++ // Optimum TAA (P3): every chunk draw in this method writes motion ++ // vectors, so the motion attachment joins Primary's draw-buffer mask for ++ // the whole pass and leaves it again below. A no-op when TAA is off. ++ ClientPlatformAbstract optimumPlatform = platform; ++ bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); ++ try + { - chunkopaque.TerrainTex2D = textureIds[l]; - chunkopaque.TerrainTexLinear2D = textureIds[l]; - poolsByRenderPass[1][l].Render(cameraPos, "origin"); @@ -428,34 +411,21 @@ index 431e51a..a0c3c42 100644 - GL.BindSampler(6, 0); - GL.BindSampler(7, 0); - GL.BindSampler(8, 0); -+ // Optimum: clears any sampler override on the nine units the chunk -+ // passes use, so the next pass gets each texture's own filtering. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ for (int k = 0; k < 9; k++) -+ { -+ optimumDevice.BindSampler(k, 0); -+ } -+ } -+ else -+ { -+ GL.BindSampler(0, 0); -+ GL.BindSampler(1, 0); -+ GL.BindSampler(2, 0); -+ GL.BindSampler(3, 0); -+ GL.BindSampler(4, 0); -+ GL.BindSampler(5, 0); -+ GL.BindSampler(6, 0); -+ GL.BindSampler(7, 0); -+ GL.BindSampler(8, 0); -+ } ++ game.Platform.BindSampler(0, 0); ++ game.Platform.BindSampler(1, 0); ++ game.Platform.BindSampler(2, 0); ++ game.Platform.BindSampler(3, 0); ++ game.Platform.BindSampler(4, 0); ++ game.Platform.BindSampler(5, 0); ++ game.Platform.BindSampler(6, 0); ++ game.Platform.BindSampler(7, 0); ++ game.Platform.BindSampler(8, 0); } } internal void RenderOIT(float deltaTime) { -@@ -402,51 +581,187 @@ public class ChunkRenderer +@@ -402,51 +551,187 @@ public class ChunkRenderer chunktransparent.Stop(); game.GlPopMatrix(); ScreenManager.FrameProfiler.Mark("rend3D-ret-tp"); @@ -605,10 +575,7 @@ index 431e51a..a0c3c42 100644 + ClientPlatformAbstract optimumPlatform = platform; + bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); + try - { -- chunkopaque.TerrainTex2D = textureIds[i]; -- chunkopaque.TerrainTexLinear2D = textureIds[i]; -- poolsByRenderPass[7][i].Render(cameraPos, "origin"); ++ { + ShaderProgramChunkopaque chunkopaque = ShaderPrograms.Chunkopaque; + platform.GlDisableCullFace(); + platform.GlToggleBlend(on: false); @@ -640,7 +607,10 @@ index 431e51a..a0c3c42 100644 + chunkopaque.Stop(); + } + finally -+ { + { +- chunkopaque.TerrainTex2D = textureIds[i]; +- chunkopaque.TerrainTexLinear2D = textureIds[i]; +- poolsByRenderPass[7][i].Render(cameraPos, "origin"); + // Same contract as RenderOpaque: the window closes even if a shader + // setup or a pool draw throws, so a failed overlay pass cannot leave + // the motion attachment in the draw-buffer mask. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index b5084ecd..649bfdff 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..ec3a19c 100644 +index 67feafa..8cb33a8 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -490,20 +490,14 @@ index 67feafa..ec3a19c 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1818,30 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1818,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); GlPushMatrix(); GlLoadIdentity(); - GL.DepthRange(0f, 20000f); -+ // glDepthRange clamps both bounds to [0, 1], so 20000 means 1 and this is -+ // the default range restated. The device has no equivalent call and needs -+ // none; PerspectiveMode's restore to (0, 1) is the same no-op. -+ if (Vintagestory.API.Config.OptimumRender.Device == null) -+ { -+ GL.DepthRange(0f, 20000f); -+ } ++ Platform.SetDepthRange(0f, 20000f); GlTranslate(0.0, 0.0, -19849.0); } @@ -514,16 +508,13 @@ index 67feafa..ec3a19c 100644 GlMatrixModeModelView(); GlPopMatrix(); - GL.DepthRange(0f, 1f); -+ if (Vintagestory.API.Config.OptimumRender.Device == null) -+ { -+ GL.DepthRange(0f, 1f); -+ } ++ Platform.SetDepthRange(0f, 1f); } public void Connect() { Compression.Reset(); -@@ -2124,12 +2386,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2377,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -548,7 +539,7 @@ index 67feafa..ec3a19c 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2815,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2806,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -562,7 +553,7 @@ index 67feafa..ec3a19c 100644 } public void DoReconnect() -@@ -3531,6 +3806,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3797,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 7ff55ca5..6d4a94d3 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..150ea7f 100644 +index d6eb844..8337688 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,313 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,405 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -295,23 +295,115 @@ index d6eb844..150ea7f 100644 + { + return null; + } ++ ++ // Optimum (Vulkan-native plan, Phase 1A step 5): the graphics-API leaf operations the ++ // render systems outside the platform used to issue directly - the depth range and the ++ // GUI depth clear, mesh handle deletion, texture and sampler parameters, a texture ++ // upload and a region clear, the OIT targets and their pass state, occlusion queries, ++ // the default framebuffer readback and the backend name. Neutral bodies here; ++ // ClientPlatformWindows overrides each with the GL lines the call site issued and ++ // VulkanClientPlatform with the device calls. ++ public virtual void SetDepthRange(float near, float far) ++ { ++ } ++ ++ public virtual void ClearDefaultDepth(float depth) ++ { ++ } ++ ++ public virtual void DeleteMeshHandle(int bufferId) ++ { ++ } ++ ++ public virtual void DeleteVertexArrayHandles(VAO vao) ++ { ++ } ++ ++ public virtual void SetTextureLodBias(int[] textureIds, float bias) ++ { ++ } ++ ++ public virtual void SetSamplerLodBias(int samplerId, float bias) ++ { ++ } ++ ++ public virtual void SetTextureDepthCompare(int textureId, int mode) ++ { ++ } ++ ++ public virtual void ClearTextureRegion(int textureId, int x, int y, int width, int height, int[] pixels) ++ { ++ } ++ ++ public virtual int LoadTextureFromRgbaPointer(int width, int height, IntPtr pixels) ++ { ++ return 0; ++ } ++ ++ public virtual void SetProgramSamplerUnit(int programId, string samplerName, int unit) ++ { ++ } ++ ++ public virtual void CreateOitTargets(FrameBufferRef transparent, int layers, out int revealTexture, out int accumTexture) ++ { ++ revealTexture = 0; ++ accumTexture = 0; ++ } ++ ++ public virtual void BeginOitAccumulation(FrameBufferRef transparent) ++ { ++ } ++ ++ public virtual void BindOitTextures(int revealTexture, int accumTexture) ++ { ++ } ++ ++ public virtual int GenOcclusionQuery() ++ { ++ return 0; ++ } ++ ++ public virtual void BeginOcclusionQuery(int queryId) ++ { ++ } ++ ++ public virtual void EndOcclusionQuery(int queryId) ++ { ++ } ++ ++ public virtual bool TryGetOcclusionQueryResult(int queryId, out int samples) ++ { ++ samples = 0; ++ return false; ++ } ++ ++ public virtual void DeleteOcclusionQuery(int queryId) ++ { ++ } ++ ++ public virtual void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) ++ { ++ } ++ ++ public virtual string GraphicsBackendName ++ { ++ get ++ { ++ return null; ++ } ++ } + public static void DisposeIndexBuffer() { if (singleIndexBufferId != 0) { - GL.DeleteBuffer(singleIndexBufferId); -+ // Mono.Cecil transplant. -+ // Shutdown runs this after the GL binding is gone on the device path, -+ // where the raw call throws rather than freeing anything. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DeleteMesh(singleIndexBufferId); -+ } -+ else ++ // Mono.Cecil transplant: the buffer is the handle of the platform that ++ // allocated it, which deletes it. ++ ClientPlatformAbstract platform = ScreenManager.Platform; ++ if (platform != null) + { -+ GL.DeleteBuffer(singleIndexBufferId); ++ platform.DeleteMeshHandle(singleIndexBufferId); + } singleIndexBufferId = 0; } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index aa89d989..3692f97a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..e7837a0 100644 +index 6edf0c9..75f35b5 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -575,7 +575,7 @@ index 6edf0c9..e7837a0 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,11 +1512,300 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,11 +1512,509 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -744,6 +744,215 @@ index 6edf0c9..e7837a0 100644 + return OptimumParityReadTextureGl(textureId); + } + ++ // Optimum (Vulkan-native plan, Phase 1A step 5): the GL lines ScreenManager, ClientMain, ++ // VAO, the shared index buffer, ChunkRenderer, ShaderRegistry, the framebuffer debug ++ // overlay, SvgLoader, InventoryItemRenderer, the OIT layers, the sun occlusion probe, ++ // Screenshot and ClientSystemStartup issued directly, verbatim; VulkanClientPlatform ++ // overrides each with device calls. ++ public override void SetDepthRange(float near, float far) ++ { ++ GL.DepthRange(near, far); ++ } ++ ++ public override void ClearDefaultDepth(float depth) ++ { ++ GL.ClearBuffer((ClearBuffer)6145, 0, ref depth); ++ } ++ ++ public override void DeleteMeshHandle(int bufferId) ++ { ++ GL.DeleteBuffer(bufferId); ++ } ++ ++ public override void DeleteVertexArrayHandles(VAO vao) ++ { ++ if (vao.xyzVboId != 0) ++ { ++ GL.DeleteBuffer(vao.xyzVboId); ++ } ++ if (vao.normalsVboId != 0) ++ { ++ GL.DeleteBuffer(vao.normalsVboId); ++ } ++ if (vao.uvVboId != 0) ++ { ++ GL.DeleteBuffer(vao.uvVboId); ++ } ++ if (vao.rgbaVboId != 0) ++ { ++ GL.DeleteBuffer(vao.rgbaVboId); ++ } ++ if (vao.customDataFloatVboId != 0) ++ { ++ GL.DeleteBuffer(vao.customDataFloatVboId); ++ } ++ if (vao.customDataShortVboId != 0) ++ { ++ GL.DeleteBuffer(vao.customDataShortVboId); ++ } ++ if (vao.customDataIntVboId != 0) ++ { ++ GL.DeleteBuffer(vao.customDataIntVboId); ++ } ++ if (vao.customDataByteVboId != 0) ++ { ++ GL.DeleteBuffer(vao.customDataByteVboId); ++ } ++ if (vao.vboIdIndex != 0 && vao.vboIdIndex != ClientPlatformAbstract.singleIndexBufferId) ++ { ++ GL.DeleteBuffer(vao.vboIdIndex); ++ } ++ if (vao.flagsVboId != 0) ++ { ++ GL.DeleteBuffer(vao.flagsVboId); ++ } ++ GL.DeleteVertexArray(vao.VaoId); ++ } ++ ++ public override void SetTextureLodBias(int[] textureIds, float bias) ++ { ++ GL.ActiveTexture((TextureUnit)33984); ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ GL.BindTexture((TextureTarget)3553, textureIds[i]); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias); ++ } ++ GL.BindTexture((TextureTarget)3553, 0); ++ } ++ ++ public override void SetSamplerLodBias(int samplerId, float bias) ++ { ++ GL.SamplerParameter(samplerId, (SamplerParameterName)34049, bias); ++ } ++ ++ /// GL sets the parameter on the texture bound to GL_TEXTURE_2D, as the overlay left it. ++ public override void SetTextureDepthCompare(int textureId, int mode) ++ { ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, mode); ++ } ++ ++ /// GL writes into the texture bound to GL_TEXTURE_2D, as CreateFrameBuffer left it. ++ public override void ClearTextureRegion(int textureId, int x, int y, int width, int height, int[] pixels) ++ { ++ GL.TexSubImage2D((TextureTarget)3553, 0, x, y, width, height, (PixelFormat)32993, (PixelType)5121, pixels); ++ } ++ ++ /// An RGBA8 texture with linear filtering from straight RGBA bytes. ++ public override int LoadTextureFromRgbaPointer(int width, int height, IntPtr pixels) ++ { ++ int num = GL.GenTexture(); ++ GL.BindTexture((TextureTarget)3553, num); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, width, height, 0, (PixelFormat)6408, (PixelType)5121, pixels); ++ return num; ++ } ++ ++ public override void SetProgramSamplerUnit(int programId, string samplerName, int unit) ++ { ++ GL.Uniform1(GL.GetUniformLocation(programId, samplerName), unit); ++ } ++ ++ public override void CreateOitTargets(FrameBufferRef transparent, int layers, out int revealTexture, out int accumTexture) ++ { ++ int width = transparent.Width; ++ int height = transparent.Height; ++ GL.BindFramebuffer((FramebufferTarget)36160, transparent.FboId); ++ revealTexture = GL.GenTexture(); ++ GL.BindTexture((TextureTarget)3553, revealTexture); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32849, width, height, 0, (PixelFormat)6407, (PixelType)5121, (IntPtr)0); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); ++ accumTexture = GL.GenTexture(); ++ GL.BindTexture((TextureTarget)35866, accumTexture); ++ GL.TexImage3D((TextureTarget)35866, 0, (PixelInternalFormat)34842, width, height, layers, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)0); ++ GL.TexParameter((TextureTarget)35866, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)35866, (TextureParameterName)10240, 9728); ++ GL.TexParameter((TextureTarget)35866, (TextureParameterName)10242, 33071); ++ GL.TexParameter((TextureTarget)35866, (TextureParameterName)10243, 33071); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, revealTexture, 0); ++ GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36067, accumTexture, 0, 0); ++ GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36068, accumTexture, 0, 1); ++ GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36069, accumTexture, 0, 2); ++ } ++ ++ public override void BeginOitAccumulation(FrameBufferRef transparent) ++ { ++ DrawBuffersEnum[] array2 = new DrawBuffersEnum[6] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3, DrawBuffersEnum.ColorAttachment4, DrawBuffersEnum.ColorAttachment5 }; ++ GL.DrawBuffers(array2.Length, array2); ++ GL.BlendFunc(0, (BlendingFactorSrc)774, (BlendingFactorDest)0); ++ GL.BlendFunc(1, (BlendingFactorSrc)774, (BlendingFactorDest)0); ++ GL.BlendFunc(3, (BlendingFactorSrc)1, (BlendingFactorDest)1); ++ GL.BlendFunc(4, (BlendingFactorSrc)1, (BlendingFactorDest)1); ++ GL.BlendFunc(5, (BlendingFactorSrc)1, (BlendingFactorDest)1); ++ float[] array3 = new float[4]; ++ float[] array4 = new float[4] { 1f, 1f, 1f, 1f }; ++ GL.ClearBuffer((ClearBuffer)6144, 0, array4); ++ GL.ClearBuffer((ClearBuffer)6144, 1, array4); ++ GL.ClearBuffer((ClearBuffer)6144, 3, array3); ++ GL.ClearBuffer((ClearBuffer)6144, 4, array3); ++ GL.ClearBuffer((ClearBuffer)6144, 5, array3); ++ } ++ ++ public override void BindOitTextures(int revealTexture, int accumTexture) ++ { ++ GL.ActiveTexture((TextureUnit)33990); ++ GL.BindTexture((TextureTarget)3553, revealTexture); ++ GL.ActiveTexture((TextureUnit)33991); ++ GL.BindTexture((TextureTarget)35866, accumTexture); ++ GL.ActiveTexture((TextureUnit)33984); ++ } ++ ++ public override int GenOcclusionQuery() ++ { ++ int queryId; ++ GL.GenQueries(1, out queryId); ++ return queryId; ++ } ++ ++ public override void BeginOcclusionQuery(int queryId) ++ { ++ GL.BeginQuery((QueryTarget)35092, queryId); ++ } ++ ++ public override void EndOcclusionQuery(int queryId) ++ { ++ GL.EndQuery((QueryTarget)35092); ++ } ++ ++ public override bool TryGetOcclusionQueryResult(int queryId, out int samples) ++ { ++ int num = default(int); ++ GL.GetQueryObject(queryId, (GetQueryObjectParam)34919, out num); ++ if (num > 0) ++ { ++ GL.GetQueryObject(queryId, (GetQueryObjectParam)34918, out samples); ++ return true; ++ } ++ samples = 0; ++ return false; ++ } ++ ++ public override void DeleteOcclusionQuery(int queryId) ++ { ++ GL.DeleteQuery(queryId); ++ } ++ ++ public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) ++ { ++ GL.ReadPixels(x, y, width, height, (PixelFormat)32993, (PixelType)5121, destination); ++ } ++ ++ public override string GraphicsBackendName ++ { ++ get ++ { ++ return "OpenGL"; ++ } ++ } ++ + /// + /// Optimum: glGetTexImage of level 0 in the parity dump's representation - + /// RGBA/UNSIGNED_BYTE for 8-bit unsigned-normalised formats, DEPTH_COMPONENT/FLOAT @@ -877,7 +1086,7 @@ index 6edf0c9..e7837a0 100644 //IL_0072: Unknown result type (might be due to invalid IL or missing references) //IL_008c: Unknown result type (might be due to invalid IL or missing references) //IL_00ad: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +1838,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1187,10 +2047,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); if (num == 0 || num2 == 0) { @@ -895,7 +1104,7 @@ index 6edf0c9..e7837a0 100644 FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef { FboId = GL.GenFramebuffer(), -@@ -1210,11 +1868,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1210,11 +2077,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); @@ -912,7 +1121,7 @@ index 6edf0c9..e7837a0 100644 GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +1913,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2122,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -951,7 +1160,7 @@ index 6edf0c9..e7837a0 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2126,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2335,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1027,7 +1236,7 @@ index 6edf0c9..e7837a0 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2303,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2512,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1042,7 +1251,7 @@ index 6edf0c9..e7837a0 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2326,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2535,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1056,7 +1265,7 @@ index 6edf0c9..e7837a0 100644 + /// member for it) attachments. + /// + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) - { ++ { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1108,7 +1317,7 @@ index 6edf0c9..e7837a0 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) -+ { + { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. @@ -1137,7 +1346,7 @@ index 6edf0c9..e7837a0 100644 } } } -@@ -1591,11 +2420,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2629,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1156,7 +1365,7 @@ index 6edf0c9..e7837a0 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2455,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2664,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1243,7 +1452,7 @@ index 6edf0c9..e7837a0 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +2564,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +2773,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1329,7 +1538,7 @@ index 6edf0c9..e7837a0 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +2644,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +2853,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1415,7 +1624,7 @@ index 6edf0c9..e7837a0 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +2726,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +2935,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1676,7 +1885,7 @@ index 6edf0c9..e7837a0 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +2990,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3199,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1713,7 +1922,7 @@ index 6edf0c9..e7837a0 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3029,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,45 +3238,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1768,7 +1977,7 @@ index 6edf0c9..e7837a0 100644 ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,35 +3099,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,35 +3308,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -1819,7 +2028,7 @@ index 6edf0c9..e7837a0 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,19 +3148,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,19 +3357,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -1845,7 +2054,7 @@ index 6edf0c9..e7837a0 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,24 +3184,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3393,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -1902,7 +2111,7 @@ index 6edf0c9..e7837a0 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3239,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3448,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2388,7 +2597,7 @@ index 6edf0c9..e7837a0 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +3875,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4084,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -2428,7 +2637,7 @@ index 6edf0c9..e7837a0 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4273,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4482,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -2481,7 +2690,7 @@ index 6edf0c9..e7837a0 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4368,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4577,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2526,7 +2735,7 @@ index 6edf0c9..e7837a0 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4405,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4614,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2547,7 +2756,7 @@ index 6edf0c9..e7837a0 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4424,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4633,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2568,7 +2777,7 @@ index 6edf0c9..e7837a0 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4443,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4652,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2589,7 +2798,7 @@ index 6edf0c9..e7837a0 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4462,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4671,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2610,7 +2819,7 @@ index 6edf0c9..e7837a0 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4485,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4694,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2631,7 +2840,7 @@ index 6edf0c9..e7837a0 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5047,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5256,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -2655,7 +2864,7 @@ index 6edf0c9..e7837a0 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +5406,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +5615,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch index 533dbf84..abefa740 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs -index f437458..db565ba 100644 +index f437458..35e8da7 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientSystemStartup.cs @@ -738,11 +738,14 @@ public class ClientSystemStartup : ClientSystem @@ -41,7 +41,7 @@ index f437458..db565ba 100644 } internal void BeginItemTesselation() -@@ -1071,11 +1080,17 @@ public class ClientSystemStartup : ClientSystem +@@ -1071,11 +1080,13 @@ public class ClientSystemStartup : ClientSystem } }, 1000L); } @@ -49,12 +49,8 @@ index f437458..db565ba 100644 game.AmbientManager.LateInit(); - if (GL.GetString((StringName)7937).Contains("Arc(TM)") && ClientSettings.AllowSSBOs) + // The advisory is about an Intel Arc OpenGL driver bug, so it only applies -+ // when the OpenGL backend is actually live; a non-OpenGL device skips it. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ string optimumRendererName = optimumDevice != null -+ ? null -+ : GL.GetString((StringName)7937); -+ if (optimumRendererName != null && optimumRendererName.Contains("Arc(TM)") && ClientSettings.AllowSSBOs) ++ // when the OpenGL backend is actually live; any other backend skips the GL query. ++ if (game.Platform.GraphicsBackendName == "OpenGL" && GL.GetString((StringName)7937).Contains("Arc(TM)") && ClientSettings.AllowSSBOs) { game.eventManager?.AddDelayedCallback(delegate { diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs.patch index 18624879..de2d8506 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs.patch @@ -1,38 +1,18 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs -index 0a45eb1..1315a97 100644 +index 0a45eb1..7f968bd 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/InventoryItemRenderer.cs -@@ -189,11 +189,34 @@ public class InventoryItemRenderer : IRenderer, IDisposable +@@ -189,11 +189,14 @@ public class InventoryItemRenderer : IRenderer, IDisposable if (clearPixels == null || clearPixels.Length < size * size) { clearPixels = new int[size * size]; } game.guiShaderProg.SepiaLevel = task.SepiaLevel; - GL.TexSubImage2D((TextureTarget)3553, 0, (int)num, (int)num2, size, size, (PixelFormat)32993, (PixelType)5121, clearPixels); -+ // Blanks this item's slot in the atlas before drawing into it. The GL -+ // form writes to whatever CreateFrameBuffer left bound; the device takes -+ // the atlas texture by id, which TexPos already carries. The pixels are -+ // all zero, so channel order does not matter. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ System.Runtime.InteropServices.GCHandle optimumPin = -+ System.Runtime.InteropServices.GCHandle.Alloc(clearPixels, -+ System.Runtime.InteropServices.GCHandleType.Pinned); -+ try -+ { -+ optimumDevice.UploadTexture2D(task.TexPos.atlasTextureId, 0, (int)num, (int)num2, -+ size, size, EnumTexturePixelFormat.Rgba, optimumPin.AddrOfPinnedObject()); -+ } -+ finally -+ { -+ optimumPin.Free(); -+ } -+ } -+ else -+ { -+ GL.TexSubImage2D((TextureTarget)3553, 0, (int)num, (int)num2, size, size, (PixelFormat)32993, (PixelType)5121, clearPixels); -+ } ++ // Blanks this item's slot in the atlas before drawing into it. The GL form ++ // writes to whatever CreateFrameBuffer left bound; the atlas texture id, ++ // which TexPos carries, is for platforms that address textures directly. ++ game.Platform.ClearTextureRegion(task.TexPos.atlasTextureId, (int)num, (int)num2, size, size, clearPixels); game.api.renderapi.inventoryItemRenderer.RenderItemstackToGui(dummySlot, num + (float)size / 2f, num2 + (float)size / 2f, 500.0, (float)(size / 2) * task.Scale, task.Color, shading: true, origRotate: false, showStackSize: false); } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index f926de75..60bf04b9 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..a16bc58 100644 +index 4a24e75..d077eb4 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..a16bc58 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,200 @@ public class ShaderRegistry +@@ -181,39 +183,199 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -158,11 +158,15 @@ index 4a24e75..a16bc58 100644 + /// + public static void ApplyOptimumTerrainSamplerLodBias(float bias) + { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunkopaque, "terrainTex", optimumDevice, bias); -+ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunkopaque, "terrainTexLinear", optimumDevice, bias); -+ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunktopsoil, "terrainTex", optimumDevice, bias); -+ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunktopsoil, "terrainTexLinear", optimumDevice, bias); ++ ClientPlatformAbstract platform = ScreenManager.Platform; ++ if (platform == null) ++ { ++ return; ++ } ++ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunkopaque, "terrainTex", platform, bias); ++ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunkopaque, "terrainTexLinear", platform, bias); ++ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunktopsoil, "terrainTex", platform, bias); ++ ApplyOptimumSamplerLodBias(ShaderPrograms.Chunktopsoil, "terrainTexLinear", platform, bias); + } + + /// @@ -172,18 +176,13 @@ index 4a24e75..a16bc58 100644 + /// a failed program load leaves the dictionary empty - and neither is an + /// error: the next load applies the bias from OptimumConfig anyway. + /// -+ private static void ApplyOptimumSamplerLodBias(ShaderProgramBase program, string samplerName, Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice, float bias) ++ private static void ApplyOptimumSamplerLodBias(ShaderProgramBase program, string samplerName, ClientPlatformAbstract platform, float bias) + { + if (program == null || !program.customSamplers.TryGetValue(samplerName, out var sampler)) + { + return; + } -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetSamplerParameter(sampler, OptimumGlConstants.TextureLodBias, bias); -+ return; -+ } -+ GL.SamplerParameter(sampler, (SamplerParameterName)34049, bias); ++ platform.SetSamplerLodBias(sampler, bias); + } + + // Optimum: shared per-program post-compile handling used by both the @@ -224,7 +223,7 @@ index 4a24e75..a16bc58 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +459,11 @@ public class ShaderRegistry +@@ -296,11 +458,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -237,7 +236,7 @@ index 4a24e75..a16bc58 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +496,48 @@ public class ShaderRegistry +@@ -333,10 +495,48 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch index 32b5eb58..e3ebf42b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch @@ -1,32 +1,26 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs -index 4548001..4c5f0d5 100644 +index 4548001..87a2113 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs -@@ -82,10 +82,27 @@ public class SvgLoader +@@ -82,17 +82,16 @@ public class SvgLoader ((Surface)intoSurface).MarkDirty(); } public unsafe LoadedTexture LoadSvg(IAsset svgAsset, int textureWidth, int textureHeight, int width = 0, int height = 0, int? color = null) { +- int num = GL.GenTexture(); +- GL.BindTexture((TextureTarget)3553, num); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); + // The rasterizer writes straight RGBA bytes, unlike the Cairo paths which -+ // hand over BGRA, so this is the plain RGBA8 upload. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ int optimumTextureId; -+ fixed (byte* pixels = rasterizeSvg(svgAsset, textureWidth, textureHeight, width, height, color)) -+ { -+ optimumTextureId = optimumDevice.CreateTexture2DRaw(textureWidth, textureHeight, -+ Vintagestory.API.Config.OptimumGlConstants.Rgba8, (IntPtr)(nint)pixels, 4); -+ } -+ optimumDevice.SetTextureParameter(optimumTextureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9729); -+ optimumDevice.SetTextureParameter(optimumTextureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureMagFilter, 9729); -+ return new LoadedTexture(capi, optimumTextureId, width, height); -+ } - int num = GL.GenTexture(); - GL.BindTexture((TextureTarget)3553, num); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ // hand over BGRA, so this is the plain RGBA8 upload with linear filtering. ++ int num; fixed (byte* ptr = rasterizeSvg(svgAsset, textureWidth, textureHeight, width, height, color)) + { +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, textureWidth, textureHeight, 0, (PixelFormat)6408, (PixelType)5121, (IntPtr)(nint)ptr); ++ num = ScreenManager.Platform.LoadTextureFromRgbaPointer(textureWidth, textureHeight, (IntPtr)(nint)ptr); + } + return new LoadedTexture(capi, num, width, height); + } + + public unsafe byte[] rasterizeSvg(IAsset svgAsset, int textureWidth, int textureHeight, int width = 0, int height = 0, int? color = null) diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch index c6e34725..c58f31bf 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs -index cb0a94d..559049f 100644 +index cb0a94d..e2aac9e 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs @@ -136,54 +136,54 @@ internal class SystemRenderFrameBufferDebug : ClientSystem @@ -9,7 +9,7 @@ index cb0a94d..559049f 100644 frameBufferRef = game.Platform.FrameBuffers[11]; debugdepthbuffer.DepthSampler2D = frameBufferRef.DepthTextureId; - GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, 0); -+ SetOptimumDepthCompare(frameBufferRef.DepthTextureId, 0); ++ game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 0); game.GlPushMatrix(); game.GlTranslate(gUIScale * 170f, gUIScale * 10f, gUIScale * 50f); game.GlScale(gUIScale * 300f, gUIScale * 300f, 0.0); @@ -21,14 +21,14 @@ index cb0a94d..559049f 100644 game.Platform.RenderMesh(quadModel); game.GlPopMatrix(); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, 34894); -+ SetOptimumDepthCompare(frameBufferRef.DepthTextureId, 34894); ++ game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 34894); } if (shadowMapQuality > 1) { frameBufferRef = game.Platform.FrameBuffers[12]; debugdepthbuffer.DepthSampler2D = frameBufferRef.DepthTextureId; - GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, 0); -+ SetOptimumDepthCompare(frameBufferRef.DepthTextureId, 0); ++ game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 0); game.GlPushMatrix(); game.GlTranslate(gUIScale * 170f, gUIScale * 320f, gUIScale * 50f); game.GlScale(gUIScale * 300f, gUIScale * 300f, 0.0); @@ -40,12 +40,12 @@ index cb0a94d..559049f 100644 game.Platform.RenderMesh(quadModel); game.GlPopMatrix(); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, 34894); -+ SetOptimumDepthCompare(frameBufferRef.DepthTextureId, 34894); ++ game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 34894); } frameBufferRef = game.Platform.FrameBuffers[5]; debugdepthbuffer.DepthSampler2D = frameBufferRef.DepthTextureId; - GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, 0); -+ SetOptimumDepthCompare(frameBufferRef.DepthTextureId, 0); ++ game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 0); game.GlPushMatrix(); game.GlTranslate(gUIScale * 170f, gUIScale * 630f, gUIScale * 50f); game.GlScale(gUIScale * 300f, gUIScale * 300f, 0.0); @@ -57,39 +57,9 @@ index cb0a94d..559049f 100644 game.Platform.RenderMesh(quadModel); game.GlPopMatrix(); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, 34894); -+ SetOptimumDepthCompare(frameBufferRef.DepthTextureId, 34894); ++ game.Platform.SetTextureDepthCompare(frameBufferRef.DepthTextureId, 34894); debugdepthbuffer.Stop(); game.guiShaderProg.Use(); game.Platform.GlDisableDepthTest(); game.Render2DLoadedTexture(labels[13], gUIScale * 170f, gUIScale * 630f); if (shadowMapQuality > 0) -@@ -198,10 +198,29 @@ internal class SystemRenderFrameBufferDebug : ClientSystem - game.Render2DLoadedTexture(labels[6], (float)game.Width - gUIScale * 170f, gUIScale * (float)num); - game.Platform.GlToggleBlend(on: true); - } - } - -+ /// -+ /// Optimum: switches a shadow map's depth-compare mode. -+ /// -+ /// The debug overlay reads shadow maps as plain values rather than through -+ /// the comparison sampler, then puts the comparison back. In GL that is a -+ /// parameter on whatever is bound; the device takes the texture itself. -+ /// -+ private static void SetOptimumDepthCompare(int textureId, int mode) -+ { -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetTextureParameter(textureId, -+ Vintagestory.API.Config.OptimumGlConstants.TextureCompareMode, mode); -+ return; -+ } -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, mode); -+ } -+ - private TextCommandResult CmdWoit(TextCommandCallingArgs textCommandCallingArgs) - { - framebufferDebug = !framebufferDebug; - return TextCommandResult.Success(); - } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch index 89f2575b..005c37c4 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs -index bba43f6..d855d51 100644 +index bba43f6..cda5b34 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs -@@ -18,66 +18,188 @@ public class SystemRenderOITLayers : ClientSystem +@@ -18,85 +18,107 @@ public class SystemRenderOITLayers : ClientSystem public int RenderRange => 1; public BeforeOIT(ICoreClientAPI capi) @@ -34,8 +34,7 @@ index bba43f6..d855d51 100644 + if (SystemRenderOITLayers.optimumOitDisabled) return; + IShaderProgram currentActiveShader = null; + try - { -- rebuild(); ++ { + if (capi == null || capi.Render == null || capi.Shader == null || capi.Render.FrameBuffers == null || capi.Render.FrameBuffers.Count <= 1) + { + SystemRenderOITLayers.DisableOptimumOit(capi, "render resources are unavailable"); @@ -60,15 +59,7 @@ index bba43f6..d855d51 100644 + program.Uniform("OITreveal", 6); + // The accumulation sampler has no typed setter on IShaderProgram, + // so it is pointed at its unit directly. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetSamplerUnit(program.ProgramId, "OITaccumulation", 7); -+ } -+ else -+ { -+ GL.Uniform1(GL.GetUniformLocation(program.ProgramId, "OITaccumulation"), 7); -+ } ++ ScreenManager.Platform.SetProgramSamplerUnit(program.ProgramId, "OITaccumulation", 7); + program.Stop(); + programByName.Use(); + programByName.Uniform("liquidDepth", 4); @@ -84,41 +75,11 @@ index bba43f6..d855d51 100644 + return; + } + } -+ DrawBuffersEnum[] array2 = new DrawBuffersEnum[6] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3, DrawBuffersEnum.ColorAttachment4, DrawBuffersEnum.ColorAttachment5 }; -+ float[] array3 = new float[4]; -+ float[] array4 = new float[4] { 1f, 1f, 1f, 1f }; -+ if (optimumDevice != null) -+ { -+ // Attachments 0 and 1 hold revealage and multiply down from -+ // one; 3, 4 and 5 accumulate additively from zero. Attachment -+ // 2 is written by the pass itself and keeps vanilla blending. -+ optimumDevice.SetDrawBuffers(currentTransparentfb.FboId, 0x3F); -+ optimumDevice.SetBlendFuncSeparate(0, 774, 0, 774, 0); -+ optimumDevice.SetBlendFuncSeparate(1, 774, 0, 774, 0); -+ optimumDevice.SetBlendFuncSeparate(3, 1, 1, 1, 1); -+ optimumDevice.SetBlendFuncSeparate(4, 1, 1, 1, 1); -+ optimumDevice.SetBlendFuncSeparate(5, 1, 1, 1, 1); -+ optimumDevice.ClearColor(0, array4[0], array4[1], array4[2], array4[3]); -+ optimumDevice.ClearColor(1, array4[0], array4[1], array4[2], array4[3]); -+ optimumDevice.ClearColor(3, array3[0], array3[1], array3[2], array3[3]); -+ optimumDevice.ClearColor(4, array3[0], array3[1], array3[2], array3[3]); -+ optimumDevice.ClearColor(5, array3[0], array3[1], array3[2], array3[3]); -+ return; -+ } -+ GL.DrawBuffers(array2.Length, array2); -+ GL.BlendFunc(0, (BlendingFactorSrc)774, (BlendingFactorDest)0); -+ GL.BlendFunc(1, (BlendingFactorSrc)774, (BlendingFactorDest)0); -+ GL.BlendFunc(3, (BlendingFactorSrc)1, (BlendingFactorDest)1); -+ GL.BlendFunc(4, (BlendingFactorSrc)1, (BlendingFactorDest)1); -+ GL.BlendFunc(5, (BlendingFactorSrc)1, (BlendingFactorDest)1); -+ GL.ClearBuffer((ClearBuffer)6144, 0, array4); -+ GL.ClearBuffer((ClearBuffer)6144, 1, array4); -+ GL.ClearBuffer((ClearBuffer)6144, 3, array3); -+ GL.ClearBuffer((ClearBuffer)6144, 4, array3); -+ GL.ClearBuffer((ClearBuffer)6144, 5, array3); ++ ScreenManager.Platform.BeginOitAccumulation(currentTransparentfb); + } + catch (Exception e) -+ { + { +- rebuild(); + SystemRenderOITLayers.DisableOptimumOit(capi, "OIT render callback failed", e); + try { freeResources(); } catch { } + try @@ -155,17 +116,8 @@ index bba43f6..d855d51 100644 { - GL.DeleteTexture(accumTextureId); - GL.DeleteTexture(revealTextureId); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DeleteTexture(accumTextureId); -+ optimumDevice.DeleteTexture(revealTextureId); -+ } -+ else -+ { -+ GL.DeleteTexture(accumTextureId); -+ GL.DeleteTexture(revealTextureId); -+ } ++ ScreenManager.Platform.GLDeleteTexture(accumTextureId); ++ ScreenManager.Platform.GLDeleteTexture(revealTextureId); accumTextureId = 0; revealTextureId = 0; } @@ -174,55 +126,43 @@ index bba43f6..d855d51 100644 { - freeResources(); + try { freeResources(); } catch (Exception e) { SystemRenderOITLayers.DisableOptimumOit(capi, "OIT resource cleanup failed", e); } -+ } -+ -+ /// -+ /// Optimum: the sampling both OIT targets use - nearest filtering and -+ /// clamped wrapping, because they are read back per fragment at exactly -+ /// the coordinate that produced them. -+ /// -+ private static void SetOptimumOitSampling( -+ Vintagestory.API.Config.IOptimumGraphicsDevice device, int textureId) -+ { -+ device.SetTextureParameter(textureId, Vintagestory.API.Config.OptimumGlConstants.TextureMinFilter, 9728); -+ device.SetTextureParameter(textureId, Vintagestory.API.Config.OptimumGlConstants.TextureMagFilter, 9728); -+ device.SetTextureParameter(textureId, Vintagestory.API.Config.OptimumGlConstants.TextureWrapS, 33071); -+ device.SetTextureParameter(textureId, Vintagestory.API.Config.OptimumGlConstants.TextureWrapT, 33071); } ++ private void rebuild() { freeResources(); transparentfb = capi.Render.FrameBuffers[1]; - int width = transparentfb.Width; - int height = transparentfb.Height; -+ // The accumulation target is a three-layer 2D array, one layer per -+ // OIT weight bucket, attached layer by layer at colour attachments -+ // 3, 4 and 5. The device addresses textures directly, so there is no -+ // framebuffer or texture to bind first. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ revealTextureId = optimumDevice.CreateTexture2DRaw(width, height, -+ Vintagestory.API.Config.OptimumGlConstants.Rgba8, IntPtr.Zero, 0); -+ SetOptimumOitSampling(optimumDevice, revealTextureId); -+ -+ accumTextureId = optimumDevice.CreateTexture2DArray(width, height, layers, -+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba); -+ SetOptimumOitSampling(optimumDevice, accumTextureId); -+ -+ optimumDevice.AttachTexture(transparentfb.FboId, EnumFramebufferAttachment.ColorAttachment0, revealTextureId, 0); -+ optimumDevice.AttachTexture(transparentfb.FboId, EnumFramebufferAttachment.ColorAttachment3, accumTextureId, 0); -+ optimumDevice.AttachTexture(transparentfb.FboId, EnumFramebufferAttachment.ColorAttachment4, accumTextureId, 1); -+ optimumDevice.AttachTexture(transparentfb.FboId, (EnumFramebufferAttachment)36069, accumTextureId, 2); -+ return; -+ } - GL.BindFramebuffer((FramebufferTarget)36160, transparentfb.FboId); - revealTextureId = GL.GenTexture(); - GL.BindTexture((TextureTarget)3553, revealTextureId); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32849, width, height, 0, (PixelFormat)6407, (PixelType)5121, (IntPtr)0); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); -@@ -104,15 +226,32 @@ public class SystemRenderOITLayers : ClientSystem +- int width = transparentfb.Width; +- int height = transparentfb.Height; +- GL.BindFramebuffer((FramebufferTarget)36160, transparentfb.FboId); +- revealTextureId = GL.GenTexture(); +- GL.BindTexture((TextureTarget)3553, revealTextureId); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32849, width, height, 0, (PixelFormat)6407, (PixelType)5121, (IntPtr)0); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); +- accumTextureId = GL.GenTexture(); +- GL.BindTexture((TextureTarget)35866, accumTextureId); +- GL.TexImage3D((TextureTarget)35866, 0, (PixelInternalFormat)34842, width, height, 3, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)0); +- GL.TexParameter((TextureTarget)35866, (TextureParameterName)10241, 9728); +- GL.TexParameter((TextureTarget)35866, (TextureParameterName)10240, 9728); +- GL.TexParameter((TextureTarget)35866, (TextureParameterName)10242, 33071); +- GL.TexParameter((TextureTarget)35866, (TextureParameterName)10243, 33071); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, revealTextureId, 0); +- GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36067, accumTextureId, 0, 0); +- GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36068, accumTextureId, 0, 1); +- GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36069, accumTextureId, 0, 2); ++ // The reveal target and the three-layer accumulation array, attached to the ++ // transparent framebuffer at colour attachments 0 and 3-5. ++ ScreenManager.Platform.CreateOitTargets(transparentfb, layers, out revealTextureId, out accumTextureId); + } + } + + public class AfterOIT : IRenderer, IDisposable + { +@@ -104,15 +126,21 @@ public class SystemRenderOITLayers : ClientSystem public int RenderRange => 1; @@ -237,18 +177,7 @@ index bba43f6..d855d51 100644 + try + { + // Units 6 and 7, matching the sampler assignments BeforeOIT made. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.BindTexture(6, revealTextureId); -+ optimumDevice.BindTexture(7, accumTextureId); -+ return; -+ } -+ GL.ActiveTexture((TextureUnit)33990); -+ GL.BindTexture((TextureTarget)3553, revealTextureId); -+ GL.ActiveTexture((TextureUnit)33991); -+ GL.BindTexture((TextureTarget)35866, accumTextureId); -+ GL.ActiveTexture((TextureUnit)33984); ++ ScreenManager.Platform.BindOitTextures(revealTextureId, accumTextureId); + } + catch (Exception e) + { @@ -260,7 +189,7 @@ index bba43f6..d855d51 100644 public void Dispose() { } -@@ -122,19 +261,65 @@ public class SystemRenderOITLayers : ClientSystem +@@ -122,19 +150,47 @@ public class SystemRenderOITLayers : ClientSystem private static int accumTextureId; @@ -272,31 +201,13 @@ index bba43f6..d855d51 100644 + + private static void RestoreVanillaTransparentState() + { -+ DrawBuffersEnum[] vanillaBuffers = new DrawBuffersEnum[3] ++ // Back to the three attachments and blend state LoadFrameBuffer sets up for ++ // the vanilla transparent pass, through the same platform member it uses. ++ ClientPlatformAbstract platform = ScreenManager.Platform; ++ if (platform != null) + { -+ DrawBuffersEnum.ColorAttachment0, -+ DrawBuffersEnum.ColorAttachment1, -+ DrawBuffersEnum.ColorAttachment2 -+ }; -+ // Back to the three attachments and blend factors LoadFrameBuffer sets up -+ // for the vanilla transparent pass. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ FrameBufferRef transparent = ScreenManager.Platform?.FrameBuffers?[1]; -+ if (transparent != null && !transparent.Disposed) -+ { -+ optimumDevice.SetDrawBuffers(transparent.FboId, 0x7); -+ } -+ optimumDevice.SetBlendFuncSeparate(0, 1, 1, 1, 1); -+ optimumDevice.SetBlendFuncSeparate(1, 0, 769, 0, 769); -+ optimumDevice.SetBlendFuncSeparate(2, 770, 771, 770, 771); -+ return; ++ platform.ApplyTransparentPassBlendState(); + } -+ GL.DrawBuffers(vanillaBuffers.Length, vanillaBuffers); -+ GL.BlendFunc(0, (BlendingFactorSrc)1, (BlendingFactorDest)1); -+ GL.BlendFunc(1, (BlendingFactorSrc)0, (BlendingFactorDest)769); -+ GL.BlendFunc(2, (BlendingFactorSrc)770, (BlendingFactorDest)771); + } + + private static void DisableOptimumOit(ICoreClientAPI capi, string reason, Exception error = null) diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch index 9b43b813..b85781e3 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch @@ -1,81 +1,49 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs -index ac7f262..4e1d839 100644 +index ac7f262..ba9f43a 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs -@@ -52,11 +52,19 @@ public class SystemRenderSunMoon : ClientSystem +@@ -52,11 +52,11 @@ public class SystemRenderSunMoon : ClientSystem MeshData customQuadModelData = QuadMeshUtilExt.GetCustomQuadModelData(0f, 0f, 0f, ImageSize, ImageSize, byte.MaxValue, byte.MaxValue, byte.MaxValue, byte.MaxValue); customQuadModelData.Flags = new int[4]; quadModel = game.Platform.UploadMesh(customQuadModelData); game.eventManager.RegisterRenderer(OnRenderFrame3D, EnumRenderStage.Opaque, Name, 0.3); game.eventManager.RegisterRenderer(OnRenderFrame3DPost, EnumRenderStage.Opaque, Name, 999.0); - GL.GenQueries(1, out occlQueryId); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ occlQueryId = optimumDevice.CreateOcclusionQuery(); -+ } -+ else -+ { -+ GL.GenQueries(1, out occlQueryId); -+ } ++ occlQueryId = game.Platform.GenOcclusionQuery(); } private void OnRenderFrame3DPost(float obj) { //IL_0052: Unknown result type (might be due to invalid IL or missing references) -@@ -75,11 +83,21 @@ public class SystemRenderSunMoon : ClientSystem +@@ -75,11 +75,11 @@ public class SystemRenderSunMoon : ClientSystem ClientPlatformAbstract platform = game.Platform; platform.GlEnableDepthTest(); platform.GlToggleBlend(on: true); platform.GlDisableCullFace(); platform.GlDepthMask(flag: false); - GL.ColorMask(false, false, false, false); -+ // The sun occlusion probe draws a quad purely to be counted by the query; -+ // nothing of it should reach the colour buffer. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetColorMask(false, false, false, false); -+ } -+ else -+ { -+ GL.ColorMask(false, false, false, false); -+ } ++ platform.GlColorMask(false, false, false, false); Vec3f sunPosition = game.Calendar.SunPosition; Quaternion val = CreateLookRotation(new Vector3(sunPosition.X, sunPosition.Y, sunPosition.Z)); sunmat = Matrix4.CreateTranslation((float)(-ImageSize / 2), (float)(-ImageSize), (float)(-ImageSize / 2)) * Matrix4.CreateScale(sunScale, sunScale * 7f, sunScale) * Matrix4.CreateFromQuaternion(val) * Matrix4.CreateTranslation(new Vector3(sunPosition.X, sunPosition.Y, sunPosition.Z)); ShaderProgramStandard standard = ShaderPrograms.Standard; standard.Use(); -@@ -101,35 +119,67 @@ public class SystemRenderSunMoon : ClientSystem +@@ -100,36 +100,33 @@ public class SystemRenderSunMoon : ClientSystem + standard.ViewMatrix = game.api.renderapi.CameraMatrixOriginf; standard.ProjectionMatrix = game.api.renderapi.CurrentProjectionMatrix; standard.Tex2D = suntextureId; if (firstTickDone) { - int num = default(int); +- int num = default(int); - GL.GetQueryObject(occlQueryId, (GetQueryObjectParam)34919, out num); - if (num > 0) -+ if (optimumDevice != null) -+ { -+ if (optimumDevice.IsQueryResultAvailable(occlQueryId)) -+ { -+ targetSunSpec = GameMath.Clamp((float)optimumDevice.GetQueryResult(occlQueryId) / 1500f, 0f, 1f); -+ nowQuerying = false; -+ } -+ } -+ else ++ int num2 = default(int); ++ if (platform.TryGetOcclusionQueryResult(occlQueryId, out num2)) { - int num2 = default(int); - GL.GetQueryObject(occlQueryId, (GetQueryObjectParam)34918, out num2); -- targetSunSpec = GameMath.Clamp((float)num2 / 1500f, 0f, 1f); -- nowQuerying = false; -+ GL.GetQueryObject(occlQueryId, (GetQueryObjectParam)34919, out num); -+ if (num > 0) -+ { -+ int num2 = default(int); -+ GL.GetQueryObject(occlQueryId, (GetQueryObjectParam)34918, out num2); -+ targetSunSpec = GameMath.Clamp((float)num2 / 1500f, 0f, 1f); -+ nowQuerying = false; -+ } + targetSunSpec = GameMath.Clamp((float)num2 / 1500f, 0f, 1f); + nowQuerying = false; } } firstTickDone = true; @@ -83,14 +51,7 @@ index ac7f262..4e1d839 100644 if (!nowQuerying) { - GL.BeginQuery((QueryTarget)35092, occlQueryId); -+ if (optimumDevice != null) -+ { -+ optimumDevice.BeginOcclusionQuery(occlQueryId); -+ } -+ else -+ { -+ GL.BeginQuery((QueryTarget)35092, occlQueryId); -+ } ++ platform.BeginOcclusionQuery(occlQueryId); nowQuerying = true; flag = true; } @@ -99,46 +60,24 @@ index ac7f262..4e1d839 100644 if (flag) { - GL.EndQuery((QueryTarget)35092); -+ if (optimumDevice != null) -+ { -+ optimumDevice.EndOcclusionQuery(occlQueryId); -+ } -+ else -+ { -+ GL.EndQuery((QueryTarget)35092); -+ } ++ platform.EndOcclusionQuery(occlQueryId); } platform.GlDepthMask(flag: true); - GL.ColorMask(true, true, true, true); -+ if (optimumDevice != null) -+ { -+ optimumDevice.SetColorMask(true, true, true, true); -+ } -+ else -+ { -+ GL.ColorMask(true, true, true, true); -+ } ++ platform.GlColorMask(true, true, true, true); } public void OnRenderFrame3D(float dt) { ClientPlatformAbstract platform = game.Platform; -@@ -420,11 +470,19 @@ public class SystemRenderSunMoon : ClientSystem +@@ -420,11 +417,11 @@ public class SystemRenderSunMoon : ClientSystem game.Platform.GLDeleteTexture(suntextureId); for (int i = 0; i < moontextureIds.Length; i++) { game.Platform.GLDeleteTexture(moontextureIds[i]); } - GL.DeleteQuery(occlQueryId); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.DeleteQuery(occlQueryId); -+ } -+ else -+ { -+ GL.DeleteQuery(occlQueryId); -+ } ++ game.Platform.DeleteOcclusionQuery(occlQueryId); } public override EnumClientSystemType GetSystemType() diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs.patch index 8d385649..3422a2c9 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs.patch @@ -1,28 +1,62 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs b/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs -index 6e4f262..2e7101a 100644 +index 6e4f262..e59d597 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/VAO.cs -@@ -70,10 +70,23 @@ public class VAO : MeshRef - } - } +@@ -72,51 +72,18 @@ public class VAO : MeshRef public override void Dispose() { -+ // Optimum: on the device path VaoId is a device handle, not a GL vertex -+ // array, and the per-attribute vbo fields are all zero - the device owns -+ // those buffers and frees them through its own deferred deletion. Falling -+ // through to the GL body would hand an unrelated integer to -+ // glDeleteVertexArray. -+ // -+ // This also runs from the finalizer thread, which is exactly why the -+ // device defers rather than destroying inline. -+ if (Vintagestory.API.Config.OptimumRender.Device != null) -+ { -+ base.Dispose(); -+ return; -+ } if (!base.Disposed) { - if (xyzVboId != 0) +- if (xyzVboId != 0) ++ // Optimum: the buffers behind this mesh are the platform's handles (GL buffer ++ // and vertex-array names, or the Vulkan platform's device mesh), so the ++ // platform that made them deletes them. ++ ClientPlatformAbstract platform = ScreenManager.Platform; ++ if (platform != null) { - GL.DeleteBuffer(xyzVboId); +- GL.DeleteBuffer(xyzVboId); ++ platform.DeleteVertexArrayHandles(this); + } +- if (normalsVboId != 0) +- { +- GL.DeleteBuffer(normalsVboId); +- } +- if (uvVboId != 0) +- { +- GL.DeleteBuffer(uvVboId); +- } +- if (rgbaVboId != 0) +- { +- GL.DeleteBuffer(rgbaVboId); +- } +- if (customDataFloatVboId != 0) +- { +- GL.DeleteBuffer(customDataFloatVboId); +- } +- if (customDataShortVboId != 0) +- { +- GL.DeleteBuffer(customDataShortVboId); +- } +- if (customDataIntVboId != 0) +- { +- GL.DeleteBuffer(customDataIntVboId); +- } +- if (customDataByteVboId != 0) +- { +- GL.DeleteBuffer(customDataByteVboId); +- } +- if (vboIdIndex != 0 && vboIdIndex != ClientPlatformAbstract.singleIndexBufferId) +- { +- GL.DeleteBuffer(vboIdIndex); +- } +- if (flagsVboId != 0) +- { +- GL.DeleteBuffer(flagsVboId); +- } +- GL.DeleteVertexArray(VaoId); + base.Dispose(); + } + } + + ~VAO() diff --git a/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch b/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch index e27971a2..aba4ec7a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch @@ -1,30 +1,19 @@ diff --git a/VintagestoryLib/Vintagestory.Client/ScreenManager.cs b/VintagestoryLib/Vintagestory.Client/ScreenManager.cs -index b5000e7..8bc52fb 100644 +index b5000e7..1befc0c 100644 --- a/VintagestoryLib/Vintagestory.Client/ScreenManager.cs +++ b/VintagestoryLib/Vintagestory.Client/ScreenManager.cs -@@ -731,13 +731,25 @@ public class ScreenManager : KeyEventHandler, MouseEventHandler, NewFrameHandler +@@ -731,13 +731,14 @@ public class ScreenManager : KeyEventHandler, MouseEventHandler, NewFrameHandler Platform.CheckGlError(); FrameProfiler.Mark("doneRender2Default"); Mat4f.Identity(api.renderapi.pMatrix); Mat4f.Ortho(api.renderapi.pMatrix, 0f, Platform.WindowSize.Width, Platform.WindowSize.Height, 0f, 0f, 20001f); Platform.GlDepthFunc(EnumDepthFunction.Lequal); + // Mono.Cecil transplant. -+ // Both GL calls clamp: glClearBuffer's depth value and glDepthRange's -+ // bounds are clamped to [0, 1], so 20000 means 1 and the depth range is -+ // left at its default. The device path clears to the same 1. float num = 20000f; - GL.ClearBuffer((ClearBuffer)6145, 0, ref num); - GL.DepthRange(0f, 20000f); -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.ClearDepth(1f); -+ } -+ else -+ { -+ GL.ClearBuffer((ClearBuffer)6145, 0, ref num); -+ GL.DepthRange(0f, 20000f); -+ } ++ Platform.ClearDefaultDepth(num); ++ Platform.SetDepthRange(0f, 20000f); Platform.GlEnableDepthTest(); Platform.GlDisableCullFace(); Platform.GlToggleBlend(on: true); diff --git a/patches/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs.patch b/patches/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs.patch index ccdee1de..3c99e1c7 100644 --- a/patches/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs.patch @@ -1,26 +1,15 @@ diff --git a/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs b/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs -index b08094b..1c29dc7 100644 +index b08094b..be592aa 100644 --- a/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs +++ b/VintagestoryLib/Vintagestory.ClientNative/Screenshot.cs -@@ -68,11 +68,22 @@ public class Screenshot +@@ -68,11 +68,11 @@ public class Screenshot //IL_009b: Unknown result type (might be due to invalid IL or missing references) //IL_00a1: Expected O, but got Unknown //IL_00a2: Unknown result type (might be due to invalid IL or missing references) //IL_00a8: Expected O, but got Unknown SKBitmap val = new SKBitmap(new SKImageInfo(size.Width, size.Height, (SKColorType)6, (SKAlphaType)((!withAlpha) ? 1 : 3))); - GL.ReadPixels(0, 0, size.Width, size.Height, (PixelFormat)32993, (PixelType)5121, val.GetPixels()); -+ // The device reads back its own default colour target, which is the same -+ // image GL would read from the back buffer and in the same orientation - -+ // the one flip happens at present, after this. -+ Vintagestory.API.Config.IOptimumGraphicsDevice optimumDevice = Vintagestory.API.Config.OptimumRender.Device; -+ if (optimumDevice != null) -+ { -+ optimumDevice.ReadDefaultFramebuffer(0, 0, size.Width, size.Height, val.GetPixels()); -+ } -+ else -+ { -+ GL.ReadPixels(0, 0, size.Width, size.Height, (PixelFormat)32993, (PixelType)5121, val.GetPixels()); -+ } ++ Vintagestory.Client.ScreenManager.Platform.ReadDefaultFramebuffer(0, 0, size.Width, size.Height, val.GetPixels()); if (scaleScreenshot) { val = val.Resize(new SKImageInfo(((NativeWindow)d_GameWindow).ClientSize.X, ((NativeWindow)d_GameWindow).ClientSize.Y), new SKSamplingOptions(SKCubicResampler.Mitchell)); diff --git a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs index 638ec00c..cf284878 100644 --- a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs +++ b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumBc7Support.cs @@ -30,7 +30,7 @@ public static void DetectSupport() { // The Vulkan backend uploads pages as RGBA8 through the device and has // no compressed upload route, so BC7 stays off there. - if (OptimumRender.Device != null) + if (OptimumRender.IsVulkan) { OptimumConfig.MapPageCacheBc7Supported = false; return; @@ -119,7 +119,7 @@ public static void DetectSupport() /// public static void UploadCompressedLayer(int textureId, int layer, byte[] compressedData, int width, int height) { - if (OptimumRender.Device != null) + if (OptimumRender.IsVulkan) { throw new InvalidOperationException("BC7 map page upload is not available on the Vulkan backend"); } diff --git a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs index d7b0ae44..6b8bf6b9 100644 --- a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs +++ b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapPageRenderer.cs @@ -89,7 +89,7 @@ public void EndFrame(float viewportWidth, float viewportHeight) { if (_instanceCount == 0 || !Ready) return; - IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; + OptimumForkGraphics optimumGraphics = OptimumForkGraphics.Active; // The map renders inside the GUI pass which uses the 'gui' engine shader. // Entity/player/waypoint layers call GetEngineShader(Gui) and set uniforms @@ -100,7 +100,7 @@ public void EndFrame(float viewportWidth, float viewportHeight) // The GUI pass runs with depth testing on; only GL can be asked, so the // device path restores that known state rather than querying it. - bool depthTestWasOn = optimumDevice != null || GL.IsEnabled(EnableCap.DepthTest); + bool depthTestWasOn = optimumGraphics != null || GL.IsEnabled(EnableCap.DepthTest); currentShader?.Stop(); @@ -124,9 +124,9 @@ public void EndFrame(float viewportWidth, float viewportHeight) // Bind the texture array to unit 0. The engine's BindTexture2D helper // binds GL_TEXTURE_2D, so the array target needs its own bind here. - if (optimumDevice != null) + if (optimumGraphics != null) { - optimumDevice.BindTexture(0, _texArray.TextureId); + optimumGraphics.BindTexture(0, _texArray.TextureId); } else { @@ -164,9 +164,9 @@ public void EndFrame(float viewportWidth, float viewportHeight) // Unbind the texture array from unit 0 so the GUI shader finds its // expected 2D texture on that unit. - if (optimumDevice != null) + if (optimumGraphics != null) { - optimumDevice.BindTexture(0, 0); + optimumGraphics.BindTexture(0, 0); } else { diff --git a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs index 47387658..d9f68ca7 100644 --- a/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs +++ b/sources/VSEssentials/Systems/WorldMap/ChunkLayer/OptimumMapTextureArray.cs @@ -55,15 +55,15 @@ public OptimumMapTextureArray(int maxLayers) // Create the texture array. On the Vulkan backend the window has no GL // context, so the device owns the array; the GL body stays for OpenGL. - IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; - if (optimumDevice != null) + OptimumForkGraphics optimumGraphics = OptimumForkGraphics.Active; + if (optimumGraphics != null) { - TextureId = optimumDevice.CreateTexture2DArray(PageSize, PageSize, maxLayers, + TextureId = optimumGraphics.CreateTexture2DArray(PageSize, PageSize, maxLayers, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba); - optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - optimumDevice.SetTextureParameter(TextureId, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + optimumGraphics.SetTextureParameter(TextureId, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + optimumGraphics.SetTextureParameter(TextureId, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + optimumGraphics.SetTextureParameter(TextureId, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + optimumGraphics.SetTextureParameter(TextureId, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); return; } @@ -164,8 +164,8 @@ public void Dispose() if (TextureId != 0) { - IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; - if (optimumDevice != null) optimumDevice.DeleteTexture(TextureId); + OptimumForkGraphics optimumGraphics = OptimumForkGraphics.Active; + if (optimumGraphics != null) optimumGraphics.DeleteTexture(TextureId); else GL.DeleteTexture(TextureId); TextureId = 0; } @@ -178,14 +178,14 @@ public void Dispose() private void UploadToLayer(int layer, int[] pixels) { - IOptimumGraphicsDevice optimumDevice = OptimumRender.Device; - if (optimumDevice != null) + OptimumForkGraphics optimumGraphics = OptimumForkGraphics.Active; + if (optimumGraphics != null) { unsafe { fixed (int* ptr = pixels) { - optimumDevice.UploadTexture2DArrayLayer(TextureId, layer, 0, 0, PageSize, PageSize, (IntPtr)ptr); + optimumGraphics.UploadTexture2DArrayLayer(TextureId, layer, 0, 0, PageSize, PageSize, (IntPtr)ptr); } } return; diff --git a/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs b/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs index cdcc357b..a936cf2b 100644 --- a/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs +++ b/sources/VintagestoryApi/Client/optimum-render-bootstrap.cs @@ -9,8 +9,8 @@ namespace Vintagestory.API.Config; /// /// The backend lives in its own assembly and is loaded by name, so no vanilla /// assembly ever gains a reference to a renderer implementation - the client -/// only ever sees through -/// , which is null on the OpenGL path. +/// only ever sees the platform returns, a subclass of +/// its own ClientPlatformWindows that overrides the graphics virtuals. /// /// The order of operations is forced by the window. A window created with no /// graphics API cannot be handed back to OpenGL without being destroyed and diff --git a/sources/VintagestoryApi/Client/optimum-render-device.cs b/sources/VintagestoryApi/Client/optimum-render-device.cs index b71112d8..fac7bd97 100644 --- a/sources/VintagestoryApi/Client/optimum-render-device.cs +++ b/sources/VintagestoryApi/Client/optimum-render-device.cs @@ -3,366 +3,6 @@ namespace Vintagestory.API.Config; -/// -/// The graphics backend seam. -/// -/// Vintage Story renders through OpenGL. Every upscaler and frame-generation SDK -/// worth shipping speaks D3D12 or Vulkan, and frame generation has to own -/// presentation, so Optimum grows a second renderer rather than a bridge. This -/// interface is that seam: ClientPlatformWindows and the handful of render -/// systems that call GL directly route through it when a device is installed, and -/// execute their untouched vanilla GL bodies when one is not. -/// -/// The contract is deliberately GL-shaped. The game and its mods were written -/// against an immediate-mode state machine - set state, set named uniforms on the -/// active program, bind textures to units, draw a MeshRef - and reproducing that -/// protocol is what lets every render system and every mod keep working unchanged. -/// An implementation is expected to record state and resolve it at draw time, the -/// way Zink and ANGLE do, not to execute it eagerly. -/// -/// Handles are int throughout because the game stores raw GL ids in public -/// API surface it cannot change: , -/// , . An -/// implementation hands out opaque dense indices into its own tables; mods that -/// pass those ids back through the API never notice the difference. -/// -/// UI toolkit types stay out. Cairo surfaces and Skia bitmaps arrive as raw pixel -/// pointers so this assembly, and any implementation of it, depends on nothing but -/// the game API. -/// -/// Threading: every member must be called on the render thread, the same thread -/// that owns the GL context today. Deletions are the one exception - they may -/// arrive from a finalizer thread, and implementations must defer them. -/// -public interface IOptimumGraphicsDevice : IDisposable -{ - // ---------------------------------------------------------------- lifecycle - - /// - /// Brings the device up against an already-created window. Returns false when - /// the device cannot run here (missing API version, feature or presentable - /// queue); the caller then falls back to OpenGL for the session. Must not - /// throw for an ordinary unsupported-hardware outcome. - /// - bool Initialize(IntPtr windowHandle, int width, int height, out string failureReason); - - /// Human-readable backend name for logs and the settings screen. - string BackendName { get; } - - string RendererString { get; } - string VendorString { get; } - string VersionString { get; } - string ShaderVersionString { get; } - - int MaxTextureSize { get; } - bool SupportsThickLines { get; } - bool SupportsSSBOs { get; } - - /// Enables validation/debug reporting if the implementation has it. - bool DebugMode { get; set; } - - /// - /// Drains queued diagnostics. Returns null when clean, mirroring the - /// contract of ClientPlatformAbstract.GlGetError. - /// - string GetError(); - - // -------------------------------------------------------------------- frame - - /// Starts a frame: resets per-frame pools and acquires a target. - void BeginFrame(); - - /// - /// Ends the frame and presents. This is the single point in the system where - /// the image is flipped for scanout; see the coordinate note on - /// . - /// - void Present(); - - void Resize(int width, int height); - void SetVSync(bool enabled); - - // ---------------------------------------------------------- immediate state - - /// - /// Sets the viewport. Coordinates are GL's, unmodified: the implementation - /// must not flip Y here. OpenGL and Vulkan differ only in what they *call* - /// the origin - the memory relationship between clip space, framebuffer rows - /// and texture coordinates is identical in both - so a layer that flips - /// nothing reproduces GL's results bit for bit through every render-to-texture - /// round trip. Only scanout differs, and that is handled once in - /// . - /// - void SetViewport(int x, int y, int width, int height); - - void SetScissor(int x, int y, int width, int height); - void SetScissorEnabled(bool enabled); - bool ScissorEnabled { get; } - - void SetDepthTest(bool enabled); - void SetDepthMask(bool enabled); - /// - /// GL comparison constant (GL_LESS 513, GL_LEQUAL 515, ...). The game's own - /// EnumDepthFunction lives in VintagestoryLib, which this assembly does - /// not reference, and the caller already holds the constant. - /// - void SetDepthFunc(int func); - - void SetCullFace(bool enabled); - /// true = cull back faces, false = cull front faces. - void SetCullFaceMode(bool back); - - void SetBlend(bool enabled, EnumBlendMode mode); - /// Toggle blending without replacing per-attachment factors or equations. - void SetBlendEnabled(bool enabled); - /// Per-attachment blend, as used by the OIT and SSAO passes. - void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha); - void SetBlendEquation(int attachment, int mode); - - void SetColorMask(bool r, bool g, bool b, bool a); - - void SetStencilTest(bool enabled); - void SetStencilMask(int mask); - void SetStencilFunc(int func, int refValue, int mask); - void SetStencilOp(int sfail, int dpfail, int dppass); - - void SetWireframe(bool enabled); - void SetLineWidth(float width); - - // ------------------------------------------------------------------ shaders - - /// - /// Prepares one stage. An implementation that links across stages (Vulkan - /// must, because GL resolves uniforms by name across the whole program) does - /// preprocessing and declaration analysis here and defers code generation to - /// . Errors are logged by the implementation and - /// reported as false, matching the GL path. - /// - bool CompileShader(IShader shader); - - /// - /// Links the staged stages into a usable program and returns its id, or 0 if - /// linking failed. - /// - /// The id comes back rather than being written through the interface because - /// is read-only there; the caller - /// assigns it, exactly as the GL path assigns what glCreateProgram returned. - /// - int LinkProgram(IShaderProgram program); - - void DeleteProgram(int programId); - void UseProgram(int programId); - - /// - /// Resolves a uniform name to an implementation-defined location, or -1 when - /// the program does not use it. Callers treat this as opaque, exactly as they - /// treat a GL uniform location. - /// - int GetUniformLocation(int programId, string name); - - void SetUniform(int programId, int location, float value); - void SetUniform(int programId, int location, int value); - /// - /// Writes an ivec3. The location is opaque, so the implementation, - /// not the caller, knows where the second and third components land. - /// - void SetUniform(int programId, int location, int x, int y, int z); - void SetUniform(int programId, int location, float x, float y); - void SetUniform(int programId, int location, float x, float y, float z); - void SetUniform(int programId, int location, float x, float y, float z, float w); - void SetUniformArray1(int programId, int location, int count, float[] values); - void SetUniformArray2(int programId, int location, int count, float[] values); - void SetUniformArray3(int programId, int location, int count, float[] values); - void SetUniformArray4(int programId, int location, int count, float[] values); - void SetUniformMatrix(int programId, int location, float[] matrix); - void SetUniformMatrices(int programId, int location, int count, float[] matrices); - void SetUniformMatrices4x3(int programId, int location, int count, float[] matrices); - - /// - /// Points a sampler uniform at a texture unit. In GL this is just - /// Uniform1i; here it is distinct so the implementation can bind the - /// right descriptor at draw time. - /// - void SetSamplerUnit(int programId, string samplerName, int unit); - - // --------------------------------------------------------- uniform buffers - - int CreateUniformBuffer(int programId, int bindingPoint, string blockName, int size); - void UpdateUniformBuffer(int handle, IntPtr data, int offset, int size); - void BindUniformBuffer(int handle); - void UnbindUniformBuffer(int handle); - void DeleteUniformBuffer(int handle); - - // ----------------------------------------------------------------- textures - - int CreateTexture2D(int width, int height, EnumTextureInternalFormat internalFormat, - EnumTexturePixelFormat pixelFormat, IntPtr pixels, bool generateMipmaps); - - /// - /// Creates a texture from a raw GL internal format. - /// - /// names only the four formats the - /// public API exposes, but the client's own framebuffer setup uses several - /// more - GL_RGB for the SSAO occlusion target, GL_RGBA32F for its noise - /// texture. Taking the constant directly avoids widening a vanilla enum, and - /// matches how the rest of this seam already accepts GL constants. - /// - /// - /// Whether the image gets a full mip chain. It has to be decided here: an - /// image is created with a fixed number of mip levels, so a later - /// GenerateMipmaps on a single-level image has nowhere to write and - /// silently does nothing. GL let the two be separate calls, which is why the - /// block atlas ended up with no mipmaps at all. - /// - int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPtr pixels, int bytesPerPixel, - bool generateMipmaps = false); - - /// - /// Creates a cubemap from a raw GL internal format, the cube counterpart of - /// . The skybox faces arrive as BGRA bytes, - /// which cannot name. - /// - int CreateTextureCubeRaw(int size, int glInternalFormat, IntPtr[] facePixels, int bytesPerPixel); - - int CreateTextureCube(int size, EnumTextureInternalFormat internalFormat, - EnumTexturePixelFormat pixelFormat, IntPtr[] facePixels); - - /// Array texture, as used for the OIT accumulation attachments. - int CreateTexture2DArray(int width, int height, int layers, - EnumTextureInternalFormat internalFormat, EnumTexturePixelFormat pixelFormat); - - void UploadTexture2D(int textureId, int level, int x, int y, int width, int height, - EnumTexturePixelFormat pixelFormat, IntPtr pixels); - /// - /// Uploads signed 16-bit normalised pixels (GL_SHORT into a normalised - /// format), as the cloud map's tile data does. - /// - void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, - int width, int height, short[] pixels); - - /// - /// Uploads one layer of a texture, as - /// glTexSubImage3D with depth 1 does. Pixels are RGBA8. - /// - void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, - int width, int height, IntPtr pixels); - - void GenerateMipmaps(int textureId); - void DeleteTexture(int textureId); - - /// - /// Per-texture sampler state, keyed by the GL parameter name the caller would - /// have passed to glTexParameteri. Kept GL-shaped because 107 call - /// sites across the client and its mods pass these constants directly. - /// - void SetTextureParameter(int textureId, int parameterName, int value); - void SetTextureParameter(int textureId, int parameterName, float value); - /// Texture border colour for clamped G-buffers and shadow maps. - void SetTextureBorderColor(int textureId, float r, float g, float b, float a); - int GetTextureParameter(int textureId, int parameterName); - - void BindTexture(int unit, int textureId); - void BindTextureCube(int unit, int textureId); - - int CreateSampler(bool linear); - void SetSamplerParameter(int samplerId, int parameterName, float value); - /// Overrides the texture's own state on this unit; 0 clears. - void BindSampler(int unit, int samplerId); - void DeleteSampler(int samplerId); - - // ------------------------------------------------------------- framebuffers - - int CreateFramebuffer(int width, int height); - void AttachTexture(int framebufferId, EnumFramebufferAttachment attachment, int textureId, int layer); - - /// - /// Selects which colour attachments are written, as glDrawBuffers does. - /// This is attachment *selection*, not a write mask: the final composition - /// pass renders into attachment 0 while sampling attachment 1, which is legal - /// only because attachment 1 is not part of the rendering scope. - /// - void SetDrawBuffers(int framebufferId, int attachmentMask); - - bool CheckFramebufferComplete(int framebufferId, out string status); - void BindFramebuffer(int framebufferId); - /// Binds the window's own target; the GL default framebuffer. - void BindDefaultFramebuffer(); - void DeleteFramebuffer(int framebufferId); - - void ClearColor(int attachment, float r, float g, float b, float a); - void ClearDepth(float depth); - void ClearStencil(); - - // ------------------------------------------------------------------- meshes - - int CreateMesh(MeshData data, bool staticDraw); - - int CreateEmptyMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, - int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, - CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, - EnumDrawMode drawMode, bool staticDraw, bool ssbo); - - void UpdateMesh(int meshId, MeshData data); - - /// - /// Persistently mapped pointer for a dynamic mesh part, or - /// when that part is not mapped. The game writes - /// straight through these while the GPU may still be reading, exactly as it - /// does under GL; implementations reproduce the semantics rather than adding - /// synchronisation the game does not expect. - /// - IntPtr GetMappedPointer(int meshId, EnumMeshBufferPart part); - - void DeleteMesh(int meshId); - - void DrawMesh(int meshId); - void DrawMeshInstanced(int meshId, int instanceCount); - void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo); - - /// - /// Writes packed face records into a mesh's storage buffer. - /// - /// The SSBO chunk path does not upload vertex attributes at all: it packs - /// four vertices into one 16-byte face record and the vertex shader expands - /// them from gl_VertexIndex. That leaves nothing for - /// to map onto, so the raw write is its own entry - /// point. is into the buffer, not a vertex - /// index. - /// - void UpdateMeshStorageBuffer(int meshId, IntPtr data, int byteOffset, int byteSize); - /// The three-vertex fullscreen triangle every post pass draws. - void DrawFullscreenTriangle(); - - // ------------------------------------------------------------------ queries - - int CreateOcclusionQuery(); - void BeginOcclusionQuery(int queryId); - void EndOcclusionQuery(int queryId); - bool IsQueryResultAvailable(int queryId); - int GetQueryResult(int queryId); - void DeleteQuery(int queryId); - - // ----------------------------------------------------------------- readback - - /// - /// Reads the default framebuffer back as BGRA8. Rows come back bottom-up, - /// which is what glReadPixels produces and therefore what the existing - /// screenshot and AVI paths already expect. - /// - void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination); - - /// - /// Reads level 0 of a texture back for the per-attachment parity dump - /// (), or returns null when the texture does - /// not exist or its format cannot be decoded. Rows come back bottom-up in GL - /// order - row 0 is texture coordinate t = 0 - exactly as - /// glGetTexImage returns them. Debug only: it waits for the device and - /// is called at most once per process, between the final blit and - /// presentation. - /// - OptimumTextureReadback ReadTextureForParity(int textureId); -} - /// /// One texture's level-0 contents in the representation both backends agree on /// for the parity dump: glGetTexImage(GL_RGBA, GL_UNSIGNED_BYTE) for 8-bit @@ -650,21 +290,16 @@ public enum EnumRenderBackend } /// -/// Where the client finds its graphics device. +/// Which backend the client runs, after fallbacks. /// -/// is null on the OpenGL path, and every routed call site -/// checks it before doing anything, so an OpenGL session executes the vanilla -/// body with one null check in front of it. That is the same shape the greedy-mesh -/// and FSR features use: off is vanilla, exactly. -/// -/// The device is created reflectively by the launcher so that VintagestoryLib -/// never carries an assembly reference to a renderer implementation. +/// The graphics operations themselves are virtual members of the client platform +/// (Vulkan-native plan, Phase 1A): ClientPlatformWindows implements them with +/// the vanilla OpenGL calls and the renderer's VulkanClientPlatform overrides +/// them, so nothing in the client asks this class which API to call. What remains +/// is the backend decision, which is made before any platform or window exists. /// public static class OptimumRender { - /// The active device, or null when running on OpenGL. - public static IOptimumGraphicsDevice Device; - /// Which backend was actually selected, after fallbacks. public static EnumRenderBackend ActiveBackend = EnumRenderBackend.OpenGL; @@ -674,16 +309,17 @@ public static class OptimumRender /// public static string FallbackReason; - public static bool IsVulkan => Device != null; + /// True once the Vulkan platform brought its graphics up for the window. + public static bool IsVulkan => ActiveBackend == EnumRenderBackend.Vulkan; /// /// Set before the window is created when the backend decision was "not /// OpenGL", so the window opens with no graphics API at all. /// - /// cannot answer this question: the device is created - /// against an existing window, so during window construction it is still - /// null even on the Vulkan path. Vanilla code that issues GL calls while the - /// window comes up - GameWindowNative's clear-and-swap, for one - has to test + /// cannot answer this question: graphics come up + /// against an existing window, so during window construction the backend is + /// still OpenGL even on the Vulkan path. Vanilla code that issues GL calls while + /// the window comes up - GameWindowNative's clear-and-swap, for one - has to test /// this flag instead, because with ContextAPI.NoAPI there is no GL binding /// loaded and every GL entry point throws. /// @@ -695,7 +331,6 @@ public static class OptimumRender /// public static void FallBackToOpenGL(string reason) { - Device = null; ActiveBackend = EnumRenderBackend.OpenGL; // The caller reopens the window for OpenGL after this, so GL calls during // window construction are live again. @@ -706,3 +341,62 @@ public static void FallBackToOpenGL(string reason) } } } + +/// +/// The graphics operations the forked vanilla mods (VSEssentials clouds and world map, +/// VSSurvivalMod's boat water mask) need beyond IRenderAPI, on the Vulkan path. +/// +/// The forks reference only the game API and the contracts, so they cannot call the +/// client platform's virtuals; on OpenGL they keep their own GL bodies and this is +/// null. VulkanClientPlatform publishes an implementation while its graphics +/// are up. It is deliberately limited to what those fork files call today and is +/// not a general device seam: the client lib never uses it, and Phase 5 of the +/// Vulkan-native plan ports the forks onto the contracts pass API and removes it. +/// +/// Handles and parameters are the same GL-shaped ints the platform uses: +/// texture and framebuffer ids as the API carries them, GL tokens for texture +/// parameters and internal formats, a bit per colour attachment for draw buffers. +/// +public abstract class OptimumForkGraphics +{ + /// The implementation while Vulkan graphics are up; null on OpenGL. + public static OptimumForkGraphics Active; + + public abstract int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPtr pixels, int bytesPerPixel); + + public abstract int CreateTexture2DArray(int width, int height, int layers, + EnumTextureInternalFormat internalFormat, EnumTexturePixelFormat pixelFormat); + + public abstract void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, int width, int height, IntPtr pixels); + + /// Signed shorts normalised into RGBA16 storage, as glTexSubImage2D(GL_SHORT) converts them. + public abstract void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, int width, int height, short[] pixels); + + public abstract void SetTextureParameter(int textureId, int parameterName, int value); + + public abstract void BindTexture(int unit, int textureId); + + public abstract void DeleteTexture(int textureId); + + public abstract int CreateFramebuffer(int width, int height); + + public abstract void AttachTexture(int framebufferId, EnumFramebufferAttachment attachment, int textureId, int layer); + + public abstract void SetDrawBuffers(int framebufferId, int attachmentMask); + + public abstract void BindFramebuffer(int framebufferId); + + public abstract void BindDefaultFramebuffer(); + + public abstract void DeleteFramebuffer(int framebufferId); + + public abstract void SetViewport(int x, int y, int width, int height); + + public abstract void SetDepthTest(bool enabled); + + public abstract void SetBlendEnabled(bool enabled); + + public abstract int GetUniformLocation(int programId, string name); + + public abstract void SetUniformArray3(int programId, int location, int count, float[] values); +} From 2526fc7f04931f98cb5c50c50a9e2c828812f55b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:46:15 +0200 Subject: [PATCH 100/226] wip(phase1b-step6): per-slot indirect ring, descriptor arena, dirty-masked dynamic state, counter-read GetError (compiles; tests not yet run) --- .../PacingStatsTests.cs | 5 +- .../VulkanDeviceIntegrationTests.cs | 4 + Optimum.Render.Vulkan/Core/DescriptorArena.cs | 128 +++++++ Optimum.Render.Vulkan/Core/DescriptorCache.cs | 31 +- .../Core/DynamicStateCache.cs | 126 +++++++ Optimum.Render.Vulkan/Core/FrameRing.cs | 10 + Optimum.Render.Vulkan/Core/ResourceAge.cs | 86 +++++ Optimum.Render.Vulkan/Core/VulkanResources.cs | 3 + Optimum.Render.Vulkan/Core/VulkanStats.cs | 13 +- Optimum.Render.Vulkan/Frame/IndirectRing.cs | 135 ++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 320 ++++++++++++++---- 11 files changed, 773 insertions(+), 88 deletions(-) create mode 100644 Optimum.Render.Vulkan/Core/DescriptorArena.cs create mode 100644 Optimum.Render.Vulkan/Core/DynamicStateCache.cs create mode 100644 Optimum.Render.Vulkan/Core/ResourceAge.cs create mode 100644 Optimum.Render.Vulkan/Frame/IndirectRing.cs diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index a3342ecd..23c7be51 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -356,7 +356,10 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() // The per-draw dynamic-state count matches the commands actually recorded. string dynamicState = Body(device, "private void ApplyDynamicState("); Assert.Equal(VulkanStats.DynamicStateCommandsPerDraw, Count(dynamicState, "api.CmdSet")); - Assert.Contains("VulkanStats.NoteDynamicStateCommands(VulkanStats.DynamicStateCommandsPerDraw);", dynamicState); + // Phase 1B step 6: dirty-masked, so the count is what was emitted, not a constant. + Assert.Contains("DynamicStateDirty dirty = _dynamicState.Update(serial, values);", dynamicState); + Assert.Contains("VulkanStats.NoteDynamicStateCommands(emitted);", dynamicState); + Assert.DoesNotContain("NoteDynamicStateCommands(VulkanStats.DynamicStateCommandsPerDraw)", dynamicState); Assert.Contains("VulkanStats.NoteScopeOpened();", Source("Core/RenderTargetManager.cs")); // Phase 1B step 5: ReBAR misses are counted where the ReBAR class falls diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index 3f95bec7..06bcd8ab 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -1591,6 +1591,10 @@ void main(void) seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); seam.SetDrawBuffers(framebuffer, 0b1); + // This test is about the long-lived cache's eviction; a texture made a + // moment ago would otherwise get its set from the per-slot arena. + device!.ShortLivedFramesForTests = 0; + int first = SolidTexture(seam, size, 10, 20, 30); seam.BeginFrame(); diff --git a/Optimum.Render.Vulkan/Core/DescriptorArena.cs b/Optimum.Render.Vulkan/Core/DescriptorArena.cs new file mode 100644 index 00000000..ddaa2608 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DescriptorArena.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// One frame slot's descriptor sets for short-lived resources, reset wholesale +/// when the slot begins its next frame. +/// +/// is the right home for a set that is bound for +/// hundreds of frames; for a set naming a texture the GUI re-creates every few +/// frames it is pure churn: a write, an index entry, an eviction and a deferred +/// individual free. Here the set lives exactly one frame. Within the frame +/// identical contents share a set, so a text texture drawn twice is written +/// once; at the slot's next frame start (after the Frame timeline says the GPU +/// finished the slot's previous frame) every pool is reset in one call each. +/// +/// No eviction is needed: a resource deleted during the frame is destroyed only +/// after that frame completes, and the sets naming it die at the next reset +/// without ever being bound again. +/// +internal sealed unsafe class DescriptorArena : IDisposable +{ + public const uint SetsPerPool = 256; + + private readonly VulkanContext _context; + private readonly List _pools = new(); + private readonly Dictionary _sets = new(); + private int _poolIndex; + private bool _disposed; + + public DescriptorArena(VulkanContext context) => _context = context; + + /// Distinct sets handed out since the last reset. + public int SetsThisFrame => _sets.Count; + + public int PoolCount => _pools.Count; + + /// Lookups that found a set already written this frame. + public long Hits { get; private set; } + + /// Sets allocated and written, over the arena's life. + public long Allocations { get; private set; } + + public long Resets { get; private set; } + + /// + /// Returns every set to the pools. Only once no submitted command buffer that + /// bound one can still execute: the slot's previous frame has completed. + /// + public void Reset() + { + foreach (DescriptorPool pool in _pools) + { + _context.Api.ResetDescriptorPool(_context.Device, pool, 0); + } + _sets.Clear(); + _poolIndex = 0; + Resets++; + } + + public DescriptorSet Get(DescriptorSetContents contents, DescriptorSetLayout layout) + { + if (_sets.TryGetValue(contents, out DescriptorSet existing)) + { + Hits++; + return existing; + } + + DescriptorSet set = Allocate(layout); + DescriptorCache.Write(_context, set, contents); + _sets[contents] = set; + Allocations++; + return set; + } + + private DescriptorSet Allocate(DescriptorSetLayout layout) + { + // Pools fill in order; a pool that refuses (out of sets, or out of one + // descriptor type) is left for the rest of the frame and the next one tried. + while (true) + { + bool freshPool = _poolIndex == _pools.Count; + if (freshPool) + { + _pools.Add(DescriptorCache.CreatePool(_context, SetsPerPool, 0)); + } + + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _pools[_poolIndex], + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + + DescriptorSet set; + Result result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &set); + if (result == Result.Success) return set; + + if (result != Result.ErrorOutOfPoolMemory && result != Result.ErrorFragmentedPool) + { + throw new InvalidOperationException("vkAllocateDescriptorSets failed in the descriptor arena: " + result); + } + + // A pool created for this very set that still refuses would loop forever. + if (freshPool) + { + throw new InvalidOperationException("a descriptor set does not fit an empty arena pool: " + result); + } + _poolIndex++; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _sets.Clear(); + foreach (DescriptorPool pool in _pools) + { + _context.Api.DestroyDescriptorPool(_context.Device, pool, null); + } + _pools.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs index 23c3a8b2..1236ef4c 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorCache.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -152,7 +152,7 @@ public DescriptorSet Get(DescriptorSetContents contents, DescriptorSetLayout lay Misses++; CachedSet cached = Allocate(layout); - Write(cached.Set, contents); + Write(_context, cached.Set, contents); _sets[contents] = cached; Index(contents); return cached.Set; @@ -322,6 +322,18 @@ private Result AllocateFrom(PoolSlot slot, DescriptorSetLayout layout, out Descr } private PoolSlot GrowPool() + { + // Evicted sets are freed individually, which a pool has to allow. + DescriptorPool pool = CreatePool(_context, SetsPerPool, DescriptorPoolCreateFlags.FreeDescriptorSetBit); + + var slot = new PoolSlot { Pool = pool, Remaining = SetsPerPool }; + _pools.Add(slot); + _current = slot; + return slot; + } + + /// A pool sized for the rewriter's three set kinds; shared with . + internal static DescriptorPool CreatePool(VulkanContext context, uint maxSets, DescriptorPoolCreateFlags flags) { // A pool can only satisfy the descriptor types it was sized for. Set 0 // holds the generated block plus every block the shader declares for @@ -343,26 +355,21 @@ private PoolSlot GrowPool() var createInfo = new DescriptorPoolCreateInfo { SType = StructureType.DescriptorPoolCreateInfo, - // Evicted sets are freed individually, which a pool has to allow. - Flags = DescriptorPoolCreateFlags.FreeDescriptorSetBit, + Flags = flags, PoolSizeCount = 3, PPoolSizes = sizes, - MaxSets = SetsPerPool, + MaxSets = maxSets, }; - if (_context.Api.CreateDescriptorPool(_context.Device, &createInfo, null, out DescriptorPool pool) + if (context.Api.CreateDescriptorPool(context.Device, &createInfo, null, out DescriptorPool pool) != Result.Success) { throw new InvalidOperationException("vkCreateDescriptorPool failed"); } - - var slot = new PoolSlot { Pool = pool, Remaining = SetsPerPool }; - _pools.Add(slot); - _current = slot; - return slot; + return pool; } - private void Write(DescriptorSet set, DescriptorSetContents contents) + internal static void Write(VulkanContext context, DescriptorSet set, DescriptorSetContents contents) { int writeCount = contents.Samplers.Length + contents.Buffers.Length; if (writeCount == 0) return; @@ -428,7 +435,7 @@ private void Write(DescriptorSet set, DescriptorSetContents contents) fixed (WriteDescriptorSet* writesPtr = writes) { - _context.Api.UpdateDescriptorSets(_context.Device, (uint)writeCount, writesPtr, 0, null); + context.Api.UpdateDescriptorSets(context.Device, (uint)writeCount, writesPtr, 0, null); } } } diff --git a/Optimum.Render.Vulkan/Core/DynamicStateCache.cs b/Optimum.Render.Vulkan/Core/DynamicStateCache.cs new file mode 100644 index 00000000..e9aec6fb --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DynamicStateCache.cs @@ -0,0 +1,126 @@ +using System; +using System.Numerics; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// One bit per dynamic-state command a draw may record. +[Flags] +internal enum DynamicStateDirty : ushort +{ + None = 0, + Viewport = 1 << 0, + Scissor = 1 << 1, + CullMode = 1 << 2, + FrontFace = 1 << 3, + Topology = 1 << 4, + DepthTestEnable = 1 << 5, + DepthWriteEnable = 1 << 6, + DepthCompareOp = 1 << 7, + StencilTestEnable = 1 << 8, + StencilOp = 1 << 9, + StencilCompareMask = 1 << 10, + StencilWriteMask = 1 << 11, + StencilReference = 1 << 12, + LineWidth = 1 << 13, + All = (1 << 14) - 1, +} + +/// The values a draw's dynamic state resolves to, already in Vulkan terms. +internal struct DynamicStateValues +{ + public Viewport Viewport; + public Rect2D Scissor; + public CullModeFlags CullMode; + public FrontFace FrontFace; + public PrimitiveTopology Topology; + public bool DepthTest; + public bool DepthWrite; + public CompareOp DepthCompare; + public bool StencilTest; + public StencilOp StencilFail; + public StencilOp StencilPass; + public StencilOp StencilDepthFail; + public CompareOp StencilCompare; + public uint StencilCompareMask; + public uint StencilWriteMask; + public uint StencilReference; + public float LineWidth; +} + +/// +/// What the command buffer being recorded already holds, so a draw emits only +/// the dynamic state that changed. +/// +/// Dynamic state is command-buffer state: it survives rendering scopes and +/// pipeline binds (every pipeline declares all of these dynamic), and is +/// undefined again when a command buffer begins. The cache is keyed on the +/// recording serial of the command buffer (), +/// so a new frame, a partial submission's continuation or a recycled handle +/// always starts from "everything dirty". +/// +internal sealed class DynamicStateCache +{ + private ulong _serial; + private DynamicStateValues _last; + + /// False emits everything on every draw, as before masking. Tests compare the two. + public bool Enabled { get; set; } = true; + + /// Forgets what was recorded; the next draw emits everything. + public void Invalidate() => _serial = 0; + + /// + /// Returns the commands a draw recorded into the command buffer with + /// has to emit for , and + /// assumes the caller emits them. A serial of 0 is never trusted. + /// + public DynamicStateDirty Update(ulong serial, in DynamicStateValues next) + { + DynamicStateDirty dirty; + if (!Enabled || serial == 0 || serial != _serial) + { + dirty = DynamicStateDirty.All; + } + else + { + dirty = DynamicStateDirty.None; + if (!SameViewport(_last.Viewport, next.Viewport)) dirty |= DynamicStateDirty.Viewport; + if (!SameRect(_last.Scissor, next.Scissor)) dirty |= DynamicStateDirty.Scissor; + if (_last.CullMode != next.CullMode) dirty |= DynamicStateDirty.CullMode; + if (_last.FrontFace != next.FrontFace) dirty |= DynamicStateDirty.FrontFace; + if (_last.Topology != next.Topology) dirty |= DynamicStateDirty.Topology; + if (_last.DepthTest != next.DepthTest) dirty |= DynamicStateDirty.DepthTestEnable; + if (_last.DepthWrite != next.DepthWrite) dirty |= DynamicStateDirty.DepthWriteEnable; + if (_last.DepthCompare != next.DepthCompare) dirty |= DynamicStateDirty.DepthCompareOp; + if (_last.StencilTest != next.StencilTest) dirty |= DynamicStateDirty.StencilTestEnable; + if (_last.StencilFail != next.StencilFail || _last.StencilPass != next.StencilPass || + _last.StencilDepthFail != next.StencilDepthFail || _last.StencilCompare != next.StencilCompare) + { + dirty |= DynamicStateDirty.StencilOp; + } + if (_last.StencilCompareMask != next.StencilCompareMask) dirty |= DynamicStateDirty.StencilCompareMask; + if (_last.StencilWriteMask != next.StencilWriteMask) dirty |= DynamicStateDirty.StencilWriteMask; + if (_last.StencilReference != next.StencilReference) dirty |= DynamicStateDirty.StencilReference; + // Bitwise, so a NaN width is not "changed" forever. + if (BitConverter.SingleToInt32Bits(_last.LineWidth) != BitConverter.SingleToInt32Bits(next.LineWidth)) + { + dirty |= DynamicStateDirty.LineWidth; + } + } + + _serial = serial; + _last = next; + return dirty; + } + + public static int CommandCount(DynamicStateDirty dirty) => BitOperations.PopCount((uint)dirty); + + private static bool SameViewport(in Viewport a, in Viewport b) => + a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && + a.MinDepth == b.MinDepth && a.MaxDepth == b.MaxDepth; + + private static bool SameRect(in Rect2D a, in Rect2D b) => + a.Offset.X == b.Offset.X && a.Offset.Y == b.Offset.Y && + a.Extent.Width == b.Extent.Width && a.Extent.Height == b.Extent.Height; +} diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 8bf9921e..68fe29a9 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -100,6 +100,15 @@ public void Begin(ulong frameValue) StartCommandBuffer(); } + private static long s_recordingSerials; + + /// + /// Unique across every slot and every begin of : a + /// recycled handle gets a new serial, so state remembered per command buffer + /// () never outlives the recording it describes. + /// + public ulong RecordingSerial { get; private set; } + private void StartCommandBuffer(bool frameCommands = true) { Vk api = _context.Api; @@ -122,6 +131,7 @@ private void StartCommandBuffer(bool frameCommands = true) _commandBuffers.Add(commandBuffer); } _commandBuffersUsed++; + RecordingSerial = (ulong)System.Threading.Interlocked.Increment(ref s_recordingSerials); var begin = new CommandBufferBeginInfo { diff --git a/Optimum.Render.Vulkan/Core/ResourceAge.cs b/Optimum.Render.Vulkan/Core/ResourceAge.cs new file mode 100644 index 00000000..9dee3ed6 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/ResourceAge.cs @@ -0,0 +1,86 @@ +using System; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Tells a resource created in the last few frames from a long-lived one, with +/// no per-resource storage. +/// +/// Resource ids () only ever increase, so the highest id +/// issued when a frame began is a watermark: every id above the watermark of the +/// frame N - 1 frames back was created within the last N frames. The class +/// keeps one watermark per frame in a ring. +/// +/// The descriptor layer uses it to route sets naming short-lived resources (GUI +/// text, atlas tasks, fresh chunk meshes, overflow uniform copies) to the per-slot +/// arena that is reset every frame, instead of caching them in +/// only to evict them moments later. +/// +internal sealed class ResourceAge +{ + public const int DefaultShortLivedFrames = 60; + + private readonly ulong[] _watermarks; + private long _frames; + private int _shortLivedFrames; + + public ResourceAge(int capacity = DefaultShortLivedFrames) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _watermarks = new ulong[capacity]; + _shortLivedFrames = capacity; + } + + /// + /// How many frames a resource counts as short-lived for, at most the ring's + /// capacity. Zero makes every resource long-lived (the arena is never used). + /// + public int ShortLivedFrames + { + get => _shortLivedFrames; + set + { + if (value < 0 || value > _watermarks.Length) throw new ArgumentOutOfRangeException(nameof(value)); + _shortLivedFrames = value; + } + } + + /// Frames noted so far. + public long Frames => _frames; + + /// Records the watermark at the start of a frame: the highest resource id issued so far. + public void NoteFrame(ulong highestIssuedId) + { + _watermarks[_frames % _watermarks.Length] = highestIssuedId; + _frames++; + } + + /// + /// Whether was created within the last + /// frames, the current one included. Id 0 (a + /// permanent resource) never is. Before that many frames have been noted, + /// every resource is: none can be older. + /// + public bool IsShortLived(ulong resource) + { + if (resource == 0 || _shortLivedFrames == 0) return false; + if (_frames < _shortLivedFrames) return true; + + ulong watermark = _watermarks[(_frames - _shortLivedFrames) % _watermarks.Length]; + return resource > watermark; + } + + /// Whether any resource a set names is short-lived. + public bool NamesShortLived(DescriptorSetContents contents) + { + foreach (SamplerBindingValue sampler in contents.Samplers) + { + if (IsShortLived(sampler.Resource)) return true; + } + foreach (BufferBindingValue buffer in contents.Buffers) + { + if (IsShortLived(buffer.Resource)) return true; + } + return false; + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index 7eeba33f..c575938d 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -22,6 +22,9 @@ internal static class ResourceIds private static long _next; public static ulong Next() => (ulong)Interlocked.Increment(ref _next); + + /// The highest id issued so far (0 before the first). + public static ulong Highest => (ulong)Interlocked.Read(ref _next); } /// A device buffer with its backing memory. diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index cbddb15d..049fc827 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -98,8 +98,10 @@ internal static class VulkanStats public const int WaitSiteCount = 9; /// - /// Dynamic-state commands VulkanDevice.ApplyDynamicState records per - /// draw today. A source test keeps this equal to the calls in that method. + /// Dynamic-state commands VulkanDevice.ApplyDynamicState can record for + /// one draw: all of them, at the first draw of a command buffer. Later draws + /// record only the ones whose value changed (Phase 1B step 6). A source test + /// keeps this equal to the calls in that method. /// public const int DynamicStateCommandsPerDraw = 14; @@ -198,6 +200,13 @@ public static void NoteUpload(long elapsedTicks) public static long RebarFallbacks => Interlocked.Read(ref _rebarFallbacks); + /// A multi-draw that did not fit its frame slot's indirect buffer and took an overflow buffer. + public static void NoteIndirectOverflow() => Interlocked.Increment(ref _indirectOverflows); + + public static long IndirectOverflows => Interlocked.Read(ref _indirectOverflows); + + private static long _indirectOverflows; + public static void NoteDynamicStateCommands(int count) => Interlocked.Add(ref _dynamicStateCommands, count); public static long DynamicStateCommands => Interlocked.Read(ref _dynamicStateCommands); diff --git a/Optimum.Render.Vulkan/Frame/IndirectRing.cs b/Optimum.Render.Vulkan/Frame/IndirectRing.cs new file mode 100644 index 00000000..34a7efb7 --- /dev/null +++ b/Optimum.Render.Vulkan/Frame/IndirectRing.cs @@ -0,0 +1,135 @@ +using System; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The bookkeeping of the per-slot indirect-command ring, with no Vulkan in it. +/// +/// Each frame slot owns one indirect buffer. Multi-draws of a frame bump-allocate +/// from the slot's buffer, and the cursor returns to zero only when the slot +/// begins its next frame, which is after the Frame timeline says the GPU has +/// finished the previous frame that used it. The ring never wraps inside a frame: +/// the wrapping ring this replaces could hand a new draw the region a frame still +/// in flight was reading. +/// +/// A frame that asks for more than its slot holds is told so ( +/// returns false) and the caller takes an overflow buffer for the rest of that +/// frame. The slot's buffer grows only at a frame boundary, to fit the busiest +/// frame any slot has seen. +/// +internal sealed class IndirectRing +{ + /// Smallest buffer a slot is given: 13107 indexed indirect commands. + public const ulong DefaultMinimumCapacity = 256UL * 1024; + + private const ulong Granularity = 64UL * 1024; + + private readonly ulong[] _capacity; + private readonly ulong[] _cursor; + private readonly ulong[] _usage; + private readonly int[] _overflows; + private int _current = -1; + + public IndirectRing(int slots, ulong minimumCapacity = DefaultMinimumCapacity) + { + if (slots <= 0) throw new ArgumentOutOfRangeException(nameof(slots)); + _capacity = new ulong[slots]; + _cursor = new ulong[slots]; + _usage = new ulong[slots]; + _overflows = new int[slots]; + MinimumCapacity = Math.Max(1UL, minimumCapacity); + } + + public ulong MinimumCapacity { get; } + + /// The slot recording now, -1 before the first frame. + public int Current => _current; + + /// Bytes the busiest frame so far asked for, overflow included. Never shrinks. + public ulong PeakFrameUsage { get; private set; } + + public ulong CapacityOf(int slot) => _capacity[slot]; + public ulong CursorOf(int slot) => _cursor[slot]; + public ulong FrameUsageOf(int slot) => _usage[slot]; + + /// Allocations of the slot's current frame that did not fit its buffer. + public int OverflowsOf(int slot) => _overflows[slot]; + + /// + /// A buffer size that holds bytes with half again as + /// much headroom, rounded to 64 KiB, and never below the minimum. + /// + public ulong CapacityFor(ulong demand) + { + ulong wanted = demand + demand / 2; + ulong rounded = (wanted + Granularity - 1) / Granularity * Granularity; + return Math.Max(MinimumCapacity, rounded); + } + + /// + /// Starts 's next frame: folds every slot's last frame + /// usage into the peak, resets this slot's cursor, and reports whether its + /// existing buffer is too small for the peak. When it is, the caller retires + /// the old buffer, creates one of bytes and calls + /// . A slot without a buffer is not grown here; it gets + /// one at its first allocation. + /// + public bool BeginFrame(int slot, out ulong capacity) + { + foreach (ulong usage in _usage) PeakFrameUsage = Math.Max(PeakFrameUsage, usage); + + _current = slot; + _cursor[slot] = 0; + _usage[slot] = 0; + _overflows[slot] = 0; + + capacity = CapacityFor(PeakFrameUsage); + return _capacity[slot] != 0 && _capacity[slot] < PeakFrameUsage; + } + + /// + /// Whether the current slot has no buffer yet, and the size to create when it + /// has none. Creating one is safe at any time: nothing recorded names it. + /// + public bool NeedsBuffer(ulong bytes, out ulong capacity) + { + RequireFrame(); + capacity = CapacityFor(Math.Max(PeakFrameUsage, _usage[_current] + bytes)); + return _capacity[_current] == 0; + } + + /// Records that the current slot's buffer now holds bytes. + public void Attach(ulong capacity) + { + RequireFrame(); + if (capacity < _cursor[_current]) throw new InvalidOperationException("a slot buffer cannot shrink under its cursor"); + _capacity[_current] = capacity; + } + + /// + /// Bump-allocates in the current slot's buffer. Never + /// wraps: when the rest of the buffer is too small, returns false and leaves the + /// cursor where it is. Either way the bytes count toward this frame's usage. + /// + public bool TryAllocate(ulong bytes, out ulong offset) + { + RequireFrame(); + _usage[_current] += bytes; + + if (_cursor[_current] + bytes > _capacity[_current]) + { + _overflows[_current]++; + offset = 0; + return false; + } + + offset = _cursor[_current]; + _cursor[_current] += bytes; + return true; + } + + private void RequireFrame() + { + if (_current < 0) throw new InvalidOperationException("BeginFrame has not been called yet"); + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 8e42f9dd..d0eed531 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -50,7 +50,39 @@ public sealed unsafe class VulkanDevice : IOptimumGraphicsDevice private uint _frameCounter; private uint _uniformExhaustionReportedFrame = uint.MaxValue; private readonly Dictionary _stagedStages = new(); - private readonly List _diagnostics = new(); + + /// + /// Error-severity diagnostics since the last GetError, under their own lock: + /// the layers call back from whichever thread made the Vulkan call. + /// + private readonly List _errors = new(); + + /// + /// How many entries holds. GetError runs after every + /// render stage, and in steady state this read is all it costs. + /// + private volatile int _errorCount; + + /// A client that never drains the queue does not grow it without bound. + private const int MaxQueuedErrors = 1024; + + /// What the frame command buffer already holds, so a draw emits only changed dynamic state. + private readonly DynamicStateCache _dynamicState = new(); + private long _dynamicStateCommands; + + /// Which resources are young enough that their descriptor sets belong in the per-slot arena. + private readonly ResourceAge _resourceAge = new(); + private DescriptorArena[] _descriptorArenas = Array.Empty(); + + /// Per-slot indirect-command buffers; see . + private IndirectRing _indirectRing = null!; + private VulkanBuffer?[] _indirectBuffers = Array.Empty(); + + /// Buffers taken this frame by multi-draws that did not fit their slot's buffer. + private readonly List _indirectOverflow = new(); + private ulong _indirectOverflowCursor; + private long _indirectOverflows; + private long _indirectGrowths; /// /// Whether the last draw got its own slice of the frame's uniform ring. A @@ -281,7 +313,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa ValidationFeatures = ValidationFeatureSetting, DebugCallback = message => { - _diagnostics.Add(SanitiseForClientLog(message)); + AddDiagnostic(SanitiseForClientLog(message)); MirrorValidationMessage(message); if (RenderTrace.Enabled) RenderTrace.Write("validation: program=" + (_state?.CurrentProgram ?? 0) + @@ -309,7 +341,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // A failed Vulkan call is an error by definition, so it carries the // same prefix the layers' error-severity messages do and reaches the // client through GetError. - _diagnostics.Add(VulkanContext.ErrorPrefix + message); + AddDiagnostic(VulkanContext.ErrorPrefix + message); MirrorValidationMessage(message); }; VulkanResult.DescribeDeviceLoss = DescribeDeviceLoss; @@ -336,6 +368,10 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer); _pipelines = new GraphicsPipelineCache(_context); _descriptors = new DescriptorCache(_context); + _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; + for (int i = 0; i < _descriptorArenas.Length; i++) _descriptorArenas[i] = new DescriptorArena(_context); + _indirectRing = new IndirectRing(_frames.FramesInFlight); + _indirectBuffers = new VulkanBuffer?[_frames.FramesInFlight]; _queryRing = new QueryRing(_context, _frames.Timeline, _frames.FramesInFlight); _readbacks = new ReadbackManager(_context, _textures, _frames); // A GL query counts across scope ends; a Vulkan one must not be active @@ -600,21 +636,40 @@ private void DestroyDefaultFramebuffer() /// public string GetError() { - if (_diagnostics.Count == 0) return null!; + // Phase 1B step 6: a volatile read; the message is built only when there is one. + if (_errorCount == 0) return null!; - var errors = new List(); - foreach (string diagnostic in _diagnostics) + lock (_errors) { - if (diagnostic.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) - { - errors.Add(diagnostic); - } + if (_errors.Count == 0) return null!; + string joined = string.Join("\n", _errors); + _errors.Clear(); + _errorCount = 0; + return joined; } - _diagnostics.Clear(); + } - return errors.Count == 0 ? null! : string.Join("\n", errors); + /// + /// Queues a diagnostic for GetError. Only error-severity messages (the + /// ones) are kept: GetError never + /// reported anything else, and warnings already reach the trace and the + /// validation log where they are raised. Safe from any thread. + /// + private void AddDiagnostic(string message) + { + if (!message.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) return; + + lock (_errors) + { + if (_errors.Count >= MaxQueuedErrors) return; + _errors.Add(message); + _errorCount = _errors.Count; + } } + /// Queues a diagnostic as the device's own sources do. Tests only. + internal void AddDiagnosticForTests(string message) => AddDiagnostic(message); + // ---------------------------------------------------------------------- frame public void BeginFrame() @@ -632,9 +687,15 @@ public void BeginFrame() FrameSlot slot = _frames.BeginFrame(); _frameActive = true; _frameCounter++; - _indirectFrameUsage = 0; Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); + // The slot's previous frame has completed (FrameRing waited for it), so + // its indirect cursor and descriptor arena reset wholesale. + BeginIndirectFrame(slot.Index); + _descriptorArenas[slot.Index].Reset(); + _resourceAge.NoteFrame(ResourceIds.Highest); + _dynamicState.Invalidate(); + // The slot's previous frame has finished: its query results move to the // host buffer before the pools reset, and its readback arena is free again. _queryRing.BeginSlot(slot.Index, slot.CommandBuffer); @@ -837,7 +898,7 @@ private void ReportRebuildFailure() string? failure = _swapchain?.RebuildFailure; if (failure != null && failure != _reportedRebuildFailure) { - _diagnostics.Add("swapchain recreation failed: " + failure); + AddDiagnostic("swapchain recreation failed: " + failure); } _reportedRebuildFailure = failure; } @@ -921,12 +982,12 @@ public bool CompileShader(IShader shader) string stageName = shader.Type.ToString(); if (shader.Code.Length + (shader.PrefixCode?.Length ?? 0) > MaxShaderSourceBytes) { - _diagnostics.Add($"{stageName}: shader source exceeds {MaxShaderSourceBytes} bytes and was rejected"); + AddDiagnostic($"{stageName}: shader source exceeds {MaxShaderSourceBytes} bytes and was rejected"); return false; } if (shader.Code.IndexOf('\0') >= 0 || (shader.PrefixCode?.IndexOf('\0') ?? -1) >= 0) { - _diagnostics.Add($"{stageName}: shader source contains a NUL byte and was rejected"); + AddDiagnostic($"{stageName}: shader source contains a NUL byte and was rejected"); return false; } @@ -949,7 +1010,7 @@ public int LinkProgram(IShaderProgram program) if (stages.Count == 0) { - _diagnostics.Add($"shader program '{program.PassName}' has no stages"); + AddDiagnostic($"shader program '{program.PassName}' has no stages"); return 0; } @@ -958,7 +1019,7 @@ public int LinkProgram(IShaderProgram program) { foreach (string error in translated.Errors) { - _diagnostics.Add($"{program.PassName}: {error}"); + AddDiagnostic($"{program.PassName}: {error}"); } return 0; } @@ -2071,7 +2132,7 @@ private void ReportUniformExhaustion(ShaderProgramResources program, string what " (" + _frames.Current.UniformBytesUsed + " of " + _frames.Current.UniformCapacity + " bytes used) at a draw with program " + program.ProgramId + " '" + ProgramNameOf(program.ProgramId) + "' for " + what; - _diagnostics.Add(SanitiseForClientLog(message)); + AddDiagnostic(SanitiseForClientLog(message)); MirrorValidationMessage(message); } @@ -2217,7 +2278,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, Array.Empty(), buffers.ToArray()); - DescriptorSet uniformSet = _descriptors.Get( + DescriptorSet uniformSet = GetDescriptorSet( uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, @@ -2311,7 +2372,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if (complete) { - DescriptorSet samplerSet = _descriptors.Get( + DescriptorSet samplerSet = GetDescriptorSet( new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.SamplerSet, bindings, Array.Empty()), program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); @@ -2346,7 +2407,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if (storage.Count == program.Interface.StorageBlocks.Count) { - DescriptorSet storageSet = _descriptors.Get( + DescriptorSet storageSet = GetDescriptorSet( new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.StorageSet, Array.Empty(), storage.ToArray()), program.SetLayouts[ProgramInterfaceLayout.StorageSet]); @@ -2367,45 +2428,148 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta Vk api = _context.Api; Rect2D viewport = _state.Viewport; - var vulkanViewport = new Viewport( - viewport.Offset.X, viewport.Offset.Y, - viewport.Extent.Width, viewport.Extent.Height, 0f, 1f); - api.CmdSetViewport(commandBuffer, 0, 1, &vulkanViewport); + var values = new DynamicStateValues + { + Viewport = new Viewport( + viewport.Offset.X, viewport.Offset.Y, + viewport.Extent.Width, viewport.Extent.Height, 0f, 1f), + // GL leaves the whole target writable when the scissor test is off; + // Vulkan always has a scissor, so "off" becomes the full target. + Scissor = _state.ScissorEnabled + ? _state.Scissor + : new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), + CullMode = _state.CullEnabled ? _state.CullMode : CullModeFlags.None, + FrontFace = GlStateTracker.FrontFace, + Topology = _state.Topology, + DepthTest = _state.DepthTest, + DepthWrite = _state.DepthWrite, + DepthCompare = _state.DepthCompare, + StencilTest = _state.StencilTest, + StencilFail = _state.StencilFail, + StencilPass = _state.StencilPass, + StencilDepthFail = _state.StencilDepthFail, + StencilCompare = _state.StencilCompare, + StencilCompareMask = _state.StencilCompareMask, + StencilWriteMask = _state.StencilWriteMask, + StencilReference = _state.StencilReference, + LineWidth = _context.Capabilities.WideLines ? _state.LineWidth : 1.0f, + }; + + // Dirty-masked (Phase 1B step 6): the cache knows what this recording of + // the command buffer already holds. A command buffer that is not the + // slot's current one is never trusted. + FrameSlot slot = _frames.Current; + ulong serial = slot.CommandBuffer.Handle == commandBuffer.Handle ? slot.RecordingSerial : 0; + DynamicStateDirty dirty = _dynamicState.Update(serial, values); + if (dirty == DynamicStateDirty.None) return; + + if ((dirty & DynamicStateDirty.Viewport) != 0) api.CmdSetViewport(commandBuffer, 0, 1, &values.Viewport); + if ((dirty & DynamicStateDirty.Scissor) != 0) api.CmdSetScissor(commandBuffer, 0, 1, &values.Scissor); + if ((dirty & DynamicStateDirty.CullMode) != 0) api.CmdSetCullMode(commandBuffer, values.CullMode); + if ((dirty & DynamicStateDirty.FrontFace) != 0) api.CmdSetFrontFace(commandBuffer, values.FrontFace); + if ((dirty & DynamicStateDirty.Topology) != 0) api.CmdSetPrimitiveTopology(commandBuffer, values.Topology); + + if ((dirty & DynamicStateDirty.DepthTestEnable) != 0) api.CmdSetDepthTestEnable(commandBuffer, values.DepthTest); + if ((dirty & DynamicStateDirty.DepthWriteEnable) != 0) api.CmdSetDepthWriteEnable(commandBuffer, values.DepthWrite); + if ((dirty & DynamicStateDirty.DepthCompareOp) != 0) api.CmdSetDepthCompareOp(commandBuffer, values.DepthCompare); + + if ((dirty & DynamicStateDirty.StencilTestEnable) != 0) api.CmdSetStencilTestEnable(commandBuffer, values.StencilTest); + if ((dirty & DynamicStateDirty.StencilOp) != 0) + api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, + values.StencilFail, values.StencilPass, values.StencilDepthFail, values.StencilCompare); + if ((dirty & DynamicStateDirty.StencilCompareMask) != 0) + api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, values.StencilCompareMask); + if ((dirty & DynamicStateDirty.StencilWriteMask) != 0) + api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, values.StencilWriteMask); + if ((dirty & DynamicStateDirty.StencilReference) != 0) + api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, values.StencilReference); + + if ((dirty & DynamicStateDirty.LineWidth) != 0) api.CmdSetLineWidth(commandBuffer, values.LineWidth); - // GL leaves the whole target writable when the scissor test is off; - // Vulkan always has a scissor, so "off" becomes the full target. - Rect2D scissor = _state.ScissorEnabled - ? _state.Scissor - : new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)); - api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + int emitted = DynamicStateCache.CommandCount(dirty); + _dynamicStateCommands += emitted; + VulkanStats.NoteDynamicStateCommands(emitted); + } + + /// Dynamic-state commands this device recorded. Tests only. + internal long DynamicStateCommandsForTests => _dynamicStateCommands; + + /// False emits every dynamic-state command on every draw, as before masking. Tests only. + internal bool DynamicStateMaskingForTests + { + get => _dynamicState.Enabled; + set => _dynamicState.Enabled = value; + } + + /// + /// Routes a set to the current slot's arena when it names a resource created + /// in the last frames (GUI text, + /// atlas tasks, fresh meshes, overflow uniform copies), otherwise to the + /// long-lived cache. + /// + private DescriptorSet GetDescriptorSet(DescriptorSetContents contents, DescriptorSetLayout layout) => + _resourceAge.NamesShortLived(contents) + ? _descriptorArenas[_frames.Current.Index].Get(contents, layout) + : _descriptors.Get(contents, layout); + + /// The frames a resource's sets stay in the arena; 0 sends every set to the cache. Tests only. + internal int ShortLivedFramesForTests + { + get => _resourceAge.ShortLivedFrames; + set => _resourceAge.ShortLivedFrames = value; + } - api.CmdSetCullMode(commandBuffer, _state.CullEnabled ? _state.CullMode : CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); - api.CmdSetPrimitiveTopology(commandBuffer, _state.Topology); + /// A slot's descriptor arena. Tests only. + internal DescriptorArena DescriptorArenaForTests(int slot) => _descriptorArenas[slot]; - api.CmdSetDepthTestEnable(commandBuffer, _state.DepthTest); - api.CmdSetDepthWriteEnable(commandBuffer, _state.DepthWrite); - api.CmdSetDepthCompareOp(commandBuffer, _state.DepthCompare); + /// The slot the current (or last) frame records into. Tests only. + internal int CurrentSlotForTests => _frames.Current.Index; - api.CmdSetStencilTestEnable(commandBuffer, _state.StencilTest); - api.CmdSetStencilOp(commandBuffer, StencilFaceFlags.FaceFrontAndBack, - _state.StencilFail, _state.StencilPass, _state.StencilDepthFail, _state.StencilCompare); - api.CmdSetStencilCompareMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, _state.StencilCompareMask); - api.CmdSetStencilWriteMask(commandBuffer, StencilFaceFlags.FaceFrontAndBack, _state.StencilWriteMask); - api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, _state.StencilReference); + /// The indirect ring's bookkeeping. Tests only. + internal IndirectRing IndirectRingForTests => _indirectRing; + + /// Multi-draws that took an overflow buffer, and slot buffers grown at a frame boundary. Tests only. + internal long IndirectOverflowsForTests => _indirectOverflows; + internal long IndirectGrowthsForTests => _indirectGrowths; + + /// Replaces the ring with one whose slot buffers start at bytes. Before the first multi-draw only. Tests only. + internal ulong IndirectMinimumCapacityForTests + { + set => _indirectRing = new IndirectRing(_frames.FramesInFlight, value); + } - api.CmdSetLineWidth(commandBuffer, _context.Capabilities.WideLines ? _state.LineWidth : 1.0f); + /// + /// The frame boundary of the indirect ring: this slot's cursor returns to 0, + /// and its buffer grows here, and only here, when the busiest frame so far did + /// not fit. Overflow buffers of the frames before retire on the timelines. + /// + private void BeginIndirectFrame(int slot) + { + foreach (VulkanBuffer overflow in _indirectOverflow) _frames.DeferDeletion(overflow); + _indirectOverflow.Clear(); + _indirectOverflowCursor = 0; - VulkanStats.NoteDynamicStateCommands(VulkanStats.DynamicStateCommandsPerDraw); + if (_indirectRing.BeginFrame(slot, out ulong capacity)) + { + // Draws of the slot's previous frame named the old buffer; it retires + // on the timelines like any other resource. + _frames.DeferDeletion(_indirectBuffers[slot]!); + _indirectBuffers[slot] = CreateIndirectBuffer(capacity); + _indirectRing.Attach(capacity); + _indirectGrowths++; + } } - private VulkanBuffer? _indirectScratch; - private ulong _indirectCursor; - private ulong _indirectFrameUsage; - private ulong _indirectPeakFrameUsage; + /// Per-frame dynamic data, so the ReBAR class (a miss falls through, counted). + private VulkanBuffer CreateIndirectBuffer(ulong size) => + new(_context, size, + BufferUsageFlags.IndirectBufferBit, + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + MemoryPoolClass.ReBar); /// - /// Hands out a region of the indirect-command buffer for one multi-draw. + /// Hands out a region of the current slot's indirect-command buffer for one + /// multi-draw. /// /// The commands are written on the CPU when the draw is recorded and read by /// the GPU when it executes, which is later - after every other draw of the @@ -2414,36 +2578,43 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta /// the ranges of whichever was recorded last, and the chunk pass is hundreds /// of them. /// - /// The buffer is a ring that wraps, sized to hold four times the busiest - /// frame seen, so a wrap can never reach a region a frame still in flight is - /// reading. A buffer that has to grow is deferred rather than freed, because - /// draws already recorded this frame still name it. + /// Regions are bump-allocated per slot and never wrap (Phase 1B step 6): the + /// cursor resets only at the slot's next frame start. A frame that outgrows + /// its slot's buffer continues in an overflow buffer, counted, and the slot + /// grows at its next frame boundary. /// private VulkanBuffer AllocateIndirect(int groupCount, out ulong offset) { ulong needed = (ulong)Math.Max(groupCount, 1) * (ulong)sizeof(DrawIndexedIndirectCommand); + int slot = _indirectRing.Current; - _indirectFrameUsage += needed; - if (_indirectFrameUsage > _indirectPeakFrameUsage) _indirectPeakFrameUsage = _indirectFrameUsage; - - ulong required = Math.Max(Math.Max(_indirectPeakFrameUsage * 4, needed), 256UL * 1024); - if (_indirectScratch == null || _indirectScratch.Size < required) + if (_indirectRing.NeedsBuffer(needed, out ulong capacity)) { - if (_indirectScratch != null) _frames.DeferDeletion(_indirectScratch); - - // Per-frame dynamic data, so the ReBAR class (a miss falls through, counted). - _indirectScratch = new VulkanBuffer(_context, required, - BufferUsageFlags.IndirectBufferBit, - MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, - MemoryPoolClass.ReBar); - _indirectCursor = 0; + // Nothing recorded names a buffer the slot never had, so creating one is safe mid-frame. + _indirectBuffers[slot] = CreateIndirectBuffer(capacity); + _indirectRing.Attach(capacity); } - if (_indirectCursor + needed > _indirectScratch.Size) _indirectCursor = 0; + if (_indirectRing.TryAllocate(needed, out offset)) return _indirectBuffers[slot]!; + + _indirectOverflows++; + VulkanStats.NoteIndirectOverflow(); + VulkanBuffer? current = _indirectOverflow.Count == 0 ? null : _indirectOverflow[^1]; + if (current == null || _indirectOverflowCursor + needed > current.Size) + { + current = CreateIndirectBuffer(_indirectRing.CapacityFor(Math.Max(needed, _indirectRing.CapacityOf(slot)))); + _indirectOverflow.Add(current); + _indirectOverflowCursor = 0; + if (RenderTrace.Enabled) + { + RenderTrace.Write("indirect overflow: slot " + slot + " capacity " + _indirectRing.CapacityOf(slot) + + " frame usage " + _indirectRing.FrameUsageOf(slot) + "; overflow buffer " + current.Size); + } + } - offset = _indirectCursor; - _indirectCursor += needed; - return _indirectScratch; + offset = _indirectOverflowCursor; + _indirectOverflowCursor += needed; + return current; } // -------------------------------------------------------------------- queries @@ -2710,7 +2881,10 @@ public void Dispose() _queryRing?.Dispose(); _readbacks?.Dispose(); - _indirectScratch?.Dispose(); + foreach (VulkanBuffer? indirect in _indirectBuffers) indirect?.Dispose(); + foreach (VulkanBuffer overflow in _indirectOverflow) overflow.Dispose(); + _indirectOverflow.Clear(); + foreach (DescriptorArena arena in _descriptorArenas) arena.Dispose(); _defaultAttributes?.Dispose(); _placeholderUniforms?.Dispose(); _swapchain?.Dispose(); From 025c82e72b6095e980a20c2bb6f32af5be25e525 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:51:57 +0200 Subject: [PATCH 101/226] wip(phase1b-step6): tests for the indirect ring, descriptor arena, dirty-masked dynamic state and GetError - 34/34 of the new and touched tests green under sync,best (IndirectRingWrapTests, DynamicStateCacheTests, ResourceAgeTests, GetErrorCounterTests, PerDrawCostTests, PacingStatsTests, descriptor cache integration) --- .../PerDrawCostTests.cs | 479 ++++++++++++++++++ .../PerDrawCostUnitTests.cs | 316 ++++++++++++ Optimum.Render.Vulkan/Frame/IndirectRing.cs | 4 +- 3 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs diff --git a/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs new file mode 100644 index 00000000..eefc2093 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs @@ -0,0 +1,479 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 1B step 6 against a real device, under the suite's sync,best validation: +/// dirty-masked dynamic state (fewer commands, same pixels, re-emitted for every +/// new recording), the per-slot descriptor arena (reset per frame, sets move to +/// the cache once their resources age), and the per-slot indirect ring (overflow +/// within a frame, growth at the boundary, every multi-draw's ranges intact). +/// +public class PerDrawCostTests +{ + private readonly ITestOutputHelper _output; + + public PerDrawCostTests(ITestOutputHelper output) => _output = output; + + private const int Size = 16; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + private static string SolidFragment(string colour) => """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4( + """ + colour + "); }\n"; + + private const string SampleFragment = """ + #version 330 core + uniform sampler2D source; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(source, uv); } + """; + + private const string MeshVertex = """ + #version 330 core + layout(location = 0) in vec3 position; + void main() { gl_Position = vec4(position, 1); } + """; + + private static int CreateTarget(IOptimumGraphicsDevice seam) + { + int texture = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + return framebuffer; + } + + private static unsafe byte[] Read(IOptimumGraphicsDevice seam, int framebuffer) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + private static unsafe int SolidTexture(IOptimumGraphicsDevice seam, byte r, byte g, byte b) + { + var pixels = new byte[Size * Size * 4]; + for (int i = 0; i < pixels.Length; i += 4) + { + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = 255; + } + fixed (byte* data = pixels) + { + return seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, + (IntPtr)data, false); + } + } + + private static void BaseState(IOptimumGraphicsDevice seam) + { + seam.SetViewport(0, 0, Size, Size); + seam.SetScissorEnabled(false); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + } + + // ------------------------------------------------------------ dynamic state + + [SkippableFact] + public void RepeatedIdenticalStateRecordsDynamicStateOncePerRecording() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int red = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, SolidFragment("1.0, 0.0, 0.0, 1.0"), "dyn-red"); + int a = CreateTarget(seam); + int b = CreateTarget(seam); + int all = VulkanStats.DynamicStateCommandsPerDraw; + + seam.BeginFrame(); + seam.BindFramebuffer(a); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(red); + BaseState(seam); + + long before = device!.DynamicStateCommandsForTests; + seam.DrawFullscreenTriangle(); + Assert.Equal(all, device.DynamicStateCommandsForTests - before); + + for (int i = 0; i < 9; i++) seam.DrawFullscreenTriangle(); + Assert.Equal(all, device.DynamicStateCommandsForTests - before); + + // One changed value, one command. + seam.SetViewport(0, 0, Size / 2, Size); + seam.DrawFullscreenTriangle(); + Assert.Equal(all + 1, device.DynamicStateCommandsForTests - before); + + // A new rendering scope in the same command buffer keeps the state. + seam.BindFramebuffer(b); + seam.DrawFullscreenTriangle(); + Assert.Equal(all + 1, device.DynamicStateCommandsForTests - before); + + // A readback submits the frame so far and continues in a new command + // buffer, whose state starts undefined: everything again. + byte[] mid = Read(seam, a); + Assert.Equal(255, mid[0]); + seam.BindFramebuffer(a); + seam.DrawFullscreenTriangle(); + Assert.Equal(2 * all + 1, device.DynamicStateCommandsForTests - before); + seam.Present(); + + // A new frame is a new recording too. + seam.BeginFrame(); + seam.BindFramebuffer(a); + before = device.DynamicStateCommandsForTests; + for (int i = 0; i < 5; i++) seam.DrawFullscreenTriangle(); + Assert.Equal(all, device.DynamicStateCommandsForTests - before); + seam.Present(); + + // Masking off is the old behaviour: every command on every draw. + device.DynamicStateMaskingForTests = false; + seam.BeginFrame(); + seam.BindFramebuffer(a); + before = device.DynamicStateCommandsForTests; + for (int i = 0; i < 5; i++) seam.DrawFullscreenTriangle(); + Assert.Equal(5 * all, device.DynamicStateCommandsForTests - before); + seam.Present(); + + GpuTest.AssertClean(seam); + } + } + + /// + /// The same state-changing sequence (viewports, scissor on and off, cull, + /// program and target switches, two frames with Present between) renders the + /// same pixels with masking on and off. + /// + [SkippableFact] + public void MaskedDynamicStateDrawsThePixelsUnmaskedStateDraws() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int red = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, SolidFragment("1.0, 0.0, 0.0, 1.0"), "dyn-red"); + int green = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, SolidFragment("0.0, 1.0, 0.0, 1.0"), "dyn-green"); + + var targets = new int[4]; + for (int i = 0; i < targets.Length; i++) targets[i] = CreateTarget(seam); + + // Masked into targets 0 and 1, unmasked into 2 and 3. + for (int run = 0; run < 2; run++) + { + device!.DynamicStateMaskingForTests = run == 0; + int a = targets[run * 2]; + int b = targets[run * 2 + 1]; + for (int frame = 0; frame < 2; frame++) + { + seam.BeginFrame(); + Scene(seam, red, green, a, b, frame); + seam.Present(); + } + } + + device!.DynamicStateMaskingForTests = true; + seam.BeginFrame(); + byte[] maskedA = Read(seam, targets[0]); + byte[] maskedB = Read(seam, targets[1]); + byte[] plainA = Read(seam, targets[2]); + byte[] plainB = Read(seam, targets[3]); + seam.Present(); + + Assert.Equal(plainA, maskedA); + Assert.Equal(plainB, maskedB); + + int lit = 0; + for (int i = 0; i < maskedA.Length; i += 4) if (maskedA[i] > 0 || maskedA[i + 1] > 0) lit++; + Assert.InRange(lit, 1, Size * Size - 1); // the sequence actually drew something partial + + GpuTest.AssertClean(seam); + } + } + + private static void Scene(IOptimumGraphicsDevice seam, int red, int green, int a, int b, int frame) + { + seam.BindFramebuffer(a); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(red); + BaseState(seam); + seam.SetViewport(0, 0, Size / 2, Size); + for (int i = 0; i < 3; i++) seam.DrawFullscreenTriangle(); + + seam.BindFramebuffer(b); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(green); + seam.SetViewport(Size / 2, 0, Size / 2, Size); + seam.DrawFullscreenTriangle(); + seam.DrawFullscreenTriangle(); + + seam.BindFramebuffer(a); + seam.SetViewport(0, 0, Size, Size); + seam.SetScissorEnabled(true); + seam.SetScissor(Size / 2, Size / 2, Size / 2, Size / 2); + seam.UseProgram(green); + seam.DrawFullscreenTriangle(); + + seam.SetScissorEnabled(false); + seam.SetViewport(0, Size / 2, Size / 4, Size / 2); + seam.UseProgram(frame == 0 ? red : green); + seam.DrawFullscreenTriangle(); + + seam.SetCullFace(true); + seam.SetCullFaceMode(frame == 0); + seam.SetViewport(Size / 4, 0, Size / 4, Size / 4); + seam.DrawFullscreenTriangle(); + seam.SetCullFace(false); + + seam.BindFramebuffer(b); + seam.SetViewport(0, 0, Size / 4, Size / 4); + seam.DrawFullscreenTriangle(); + } + + // -------------------------------------------------------- descriptor arena + + [SkippableFact] + public void TheDescriptorArenaResetsEveryFrameAndAgedSetsMoveToTheCache() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + const int window = 4; + device!.ShortLivedFramesForTests = window; + + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, SampleFragment, "arena-sample"); + int target = CreateTarget(seam); + + // Frames pass so the texture below is the only young resource. + for (int i = 0; i < window + 1; i++) + { + seam.BeginFrame(); + seam.Present(); + } + + int texture = SolidTexture(seam, 40, 120, 200); + int cachedBefore = device.CachedDescriptorSets; + long[] allocationsBefore = { device.DescriptorArenaForTests(0).Allocations, device.DescriptorArenaForTests(1).Allocations }; + int[] poolsAfterFirstUse = { -1, -1 }; + + for (int frame = 0; frame < window + 4; frame++) + { + seam.BeginFrame(); + int slot = device.CurrentSlotForTests; + DescriptorArena arena = device.DescriptorArenaForTests(slot); + Assert.Equal(0, arena.SetsThisFrame); // reset at the slot's frame start + + seam.BindFramebuffer(target); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, texture); + BaseState(seam); + seam.DrawFullscreenTriangle(); + seam.DrawFullscreenTriangle(); + + // Whatever else the program's first draw cached (a set naming only + // permanent resources) is counted from frame 0 on. + if (frame == 0) cachedBefore = device.CachedDescriptorSets; + + if (frame < window - 1) + { + // Young: one set in the arena, written once and shared by both draws. + Assert.Equal(1, arena.SetsThisFrame); + Assert.Equal(cachedBefore, device.CachedDescriptorSets); + if (poolsAfterFirstUse[slot] < 0) poolsAfterFirstUse[slot] = arena.PoolCount; + Assert.Equal(poolsAfterFirstUse[slot], arena.PoolCount); + } + else if (frame >= window) + { + // Aged out: the long-lived cache holds it now, the arena nothing. + Assert.Equal(0, arena.SetsThisFrame); + Assert.Equal(cachedBefore + 1, device.CachedDescriptorSets); + } + + if (frame == 0 || frame == window + 3) + { + byte[] pixels = Read(seam, target); + int centre = (Size / 2 * Size + Size / 2) * 4; + Assert.Equal(40, pixels[centre]); + Assert.Equal(120, pixels[centre + 1]); + Assert.Equal(200, pixels[centre + 2]); + } + seam.Present(); + } + + // Per-frame allocation, not accumulation: each slot wrote its set once + // per young frame and the pools never grew. + long written = device.DescriptorArenaForTests(0).Allocations - allocationsBefore[0] + + device.DescriptorArenaForTests(1).Allocations - allocationsBefore[1]; + Assert.InRange(written, window - 1, window); + + GpuTest.AssertClean(seam); + } + } + + /// + /// A texture deleted and replaced while its sets live in the arena: the next + /// frame's reset drops them, the successor gets its own, and the old image is + /// destroyed only after the frame that bound it (validation checks that). + /// + [SkippableFact] + public void AnArenaSetNamingADeletedTextureNeverReachesALaterFrame() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, SampleFragment, "arena-delete"); + int target = CreateTarget(seam); + + byte[] last = Array.Empty(); + for (int frame = 0; frame < 6; frame++) + { + int texture = SolidTexture(seam, (byte)(30 * frame), 90, 10); + seam.BeginFrame(); + seam.BindFramebuffer(target); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, texture); + BaseState(seam); + seam.DrawFullscreenTriangle(); + Assert.True(device!.DescriptorArenaForTests(device.CurrentSlotForTests).SetsThisFrame >= 1); + seam.DeleteTexture(texture); + if (frame == 5) last = Read(seam, target); + seam.Present(); + } + + int centre = (Size / 2 * Size + Size / 2) * 4; + Assert.Equal(150, last[centre]); + Assert.Equal(90, last[centre + 1]); + GpuTest.AssertClean(seam); + } + } + + // ------------------------------------------------------------ indirect ring + + /// Four quads side by side in one mesh, one quarter of the width each. + private static MeshData Strips() + { + var xyz = new float[4 * 4 * 3]; + var indices = new int[4 * 6]; + for (int strip = 0; strip < 4; strip++) + { + float x0 = -1f + strip * 0.5f; + float x1 = x0 + 0.5f; + float[] corners = { x0, -1f, 0f, x1, -1f, 0f, x1, 1f, 0f, x0, 1f, 0f }; + Array.Copy(corners, 0, xyz, strip * 12, 12); + int v = strip * 4; + int[] quad = { v, v + 1, v + 2, v, v + 2, v + 3 }; + Array.Copy(quad, 0, indices, strip * 6, 6); + } + return new MeshData(16, 24) + { + xyz = xyz, + VerticesCount = 16, + Indices = indices, + IndicesCount = 24, + }; + } + + private static void DrawStrips(IOptimumGraphicsDevice seam, int target, int program, int mesh) + { + seam.BindFramebuffer(target); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(program); + BaseState(seam); + // One multi-draw per strip, each with its own indirect region: a region + // overwritten or shared shows up as a missing strip. + for (int strip = 0; strip < 4; strip++) + { + // Starts are GL's 64-bit byte offsets, two ints per group (low, high). + seam.DrawMeshMulti(mesh, new[] { strip * 6 * sizeof(int), 0 }, new[] { 6 }, 1, false); + } + } + + [SkippableFact] + public void AFrameOutgrowingItsSlotOverflowsThenTheSlotGrowsAtItsNextFrame() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + IOptimumGraphicsDevice seam = device!; + // Two indirect commands per slot buffer to start with. + device!.IndirectMinimumCapacityForTests = 40; + + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, MeshVertex, SolidFragment("1.0, 1.0, 1.0, 1.0"), "indirect-strips"); + int mesh = seam.CreateMesh(Strips(), false); + int first = CreateTarget(seam); + int second = CreateTarget(seam); + + // Frame 1 (slot 0): two regions fit, two overflow. + seam.BeginFrame(); + int slot0 = device.CurrentSlotForTests; + DrawStrips(seam, first, program, mesh); + Assert.Equal(2, device.IndirectOverflowsForTests); + Assert.Equal(40UL, device.IndirectRingForTests.CapacityOf(slot0)); + seam.Present(); + + // Frame 2 (slot 1): its buffer is created at the busiest frame's size. + seam.BeginFrame(); + DrawStrips(seam, second, program, mesh); + Assert.Equal(2, device.IndirectOverflowsForTests); + seam.Present(); + + // Frame 3 (slot 0 again): grown at the boundary, nothing overflows. + seam.BeginFrame(); + Assert.Equal(slot0, device.CurrentSlotForTests); + Assert.Equal(1, device.IndirectGrowthsForTests); + Assert.True(device.IndirectRingForTests.CapacityOf(slot0) >= 80); + DrawStrips(seam, second, program, mesh); + Assert.Equal(2, device.IndirectOverflowsForTests); + seam.Present(); + + // No readback until now: the first target still holds frame 1's + // overflowed draws, the second frame 3's. + seam.BeginFrame(); + foreach (int target in new[] { first, second }) + { + byte[] pixels = Read(seam, target); + for (int i = 0; i < pixels.Length; i += 4) + { + Assert.True(pixels[i] == 255, "target " + target + " pixel " + i / 4 + " is " + pixels[i]); + } + } + seam.Present(); + + seam.DeleteMesh(mesh); + GpuTest.AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs new file mode 100644 index 00000000..2993b81c --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs @@ -0,0 +1,316 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 1B step 6, pure half: the per-slot indirect ring never wraps inside a +/// frame and grows only at a frame boundary. +/// +public class IndirectRingWrapTests +{ + private const ulong Command = 20; // sizeof(DrawIndexedIndirectCommand) + + [Fact] + public void AllocationsInOneFrameNeverWrapBackToOffsetZero() + { + var ring = new IndirectRing(2, minimumCapacity: 100); + Assert.False(ring.BeginFrame(0, out _)); + + Assert.True(ring.NeedsBuffer(Command, out ulong capacity)); + Assert.Equal(100UL, capacity); + ring.Attach(capacity); + Assert.False(ring.NeedsBuffer(Command, out _)); + + for (ulong i = 0; i < 5; i++) + { + Assert.True(ring.TryAllocate(Command, out ulong offset)); + Assert.Equal(i * Command, offset); + } + + // Full: the wrapping ring would have handed out offset 0 here, a region a + // draw of this very frame is still going to read. + Assert.False(ring.TryAllocate(Command, out _)); + Assert.False(ring.TryAllocate(Command, out _)); + Assert.Equal(100UL, ring.CursorOf(0)); + Assert.Equal(2, ring.OverflowsOf(0)); + Assert.Equal(7 * Command, ring.FrameUsageOf(0)); + Assert.Equal(100UL, ring.CapacityOf(0)); + } + + [Fact] + public void ASlotResetsOnlyAtItsOwnFrameBoundary() + { + var ring = new IndirectRing(2, minimumCapacity: 100); + ring.BeginFrame(0, out _); + ring.NeedsBuffer(Command, out ulong capacity); + ring.Attach(capacity); + for (int i = 0; i < 3; i++) Assert.True(ring.TryAllocate(Command, out _)); + + // Slot 1's frame starts while slot 0's may still be executing: slot 0's + // regions stay where they are. + ring.BeginFrame(1, out _); + Assert.Equal(1, ring.Current); + Assert.Equal(3 * Command, ring.CursorOf(0)); + Assert.True(ring.NeedsBuffer(Command, out capacity)); + ring.Attach(capacity); + Assert.True(ring.TryAllocate(Command, out ulong offset)); + Assert.Equal(0UL, offset); + + ring.BeginFrame(0, out _); + Assert.Equal(0UL, ring.CursorOf(0)); + Assert.Equal(Command, ring.CursorOf(1)); + Assert.True(ring.TryAllocate(Command, out offset)); + Assert.Equal(0UL, offset); + } + + [Fact] + public void GrowthWaitsForTheFrameBoundaryAndThenFitsTheBusiestFrame() + { + var ring = new IndirectRing(2, minimumCapacity: 100); + ring.BeginFrame(0, out _); + ring.NeedsBuffer(Command, out ulong capacity); + ring.Attach(capacity); + + int fitted = 0; + for (int i = 0; i < 8; i++) + { + if (ring.TryAllocate(Command, out _)) fitted++; + } + Assert.Equal(5, fitted); + Assert.Equal(100UL, ring.CapacityOf(0)); // no growth mid-frame + + // A slot without a buffer is not "grown"; it is created at its first + // allocation, already big enough for the busiest frame so far. + Assert.False(ring.BeginFrame(1, out _)); + Assert.Equal(8 * Command, ring.PeakFrameUsage); + Assert.True(ring.NeedsBuffer(Command, out capacity)); + Assert.True(capacity >= 8 * Command); + + Assert.True(ring.BeginFrame(0, out capacity)); + Assert.True(capacity >= 8 * Command); + ring.Attach(capacity); + for (int i = 0; i < 8; i++) Assert.True(ring.TryAllocate(Command, out _)); + Assert.Equal(0, ring.OverflowsOf(0)); + + // Fitting now: the next boundary does not grow again. + ring.BeginFrame(1, out _); + Assert.False(ring.BeginFrame(0, out _)); + } + + [Fact] + public void CapacityHasHeadroomAFloorAndA64KiBGrain() + { + var ring = new IndirectRing(2); + Assert.Equal(IndirectRing.DefaultMinimumCapacity, ring.CapacityFor(0)); + Assert.Equal(IndirectRing.DefaultMinimumCapacity, ring.CapacityFor(Command)); + Assert.Equal(1536UL * 1024, ring.CapacityFor(1024UL * 1024)); + + var random = new Random(6); + for (int i = 0; i < 200; i++) + { + ulong demand = (ulong)random.Next(0, 64 * 1024 * 1024); + ulong capacity = ring.CapacityFor(demand); + Assert.True(capacity >= demand + demand / 2); + Assert.Equal(0UL, capacity % (64UL * 1024)); + } + } + + [Fact] + public void MisuseIsRefused() + { + var ring = new IndirectRing(1, minimumCapacity: 100); + Assert.Throws(() => ring.TryAllocate(Command, out _)); + + ring.BeginFrame(0, out _); + ring.Attach(100); + Assert.True(ring.TryAllocate(Command * 3, out _)); + Assert.Throws(() => ring.Attach(Command)); + } +} + +/// Phase 1B step 6, pure half: only changed dynamic state is emitted. +public class DynamicStateCacheTests +{ + private static DynamicStateValues Values() => new() + { + Viewport = new Viewport(0, 0, 64, 64, 0, 1), + Scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(64, 64)), + CullMode = CullModeFlags.None, + FrontFace = FrontFace.Clockwise, + Topology = PrimitiveTopology.TriangleList, + DepthCompare = CompareOp.Less, + StencilCompare = CompareOp.Always, + StencilCompareMask = 0xFF, + StencilWriteMask = 0xFF, + LineWidth = 1f, + }; + + [Fact] + public void EveryBitIsOneCommandAndAllIsTheFullSet() + { + Assert.Equal(VulkanStats.DynamicStateCommandsPerDraw, DynamicStateCache.CommandCount(DynamicStateDirty.All)); + } + + [Fact] + public void RepeatedIdenticalStateEmitsNothing() + { + var cache = new DynamicStateCache(); + DynamicStateValues values = Values(); + + Assert.Equal(DynamicStateDirty.All, cache.Update(1, values)); + for (int i = 0; i < 10; i++) Assert.Equal(DynamicStateDirty.None, cache.Update(1, values)); + } + + [Fact] + public void OnlyTheChangedFieldsAreDirty() + { + var cache = new DynamicStateCache(); + DynamicStateValues values = Values(); + cache.Update(1, values); + + values.Viewport.Width = 32; + Assert.Equal(DynamicStateDirty.Viewport, cache.Update(1, values)); + + values.DepthTest = !values.DepthTest; + values.DepthWrite = !values.DepthWrite; + Assert.Equal(DynamicStateDirty.DepthTestEnable | DynamicStateDirty.DepthWriteEnable, cache.Update(1, values)); + + values.StencilDepthFail = StencilOp.Replace; + Assert.Equal(DynamicStateDirty.StencilOp, cache.Update(1, values)); + + values.Scissor.Offset.Y = 4; + values.LineWidth = 2f; + Assert.Equal(DynamicStateDirty.Scissor | DynamicStateDirty.LineWidth, cache.Update(1, values)); + + values.LineWidth = float.NaN; + cache.Update(1, values); + Assert.Equal(DynamicStateDirty.None, cache.Update(1, values)); + } + + [Fact] + public void ANewRecordingInvalidationOrDisabledCacheEmitsEverything() + { + var cache = new DynamicStateCache(); + DynamicStateValues values = Values(); + cache.Update(1, values); + + Assert.Equal(DynamicStateDirty.All, cache.Update(2, values)); + Assert.Equal(DynamicStateDirty.All, cache.Update(0, values)); + Assert.Equal(DynamicStateDirty.All, cache.Update(0, values)); + + cache.Update(3, values); + cache.Invalidate(); + Assert.Equal(DynamicStateDirty.All, cache.Update(3, values)); + + cache.Enabled = false; + Assert.Equal(DynamicStateDirty.All, cache.Update(3, values)); + } +} + +/// Phase 1B step 6, pure half: short-lived resources by id watermark. +public class ResourceAgeTests +{ + [Fact] + public void BeforeTheWindowFillsEveryResourceIsYoungButIdZeroNeverIs() + { + var age = new ResourceAge(); + age.NoteFrame(10); + Assert.True(age.IsShortLived(1)); + Assert.False(age.IsShortLived(0)); + } + + [Fact] + public void AResourceIsShortLivedForExactlyTheLastNFrames() + { + var age = new ResourceAge(60); + // Frame k starts with ids up to 10k issued; frame k creates 10k+1 .. 10k+10. + for (ulong frame = 0; frame < 100; frame++) age.NoteFrame(frame * 10); + + // Frame 99 records now; frames 40..99 are the last 60. + Assert.True(age.IsShortLived(401)); // created in frame 40 + Assert.False(age.IsShortLived(400)); // created in frame 39 + Assert.True(age.IsShortLived(999)); + + age.ShortLivedFrames = 1; + Assert.True(age.IsShortLived(991)); + Assert.False(age.IsShortLived(990)); + + age.ShortLivedFrames = 0; + Assert.False(age.IsShortLived(999)); + Assert.Throws(() => age.ShortLivedFrames = 61); + } + + [Fact] + public void ASetIsShortLivedWhenAnyResourceItNamesIs() + { + var age = new ResourceAge(2); + age.NoteFrame(100); + age.NoteFrame(200); + age.NoteFrame(300); // frames 1..2 are the window: ids above 200 + + var old = new SamplerBindingValue(0, new ImageView(1), new Sampler(1), Resource: 150); + var young = new SamplerBindingValue(1, new ImageView(2), new Sampler(1), Resource: 250); + var permanent = new BufferBindingValue(0, new Silk.NET.Vulkan.Buffer(3), 0, 64, Resource: 0); + + Assert.False(age.NamesShortLived(new DescriptorSetContents(1, 1, new[] { old }, new[] { permanent }))); + Assert.True(age.NamesShortLived(new DescriptorSetContents(1, 1, new[] { old, young }, Array.Empty()))); + Assert.True(age.NamesShortLived(new DescriptorSetContents(1, 0, Array.Empty(), + new[] { permanent, new BufferBindingValue(1, new Silk.NET.Vulkan.Buffer(4), 0, 64, Resource: 201) }))); + } +} + +/// Phase 1B step 6: GetError is a counter read until there is an error. +public class GetErrorCounterTests +{ + [Fact] + public void OnlyErrorsAreReportedOnceInOrder() + { + using var device = new VulkanDevice(); + Assert.Null(device.GetError()); + + device.AddDiagnosticForTests("a warning the layers raised"); + Assert.Null(device.GetError()); + + device.AddDiagnosticForTests(VulkanContext.ErrorPrefix + "first"); + device.AddDiagnosticForTests("advice"); + device.AddDiagnosticForTests(VulkanContext.ErrorPrefix + "second"); + Assert.Equal(VulkanContext.ErrorPrefix + "first\n" + VulkanContext.ErrorPrefix + "second", device.GetError()); + Assert.Null(device.GetError()); + } + + [Fact] + public void ErrorsFromManyThreadsAreAllKept() + { + using var device = new VulkanDevice(); + Parallel.For(0, 4, thread => + { + for (int i = 0; i < 200; i++) device.AddDiagnosticForTests(VulkanContext.ErrorPrefix + thread + ":" + i); + }); + + string? errors = device.GetError(); + Assert.NotNull(errors); + Assert.Equal(800, errors!.Split('\n').Length); + Assert.Null(device.GetError()); + } + + [Fact] + public void TheSteadyStatePathIsACounterReadWithNoAllocation() + { + string device = File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, "Optimum.Render.Vulkan", "VulkanDevice.cs")); + int start = device.IndexOf("public string GetError()", StringComparison.Ordinal); + Assert.True(start >= 0); + string body = device.Substring(start, device.IndexOf("\n }\n", start, StringComparison.Ordinal) - start); + + int counter = body.IndexOf("if (_errorCount == 0) return null!;", StringComparison.Ordinal); + Assert.True(counter >= 0, "GetError must start with the counter read"); + Assert.True(counter < body.IndexOf("lock (_errors)", StringComparison.Ordinal)); + Assert.DoesNotContain("new List", body); + Assert.Contains("private volatile int _errorCount;", device); + Assert.DoesNotContain("_diagnostics", device); + } +} diff --git a/Optimum.Render.Vulkan/Frame/IndirectRing.cs b/Optimum.Render.Vulkan/Frame/IndirectRing.cs index 34a7efb7..8d711ba4 100644 --- a/Optimum.Render.Vulkan/Frame/IndirectRing.cs +++ b/Optimum.Render.Vulkan/Frame/IndirectRing.cs @@ -61,8 +61,10 @@ public IndirectRing(int slots, ulong minimumCapacity = DefaultMinimumCapacity) /// public ulong CapacityFor(ulong demand) { + // A ring whose minimum is below the granularity (tests) rounds to its minimum. + ulong granularity = Math.Min(Granularity, MinimumCapacity); ulong wanted = demand + demand / 2; - ulong rounded = (wanted + Granularity - 1) / Granularity * Granularity; + ulong rounded = (wanted + granularity - 1) / granularity * granularity; return Math.Max(MinimumCapacity, rounded); } From 9d0fa8519f4b546fd88366e7bb7f0bb5b970f6a4 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:54:36 +0200 Subject: [PATCH 102/226] wip(phase1b-step6): per-draw costs verified - Release solution build 0 errors, Optimum.Tests 1070/0 failed, GPU suite 482/482 under sync,best; GetError unit tests build their device through GpuTest.NewDevice --- Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs index 2993b81c..8fd01e62 100644 --- a/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs @@ -270,7 +270,8 @@ public class GetErrorCounterTests [Fact] public void OnlyErrorsAreReportedOnceInOrder() { - using var device = new VulkanDevice(); + // Never initialised: no context, no GPU; only the diagnostics queue is exercised. + using VulkanDevice device = GpuTest.NewDevice(); Assert.Null(device.GetError()); device.AddDiagnosticForTests("a warning the layers raised"); @@ -286,7 +287,8 @@ public void OnlyErrorsAreReportedOnceInOrder() [Fact] public void ErrorsFromManyThreadsAreAllKept() { - using var device = new VulkanDevice(); + // Never initialised: no context, no GPU; only the diagnostics queue is exercised. + using VulkanDevice device = GpuTest.NewDevice(); Parallel.For(0, 4, thread => { for (int i = 0; i < 200; i++) device.AddDiagnosticForTests(VulkanContext.ErrorPrefix + thread + ":" + i); From 29badd58db69334af4b1cd76f6314457985971c5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 20:57:28 +0200 Subject: [PATCH 103/226] wip(phase1): integrate Phase 1A steps 2-5 with Phase 1B steps 1-2 and review fixes Merge of worktree-wf_c601e176-bbf-3 applied without textual conflicts; QueryRingTests and ReadbackMidFrameTests ported from IOptimumGraphicsDevice to VulkanDevice (1A pattern). VulkanDevice keeps QueryRing and ReadbackManager and implements only IDisposable. Verified: - dotnet build VintageStory.slnx -c Release: 0 errors, 1 warning - extract-patches: 157 patches, no diff; check-patches: 93 applied, 64 cecil, 0 pending, 0 conflict - Optimum.Tests: 1126 passed, 0 failed, 34 skipped (1160) - Optimum.Render.Vulkan.Tests (sync,best): 431 passed, 0 failed, 0 skipped - Cecil patch: 197/197 required methods patched; Virtual dispatch verifier: ok, 25 callvirt sites, 0 call - API patch complete; check-vanilla-compat: ok, 17 skipped --- Optimum.Render.Vulkan.Tests/QueryRingTests.cs | 10 +++++----- .../ReadbackMidFrameTests.cs | 16 ++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/QueryRingTests.cs b/Optimum.Render.Vulkan.Tests/QueryRingTests.cs index 7b5afe0c..fa15f8a8 100644 --- a/Optimum.Render.Vulkan.Tests/QueryRingTests.cs +++ b/Optimum.Render.Vulkan.Tests/QueryRingTests.cs @@ -61,7 +61,7 @@ private static void AssertNoSilentWaits(long[] before) } } - private static int CreateTarget(IOptimumGraphicsDevice seam) + private static int CreateTarget(VulkanDevice seam) { int texture = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); @@ -75,7 +75,7 @@ private static int CreateTarget(IOptimumGraphicsDevice seam) /// SystemRenderSunMoon's probe: colour writes off, a query around one draw /// covering squared pixels. /// - private static void Probe(IOptimumGraphicsDevice seam, int framebuffer, int program, int query, int coverage) + private static void Probe(VulkanDevice seam, int framebuffer, int program, int query, int coverage) { seam.BindFramebuffer(framebuffer); seam.UseProgram(program); @@ -105,7 +105,7 @@ public void SunGlarePatternReadsEveryResultAFrameOrTwoLaterWithoutWaiting() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, WhiteFragment, "query-probe"); int framebuffer = CreateTarget(seam); int query = seam.CreateOcclusionQuery(); @@ -180,7 +180,7 @@ public void ResultsSurviveTheirSlotBeingRecycledAndSpanSeveralPools() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, WhiteFragment, "query-probe"); int framebuffer = CreateTarget(seam); bool precise = device!.PreciseOcclusionForTests; @@ -243,7 +243,7 @@ public unsafe void AQuerySpanningScopeEndsAndAPartialSubmitCountsEveryDraw() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, WhiteFragment, "query-probe"); int targetA = CreateTarget(seam); int targetB = CreateTarget(seam); diff --git a/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs index d6d89299..ad5aadb2 100644 --- a/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs +++ b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs @@ -41,7 +41,7 @@ private static void AssertUnchanged(WaitSite[] sites, long[] before) } } - private static int CreateTarget(IOptimumGraphicsDevice seam, int size, out int texture) + private static int CreateTarget(VulkanDevice seam, int size, out int texture) { texture = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); @@ -56,7 +56,7 @@ private static int CreateTarget(IOptimumGraphicsDevice seam, int size, out int t /// target: between frames BindFramebuffer is a no-op and the read sees /// whichever target the last frame bound. /// - private static byte[] Read(IOptimumGraphicsDevice seam, int framebuffer, int width, int height) + private static byte[] Read(VulkanDevice seam, int framebuffer, int width, int height) { var pixels = new byte[width * height * 4]; unsafe @@ -80,7 +80,7 @@ private static void AssertEvery(byte[] pixels, byte r, byte g, byte b, byte a, s } } - private static unsafe void SetTint(IOptimumGraphicsDevice seam, int ubo, byte r, byte g, byte b) + private static unsafe void SetTint(VulkanDevice seam, int ubo, byte r, byte g, byte b) { var tint = new float[] { r / 255f, g / 255f, b / 255f, 1f }; fixed (float* values = tint) @@ -104,7 +104,7 @@ public void AReadbackMidFrameSeesEarlierDrawsAndLaterDrawsStillReachTheFrame() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 16; int program = VulkanDeviceIntegrationTests.LinkProgram(seam, """ @@ -194,7 +194,7 @@ public void ReadbacksInConsecutiveFramesPaceOncePerFrameAndReadTheirOwnFrame() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 4; const int frames = 12; int target = CreateTarget(seam, size, out _); @@ -255,7 +255,7 @@ public void ReadbacksLargerThanTheArenaGrowItAndStayExact() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 1024; Assert.True((ulong)size * size * 4 > ReadbackManager.MinimumArenaSize); int target = CreateTarget(seam, size, out _); @@ -296,7 +296,7 @@ public unsafe void ReadbacksOfDifferentTexelSizesInOneFrameUseLegalOffsets() Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; byte[] rgba = { 11, 22, 33, 44 }; var floats = new float[2 * 2 * 4]; for (int i = 0; i < floats.Length; i++) floats[i] = i * 0.25f - 1.5f; @@ -337,7 +337,7 @@ public unsafe void AnUploadInsideAFrameSubmitsTheRecordedPartAndRecordingContinu Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); using (device) { - IOptimumGraphicsDevice seam = device!; + VulkanDevice seam = device!; const int size = 4; int targetA = CreateTarget(seam, size, out _); int targetB = CreateTarget(seam, size, out _); From 62c406b1be913decb4d34d4c37b10019d7827ca8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 21:20:01 +0200 Subject: [PATCH 104/226] wip(phase1-review): MeshRef.Dispose releases the device mesh; replaced swapchain slots retire one frame after their last present Adversarial review of Phase 1 (805efc6..bc7ae34), two fixes with regression tests: - VulkanClientPlatform.DeleteVertexArrayHandles was empty, so every MeshRef.Dispose (the client and mods call it directly ~78 times vs ~22 DeleteMesh) leaked the device mesh. VAO.Dispose now releases it once; DeleteMesh only disposes, as vanilla. - Swapchain retirement keyed on the last present submission's Frame value, which only proves the present semaphore was signalled, not that vkQueuePresentKHR ran. Now keyed on the next frame value (SwapchainPolicy.RetireAfter). Verified: Release build 0 errors; Optimum.Tests 1128 passed/0 failed/34 skipped; GPU suite 494/494 under default sync,best, 0 [FAIL]; Cecil lib patch 197/197 required methods, virtual dispatch verifier ok (0 call/ldftn); API patch exit 0; check-vanilla-compat ok, 0 cast divergences. Not verified in game. --- .../PlatformLeafRoutingTests.cs | 65 +++++++++++++++++++ .../SwapchainRetirementTests.cs | 26 ++++++++ .../Platform/VulkanClientPlatform.Leaf.cs | 13 +++- .../Platform/VulkanClientPlatform.Meshes.cs | 6 +- Optimum.Render.Vulkan/Present/Swapchain.cs | 2 +- .../Present/SwapchainRetirement.cs | 12 ++++ .../platform-seam-deletion-coverage-tests.cs | 21 +++++- .../vulkan-backend-integration-tests.cs | 4 +- 8 files changed, 140 insertions(+), 9 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs index 3eb138ce..242c495d 100644 --- a/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs +++ b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs @@ -3,6 +3,7 @@ using Optimum.Render.Vulkan.Platform; using Vintagestory.API.Client; using Vintagestory.API.Config; +using Vintagestory.Client; using Vintagestory.Client.NoObf; using Xunit; using Xunit.Abstractions; @@ -372,4 +373,68 @@ byte[] Centre(int textureId) seam.DeleteProgram(oitProgram); GpuTest.AssertClean(seam); } + + private static MeshData Quad() => new MeshData(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + + /// + /// Phase 1 review regression: MeshRef.Dispose (which the client and mods call directly as + /// often as DeleteMesh) releases the device mesh, exactly once. Before the fix VAO.Dispose + /// reached an empty DeleteVertexArrayHandles override and every directly disposed mesh + /// leaked its device buffers for the rest of the session. + /// + [SkippableFact] + public void DisposingAMeshRefReleasesTheDeviceMeshOnce() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanClientPlatform platform = session!.Platform; + VulkanDevice seam = session.Seam; + ClientPlatformAbstract previous = ScreenManager.Platform; + ScreenManager.Platform = platform; + try + { + MeshRef direct = platform.UploadMesh(Quad()); + int directId = ((VAO)direct).VaoId; + Assert.NotNull(seam.MeshesForTests.Get(directId)); + direct.Dispose(); + Assert.True(direct.Disposed); + Assert.Null(seam.MeshesForTests.Get(directId)); + + MeshRef viaPlatform = platform.UploadMesh(Quad()); + int viaId = ((VAO)viaPlatform).VaoId; + platform.DeleteMesh(viaPlatform); + Assert.True(viaPlatform.Disposed); + Assert.Null(seam.MeshesForTests.Get(viaId)); + + // The freed ids are reused; disposing the released VAOs again must not free the + // mesh that now holds one of them. + MeshRef survivor = platform.UploadMesh(Quad()); + int survivorId = ((VAO)survivor).VaoId; + direct.Dispose(); + viaPlatform.Dispose(); + platform.DeleteMesh(viaPlatform); + Assert.NotNull(seam.MeshesForTests.Get(survivorId)); + + // The released meshes are destroyed on the timelines like any other resource. + for (int frame = 0; frame < 4; frame++) + { + platform.BeginFrame(); + platform.EndFrame(); + } + Assert.NotNull(seam.MeshesForTests.Get(survivorId)); + survivor.Dispose(); + Assert.Null(seam.MeshesForTests.Get(survivorId)); + } + finally + { + ScreenManager.Platform = previous; + } + GpuTest.AssertClean(seam); + } } diff --git a/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs index 231e98ae..ce75d618 100644 --- a/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs +++ b/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs @@ -70,6 +70,32 @@ public void ASlotIsNotDestroyedBeforeItsLastPresentSubmissionCompleted() Assert.Equal(1, slot.DisposeCount); } + /// + /// Phase 1 review regression: a replaced slot is keyed on the frame after its last present + /// submission. That submission completing only signals the present semaphore; the + /// vkQueuePresentKHR queued after it may still be pending, and only the next frame's + /// submission (queued after the present) completing proves it was processed. + /// + [Fact] + public void AReplacedSlotOutlivesItsLastPresentSubmissionByOneFrame() + { + Assert.Equal(0UL, SwapchainPolicy.RetireAfter(0)); + Assert.Equal(8UL, SwapchainPolicy.RetireAfter(7)); + + var clock = new FakeClock(); + var retirement = new SwapchainRetirement(clock); + var slot = new Slot("old"); + retirement.Retire(slot, SwapchainPolicy.RetireAfter(7)); + + clock.FrameCompleted = 7; + Assert.Equal(0, retirement.Collect()); + Assert.Equal(0, slot.DisposeCount); + + clock.FrameCompleted = 8; + Assert.Equal(1, retirement.Collect()); + Assert.Equal(1, slot.DisposeCount); + } + [Fact] public void ASlotThatNeverPresentedGoesAtTheNextCollect() { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index 25f9e2d1..4e524b7a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -38,12 +38,19 @@ public override void DeleteMeshHandle(int bufferId) /// /// On this path VaoId is the device's mesh handle and the per-attribute buffer fields - /// are zero: released the mesh through the device's deferred - /// deletion before disposing the VAO, so there is nothing left to free here. VAO.Dispose - /// can also run from a finalizer, which is why nothing is destroyed inline. + /// are zero. VAO.Dispose is the one place a mesh is released, as on GL: the client and + /// mods call MeshRef.Dispose directly at least as often as DeleteMesh (which only + /// disposes the VAO), so the device mesh is released here, through the device's + /// deferred deletion (destroyed once the timelines pass every frame that drew it). + /// VAO.Dispose runs once per VAO (its Disposed guard) and never from the finalizer. + /// After ShutdownGraphics the device is gone and took every mesh with it. /// public override void DeleteVertexArrayHandles(VAO vao) { + if (device != null && vao.VaoId != 0) + { + device.DeleteMesh(vao.VaoId); + } } /// The device addresses each texture directly; nothing to bind or restore. diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index b2e7ad05..10d175e7 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -128,9 +128,9 @@ public override void DeleteMesh(MeshRef modelref) { if (modelref != null) { - // Deferred until the GPU is done with the frame that used it; - // GL left that to the driver. - device.DeleteMesh(((VAO)modelref).VaoId); + // The GL body's shape: VAO.Dispose reaches DeleteVertexArrayHandles, which + // releases the device mesh (deferred until the GPU is done with the frames + // that drew it). Releasing it here as well would free the id twice. ((VAO)modelref).Dispose(); } } diff --git a/Optimum.Render.Vulkan/Present/Swapchain.cs b/Optimum.Render.Vulkan/Present/Swapchain.cs index ec3c7b38..19043d85 100644 --- a/Optimum.Render.Vulkan/Present/Swapchain.cs +++ b/Optimum.Render.Vulkan/Present/Swapchain.cs @@ -344,7 +344,7 @@ private bool Build(out string? failureReason) // Passing oldSwapchain retires it even when creation fails. if (old != null) { - _retirement.Retire(old, old.LastPresentValue); + _retirement.Retire(old, SwapchainPolicy.RetireAfter(old.LastPresentValue)); _current = null; } diff --git a/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs b/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs index b50593cf..ce0dffc1 100644 --- a/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs +++ b/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs @@ -113,6 +113,18 @@ public static uint ChooseImageCount(uint capabilitiesMin, uint capabilitiesMax, return wanted; } + /// + /// The Frame value a replaced slot retires on. Completing the present submission + /// (Submit B, ) only proves the present semaphore + /// was signalled; vkQueuePresentKHR on that image is queued after it and may still be + /// pending in the WSI (VUID-vkDestroySwapchainKHR-swapchain-01282). The next Frame + /// value is reserved only by the following frame, whose submission is queued after that + /// vkQueuePresentKHR, so its completion is the first timeline proof the present was + /// processed. Without present fences (VK_EXT_swapchain_maintenance) the Khronos + /// swapchain_recreation sample likewise waits for a later operation. 0: never presented. + /// + public static ulong RetireAfter(ulong lastPresentValue) => lastPresentValue == 0 ? 0 : lastPresentValue + 1; + /// A minimised window reports a zero extent; presentation parks until it grows again. public static bool IsParked(Extent2D extent) => extent.Width == 0 || extent.Height == 0; diff --git a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs index 8852d509..ced4789a 100644 --- a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs +++ b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs @@ -140,6 +140,25 @@ public void TheSharedIndexBufferIsDeletedByThePlatform() Assert.DoesNotContain("GL.", body); } + /// + /// Phase 1 review: VAO.Dispose is the single release point on both backends, as vanilla's + /// DeleteMesh is only a Dispose. The Vulkan DeleteMesh override must not release the + /// device mesh itself (a double free of a reusable id), and the Vulkan + /// DeleteVertexArrayHandles must (MeshRef.Dispose is called directly everywhere). + /// + [Fact] + public void AMeshIsReleasedOnlyThroughVaoDispose() + { + string vulkan = StripComments(VulkanPlatformSource.Read()); + string deleteMesh = Body(vulkan, "public override void DeleteMesh(MeshRef modelref)"); + Assert.Contains("((VAO)modelref).Dispose();", deleteMesh); + Assert.DoesNotContain("device.DeleteMesh", deleteMesh); + Assert.Contains("device.DeleteMesh(vao.VaoId);", Body(vulkan, "public override void DeleteVertexArrayHandles(VAO vao)")); + + string swapchain = Read("Optimum.Render.Vulkan/Present/Swapchain.cs"); + Assert.Contains("_retirement.Retire(old, SwapchainPolicy.RetireAfter(old.LastPresentValue));", swapchain); + } + [Fact] public void TheOitLayersKeepOnlyTheirFailurePathUnitReset() { @@ -155,7 +174,7 @@ public void TheOitLayersKeepOnlyTheirFailurePathUnitReset() yield return new object?[] { "SetDepthRange", "public override void SetDepthRange(float near, float far)", "GL.DepthRange(near, far);", null }; yield return new object?[] { "ClearDefaultDepth", "public override void ClearDefaultDepth(float depth)", "GL.ClearBuffer((ClearBuffer)6145, 0, ref depth);", "device.ClearDepth(Math.Clamp(depth, 0f, 1f));" }; yield return new object?[] { "DeleteMeshHandle", "public override void DeleteMeshHandle(int bufferId)", "GL.DeleteBuffer(bufferId);", "device.DeleteMesh(bufferId);" }; - yield return new object?[] { "DeleteVertexArrayHandles", "public override void DeleteVertexArrayHandles(VAO vao)", "GL.DeleteVertexArray(vao.VaoId);", null }; + yield return new object?[] { "DeleteVertexArrayHandles", "public override void DeleteVertexArrayHandles(VAO vao)", "GL.DeleteVertexArray(vao.VaoId);", "device.DeleteMesh(vao.VaoId);" }; yield return new object?[] { "SetTextureLodBias", "public override void SetTextureLodBias(int[] textureIds, float bias)", "GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias);", "device.SetTextureParameter(textureIds[k], OptimumGlConstants.TextureLodBias, bias);" }; yield return new object?[] { "SetSamplerLodBias", "public override void SetSamplerLodBias(int samplerId, float bias)", "GL.SamplerParameter(samplerId, (SamplerParameterName)34049, bias);", "device.SetSamplerParameter(samplerId, OptimumGlConstants.TextureLodBias, bias);" }; yield return new object?[] { "SetTextureDepthCompare", "public override void SetTextureDepthCompare(int textureId, int mode)", "GL.TexParameter((TextureTarget)3553, (TextureParameterName)34892, mode);", "device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, mode);" }; diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 0c75690f..d6645829 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -498,7 +498,9 @@ public void ThePresentPathSplitsTheSubmissionAndRecreatesWithoutWaiting() string swapchain = Read("Optimum.Render.Vulkan/Present/Swapchain.cs"); Assert.Contains("OldSwapchain = old?.Handle ?? default,", swapchain); - Assert.Contains("_retirement.Retire(old, old.LastPresentValue);", swapchain); + // Keyed on the frame after the last present submission (Phase 1 review): only a + // submission queued after vkQueuePresentKHR proves the present was processed. + Assert.Contains("_retirement.Retire(old, SwapchainPolicy.RetireAfter(old.LastPresentValue));", swapchain); Assert.Contains("public Semaphore PresentSemaphoreFor(uint imageIndex) => _presentSemaphores[imageIndex];", swapchain); // vkDeviceWaitIdle only at teardown (Dispose), never in a rebuild. Assert.Equal(1, swapchain.Split("WaitDeviceIdle").Length - 1); From 7263aaede004d1f1baa551e5749139bcd073943b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 21:52:20 +0200 Subject: [PATCH 105/226] wip(phase1): exit recorded; OpenGL pacing is bimodal between launches; perf-capture restores Renderer Phase 1 exit on the RTX 4070 (f373c4a): both renderers start, the forced install failure falls back to OpenGL and renders, sync,best validation 0 errors, Vulkan blocking uploads 0 in all samples, Vulkan pacing 8.21/18.07/3.74 ms (Phase 0 9.90/20.08/4.96). A/B/A OpenGL runs (current 6.08/0.28, Phase 0 build 8.79/4.16, current 9.11/4.43) show the pacing state changes between launches regardless of the build, so M1.1 now interleaves runs. perf-capture left Renderer on its last value; it now restores it (pinning tests 14/14). --- docs/vulkan-acceptance.md | 38 +++++++++++++++++++++++++++++++++++++ scripts/dev/perf-capture.sh | 9 +++++++++ 2 files changed, 47 insertions(+) diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index c4d233f2..93411206 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -145,6 +145,40 @@ history colour 0.968/0.969). Vulkan-vs-GL (V0.2) attachments clearly below their | 0-Primary color4 (motion) | 0.979 | 0.907 | | | 0-Primary color2 alpha, color1 alpha | 0.984 / 0.978 | 0.950 / 0.923 | | +### Phase 1 exit results (2026-09-11) + +Deployed `f373c4a` (Phase 1A and 1B merged and reviewed). NVIDIA GeForce RTX 4070 Laptop GPU, driver +615.71.09, same settings as Phase 0, section 0's scene commands not applied. Logs in +`docs/gpu-verification-2026-09-11/phase1/` (local). + +- Both renderers start; renderer and GPU lines confirmed from each client log. +- Forced install failure (`OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE=1`): "[Optimum] Vulkan unavailable, + reopening for OpenGL: forced by OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE", then OpenGL on the RTX 4070 + rendered the world and wrote its parity dump. +- Validation with `sync,best` from load through in-world frame 300: 0 error lines. +- Pacing, same session, medians of per-second windows: + +| | OpenGL | Vulkan (Phase 0 Vulkan) | +|---|---|---| +| mean | 8.97 ms | 8.21 ms (9.90) | +| p99 | 19.76 ms | 18.07 ms (20.08) | +| stddev | 4.62 ms | 3.74 ms (4.96) | +| blocking uploads per sample | - | 0 in all 90 samples (up to 193) | +| gate | - | pass: stddev vs baseline, blocking uploads, dropped mesh writes, uniform overflows; fail: p99 <= 1.5 x mean | + +- **The OpenGL pacing state is bimodal between launches, independent of the build.** A/B/A in one + session, OpenGL, 60 s each: current build 6.08 ms mean / 6.74 p99 / 0.28 stddev; Phase 0 build + (`7685fcc`) 8.79 / 18.00 / 4.16; current build again 9.11 / 18.48 / 4.43. The same build produced + both states, so the Phase 1 OpenGL numbers above are the slow state, not a regression. Pacing + comparisons therefore interleave runs (see M1.1). +- Parity, Vulkan vs OpenGL, tracks Phase 0 except: far shadow map 0.845 (0.940); `0-Primary color2` + reads SSIM 0 because some foliage writes NaN normals, on both backends (OpenGL 7992 texels, Vulkan + 1914, the OpenGL fallback run 177, same screen region; none in Phase 0), recorded for Phase 3's native + shaders; the SSAO colour1 alpha gap (OpenGL 1.0, Vulkan 0.0) is unchanged and belongs to Phase 2. +- Not done at this exit, carried to the Milestone 1 session: a 10-minute session, a resize, alt-tab and + minimise loop in a real window, the sun glare and the fork bridge (clouds, world map, boat water mask) + judged on screen. + ### Milestone 1 (Phase 2 exit): stable frame delivery with TAA All numbers first, then eyes. Each row names the plan's definition of done verbatim. @@ -152,6 +186,9 @@ All numbers first, then eyes. Each row names the plan's definition of done verba #### M1.1 Pacing gate - Commands: `scripts/dev/perf-capture.sh` once per backend on the same scene and settings (V0.3, V0.4), then `scripts/dev/pacing-gate.sh --renderer vulkan --fps --stats --baseline `. +- Protocol: OpenGL pacing on this machine is bimodal between launches (Phase 1 exit, A/B/A). Run at + least OpenGL, Vulkan, OpenGL, Vulkan in one session and judge each Vulkan run against its neighbouring + OpenGL run; a pair whose two OpenGL runs disagree by more than 1 ms stddev is re-run. - Pass: exit 0 - blocking uploads 0 in every sample, median window stddev <= baseline x 1.25, median window p99 <= 1.5 x median mean, dropped mesh writes 0, uniform overflows 0. - Record: renderer and GPU lines, the gate output, both log paths. @@ -268,6 +305,7 @@ One entry per phase exit or milestone, appended, never edited after the fact. | date | phase / milestone | commit | rows passed | rows failed or deferred (with reason) | evidence paths | decision | |---|---|---|---|---|---|---| | 2026-09-11 | Phase 0 exit | 906b40f deployed (Phase 0 merged at cdd7412) | V0.1 and V0.2 recorded, V0.3 and V0.4 recorded, V0.5 pass (build 0 errors, Optimum.Tests 1056, GPU 386 with sync,best, check-patches 0 conflicts) | section 0 fixed scene not applied; Vulkan fails the pacing gate on p99, stddev and blocking uploads (the Milestone 1 target, not a Phase 0 gate) | `docs/gpu-verification-2026-09-11/phase0/` | Phase 0 accepted; Phase 1A and 1B start. M1.6 changed to a noise-floor rule. User observed no Vulkan jitter on these runs (driver 615.71.09, sky-direction fix not deployed). | +| 2026-09-11 | Phase 1 exit (1A + 1B) | f373c4a deployed | both renderers start; forced-install-failure fallback renders on OpenGL; sync,best validation 0 errors; Vulkan blocking uploads 0 in all samples; Vulkan pacing better than Phase 0 on mean, p99 and stddev; build 0 errors, Optimum.Tests 1128, GPU 494 | Vulkan p99 fails 1.5 x mean; 10-minute session, window resize/alt-tab/minimise loop, sun glare and fork bridge on screen carried to Milestone 1; OpenGL pacing found bimodal between launches (A/B/A), not a regression | `docs/gpu-verification-2026-09-11/phase1/` | Phase 1 accepted; Phase 2 (frame graph) starts; M1.1 now interleaves runs | ## 6. Vendor matrix diff --git a/scripts/dev/perf-capture.sh b/scripts/dev/perf-capture.sh index 2d55934f..a171b93a 100755 --- a/scripts/dev/perf-capture.sh +++ b/scripts/dev/perf-capture.sh @@ -128,6 +128,15 @@ if [[ -n "$VSYNC_ARG" ]]; then echo "clientsettings: vsyncMode=$WANT (was $VSYNC_SAVED)" fi +# Renderer is rewritten by run-client.sh; restore the user's value on every exit path. +SAVED_RENDERER="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("Renderer",""))' "$CONFIG")" || exit 1 +restore_renderer() { + [[ -n "$SAVED_RENDERER" ]] || return 0 + python3 -c 'import json,sys; p,v=sys.argv[1],sys.argv[2]; d=json.load(open(p)); d["Renderer"]=v; json.dump(d,open(p,"w"),indent=2)' "$CONFIG" "$SAVED_RENDERER" || true +} +restore_all() { restore_vsync; restore_renderer; } +trap restore_all EXIT + # 2. Launch. The client writes both logs itself; run-client.sh rewrites Renderer. export OPTIMUM_FPS_LOG="$FPS_LOG" if [[ "$RENDERER_ARG" == "vulkan" ]]; then From 9da9f7583b8e1a41637eab2b41257beb3a8e6854 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 21:59:49 +0200 Subject: [PATCH 106/226] wip(phase2-frame-plan): FramePlan load/store solving, exact signature match, TransientPlacement; ResourceUsage is a local C1 stub --- Optimum.Render.Vulkan.Tests/FramePlanTests.cs | 404 ++++++++++++++++++ Optimum.Render.Vulkan/Graph/FramePlan.cs | 264 ++++++++++++ Optimum.Render.Vulkan/Graph/PassSignature.cs | 60 +++ Optimum.Render.Vulkan/Graph/ResourceUsage.cs | 22 + .../Graph/TransientPlacement.cs | 94 ++++ 5 files changed, 844 insertions(+) create mode 100644 Optimum.Render.Vulkan.Tests/FramePlanTests.cs create mode 100644 Optimum.Render.Vulkan/Graph/FramePlan.cs create mode 100644 Optimum.Render.Vulkan/Graph/PassSignature.cs create mode 100644 Optimum.Render.Vulkan/Graph/ResourceUsage.cs create mode 100644 Optimum.Render.Vulkan/Graph/TransientPlacement.cs diff --git a/Optimum.Render.Vulkan.Tests/FramePlanTests.cs b/Optimum.Render.Vulkan.Tests/FramePlanTests.cs new file mode 100644 index 00000000..e31f4439 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FramePlanTests.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Pure tests for the frame plan (Phase 2, contract C2): signature matching, load/store +/// solving and transient alias placement. No device. +/// +public class FramePlanTests +{ + // Resource ids for the TAA-shaped frame. + private const int Primary = 1; + private const int Depth = 2; + private const int Motion = 3; + private const int HistoryOut = 4; + private const int Resolved = 5; + private const int Sharpened = 6; + private const int Bloom1 = 7; + private const int Bloom2 = 8; + private const int Final = 9; + private const int HistoryIn = 10; + private const int Swapchain = 11; + private const int BloomDepth = 12; + + // Pass indices. + private const int Opaque = 0; + private const int SkyMotion = 1; + private const int TaaResolve = 2; + private const int TaaSharpen = 3; + private const int BloomDown = 4; + private const int BloomUp = 5; + private const int FinalComposition = 6; + private const int Blit = 7; + + private static AttachmentUse Use(int id, ResourceUsage usage, bool transient = false) => new(id, usage, transient); + + private static PassSignature Pass(int name, AttachmentUse[] attachments, int[] reads, int width = 1920, int height = 1080, int formats = 1) => + new() { NameId = name, Attachments = attachments, Reads = reads, Width = width, Height = height, FormatsId = formats }; + + /// Opaque, sky motion, TAA resolve and sharpen, a two-step bloom chain on + /// transients, final composition and the blit. + private static List TaaFrame() => new() + { + Pass(100, new[] { Use(Primary, ResourceUsage.ColorWrite), Use(Motion, ResourceUsage.ColorWrite), Use(Depth, ResourceUsage.DepthWrite) }, + Array.Empty(), formats: 7), + Pass(101, new[] { Use(Motion, ResourceUsage.ColorBlend), Use(Depth, ResourceUsage.DepthReadOnly) }, + Array.Empty(), formats: 8), + Pass(102, new[] { Use(Resolved, ResourceUsage.ColorWrite, true), Use(HistoryOut, ResourceUsage.ColorWrite) }, + new[] { Primary, Motion, Depth, HistoryIn }, formats: 2), + Pass(103, new[] { Use(Sharpened, ResourceUsage.ColorWrite, true) }, new[] { Resolved }, formats: 2), + Pass(104, new[] { Use(Bloom1, ResourceUsage.ColorWrite, true), Use(BloomDepth, ResourceUsage.DepthWrite, true) }, + new[] { Sharpened }, formats: 2), + Pass(105, new[] { Use(Bloom2, ResourceUsage.ColorWrite, true) }, new[] { Bloom1 }, formats: 2), + Pass(106, new[] { Use(Final, ResourceUsage.ColorWrite) }, new[] { Sharpened, Bloom2 }, formats: 3), + Pass(107, new[] { Use(Swapchain, ResourceUsage.ColorWrite) }, new[] { Final }, formats: 4), + }; + + [Fact] + public void IdenticalFrameMatches() + { + FramePlan plan = FramePlan.Build(TaaFrame()); + Assert.True(plan.Matches(TaaFrame())); + Assert.False(plan.IsConservative); + Assert.Equal(8, plan.PassCount); + for (int p = 0; p < plan.PassCount; p++) + Assert.True(plan.MatchesPass(p, TaaFrame()[p])); + Assert.False(plan.MatchesPass(8, TaaFrame()[0])); + } + + [Fact] + public void NullArraysMatchEmptyArrays() + { + var withEmpty = new List { Pass(1, Array.Empty(), Array.Empty()) }; + var withNull = new List { new() { NameId = 1, Attachments = null!, Reads = null!, Width = 1920, Height = 1080, FormatsId = 1 } }; + Assert.True(FramePlan.Build(withEmpty).Matches(withNull)); + Assert.True(FramePlan.Build(withNull).Matches(withEmpty)); + } + + public static IEnumerable Mismatches() + { + yield return new object[] { "pass removed", (Action>)(f => f.RemoveAt(BloomUp)) }; + yield return new object[] { "pass added", (Action>)(f => f.Add(Pass(999, Array.Empty(), Array.Empty()))) }; + yield return new object[] { "passes reordered", (Action>)(f => { (f[BloomDown], f[BloomUp]) = (f[BloomUp], f[BloomDown]); }) }; + yield return new object[] { "name", (Action>)(f => f[TaaSharpen].NameId = 555) }; + yield return new object[] { "attachment added", (Action>)(f => f[TaaSharpen].Attachments = new[] { Use(Sharpened, ResourceUsage.ColorWrite, true), Use(Motion, ResourceUsage.ColorWrite) }) }; + yield return new object[] { "attachment removed", (Action>)(f => f[Opaque].Attachments = new[] { Use(Primary, ResourceUsage.ColorWrite), Use(Depth, ResourceUsage.DepthWrite) }) }; + yield return new object[] { "attachment resource", (Action>)(f => f[Blit].Attachments[0] = Use(Final + 100, ResourceUsage.ColorWrite)) }; + yield return new object[] { "attachment order", (Action>)(f => f[TaaResolve].Attachments = new[] { Use(HistoryOut, ResourceUsage.ColorWrite), Use(Resolved, ResourceUsage.ColorWrite, true) }) }; + yield return new object[] { "usage", (Action>)(f => f[SkyMotion].Attachments[1] = Use(Depth, ResourceUsage.DepthReadOnlySampled)) }; + yield return new object[] { "transient flag", (Action>)(f => f[BloomUp].Attachments[0] = Use(Bloom2, ResourceUsage.ColorWrite, false)) }; + yield return new object[] { "read added", (Action>)(f => f[FinalComposition].Reads = new[] { Sharpened, Bloom2, Depth }) }; + yield return new object[] { "read removed", (Action>)(f => f[FinalComposition].Reads = new[] { Sharpened }) }; + yield return new object[] { "read changed", (Action>)(f => f[TaaSharpen].Reads = new[] { Primary }) }; + yield return new object[] { "read order", (Action>)(f => f[FinalComposition].Reads = new[] { Bloom2, Sharpened }) }; + yield return new object[] { "width", (Action>)(f => f[BloomDown].Width = 960) }; + yield return new object[] { "height", (Action>)(f => f[BloomDown].Height = 540) }; + yield return new object[] { "formats", (Action>)(f => f[Opaque].FormatsId = 70) }; + } + + [Theory] + [MemberData(nameof(Mismatches))] + public void EveryFieldChangeIsAMismatch(string field, object mutation) + { + // xunit needs public parameter types; the signature types are internal. + var mutate = (Action>)mutation; + FramePlan plan = FramePlan.Build(TaaFrame()); + List changed = TaaFrame(); + mutate(changed); + Assert.False(plan.Matches(changed), $"A change in '{field}' still matched."); + } + + [Fact] + public void PlanIsASnapshotOfTheSignatures() + { + List frame = TaaFrame(); + FramePlan plan = FramePlan.Build(frame); + frame[TaaResolve].Reads[0] = 77; + frame[Opaque].Attachments[0] = Use(77, ResourceUsage.ColorWrite); + Assert.True(plan.Matches(TaaFrame())); + Assert.False(plan.Matches(frame)); + } + + [Fact] + public void TaaFrameLoadStoreSolve() + { + FramePlan plan = FramePlan.Build(TaaFrame()); + const AttachmentLoadOp L = AttachmentLoadOp.Load, LX = AttachmentLoadOp.DontCare; + const AttachmentStoreOp S = AttachmentStoreOp.Store, SX = AttachmentStoreOp.DontCare; + + // Persistent attachments always load and store, including the history the next frame reads. + AssertOps(plan, Opaque, 0, L, S); + AssertOps(plan, Opaque, 1, L, S); + AssertOps(plan, Opaque, 2, L, S); + AssertOps(plan, SkyMotion, 0, L, S); + AssertOps(plan, SkyMotion, 1, L, S); + AssertOps(plan, TaaResolve, 1, L, S); + AssertOps(plan, FinalComposition, 0, L, S); + AssertOps(plan, Blit, 0, L, S); + + // Transients: first write does not load; stored because a later pass reads them. + AssertOps(plan, TaaResolve, 0, LX, S); + AssertOps(plan, TaaSharpen, 0, LX, S); + AssertOps(plan, BloomDown, 0, LX, S); + AssertOps(plan, BloomUp, 0, LX, S); + + // A transient nobody reads after its only pass is neither loaded nor stored. + AssertOps(plan, BloomDown, 1, LX, SX); + + Assert.Equal(-1, plan.AliasSlot(Primary)); + Assert.Equal(-1, plan.AliasSlot(HistoryOut)); + Assert.Equal(-1, plan.AliasSlot(HistoryIn)); + Assert.Equal(-1, plan.AliasSlot(12345)); + } + + [Fact] + public void TransientLastAttachedIsNotStoredButLoadsInLaterPasses() + { + const int scratch = 50; + var frame = new List + { + Pass(1, new[] { Use(scratch, ResourceUsage.ColorWrite, true) }, Array.Empty()), + Pass(2, new[] { Use(scratch, ResourceUsage.ColorBlend, true) }, Array.Empty()), + Pass(3, new[] { Use(scratch, ResourceUsage.ColorWrite, true) }, Array.Empty()), + }; + FramePlan plan = FramePlan.Build(frame); + AssertOps(plan, 0, 0, AttachmentLoadOp.DontCare, AttachmentStoreOp.Store); + AssertOps(plan, 1, 0, AttachmentLoadOp.Load, AttachmentStoreOp.Store); + AssertOps(plan, 2, 0, AttachmentLoadOp.Load, AttachmentStoreOp.DontCare); + Assert.Equal(0, plan.AliasSlot(scratch)); + } + + public static IEnumerable NotReallyTransient() + { + const int r = 60; + // Read before it is written this frame: it depends on last frame's contents. + yield return new object[] { "read first", new List + { + Pass(1, new[] { Use(99, ResourceUsage.ColorWrite) }, new[] { r }), + Pass(2, new[] { Use(r, ResourceUsage.ColorWrite, true) }, Array.Empty()), + }, 1 }; + // Blended into on first use: the destination is read. + yield return new object[] { "blend first", new List + { + Pass(1, new[] { Use(r, ResourceUsage.ColorBlend, true) }, Array.Empty()), + Pass(2, new[] { Use(99, ResourceUsage.ColorWrite) }, new[] { r }), + }, 0 }; + // Depth tested read-only on first use. + yield return new object[] { "depth read first", new List + { + Pass(1, new[] { Use(r, ResourceUsage.DepthReadOnly, true) }, Array.Empty()), + Pass(2, new[] { Use(r, ResourceUsage.DepthWrite, true) }, Array.Empty()), + }, 0 }; + // Sampled by the pass that first writes it (feedback). + yield return new object[] { "feedback first", new List + { + Pass(1, new[] { Use(r, ResourceUsage.ColorWrite, true) }, new[] { r }), + }, 0 }; + // One use is not marked transient. + yield return new object[] { "mixed flag", new List + { + Pass(1, new[] { Use(r, ResourceUsage.ColorWrite, true) }, Array.Empty()), + Pass(2, new[] { Use(r, ResourceUsage.ColorBlend, false) }, Array.Empty()), + }, 0 }; + } + + [Theory] + [MemberData(nameof(NotReallyTransient))] + public void TransientThatDependsOnOlderContentsStaysPersistent(string why, object frameObject, int firstAttachedPass) + { + var frame = (List)frameObject; + const int r = 60; + FramePlan plan = FramePlan.Build(frame); + Assert.True(plan.AliasSlot(r) == -1, why); + for (int p = 0; p < frame.Count; p++) + { + for (int a = 0; a < frame[p].Attachments.Length; a++) + { + if (frame[p].Attachments[a].ResourceId != r) continue; + Assert.True(plan.LoadOp(p, a) == AttachmentLoadOp.Load, $"{why}: pass {p} (first attached {firstAttachedPass}) does not load."); + Assert.True(plan.StoreOp(p, a) == AttachmentStoreOp.Store, $"{why}: pass {p} does not store."); + } + } + } + + [Fact] + public void TaaFrameAliasesDisjointTransientsAndNeverOverlapping() + { + List frame = TaaFrame(); + FramePlan plan = FramePlan.Build(frame); + + // Resolved [2,3] and Bloom1 [4,5] share bucket (1920x1080, formats 2, index 0) and are disjoint. + Assert.Equal(plan.AliasSlot(Resolved), plan.AliasSlot(Bloom1)); + // Bloom depth has another attachment index, so another bucket and its own slot. + Assert.NotEqual(plan.AliasSlot(Resolved), plan.AliasSlot(BloomDepth)); + Assert.Equal(4, plan.AliasSlotCount); + + AssertNoOverlap(frame, plan, new[] { Resolved, Sharpened, Bloom1, Bloom2, BloomDepth }); + } + + [Fact] + public void DifferentExtentOrFormatsNeverShareASlot() + { + var frame = new List + { + Pass(1, new[] { Use(20, ResourceUsage.ColorWrite, true) }, Array.Empty(), width: 960, height: 540), + Pass(2, new[] { Use(21, ResourceUsage.ColorWrite, true) }, new[] { 20 }, width: 480, height: 270), + Pass(3, new[] { Use(22, ResourceUsage.ColorWrite, true) }, new[] { 21 }, width: 480, height: 270, formats: 9), + Pass(4, new[] { Use(23, ResourceUsage.ColorWrite, true) }, new[] { 22 }, width: 960, height: 540), + }; + FramePlan plan = FramePlan.Build(frame); + // 20 [0,1] and 23 [3,3] share the 960x540 bucket; 21 and 22 differ in extent/formats. + Assert.Equal(plan.AliasSlot(20), plan.AliasSlot(23)); + Assert.Equal(3, plan.AliasSlotCount); + Assert.NotEqual(plan.AliasSlot(21), plan.AliasSlot(22)); + Assert.NotEqual(plan.AliasSlot(20), plan.AliasSlot(21)); + } + + [Fact] + public void RandomIntervalsNeverOverlapAndUseMinimalSlots() + { + var random = new Random(1234); + var buckets = new[] + { + new SizeBucket(1920, 1080, 1, 0), + new SizeBucket(960, 540, 1, 0), + new SizeBucket(1920, 1080, 2, 0), + }; + + for (int round = 0; round < 500; round++) + { + int count = random.Next(0, 40); + var intervals = new List(count); + for (int i = 0; i < count; i++) + { + int first = random.Next(0, 30); + intervals.Add(new TransientInterval(i, buckets[random.Next(buckets.Length)], first, first + random.Next(0, 8))); + } + + int[] slots = TransientPlacement.Place(intervals); + Assert.Equal(count, slots.Length); + + var slotBucket = new Dictionary(); + for (int i = 0; i < count; i++) + { + if (slotBucket.TryGetValue(slots[i], out SizeBucket existing)) + Assert.Equal(existing, intervals[i].Bucket); + else + slotBucket[slots[i]] = intervals[i].Bucket; + + for (int j = i + 1; j < count; j++) + { + if (slots[i] != slots[j]) continue; + bool overlap = intervals[i].FirstPass <= intervals[j].LastPass && intervals[j].FirstPass <= intervals[i].LastPass; + Assert.False(overlap, $"round {round}: {intervals[i]} and {intervals[j]} share slot {slots[i]}"); + } + } + + // Slots are dense and, per bucket, equal to the peak number of live intervals. + for (int s = 0; s < slotBucket.Count; s++) Assert.True(slotBucket.ContainsKey(s)); + foreach (SizeBucket bucket in buckets) + { + int peak = 0; + for (int pass = 0; pass < 40; pass++) + { + int live = 0; + foreach (TransientInterval t in intervals) + if (t.Bucket == bucket && t.FirstPass <= pass && pass <= t.LastPass) live++; + peak = Math.Max(peak, live); + } + int used = 0; + foreach (KeyValuePair entry in slotBucket) + if (entry.Value == bucket) used++; + Assert.Equal(peak, used); + } + } + } + + [Fact] + public void PlacementRejectsInvertedIntervals() + { + Assert.Throws(() => + TransientPlacement.Place(new[] { new TransientInterval(1, new SizeBucket(1, 1, 1, 0), 3, 2) })); + } + + [Fact] + public void ChangedFrameGetsConservativePlan() + { + FramePlan previous = FramePlan.Build(TaaFrame()); + + List changed = TaaFrame(); + changed.RemoveAt(BloomUp); // bloom toggled: the chain is one pass shorter + changed[BloomUp].Reads = new[] { Sharpened, Bloom1 }; + + FramePlan applied = FramePlan.Select(previous, changed); + Assert.NotSame(previous, applied); + Assert.True(applied.IsConservative); + Assert.True(applied.Matches(changed)); + Assert.Equal(0, applied.AliasSlotCount); + for (int p = 0; p < changed.Count; p++) + { + for (int a = 0; a < changed[p].Attachments.Length; a++) + { + Assert.Equal(AttachmentLoadOp.Load, applied.LoadOp(p, a)); + Assert.Equal(AttachmentStoreOp.Store, applied.StoreOp(p, a)); + Assert.Equal(-1, applied.AliasSlot(changed[p].Attachments[a].ResourceId)); + } + } + + // The same frame again reuses the plan built from it; no previous plan is conservative. + Assert.Same(previous, FramePlan.Select(previous, TaaFrame())); + Assert.True(FramePlan.Select(null, TaaFrame()).IsConservative); + + // The next frame's plan, built from the changed frame, is solved again. + FramePlan rebuilt = FramePlan.Build(changed); + Assert.False(rebuilt.IsConservative); + Assert.Equal(AttachmentStoreOp.DontCare, rebuilt.StoreOp(BloomDown, 1)); + } + + [Fact] + public void OutOfRangeIndicesThrow() + { + FramePlan plan = FramePlan.Build(TaaFrame()); + Assert.Throws(() => plan.LoadOp(8, 0)); + Assert.Throws(() => plan.LoadOp(-1, 0)); + Assert.Throws(() => plan.StoreOp(TaaSharpen, 1)); + } + + private static void AssertOps(FramePlan plan, int pass, int attachment, AttachmentLoadOp load, AttachmentStoreOp store) + { + Assert.True(load == plan.LoadOp(pass, attachment), $"pass {pass} attachment {attachment}: load {plan.LoadOp(pass, attachment)}, expected {load}"); + Assert.True(store == plan.StoreOp(pass, attachment), $"pass {pass} attachment {attachment}: store {plan.StoreOp(pass, attachment)}, expected {store}"); + } + + private static void AssertNoOverlap(List frame, FramePlan plan, int[] transients) + { + var first = new Dictionary(); + var last = new Dictionary(); + for (int p = 0; p < frame.Count; p++) + { + var ids = new List(); + foreach (AttachmentUse use in frame[p].Attachments) ids.Add(use.ResourceId); + ids.AddRange(frame[p].Reads); + foreach (int id in ids) + { + if (!first.ContainsKey(id)) first[id] = p; + last[id] = p; + } + } + + foreach (int a in transients) + { + Assert.True(plan.AliasSlot(a) >= 0, $"transient {a} has no slot"); + foreach (int b in transients) + { + if (a >= b || plan.AliasSlot(a) != plan.AliasSlot(b)) continue; + Assert.False(first[a] <= last[b] && first[b] <= last[a], $"{a} [{first[a]},{last[a]}] and {b} [{first[b]},{last[b]}] share slot {plan.AliasSlot(a)}"); + } + } + } +} diff --git a/Optimum.Render.Vulkan/Graph/FramePlan.cs b/Optimum.Render.Vulkan/Graph/FramePlan.cs new file mode 100644 index 00000000..ac67f6f7 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/FramePlan.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// Load/store ops and transient alias slots for one frame, solved from a recorded frame's +/// ordered pass signatures and applied to a later frame only when that frame's signatures +/// match exactly (). A mismatch costs one conservative frame +/// (: LOAD/STORE everywhere, no aliasing). +/// +/// Rules, per pass p and attachment a on resource r: +/// +/// r is transient for the frame when it is an attachment somewhere, every +/// attachment use of it is marked transient, and its first reference in the frame is a plain +/// write ( or ) +/// with no other use of r in that pass (no read, no blend, no read-only depth). A transient +/// that is read, blended into or depth-tested before it is written this frame depends on older +/// contents and is treated as persistent. +/// is DONT_CARE for a plain write in r's first pass when r is +/// transient, LOAD otherwise. CLEAR is never returned: the recorder decides clears and +/// overrides the plan's op. +/// is DONT_CARE in r's last pass when r is transient, STORE +/// otherwise. Any later reference (a read or an attachment, which always loads) keeps +/// STORE. +/// is the slot of a transient +/// resource, -1 for everything else. Whether slots are actually shared is the recorder's +/// decision (OPTIMUM_VULKAN_ALIAS, default off). +/// +/// +/// Store DONT_CARE is only sound for the whole matched frame: a streaming recorder that +/// applies the plan pass by pass must not apply a DONT_CARE store before it knows the rest of +/// the frame still matches ( checks one pass). +/// +internal sealed class FramePlan +{ + private readonly PassSignature[] _passes; + private readonly AttachmentLoadOp[][] _load; + private readonly AttachmentStoreOp[][] _store; + private readonly Dictionary _aliasSlots; + + private FramePlan(PassSignature[] passes, AttachmentLoadOp[][] load, AttachmentStoreOp[][] store, + Dictionary aliasSlots, bool conservative, int aliasSlotCount) + { + _passes = passes; + _load = load; + _store = store; + _aliasSlots = aliasSlots; + IsConservative = conservative; + AliasSlotCount = aliasSlotCount; + } + + /// True for a plan that loads and stores everything and aliases nothing. + public bool IsConservative { get; } + + public int PassCount => _passes.Length; + + /// Number of distinct alias slots the transients were placed into. + public int AliasSlotCount { get; } + + /// Solves load/store ops and alias slots for . The + /// signatures are copied; later mutation by the caller does not change the plan. + public static FramePlan Build(IReadOnlyList frame) + { + PassSignature[] passes = Snapshot(frame); + var resources = new Dictionary(); + var order = new List(); + + for (int p = 0; p < passes.Length; p++) + { + PassSignature pass = passes[p]; + AttachmentUse[] attachments = pass.Attachments; + + // First touch in this pass: remember whether the pass only plainly writes it. + for (int a = 0; a < attachments.Length; a++) + { + AttachmentUse use = attachments[a]; + ResourceInfo info = Touch(resources, order, use.ResourceId, p, pass, a); + info.Attached = true; + if (!use.Transient) info.AllTransient = false; + if (info.FirstPass == p && !IsPlainWrite(use.Usage)) info.FirstIsPlainWrite = false; + } + + int[] reads = pass.Reads; + for (int i = 0; i < reads.Length; i++) + { + ResourceInfo info = Touch(resources, order, reads[i], p, pass, -1); + if (info.FirstPass == p) info.FirstIsPlainWrite = false; + } + } + + var intervals = new List(); + for (int i = 0; i < order.Count; i++) + { + ResourceInfo info = resources[order[i]]; + if (info.IsTransient) + intervals.Add(new TransientInterval(info.ResourceId, info.Bucket, info.FirstPass, info.LastPass)); + } + + int[] slots = TransientPlacement.Place(intervals); + var aliasSlots = new Dictionary(intervals.Count); + int slotCount = 0; + for (int i = 0; i < intervals.Count; i++) + { + aliasSlots[intervals[i].ResourceId] = slots[i]; + if (slots[i] + 1 > slotCount) slotCount = slots[i] + 1; + } + + var load = new AttachmentLoadOp[passes.Length][]; + var store = new AttachmentStoreOp[passes.Length][]; + for (int p = 0; p < passes.Length; p++) + { + AttachmentUse[] attachments = passes[p].Attachments; + load[p] = new AttachmentLoadOp[attachments.Length]; + store[p] = new AttachmentStoreOp[attachments.Length]; + for (int a = 0; a < attachments.Length; a++) + { + AttachmentUse use = attachments[a]; + ResourceInfo info = resources[use.ResourceId]; + bool transient = info.IsTransient; + load[p][a] = transient && info.FirstPass == p && IsPlainWrite(use.Usage) + ? AttachmentLoadOp.DontCare + : AttachmentLoadOp.Load; + store[p][a] = transient && info.LastPass == p + ? AttachmentStoreOp.DontCare + : AttachmentStoreOp.Store; + } + } + + return new FramePlan(passes, load, store, aliasSlots, conservative: false, slotCount); + } + + /// The plan for a frame whose signature did not match: LOAD and STORE on every + /// attachment, no aliasing. + public static FramePlan Conservative(IReadOnlyList frame) + { + PassSignature[] passes = Snapshot(frame); + var load = new AttachmentLoadOp[passes.Length][]; + var store = new AttachmentStoreOp[passes.Length][]; + for (int p = 0; p < passes.Length; p++) + { + int count = passes[p].Attachments.Length; + load[p] = new AttachmentLoadOp[count]; + store[p] = new AttachmentStoreOp[count]; + for (int a = 0; a < count; a++) + { + load[p][a] = AttachmentLoadOp.Load; + store[p][a] = AttachmentStoreOp.Store; + } + } + + return new FramePlan(passes, load, store, new Dictionary(), conservative: true, 0); + } + + /// The plan to apply to : when + /// it was built from an identical frame, otherwise a conservative plan. + public static FramePlan Select(FramePlan? previous, IReadOnlyList frame) + { + if (previous != null && previous.Matches(frame)) return previous; + return Conservative(frame); + } + + /// Exact match on pass count and, per pass, name, attachments (resource, usage, + /// transient flag, order), reads (order significant), extent and formats. + public bool Matches(IReadOnlyList frame) + { + if (frame == null || frame.Count != _passes.Length) return false; + for (int p = 0; p < _passes.Length; p++) + { + if (!_passes[p].SameAs(frame[p])) return false; + } + return true; + } + + /// Whether pass of this plan is identical to + /// . False for an index past the plan's end. + public bool MatchesPass(int pass, PassSignature signature) + { + if (pass < 0 || pass >= _passes.Length) return false; + return _passes[pass].SameAs(signature); + } + + public AttachmentLoadOp LoadOp(int pass, int attachment) + { + CheckIndex(pass, attachment); + return _load[pass][attachment]; + } + + public AttachmentStoreOp StoreOp(int pass, int attachment) + { + CheckIndex(pass, attachment); + return _store[pass][attachment]; + } + + /// The alias slot of a transient resource, or -1 when the resource is persistent, + /// unknown, or the plan is conservative. + public int AliasSlot(int resourceId) => _aliasSlots.TryGetValue(resourceId, out int slot) ? slot : -1; + + private void CheckIndex(int pass, int attachment) + { + if ((uint)pass >= (uint)_passes.Length) + throw new ArgumentOutOfRangeException(nameof(pass), pass, $"Plan has {_passes.Length} passes."); + if ((uint)attachment >= (uint)_load[pass].Length) + throw new ArgumentOutOfRangeException(nameof(attachment), attachment, $"Pass {pass} has {_load[pass].Length} attachments."); + } + + private static bool IsPlainWrite(ResourceUsage usage) => + usage == ResourceUsage.ColorWrite || usage == ResourceUsage.DepthWrite; + + private static PassSignature[] Snapshot(IReadOnlyList frame) + { + if (frame == null) throw new ArgumentNullException(nameof(frame)); + var passes = new PassSignature[frame.Count]; + for (int p = 0; p < passes.Length; p++) + { + PassSignature source = frame[p] ?? throw new ArgumentException($"Pass {p} is null.", nameof(frame)); + passes[p] = source.Clone(); + } + return passes; + } + + private static ResourceInfo Touch(Dictionary resources, List order, int resourceId, + int pass, PassSignature signature, int attachmentIndex) + { + if (!resources.TryGetValue(resourceId, out ResourceInfo? info)) + { + info = new ResourceInfo(resourceId, pass); + resources.Add(resourceId, info); + order.Add(resourceId); + } + + if (info.FirstPass == pass && attachmentIndex >= 0 && !info.HasBucket) + { + info.Bucket = new SizeBucket(signature.Width, signature.Height, signature.FormatsId, attachmentIndex); + info.HasBucket = true; + } + + info.LastPass = pass; + return info; + } + + private sealed class ResourceInfo + { + public ResourceInfo(int resourceId, int firstPass) + { + ResourceId = resourceId; + FirstPass = firstPass; + LastPass = firstPass; + } + + public readonly int ResourceId; + public readonly int FirstPass; + public int LastPass; + public bool Attached; + public bool AllTransient = true; + public bool FirstIsPlainWrite = true; + public SizeBucket Bucket; + public bool HasBucket; + + // A resource first touched only by a read has no bucket, but then FirstIsPlainWrite is false too. + public bool IsTransient => Attached && AllTransient && FirstIsPlainWrite && HasBucket; + } +} diff --git a/Optimum.Render.Vulkan/Graph/PassSignature.cs b/Optimum.Render.Vulkan/Graph/PassSignature.cs new file mode 100644 index 00000000..bde5f4a2 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/PassSignature.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Optimum.Render.Vulkan.Graph; + +/// One attachment of a pass: which resource, how it is used, and whether its +/// contents are allowed to die with the frame. +/// The contents are never read in a later frame. A resource is +/// treated as transient only when every attachment use of it in the frame says so and its +/// first reference is a plain write (see ). +internal readonly record struct AttachmentUse(int ResourceId, ResourceUsage Usage, bool Transient); + +/// +/// What a pass looked like when it was recorded: the unit a is +/// built from and matched against. Resource ids are frame-graph ids, stable across frames +/// for the same logical image. +/// +internal sealed class PassSignature +{ + public int NameId; + public AttachmentUse[] Attachments = Array.Empty(); + /// Resources sampled or otherwise read (not as attachments), in declaration order. + public int[] Reads = Array.Empty(); + public int Width; + public int Height; + /// Interned id of the ordered attachment format list. + public int FormatsId; + + public PassSignature Clone() => new() + { + NameId = NameId, + Attachments = Attachments == null ? Array.Empty() : (AttachmentUse[])Attachments.Clone(), + Reads = Reads == null ? Array.Empty() : (int[])Reads.Clone(), + Width = Width, + Height = Height, + FormatsId = FormatsId, + }; + + /// Exact equality on every field the plan depends on. Null arrays equal empty ones; + /// read order is significant. + public bool SameAs(PassSignature other) + { + if (other == null) return false; + if (NameId != other.NameId || Width != other.Width || Height != other.Height || FormatsId != other.FormatsId) + return false; + return SameSequence(Attachments, other.Attachments) && SameSequence(Reads, other.Reads); + } + + private static bool SameSequence(T[]? a, T[]? b) where T : IEquatable + { + int la = a?.Length ?? 0; + int lb = b?.Length ?? 0; + if (la != lb) return false; + for (int i = 0; i < la; i++) + { + if (!a![i].Equals(b![i])) return false; + } + return true; + } +} diff --git a/Optimum.Render.Vulkan/Graph/ResourceUsage.cs b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs new file mode 100644 index 00000000..a48df65d --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs @@ -0,0 +1,22 @@ +namespace Optimum.Render.Vulkan.Graph; + +// LOCAL STUB (stage "frame-plan"): contract C1 is owned by stage "barriers", which also +// defines UsageState.For next to this enum. This file exists only so FramePlan compiles +// before the merge; the integration stage keeps the barriers stage's definition and +// drops this file. The member list is copied verbatim from the contract. + +/// How a pass uses one resource. Stage and access derive from this, never from the layout alone. +public enum ResourceUsage +{ + ColorWrite, + ColorBlend, + DepthWrite, + DepthReadOnly, + DepthReadOnlySampled, + SampleFragment, + SampleVertex, + StorageRead, + TransferSrc, + TransferDst, + PresentSrc, +} diff --git a/Optimum.Render.Vulkan/Graph/TransientPlacement.cs b/Optimum.Render.Vulkan/Graph/TransientPlacement.cs new file mode 100644 index 00000000..f358d3f4 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/TransientPlacement.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// Images that may share one allocation: same extent and the same format. The format is not +/// part of a pass signature per attachment, so it is identified by the pass's interned format +/// list plus the attachment index, taken from the resource's first pass. That key can refuse +/// aliasing two images that would have been compatible; it can never alias two that are not. +/// +internal readonly record struct SizeBucket(int Width, int Height, int FormatsId, int AttachmentIndex); + +/// A transient resource's lifetime within one frame, both ends inclusive. +internal readonly record struct TransientInterval(int ResourceId, SizeBucket Bucket, int FirstPass, int LastPass); + +/// +/// Assigns alias slots to transient resources. Greedy first-fit in order of first pass (then +/// resource id, so the result is deterministic): a resource takes the lowest-numbered slot of +/// its own bucket whose previous occupant's last pass is strictly before this resource's first +/// pass, otherwise it opens a new slot. Two intervals in one slot therefore never overlap, a +/// slot only ever holds one bucket, and because intervals are taken by start time the slot +/// count per bucket equals the largest number of that bucket's lifetimes alive at one pass. +/// +internal static class TransientPlacement +{ + /// Returns one slot per input interval, in input order. Slots are dense from 0. + public static int[] Place(IReadOnlyList intervals) + { + if (intervals == null) throw new ArgumentNullException(nameof(intervals)); + + int count = intervals.Count; + int[] order = new int[count]; + for (int i = 0; i < count; i++) + { + TransientInterval interval = intervals[i]; + if (interval.FirstPass < 0 || interval.LastPass < interval.FirstPass) + throw new ArgumentException($"Interval for resource {interval.ResourceId} is [{interval.FirstPass},{interval.LastPass}].", nameof(intervals)); + order[i] = i; + } + + Array.Sort(order, new StartOrder(intervals)); + + int[] slots = new int[count]; + var slotBucket = new List(); + var slotLastPass = new List(); + for (int k = 0; k < count; k++) + { + int index = order[k]; + TransientInterval interval = intervals[index]; + int chosen = -1; + for (int s = 0; s < slotBucket.Count; s++) + { + if (slotBucket[s] == interval.Bucket && slotLastPass[s] < interval.FirstPass) + { + chosen = s; + break; + } + } + + if (chosen < 0) + { + chosen = slotBucket.Count; + slotBucket.Add(interval.Bucket); + slotLastPass.Add(interval.LastPass); + } + else + { + slotLastPass[chosen] = interval.LastPass; + } + + slots[index] = chosen; + } + + return slots; + } + + private sealed class StartOrder : IComparer + { + private readonly IReadOnlyList _intervals; + + public StartOrder(IReadOnlyList intervals) => _intervals = intervals; + + public int Compare(int x, int y) + { + TransientInterval a = _intervals[x]; + TransientInterval b = _intervals[y]; + int c = a.FirstPass.CompareTo(b.FirstPass); + if (c != 0) return c; + c = a.ResourceId.CompareTo(b.ResourceId); + return c != 0 ? c : x.CompareTo(y); + } + } +} From 2e649d0396c9855df0062b8afb71c667d0de1465 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 21:59:54 +0200 Subject: [PATCH 107/226] wip(phase2-ssao-alpha): SSAO noise alpha 1 like GL's RGB upload into RGBA32F --- .../SsaoNoiseAlphaTests.cs | 138 ++++++++++++++++++ .../VulkanClientPlatform.FrameBuffers.cs | 41 ++++-- 2 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/SsaoNoiseAlphaTests.cs diff --git a/Optimum.Render.Vulkan.Tests/SsaoNoiseAlphaTests.cs b/Optimum.Render.Vulkan.Tests/SsaoNoiseAlphaTests.cs new file mode 100644 index 00000000..a65bae73 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SsaoNoiseAlphaTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan; +using Optimum.Render.Vulkan.Platform; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Parity gap from the Phase 0 and Phase 1 dumps: framebuffer slot 13 (SSAO) colour +/// attachment 1, the 16x16 rotation-noise texture, read alpha 1.0 in every texel on +/// OpenGL and 0.0 on Vulkan. +/// +/// Not a masked write (no shader renders into that texture): the GL path allocates +/// GL_RGBA32F and uploads GL_RGB float data, and GL's pixel transfer fills the absent +/// alpha with 1. The device path uploaded four channels with a padding 0, and the device +/// copies channels verbatim. The fix is in the data (); +/// these tests read the texture back through the parity dump's own readback, inside a +/// frame, with sync + best-practices validation on. +/// +public class SsaoNoiseAlphaTests +{ + private const int NoiseSize = 16; + private const int GlRgba32f = 0x8814; // 34836, what both paths allocate + + private readonly ITestOutputHelper _output; + + public SsaoNoiseAlphaTests(ITestOutputHelper output) => _output = output; + + /// + /// The GL path's generation verbatim (ClientPlatformWindows.SetupDefaultFrameBuffers): + /// three floats per texel, uploaded as GL_RGB. + /// + private static float[] GlRgbNoise(Random random) + { + float[] rgb = new float[NoiseSize * NoiseSize * 3]; + Vec3f direction = new Vec3f(); + for (int texel = 0; texel < NoiseSize * NoiseSize; texel++) + { + direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize(); + rgb[texel * 3] = direction.X; + rgb[texel * 3 + 1] = direction.Y; + rgb[texel * 3 + 2] = direction.Z; + } + return rgb; + } + + /// + /// Same colour values and the same random draws as the GL path, alpha 1 where GL fills + /// it, and the stream position afterwards unchanged, so the kernel drawn next matches. + /// + [Fact] + public void NoiseMatchesTheGlUploadIncludingTheFilledAlpha() + { + var glRandom = new Random(5); + var deviceRandom = new Random(5); + float[] rgb = GlRgbNoise(glRandom); + float[] rgba = VulkanClientPlatform.BuildOptimumSsaoNoise(deviceRandom, NoiseSize); + + Assert.Equal(NoiseSize * NoiseSize * 4, rgba.Length); + for (int texel = 0; texel < NoiseSize * NoiseSize; texel++) + { + Assert.Equal(rgb[texel * 3], rgba[texel * 4]); + Assert.Equal(rgb[texel * 3 + 1], rgba[texel * 4 + 1]); + Assert.Equal(rgb[texel * 3 + 2], rgba[texel * 4 + 2]); + Assert.Equal(1f, rgba[texel * 4 + 3]); + } + Assert.Equal(glRandom.NextDouble(), deviceRandom.NextDouble()); + } + + /// + /// GPU readback through , the path that + /// writes 13-SSAO-color1-rgba32f.alpha.pfm. The platform's noise reads alpha 1.0 in all + /// 256 texels with the GL colour values; the pre-fix upload (identical colour, padding + /// alpha 0) on the same device reads 0.0, which is the Vulkan dump before the fix. + /// + [SkippableFact] + public void SsaoNoiseTextureReadsAlphaOneLikeOpenGl() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + float[] noise = VulkanClientPlatform.BuildOptimumSsaoNoise(new Random(5), NoiseSize); + float[] preFix = (float[])noise.Clone(); + for (int texel = 0; texel < NoiseSize * NoiseSize; texel++) preFix[texel * 4 + 3] = 0f; + float[] glRgb = GlRgbNoise(new Random(5)); + + using (device) + { + VulkanDevice seam = device!; + int fixedTexture = Upload(seam, noise); + int preFixTexture = Upload(seam, preFix); + + seam.BeginFrame(); + OptimumTextureReadback? fixedReadback = seam.ReadTextureForParity(fixedTexture); + OptimumTextureReadback? preFixReadback = seam.ReadTextureForParity(preFixTexture); + seam.Present(); + GpuTest.AssertClean(seam); + + Assert.NotNull(fixedReadback); + Assert.NotNull(preFixReadback); + Assert.Equal(GlRgba32f, fixedReadback!.GlInternalFormat); + Assert.Equal(NoiseSize, fixedReadback.Width); + Assert.Equal(NoiseSize, fixedReadback.Height); + Assert.NotNull(fixedReadback.Floats); + Assert.NotNull(preFixReadback!.Floats); + + int alphaOne = 0; + int preFixAlphaZero = 0; + for (int texel = 0; texel < NoiseSize * NoiseSize; texel++) + { + Assert.Equal(glRgb[texel * 3], fixedReadback.Floats![texel * 4]); + Assert.Equal(glRgb[texel * 3 + 1], fixedReadback.Floats[texel * 4 + 1]); + Assert.Equal(glRgb[texel * 3 + 2], fixedReadback.Floats[texel * 4 + 2]); + if (fixedReadback.Floats[texel * 4 + 3] == 1f) alphaOne++; + if (preFixReadback.Floats![texel * 4 + 3] == 0f) preFixAlphaZero++; + } + _output.WriteLine("alpha 1.0 texels: " + alphaOne + "/256; pre-fix upload alpha 0.0 texels: " + preFixAlphaZero + "/256"); + Assert.Equal(NoiseSize * NoiseSize, preFixAlphaZero); + Assert.Equal(NoiseSize * NoiseSize, alphaOne); + } + } + + private static int Upload(VulkanDevice seam, float[] texels) + { + GCHandle handle = GCHandle.Alloc(texels, GCHandleType.Pinned); + try + { + return seam.CreateTexture2DRaw(NoiseSize, NoiseSize, GlRgba32f, handle.AddrOfPinnedObject(), 16); + } + finally + { + handle.Free(); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index 40eb6384..09d7efb3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -171,19 +171,11 @@ public override List SetupDefaultFrameBuffers() // and draw order as the GL path, so the pattern matches exactly. Random random = new Random(5); int noiseSize = 16; - float[] noise = new float[noiseSize * noiseSize * 4]; - Vec3f direction = new Vec3f(); - for (int texel = 0; texel < noiseSize * noiseSize; texel++) - { - direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize(); - noise[texel * 4] = direction.X; - noise[texel * 4 + 1] = direction.Y; - noise[texel * 4 + 2] = direction.Z; - noise[texel * 4 + 3] = 0f; - } + float[] noise = BuildOptimumSsaoNoise(random, noiseSize); GCHandle noiseHandle = GCHandle.Alloc(noise, GCHandleType.Pinned); - // RGBA32F rather than the GL path's RGB32F: the fourth channel is - // padding, and a three-channel float format is not guaranteed. + // GL_RGBA32F, the same internal format the GL path allocates; GL + // uploads GL_RGB data into it and fills alpha with 1 (see + // BuildOptimumSsaoNoise), the device copies all four channels as given. ssao.ColorTextureIds[1] = device.CreateTexture2DRaw( noiseSize, noiseSize, 34836, noiseHandle.AddrOfPinnedObject(), 16); noiseHandle.Free(); @@ -663,6 +655,31 @@ public override void ApplyTransparentMergeBlendState() device.SetBlendFuncSeparate(0, 770, 771, 770, 771); } + /// + /// The SSAO rotation noise as RGBA float texels, drawing from + /// in the GL path's order (two doubles per texel) so the sample kernel drawn after it + /// matches too. + /// + /// Alpha is 1, not 0. The GL path allocates GL_RGBA32F and uploads GL_RGB pixel data; + /// GL's pixel transfer fills the missing alpha with 1, so the texture holds 1.0 in every + /// texel (the parity dump reads 1.0 on OpenGL). The device takes the four channels + /// verbatim, and a padding 0 here left SSAO colour1 alpha at 0.0 on Vulkan. + /// + internal static float[] BuildOptimumSsaoNoise(Random random, int noiseSize) + { + float[] noise = new float[noiseSize * noiseSize * 4]; + Vec3f direction = new Vec3f(); + for (int texel = 0; texel < noiseSize * noiseSize; texel++) + { + direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize(); + noise[texel * 4] = direction.X; + noise[texel * 4 + 1] = direction.Y; + noise[texel * 4 + 2] = direction.Z; + noise[texel * 4 + 3] = 1f; + } + return noise; + } + public override void ClearSsaoTarget() { device.ClearColor(0, 1f, 1f, 1f, 1f); From 0d67057f9dc269e39dd2b3b7332659616e43ec20 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:01:01 +0200 Subject: [PATCH 108/226] wip(phase2-stage-hooks): BeginRenderStage/EndRenderStage bracket (contract C3) ClientPlatformAbstract gains neutral BeginRenderStage/EndRenderStage virtuals; ClientMain.TriggerRenderStage brackets eventManager?.TriggerRenderStage with them (transplant target listed, both virtuals injected). VulkanClientPlatform.Stages.cs overrides both, tracks CurrentRenderStage/InRenderStage and forwards to an optional Graph.IRenderStageListener; both added to ExpectedVirtuals. Verified: Release build 0 errors; check-patches 0 conflict 0 pending, 43 runtime patches; Optimum.Tests 1133 passed 0 failed; real Cecil patch: both virtuals INJECTED, ClientMain::TriggerRenderStage PATCHED, Virtual dispatch verifier ok; check-vanilla-compat ok. GPU suite result in the next commit/report. --- Optimum.Patcher/Program.cs | 6 + .../RenderStageHookTests.cs | 120 +++++++++++++++ .../Graph/IRenderStageListener.cs | 18 +++ .../Platform/VulkanClientPlatform.Stages.cs | 35 +++++ .../Platform/VulkanClientPlatform.cs | 3 + .../render-stage-hooks-coverage-tests.cs | 139 ++++++++++++++++++ .../ClientMain.cs.patch | 32 ++-- .../ClientPlatformAbstract.cs.patch | 15 +- 8 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/RenderStageHookTests.cs create mode 100644 Optimum.Render.Vulkan/Graph/IRenderStageListener.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs create mode 100644 Optimum.Tests/render-stage-hooks-coverage-tests.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index cd26d8ca..46a0c9f3 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -157,6 +157,9 @@ "DeleteOcclusionQuery", "ReadDefaultFramebuffer", "GraphicsBackendName", + // Phase 2 (contract C3): the render-stage bracket ClientMain.TriggerRenderStage calls. + "BeginRenderStage", + "EndRenderStage", }, ["Vintagestory.Client.ClientProgram"] = new() { @@ -732,6 +735,9 @@ // Set3DProjection call sites, and the resets (FOV change, resize, world // load already listed below as Start, shader reload). new("Vintagestory.Client.NoObf.ClientMain", "MainRenderLoop", 1), + // Phase 2 (contract C3): brackets the stage's renderers with the platform's + // BeginRenderStage/EndRenderStage virtuals. + new("Vintagestory.Client.NoObf.ClientMain", "TriggerRenderStage", 2), new("Vintagestory.Client.NoObf.ClientMain", "Set3DProjection", 2), new("Vintagestory.Client.NoObf.ClientMain", "get_CurrentProjectionMatrix", 0), new("Vintagestory.Client.NoObf.ClientMain", "OnFowChanged", 1), diff --git a/Optimum.Render.Vulkan.Tests/RenderStageHookTests.cs b/Optimum.Render.Vulkan.Tests/RenderStageHookTests.cs new file mode 100644 index 00000000..80702ef7 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/RenderStageHookTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Optimum.Render.Vulkan.Graph; +using Optimum.Render.Vulkan.Platform; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Vulkan-native plan, Phase 2 (contract C3): the donor ClientMain.TriggerRenderStage brackets +/// each stage with the platform's BeginRenderStage/EndRenderStage, and VulkanClientPlatform +/// records the stage and forwards the bracket to its listener. Headless: the real +/// TriggerRenderStage runs on an uninitialised ClientMain (no event manager, so no renderers) +/// against a platform with no device; neither touches GL or Vulkan. +/// +public class RenderStageHookTests +{ + private sealed class RecordingListener : IRenderStageListener + { + public readonly List Calls = new(); + public VulkanClientPlatform? Platform; + public readonly List Faults = new(); + + public void OnBeginRenderStage(EnumRenderStage stage) + { + Calls.Add("begin " + stage); + if (Platform != null && (!Platform.InRenderStage || Platform.CurrentRenderStage != stage)) + Faults.Add("begin " + stage + " saw stage " + Platform.CurrentRenderStage + " active=" + Platform.InRenderStage); + } + + public void OnEndRenderStage(EnumRenderStage stage) + { + Calls.Add("end " + stage); + if (Platform != null && (Platform.InRenderStage || Platform.CurrentRenderStage != stage)) + Faults.Add("end " + stage + " saw stage " + Platform.CurrentRenderStage + " active=" + Platform.InRenderStage); + } + } + + /// Every stage once, in declaration order (the order MainRenderLoop broadly follows). + private static readonly EnumRenderStage[] FrameStages = (EnumRenderStage[])Enum.GetValues(typeof(EnumRenderStage)); + + private static ClientMain HeadlessGame(ClientPlatformAbstract platform) + { + // TriggerRenderStage marks the profiler first; a disabled one returns immediately. + ScreenManager.FrameProfiler ??= new FrameProfilerUtil(static (string _) => { }); + var game = (ClientMain)RuntimeHelpers.GetUninitializedObject(typeof(ClientMain)); + game.Platform = platform; + return game; + } + + [Fact] + public void AListenerSeesBeginAndEndForEachStageInOrder() + { + var platform = new VulkanClientPlatform(null!); + var listener = new RecordingListener { Platform = platform }; + platform.RenderStageListener = listener; + ClientMain game = HeadlessGame(platform); + + for (int frame = 0; frame < 2; frame++) + { + foreach (EnumRenderStage stage in FrameStages) + { + game.TriggerRenderStage(stage, 0.016f); + Assert.False(platform.InRenderStage); + Assert.Equal(stage, platform.CurrentRenderStage); + } + } + + var expected = new List(); + for (int frame = 0; frame < 2; frame++) + { + foreach (EnumRenderStage stage in FrameStages) + { + expected.Add("begin " + stage); + expected.Add("end " + stage); + } + } + Assert.Equal(expected, listener.Calls); + Assert.Empty(listener.Faults); + Assert.True(FrameStages.Length >= 10); + } + + [Fact] + public void WithoutAListenerTheBracketOnlyTracksTheStage() + { + var platform = new VulkanClientPlatform(null!); + Assert.Null(platform.RenderStageListener); + ClientMain game = HeadlessGame(platform); + + game.TriggerRenderStage(EnumRenderStage.Opaque, 0.016f); + Assert.Equal(EnumRenderStage.Opaque, platform.CurrentRenderStage); + Assert.False(platform.InRenderStage); + + platform.BeginRenderStage(EnumRenderStage.OIT); + Assert.True(platform.InRenderStage); + Assert.Equal(EnumRenderStage.OIT, platform.CurrentRenderStage); + platform.EndRenderStage(EnumRenderStage.OIT); + Assert.False(platform.InRenderStage); + } + + [Fact] + public void TheOpenGlPlatformKeepsTheNeutralBodies() + { + var platform = new ClientPlatformWindows(null!); + ClientMain game = HeadlessGame(platform); + + foreach (EnumRenderStage stage in FrameStages) + game.TriggerRenderStage(stage, 0.016f); + + Assert.Equal(typeof(ClientPlatformAbstract), + typeof(ClientPlatformWindows).GetMethod(nameof(ClientPlatformAbstract.BeginRenderStage))!.DeclaringType); + Assert.Equal(typeof(ClientPlatformAbstract), + typeof(ClientPlatformWindows).GetMethod(nameof(ClientPlatformAbstract.EndRenderStage))!.DeclaringType); + } +} diff --git a/Optimum.Render.Vulkan/Graph/IRenderStageListener.cs b/Optimum.Render.Vulkan/Graph/IRenderStageListener.cs new file mode 100644 index 00000000..4fa2a7e5 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/IRenderStageListener.cs @@ -0,0 +1,18 @@ +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// Vulkan-native plan, Phase 2 (contract C3): receives the render-stage bracket +/// ClientMain.TriggerRenderStage issues around each stage's renderers, forwarded by +/// . The frame graph implements it to map +/// (stage, target) to passes. Called on the render thread only. +/// +internal interface IRenderStageListener +{ + /// Before the stage's renderers run. + void OnBeginRenderStage(EnumRenderStage stage); + + /// After the stage's renderers ran, before the GL error check. + void OnEndRenderStage(EnumRenderStage stage); +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs new file mode 100644 index 00000000..a9922c0e --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs @@ -0,0 +1,35 @@ +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 2 (contract C3): the render-stage bracket. ClientMain.TriggerRenderStage +// calls BeginRenderStage before the stage's renderers and EndRenderStage after them; the +// platform records the stage and forwards both to the frame graph once it listens. +public partial class VulkanClientPlatform +{ + /// + /// The frame graph's hook; null until the frame graph sets it, in which case the bracket + /// only updates . + /// + internal IRenderStageListener? RenderStageListener; + + /// The stage most recently begun (still valid after it ended). + internal EnumRenderStage CurrentRenderStage { get; private set; } + + /// True between a stage's Begin and End. + internal bool InRenderStage { get; private set; } + + public override void BeginRenderStage(EnumRenderStage stage) + { + CurrentRenderStage = stage; + InRenderStage = true; + RenderStageListener?.OnBeginRenderStage(stage); + } + + public override void EndRenderStage(EnumRenderStage stage) + { + InRenderStage = false; + RenderStageListener?.OnEndRenderStage(stage); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index e2ac88b5..049f711a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -124,6 +124,9 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "DeleteOcclusionQuery", new[] { "Int32" }), new(true, "ReadDefaultFramebuffer", new[] { "Int32", "Int32", "Int32", "Int32", "IntPtr" }), new(true, "get_GraphicsBackendName", Array.Empty()), + // Phase 2: render-stage bracket from ClientMain.TriggerRenderStage (contract C3). + new(true, "BeginRenderStage", new[] { "EnumRenderStage" }), + new(true, "EndRenderStage", new[] { "EnumRenderStage" }), }; /// diff --git a/Optimum.Tests/render-stage-hooks-coverage-tests.cs b/Optimum.Tests/render-stage-hooks-coverage-tests.cs new file mode 100644 index 00000000..756a7bc6 --- /dev/null +++ b/Optimum.Tests/render-stage-hooks-coverage-tests.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 2 (contract C3): ClientMain.TriggerRenderStage brackets the stage's +/// renderers with ClientPlatformAbstract.BeginRenderStage/EndRenderStage, the method and both +/// virtuals reach the shipped DLL through Cecil, the OpenGL path keeps the neutral bodies and +/// VulkanClientPlatform overrides them (self-checked) and forwards to IRenderStageListener. +/// +public class RenderStageHooksCoverageTests +{ + private const string ClientMainPath = "Vintagestory.Client.NoObf/ClientMain.cs"; + private const string AbstractPath = "Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"; + private const string TriggerSignature = "public void TriggerRenderStage(EnumRenderStage stage, float dt)"; + + [Fact] + public void TriggerRenderStageBracketsTheEvent() + { + string body = Body(ReadLib(ClientMainPath), TriggerSignature); + + int begin = body.IndexOf("stagePlatform.BeginRenderStage(stage);", StringComparison.Ordinal); + int trigger = body.IndexOf("eventManager?.TriggerRenderStage(stage, dt);", StringComparison.Ordinal); + int end = body.IndexOf("stagePlatform.EndRenderStage(stage);", StringComparison.Ordinal); + int glCheck = body.IndexOf("Platform.CheckGlError(", StringComparison.Ordinal); + Assert.True(begin >= 0, "BeginRenderStage missing:\n" + body); + Assert.True(trigger > begin, "the event does not follow BeginRenderStage:\n" + body); + Assert.True(end > trigger, "EndRenderStage does not follow the event:\n" + body); + Assert.True(glCheck > end, "the GL error check moved inside the bracket:\n" + body); + Assert.Single(Regex.Matches(body, @"BeginRenderStage\(")); + Assert.Single(Regex.Matches(body, @"EndRenderStage\(")); + } + + [Fact] + public void TriggerRenderStageIsCecilSafe() + { + string body = StripComments(Body(ReadLib(ClientMainPath), TriggerSignature)); + Assert.DoesNotContain("=>", body); + Assert.DoesNotContain("delegate", body); + Assert.DoesNotContain("static ", body); + Assert.False(Regex.IsMatch(body, @"\.(All|Any|Where|Select|First|Count)\s*\("), "LINQ in a transplanted method:\n" + body); + } + + [Fact] + public void ThePatcherShipsTheMethodAndTheVirtuals() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientMain\", \"TriggerRenderStage\", 2),", patcher); + + string injected = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()", "},"); + Assert.Contains("\"BeginRenderStage\",", injected); + Assert.Contains("\"EndRenderStage\",", injected); + } + + [Fact] + public void TheAbstractPlatformDeclaresNeutralVirtualsAndOpenGlDoesNotOverrideThem() + { + string platform = ReadLib(AbstractPath); + foreach (string signature in new[] + { + "public virtual void BeginRenderStage(EnumRenderStage stage)", + "public virtual void EndRenderStage(EnumRenderStage stage)", + }) + { + Assert.Equal("{ }", Regex.Replace(Body(platform, signature), @"\s+", " ").Trim()); + } + + string windows = VulkanPlatformSource.ReadClientPlatformWindows(); + Assert.DoesNotContain("BeginRenderStage", windows); + Assert.DoesNotContain("EndRenderStage", windows); + } + + [Fact] + public void TheVulkanPlatformOverridesSelfChecksAndForwards() + { + string selfCheck = Block(Read(VulkanPlatformSource.MainFile), "internal static readonly ExpectedVirtual[] ExpectedVirtuals", "};"); + Assert.Contains("new(true, \"BeginRenderStage\", new[] { \"EnumRenderStage\" }),", selfCheck); + Assert.Contains("new(true, \"EndRenderStage\", new[] { \"EnumRenderStage\" }),", selfCheck); + + string stages = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs"); + string begin = Body(stages, "public override void BeginRenderStage(EnumRenderStage stage)"); + Assert.Contains("CurrentRenderStage = stage;", begin); + Assert.Contains("RenderStageListener?.OnBeginRenderStage(stage);", begin); + Assert.Contains("RenderStageListener?.OnEndRenderStage(stage);", + Body(stages, "public override void EndRenderStage(EnumRenderStage stage)")); + Assert.Contains("internal IRenderStageListener? RenderStageListener;", stages); + + string listener = Read("Optimum.Render.Vulkan/Graph/IRenderStageListener.cs"); + Assert.Contains("namespace Optimum.Render.Vulkan.Graph;", listener); + Assert.Contains("internal interface IRenderStageListener", listener); + Assert.Contains("void OnBeginRenderStage(EnumRenderStage stage);", listener); + Assert.Contains("void OnEndRenderStage(EnumRenderStage stage);", listener); + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + private static string ReadLib(string relativePath) + { + try + { + return File.ReadAllText(PatchReader.FindRepositoryFile("build/VintagestoryLib/" + relativePath)); + } + catch (FileNotFoundException) + { + return PatchReader.ReadPatchedContent(PatchReader.FindRepositoryFile( + "patches/VintagestoryLib/" + relativePath + ".patch")); + } + } + + private static string StripComments(string source) => + Regex.Replace(source, @"//[^\n]*", string.Empty); + + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + signature); + int open = source.IndexOf('{', start + signature.Length); + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced body: " + signature); + } + + private static string Block(string source, string header, string terminator) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf(terminator, start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } +} diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index 649bfdff..08660943 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..8cb33a8 100644 +index 67feafa..4037dee 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -376,7 +376,19 @@ index 67feafa..8cb33a8 100644 { if (SuspendMainThreadTasks) { -@@ -1094,10 +1292,11 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1087,17 +1285,23 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + + public void TriggerRenderStage(EnumRenderStage stage, float dt) + { + ScreenManager.FrameProfiler.Mark("beginrenderstage-", stage); + currentRenderStage = stage; ++ // Vulkan-native plan, Phase 2: the platform sees where each stage's renderers ++ // start and end (frame-graph pass boundaries). Neutral on OpenGL. ++ ClientPlatformAbstract stagePlatform = Platform; ++ stagePlatform.BeginRenderStage(stage); + eventManager?.TriggerRenderStage(stage, dt); ++ stagePlatform.EndRenderStage(stage); + Platform.CheckGlError("After render stage " + stage); } public void MainRenderLoop(float dt) @@ -388,7 +400,7 @@ index 67feafa..8cb33a8 100644 timelapse = 0f; timelapsedCurrent = 0f; timelapseEnd = float.MaxValue; -@@ -1122,10 +1321,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1122,10 +1326,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo Platform.ThreadSpinWait(10000000); } shUniforms.Update(dt, api); @@ -406,7 +418,7 @@ index 67feafa..8cb33a8 100644 Platform.GlDepthMask(flag: true); ScreenManager.FrameProfiler.Mark("rendOpaque-12before"); if (AmbientManager.ShadowQuality > 0 && (double)AmbientManager.DropShadowIntensity > 0.01) -@@ -1140,10 +1346,24 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1140,10 +1351,24 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo } ScreenManager.FrameProfiler.Mark("rendOpaque-3shadows"); GlMatrixModeModelView(); @@ -431,7 +443,7 @@ index 67feafa..8cb33a8 100644 { PerspectiveProjectionMat[i] = top[i]; PerspectiveViewMat[i] = top2[i]; -@@ -1176,14 +1396,43 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1176,14 +1401,43 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo Platform.GlDepthMask(flag: true); Platform.GlEnableDepthTest(); Platform.GlCullFaceBack(); @@ -475,7 +487,7 @@ index 67feafa..8cb33a8 100644 dt = DeltaTimeLimiter; } TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); -@@ -1420,10 +1669,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1420,10 +1674,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo { float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); @@ -490,7 +502,7 @@ index 67feafa..8cb33a8 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1818,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1823,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -514,7 +526,7 @@ index 67feafa..8cb33a8 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2377,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2382,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -539,7 +551,7 @@ index 67feafa..8cb33a8 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2806,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2811,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -553,7 +565,7 @@ index 67feafa..8cb33a8 100644 } public void DoReconnect() -@@ -3531,6 +3797,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3802,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 6d4a94d3..3c875b53 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..8337688 100644 +index d6eb844..49a9a11 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,405 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,416 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -392,6 +392,17 @@ index d6eb844..8337688 100644 + return null; + } + } ++ ++ // Vulkan-native plan, Phase 2: ClientMain.TriggerRenderStage brackets each render ++ // stage's renderers with these, so the Vulkan platform can map stages to frame-graph ++ // passes. Neutral bodies; the OpenGL path does not override them. ++ public virtual void BeginRenderStage(EnumRenderStage stage) ++ { ++ } ++ ++ public virtual void EndRenderStage(EnumRenderStage stage) ++ { ++ } + public static void DisposeIndexBuffer() { From 0d8cdf5c0f9d3d35703e72bd6f410acca3e25ce7 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:04:01 +0200 Subject: [PATCH 109/226] wip(phase2-ssao-alpha): source test follows the noise helper and pins alpha 1 Verified: Release build 0 errors; Optimum.Render.Vulkan.Tests 496/496 (sync,best); Optimum.Tests 1128 passed, 34 skipped, 0 failed; SsaoNoiseAlphaTests fail with the old padding alpha 0 (alpha 1.0 texels 0/256) and pass with the fix (256/256). --- Optimum.Tests/vulkan-backend-integration-tests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index d6645829..b0b2f7a0 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -421,10 +421,17 @@ public void SsaoNoiseAndKernelKeepTheirSeedAndOrder() Assert.Contains("new Random(5)", added); - int noise = added.IndexOf("noise[texel * 4]", StringComparison.Ordinal); + // The noise texels come from BuildOptimumSsaoNoise (Phase 2 ssao-alpha), called on + // the same Random before the kernel loop; SsaoNoiseAlphaTests pins the stream position. + int noise = added.IndexOf("BuildOptimumSsaoNoise(random, noiseSize)", StringComparison.Ordinal); int kernel = added.IndexOf("ssaoKernel[sample * 3]", StringComparison.Ordinal); Assert.True(noise >= 0 && kernel >= 0); Assert.True(noise < kernel, "the noise texels must be drawn before the sample kernel"); + + // GL uploads GL_RGB data into GL_RGBA32F and fills alpha with 1; the device copies + // four channels verbatim, so the texels carry that 1 themselves. + Assert.Contains("noise[texel * 4 + 3] = 1f;", added); + Assert.DoesNotContain("noise[texel * 4 + 3] = 0f;", added); } /// From a4016af523b12c74fb4a911dbbd0e397a50f9c68 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:07:37 +0200 Subject: [PATCH 110/226] wip(phase2-write-masks): colour write tiers, draw buffers as write masks, scope keeps attachments across mask changes Builds (Optimum.Render.Vulkan.Tests, Release). Not yet tested. --- .../PacingStatsTests.cs | 4 +- Optimum.Render.Vulkan/Core/ColorWriteTier.cs | 74 +++++++ .../Core/DynamicStateCache.cs | 18 +- Optimum.Render.Vulkan/Core/GlStateTracker.cs | 135 ++++++++++-- Optimum.Render.Vulkan/Core/PipelineCache.cs | 70 ++++-- .../Core/RenderTargetManager.cs | 200 +++++++++++++----- Optimum.Render.Vulkan/Core/VulkanContext.cs | 91 +++++++- Optimum.Render.Vulkan/Core/VulkanStats.cs | 33 ++- Optimum.Render.Vulkan/VulkanDevice.cs | 104 ++++++++- 9 files changed, 628 insertions(+), 101 deletions(-) create mode 100644 Optimum.Render.Vulkan/Core/ColorWriteTier.cs diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 3962cd31..a8f209f4 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -132,8 +132,8 @@ public void NewStatsLinesCarryStableKeyValueTokens() Assert.Equal( "stats.counters blocking_uploads=1 uploads=2 scopes=3 barriers=4 rebar_fallbacks=5 " + - "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8", - VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8))); + "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 mask_restarts=9 feedback_splits=10", + VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))); // The enum and the token table cannot drift apart. Assert.Equal(VulkanStats.WaitSiteCount, Enum.GetValues().Length); diff --git a/Optimum.Render.Vulkan/Core/ColorWriteTier.cs b/Optimum.Render.Vulkan/Core/ColorWriteTier.cs new file mode 100644 index 00000000..3dd47d20 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/ColorWriteTier.cs @@ -0,0 +1,74 @@ +using System; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// How a draw's effective colour write mask reaches the GPU (Phase 2, contract C4). +/// +/// The effective mask of attachment i is drawBufferEnabled(i) ? colorMask : 0, +/// then masked by the outputs the program writes. glDrawBuffers and the TAA motion +/// windows change it between draws of one pass; every tier expresses that without +/// restarting the rendering scope. Ordered from most to least capable. +/// +internal enum ColorWriteTier +{ + /// + /// The write-mask set is interned into the pipeline key: a mask change selects + /// another pipeline. Always available; bounded by programs used inside a window x 2. + /// + PipelineKey = 0, + + /// + /// VK_EXT_extended_dynamic_state3 colorWriteMask: the full per-attachment mask is + /// dynamic, so neither glColorMask nor glDrawBuffers is in the key. With + /// colorBlendEnable + colorBlendEquation also present the blend set leaves the + /// key as well. + /// + DynamicMask = 1, + + /// + /// VK_EXT_color_write_enable: glDrawBuffers is exactly a per-attachment enable, + /// zero extra pipelines; glColorMask stays in the key as before. + /// + DynamicEnable = 2, +} + +/// The optional-feature tier table: selection with an env override that forces a fallback. +internal static class DeviceCaps +{ + /// enable | mask | pipeline. A forced tier the device lacks degrades to the next one below. + public const string ColorWriteTierVariable = "OPTIMUM_VULKAN_COLOR_WRITE_TIER"; + + /// Parses an override; null for empty or unknown values. + public static ColorWriteTier? ParseColorWriteTier(string? value) => + value?.Trim().ToLowerInvariant() switch + { + "enable" or "dynamic-enable" => ColorWriteTier.DynamicEnable, + "mask" or "dynamic-mask" => ColorWriteTier.DynamicMask, + "pipeline" or "pipeline-key" or "key" => ColorWriteTier.PipelineKey, + _ => null, + }; + + /// + /// The best tier the device supports, or the forced one when it is supported; + /// a forced tier the device lacks falls to the best supported tier below it. + /// + public static ColorWriteTier SelectColorWriteTier(bool colorWriteEnable, bool colorWriteMask, ColorWriteTier? forced) + { + ColorWriteTier ceiling = forced ?? ColorWriteTier.DynamicEnable; + if (ceiling == ColorWriteTier.DynamicEnable && colorWriteEnable) return ColorWriteTier.DynamicEnable; + if (ceiling >= ColorWriteTier.DynamicMask && colorWriteMask) return ColorWriteTier.DynamicMask; + return ColorWriteTier.PipelineKey; + } + + /// The token written to the device log line and the stats. + public static string Token(ColorWriteTier tier) => tier switch + { + ColorWriteTier.DynamicEnable => "enable", + ColorWriteTier.DynamicMask => "mask", + _ => "pipeline", + }; + + public static ColorWriteTier? FromEnvironment() => + ParseColorWriteTier(Environment.GetEnvironmentVariable(ColorWriteTierVariable)); +} diff --git a/Optimum.Render.Vulkan/Core/DynamicStateCache.cs b/Optimum.Render.Vulkan/Core/DynamicStateCache.cs index e9aec6fb..294f7a79 100644 --- a/Optimum.Render.Vulkan/Core/DynamicStateCache.cs +++ b/Optimum.Render.Vulkan/Core/DynamicStateCache.cs @@ -23,7 +23,14 @@ internal enum DynamicStateDirty : ushort StencilWriteMask = 1 << 11, StencilReference = 1 << 12, LineWidth = 1 << 13, + /// The Vulkan 1.3 core set every pipeline declares dynamic. All = (1 << 14) - 1, + /// vkCmdSetColorWriteEnableEXT or vkCmdSetColorWriteMaskEXT, per the colour write tier. + ColorWrite = 1 << 14, + /// vkCmdSetColorBlendEnableEXT + vkCmdSetColorBlendEquationEXT (mask tier with dynamic blend). + ColorBlend = 1 << 15, + /// What a fresh recording marks dirty; the device drops the bits its tier does not use. + Everything = All | ColorWrite | ColorBlend, } /// The values a draw's dynamic state resolves to, already in Vulkan terms. @@ -46,6 +53,13 @@ internal struct DynamicStateValues public uint StencilWriteMask; public uint StencilReference; public float LineWidth; + /// + /// The colour write state the tier makes dynamic: enable bits (enable tier) or + /// the effective masks packed four bits per attachment (mask tier); 0 otherwise. + /// + public uint ColorWrite; + /// Interned id of the full per-attachment blend set (mask tier with dynamic blend); 0 otherwise. + public int BlendStateId; } /// @@ -80,7 +94,7 @@ public DynamicStateDirty Update(ulong serial, in DynamicStateValues next) DynamicStateDirty dirty; if (!Enabled || serial == 0 || serial != _serial) { - dirty = DynamicStateDirty.All; + dirty = DynamicStateDirty.Everything; } else { @@ -107,6 +121,8 @@ public DynamicStateDirty Update(ulong serial, in DynamicStateValues next) { dirty |= DynamicStateDirty.LineWidth; } + if (_last.ColorWrite != next.ColorWrite) dirty |= DynamicStateDirty.ColorWrite; + if (_last.BlendStateId != next.BlendStateId) dirty |= DynamicStateDirty.ColorBlend; } _serial = serial; diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs index 3245e5df..509c0791 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -309,8 +309,7 @@ public void SetColorMask(bool r, bool g, bool b, bool a) _colorWriteMask = mask; for (int i = 0; i < _blend.Length; i++) _blend[i].WriteMask = mask; - _cachedBlendId = -1; - _cachedBlendCount = -1; + InvalidateBlend(); } /// @@ -345,16 +344,14 @@ public void SetBlend(bool enabled, EnumBlendMode mode) _blend[i].DstAlpha = dstAlpha; _blend[i].AlphaOp = BlendOp.Add; } - _cachedBlendId = -1; - _cachedBlendCount = -1; + InvalidateBlend(); } /// glEnable/glDisable(GL_BLEND) preserve the indexed blend functions. public void SetBlendEnabled(bool enabled) { for (int i = 0; i < _blend.Length; i++) _blend[i].Enabled = enabled; - _cachedBlendId = -1; - _cachedBlendCount = -1; + InvalidateBlend(); } /// @@ -369,8 +366,7 @@ public void SetAttachmentBlendFunc(int attachment, int srcColor, int dstColor, i _blend[attachment].DstColor = GlEnums.BlendFactorFrom(dstColor); _blend[attachment].SrcAlpha = GlEnums.BlendFactorFrom(srcAlpha); _blend[attachment].DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); - _cachedBlendId = -1; - _cachedBlendCount = -1; + InvalidateBlend(); } public void SetAttachmentBlendEquation(int attachment, int equation) @@ -380,8 +376,7 @@ public void SetAttachmentBlendEquation(int attachment, int equation) BlendOp op = GlEnums.BlendOpFrom(equation); _blend[attachment].ColorOp = op; _blend[attachment].AlphaOp = op; - _cachedBlendId = -1; - _cachedBlendCount = -1; + InvalidateBlend(); } // ---------------------------------------------------------------------- keys @@ -406,14 +401,127 @@ public int BlendId(int attachmentCount) /// Blend state for one attachment, for pipeline creation. public AttachmentBlend BlendFor(int attachment) => _blend[attachment]; - public PipelineKey BuildKey(int vertexLayoutId, int targetFormatsId, int attachmentCount) => new( + public PipelineKey BuildKey(int vertexLayoutId, int targetFormatsId, int attachmentCount) => + BuildKey(vertexLayoutId, targetFormatsId, attachmentCount, uint.MaxValue); + + /// The key under the current for a target with these draw buffers. + public PipelineKey BuildKey(int vertexLayoutId, int targetFormatsId, int attachmentCount, uint drawBufferMask) => new( ProgramId: CurrentProgram, VertexLayoutId: vertexLayoutId, TargetFormatsId: targetFormatsId, - BlendId: BlendId(attachmentCount), + BlendId: PipelineBlendId(attachmentCount, drawBufferMask), PolygonMode: PolygonMode, TopologyClass: GlEnums.TopologyClassOf(Topology)); + // ------------------------------------------------------- colour write masks + + private const ColorComponentFlags AllChannels = + ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + + private int _cachedPipelineBlendId = -1; + private int _cachedPipelineBlendCount = -1; + private uint _cachedPipelineDrawBuffers; + + /// + /// How draw-buffer and colour-mask changes reach the GPU (Phase 2, C4). The + /// device sets it from the context's selected tier; component tests keep the + /// default, which bakes everything into the pipeline key. + /// + public ColorWriteTier ColorWriteTier { get; set; } = ColorWriteTier.PipelineKey; + + /// With the mask tier: blend enable and equation are dynamic too, so the blend set leaves the key. + public bool DynamicBlend { get; set; } + + /// The global glColorMask. + public ColorComponentFlags ColorMask => _colorWriteMask; + + private void InvalidateBlend() + { + _cachedBlendId = -1; + _cachedBlendCount = -1; + _cachedPipelineBlendId = -1; + _cachedPipelineBlendCount = -1; + } + + /// Bit i set when the program statically writes fragment output i. + public static uint OutputBits(HashSet writtenOutputs) + { + uint bits = 0; + for (int i = 0; i < MaxColorAttachments; i++) + { + if (writtenOutputs.Contains(i)) bits |= 1u << i; + } + return bits; + } + + /// + /// The effective write mask of one attachment: drawBufferEnabled ? colorMask : 0, + /// then masked by the outputs the program writes (an unwritten output keeps + /// the attachment's contents, as GL does; Vulkan would store undefined values). + /// + public ColorComponentFlags EffectiveWriteMask(int attachment, uint drawBufferMask, uint writtenOutputs) + { + if ((uint)attachment >= MaxColorAttachments) return 0; + if (((drawBufferMask >> attachment) & 1) == 0) return 0; + if (((writtenOutputs >> attachment) & 1) == 0) return 0; + return _colorWriteMask; + } + + /// + /// The blend state of one attachment as the pipeline bakes it under the tier: + /// the draw-buffer-masked write mask in the key tier, glColorMask alone in the + /// enable tier (draw buffers are the dynamic enable), and a canonical mask in + /// the mask tier (the dynamic mask replaces it; with dynamic blend the whole + /// attachment state is canonical). + /// + public AttachmentBlend PipelineBlendFor(int attachment, uint drawBufferMask) + { + AttachmentBlend blend = _blend[attachment]; + switch (ColorWriteTier) + { + case ColorWriteTier.PipelineKey: + if (((drawBufferMask >> attachment) & 1) == 0) blend.WriteMask = 0; + break; + case ColorWriteTier.DynamicMask: + if (DynamicBlend) blend = AttachmentBlend.Default; + blend.WriteMask = AllChannels; + break; + } + return blend; + } + + /// + /// The interned blend set a pipeline is keyed on under the tier. Equal to + /// whenever the tier leaves the state unchanged, so the + /// key tier with every draw buffer selected keys exactly as before. + /// + public int PipelineBlendId(int attachmentCount, uint drawBufferMask) + { + int count = Math.Clamp(attachmentCount, 0, MaxColorAttachments); + uint selectable = count == 32 ? uint.MaxValue : (1u << count) - 1; + uint relevant = drawBufferMask & selectable; + + if (ColorWriteTier == ColorWriteTier.DynamicEnable || + (ColorWriteTier == ColorWriteTier.PipelineKey && relevant == selectable)) + { + return BlendId(count); + } + + if (ColorWriteTier == ColorWriteTier.DynamicMask) relevant = 0; + if (_cachedPipelineBlendId >= 0 && _cachedPipelineBlendCount == count && _cachedPipelineDrawBuffers == relevant) + { + return _cachedPipelineBlendId; + } + + Span baked = stackalloc AttachmentBlend[count]; + for (int i = 0; i < count; i++) baked[i] = PipelineBlendFor(i, drawBufferMask); + + _cachedPipelineBlendCount = count; + _cachedPipelineDrawBuffers = relevant; + _cachedPipelineBlendId = _blendSignatures.Intern(new BlendSignature(baked)); + return _cachedPipelineBlendId; + } + /// /// Restores the defaults a fresh GL context would have. Called when the /// device is created and whenever the client resets its own state wholesale. @@ -421,8 +529,7 @@ public int BlendId(int attachmentCount) public void Reset() { for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; - _cachedBlendId = -1; - _cachedBlendCount = -1; + InvalidateBlend(); _colorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index a6478610..43eca1ce 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -25,6 +25,7 @@ internal sealed unsafe class GraphicsPipelineCache : IDisposable private readonly VulkanContext _context; private readonly Dictionary _pipelines = new(); private readonly Silk.NET.Vulkan.PipelineCache _driverCache; + private readonly DynamicState[] _dynamicStates; private bool _disposed; /// How many pipelines have been compiled, for diagnostics. @@ -36,9 +37,55 @@ internal sealed unsafe class GraphicsPipelineCache : IDisposable /// How many lookups had to compile. public long Misses { get; private set; } + /// The colour write tier every pipeline of this cache is built for. + public ColorWriteTier ColorWriteTier { get; } + + /// Everything Vulkan 1.3 core lets us change without a new pipeline. + private static readonly DynamicState[] CoreDynamicStates = + { + DynamicState.Viewport, + DynamicState.Scissor, + DynamicState.LineWidth, + DynamicState.CullMode, + DynamicState.FrontFace, + DynamicState.PrimitiveTopology, + DynamicState.DepthTestEnable, + DynamicState.DepthWriteEnable, + DynamicState.DepthCompareOp, + DynamicState.StencilTestEnable, + DynamicState.StencilOp, + DynamicState.StencilCompareMask, + DynamicState.StencilWriteMask, + DynamicState.StencilReference, + }; + public GraphicsPipelineCache(VulkanContext context, byte[]? initialData = null) + : this(context, ColorWriteTier.PipelineKey, dynamicBlend: false, initialData) + { + } + + /// + /// A cache whose pipelines declare the colour write state of + /// dynamic (and the blend set, with on the mask tier). + /// Draws through it must then emit that state (VulkanDevice.ApplyDynamicState). + /// + public GraphicsPipelineCache(VulkanContext context, ColorWriteTier tier, bool dynamicBlend, byte[]? initialData = null) { _context = context; + ColorWriteTier = tier; + + var dynamicStates = new List(CoreDynamicStates); + if (tier == ColorWriteTier.DynamicEnable) dynamicStates.Add(DynamicState.ColorWriteEnableExt); + if (tier == ColorWriteTier.DynamicMask) + { + dynamicStates.Add(DynamicState.ColorWriteMaskExt); + if (dynamicBlend) + { + dynamicStates.Add(DynamicState.ColorBlendEnableExt); + dynamicStates.Add(DynamicState.ColorBlendEquationExt); + } + } + _dynamicStates = dynamicStates.ToArray(); fixed (byte* data = initialData) { @@ -153,25 +200,10 @@ private Pipeline Create(PipelineRequest request) }; } - // Everything Vulkan 1.3 lets us change without a new pipeline. Keeping - // this list wide is what keeps the cache small. - var dynamicStates = new[] - { - DynamicState.Viewport, - DynamicState.Scissor, - DynamicState.LineWidth, - DynamicState.CullMode, - DynamicState.FrontFace, - DynamicState.PrimitiveTopology, - DynamicState.DepthTestEnable, - DynamicState.DepthWriteEnable, - DynamicState.DepthCompareOp, - DynamicState.StencilTestEnable, - DynamicState.StencilOp, - DynamicState.StencilCompareMask, - DynamicState.StencilWriteMask, - DynamicState.StencilReference, - }; + // Everything Vulkan lets us change without a new pipeline, plus the + // colour write state of the tier. Keeping this list wide is what keeps + // the cache small. + DynamicState[] dynamicStates = _dynamicStates; try { diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 72be6a8e..d4a030f9 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -35,19 +35,32 @@ internal sealed class VulkanFramebuffer /// Cached interned id of the attachment formats, or -1 when stale. public int FormatsId = -1; + + /// + /// Bound colour slots left out of the rendering scope because a draw samples + /// them while their draw buffer is off (the composition pass writes Primary 0 + /// and reads Primary 1). Only ever a subset of the cleared draw-buffer bits; + /// reset when the framebuffer is bound again. + /// + public uint SampledExclusion; } /// /// Owns framebuffers and drives dynamic rendering scopes. /// -/// The subtle part is glDrawBuffers. It does not mask writes - it selects -/// which attachments participate - and the game depends on that: the final -/// composition pass renders into the primary framebuffer's attachment 0 while -/// sampling its attachment 1, which is only legal because attachment 1 is not -/// part of the draw. Vulkan agrees, as long as the excluded attachments are left -/// out of vkCmdBeginRendering and moved to a shader-readable layout, so -/// the mask is honoured positionally: a disabled slot becomes a null attachment, -/// keeping fragment output N aimed at slot N. +/// The subtle part is glDrawBuffers. Since Phase 2 (contract C4) it is a +/// write mask, never a scope restart: the scope carries every bound colour slot, +/// and a cleared draw-buffer bit only zeroes that attachment's effective write +/// mask (dynamic enable, dynamic mask or pipeline key, per +/// ). The TAA motion windows toggle the motion +/// attachment that way inside one scope. +/// +/// The exception is a read: the final composition pass renders into the primary +/// framebuffer's attachment 0 while sampling its attachment 1, which Vulkan only +/// allows with attachment 1 out of the scope. A draw that samples a bound slot +/// whose draw buffer is off therefore leaves that slot out (a null attachment, +/// keeping fragment output N aimed at slot N) until its draw buffer is selected +/// again or the framebuffer is rebound; those restarts are feedback splits. /// internal sealed unsafe class RenderTargetManager : IDisposable { @@ -65,6 +78,21 @@ internal sealed unsafe class RenderTargetManager : IDisposable /// How many rendering scopes have been opened, for diagnostics. public long ScopesOpened { get; private set; } + /// + /// Restarts that reopened exactly the attachment set they closed (views and + /// layouts). Draw-buffer and colour-mask changes never restart, so this stays 0. + /// + public long MaskRestarts { get; private set; } + + /// Restarts that left a sampled, draw-buffer-excluded slot out of the scope or let it rejoin. + public long FeedbackSplits { get; private set; } + + // What the open scope was begun with, to recognise a restart that changed nothing. + private readonly ImageView[] _openViews = new ImageView[GlStateTracker.MaxColorAttachments]; + private int _openCount = -1; + private ImageView _openDepthView; + private ImageLayout _openDepthLayout; + /// /// Runs right after vkCmdBeginRendering, inside the new scope. The /// occlusion query ring resumes a query the previous scope's end suspended: @@ -122,7 +150,10 @@ public void Attach(int framebufferId, int attachmentIndex, int textureId, uint l } else if (attachmentIndex < GlStateTracker.MaxColorAttachments) { + AttachmentSlot previous = framebuffer.Color[attachmentIndex]; + if (previous.TextureId == textureId && previous.Layer == layer) return; framebuffer.Color[attachmentIndex] = new AttachmentSlot { TextureId = textureId, Layer = layer }; + framebuffer.SampledExclusion &= ~(1u << attachmentIndex); } framebuffer.FormatsId = -1; @@ -132,19 +163,73 @@ public void Attach(int framebufferId, int attachmentIndex, int textureId, uint l if (_bound == framebuffer) _needsRestart = true; } + /// + /// Records glDrawBuffers. The scope keeps its attachments: only the effective + /// write masks change, which the next draw emits (C4). The one restart is a + /// slot a sampling draw left out whose draw buffer is selected again: it has + /// to rejoin the scope before anything can be written into it. + /// public void SetDrawBuffers(int framebufferId, uint mask) { VulkanFramebuffer? framebuffer = Get(framebufferId); if (framebuffer == null || framebuffer.DrawBufferMask == mask) return; framebuffer.DrawBufferMask = mask; + + uint rejoining = framebuffer.SampledExclusion & mask; + if (rejoining == 0) return; + + framebuffer.SampledExclusion &= ~rejoining; framebuffer.FormatsId = -1; + if (_bound == framebuffer && _renderingActive) + { + _needsRestart = true; + NoteFeedbackSplit(); + } + } - // The set of attachments changed, so the current scope no longer - // describes what is being rendered into. - if (_bound == framebuffer) _needsRestart = true; + /// + /// A draw is about to sample . If that texture is a + /// bound colour slot of the bound framebuffer whose draw buffer is off, the slot + /// leaves the scope (closing an open one that holds it) so the caller can move + /// it to a shader-readable layout. Slots whose draw buffer is on are feedback + /// the caller resolves with a snapshot instead (). + /// + public void ExcludeSampledAttachment(CommandBuffer commandBuffer, int textureId) + { + VulkanFramebuffer? framebuffer = _bound; + if (framebuffer == null || textureId <= 0) return; + + uint slots = 0; + for (int i = 0; i < framebuffer.Color.Length; i++) + { + if (framebuffer.Color[i].TextureId != textureId) continue; + if (((framebuffer.DrawBufferMask >> i) & 1) != 0) continue; + slots |= 1u << i; + } + + uint newlyExcluded = slots & ~framebuffer.SampledExclusion; + if (newlyExcluded == 0) return; + + framebuffer.SampledExclusion |= newlyExcluded; + framebuffer.FormatsId = -1; + if (_renderingActive) + { + EndRendering(commandBuffer); + NoteFeedbackSplit(); + } + } + + private void NoteFeedbackSplit() + { + FeedbackSplits++; + VulkanStats.NoteFeedbackSplit(); } + /// Whether colour slot is part of the scope the framebuffer opens. + private static bool InScope(VulkanFramebuffer framebuffer, int index) => + framebuffer.Color[index].IsBound && ((framebuffer.SampledExclusion >> index) & 1) == 0; + private bool _needsRestart; /// @@ -205,6 +290,13 @@ public void Bind(CommandBuffer commandBuffer, int framebufferId) EndRendering(commandBuffer); _bound = framebuffer; _needsRestart = false; + + // A new bind starts a new use of the target: every bound slot is back in. + if (framebuffer != null && framebuffer.SampledExclusion != 0) + { + framebuffer.SampledExclusion = 0; + framebuffer.FormatsId = -1; + } } public void Delete(int framebufferId) @@ -221,50 +313,33 @@ public void Delete(int framebufferId) /// /// Opens a rendering scope if one is not already open, transitioning every - /// participating attachment into its attachment layout and every excluded - /// one into a shader-readable layout. + /// participating attachment into its attachment layout. Every bound colour + /// slot participates, whatever its draw buffer, unless a sampling draw left + /// it out (); that caller moves it to + /// a shader-readable layout itself. /// public void EnsureRendering(CommandBuffer commandBuffer) { if (_renderingActive && !_needsRestart) return; if (_bound == null) return; + bool restarting = _renderingActive; if (_renderingActive) EndRendering(commandBuffer); VulkanFramebuffer framebuffer = _bound; - int highest = HighestEnabledAttachment(framebuffer); + int highest = HighestScopeAttachment(framebuffer); int count = highest + 1; var attachments = new RenderingAttachmentInfo[Math.Max(count, 0)]; - // Every slot the draw does not write may be sampled instead, so it has to - // be readable. This runs over all of them, not just the ones below the - // highest enabled index: the composition pass renders into attachment 0 - // while sampling attachment 1, and a loop bounded by the attachment count - // would never reach the slot it samples. - for (int i = 0; i < framebuffer.Color.Length; i++) - { - AttachmentSlot unused = framebuffer.Color[i]; - if (!unused.IsBound) continue; - if ((framebuffer.DrawBufferMask & (1u << i)) != 0) continue; - - VulkanTexture? excluded = _textures.Get(unused.TextureId); - if (excluded != null) - { - _textures.TransitionTexture(commandBuffer, excluded, ImageLayout.ShaderReadOnlyOptimal); - } - } - for (int i = 0; i < count; i++) { - bool enabled = (framebuffer.DrawBufferMask & (1u << i)) != 0; AttachmentSlot slot = framebuffer.Color[i]; - if (!enabled || !slot.IsBound) + if (!InScope(framebuffer, i)) { - // A null view keeps fragment output i pointed at slot i while - // discarding its writes, which is what a cleared draw-buffer bit - // means in GL. + // A null view keeps fragment output i pointed at slot i: an + // unbound slot, or one a draw samples while its draw buffer is off. attachments[i] = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo, @@ -337,6 +412,26 @@ public void EnsureRendering(CommandBuffer commandBuffer) _context.Api.CmdBeginRendering(commandBuffer, &rendering); } + // A restart that reopened the very set it closed bought nothing: with + // write masks carrying draw buffers and motion windows this never happens. + ImageView depthView = hasDepth ? depthAttachment.ImageView : default; + ImageLayout depthLayoutOpened = hasDepth ? depthAttachment.ImageLayout : ImageLayout.Undefined; + bool unchanged = restarting && count == _openCount && depthView.Handle == _openDepthView.Handle + && depthLayoutOpened == _openDepthLayout; + for (int i = 0; unchanged && i < count; i++) + { + unchanged = attachments[i].ImageView.Handle == _openViews[i].Handle; + } + if (unchanged) + { + MaskRestarts++; + VulkanStats.NoteMaskRestart(); + } + _openCount = count; + _openDepthView = depthView; + _openDepthLayout = depthLayoutOpened; + for (int i = 0; i < count; i++) _openViews[i] = attachments[i].ImageView; + _renderingActive = true; _needsRestart = false; ScopesOpened++; @@ -364,13 +459,14 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo if (_bound == null) return; // glClearBuffer names a draw buffer, and one that glDrawBuffers left out - // is simply not cleared. The scope only carries attachments up to the - // highest selected one, so a clear aimed past that - the game clears - // attachments 2 and 3 of the primary target while only 0 and 1 are - // selected - would name an attachment the scope does not have. + // is simply not cleared - the game clears attachments 2 and 3 of the + // primary target while only 0 and 1 are selected. Nor does GL clear + // through an all-false glColorMask. A clear on an attachment whose + // effective write mask is zero is a no-op on every path (CLAUDE.md rule 9). if ((uint)attachment >= (uint)_bound.Color.Length) return; if (!_bound.Color[attachment].IsBound) return; if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return; + if (_state.ColorMask == 0) return; EnsureRendering(commandBuffer); if (!_renderingActive) return; @@ -416,22 +512,22 @@ public void ClearDepth(CommandBuffer commandBuffer, float depth) // ------------------------------------------------------------------ formats /// - /// The attachment formats of the bound target, for the pipeline key. Disabled - /// slots report so the pipeline agrees with - /// the null attachments the scope was opened with. + /// The attachment formats of the bound target, for the pipeline key. They + /// follow the scope, not the draw buffers, so a mask toggle keeps the id: an + /// unbound or sample-excluded slot reports , + /// agreeing with the null attachment the scope was opened with. /// public int FormatsIdOf(VulkanFramebuffer framebuffer) { if (framebuffer.FormatsId >= 0) return framebuffer.FormatsId; - int count = HighestEnabledAttachment(framebuffer) + 1; + int count = HighestScopeAttachment(framebuffer) + 1; var colorFormats = new Format[Math.Max(count, 0)]; for (int i = 0; i < count; i++) { - bool enabled = (framebuffer.DrawBufferMask & (1u << i)) != 0; AttachmentSlot slot = framebuffer.Color[i]; - VulkanTexture? texture = enabled && slot.IsBound ? _textures.Get(slot.TextureId) : null; + VulkanTexture? texture = InScope(framebuffer, i) ? _textures.Get(slot.TextureId) : null; colorFormats[i] = texture?.Format ?? Format.Undefined; } @@ -445,18 +541,16 @@ public int FormatsIdOf(VulkanFramebuffer framebuffer) return framebuffer.FormatsId; } + /// Colour attachments of the scope the framebuffer opens (highest participating slot + 1). public int EnabledAttachmentCount(VulkanFramebuffer framebuffer) => - HighestEnabledAttachment(framebuffer) + 1; + HighestScopeAttachment(framebuffer) + 1; - private static int HighestEnabledAttachment(VulkanFramebuffer framebuffer) + private static int HighestScopeAttachment(VulkanFramebuffer framebuffer) { int highest = -1; for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) { - if ((framebuffer.DrawBufferMask & (1u << i)) != 0 && framebuffer.Color[i].IsBound) - { - highest = i; - } + if (InScope(framebuffer, i)) highest = i; } return highest; } diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index fb724f8b..c6ea0fa9 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -36,6 +36,12 @@ internal sealed class VulkanContextOptions /// public bool? Poison; + /// + /// Forces a colour write tier (); null reads + /// OPTIMUM_VULKAN_COLOR_WRITE_TIER. A tier the device lacks degrades to the next below. + /// + public ColorWriteTier? ColorWriteTier; + /// /// Tests only: sleeps this long before every vkAcquireNextImageKHR, standing /// in for a compositor that holds images back (PresentDecouplingTests). @@ -61,6 +67,16 @@ internal sealed class VulkanCapabilities public int MaxBoundDescriptorSets; public ulong MinUniformBufferOffsetAlignment; public ulong MaxUniformBufferRange; + public uint MaxColorAttachments = 8; + + /// VK_EXT_color_write_enable enabled (only when the selected tier uses it). + public bool ColorWriteEnable; + /// VK_EXT_extended_dynamic_state3 colorWriteMask enabled (only for the mask tier). + public bool DynamicColorWriteMask; + /// colorBlendEnable + colorBlendEquation enabled alongside the mask tier: the blend set is dynamic. + public bool DynamicColorBlend; + /// The tier draws use; see . + public ColorWriteTier ColorWriteTier = ColorWriteTier.PipelineKey; } /// @@ -116,6 +132,12 @@ internal sealed unsafe class VulkanContext : IDisposable public bool MemoryBudgetAvailable { get; private set; } public VulkanCapabilities Capabilities { get; private set; } = new(); + /// vkCmdSetColorWriteEnableEXT, when the enable tier is selected. + public ExtColorWriteEnable? ColorWriteEnableApi { get; private set; } + + /// vkCmdSetColorWriteMaskEXT / BlendEnable / BlendEquation, when the mask tier is selected. + public ExtExtendedDynamicState3? DynamicState3Api { get; private set; } + /// /// Whether the validation layers are actually loaded, which is not the same /// as having been asked for: the layer has to be installed on the machine. @@ -681,10 +703,62 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso OcclusionQueryPrecise = available.OcclusionQueryPrecise, }; + // Optional tier (Phase 2, C4): colour write masks as dynamic state. Only the + // extension the selected tier uses is enabled, so validation sees exactly + // what draws record. OPTIMUM_VULKAN_COLOR_WRITE_TIER forces a fallback. + var colorWriteFeatures = new PhysicalDeviceColorWriteEnableFeaturesEXT + { + SType = StructureType.PhysicalDeviceColorWriteEnableFeaturesExt, + }; + var dynamicState3Features = new PhysicalDeviceExtendedDynamicState3FeaturesEXT + { + SType = StructureType.PhysicalDeviceExtendedDynamicState3FeaturesExt, + }; + bool hasColorWriteEnable = deviceExtensionsAvailable.Contains("VK_EXT_color_write_enable"); + bool hasDynamicState3 = deviceExtensionsAvailable.Contains("VK_EXT_extended_dynamic_state3"); + if (hasColorWriteEnable || hasDynamicState3) + { + colorWriteFeatures.PNext = hasDynamicState3 ? &dynamicState3Features : null; + var query = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = hasColorWriteEnable ? &colorWriteFeatures : &dynamicState3Features, + }; + Api.GetPhysicalDeviceFeatures2(PhysicalDevice, &query); + } + bool canEnable = hasColorWriteEnable && colorWriteFeatures.ColorWriteEnable; + bool canMask = hasDynamicState3 && dynamicState3Features.ExtendedDynamicState3ColorWriteMask; + bool canBlend = canMask && dynamicState3Features.ExtendedDynamicState3ColorBlendEnable + && dynamicState3Features.ExtendedDynamicState3ColorBlendEquation; + ColorWriteTier colorWriteTier = DeviceCaps.SelectColorWriteTier(canEnable, canMask, + options.ColorWriteTier ?? DeviceCaps.FromEnvironment()); + + // Re-request only what the tier uses; the queries may have reported more. + colorWriteFeatures = new PhysicalDeviceColorWriteEnableFeaturesEXT + { + SType = StructureType.PhysicalDeviceColorWriteEnableFeaturesExt, + PNext = wantDeviceFault ? &faultFeatures : null, + ColorWriteEnable = true, + }; + dynamicState3Features = new PhysicalDeviceExtendedDynamicState3FeaturesEXT + { + SType = StructureType.PhysicalDeviceExtendedDynamicState3FeaturesExt, + PNext = wantDeviceFault ? &faultFeatures : null, + ExtendedDynamicState3ColorWriteMask = true, + ExtendedDynamicState3ColorBlendEnable = canBlend, + ExtendedDynamicState3ColorBlendEquation = canBlend, + }; + void* optionalFeatures = colorWriteTier switch + { + ColorWriteTier.DynamicEnable => &colorWriteFeatures, + ColorWriteTier.DynamicMask => &dynamicState3Features, + _ => wantDeviceFault ? &faultFeatures : null, + }; + var vulkan13 = new PhysicalDeviceVulkan13Features { SType = StructureType.PhysicalDeviceVulkan13Features, - PNext = wantDeviceFault ? &faultFeatures : null, + PNext = optionalFeatures, DynamicRendering = true, Synchronization2 = true, }; @@ -712,6 +786,8 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso bool wantMemoryBudget = deviceExtensionsAvailable.Contains("VK_EXT_memory_budget") && Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_NO_MEMORY_BUDGET") != "1"; if (wantMemoryBudget) deviceExtensions.Add("VK_EXT_memory_budget"); + if (colorWriteTier == ColorWriteTier.DynamicEnable) deviceExtensions.Add("VK_EXT_color_write_enable"); + if (colorWriteTier == ColorWriteTier.DynamicMask) deviceExtensions.Add("VK_EXT_extended_dynamic_state3"); nint extensionsPtr = deviceExtensions.Count > 0 ? SilkMarshal.StringArrayToPtr(deviceExtensions) @@ -745,6 +821,18 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso GraphicsQueue = Api.GetDeviceQueue(Device, family, 0); LoadDiagnosticExtensions(wantCheckpoints, wantDeviceFault); Capabilities = ReadCapabilities(); + Capabilities.ColorWriteTier = colorWriteTier; + Capabilities.ColorWriteEnable = colorWriteTier == ColorWriteTier.DynamicEnable; + Capabilities.DynamicColorWriteMask = colorWriteTier == ColorWriteTier.DynamicMask; + Capabilities.DynamicColorBlend = colorWriteTier == ColorWriteTier.DynamicMask && canBlend; + if (Capabilities.ColorWriteEnable && Api.TryGetDeviceExtension(Instance, Device, out ExtColorWriteEnable writeEnable)) + { + ColorWriteEnableApi = writeEnable; + } + if (Capabilities.DynamicColorWriteMask && Api.TryGetDeviceExtension(Instance, Device, out ExtExtendedDynamicState3 state3)) + { + DynamicState3Api = state3; + } MemoryBudgetAvailable = wantMemoryBudget; Allocator = new VulkanAllocator(this); return true; @@ -911,6 +999,7 @@ private VulkanCapabilities ReadCapabilities() MaxBoundDescriptorSets = (int)properties.Limits.MaxBoundDescriptorSets, MinUniformBufferOffsetAlignment = properties.Limits.MinUniformBufferOffsetAlignment, MaxUniformBufferRange = properties.Limits.MaxUniformBufferRange, + MaxColorAttachments = properties.Limits.MaxColorAttachments, }; } diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 049fc827..a3b31b5d 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -190,6 +190,26 @@ public static void NoteUpload(long elapsedTicks) public static long ScopesOpened => Interlocked.Read(ref _scopesOpened); + private static long _maskRestarts; + private static long _feedbackSplits; + + /// + /// A scope restart that reopened exactly the attachment set it closed (same + /// views, same layouts). Draw-buffer and colour-mask changes only alter write + /// masks (Phase 2, C4), so this must stay 0. + /// + public static void NoteMaskRestart() => Interlocked.Increment(ref _maskRestarts); + + /// + /// A scope restart because a draw samples a bound colour attachment its draw + /// buffers exclude (the composition pass reads Primary 1), or because such a + /// slot rejoins the scope once its draw buffer is enabled again. + /// + public static void NoteFeedbackSplit() => Interlocked.Increment(ref _feedbackSplits); + + public static long MaskRestarts => Interlocked.Read(ref _maskRestarts); + public static long FeedbackSplits => Interlocked.Read(ref _feedbackSplits); + /// Image memory barriers recorded into a command buffer. public static void NoteImageBarriers(int count) => Interlocked.Add(ref _imageBarriers, count); @@ -302,7 +322,9 @@ public static Result WaitDeviceIdle(Vk api, Device device) RebarFallbacks: Interlocked.Exchange(ref _rebarFallbacks, 0), DynamicState: Interlocked.Exchange(ref _dynamicStateCommands, 0), UniformRingUsed: Interlocked.Exchange(ref _uniformRingPeak, 0), - UniformRingCapacity: Interlocked.Read(ref _uniformRingCapacity)); + UniformRingCapacity: Interlocked.Read(ref _uniformRingCapacity), + MaskRestarts: Interlocked.Exchange(ref _maskRestarts, 0), + FeedbackSplits: Interlocked.Exchange(ref _feedbackSplits, 0)); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -359,9 +381,10 @@ public static string FormatWaitsLine(long[] counts, double[] milliseconds) public static string FormatCountersLine(CounterSample counters) => string.Format(CultureInfo.InvariantCulture, "stats.counters blocking_uploads={0} uploads={1} scopes={2} barriers={3} rebar_fallbacks={4} " + - "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7}", + "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7} mask_restarts={8} feedback_splits={9}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, - counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity); + counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, + counters.MaskRestarts, counters.FeedbackSplits); private static long _lastSample; } @@ -375,7 +398,9 @@ internal readonly record struct CounterSample( long RebarFallbacks, long DynamicState, long UniformRingUsed, - long UniformRingCapacity); + long UniformRingCapacity, + long MaskRestarts = 0, + long FeedbackSplits = 0); /// Percentiles and spread of the frame-interval ring at one moment. internal readonly record struct FramePacingSnapshot( diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index cf58dc9b..cc5c56ca 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -350,7 +350,9 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; validation layers " + (_context.ValidationEnabled ? "ENABLED" : "NOT AVAILABLE") + "; GPU checkpoints " + (_context.CheckpointsAvailable ? "ENABLED" : "NOT AVAILABLE") + "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE") + - "; poison " + (_context.PoisonFreshResources ? "ON" : "off")); + "; poison " + (_context.PoisonFreshResources ? "ON" : "off") + + "; color write tier " + DeviceCaps.Token(_context.Capabilities.ColorWriteTier) + + (_context.Capabilities.DynamicColorBlend ? " (dynamic blend)" : "")); // A ReBAR miss is logged, not an error: the validation mirror and the // trace, never GetError. The stats sample reads this allocator's heaps. _context.Allocator.Log = MirrorValidationMessage; @@ -367,7 +369,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // An inline upload records transfer commands into the frame command // buffer, which no rendering scope may enclose. _uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer); - _pipelines = new GraphicsPipelineCache(_context); + // Colour write tier (C4): draw buffers and motion windows are write masks. + _state.ColorWriteTier = _context.Capabilities.ColorWriteTier; + _state.DynamicBlend = _context.Capabilities.DynamicColorBlend; + _pipelines = new GraphicsPipelineCache(_context, _context.Capabilities.ColorWriteTier, + _context.Capabilities.DynamicColorBlend); _descriptors = new DescriptorCache(_context); _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; for (int i = 0; i < _descriptorArenas.Length; i++) _descriptorArenas[i] = new DescriptorArena(_context); @@ -1892,8 +1898,11 @@ private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer comma RenderTargetFormats formats = _state.TargetFormats(formatsId); int attachmentCount = _targets.EnabledAttachmentCount(target); + // Draw buffers are write masks (C4): the tier decides whether they reach + // the pipeline key, a dynamic enable or a dynamic mask. + uint drawBuffers = target.DrawBufferMask; var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; - for (int i = 0; i < blend.Length; i++) blend[i] = _state.BlendFor(i); + for (int i = 0; i < blend.Length; i++) blend[i] = _state.PipelineBlendFor(i, drawBuffers); // A mesh that no longer exists reports -1; falling back to the reserved // empty layout keeps the key valid rather than indexing past the interner. @@ -1912,7 +1921,7 @@ private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer comma } Pipeline pipeline = _pipelines.Get( - _state.BuildKey(layoutId, formatsId, attachmentCount), + _state.BuildKey(layoutId, formatsId, attachmentCount, drawBuffers), new GraphicsPipelineCache.PipelineRequest { Program = program, @@ -1939,7 +1948,7 @@ private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer comma } BindDescriptors(commandBuffer, program, meshId); - ApplyDynamicState(commandBuffer, target); + ApplyDynamicState(commandBuffer, target, program); return true; } @@ -2007,6 +2016,10 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra // puts it in the read-only layout, which serves both uses at once. if (_targets.DepthReadOnly && _targets.IsBoundDepth(_boundTextures[unit])) continue; + // A bound slot whose draw buffer is off is in the scope with a zero + // write mask (C4); sampling it takes it out, so it can be read below. + _targets.ExcludeSampledAttachment(commandBuffer, _boundTextures[unit]); + if (_targets.IsAttachmentOfBound(_boundTextures[unit])) { if (texture.Aspect == ImageAspectFlags.ColorBit) @@ -2424,9 +2437,28 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources } } - private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target) + private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target, ShaderProgramResources program) { Vk api = _context.Api; + ColorWriteTier tier = _context.Capabilities.ColorWriteTier; + bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; + int colorStates = (int)Math.Min(_context.Capabilities.MaxColorAttachments, (uint)GlStateTracker.MaxColorAttachments); + + // The colour write state the tier makes dynamic, folded into one value so + // the cache can tell whether it changed. + uint colorWrite = 0; + if (tier == ColorWriteTier.DynamicEnable) + { + colorWrite = target.DrawBufferMask & ((1u << colorStates) - 1); + } + else if (tier == ColorWriteTier.DynamicMask) + { + uint written = GlStateTracker.OutputBits(program.Interface.WrittenFragmentOutputs); + for (int i = 0; i < colorStates; i++) + { + colorWrite |= (uint)_state.EffectiveWriteMask(i, target.DrawBufferMask, written) << (i * 4); + } + } Rect2D viewport = _state.Viewport; var values = new DynamicStateValues @@ -2454,6 +2486,8 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta StencilWriteMask = _state.StencilWriteMask, StencilReference = _state.StencilReference, LineWidth = _context.Capabilities.WideLines ? _state.LineWidth : 1.0f, + ColorWrite = colorWrite, + BlendStateId = dynamicBlend ? _state.BlendId(GlStateTracker.MaxColorAttachments) : 0, }; // Dirty-masked (Phase 1B step 6): the cache knows what this recording of @@ -2462,7 +2496,51 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta FrameSlot slot = _frames.Current; ulong serial = slot.CommandBuffer.Handle == commandBuffer.Handle ? slot.RecordingSerial : 0; DynamicStateDirty dirty = _dynamicState.Update(serial, values); + if (tier == ColorWriteTier.PipelineKey) dirty &= ~DynamicStateDirty.ColorWrite; + if (!dynamicBlend) dirty &= ~DynamicStateDirty.ColorBlend; if (dirty == DynamicStateDirty.None) return; + int extraCommands = 0; + + if ((dirty & DynamicStateDirty.ColorWrite) != 0) + { + if (tier == ColorWriteTier.DynamicEnable) + { + // All of maxColorAttachments, so the count covers every pipeline's attachments. + Silk.NET.Core.Bool32* enables = stackalloc Silk.NET.Core.Bool32[colorStates]; + for (int i = 0; i < colorStates; i++) enables[i] = ((colorWrite >> i) & 1) != 0; + _context.ColorWriteEnableApi!.CmdSetColorWriteEnable(commandBuffer, (uint)colorStates, enables); + } + else + { + ColorComponentFlags* masks = stackalloc ColorComponentFlags[colorStates]; + for (int i = 0; i < colorStates; i++) masks[i] = (ColorComponentFlags)((colorWrite >> (i * 4)) & 0xF); + _context.DynamicState3Api!.CmdSetColorWriteMask(commandBuffer, 0, (uint)colorStates, masks); + } + extraCommands++; + } + + if ((dirty & DynamicStateDirty.ColorBlend) != 0) + { + Silk.NET.Core.Bool32* blendEnables = stackalloc Silk.NET.Core.Bool32[colorStates]; + ColorBlendEquationEXT* equations = stackalloc ColorBlendEquationEXT[colorStates]; + for (int i = 0; i < colorStates; i++) + { + AttachmentBlend blend = _state.BlendFor(i); + blendEnables[i] = blend.Enabled; + equations[i] = new ColorBlendEquationEXT + { + SrcColorBlendFactor = blend.SrcColor, + DstColorBlendFactor = blend.DstColor, + ColorBlendOp = blend.ColorOp, + SrcAlphaBlendFactor = blend.SrcAlpha, + DstAlphaBlendFactor = blend.DstAlpha, + AlphaBlendOp = blend.AlphaOp, + }; + } + _context.DynamicState3Api!.CmdSetColorBlendEnable(commandBuffer, 0, (uint)colorStates, blendEnables); + _context.DynamicState3Api!.CmdSetColorBlendEquation(commandBuffer, 0, (uint)colorStates, equations); + extraCommands += 2; + } if ((dirty & DynamicStateDirty.Viewport) != 0) api.CmdSetViewport(commandBuffer, 0, 1, &values.Viewport); if ((dirty & DynamicStateDirty.Scissor) != 0) api.CmdSetScissor(commandBuffer, 0, 1, &values.Scissor); @@ -2487,11 +2565,23 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta if ((dirty & DynamicStateDirty.LineWidth) != 0) api.CmdSetLineWidth(commandBuffer, values.LineWidth); - int emitted = DynamicStateCache.CommandCount(dirty); + int emitted = DynamicStateCache.CommandCount(dirty & DynamicStateDirty.All) + extraCommands; _dynamicStateCommands += emitted; VulkanStats.NoteDynamicStateCommands(emitted); } + /// The colour write tier this device's draws use. Tests only. + internal ColorWriteTier ColorWriteTierForTests => _context.Capabilities.ColorWriteTier; + + /// vkCmdBeginRendering calls of this device. Tests only. + internal long ScopesOpenedForTests => _targets.ScopesOpened; + + /// Restarts that reopened an identical attachment set; must stay 0. Tests only. + internal long MaskRestartsForTests => _targets.MaskRestarts; + + /// Restarts for a sampled, draw-buffer-excluded slot. Tests only. + internal long FeedbackSplitsForTests => _targets.FeedbackSplits; + /// Dynamic-state commands this device recorded. Tests only. internal long DynamicStateCommandsForTests => _dynamicStateCommands; From be614df73cfea0009ab1b1657417bb38d418fa79 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:13:49 +0200 Subject: [PATCH 111/226] wip(phase2-write-masks): per-tier MotionWindowTests, tier unit tests, source coverage, stats tokens documented Tracker invalidates its pipeline blend cache when the tier changes. Targeted GPU run before this fix: 47/48 (the failing case is the cache bug fixed here). --- .../ColorWriteTierTests.cs | 136 +++++++ .../MotionWindowTests.cs | 371 ++++++++++++++++++ .../PerDrawCostTests.cs | 2 +- .../RenderTargetTests.cs | 78 +++- Optimum.Render.Vulkan/Core/GlStateTracker.cs | 23 +- Optimum.Render.Vulkan/VulkanDevice.cs | 9 + .../color-write-tier-coverage-tests.cs | 76 ++++ docs/taa-acceptance.md | 9 +- 8 files changed, 682 insertions(+), 22 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/MotionWindowTests.cs create mode 100644 Optimum.Tests/color-write-tier-coverage-tests.cs diff --git a/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs b/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs new file mode 100644 index 00000000..c916b449 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 2 contract C4 without a device: tier selection and its env override, +/// the effective write mask, how each tier keys pipelines, and the dirty bits the +/// dynamic tiers add. +/// +public class ColorWriteTierTests +{ + private const ColorComponentFlags Rgba = + ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + + // Tiers travel as their env tokens: the enum is internal to the backend. + [Theory] + [InlineData(true, true, null, "enable")] + [InlineData(false, true, null, "mask")] + [InlineData(false, false, null, "pipeline")] + [InlineData(true, true, "mask", "mask")] + [InlineData(true, true, "pipeline", "pipeline")] + [InlineData(true, false, "mask", "pipeline")] + [InlineData(false, true, "enable", "mask")] + public void TheBestSupportedTierAtOrBelowTheForcedOneIsSelected( + bool enable, bool mask, string? forced, string expected) + { + ColorWriteTier selected = DeviceCaps.SelectColorWriteTier(enable, mask, DeviceCaps.ParseColorWriteTier(forced)); + Assert.Equal(expected, DeviceCaps.Token(selected)); + } + + [Theory] + [InlineData("enable", "enable")] + [InlineData(" MASK ", "mask")] + [InlineData("pipeline", "pipeline")] + [InlineData("", null)] + [InlineData("bogus", null)] + [InlineData(null, null)] + public void TheOverrideParsesItsThreeTokens(string? value, string? expected) + { + ColorWriteTier? parsed = DeviceCaps.ParseColorWriteTier(value); + Assert.Equal(expected, parsed == null ? null : DeviceCaps.Token(parsed.Value)); + } + + [Fact] + public void TheEffectiveMaskIsTheColorMaskOnlyWhereTheDrawBufferIsOnAndTheOutputWritten() + { + var tracker = new GlStateTracker(); + tracker.SetColorMask(true, true, false, true); + uint written = GlStateTracker.OutputBits(new HashSet { 0, 1, 2 }); + Assert.Equal(0b111u, written); + + ColorComponentFlags rgA = ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.ABit; + Assert.Equal(rgA, tracker.EffectiveWriteMask(0, 0b101, written)); + Assert.Equal((ColorComponentFlags)0, tracker.EffectiveWriteMask(1, 0b101, written)); // draw buffer off + Assert.Equal(rgA, tracker.EffectiveWriteMask(2, 0b101, written)); + Assert.Equal((ColorComponentFlags)0, tracker.EffectiveWriteMask(3, 0b1111, written)); // not written + } + + /// The key tier with every draw buffer selected keys exactly as before C4. + [Fact] + public void TheKeyTierWithAllDrawBuffersKeysLikeTheLegacyBlendId() + { + var tracker = new GlStateTracker(); + tracker.SetBlend(true, EnumBlendMode.Standard); + tracker.SetAttachmentBlendFunc(1, 1, 1, 1, 1); + + Assert.Equal(tracker.BlendId(3), tracker.PipelineBlendId(3, uint.MaxValue)); + Assert.Equal(tracker.BuildKey(1, 2, 3), tracker.BuildKey(1, 2, 3, 0b111)); + Assert.NotEqual(tracker.BuildKey(1, 2, 3), tracker.BuildKey(1, 2, 3, 0b011)); + Assert.Equal((ColorComponentFlags)0, tracker.PipelineBlendFor(2, 0b011).WriteMask); + Assert.Equal(Rgba, tracker.PipelineBlendFor(1, 0b011).WriteMask); + } + + [Fact] + public void TheEnableTierKeepsTheKeyAcrossDrawBufferChanges() + { + var tracker = new GlStateTracker { ColorWriteTier = ColorWriteTier.DynamicEnable }; + tracker.SetBlend(true, EnumBlendMode.Standard); + + PipelineKey all = tracker.BuildKey(1, 2, 3, 0b111); + Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b011)); + Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b100)); + Assert.Equal(Rgba, tracker.PipelineBlendFor(2, 0b011).WriteMask); + + // glColorMask stays baked in this tier. + tracker.SetColorMask(true, true, true, false); + Assert.NotEqual(all, tracker.BuildKey(1, 2, 3, 0b111)); + } + + [Fact] + public void TheMaskTierKeepsTheKeyAcrossDrawBufferAndColorMaskChanges() + { + var tracker = new GlStateTracker { ColorWriteTier = ColorWriteTier.DynamicMask }; + tracker.SetBlend(true, EnumBlendMode.Standard); + + PipelineKey all = tracker.BuildKey(1, 2, 3, 0b111); + Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b001)); + tracker.SetColorMask(false, false, false, false); + Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b001)); + + // Blend factors stay in the key unless blend is dynamic too. + tracker.SetAttachmentBlendFunc(2, 1, 1, 1, 1); + PipelineKey additive = tracker.BuildKey(1, 2, 3, 0b111); + Assert.NotEqual(all, additive); + + tracker.DynamicBlend = true; + PipelineKey dynamicBlend = tracker.BuildKey(1, 2, 3, 0b111); + tracker.SetBlend(false, EnumBlendMode.Glow); + tracker.SetAttachmentBlendEquation(0, 0x800A); + Assert.Equal(dynamicBlend, tracker.BuildKey(1, 2, 3, 0b010)); + } + + [Fact] + public void ColorWriteAndBlendChangesAreTheirOwnDirtyBits() + { + var cache = new DynamicStateCache(); + var values = new DynamicStateValues { ColorWrite = 0b011, BlendStateId = 4, LineWidth = 1f }; + + Assert.Equal(DynamicStateDirty.Everything, cache.Update(7, values)); + Assert.Equal(DynamicStateDirty.None, cache.Update(7, values)); + + values.ColorWrite = 0b111; + Assert.Equal(DynamicStateDirty.ColorWrite, cache.Update(7, values)); + + values.BlendStateId = 5; + Assert.Equal(DynamicStateDirty.ColorBlend, cache.Update(7, values)); + + // The core set is unchanged: the per-draw constant still counts only it. + Assert.Equal(VulkanStats.DynamicStateCommandsPerDraw, DynamicStateCache.CommandCount(DynamicStateDirty.All)); + Assert.Equal(DynamicStateDirty.None, DynamicStateDirty.All & (DynamicStateDirty.ColorWrite | DynamicStateDirty.ColorBlend)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs new file mode 100644 index 00000000..a299019f --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs @@ -0,0 +1,371 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 2 contract C4 on a real device, once per colour write tier (forced the +/// way OPTIMUM_VULKAN_COLOR_WRITE_TIER forces it, through the context options): +/// draw buffers and the TAA motion windows are write masks inside one rendering +/// scope, never scope restarts. Every test reads back only after the draws, in +/// the frame, and asserts bytes rather than approximations. +/// +public class MotionWindowTests +{ + private readonly ITestOutputHelper _output; + + public MotionWindowTests(ITestOutputHelper output) => _output = output; + + private const int Size = 8; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + private static string FourOutputs(string colour, string glow, string motion, string extra) => $$""" + #version 330 core + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + layout(location = 2) out vec4 outMotion; + layout(location = 3) out vec4 outExtra; + void main(void) + { + outColor = vec4({{colour}}); + outGlow = vec4({{glow}}); + outMotion = vec4({{motion}}); + outExtra = vec4({{extra}}); + } + """; + + /// The OPTIMUM_VULKAN_COLOR_WRITE_TIER tokens. + public static TheoryData Tiers => new() { "enable", "mask", "pipeline" }; + + private static ColorWriteTier Tier(string token) => + DeviceCaps.ParseColorWriteTier(token) ?? throw new ArgumentException("unknown tier " + token); + + private bool TryCreateDevice(ColorWriteTier tier, out VulkanDevice? device) + { + VulkanDevice created = GpuTest.NewDevice(); + Action? suite = created.ConfigureContextOptions; + created.ConfigureContextOptions = options => + { + suite?.Invoke(options); + options.ColorWriteTier = tier; + }; + + if (!created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + _output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + if (created.ColorWriteTierForTests != tier) + { + _output.WriteLine("device lacks tier " + tier + "; selected " + created.ColorWriteTierForTests); + created.Dispose(); + device = null; + return false; + } + device = created; + return true; + } + + private sealed class Scene + { + public int Framebuffer; + public int Colour; + public int Glow; + public int Motion; + public int Extra; + } + + /// Colour and glow RGBA8, motion RGBA16F, a fourth RGBA8 slot: Primary's shape with TAA on. + private static Scene CreateScene(VulkanDevice seam) + { + var scene = new Scene + { + Colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + Glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + Motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + Extra = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }; + scene.Framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment0, scene.Colour, 0); + seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment1, scene.Glow, 0); + seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment2, scene.Motion, 0); + seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment3, scene.Extra, 0); + Assert.True(seam.CheckFramebufferComplete(scene.Framebuffer, out string status), status); + return scene; + } + + private static void BaseState(VulkanDevice seam) + { + seam.SetViewport(0, 0, Size, Size); + seam.SetScissorEnabled(false); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetColorMask(true, true, true, true); + } + + /// A frame that clears every attachment to a known value, with every draw buffer selected. + private static void SeedFrame(VulkanDevice seam, Scene scene) + { + seam.BeginFrame(); + seam.BindFramebuffer(scene.Framebuffer); + BaseState(seam); + seam.SetDrawBuffers(scene.Framebuffer, 0b1111); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0.2f, 0.4f, 0.6f, 1f); + seam.ClearColor(2, 0.125f, 0.25f, 0.375f, 0.5f); + seam.ClearColor(3, 0.4f, 0.6f, 0.8f, 1f); + seam.Present(); + } + + private static void AssertEveryPixel(byte[] texels, int bytesPerPixel, byte[] expected, string what) + { + Assert.Equal(Size * Size * bytesPerPixel, texels.Length); + for (int p = 0; p < texels.Length; p += bytesPerPixel) + { + for (int c = 0; c < bytesPerPixel; c++) + { + if (texels[p + c] != expected[c]) + { + Assert.Fail($"{what}: pixel {p / bytesPerPixel} byte {c} is {texels[p + c]}, expected {expected[c]}"); + } + } + } + } + + private static byte[] Half(float r, float g, float b, float a) + { + var bytes = new byte[8]; + BitConverter.TryWriteBytes(bytes.AsSpan(0, 2), (Half)r); + BitConverter.TryWriteBytes(bytes.AsSpan(2, 2), (Half)g); + BitConverter.TryWriteBytes(bytes.AsSpan(4, 2), (Half)b); + BitConverter.TryWriteBytes(bytes.AsSpan(6, 2), (Half)a); + return bytes; + } + + /// + /// A pass over Primary with the default colour set, a motion window (all + /// three), a motion-only window, a restore, a masked-out clear and an + /// all-false colour-mask clear. The fourth slot never has its draw buffer on + /// in the pass, though every program writes it: bit-identical to the previous + /// frame's seed. The motion-only draw writes exactly the motion attachment. + /// All of it in one rendering scope; no restart from a mask change. + /// + [SkippableTheory] + [MemberData(nameof(Tiers))] + public void MotionWindowsAreWriteMasksInsideOneScope(string tierToken) + { + ColorWriteTier tier = Tier(tierToken); + Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable."); + using (device) + { + VulkanDevice seam = device!; + int opaque = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, + FourOutputs("1.0, 0.0, 0.0, 1.0", "0.0, 1.0, 0.0, 1.0", "0.75, 0.75, 0.75, 0.75", "1.0, 1.0, 1.0, 1.0"), + "mw-opaque"); + int motionOnly = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, + FourOutputs("0.0, 0.0, 1.0, 1.0", "0.0, 0.0, 1.0, 1.0", "1.0, 0.5, 0.0, 1.0", "0.0, 0.0, 0.0, 0.0"), + "mw-motion-only"); + Scene scene = CreateScene(seam); + SeedFrame(seam, scene); + + seam.BeginFrame(); + seam.BindFramebuffer(scene.Framebuffer); + BaseState(seam); + long scopesBefore = device!.ScopesOpenedForTests; + + // Default colour set: colour and glow; motion and extra masked. + seam.SetDrawBuffers(scene.Framebuffer, 0b0011); + seam.UseProgram(opaque); + seam.DrawFullscreenTriangle(); + + // Motion window (EnableMotionDrawBuffers), then the motion-only window. + seam.SetDrawBuffers(scene.Framebuffer, 0b0111); + seam.DrawFullscreenTriangle(); + seam.SetDrawBuffers(scene.Framebuffer, 0b0100); + seam.UseProgram(motionOnly); + seam.DrawFullscreenTriangle(); + + // Restore, then two clears that must not land: motion masked out, and + // colour through an all-false glColorMask. + seam.SetDrawBuffers(scene.Framebuffer, 0b0011); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.SetColorMask(false, false, false, false); + seam.ClearColor(0, 0f, 0f, 1f, 1f); + seam.SetColorMask(true, true, true, true); + seam.UseProgram(opaque); + seam.SetDrawBuffers(scene.Framebuffer, 0b0001); + seam.DrawFullscreenTriangle(); + + long scopes = device.ScopesOpenedForTests - scopesBefore; + long maskRestarts = device.MaskRestartsForTests; + long splits = device.FeedbackSplitsForTests; + + byte[] colour = device.ReadBackLevel0ForTests(scene.Colour); + byte[] glow = device.ReadBackLevel0ForTests(scene.Glow); + byte[] motion = device.ReadBackLevel0ForTests(scene.Motion); + byte[] extra = device.ReadBackLevel0ForTests(scene.Extra); + seam.Present(); + + _output.WriteLine($"tier={tier} scopes={scopes} mask_restarts={maskRestarts} feedback_splits={splits}"); + Assert.Equal(1, scopes); + Assert.Equal(0, maskRestarts); + Assert.Equal(0, splits); + + AssertEveryPixel(colour, 4, new byte[] { 255, 0, 0, 255 }, "colour"); + AssertEveryPixel(glow, 4, new byte[] { 0, 255, 0, 255 }, "glow"); + AssertEveryPixel(motion, 8, Half(1f, 0.5f, 0f, 1f), "motion (motion-only window wrote it last)"); + AssertEveryPixel(extra, 4, new byte[] { 102, 153, 204, 255 }, "extra (draw buffer never on)"); + + GpuTest.AssertClean(seam); + } + } + + /// + /// The OIT merge: standard blending on colour, additive (ONE, ONE) on the + /// motion attachment (ApplyOptimumMotionAccumulateBlendState), a program that + /// adds only to motion's blue. Red, green and alpha of the RGBA16F motion + /// texels stay bit-exact; glow, which the program does not write, is untouched. + /// + [SkippableTheory] + [MemberData(nameof(Tiers))] + public void OitMergeAdditiveOnMotionLeavesRedGreenAndAlphaBitExact(string tierToken) + { + ColorWriteTier tier = Tier(tierToken); + Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable."); + using (device) + { + VulkanDevice seam = device!; + int merge = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + layout(location = 2) out vec4 outMotion; + void main(void) + { + outColor = vec4(1.0, 1.0, 1.0, 0.5); + outMotion = vec4(0.0, 0.0, 0.25, 0.0); + } + """, "mw-oit-merge"); + Scene scene = CreateScene(seam); + SeedFrame(seam, scene); + + seam.BeginFrame(); + seam.BindFramebuffer(scene.Framebuffer); + BaseState(seam); + seam.SetDrawBuffers(scene.Framebuffer, 0b0111); + seam.UseProgram(merge); + // ApplyTransparentMergeBlendState, then the motion accumulate override. + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetBlendFuncSeparate(0, 770, 771, 770, 771); + seam.SetBlendEquation(2, 32774); + seam.SetBlendFuncSeparate(2, 1, 1, 1, 1); + seam.DrawFullscreenTriangle(); + + // The accumulate override must not outlive the draw: a second draw with + // replace blending on motion keeps the scope and rewrites blue only. + seam.SetBlendFuncSeparate(2, 1, 0, 1, 0); + seam.SetDrawBuffers(scene.Framebuffer, 0b0001); + seam.DrawFullscreenTriangle(); + + long maskRestarts = device!.MaskRestartsForTests; + byte[] colour = device.ReadBackLevel0ForTests(scene.Colour); + byte[] glow = device.ReadBackLevel0ForTests(scene.Glow); + byte[] motion = device.ReadBackLevel0ForTests(scene.Motion); + seam.Present(); + + Assert.Equal(0, maskRestarts); + byte[] seed = Half(0.125f, 0.25f, 0.375f, 0.5f); + byte[] merged = Half(0.125f, 0.25f, 0.625f, 0.5f); + for (int p = 0; p < motion.Length; p += 8) + { + Assert.Equal(seed.AsSpan(0, 4).ToArray(), motion.AsSpan(p, 4).ToArray()); // red, green + Assert.Equal(seed.AsSpan(6, 2).ToArray(), motion.AsSpan(p + 6, 2).ToArray()); // alpha + } + AssertEveryPixel(motion, 8, merged, "motion after additive merge"); + AssertEveryPixel(glow, 4, new byte[] { 51, 102, 153, 255 }, "glow (not written by the merge)"); + + // Colour: two draws of (1,1,1,0.5) over black with SRC_ALPHA blending. + Assert.InRange(colour[0], 189, 193); + Assert.Equal(colour[0], colour[1]); + + GpuTest.AssertClean(seam); + } + } + + /// + /// The final composition shape: draw buffers select Primary 0 only while the + /// program samples Primary 1. The sampled slot leaves the scope (one feedback + /// split), colour receives glow's texels exactly, glow keeps them; selecting + /// glow again lets it rejoin and a write lands. Validation stays clean. + /// + [SkippableTheory] + [MemberData(nameof(Tiers))] + public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken) + { + ColorWriteTier tier = Tier(tierToken); + Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable."); + using (device) + { + VulkanDevice seam = device!; + int compose = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D glowTex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = texture(glowTex, uv); } + """, "mw-compose"); + int writeGlow = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, + FourOutputs("0.0, 0.0, 0.0, 1.0", "1.0, 0.0, 0.0, 1.0", "0.0, 0.0, 0.0, 0.0", "0.0, 0.0, 0.0, 0.0"), + "mw-write-glow"); + Scene scene = CreateScene(seam); + SeedFrame(seam, scene); + + seam.BeginFrame(); + seam.BindFramebuffer(scene.Framebuffer); + BaseState(seam); + seam.SetDrawBuffers(scene.Framebuffer, 0b0001); + seam.ClearColor(0, 0f, 0f, 0f, 1f); // opens the scope with glow in it, masked + seam.UseProgram(compose); + seam.SetSamplerUnit(compose, "glowTex", 0); + seam.BindTexture(0, scene.Glow); + seam.DrawFullscreenTriangle(); + byte[] composed = device!.ReadBackLevel0ForTests(scene.Colour); + byte[] glowAfterCompose = device.ReadBackLevel0ForTests(scene.Glow); + long splitsAfterCompose = device.FeedbackSplitsForTests; + + seam.BindTexture(0, 0); + seam.BindFramebuffer(scene.Framebuffer); + seam.SetDrawBuffers(scene.Framebuffer, 0b0010); + seam.UseProgram(writeGlow); + seam.DrawFullscreenTriangle(); + long maskRestarts = device.MaskRestartsForTests; + byte[] glowAfterWrite = device.ReadBackLevel0ForTests(scene.Glow); + seam.Present(); + + _output.WriteLine($"tier={tier} splits_after_compose={splitsAfterCompose} mask_restarts={maskRestarts}"); + Assert.Equal(1, splitsAfterCompose); + Assert.Equal(0, maskRestarts); + AssertEveryPixel(composed, 4, new byte[] { 51, 102, 153, 255 }, "colour = sampled glow"); + AssertEveryPixel(glowAfterCompose, 4, new byte[] { 51, 102, 153, 255 }, "glow untouched by composition"); + AssertEveryPixel(glowAfterWrite, 4, new byte[] { 255, 0, 0, 255 }, "glow written after it rejoined"); + + GpuTest.AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs index 743e5130..0932c57e 100644 --- a/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs @@ -113,7 +113,7 @@ public void RepeatedIdenticalStateRecordsDynamicStateOncePerRecording() int red = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, SolidFragment("1.0, 0.0, 0.0, 1.0"), "dyn-red"); int a = CreateTarget(seam); int b = CreateTarget(seam); - int all = VulkanStats.DynamicStateCommandsPerDraw; + int all = device!.DynamicStateCommandsPerDrawForTests; seam.BeginFrame(); seam.BindFramebuffer(a); diff --git a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs index 06847a1e..cc6168ec 100644 --- a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs +++ b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs @@ -14,11 +14,11 @@ namespace Optimum.Render.Vulkan.Tests; /// /// Covers render targets, and specifically the semantics of glDrawBuffers. /// -/// This is the least obvious behaviour in the whole backend. glDrawBuffers does -/// not mask writes, it selects which attachments take part, and the game leans on -/// that: the final composition pass renders into the primary framebuffer's -/// attachment 0 while sampling its attachment 1. Reproducing it as a write mask -/// would either corrupt the glow buffer or trip a feedback-loop error. +/// This is the least obvious behaviour in the whole backend. Since Phase 2 (C4) +/// glDrawBuffers is a write mask inside a scope that keeps every bound attachment; +/// the game's one read of an excluded slot (the final composition pass renders into +/// Primary 0 while sampling Primary 1) takes that slot out of the scope instead of +/// tripping a feedback-loop error. MotionWindowTests covers the pixels per tier. /// public class RenderTargetTests { @@ -162,11 +162,13 @@ void main(void) } /// - /// Changing the draw-buffer mask changes which attachments participate, so - /// the open scope no longer describes the target and has to be restarted. + /// Phase 2 (C4): a draw-buffer change is a write-mask change. The scope keeps + /// every bound attachment, so changing the mask never restarts it; sampling a + /// slot whose draw buffer is off takes that slot out (one feedback split), and + /// selecting it again lets it rejoin (another). /// [SkippableFact] - public unsafe void ChangingTheDrawBufferMaskRestartsTheRenderingScope() + public unsafe void ChangingTheDrawBufferMaskKeepsTheRenderingScope() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); @@ -197,9 +199,34 @@ public unsafe void ChangingTheDrawBufferMaskRestartsTheRenderingScope() targets.EnsureRendering(commandBuffer); Assert.Equal(1, targets.ScopesOpened); + // Mask changes, back and forth: still the one scope. targets.SetDrawBuffers(framebuffer, 0b11); targets.EnsureRendering(commandBuffer); + targets.SetDrawBuffers(framebuffer, 0b01); + targets.EnsureRendering(commandBuffer); + targets.SetDrawBuffers(framebuffer, 0b10); + targets.EnsureRendering(commandBuffer); + Assert.Equal(1, targets.ScopesOpened); + Assert.Equal(0, targets.MaskRestarts); + Assert.Equal(0, targets.FeedbackSplits); + + // Sampling b while its draw buffer is off takes it out of the scope. + targets.SetDrawBuffers(framebuffer, 0b01); + targets.ExcludeSampledAttachment(commandBuffer, b); + Assert.False(targets.RenderingActive); + textures.TransitionTexture(commandBuffer, textures.Get(b)!, ImageLayout.ShaderReadOnlyOptimal); + targets.EnsureRendering(commandBuffer); Assert.Equal(2, targets.ScopesOpened); + Assert.Equal(1, targets.FeedbackSplits); + Assert.Equal(1, targets.EnabledAttachmentCount(targets.Get(framebuffer)!)); + + // Selecting b again lets it rejoin. + targets.SetDrawBuffers(framebuffer, 0b11); + targets.EnsureRendering(commandBuffer); + Assert.Equal(3, targets.ScopesOpened); + Assert.Equal(2, targets.FeedbackSplits); + Assert.Equal(2, targets.EnabledAttachmentCount(targets.Get(framebuffer)!)); + Assert.Equal(0, targets.MaskRestarts); targets.EndRendering(commandBuffer); }); @@ -212,11 +239,12 @@ public unsafe void ChangingTheDrawBufferMaskRestartsTheRenderingScope() /// /// The attachment formats fed to the pipeline must match the attachments the - /// scope was opened with, including the gaps: a disabled slot is Undefined, - /// which keeps fragment output N aimed at slot N. + /// scope was opened with. Since Phase 2 (C4) that is every bound slot whatever + /// its draw buffer, so the formats id is stable across mask toggles; only a + /// sample-excluded or unbound slot is Undefined, keeping output N aimed at slot N. /// [SkippableFact] - public void DisabledAttachmentsReportAnUndefinedFormatToThePipeline() + public void DrawBufferMasksDoNotChangeTheFormatsThePipelineSees() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); @@ -234,17 +262,33 @@ public void DisabledAttachmentsReportAnUndefinedFormatToThePipeline() targets.Attach(framebuffer, i, textures.Create(8, 8, Format.R8G8B8A8Unorm)); } + VulkanFramebuffer bound = targets.Get(framebuffer)!; + targets.SetDrawBuffers(framebuffer, 0b1111); + int allSelected = targets.FormatsIdOf(bound); + // The OIT pass draws to 0 and 3 while leaving 1 and 2 out. targets.SetDrawBuffers(framebuffer, 0b1001); - - VulkanFramebuffer bound = targets.Get(framebuffer)!; RenderTargetFormats formats = state.TargetFormats(targets.FormatsIdOf(bound)); + Assert.Equal(allSelected, targets.FormatsIdOf(bound)); Assert.Equal(4, formats.ColorFormats.Length); - Assert.Equal(Format.R8G8B8A8Unorm, formats.ColorFormats[0]); - Assert.Equal(Format.Undefined, formats.ColorFormats[1]); - Assert.Equal(Format.Undefined, formats.ColorFormats[2]); - Assert.Equal(Format.R8G8B8A8Unorm, formats.ColorFormats[3]); + Assert.All(formats.ColorFormats, format => Assert.Equal(Format.R8G8B8A8Unorm, format)); + + // A slot a draw samples with its draw buffer off leaves the scope: Undefined. + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.ExcludeSampledAttachment(commandBuffer, bound.Color[2].TextureId); + }); + RenderTargetFormats excluded = state.TargetFormats(targets.FormatsIdOf(bound)); + Assert.Equal(4, excluded.ColorFormats.Length); + Assert.Equal(Format.R8G8B8A8Unorm, excluded.ColorFormats[1]); + Assert.Equal(Format.Undefined, excluded.ColorFormats[2]); + Assert.Equal(Format.R8G8B8A8Unorm, excluded.ColorFormats[3]); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); } } diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs index 509c0791..e887e4c2 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -427,10 +427,29 @@ public PipelineKey BuildKey(int vertexLayoutId, int targetFormatsId, int attachm /// device sets it from the context's selected tier; component tests keep the /// default, which bakes everything into the pipeline key. /// - public ColorWriteTier ColorWriteTier { get; set; } = ColorWriteTier.PipelineKey; + public ColorWriteTier ColorWriteTier + { + get => _colorWriteTier; + set + { + _colorWriteTier = value; + InvalidateBlend(); + } + } + + private ColorWriteTier _colorWriteTier = ColorWriteTier.PipelineKey; + private bool _dynamicBlend; /// With the mask tier: blend enable and equation are dynamic too, so the blend set leaves the key. - public bool DynamicBlend { get; set; } + public bool DynamicBlend + { + get => _dynamicBlend; + set + { + _dynamicBlend = value; + InvalidateBlend(); + } + } /// The global glColorMask. public ColorComponentFlags ColorMask => _colorWriteMask; diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index cc5c56ca..5a562169 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -2570,6 +2570,15 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta VulkanStats.NoteDynamicStateCommands(emitted); } + /// + /// Commands the first draw of a recording emits: the core set plus the colour + /// write state of the tier (one command; two more with dynamic blend). Tests only. + /// + internal int DynamicStateCommandsPerDrawForTests => + VulkanStats.DynamicStateCommandsPerDraw + + (_context.Capabilities.ColorWriteTier == ColorWriteTier.PipelineKey ? 0 : 1) + + (_context.Capabilities.DynamicColorBlend ? 2 : 0); + /// The colour write tier this device's draws use. Tests only. internal ColorWriteTier ColorWriteTierForTests => _context.Capabilities.ColorWriteTier; diff --git a/Optimum.Tests/color-write-tier-coverage-tests.cs b/Optimum.Tests/color-write-tier-coverage-tests.cs new file mode 100644 index 00000000..f09974d6 --- /dev/null +++ b/Optimum.Tests/color-write-tier-coverage-tests.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Phase 2 contract C4 (colour write masks) at source level: the optional +/// extensions are negotiated with an env override, draws emit the tier's dynamic +/// state, draw buffers never restart a scope, and a masked-out clear stays a no-op. +/// The pixels are proven per tier by Optimum.Render.Vulkan.Tests/MotionWindowTests.cs. +/// +public class ColorWriteTierCoverageTests +{ + [Fact] + public void TheContextNegotiatesTheTiersWithAnOverride() + { + string tiers = Read("Optimum.Render.Vulkan/Core/ColorWriteTier.cs"); + Assert.Contains("OPTIMUM_VULKAN_COLOR_WRITE_TIER", tiers); + Assert.Contains("public static ColorWriteTier SelectColorWriteTier(", tiers); + + string context = Read("Optimum.Render.Vulkan/Core/VulkanContext.cs"); + Assert.Contains("\"VK_EXT_color_write_enable\"", context); + Assert.Contains("\"VK_EXT_extended_dynamic_state3\"", context); + Assert.Contains("ExtendedDynamicState3ColorWriteMask = true,", context); + Assert.Contains("options.ColorWriteTier ?? DeviceCaps.FromEnvironment()", context); + } + + [Fact] + public void DrawsEmitTheTiersColourWriteState() + { + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("CmdSetColorWriteEnable(commandBuffer,", device); + Assert.Contains("CmdSetColorWriteMask(commandBuffer, 0,", device); + Assert.Contains("CmdSetColorBlendEquation(commandBuffer, 0,", device); + Assert.Contains("_state.BuildKey(layoutId, formatsId, attachmentCount, drawBuffers)", device); + Assert.Contains("_targets.ExcludeSampledAttachment(commandBuffer, _boundTextures[unit]);", device); + + string cache = Read("Optimum.Render.Vulkan/Core/PipelineCache.cs"); + Assert.Contains("DynamicState.ColorWriteEnableExt", cache); + Assert.Contains("DynamicState.ColorWriteMaskExt", cache); + // The undeclared-output masking is kept on every tier. + Assert.Contains("request.Program.Interface.WrittenFragmentOutputs.Contains(i)", cache); + } + + [Fact] + public void DrawBufferChangesNeverRestartTheScope() + { + string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); + int start = targets.IndexOf("public void SetDrawBuffers(int framebufferId, uint mask)", StringComparison.Ordinal); + int end = targets.IndexOf("public void ExcludeSampledAttachment(", start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + string setDrawBuffers = targets.Substring(start, end - start); + // The only restart is a sample-excluded slot rejoining. + Assert.Contains("uint rejoining = framebuffer.SampledExclusion & mask;", setDrawBuffers); + Assert.Contains("if (rejoining == 0) return;", setDrawBuffers); + + Assert.Contains("VulkanStats.NoteMaskRestart();", targets); + Assert.Contains("if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return;", targets); + Assert.Contains("if (_state.ColorMask == 0) return;", targets); + + string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); + Assert.Contains("mask_restarts={8} feedback_splits={9}", stats); + } + + private static string Read(string relativePath) + { + string? directory = AppContext.BaseDirectory; + while (directory != null && !File.Exists(Path.Combine(directory, "VintageStory.slnx"))) + { + directory = Path.GetDirectoryName(directory); + } + Assert.NotNull(directory); + return File.ReadAllText(Path.Combine(directory!, relativePath)); + } +} diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 5bc144d3..0f3598d7 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -261,7 +261,7 @@ unchanged from earlier builds; the other four carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= mask_restarts= feedback_splits= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... ``` @@ -282,7 +282,12 @@ stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar asked for the ReBAR pool class and fell through to host staging memory because no ReBAR type exists, the cap was reached or `OPTIMUM_VULKAN_NO_REBAR=1`; each is also logged), `dynamic_state` (dynamic-state commands), `uniform_ring_used` (peak bytes one frame - slot used) and `uniform_ring_capacity` (bytes per slot). + slot used), `uniform_ring_capacity` (bytes per slot), `mask_restarts` (scope restarts that + reopened an identical attachment set; draw buffers and motion windows are write masks since + Phase 2 contract C4, so this must be 0) and `feedback_splits` (restarts that took a sampled, + draw-buffer-excluded slot out of the scope, as the final composition does with Primary 1, or + let it rejoin). The colour write tier is on the device-up validation log line; + `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. - `stats.memory`, a snapshot at sample time (Phase 1B step 5): `blocks` (live device allocations the allocator holds), `dedicated` (of them, one-resource blocks), `rebar_used` and `rebar_cap` (ReBAR class bytes and its cap, min(192 MiB, heap budget x 0.25)), `rebar_misses` From 4d18f3ff74cac1ffc89bdc652f50a407a2d300fc Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:16:20 +0200 Subject: [PATCH 112/226] wip(phase2-write-masks): dynamic-state unit tests expect Everything on a fresh recording Full GPU suite before this: 519 passed, 2 failed (these two assertions). --- Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs index 8fd01e62..aadcb0f8 100644 --- a/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs @@ -162,7 +162,7 @@ public void RepeatedIdenticalStateEmitsNothing() var cache = new DynamicStateCache(); DynamicStateValues values = Values(); - Assert.Equal(DynamicStateDirty.All, cache.Update(1, values)); + Assert.Equal(DynamicStateDirty.Everything, cache.Update(1, values)); for (int i = 0; i < 10; i++) Assert.Equal(DynamicStateDirty.None, cache.Update(1, values)); } @@ -199,16 +199,16 @@ public void ANewRecordingInvalidationOrDisabledCacheEmitsEverything() DynamicStateValues values = Values(); cache.Update(1, values); - Assert.Equal(DynamicStateDirty.All, cache.Update(2, values)); - Assert.Equal(DynamicStateDirty.All, cache.Update(0, values)); - Assert.Equal(DynamicStateDirty.All, cache.Update(0, values)); + Assert.Equal(DynamicStateDirty.Everything, cache.Update(2, values)); + Assert.Equal(DynamicStateDirty.Everything, cache.Update(0, values)); + Assert.Equal(DynamicStateDirty.Everything, cache.Update(0, values)); cache.Update(3, values); cache.Invalidate(); - Assert.Equal(DynamicStateDirty.All, cache.Update(3, values)); + Assert.Equal(DynamicStateDirty.Everything, cache.Update(3, values)); cache.Enabled = false; - Assert.Equal(DynamicStateDirty.All, cache.Update(3, values)); + Assert.Equal(DynamicStateDirty.Everything, cache.Update(3, values)); } } From 4906a9e5c83f250bfc08a116fa0c066c697b4f66 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:17:45 +0200 Subject: [PATCH 113/226] wip(phase2-barriers): ResourceStateTracker + BarrierBatcher drive every image barrier with usage-derived stages Contract C1 (Optimum.Render.Vulkan/Graph): ResourceUsage, UsageState.For, ResourceStateTracker (per-subresource layout, write stage/access, visibility, read stages; RAW/WAR/WAW; one entry per image, split/merge on sub-ranges), BarrierBatcher (one CmdPipelineBarrier2 per Flush, debug throw inside an open scope). TextureManager, RenderTargetManager (one flush per scope), sampled textures and feedback snapshots, the blit present path (acquire wait stage on the source side) and staged buffer copies (reader stages from buffer usage) no longer use ALL_COMMANDS. Stats: barrier_commands, barriers_per_frame. Verified: dotnet test Optimum.Render.Vulkan.Tests (sync,best) 525/525 passed; representative frame 18 image barriers / 18 commands over 3 frames at f187375, same pixels now. --- .../BarrierBatcherFrameTests.cs | 187 +++++++++++ .../FrameGraphBarrierTests.cs | 312 ++++++++++++++++++ .../PacingStatsTests.cs | 4 +- .../Core/RenderTargetManager.cs | 17 +- Optimum.Render.Vulkan/Core/TextureManager.cs | 147 +++++---- Optimum.Render.Vulkan/Core/VulkanResources.cs | 4 + Optimum.Render.Vulkan/Core/VulkanStats.cs | 20 +- Optimum.Render.Vulkan/Graph/BarrierBatcher.cs | 142 ++++++++ .../Graph/ResourceStateTracker.cs | 261 +++++++++++++++ Optimum.Render.Vulkan/Graph/ResourceUsage.cs | 189 +++++++++++ Optimum.Render.Vulkan/Present/IPresentPath.cs | 55 ++- .../Transfer/UploadManager.cs | 10 +- Optimum.Render.Vulkan/VulkanDevice.cs | 31 +- .../vulkan-backend-integration-tests.cs | 45 ++- docs/taa-acceptance.md | 7 +- 15 files changed, 1303 insertions(+), 128 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/BarrierBatcherFrameTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/FrameGraphBarrierTests.cs create mode 100644 Optimum.Render.Vulkan/Graph/BarrierBatcher.cs create mode 100644 Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs create mode 100644 Optimum.Render.Vulkan/Graph/ResourceUsage.cs diff --git a/Optimum.Render.Vulkan.Tests/BarrierBatcherFrameTests.cs b/Optimum.Render.Vulkan.Tests/BarrierBatcherFrameTests.cs new file mode 100644 index 00000000..6aca61af --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/BarrierBatcherFrameTests.cs @@ -0,0 +1,187 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using static Optimum.Render.Vulkan.Tests.GpuTest; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// A representative frame through the device: a primary target with two colour +/// attachments and depth, a composition pass that writes attachment 0 while +/// sampling attachment 1, and an output pass that samples the scene colour and +/// the depth. Measures the image barriers and barrier commands per frame in +/// steady state. +/// +public class BarrierBatcherFrameTests +{ + private readonly ITestOutputHelper _output; + + public BarrierBatcherFrameTests(ITestOutputHelper output) => _output = output; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.5, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + internal readonly record struct FrameCounts(long ImageBarriers, long BarrierCommands, byte[] Pixels); + + internal static unsafe FrameCounts RenderRepresentativeFrames(VulkanDevice seam, int warmup, int measured) + { + const int size = 16; + + int sceneProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + in vec2 uv; + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + void main(void) + { + outColor = vec4(40.0 / 255.0, 90.0 / 255.0, 160.0 / 255.0, 1.0); + outGlow = vec4(20.0 / 255.0, 0.0, 0.0, 1.0); + } + """, "scene"); + int composeProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D glow; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(texture(glow, uv).r + 40.0 / 255.0, 90.0 / 255.0, 160.0 / 255.0, 1.0); } + """, "compose"); + int outputProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D scene; + uniform sampler2D depthTex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(texture(scene, uv).rgb, texture(depthTex, uv).r); } + """, "output"); + + int Texture(EnumTextureInternalFormat format) => seam.CreateTexture2D( + size, size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + int scene = Texture(EnumTextureInternalFormat.Rgba8); + int glow = Texture(EnumTextureInternalFormat.Rgba8); + int depth = Texture(EnumTextureInternalFormat.DepthComponent32); + int final = Texture(EnumTextureInternalFormat.Rgba8); + + int primary = seam.CreateFramebuffer(size, size); + seam.AttachTexture(primary, EnumFramebufferAttachment.ColorAttachment0, scene, 0); + seam.AttachTexture(primary, EnumFramebufferAttachment.ColorAttachment1, glow, 0); + seam.AttachTexture(primary, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(primary, 0b11); + + int output = seam.CreateFramebuffer(size, size); + seam.AttachTexture(output, EnumFramebufferAttachment.ColorAttachment0, final, 0); + seam.SetDrawBuffers(output, 0b1); + + long barriersBefore = 0; + long commandsBefore = 0; + for (int frame = 0; frame < warmup + measured; frame++) + { + if (frame == warmup) + { + barriersBefore = VulkanStats.ImageBarriers; + commandsBefore = BarrierCommands(); + } + + seam.BeginFrame(); + + seam.BindFramebuffer(primary); + seam.SetDrawBuffers(primary, 0b11); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x203); + seam.ClearDepth(1f); + seam.ClearColor(0, 0, 0, 0, 0); + seam.ClearColor(1, 0, 0, 0, 0); + seam.UseProgram(sceneProgram); + seam.DrawFullscreenTriangle(); + + // Composition: write attachment 0, sample attachment 1. + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetDrawBuffers(primary, 0b1); + seam.UseProgram(composeProgram); + seam.SetSamplerUnit(composeProgram, "glow", 0); + seam.BindTexture(0, glow); + seam.DrawFullscreenTriangle(); + seam.SetDrawBuffers(primary, 0b11); + + // Output: sample the scene colour and the depth. + seam.BindFramebuffer(output); + seam.SetViewport(0, 0, size, size); + seam.UseProgram(outputProgram); + seam.SetSamplerUnit(outputProgram, "scene", 0); + seam.SetSamplerUnit(outputProgram, "depthTex", 1); + seam.BindTexture(0, scene); + seam.BindTexture(1, depth); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + seam.BindTexture(1, 0); + + seam.Present(); + } + + long barriers = VulkanStats.ImageBarriers - barriersBefore; + long commands = BarrierCommands() - commandsBefore; + + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(output); + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + return new FrameCounts(barriers, commands, pixels); + } + + private static long BarrierCommands() => VulkanStats.BarrierCommands; + + /// + /// Recorded at f187375 (feat/vulkan-native before Phase 2 step 1) with this + /// exact frame: 18 image barriers over 3 steady-state frames, each its own + /// vkCmdPipelineBarrier2 with ALL_COMMANDS stages (6 commands per frame). + /// Centre pixel 60,90,160,191. + /// + private const long RecordedImageBarriers = 18; + private const long RecordedBarrierCommands = 18; + + [SkippableFact] + public void ARepresentativeFrameNeedsFewerBarrierCommandsAndStaysSyncClean() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + const int measured = 3; + FrameCounts counts = RenderRepresentativeFrames(device!, warmup: 2, measured); + _output.WriteLine("image barriers over " + measured + " frames: " + counts.ImageBarriers + + " (recorded " + RecordedImageBarriers + "), barrier commands: " + + counts.BarrierCommands + " (recorded " + RecordedBarrierCommands + ")"); + + // The pixels are what they were before the barriers changed: glow + // (20) added to the scene red (40) by the composition pass, the + // scene's green and blue, and the depth (0.5 remapped to 0.75). + int centre = (16 / 2 * 16 + 16 / 2) * 4; + Assert.Equal(60, counts.Pixels[centre]); + Assert.Equal(90, counts.Pixels[centre + 1]); + Assert.Equal(160, counts.Pixels[centre + 2]); + Assert.InRange(counts.Pixels[centre + 3], (byte)189, (byte)193); + + Assert.True(counts.ImageBarriers <= RecordedImageBarriers, + "image barriers per frame grew: " + counts.ImageBarriers); + Assert.True(counts.BarrierCommands < RecordedBarrierCommands, + "barrier commands per frame did not drop: " + counts.BarrierCommands); + Assert.True(counts.BarrierCommands <= counts.ImageBarriers); + AssertClean(device!); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/FrameGraphBarrierTests.cs b/Optimum.Render.Vulkan.Tests/FrameGraphBarrierTests.cs new file mode 100644 index 00000000..e28fa3e6 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGraphBarrierTests.cs @@ -0,0 +1,312 @@ +using System.Collections.Generic; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The usage table and the barrier derivation of , +/// without a device: layout, stage and access come from the usage; read after +/// write, write after read and write after write are ordered; repeated reads and +/// repeated uses cost nothing; sub-ranges split and merge; a present ends in +/// PRESENT_SRC. +/// +public class FrameGraphBarrierTests +{ + private static List Require(ResourceStateTracker tracker, ResourceUsage usage, + bool discard = false) => + Require(tracker, 0, tracker.MipLevels, 0, tracker.Layers, usage, discard); + + private static List Require(ResourceStateTracker tracker, uint baseMip, uint mipCount, + uint baseLayer, uint layerCount, ResourceUsage usage, bool discard = false) + { + var output = new List(); + int count = tracker.Require(baseMip, mipCount, baseLayer, layerCount, usage, discard, output); + Assert.Equal(output.Count, count); + return output; + } + + [Theory] + [InlineData(ResourceUsage.ColorWrite, false, ImageLayout.ColorAttachmentOptimal, PipelineStageFlags2.ColorAttachmentOutputBit, AccessFlags2.ColorAttachmentWriteBit)] + [InlineData(ResourceUsage.ColorBlend, false, ImageLayout.ColorAttachmentOptimal, PipelineStageFlags2.ColorAttachmentOutputBit, AccessFlags2.ColorAttachmentReadBit | AccessFlags2.ColorAttachmentWriteBit)] + [InlineData(ResourceUsage.DepthWrite, true, ImageLayout.DepthAttachmentOptimal, PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.DepthStencilAttachmentWriteBit)] + [InlineData(ResourceUsage.DepthReadOnly, true, ImageLayout.DepthReadOnlyOptimal, PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit, AccessFlags2.DepthStencilAttachmentReadBit)] + [InlineData(ResourceUsage.DepthReadOnlySampled, true, ImageLayout.DepthReadOnlyOptimal, PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit | PipelineStageFlags2.FragmentShaderBit, AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.ShaderSampledReadBit)] + [InlineData(ResourceUsage.SampleFragment, false, ImageLayout.ShaderReadOnlyOptimal, PipelineStageFlags2.FragmentShaderBit, AccessFlags2.ShaderSampledReadBit)] + [InlineData(ResourceUsage.SampleFragment, true, ImageLayout.ShaderReadOnlyOptimal, PipelineStageFlags2.FragmentShaderBit, AccessFlags2.ShaderSampledReadBit)] + [InlineData(ResourceUsage.SampleVertex, false, ImageLayout.ShaderReadOnlyOptimal, PipelineStageFlags2.VertexShaderBit, AccessFlags2.ShaderSampledReadBit)] + [InlineData(ResourceUsage.StorageRead, false, ImageLayout.General, PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderStorageReadBit)] + [InlineData(ResourceUsage.TransferSrc, false, ImageLayout.TransferSrcOptimal, PipelineStageFlags2.TransferBit, AccessFlags2.TransferReadBit)] + [InlineData(ResourceUsage.TransferDst, false, ImageLayout.TransferDstOptimal, PipelineStageFlags2.TransferBit, AccessFlags2.TransferWriteBit)] + [InlineData(ResourceUsage.PresentSrc, false, ImageLayout.PresentSrcKhr, PipelineStageFlags2.BottomOfPipeBit, AccessFlags2.None)] + public void TheUsageTableDerivesLayoutStageAndAccess(ResourceUsage usage, bool depth, ImageLayout layout, + PipelineStageFlags2 stage, AccessFlags2 access) + { + Assert.Equal(new UsageState(layout, stage, access), UsageState.For(usage, depth)); + Assert.NotEqual(PipelineStageFlags2.AllCommandsBit, UsageState.For(usage, depth).Stage & PipelineStageFlags2.AllCommandsBit); + } + + [Fact] + public void AnAttachmentUsageFollowsTheImageAspect() + { + Assert.Equal(ImageLayout.DepthAttachmentOptimal, UsageState.For(ResourceUsage.ColorBlend, depth: true).Layout); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, UsageState.For(ResourceUsage.DepthWrite, depth: false).Layout); + } + + [Fact] + public void TheFirstUseStartsFromUndefinedWithNoSourceStage() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + ImageTransition only = Assert.Single(Require(tracker, ResourceUsage.TransferDst)); + Assert.Equal(ImageLayout.Undefined, only.Sides.OldLayout); + Assert.Equal(ImageLayout.TransferDstOptimal, only.Sides.NewLayout); + Assert.Equal(PipelineStageFlags2.None, only.Sides.SrcStage); + Assert.Equal(AccessFlags2.None, only.Sides.SrcAccess); + Assert.Equal(PipelineStageFlags2.TransferBit, only.Sides.DstStage); + Assert.Equal(AccessFlags2.TransferWriteBit, only.Sides.DstAccess); + } + + [Fact] + public void WriteThenReadGivesOneBarrierThatMakesTheWriteAvailable() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + Require(tracker, ResourceUsage.TransferDst); + + ImageTransition read = Assert.Single(Require(tracker, ResourceUsage.SampleFragment)); + Assert.Equal(ImageLayout.TransferDstOptimal, read.Sides.OldLayout); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, read.Sides.NewLayout); + Assert.Equal(PipelineStageFlags2.TransferBit, read.Sides.SrcStage); + Assert.Equal(AccessFlags2.TransferWriteBit, read.Sides.SrcAccess); + Assert.Equal(PipelineStageFlags2.FragmentShaderBit, read.Sides.DstStage); + Assert.Equal(AccessFlags2.ShaderSampledReadBit, read.Sides.DstAccess); + } + + [Fact] + public void ReadThenReadNeedsNoBarrier() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + Require(tracker, ResourceUsage.TransferDst); + Assert.Single(Require(tracker, ResourceUsage.SampleFragment)); + Assert.Empty(Require(tracker, ResourceUsage.SampleFragment)); + // A reader at another stage, with no write since the barrier: still nothing. + Assert.Empty(Require(tracker, ResourceUsage.SampleVertex)); + } + + [Fact] + public void RepeatedAttachmentUseNeedsNoBarrier() + { + var colour = new ResourceStateTracker(1, 1, depth: false); + Assert.Single(Require(colour, ResourceUsage.ColorBlend)); + Assert.Empty(Require(colour, ResourceUsage.ColorBlend)); + Assert.Empty(Require(colour, ResourceUsage.ColorWrite)); + + var depth = new ResourceStateTracker(1, 1, depth: true); + Assert.Single(Require(depth, ResourceUsage.DepthReadOnlySampled)); + Assert.Empty(Require(depth, ResourceUsage.DepthReadOnlySampled)); + } + + [Fact] + public void ReadAfterWriteInTheSameLayoutOrdersOnlyAStageTheWriteIsNotVisibleTo() + { + // A storage write at the fragment shader in GENERAL, then readers in the + // same layout. No usage in the table writes a layout a pure reader shares, + // so the rule is exercised on the state directly. + var written = new SubresourceState(ImageLayout.General, + PipelineStageFlags2.FragmentShaderBit, AccessFlags2.ShaderStorageWriteBit, + PipelineStageFlags2.FragmentShaderBit, PipelineStageFlags2.None, AccessFlags2.None); + + SubresourceState sameStage = written; + var fragmentReader = new UsageState(ImageLayout.General, + PipelineStageFlags2.FragmentShaderBit, AccessFlags2.ShaderStorageReadBit); + Assert.False(ResourceStateTracker.Advance(ref sameStage, fragmentReader, + PipelineStageFlags2.None, AccessFlags2.None, discard: false, out _)); + + SubresourceState otherStage = written; + Assert.True(ResourceStateTracker.Advance(ref otherStage, UsageState.For(ResourceUsage.StorageRead, false), + PipelineStageFlags2.None, AccessFlags2.None, discard: false, out BarrierSides raw)); + Assert.Equal(ImageLayout.General, raw.OldLayout); + Assert.Equal(ImageLayout.General, raw.NewLayout); + Assert.Equal(PipelineStageFlags2.FragmentShaderBit, raw.SrcStage); + Assert.Equal(AccessFlags2.ShaderStorageWriteBit, raw.SrcAccess); + Assert.True((raw.DstStage & PipelineStageFlags2.ComputeShaderBit) != 0); + + // Once visible, the same reader again costs nothing. + Assert.False(ResourceStateTracker.Advance(ref otherStage, UsageState.For(ResourceUsage.StorageRead, false), + PipelineStageFlags2.None, AccessFlags2.None, discard: false, out _)); + } + + [Fact] + public void SamplingTheReadOnlyDepthOfTheSameScopeNeedsNoBarrier() + { + var tracker = new ResourceStateTracker(1, 1, depth: true); + Require(tracker, ResourceUsage.DepthReadOnly); + // Both store the attachment at the depth tests; the sampled form only adds a reader. + Assert.Empty(Require(tracker, ResourceUsage.DepthReadOnlySampled)); + } + + [Fact] + public void WriteAfterReadInTheSameLayoutOrdersAgainstTheEarlierReaderStage() + { + var tracker = new ResourceStateTracker(1, 1, depth: true); + Require(tracker, ResourceUsage.DepthReadOnlySampled); + // The depth-test-only use does not run at the fragment shader stage that read before. + ImageTransition war = Assert.Single(Require(tracker, ResourceUsage.DepthReadOnly)); + Assert.True((war.Sides.SrcStage & PipelineStageFlags2.FragmentShaderBit) != 0); + Assert.True((war.Sides.SrcAccess & AccessFlags2.ShaderSampledReadBit) != 0); + } + + [Fact] + public void AReadOnlyDepthAttachmentMakesItsStoreWriteAvailableToTheNextTransition() + { + var tracker = new ResourceStateTracker(1, 1, depth: true); + Require(tracker, ResourceUsage.DepthReadOnlySampled); + ImageTransition next = Assert.Single(Require(tracker, ResourceUsage.DepthWrite)); + Assert.True((next.Sides.SrcAccess & AccessFlags2.DepthStencilAttachmentWriteBit) != 0, + "write-after-write: the read-only pass's store must be named on the source side"); + Assert.Equal(ImageLayout.DepthReadOnlyOptimal, next.Sides.OldLayout); + Assert.Equal(ImageLayout.DepthAttachmentOptimal, next.Sides.NewLayout); + } + + [Fact] + public void WriteAfterWriteAcrossLayoutsNamesThePreviousWrite() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + Require(tracker, ResourceUsage.ColorWrite); + ImageTransition waw = Assert.Single(Require(tracker, ResourceUsage.TransferDst)); + Assert.Equal(PipelineStageFlags2.ColorAttachmentOutputBit, waw.Sides.SrcStage); + Assert.Equal(AccessFlags2.ColorAttachmentWriteBit, waw.Sides.SrcAccess); + } + + [Fact] + public void ADiscardingUseStartsFromUndefined() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + Require(tracker, ResourceUsage.SampleFragment); + ImageTransition discarded = Assert.Single(Require(tracker, ResourceUsage.TransferDst, discard: true)); + Assert.Equal(ImageLayout.Undefined, discarded.Sides.OldLayout); + } + + [Fact] + public void ASubRangeSplitsTheImageAndAgreeingAgainMergesIt() + { + var tracker = new ResourceStateTracker(7, 1, depth: false); + Require(tracker, ResourceUsage.TransferSrc); + Assert.False(tracker.IsSplit); + + // Mip 3 alone: one barrier over exactly that level, and the image splits. + ImageTransition level = Assert.Single(Require(tracker, 3, 1, 0, 1, ResourceUsage.TransferDst, discard: true)); + Assert.Equal((3u, 1u, 0u, 1u), (level.BaseMip, level.MipCount, level.BaseLayer, level.LayerCount)); + Assert.True(tracker.IsSplit); + Assert.Equal(ImageLayout.Undefined, tracker.Layout); + Assert.Equal(ImageLayout.TransferDstOptimal, tracker.StateOf(3, 0).Layout); + Assert.Equal(ImageLayout.TransferSrcOptimal, tracker.StateOf(2, 0).Layout); + + // Back to TRANSFER_SRC: every level agrees, one entry again. + Assert.Single(Require(tracker, 3, 1, 0, 1, ResourceUsage.TransferSrc)); + Assert.False(tracker.IsSplit); + Assert.Equal(ImageLayout.TransferSrcOptimal, tracker.Layout); + + // And the whole image moves in a single barrier. + ImageTransition whole = Assert.Single(Require(tracker, ResourceUsage.SampleFragment)); + Assert.Equal((0u, 7u), (whole.BaseMip, whole.MipCount)); + } + + [Fact] + public void AWholeUseOfASplitImageEmitsOneRectanglePerAgreeingRun() + { + var tracker = new ResourceStateTracker(4, 2, depth: false); + Require(tracker, ResourceUsage.SampleFragment); + // Layer 1 of mips 2 and 3 becomes a copy destination. + Assert.Single(Require(tracker, 2, 2, 1, 1, ResourceUsage.TransferDst)); + + List back = Require(tracker, ResourceUsage.SampleFragment); + // Only the destination rectangle changes layout; the rest is already sampled. + ImageTransition rectangle = Assert.Single(back); + Assert.Equal((2u, 2u, 1u, 1u), (rectangle.BaseMip, rectangle.MipCount, rectangle.BaseLayer, rectangle.LayerCount)); + Assert.False(tracker.IsSplit); + } + + [Fact] + public void AMipChainBuildEndsInOneWholeImageBarrier() + { + const uint mips = 7; + var tracker = new ResourceStateTracker(mips, 1, depth: false); + int barriers = 0; + barriers += Require(tracker, ResourceUsage.TransferDst).Count; + barriers += Require(tracker, ResourceUsage.SampleFragment).Count; + barriers += Require(tracker, ResourceUsage.TransferSrc).Count; + for (uint level = 1; level < mips; level++) + { + barriers += Require(tracker, level, 1, 0, 1, ResourceUsage.TransferDst, discard: true).Count; + barriers += Require(tracker, level, 1, 0, 1, ResourceUsage.TransferSrc).Count; + } + Assert.False(tracker.IsSplit); + ImageTransition final = Assert.Single(Require(tracker, ResourceUsage.SampleFragment)); + Assert.Equal(mips, final.MipCount); + Assert.Equal(3 + 2 * (int)(mips - 1), barriers); + } + + [Fact] + public void APresentEndsInPresentSrc() + { + var swapchainImage = new ResourceStateTracker(1, 1, depth: false); + Require(swapchainImage, ResourceUsage.TransferDst, discard: true); + ImageTransition present = Assert.Single(Require(swapchainImage, ResourceUsage.PresentSrc)); + Assert.Equal(ImageLayout.PresentSrcKhr, present.Sides.NewLayout); + Assert.Equal(ImageLayout.PresentSrcKhr, swapchainImage.Layout); + Assert.Equal(PipelineStageFlags2.TransferBit, present.Sides.SrcStage); + Assert.Equal(AccessFlags2.TransferWriteBit, present.Sides.SrcAccess); + Assert.Equal(PipelineStageFlags2.BottomOfPipeBit, present.Sides.DstStage); + + swapchainImage.Reset(); + Assert.Equal(ImageLayout.Undefined, swapchainImage.Layout); + } + + /// + /// The next frame's first barrier on an acquired image names the acquire's + /// wait stage on its source side (synchronization validation reported + /// write-after-read against vkAcquireNextImageKHR without it, 2026-09-11). + /// + [Fact] + public void AnAcquiredImagesFirstBarrierOrdersAgainstTheAcquireWaitStage() + { + var swapchainImage = new ResourceStateTracker(1, 1, depth: false); + Require(swapchainImage, ResourceUsage.TransferDst, discard: true); + Require(swapchainImage, ResourceUsage.PresentSrc); + + swapchainImage.Reset(PipelineStageFlags2.TransferBit); + ImageTransition first = Assert.Single(Require(swapchainImage, ResourceUsage.TransferDst, discard: true)); + Assert.Equal(ImageLayout.Undefined, first.Sides.OldLayout); + Assert.Equal(PipelineStageFlags2.TransferBit, first.Sides.SrcStage); + Assert.Equal(AccessFlags2.None, first.Sides.SrcAccess); + } + + [Fact] + public void NoBarrierEverNamesAllCommands() + { + foreach (ResourceUsage from in System.Enum.GetValues()) + foreach (ResourceUsage to in System.Enum.GetValues()) + { + bool depth = from is ResourceUsage.DepthWrite or ResourceUsage.DepthReadOnly or ResourceUsage.DepthReadOnlySampled; + var tracker = new ResourceStateTracker(1, 1, depth); + Require(tracker, from); + foreach (ImageTransition transition in Require(tracker, to)) + { + Assert.True((transition.Sides.SrcStage & PipelineStageFlags2.AllCommandsBit) == 0, from + "->" + to); + Assert.True((transition.Sides.DstStage & PipelineStageFlags2.AllCommandsBit) == 0, from + "->" + to); + } + } + } + + [Fact] + public void BufferCopiesNameTheBuffersOwnUses() + { + (PipelineStageFlags2 stage, AccessFlags2 access) = BufferUsageState.UsesOf( + BufferUsageFlags.VertexBufferBit | BufferUsageFlags.IndexBufferBit | BufferUsageFlags.TransferDstBit); + Assert.Equal(PipelineStageFlags2.VertexAttributeInputBit | PipelineStageFlags2.IndexInputBit | + PipelineStageFlags2.TransferBit, stage); + Assert.Equal(AccessFlags2.VertexAttributeReadBit | AccessFlags2.IndexReadBit | AccessFlags2.TransferWriteBit, access); + Assert.Equal(0, (int)((ulong)stage & (ulong)PipelineStageFlags2.AllCommandsBit)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 3962cd31..5e604c8e 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -132,8 +132,8 @@ public void NewStatsLinesCarryStableKeyValueTokens() Assert.Equal( "stats.counters blocking_uploads=1 uploads=2 scopes=3 barriers=4 rebar_fallbacks=5 " + - "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8", - VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8))); + "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 barrier_commands=9 barriers_per_frame=2.0", + VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2))); // The enum and the token table cannot drift apart. Assert.Equal(VulkanStats.WaitSiteCount, Enum.GetValues().Length); diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 72be6a8e..e523de20 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Optimum.Render.Vulkan.Graph; using Silk.NET.Vulkan; namespace Optimum.Render.Vulkan.Core; @@ -55,6 +56,9 @@ internal sealed unsafe class RenderTargetManager : IDisposable private readonly TextureManager _textures; private readonly GlStateTracker _state; + /// Every attachment of a scope moves in one barrier command before vkCmdBeginRendering. + private readonly BarrierBatcher _barriers; + private readonly List _framebuffers = new(); private readonly Stack _freeIds = new(); @@ -83,6 +87,7 @@ public RenderTargetManager(VulkanContext context, TextureManager textures, GlSta _context = context; _textures = textures; _state = state; + _barriers = textures.CreateBatcher(); // Index 0 is the default framebuffer, installed separately. _framebuffers.Add(null); @@ -251,7 +256,7 @@ public void EnsureRendering(CommandBuffer commandBuffer) VulkanTexture? excluded = _textures.Get(unused.TextureId); if (excluded != null) { - _textures.TransitionTexture(commandBuffer, excluded, ImageLayout.ShaderReadOnlyOptimal); + _textures.Require(_barriers, commandBuffer, excluded, ResourceUsage.SampleFragment); } } @@ -283,7 +288,9 @@ public void EnsureRendering(CommandBuffer commandBuffer) continue; } - _textures.TransitionTexture(commandBuffer, texture, ImageLayout.ColorAttachmentOptimal); + // Blend state can change inside the scope, so the attachment is + // declared for the widest colour use (read and write). + _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.ColorBlend); attachments[i] = new RenderingAttachmentInfo { @@ -309,7 +316,9 @@ public void EnsureRendering(CommandBuffer commandBuffer) ImageLayout depthLayout = DepthReadOnly ? ImageLayout.DepthReadOnlyOptimal : ImageLayout.DepthAttachmentOptimal; - _textures.TransitionTexture(commandBuffer, depth, depthLayout); + // Read-only depth may be sampled by the draws of this scope. + _textures.Require(_barriers, commandBuffer, depth, + DepthReadOnly ? ResourceUsage.DepthReadOnlySampled : ResourceUsage.DepthWrite); depthAttachment = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo, @@ -322,6 +331,8 @@ public void EnsureRendering(CommandBuffer commandBuffer) } } + _barriers.Flush(commandBuffer); + fixed (RenderingAttachmentInfo* attachmentsPtr = attachments) { var rendering = new RenderingInfo diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index d8142a32..36c0ea3a 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Optimum.Render.Vulkan.Graph; using Silk.NET.Vulkan; using Buffer = Silk.NET.Vulkan.Buffer; @@ -89,8 +90,30 @@ internal sealed unsafe class VulkanTexture : IDisposable /// Mutable, as glTexParameter is. public SamplerState State { get; set; } = SamplerState.Default; - /// Tracked because Vulkan offers no way to query it. - public ImageLayout Layout { get; set; } = ImageLayout.Undefined; + private ResourceStateTracker? _sync; + + /// + /// Per-subresource layout, write and read stages, which every barrier on this + /// texture derives from (). Created on first use, + /// once the image's dimensions are set. + /// + internal ResourceStateTracker Sync + { + get + { + ResourceStateTracker? sync = _sync; + if (sync != null) return sync; + System.Threading.Interlocked.CompareExchange(ref _sync, + new ResourceStateTracker(MipLevels, Layers, Aspect != ImageAspectFlags.ColorBit), null); + return _sync!; + } + } + + /// + /// The layout of the whole image, tracked because Vulkan offers no way to + /// query it; UNDEFINED while its subresources are in different layouts. + /// + public ImageLayout Layout => Sync.Layout; /// /// The frame command buffer generation that last used this texture; an @@ -262,6 +285,7 @@ public TextureManager(VulkanContext context, UploadManager uploads) _context = context; _uploads = uploads; Samplers = new SamplerCache(context); + _barriers = CreateBatcher(); // Index 0 is reserved so a zero id never names a real texture. _textures.Add(null); @@ -519,15 +543,15 @@ public void GenerateMipmaps(int textureId) _context.CmdSetCheckpoint(commandBuffer, CheckpointMarker.Mipmaps(textureId, texture.MipLevels)); } - TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + TransitionTexture(commandBuffer, texture, ResourceUsage.TransferSrc); for (uint level = 1; level < texture.MipLevels; level++) { int nextWidth = Math.Max(1, mipWidth / 2); int nextHeight = Math.Max(1, mipHeight / 2); - TransitionRange(commandBuffer, texture, level, 1, - ImageLayout.Undefined, ImageLayout.TransferDstOptimal); + // The level is about to be overwritten whole: discard it. + TransitionRange(commandBuffer, texture, level, 1, ResourceUsage.TransferDst, discard: true); var blit = new ImageBlit { @@ -544,15 +568,15 @@ public void GenerateMipmaps(int textureId) texture.Image, ImageLayout.TransferDstOptimal, 1, &blit, Filter.Linear); - TransitionRange(commandBuffer, texture, level, 1, - ImageLayout.TransferDstOptimal, ImageLayout.TransferSrcOptimal); + TransitionRange(commandBuffer, texture, level, 1, ResourceUsage.TransferSrc, discard: false); mipWidth = nextWidth; mipHeight = nextHeight; } - texture.Layout = ImageLayout.TransferSrcOptimal; - TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + // Every level is TRANSFER_SRC again, so the tracker merged the + // per-level entries back into one and this is a single barrier. + TransitionTexture(commandBuffer, texture, ResourceUsage.SampleFragment); } finally { @@ -645,77 +669,56 @@ public void Delete(int textureId, FrameRing? ring = null) // ------------------------------------------------------------------ barriers - public void TransitionTexture(CommandBuffer commandBuffer, VulkanTexture texture, ImageLayout target) + // Barriers for the immediate transitions below. Uploads record from any + // thread under the upload lock and the frame thread records without it, so + // the shared batcher is used under its own lock, one Require+Flush at a time. + private readonly BarrierBatcher _barriers; + private readonly object _barrierLock = new(); + + /// + /// Whether a rendering scope is open in a command buffer; the device answers + /// for its frame command buffer. Every batcher from + /// asks it at flush time (debug builds reject a flush inside a scope). + /// + public Func? ScopeOpen { get; set; } + + /// A batcher for one recording thread (a render target manager, the present path). + public BarrierBatcher CreateBatcher() => + new(_context.Api) { ScopeOpen = commandBuffer => ScopeOpen?.Invoke(commandBuffer) == true }; + + /// + /// Adds a whole-texture use to without flushing, so + /// several textures move in one barrier command. The caller flushes before + /// recording the commands that use them. + /// + public void Require(BarrierBatcher batcher, CommandBuffer commandBuffer, VulkanTexture texture, ResourceUsage usage) { - // Every path that records a texture into a command buffer goes through - // here (attachments, reads, copies, blits), even when no barrier is due. _uploads.NoteUse(commandBuffer, texture); - if (texture.Layout == target) return; - TransitionRange(commandBuffer, texture, 0, texture.MipLevels, texture.Layout, target); - texture.Layout = target; + batcher.Require(texture, 0, texture.MipLevels, 0, texture.Layers, usage); } - private void TransitionRange( - CommandBuffer commandBuffer, VulkanTexture texture, - uint baseMip, uint mipCount, ImageLayout from, ImageLayout to) - { - // Execution dependency stays ALL_COMMANDS on both sides (a transition - // must order against every earlier use, and this backend does not - // track per-use stages); the access masks name what each layout is - // really used for, which is what makes the availability/visibility - // operations precise and keeps the layers quiet about them. - var barrier = new ImageMemoryBarrier2 - { - SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = PipelineStageFlags2.AllCommandsBit, - SrcAccessMask = AccessForLayout(from, writer: true), - DstStageMask = PipelineStageFlags2.AllCommandsBit, - DstAccessMask = AccessForLayout(to, writer: false), - OldLayout = from, - NewLayout = to, - Image = texture.Image, - SubresourceRange = new ImageSubresourceRange(texture.Aspect, baseMip, mipCount, 0, texture.Layers), - }; + /// A transition named by layout (tests, a readback restoring what it found); see . + public void TransitionTexture(CommandBuffer commandBuffer, VulkanTexture texture, ImageLayout target) => + TransitionTexture(commandBuffer, texture, UsageState.ForLayout(target)); - var dependency = new DependencyInfo - { - SType = StructureType.DependencyInfo, - ImageMemoryBarrierCount = 1, - PImageMemoryBarriers = &barrier, - }; - _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); - VulkanStats.NoteImageBarriers(1); + public void TransitionTexture(CommandBuffer commandBuffer, VulkanTexture texture, ResourceUsage usage, + bool discard = false) + { + // Every path that records a texture into a command buffer goes through + // here or Require (attachments, reads, copies, blits), even when no barrier is due. + _uploads.NoteUse(commandBuffer, texture); + TransitionRange(commandBuffer, texture, 0, texture.MipLevels, usage, discard); } - /// - /// The accesses a layout is used for: as the source side of a barrier the - /// writes that must be made available, as the destination side the reads - /// and writes that must see them. - /// - internal static AccessFlags2 AccessForLayout(ImageLayout layout, bool writer) => layout switch + private void TransitionRange(CommandBuffer commandBuffer, VulkanTexture texture, + uint baseMip, uint mipCount, ResourceUsage usage, bool discard) { - ImageLayout.TransferDstOptimal => AccessFlags2.TransferWriteBit, - ImageLayout.TransferSrcOptimal => writer ? AccessFlags2.None : AccessFlags2.TransferReadBit, - ImageLayout.ColorAttachmentOptimal => writer - ? AccessFlags2.ColorAttachmentWriteBit - : AccessFlags2.ColorAttachmentReadBit | AccessFlags2.ColorAttachmentWriteBit, - ImageLayout.DepthAttachmentOptimal or ImageLayout.DepthStencilAttachmentOptimal => writer - ? AccessFlags2.DepthStencilAttachmentWriteBit - : AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.DepthStencilAttachmentWriteBit, - // Read-only depth is still written by the pass's storeOp, so as a - // source it must make that write available or the next transition is - // a write-after-write hazard (synchronization validation, 2026-09-11). - ImageLayout.DepthReadOnlyOptimal or ImageLayout.DepthStencilReadOnlyOptimal => writer - ? AccessFlags2.DepthStencilAttachmentWriteBit - : AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.ShaderSampledReadBit, - ImageLayout.ShaderReadOnlyOptimal => writer ? AccessFlags2.None : AccessFlags2.ShaderSampledReadBit, - ImageLayout.General => writer - ? AccessFlags2.MemoryWriteBit - : AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, - ImageLayout.PresentSrcKhr => AccessFlags2.None, - ImageLayout.Undefined or ImageLayout.Preinitialized => writer ? AccessFlags2.None : AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, - _ => writer ? AccessFlags2.MemoryWriteBit : AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, - }; + lock (_barrierLock) + { + _barriers.Require(texture, baseMip, mipCount, 0, texture.Layers, usage, discard); + _barriers.Flush(commandBuffer); + } + } // -------------------------------------------------------------------- helpers diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs index c575938d..6dc22555 100644 --- a/Optimum.Render.Vulkan/Core/VulkanResources.cs +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -36,6 +36,9 @@ internal sealed unsafe class VulkanBuffer : IDisposable public Buffer Handle { get; } public ulong Size { get; } + /// What the buffer was created for; the barriers around a staged copy name these uses. + public BufferUsageFlags Usage { get; } + /// Never reused, unlike ; see . public ulong Id { get; } = ResourceIds.Next(); @@ -67,6 +70,7 @@ public VulkanBuffer(VulkanContext context, ulong size, BufferUsageFlags usage, M { _context = context; Size = size; + Usage = usage; var createInfo = new BufferCreateInfo { diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 049fc827..fba15de3 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -118,6 +118,7 @@ internal static class VulkanStats private static long _uploadRequests; private static long _scopesOpened; private static long _imageBarriers; + private static long _barrierCommands; private static long _rebarFallbacks; private static long _dynamicStateCommands; private static long _uniformRingPeak; @@ -195,6 +196,11 @@ public static void NoteUpload(long elapsedTicks) public static long ImageBarriers => Interlocked.Read(ref _imageBarriers); + /// One vkCmdPipelineBarrier2 carrying image barriers (a BarrierBatcher flush). + public static void NoteBarrierCommand() => Interlocked.Increment(ref _barrierCommands); + + public static long BarrierCommands => Interlocked.Read(ref _barrierCommands); + /// A buffer that asked for ReBAR (device-local and host-visible) and fell back to plain host memory. public static void NoteRebarFallback() => Interlocked.Increment(ref _rebarFallbacks); @@ -302,7 +308,9 @@ public static Result WaitDeviceIdle(Vk api, Device device) RebarFallbacks: Interlocked.Exchange(ref _rebarFallbacks, 0), DynamicState: Interlocked.Exchange(ref _dynamicStateCommands, 0), UniformRingUsed: Interlocked.Exchange(ref _uniformRingPeak, 0), - UniformRingCapacity: Interlocked.Read(ref _uniformRingCapacity)); + UniformRingCapacity: Interlocked.Read(ref _uniformRingCapacity), + BarrierCommands: Interlocked.Exchange(ref _barrierCommands, 0), + Frames: frames); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -359,9 +367,11 @@ public static string FormatWaitsLine(long[] counts, double[] milliseconds) public static string FormatCountersLine(CounterSample counters) => string.Format(CultureInfo.InvariantCulture, "stats.counters blocking_uploads={0} uploads={1} scopes={2} barriers={3} rebar_fallbacks={4} " + - "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7}", + "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7} " + + "barrier_commands={8} barriers_per_frame={9:F1}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, - counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity); + counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, + counters.BarrierCommands, counters.Frames > 0 ? counters.Barriers / (double)counters.Frames : 0.0); private static long _lastSample; } @@ -375,7 +385,9 @@ internal readonly record struct CounterSample( long RebarFallbacks, long DynamicState, long UniformRingUsed, - long UniformRingCapacity); + long UniformRingCapacity, + long BarrierCommands = 0, + long Frames = 0); /// Percentiles and spread of the frame-interval ring at one moment. internal readonly record struct FramePacingSnapshot( diff --git a/Optimum.Render.Vulkan/Graph/BarrierBatcher.cs b/Optimum.Render.Vulkan/Graph/BarrierBatcher.cs new file mode 100644 index 00000000..5ebf00f4 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/BarrierBatcher.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// Collects the image barriers a group of uses needs and records them as one +/// vkCmdPipelineBarrier2. Stages and accesses come from each image's +/// , never ALL_COMMANDS. +/// +/// updates the tracker at once, so the barriers must be +/// flushed before the commands that perform the uses are recorded, and never +/// inside an open rendering scope (checked in debug builds). One batcher belongs +/// to one recording thread; the trackers it touches are locked per call. +/// +internal sealed unsafe class BarrierBatcher +{ + private readonly Vk _api; + private readonly List _scratch = new(); + private ImageMemoryBarrier2[] _pending = new ImageMemoryBarrier2[16]; + private int _count; + + public BarrierBatcher(Vk api) => _api = api; + + /// + /// Whether a rendering scope is open in the given command buffer. A flush + /// there is a transition inside a scope, which debug builds reject. + /// + public Func? ScopeOpen { get; set; } + + /// Barriers recorded by and not yet flushed. + public int Pending => _count; + + public void Require(VulkanTexture texture, uint baseMip, uint mipCount, uint baseLayer, uint layerCount, + ResourceUsage usage) => + Require(texture, baseMip, mipCount, baseLayer, layerCount, usage, discard: false); + + public void Require(VulkanTexture texture, uint baseMip, uint mipCount, uint baseLayer, uint layerCount, + ResourceUsage usage, bool discard) => + Require(texture.Image, texture.Aspect, texture.Sync, baseMip, mipCount, baseLayer, layerCount, usage, discard); + + /// An image the texture table does not own (a swapchain image), with its own tracker. + public void Require(Image image, ImageAspectFlags aspect, ResourceStateTracker tracker, + uint baseMip, uint mipCount, uint baseLayer, uint layerCount, ResourceUsage usage, bool discard) + { + lock (tracker) + { + _scratch.Clear(); + if (tracker.Require(baseMip, mipCount, baseLayer, layerCount, usage, discard, _scratch) == 0) return; + + foreach (ImageTransition transition in _scratch) + { + Append(image, aspect, transition); + } + } + } + + private void Append(Image image, ImageAspectFlags aspect, ImageTransition transition) + { + BarrierSides sides = transition.Sides; + var range = new ImageSubresourceRange(aspect, transition.BaseMip, transition.MipCount, + transition.BaseLayer, transition.LayerCount); + + // A second use of the same range before the flush (one texture attached + // to two slots in different roles): one barrier per subresource per + // call, so the two chain into one from the first source to the last + // destination. + for (int i = 0; i < _count; i++) + { + ref ImageMemoryBarrier2 existing = ref _pending[i]; + if (existing.Image.Handle != image.Handle || !SameRange(existing.SubresourceRange, range)) continue; + existing.NewLayout = sides.NewLayout; + existing.DstStageMask = sides.DstStage; + existing.DstAccessMask = sides.DstAccess; + return; + } + + if (_count == _pending.Length) Array.Resize(ref _pending, _pending.Length * 2); + _pending[_count++] = new ImageMemoryBarrier2 + { + SType = StructureType.ImageMemoryBarrier2, + SrcStageMask = sides.SrcStage, + SrcAccessMask = sides.SrcAccess, + DstStageMask = sides.DstStage, + DstAccessMask = sides.DstAccess, + OldLayout = sides.OldLayout, + NewLayout = sides.NewLayout, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = range, + }; + } + + private static bool SameRange(ImageSubresourceRange a, ImageSubresourceRange b) => + a.AspectMask == b.AspectMask && a.BaseMipLevel == b.BaseMipLevel && a.LevelCount == b.LevelCount && + a.BaseArrayLayer == b.BaseArrayLayer && a.LayerCount == b.LayerCount; + + /// Records every pending barrier as one vkCmdPipelineBarrier2; nothing when none is pending. + public void Flush(CommandBuffer commandBuffer) + { + if (_count == 0) return; + +#if DEBUG + if (ScopeOpen?.Invoke(commandBuffer) == true) + { + _count = 0; + throw new InvalidOperationException("image barriers flushed inside an open rendering scope"); + } +#endif + + fixed (ImageMemoryBarrier2* barriers = _pending) + { + var dependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + ImageMemoryBarrierCount = (uint)_count, + PImageMemoryBarriers = barriers, + }; + _api.CmdPipelineBarrier2(commandBuffer, &dependency); + } + + if (RenderTrace.Enabled) + { + for (int i = 0; i < _count; i++) + { + ImageMemoryBarrier2 b = _pending[i]; + RenderTrace.Write("barrier image=" + b.Image.Handle.ToString("x") + " mips=" + + b.SubresourceRange.BaseMipLevel + "+" + b.SubresourceRange.LevelCount + " layers=" + + b.SubresourceRange.BaseArrayLayer + "+" + b.SubresourceRange.LayerCount + " " + + b.OldLayout + "->" + b.NewLayout + " src=" + b.SrcStageMask + "/" + b.SrcAccessMask + + " dst=" + b.DstStageMask + "/" + b.DstAccessMask); + } + } + + VulkanStats.NoteImageBarriers(_count); + VulkanStats.NoteBarrierCommand(); + _count = 0; + } +} diff --git a/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs b/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs new file mode 100644 index 00000000..ec84d083 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// The synchronization state of one subresource (mip level, array layer). +/// +/// The layout the subresource is in. +/// The stage of the last write since the last barrier, or none. +/// The access of that write. +/// The stages the current contents were made visible to by a barrier or a write. +/// The stages that read since the last barrier or write. +/// The accesses of those reads. +internal readonly record struct SubresourceState( + ImageLayout Layout, + PipelineStageFlags2 WriteStage, + AccessFlags2 WriteAccess, + PipelineStageFlags2 VisibleStages, + PipelineStageFlags2 ReadStages, + AccessFlags2 ReadAccess) +{ + public static SubresourceState Undefined => new(ImageLayout.Undefined, + PipelineStageFlags2.None, AccessFlags2.None, PipelineStageFlags2.None, PipelineStageFlags2.None, AccessFlags2.None); +} + +/// The two sides of one barrier, without its subresource range. +internal readonly record struct BarrierSides( + ImageLayout OldLayout, + ImageLayout NewLayout, + PipelineStageFlags2 SrcStage, + AccessFlags2 SrcAccess, + PipelineStageFlags2 DstStage, + AccessFlags2 DstAccess); + +/// One barrier over a rectangle of subresources. +internal readonly record struct ImageTransition(uint BaseMip, uint MipCount, uint BaseLayer, uint LayerCount, BarrierSides Sides); + +/// +/// Per-subresource layout, last write stage and access, the stages that write is +/// visible to, and the read stages since, for one image. Derives the barriers a +/// new use needs: +/// +/// a layout change always needs one; its source side names the last write +/// and the reads since (so the write is made available and the reads complete), +/// its destination side the new use; +/// read after write (RAW) in the same layout needs one only when the +/// reader's stage is not among the stages the write is visible to; +/// write after read (WAR) and write after write (WAW) in the same layout +/// need one only when an earlier read or write ran at a stage the new write +/// does not; +/// read after read, and any use repeating the previous one, needs none. +/// +/// One entry covers the whole image while every subresource agrees; a use of a +/// sub-range splits it into per-subresource entries, and a use that makes them +/// agree again merges them back. Not thread-safe: +/// locks the tracker around each call. +/// +internal sealed class ResourceStateTracker +{ + private SubresourceState _whole = SubresourceState.Undefined; + private SubresourceState[]? _split; + + public ResourceStateTracker(uint mipLevels, uint layers, bool depth) + { + MipLevels = Math.Max(1, mipLevels); + Layers = Math.Max(1, layers); + Depth = depth; + } + + public uint MipLevels { get; } + public uint Layers { get; } + public bool Depth { get; } + + /// Whether sub-ranges currently differ (one entry per subresource). + public bool IsSplit => _split != null; + + /// The layout of the whole image, or UNDEFINED while sub-ranges differ. + public ImageLayout Layout => _split == null ? _whole.Layout : ImageLayout.Undefined; + + public SubresourceState StateOf(uint mip, uint layer) => + _split == null ? _whole : _split[mip * Layers + layer]; + + /// Forgets everything: the image's contents are undefined (a swapchain image before its frame). + public void Reset() => Reset(PipelineStageFlags2.None); + + /// + /// Forgets the contents, keeping one prior use: is + /// where something outside the command buffer last touched the image. A + /// swapchain image was accessed by vkAcquireNextImageKHR, and the submission + /// waits on the acquire semaphore at a stage, so the first barrier must name + /// that stage on its source side or synchronization validation reports + /// write-after-read against the acquire. + /// + public void Reset(PipelineStageFlags2 priorStage) + { + _whole = SubresourceState.Undefined with { VisibleStages = priorStage }; + _split = null; + } + + /// + /// Records a use of a subresource range and appends the barriers it needs to + /// , as few rectangles as the state allows. + /// says the contents do not matter, so a layout + /// change starts from UNDEFINED. Returns how many were appended. + /// + public int Require(uint baseMip, uint mipCount, uint baseLayer, uint layerCount, ResourceUsage usage, + bool discard, List output) + { + if (baseMip >= MipLevels || baseLayer >= Layers) return 0; + mipCount = Math.Min(mipCount, MipLevels - baseMip); + layerCount = Math.Min(layerCount, Layers - baseLayer); + if (mipCount == 0 || layerCount == 0) return 0; + + UsageState target = UsageState.For(usage, Depth); + (PipelineStageFlags2 writeStage, AccessFlags2 writeAccess) = UsageState.WriteOf(usage, Depth); + + bool whole = baseMip == 0 && mipCount == MipLevels && baseLayer == 0 && layerCount == Layers; + if (_split == null && whole) + { + if (!Advance(ref _whole, target, writeStage, writeAccess, discard, out BarrierSides sides)) return 0; + output.Add(new ImageTransition(0, MipLevels, 0, Layers, sides)); + return 1; + } + + if (_split == null) + { + _split = new SubresourceState[MipLevels * Layers]; + Array.Fill(_split, _whole); + } + + int before = output.Count; + int firstRectangle = output.Count; + for (uint mip = baseMip; mip < baseMip + mipCount; mip++) + { + uint runStart = 0; + BarrierSides runSides = default; + bool inRun = false; + for (uint layer = baseLayer; layer < baseLayer + layerCount; layer++) + { + ref SubresourceState state = ref _split[mip * Layers + layer]; + bool needed = Advance(ref state, target, writeStage, writeAccess, discard, out BarrierSides sides); + if (inRun && (!needed || sides != runSides)) + { + AddRectangle(output, firstRectangle, mip, runStart, layer - runStart, runSides); + inRun = false; + } + if (needed && !inRun) + { + runStart = layer; + runSides = sides; + inRun = true; + } + } + if (inRun) AddRectangle(output, firstRectangle, mip, runStart, baseLayer + layerCount - runStart, runSides); + } + + TryMerge(); + return output.Count - before; + } + + /// + /// Adds a one-mip rectangle, extending the rectangle of the previous mip with + /// the same layer span and sides when there is one. + /// + private static void AddRectangle(List output, int first, uint mip, uint baseLayer, + uint layerCount, BarrierSides sides) + { + for (int i = first; i < output.Count; i++) + { + ImageTransition candidate = output[i]; + if (candidate.BaseLayer == baseLayer && candidate.LayerCount == layerCount && + candidate.BaseMip + candidate.MipCount == mip && candidate.Sides == sides) + { + output[i] = candidate with { MipCount = candidate.MipCount + 1 }; + return; + } + } + output.Add(new ImageTransition(mip, 1, baseLayer, layerCount, sides)); + } + + private void TryMerge() + { + if (_split == null) return; + SubresourceState first = _split[0]; + for (int i = 1; i < _split.Length; i++) + { + if (_split[i] != first) return; + } + _whole = first; + _split = null; + } + + /// + /// Applies one use to one subresource. Returns whether a barrier is needed + /// before it, and that barrier's sides. + /// + internal static bool Advance(ref SubresourceState state, UsageState target, + PipelineStageFlags2 writeStage, AccessFlags2 writeAccess, bool discard, out BarrierSides sides) + { + AccessFlags2 readAccess = target.Access & ~UsageState.WriteAccessMask; + PipelineStageFlags2 readStage = readAccess != AccessFlags2.None || writeAccess == AccessFlags2.None + ? target.Stage + : PipelineStageFlags2.None; + bool writes = writeAccess != AccessFlags2.None; + + bool needed; + if (state.Layout != target.Layout) + { + needed = true; + } + else if (writes) + { + // WAR / WAW: an earlier read or write at a stage this write does not run at. + needed = (state.ReadStages & ~target.Stage) != PipelineStageFlags2.None || + (state.WriteStage & ~writeStage) != PipelineStageFlags2.None; + } + else + { + // RAW: the last write is not yet visible to this reader's stage. + needed = state.WriteStage != PipelineStageFlags2.None && + (target.Stage & ~state.VisibleStages) != PipelineStageFlags2.None; + } + + sides = default; + if (needed) + { + PipelineStageFlags2 srcStage = state.WriteStage | state.ReadStages; + AccessFlags2 srcAccess = state.WriteAccess | state.ReadAccess; + if (srcStage == PipelineStageFlags2.None) + { + // Nothing used it since the last barrier: that barrier is the prior use. + srcStage = state.VisibleStages; + srcAccess = AccessFlags2.None; + } + + ImageLayout oldLayout = state.Layout; + if (discard && state.Layout != target.Layout) oldLayout = ImageLayout.Undefined; + sides = new BarrierSides(oldLayout, target.Layout, srcStage, srcAccess, target.Stage, target.Access); + state = new SubresourceState(target.Layout, PipelineStageFlags2.None, AccessFlags2.None, + target.Stage, PipelineStageFlags2.None, AccessFlags2.None); + } + + if (writes) + { + state = new SubresourceState(target.Layout, writeStage, writeAccess, + target.Stage, readStage, readAccess); + } + else + { + state = state with + { + ReadStages = state.ReadStages | readStage, + ReadAccess = state.ReadAccess | readAccess, + VisibleStages = state.VisibleStages | target.Stage, + }; + } + return needed; + } +} diff --git a/Optimum.Render.Vulkan/Graph/ResourceUsage.cs b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs new file mode 100644 index 00000000..8361fa3f --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs @@ -0,0 +1,189 @@ +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// What a command does with an image. Layout, pipeline stage and access all +/// derive from this (), never from the layout +/// alone: two uses can share a layout and still differ in stage (a depth +/// attachment read only by the depth test versus one also sampled by the +/// fragment shader). +/// +public enum ResourceUsage +{ + /// Colour attachment, written without reading the destination. + ColorWrite, + /// Colour attachment with blending: the destination is read and written. + ColorBlend, + /// Depth attachment with writes on. + DepthWrite, + /// Depth attachment with writes off, read by the depth test only. + DepthReadOnly, + /// Depth attachment with writes off, also sampled by the fragment shader. + DepthReadOnlySampled, + /// Sampled by a fragment shader. + SampleFragment, + /// Sampled by a vertex shader. + SampleVertex, + /// Read as a storage image. + StorageRead, + /// Source of a copy or blit. + TransferSrc, + /// Destination of a copy, blit or clear. + TransferDst, + /// Handed to vkQueuePresentKHR. + PresentSrc, +} + +/// +/// The layout, stage and access of one : the +/// destination side of a barrier into that usage. +/// +internal readonly record struct UsageState(ImageLayout Layout, PipelineStageFlags2 Stage, AccessFlags2 Access) +{ + /// The fragment test stages a depth attachment is used at. + public const PipelineStageFlags2 DepthTests = + PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit; + + /// Every access bit that writes. + public const AccessFlags2 WriteAccessMask = + AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.DepthStencilAttachmentWriteBit | + AccessFlags2.TransferWriteBit | AccessFlags2.ShaderStorageWriteBit | AccessFlags2.MemoryWriteBit; + + /// + /// The usage table. is the image's aspect: an + /// attachment usage on a depth image resolves to its depth form and a depth + /// attachment usage on a colour image to its colour form, so a caller that + /// only knows "attachment" gets the right one. Sampling keeps + /// SHADER_READ_ONLY_OPTIMAL for both aspects, because that is the layout the + /// descriptor writes name. + /// + public static UsageState For(ResourceUsage usage, bool depth) => Normalise(usage, depth) switch + { + ResourceUsage.ColorWrite => new(ImageLayout.ColorAttachmentOptimal, + PipelineStageFlags2.ColorAttachmentOutputBit, AccessFlags2.ColorAttachmentWriteBit), + ResourceUsage.ColorBlend => new(ImageLayout.ColorAttachmentOptimal, + PipelineStageFlags2.ColorAttachmentOutputBit, + AccessFlags2.ColorAttachmentReadBit | AccessFlags2.ColorAttachmentWriteBit), + ResourceUsage.DepthWrite => new(ImageLayout.DepthAttachmentOptimal, DepthTests, + AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.DepthStencilAttachmentWriteBit), + ResourceUsage.DepthReadOnly => new(ImageLayout.DepthReadOnlyOptimal, DepthTests, + AccessFlags2.DepthStencilAttachmentReadBit), + ResourceUsage.DepthReadOnlySampled => new(ImageLayout.DepthReadOnlyOptimal, + DepthTests | PipelineStageFlags2.FragmentShaderBit, + AccessFlags2.DepthStencilAttachmentReadBit | AccessFlags2.ShaderSampledReadBit), + ResourceUsage.SampleFragment => new(ImageLayout.ShaderReadOnlyOptimal, + PipelineStageFlags2.FragmentShaderBit, AccessFlags2.ShaderSampledReadBit), + ResourceUsage.SampleVertex => new(ImageLayout.ShaderReadOnlyOptimal, + PipelineStageFlags2.VertexShaderBit, AccessFlags2.ShaderSampledReadBit), + ResourceUsage.StorageRead => new(ImageLayout.General, + PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.ComputeShaderBit, + AccessFlags2.ShaderStorageReadBit), + ResourceUsage.TransferSrc => new(ImageLayout.TransferSrcOptimal, + PipelineStageFlags2.TransferBit, AccessFlags2.TransferReadBit), + ResourceUsage.TransferDst => new(ImageLayout.TransferDstOptimal, + PipelineStageFlags2.TransferBit, AccessFlags2.TransferWriteBit), + ResourceUsage.PresentSrc => new(ImageLayout.PresentSrcKhr, + PipelineStageFlags2.BottomOfPipeBit, AccessFlags2.None), + _ => throw new System.ArgumentOutOfRangeException(nameof(usage), usage, null), + }; + + /// + /// The write a usage performs, which the next barrier must make available. + /// An attachment is written by its store op even with writes off: a + /// read-only depth attachment is still stored, and synchronization + /// validation reports the next transition as write-after-write unless the + /// barrier names that write (2026-09-11). + /// + public static (PipelineStageFlags2 Stage, AccessFlags2 Access) WriteOf(ResourceUsage usage, bool depth) => + Normalise(usage, depth) switch + { + ResourceUsage.ColorWrite or ResourceUsage.ColorBlend => + (PipelineStageFlags2.ColorAttachmentOutputBit, AccessFlags2.ColorAttachmentWriteBit), + ResourceUsage.DepthWrite or ResourceUsage.DepthReadOnly or ResourceUsage.DepthReadOnlySampled => + (DepthTests, AccessFlags2.DepthStencilAttachmentWriteBit), + ResourceUsage.TransferDst => (PipelineStageFlags2.TransferBit, AccessFlags2.TransferWriteBit), + _ => (PipelineStageFlags2.None, AccessFlags2.None), + }; + + /// + /// The usage a layout stands for, for callers that still speak in layouts + /// (tests, a readback restoring what it found). Attachment layouts map to + /// the widest use of that layout: blending for colour, sampled for + /// read-only depth. + /// + public static ResourceUsage ForLayout(ImageLayout layout) => layout switch + { + ImageLayout.ShaderReadOnlyOptimal => ResourceUsage.SampleFragment, + ImageLayout.ColorAttachmentOptimal => ResourceUsage.ColorBlend, + ImageLayout.DepthAttachmentOptimal or ImageLayout.DepthStencilAttachmentOptimal => ResourceUsage.DepthWrite, + ImageLayout.DepthReadOnlyOptimal or ImageLayout.DepthStencilReadOnlyOptimal => ResourceUsage.DepthReadOnlySampled, + ImageLayout.TransferSrcOptimal => ResourceUsage.TransferSrc, + ImageLayout.TransferDstOptimal => ResourceUsage.TransferDst, + ImageLayout.PresentSrcKhr => ResourceUsage.PresentSrc, + ImageLayout.General => ResourceUsage.StorageRead, + _ => throw new System.ArgumentOutOfRangeException(nameof(layout), layout, "no usage stands for this layout"), + }; + + private static ResourceUsage Normalise(ResourceUsage usage, bool depth) => (usage, depth) switch + { + (ResourceUsage.ColorWrite, true) => ResourceUsage.DepthWrite, + (ResourceUsage.ColorBlend, true) => ResourceUsage.DepthWrite, + (ResourceUsage.DepthWrite, false) => ResourceUsage.ColorBlend, + (ResourceUsage.DepthReadOnly, false) => ResourceUsage.ColorBlend, + (ResourceUsage.DepthReadOnlySampled, false) => ResourceUsage.ColorBlend, + _ => usage, + }; +} + +/// +/// The readers a buffer can have, derived from its usage flags. Buffers have no +/// layout, so the barriers around a staged copy name every use the buffer was +/// created for instead of ALL_COMMANDS. +/// +internal static class BufferUsageState +{ + public static (PipelineStageFlags2 Stage, AccessFlags2 Access) UsesOf(BufferUsageFlags usage) + { + PipelineStageFlags2 stage = PipelineStageFlags2.None; + AccessFlags2 access = AccessFlags2.None; + if ((usage & BufferUsageFlags.VertexBufferBit) != 0) + { + stage |= PipelineStageFlags2.VertexAttributeInputBit; + access |= AccessFlags2.VertexAttributeReadBit; + } + if ((usage & BufferUsageFlags.IndexBufferBit) != 0) + { + stage |= PipelineStageFlags2.IndexInputBit; + access |= AccessFlags2.IndexReadBit; + } + if ((usage & BufferUsageFlags.UniformBufferBit) != 0) + { + stage |= PipelineStageFlags2.VertexShaderBit | PipelineStageFlags2.FragmentShaderBit; + access |= AccessFlags2.UniformReadBit; + } + if ((usage & BufferUsageFlags.StorageBufferBit) != 0) + { + stage |= PipelineStageFlags2.VertexShaderBit | PipelineStageFlags2.FragmentShaderBit | + PipelineStageFlags2.ComputeShaderBit; + access |= AccessFlags2.ShaderStorageReadBit; + } + if ((usage & BufferUsageFlags.IndirectBufferBit) != 0) + { + stage |= PipelineStageFlags2.DrawIndirectBit; + access |= AccessFlags2.IndirectCommandReadBit; + } + if ((usage & BufferUsageFlags.TransferSrcBit) != 0) + { + stage |= PipelineStageFlags2.TransferBit; + access |= AccessFlags2.TransferReadBit; + } + if ((usage & BufferUsageFlags.TransferDstBit) != 0) + { + // A previous staged copy wrote it: that write must be made available too. + stage |= PipelineStageFlags2.TransferBit; + access |= AccessFlags2.TransferWriteBit; + } + return (stage, access); + } +} diff --git a/Optimum.Render.Vulkan/Present/IPresentPath.cs b/Optimum.Render.Vulkan/Present/IPresentPath.cs index 4fe1f356..687d176e 100644 --- a/Optimum.Render.Vulkan/Present/IPresentPath.cs +++ b/Optimum.Render.Vulkan/Present/IPresentPath.cs @@ -1,4 +1,5 @@ using System; +using Optimum.Render.Vulkan.Graph; using Silk.NET.Vulkan; namespace Optimum.Render.Vulkan.Core; @@ -63,6 +64,11 @@ internal sealed unsafe class BlitPresentPath : IPresentPath private readonly VulkanContext _context; private readonly TextureManager _textures; private readonly Func _source; + /// Created at the first record, so a path built for its stage table alone needs no texture table. + private BarrierBatcher? _barriers; + + /// The acquired image's state; reset per frame, since its contents are discarded. + private readonly ResourceStateTracker _swapchainImage = new(1, 1, depth: false); public BlitPresentPath(VulkanContext context, TextureManager textures, Func source) { @@ -78,15 +84,20 @@ public void Record(CommandBuffer commandBuffer, in PresentTarget target) Image destination = target.Image; // Every present leaves the swapchain image in PRESENT_SRC, and nothing - // else writes it, so UNDEFINED discards nothing that matters. - TransitionSwapchainImage(commandBuffer, destination, - ImageLayout.Undefined, ImageLayout.TransferDstOptimal, - PipelineStageFlags2.TransferBit, PipelineStageFlags2.TransferBit); + // else writes it, so UNDEFINED discards nothing that matters. The + // destination and the source move in one barrier command. + // The acquire touched it last, at the stage this submission waits on it. + BarrierBatcher barriers = _barriers ??= _textures.CreateBatcher(); + _swapchainImage.Reset((PipelineStageFlags2)(ulong)AcquireWaitStage); + barriers.Require(destination, ImageAspectFlags.ColorBit, _swapchainImage, 0, 1, 0, 1, + ResourceUsage.TransferDst, discard: true); VulkanTexture? source = _source(); + if (source != null) _textures.Require(barriers, commandBuffer, source, ResourceUsage.TransferSrc); + barriers.Flush(commandBuffer); + if (source != null) { - _textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal); var blit = new ImageBlit { @@ -105,35 +116,9 @@ public void Record(CommandBuffer commandBuffer, in PresentTarget target) 1, &blit, Filter.Linear); } - TransitionSwapchainImage(commandBuffer, destination, - ImageLayout.TransferDstOptimal, ImageLayout.PresentSrcKhr, - PipelineStageFlags2.TransferBit, PipelineStageFlags2.BottomOfPipeBit); - } - - private void TransitionSwapchainImage( - CommandBuffer commandBuffer, Image image, ImageLayout from, ImageLayout to, - PipelineStageFlags2 srcStage, PipelineStageFlags2 dstStage) - { - var barrier = new ImageMemoryBarrier2 - { - SType = StructureType.ImageMemoryBarrier2, - SrcStageMask = srcStage, - SrcAccessMask = from == ImageLayout.TransferDstOptimal ? AccessFlags2.TransferWriteBit : AccessFlags2.None, - DstStageMask = dstStage, - DstAccessMask = to == ImageLayout.TransferDstOptimal ? AccessFlags2.TransferWriteBit : AccessFlags2.None, - OldLayout = from, - NewLayout = to, - Image = image, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), - }; - - var dependency = new DependencyInfo - { - SType = StructureType.DependencyInfo, - ImageMemoryBarrierCount = 1, - PImageMemoryBarriers = &barrier, - }; - _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency); - VulkanStats.NoteImageBarriers(1); + // TRANSFER_DST (written at TRANSFER) to PRESENT_SRC (BOTTOM_OF_PIPE, no access). + barriers.Require(destination, ImageAspectFlags.ColorBit, _swapchainImage, 0, 1, 0, 1, + ResourceUsage.PresentSrc, discard: false); + barriers.Flush(commandBuffer); } } diff --git a/Optimum.Render.Vulkan/Transfer/UploadManager.cs b/Optimum.Render.Vulkan/Transfer/UploadManager.cs index cde23781..1b9169a1 100644 --- a/Optimum.Render.Vulkan/Transfer/UploadManager.cs +++ b/Optimum.Render.Vulkan/Transfer/UploadManager.cs @@ -237,15 +237,19 @@ public void UploadToBuffer(VulkanBuffer destination, ulong offset, IntPtr source // command buffer (a later one in the same submission) that reads it // after. Synchronization validation reports both as hazards without an // explicit buffer barrier on each side (2026-09-11, the staged index - // buffer of AsyncTransferTests). + // buffer of AsyncTransferTests). The other side of each barrier is + // every use the buffer was created for (vertex/index input, uniform or + // storage reads, indirect, transfer), not ALL_COMMANDS. + (PipelineStageFlags2 readerStage, AccessFlags2 readerAccess) = + Graph.BufferUsageState.UsesOf(destination.Usage); BufferBarrier(commandBuffer, destination, - PipelineStageFlags2.AllCommandsBit, AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit, + readerStage, readerAccess, PipelineStageFlags2.CopyBit, AccessFlags2.TransferWriteBit); var copy = new BufferCopy { SrcOffset = staging.Offset, DstOffset = offset, Size = size }; _context.Api.CmdCopyBuffer(commandBuffer, staging.Buffer, destination.Handle, 1, ©); BufferBarrier(commandBuffer, destination, PipelineStageFlags2.CopyBit, AccessFlags2.TransferWriteBit, - PipelineStageFlags2.AllCommandsBit, AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit); + readerStage, readerAccess); NoteUse(commandBuffer, destination); } finally diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index cf58dc9b..a26af1a0 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -367,6 +367,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // An inline upload records transfer commands into the frame command // buffer, which no rendering scope may enclose. _uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer); + // A barrier flushed into the frame command buffer while a scope is open + // is a transition inside the scope; debug builds reject it. + _textures.ScopeOpen = commandBuffer => + _frameActive && _targets.RenderingActive && commandBuffer.Handle == Commands.Handle; + _barriers = _textures.CreateBatcher(); _pipelines = new GraphicsPipelineCache(_context); _descriptors = new DescriptorCache(_context); _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; @@ -1973,6 +1978,12 @@ private bool SamplesBoundDepthWithoutWriting(ShaderProgramResources program) return false; } + /// + /// The frame thread's barriers for sampled textures and feedback snapshots: + /// every texture a draw samples moves in one barrier command. + /// + private Graph.BarrierBatcher _barriers = null!; + private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgramResources program) { _sampledTextureOverrides.Clear(); @@ -2027,10 +2038,14 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; _targets.EndRendering(commandBuffer); - _textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + _textures.Require(_barriers, commandBuffer, texture, Graph.ResourceUsage.SampleFragment); } - if (!placeholderNeeded) return; + if (!placeholderNeeded) + { + _barriers.Flush(commandBuffer); + return; + } foreach (int id in new[] { @@ -2041,8 +2056,9 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; _targets.EndRendering(commandBuffer); - _textures.TransitionTexture(commandBuffer, placeholder, ImageLayout.ShaderReadOnlyOptimal); + _textures.Require(_barriers, commandBuffer, placeholder, Graph.ResourceUsage.SampleFragment); } + _barriers.Flush(commandBuffer); } private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, VulkanTexture source) @@ -2060,8 +2076,10 @@ private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, VulkanTexture copy = _textures.Get(copyId)!; copy.State = source.State; - _textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal); - _textures.TransitionTexture(commandBuffer, copy, ImageLayout.TransferDstOptimal); + // Source, copy, and any texture this draw already queued: one command. + _textures.Require(_barriers, commandBuffer, source, Graph.ResourceUsage.TransferSrc); + _textures.Require(_barriers, commandBuffer, copy, Graph.ResourceUsage.TransferDst); + _barriers.Flush(commandBuffer); for (uint level = 0; level < source.MipLevels; level++) { var region = new ImageCopy @@ -2074,7 +2092,8 @@ private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, _context.Api.CmdCopyImage(commandBuffer, source.Image, ImageLayout.TransferSrcOptimal, copy.Image, ImageLayout.TransferDstOptimal, 1, ®ion); } - _textures.TransitionTexture(commandBuffer, copy, ImageLayout.ShaderReadOnlyOptimal); + _textures.Require(_barriers, commandBuffer, copy, Graph.ResourceUsage.SampleFragment); + _barriers.Flush(commandBuffer); _sampledTextureOverrides.Add(textureId, copyId); if (RenderTrace.Enabled) RenderTrace.Write("snapshot texture=" + textureId + " copy=" + copyId + diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index d6645829..c965d14c 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -658,8 +658,51 @@ public void UnwrittenFragmentOutputsAreMaskedOffInThePipeline() Assert.Contains("internal static bool FragmentOutputIsAssigned(string source, string name)", layout); string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); Assert.Contains("if (instanceCount <= 0) return;", device); + } + + /// + /// Phase 2 step 1: every image barrier derives its stages from the usage + /// through ResourceStateTracker and is recorded by BarrierBatcher; no barrier + /// on the texture, attachment, present or upload path names ALL_COMMANDS. + /// + [Fact] + public void ImageBarriersGoThroughTheBatcherWithUsageDerivedStages() + { + string batcher = Read("Optimum.Render.Vulkan/Graph/BarrierBatcher.cs"); + Assert.Contains("_api.CmdPipelineBarrier2(commandBuffer, &dependency);", batcher); + Assert.Contains("VulkanStats.NoteBarrierCommand();", batcher); + Assert.Contains("throw new InvalidOperationException(\"image barriers flushed inside an open rendering scope\");", batcher); + Assert.Contains("public static UsageState For(ResourceUsage usage, bool depth)", + Read("Optimum.Render.Vulkan/Graph/ResourceUsage.cs")); + + foreach (string path in new[] + { + "Optimum.Render.Vulkan/Core/TextureManager.cs", + "Optimum.Render.Vulkan/Core/RenderTargetManager.cs", + "Optimum.Render.Vulkan/Present/IPresentPath.cs", + "Optimum.Render.Vulkan/VulkanDevice.cs", + "Optimum.Render.Vulkan/Transfer/ReadbackManager.cs", + }) + { + string source = Read(path); + Assert.DoesNotContain("CmdPipelineBarrier2(", source); + Assert.DoesNotContain("AllCommandsBit", source); + } + string textures = Read("Optimum.Render.Vulkan/Core/TextureManager.cs"); - Assert.Contains("internal static AccessFlags2 AccessForLayout(ImageLayout layout, bool writer)", textures); + Assert.DoesNotContain("AccessForLayout", textures); + Assert.Contains("_barriers.Require(texture, baseMip, mipCount, 0, texture.Layers, usage, discard);", textures); + Assert.Contains("TransitionRange(commandBuffer, texture, level, 1, ResourceUsage.TransferDst, discard: true);", textures); + + string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); + Assert.Contains("_barriers.Flush(commandBuffer);\n\n fixed (RenderingAttachmentInfo* attachmentsPtr = attachments)", + targets.Replace("\r\n", "\n")); + + string uploads = Read("Optimum.Render.Vulkan/Transfer/UploadManager.cs"); + Assert.DoesNotContain("AllCommandsBit", uploads); + Assert.Contains("Graph.BufferUsageState.UsesOf(destination.Usage);", uploads); + + Assert.Contains("barrier_commands={8} barriers_per_frame={9:F1}", Read("Optimum.Render.Vulkan/Core/VulkanStats.cs")); } [Fact] diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 5bc144d3..aade6edb 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -261,7 +261,7 @@ unchanged from earlier builds; the other four carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... ``` @@ -282,7 +282,10 @@ stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar asked for the ReBAR pool class and fell through to host staging memory because no ReBAR type exists, the cap was reached or `OPTIMUM_VULKAN_NO_REBAR=1`; each is also logged), `dynamic_state` (dynamic-state commands), `uniform_ring_used` (peak bytes one frame - slot used) and `uniform_ring_capacity` (bytes per slot). + slot used) and `uniform_ring_capacity` (bytes per slot), `barrier_commands` + (vkCmdPipelineBarrier2 calls carrying image barriers: one per `BarrierBatcher` flush, so + `barriers` / `barrier_commands` is the batching factor) and `barriers_per_frame` (`barriers` + divided by the interval's frames). - `stats.memory`, a snapshot at sample time (Phase 1B step 5): `blocks` (live device allocations the allocator holds), `dedicated` (of them, one-resource blocks), `rebar_used` and `rebar_cap` (ReBAR class bytes and its cap, min(192 MiB, heap budget x 0.25)), `rebar_misses` From 7e4846998c4cfb684e51f0c84263b5093796c614 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:29:27 +0200 Subject: [PATCH 114/226] wip(phase2): merge wave 1 (barriers C1, frame plan C2, stage hooks C3, write masks C4, SSAO noise alpha) Merged worktree-wf_bbf96cb3-29d-1..5 with --no-ff. Conflicts: ResourceUsage.cs add/add (kept the barriers stage's C1), RenderTargetManager.EnsureRendering (took C4, dropped the loop moving draw-buffer-off slots to SampleFragment), VulkanDevice ctor (both), stats.counters tokens (barrier_commands barriers_per_frame mask_restarts feedback_splits), docs/taa-acceptance.md. Verified at this commit: - env -u Platform dotnet build VintageStory.slnx -c Release: 0 Fehler, 6 Warnung(en) - dotnet test Optimum.Tests -c Release: Fehler 0, erfolgreich 1137, uebersprungen 34, gesamt 1171 - dotnet test Optimum.Render.Vulkan.Tests (sync,best): Fehler 0, erfolgreich 590, gesamt 590, 2 m 15 s; no SYNC- lines - Cecil patch-il (lib): 198/198 required methods patched; Virtual dispatch verifier: ok, 25 callvirt/ldvirtftn sites, 0 call/ldftn - check-vanilla-compat: Vanilla compat: ok, 17 skipped - extract-patches: 157 patches, no diff; check-patches: 93 applied, 64 cecil, 0 pending, 0 conflict; runtime 43 applied - worktree-bootstrap.sh --in-place: 157 patches applied, 0 failed Not run in game. From 2e401b43866c45e0ef99b9262414a151694a21ed Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:34:32 +0200 Subject: [PATCH 115/226] wip(docs): correct the Phase 0 jitter record - it returned on Vulkan in later runs; root cause was the TAA resolve The Phase 0 decision row said the user saw no Vulkan jitter; that held for those two runs only. The cause, found 2026-09-11 evening, was taa-resolve.fsh (single-sample depth disocclusion on sub-pixel foliage, no anti-flicker weighting); the fix is ported with tests on fix/taa-antiflicker-disocclusion. --- docs/vulkan-acceptance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 93411206..e02bd863 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -304,7 +304,7 @@ One entry per phase exit or milestone, appended, never edited after the fact. | date | phase / milestone | commit | rows passed | rows failed or deferred (with reason) | evidence paths | decision | |---|---|---|---|---|---|---| -| 2026-09-11 | Phase 0 exit | 906b40f deployed (Phase 0 merged at cdd7412) | V0.1 and V0.2 recorded, V0.3 and V0.4 recorded, V0.5 pass (build 0 errors, Optimum.Tests 1056, GPU 386 with sync,best, check-patches 0 conflicts) | section 0 fixed scene not applied; Vulkan fails the pacing gate on p99, stddev and blocking uploads (the Milestone 1 target, not a Phase 0 gate) | `docs/gpu-verification-2026-09-11/phase0/` | Phase 0 accepted; Phase 1A and 1B start. M1.6 changed to a noise-floor rule. User observed no Vulkan jitter on these runs (driver 615.71.09, sky-direction fix not deployed). | +| 2026-09-11 | Phase 0 exit | 906b40f deployed (Phase 0 merged at cdd7412) | V0.1 and V0.2 recorded, V0.3 and V0.4 recorded, V0.5 pass (build 0 errors, Optimum.Tests 1056, GPU 386 with sync,best, check-patches 0 conflicts) | section 0 fixed scene not applied; Vulkan fails the pacing gate on p99, stddev and blocking uploads (the Milestone 1 target, not a Phase 0 gate) | `docs/gpu-verification-2026-09-11/phase0/` | Phase 0 accepted; Phase 1A and 1B start. M1.6 changed to a noise-floor rule. User observed no Vulkan jitter on these two runs (driver 615.71.09, sky-direction fix not deployed); correction 2026-09-11 evening: the distance jitter was back on Vulkan in every later run, and OpenGL never shows it. | | 2026-09-11 | Phase 1 exit (1A + 1B) | f373c4a deployed | both renderers start; forced-install-failure fallback renders on OpenGL; sync,best validation 0 errors; Vulkan blocking uploads 0 in all samples; Vulkan pacing better than Phase 0 on mean, p99 and stddev; build 0 errors, Optimum.Tests 1128, GPU 494 | Vulkan p99 fails 1.5 x mean; 10-minute session, window resize/alt-tab/minimise loop, sun glare and fork bridge on screen carried to Milestone 1; OpenGL pacing found bimodal between launches (A/B/A), not a regression | `docs/gpu-verification-2026-09-11/phase1/` | Phase 1 accepted; Phase 2 (frame graph) starts; M1.1 now interleaves runs | ## 6. Vendor matrix From dd6fa5bc2910294f49b08d483f565ad75e7f761e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:39:15 +0200 Subject: [PATCH 116/226] wip(taa): take taa-resolve.fsh anti-flicker + 3x3 nearest-depth disocclusion from b0a473f, with the 2026-09-11 root-cause header comments --- sources/shaders/taa-resolve.fsh | 101 ++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 13 deletions(-) diff --git a/sources/shaders/taa-resolve.fsh b/sources/shaders/taa-resolve.fsh index ebab9b82..4af0ec06 100644 --- a/sources/shaders/taa-resolve.fsh +++ b/sources/shaders/taa-resolve.fsh @@ -99,6 +99,11 @@ void main(void) // ---- current frame: 3x3 neighbourhood, un-jittered reconstruction and statistics vec4 centreSample = texelFetch(sceneTex, pixel, 0); + // Nearest window depth in the 3x3 (0 = near): its motion and its linear depth + // drive the reprojection and the disocclusion test, so a sub-pixel leaf in front + // of a far background keeps one consistent answer across jitter phases. + float closestDepth = 2.0; + ivec2 closestPixel = pixel; vec4 filtered = vec4(0.0); float filteredWeight = 0.0; vec3 m1 = vec3(0.0), m2 = vec3(0.0); @@ -108,6 +113,8 @@ void main(void) { ivec2 p = clamp(pixel + ivec2(x, y), ivec2(0), ivec2(renderSize) - ivec2(1)); vec4 c = texelFetch(sceneTex, p, 0); + float tapDepth = texelFetch(depthTex, p, 0).r; + if (tapDepth < closestDepth) { closestDepth = tapDepth; closestPixel = p; } vec3 ycc = rgbToYCoCg(c.rgb); m1 += ycc; m2 += ycc * ycc; boxMin = min(boxMin, ycc); boxMax = max(boxMax, ycc); @@ -133,19 +140,24 @@ void main(void) vec4 worldH = invViewProjJittered * vec4(ndc, depth * 2.0 - 1.0, 1.0); vec3 world = worldH.xyz / max(abs(worldH.w), 1e-6) * sign(worldH.w); float linearDepth = -(viewMatrix * vec4(world, 1.0)).z; + vec2 closestCentre = vec2(closestPixel) + 0.5; + vec2 closestNdc = closestCentre * invSize * 2.0 - 1.0; + vec4 closestH = invViewProjJittered * vec4(closestNdc, closestDepth * 2.0 - 1.0, 1.0); + vec3 closestWorld = closestH.xyz / max(abs(closestH.w), 1e-6) * sign(closestH.w); + float closestLinearDepth = -(viewMatrix * vec4(closestWorld, 1.0)).z; vec4 glow = texelFetch(glowTex, pixel, 0); // ---- motion: written vector when its depth matches, else camera reprojection - vec4 motion = texelFetch(motionTex, pixel, 0); - float reactive = clamp(motion.b, 0.0, 1.0); - vec2 currentUnjittered = pixelCentre - jitterPx; + float reactive = clamp(texelFetch(motionTex, pixel, 0).b, 0.0, 1.0); + vec4 motion = texelFetch(motionTex, closestPixel, 0); + vec2 currentUnjittered = closestCentre - jitterPx; vec2 mv; // motion.a is the writer's window depth in [0,1], stored in an RGBA16F // attachment: half precision alone costs ~5e-4 near 1.0, so the tolerance // has to scale with the value and keep a floor for depths near the near // plane. A fixed 1e-4 rejected every legitimate writer past mid-range. - bool written = motion.a > 0.0 && abs(motion.a - depth) <= max(2e-4, 8e-4 * depth); + bool written = motion.a > 0.0 && abs(motion.a - closestDepth) <= max(2e-4, 8e-4 * closestDepth); if (written) { mv = motion.rg; @@ -156,18 +168,18 @@ void main(void) // reproject it with w = 0 so camera translation cannot move it (plan: // "infinite-direction reprojection where depth == 1"). Finite surfaces // translate by cameraDelta into the previous camera's frame. - bool sky = depth >= 0.999999; + bool sky = closestDepth >= 0.999999; // The sky direction is far point minus near point, never the far point's // position alone: the view matrix's eye sits ~1.7 blocks above the origin // (CameraMatrixOrigin is a look-at from LocalEyePos), and that offset in a // "direction" is a fixed ~0.6 px error at 3000 blocks. Homogeneous // difference with the sign of worldH.w * nearH.w, w == 0 counting as // positive, exactly as taa-skymotion.fsh does. - vec4 nearH = invViewProjJittered * vec4(ndc, -1.0, 1.0); - vec3 skyDirection = worldH.xyz * nearH.w - nearH.xyz * worldH.w; - if ((worldH.w < 0.0) != (nearH.w < 0.0)) skyDirection = -skyDirection; + vec4 nearH = invViewProjJittered * vec4(closestNdc, -1.0, 1.0); + vec3 skyDirection = closestH.xyz * nearH.w - nearH.xyz * closestH.w; + if ((closestH.w < 0.0) != (nearH.w < 0.0)) skyDirection = -skyDirection; vec4 prevClip = sky ? prevViewProj * vec4(skyDirection, 0.0) - : prevViewProj * vec4(world + cameraDelta, 1.0); + : prevViewProj * vec4(closestWorld + cameraDelta, 1.0); if (prevClip.w <= 1e-6) { outColor = current; outGlow = glow; outDepth = vec4(linearDepth); return; } vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize; mv = prevPixel - currentUnjittered; @@ -182,7 +194,8 @@ void main(void) // ---- history sample and rejection float alpha = blendAlpha; bool offscreen = any(lessThan(historyUv, vec2(0.0))) || any(greaterThan(historyUv, vec2(1.0))); - if (resetHistory != 0 || offscreen) alpha = 1.0; + bool rejected = resetHistory != 0 || offscreen; + if (rejected) alpha = 1.0; vec4 history = sampleCatmullRom(historyColor, historyUv); vec4 historyGlowSample = texture(historyGlow, historyUv); @@ -199,20 +212,82 @@ void main(void) historyGlowSample = glow; historyLinear = linearDepth; alpha = 1.0; + rejected = true; } // Disocclusion: the surface seen last frame at that location must be at a // comparable distance. Tolerance grows with distance; camera translation // along the view axis is covered by the relative term. A disoccluded pixel // has no valid history at all, so it is rejected outright - half-rejecting // it just blends in whatever surface used to be in front. - float depthTolerance = 0.5 + 0.08 * linearDepth; - if (abs(historyLinear - linearDepth) > depthTolerance) alpha = 1.0; - alpha = max(alpha, reactive); + // ==== 2026-09-11: distant foliage jitter was THIS test ==================== + // Root cause: a single-sample depth test (this pixel's linear depth against + // the one history depth under historyUv) rejected history on ~3.7% of distant + // leaf pixels per frame, on BOTH backends (parity dumps). A sub-pixel leaf + // covers the leaf in one jitter phase and the far background in the next, so + // the two depths disagree by tens of blocks and the pixel reset to the raw + // aliased sample - the shimmer the user saw on distant trees. + // Fix: the nearest current depth in the 3x3 (closestLinearDepth, the tap the + // motion vector also comes from) against the nearest finite history depth in + // the 3x3 around historyUv, tolerance 0.5 + 0.08 * closestLinearDepth. A leaf + // that moves one pixel between phases stays inside both windows and keeps its + // history. Measured: leaf-far rejection ~3.7% -> ~1.1% per frame; the user + // confirmed on Vulkan that the distant-foliage flicker is gone. + // Guard: scripts/dev/taa-rejection.py on a parity dump (3x3 leaf-far <= 1.5%). + // DO NOT REVERT to a single-sample depth test. Pinned by + // TaaResolveTests.FlippingSubPixelLeafKeepsItsHistory, + // TaaResolveTests.DisocclusionLargerThanTheNeighbourhoodStillResets, + // TaaResolveTests.MotionComesFromTheNearestDepthTapAtAnEdge and + // Optimum.Tests TaaAntiFlickerCoverageTests. + // ========================================================================== + // Nearest history depth in the 3x3 around the reprojected point, against the + // nearest current depth: a single-sample test flips on sub-pixel foliage every + // few frames (leaf in one jitter phase, background in the next) and threw the + // history away on ~3.7% of distant leaf pixels per frame. + float historyNearest = historyLinear; + for (int hy = -1; hy <= 1; hy++) + for (int hx = -1; hx <= 1; hx++) + { + float h = texture(historyDepth, historyUv + vec2(hx, hy) * invSize).r; + if (!isnan(h) && !isinf(h)) historyNearest = min(historyNearest, h); + } + float depthTolerance = 0.5 + 0.08 * closestLinearDepth; + if (abs(historyNearest - closestLinearDepth) > depthTolerance) { alpha = 1.0; rejected = true; } // ---- rectify and blend in YCoCg with luminance weighting float clipKeep = 1.0; vec3 histYcc = clipToBox(clipMin, clipMax, rgbToYCoCg(history.rgb), clipKeep); vec3 curYcc = rgbToYCoCg(current.rgb); + // ==== 2026-09-11: distant foliage jitter, second half ===================== + // Root cause: with a fixed current weight (blendAlpha for every pixel that + // survived rejection), a sub-pixel leaf that enters and leaves the 3x3 moves + // the neighbourhood clip box every frame, and the clip drags the history + // with it at full blendAlpha - the history itself oscillates. + // Fix: current weight mix(1.2, 0.3, w * w) * blendAlpha with + // w = 1 - |lumCur - lumHist| / max(lumCur, max(lumHist, 0.2)) on the + // rectified YCoCg luminance, only for pixels not rejected above (reset, + // off-screen, NaN history, disocclusion keep alpha = 1); reactive is applied + // after it. Together with the 3x3 nearest-depth test above this took distant + // leaf rejection from ~3.7% to ~1.1% per frame and removed the flicker in game. + // DO NOT REVERT to a fixed blend weight. Pinned by + // TaaResolveTests.AntiFlickerWeightsFollowTheLuminanceDifference and + // Optimum.Tests TaaAntiFlickerCoverageTests. + // ========================================================================== + // Anti-flicker feedback (Playdead INSIDE TAA, 2016): a sub-pixel feature that + // appears in some jitter phases and not in others moves the neighbourhood box + // every frame, and a fixed current weight lets the clip drag the history back + // and forth - the shimmer on distant foliage. Weight the current sample by how + // different it is from the rectified history in luminance: near-identical + // pixels keep more history (0.3 x blendAlpha), real changes take more of the + // current frame (1.2 x blendAlpha). Rejected pixels keep their full reset. + if (!rejected) + { + float lumCur = max(curYcc.x, 0.0); + float lumHist = max(histYcc.x, 0.0); + float unbiasedDiff = abs(lumCur - lumHist) / max(lumCur, max(lumHist, 0.2)); + float unbiasedWeight = 1.0 - unbiasedDiff; + alpha = mix(blendAlpha * 1.2, blendAlpha * 0.3, unbiasedWeight * unbiasedWeight); + } + alpha = max(alpha, reactive); float wCur = alpha / (1.0 + curYcc.x); float wHist = (1.0 - alpha) / (1.0 + histYcc.x); vec3 resolvedYcc = (curYcc * wCur + histYcc * wHist) / max(wCur + wHist, 1e-5); From f6c1376bd673fbccdad25719598b0144d2293c78 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:45:12 +0200 Subject: [PATCH 117/226] wip(phase2-transients): TransientAllocator, pooled ReadSelf copies, post-chain opt-in, stats.transients --- .../PacingStatsTests.cs | 12 +- .../TransientAllocatorTests.cs | 534 ++++++++++++++++++ Optimum.Render.Vulkan/Core/TextureManager.cs | 83 ++- Optimum.Render.Vulkan/Core/VulkanAllocator.cs | 32 ++ Optimum.Render.Vulkan/Core/VulkanStats.cs | 70 ++- .../Graph/FeedbackCopyPool.cs | 158 ++++++ .../Graph/ResourceStateTracker.cs | 16 + .../Graph/TextureTransientBacking.cs | 54 ++ .../Graph/TransientAllocator.cs | 328 +++++++++++ .../VulkanClientPlatform.FrameBuffers.cs | 36 +- Optimum.Render.Vulkan/VulkanDevice.cs | 97 +++- .../transient-allocator-coverage-tests.cs | 91 +++ docs/taa-acceptance.md | 10 + 13 files changed, 1489 insertions(+), 32 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/TransientAllocatorTests.cs create mode 100644 Optimum.Render.Vulkan/Graph/FeedbackCopyPool.cs create mode 100644 Optimum.Render.Vulkan/Graph/TextureTransientBacking.cs create mode 100644 Optimum.Render.Vulkan/Graph/TransientAllocator.cs create mode 100644 Optimum.Tests/transient-allocator-coverage-tests.cs diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 2e62aed8..1b76ca9c 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -136,6 +136,12 @@ public void NewStatsLinesCarryStableKeyValueTokens() "mask_restarts=10 feedback_splits=11", VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11))); + Assert.Equal( + "stats.transients transient_mib=1.5 aliased_mib=0.5 heap_peak_mib=64.0 leases=3 aliased_leases=1 " + + "readself_copies=2 readself_pool=4", + VulkanStats.FormatTransientsLine(new TransientSample(1536UL * 1024, 512UL * 1024, 64UL * 1024 * 1024, + 3, 1, 2, 4))); + // The enum and the token table cannot drift apart. Assert.Equal(VulkanStats.WaitSiteCount, Enum.GetValues().Length); Assert.Equal(VulkanStats.WaitSiteCount, VulkanStats.WaitSiteTokens.Length); @@ -153,7 +159,9 @@ public void SampleIsTheOriginalLineFollowedByFourTokenLines() Assert.NotNull(sample); string[] lines = sample!.Split('\n'); - Assert.Equal(5, lines.Length); + Assert.Equal(6, lines.Length); + // Phase 2 step 4: transient and aliased MiB, the Transient pool's heap peak, ReadSelf copies. + Assert.StartsWith("stats.transients transient_mib=", lines[5]); // Phase 1B step 5: pool classes, ReBAR use and misses, used/budget per heap. Assert.StartsWith("stats.memory blocks=", lines[4]); Assert.Matches(new Regex( @@ -175,6 +183,7 @@ public void AcceptanceDocumentNamesEveryStatsToken() VulkanStats.FormatPacingLine(default), VulkanStats.FormatCountersLine(default), VulkanAllocator.FormatMemoryLine(default), + VulkanStats.FormatTransientsLine(default), }) { foreach (Match token in Regex.Matches(line, @"([a-z0-9_]+)=")) @@ -190,6 +199,7 @@ public void AcceptanceDocumentNamesEveryStatsToken() Assert.Contains("stats.waits", doc); Assert.Contains("stats.counters", doc); Assert.Contains("stats.memory", doc); + Assert.Contains("stats.transients", doc); } [Fact] diff --git a/Optimum.Render.Vulkan.Tests/TransientAllocatorTests.cs b/Optimum.Render.Vulkan.Tests/TransientAllocatorTests.cs new file mode 100644 index 00000000..c8ea60db --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TransientAllocatorTests.cs @@ -0,0 +1,534 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using static Optimum.Render.Vulkan.Tests.GpuTest; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 2 step 4: (placement, pooling, aliasing, discard on +/// first use), (ReadSelf copies retired on the timeline), and +/// the device path for both: a post chain reads back the same with aliasing on and off, and +/// stays free of synchronization hazards with aliasing on. +/// +public class TransientAllocatorTests +{ + private readonly ITestOutputHelper _output; + + public TransientAllocatorTests(ITestOutputHelper output) => _output = output; + + private sealed class FakeBacking : ITransientBacking + { + private int _next = 100; + public readonly List Created = new(); + public readonly List Destroyed = new(); + public readonly List Discards = new(); + public readonly Dictionary Rebinds = new(); + public readonly Dictionary Logical = new(); + public int Restores; + + public int Create(TransientImageDesc desc) + { + int id = _next++; + Created.Add(id); + return id; + } + + public void Destroy(int textureId) => Destroyed.Add(textureId); + public ulong BytesOf(int textureId) => 1024; + public bool TryDescribe(int textureId, out TransientImageDesc desc) => Logical.TryGetValue(textureId, out desc); + public void Discard(int textureId) => Discards.Add(textureId); + public void Rebind(int logicalTextureId, int physicalTextureId) => Rebinds[logicalTextureId] = physicalTextureId; + + public void RestoreBindings() + { + Restores++; + Rebinds.Clear(); + } + } + + private sealed class FakeClock : ITimelineClock + { + public ulong FrameRecorded { get; set; } + public ulong TransferRecorded { get; set; } + public ulong FrameCompleted { get; set; } + public ulong TransferCompleted { get; set; } + } + + private static readonly TransientImageDesc Half = new(960, 540, Format.R8G8B8A8Unorm); + private static readonly TransientImageDesc Full = new(1920, 1080, Format.R16G16B16A16Sfloat); + + [Fact] + public void ThePostChainSlotsAreTheTwelveNamedOnes() + { + Assert.Equal(new[] { 2, 3, 4, 7, 8, 9, 10, 13, 14, 15, 18, 21 }, TransientAllocator.PostChainSlots); + Assert.False(TransientAllocator.IsPostChainSlot(0)); + Assert.False(TransientAllocator.IsPostChainSlot(19)); + Assert.True(TransientAllocator.IsPostChainSlot(21)); + } + + [Fact] + public void AliasedTransientsNeverOverlapInPassLifetime() + { + var backing = new FakeBacking(); + var allocator = new TransientAllocator(backing, aliasing: true); + var random = new Random(7); + int aliasedTotal = 0; + var descOf = new Dictionary(); + + for (int frame = 0; frame < 200; frame++) + { + allocator.BeginFrame(); + int first = 0; + for (int i = 0; i < 12; i++) + { + first += random.Next(0, 2); + int last = first + random.Next(0, 4); + TransientImageDesc desc = random.Next(2) == 0 ? Half : Full; + TransientLease lease = allocator.Acquire(desc, first, last); + if (descOf.TryGetValue(lease.TextureId, out TransientImageDesc seen)) Assert.Equal(seen, desc); + else descOf.Add(lease.TextureId, desc); + } + + IReadOnlyList leases = allocator.Leases; + for (int a = 0; a < leases.Count; a++) + { + for (int b = a + 1; b < leases.Count; b++) + { + bool sameImage = leases[a].TextureId == leases[b].TextureId; + Assert.Equal(sameImage, leases[a].Slot == leases[b].Slot); + if (!sameImage) continue; + Assert.True(leases[a].LastPass < leases[b].FirstPass || leases[b].LastPass < leases[a].FirstPass, + "frame " + frame + ": leases " + a + " [" + leases[a].FirstPass + "," + leases[a].LastPass + + "] and " + b + " [" + leases[b].FirstPass + "," + leases[b].LastPass + "] share image " + + leases[a].TextureId); + Assert.True(leases[b].Aliased); + } + } + aliasedTotal += allocator.AliasedLeaseCount; + } + + Assert.True(aliasedTotal > 0, "the random frames never aliased"); + // Every lease starts a new lifetime from UNDEFINED. + Assert.Equal(200 * 12, backing.Discards.Count); + } + + [Fact] + public void TheChainAliasesItsThirdLeaseOntoTheFirstLeasesImage() + { + var backing = new FakeBacking(); + var allocator = new TransientAllocator(backing, aliasing: true); + + for (int frame = 0; frame < 3; frame++) + { + allocator.BeginFrame(); + TransientLease first = allocator.Acquire(Half, 0, 1); + TransientLease second = allocator.Acquire(Half, 1, 2); + TransientLease third = allocator.Acquire(Half, 2, 3); + + Assert.NotEqual(first.TextureId, second.TextureId); + Assert.Equal(first.TextureId, third.TextureId); + Assert.False(first.Aliased); + Assert.False(second.Aliased); + Assert.True(third.Aliased); + Assert.Equal(1024UL, allocator.AliasedBytes); + } + + // Two images, created in the first frame and reused after. + Assert.Equal(2, backing.Created.Count); + Assert.Equal(2, allocator.PhysicalImageCount); + } + + [Fact] + public void WithAliasingOffEveryLeaseKeepsItsOwnImageAndItsContents() + { + var backing = new FakeBacking(); + var allocator = new TransientAllocator(backing, aliasing: false); + int[]? previous = null; + + for (int frame = 0; frame < 3; frame++) + { + allocator.BeginFrame(); + int[] ids = + { + allocator.Acquire(Half, 0, 1).TextureId, + allocator.Acquire(Half, 1, 2).TextureId, + allocator.Acquire(Half, 2, 3).TextureId, + }; + Assert.Equal(3, new HashSet(ids).Count); + Assert.Equal(0, allocator.AliasedLeaseCount); + if (previous != null) Assert.Equal(previous, ids); + previous = ids; + } + + Assert.Equal(3, backing.Created.Count); + Assert.Empty(backing.Discards); + } + + [Fact] + public void LeasesArriveInFrameOrder() + { + var allocator = new TransientAllocator(new FakeBacking(), aliasing: true); + allocator.BeginFrame(); + allocator.Acquire(Half, 3, 4); + Assert.Throws(() => allocator.Acquire(Half, 2, 5)); + Assert.Throws(() => allocator.Acquire(Half, 5, 4)); + } + + [Fact] + public void BindRebindsOnlyWithAliasingOnAndTheNextFrameRestores() + { + var backing = new FakeBacking(); + backing.Logical[7] = Half; + backing.Logical[8] = Half; + + var off = new TransientAllocator(backing, aliasing: false); + off.BeginFrame(); + Assert.Equal(7, off.Bind(7, 0, 1)); + Assert.Empty(backing.Rebinds); + + var on = new TransientAllocator(backing, aliasing: true); + on.BeginFrame(); + int physical7 = on.Bind(7, 0, 0); + int physical8 = on.Bind(8, 1, 1); + Assert.Equal(physical7, physical8); + Assert.Equal(physical7, backing.Rebinds[7]); + Assert.Equal(physical8, backing.Rebinds[8]); + // Not describable (a cube or depth texture): served by itself. + Assert.Equal(9, on.Bind(9, 2, 2)); + + on.BeginFrame(); + Assert.Empty(backing.Rebinds); + } + + [Fact] + public void ImagesUnusedForIdleFramesAreReleased() + { + var backing = new FakeBacking(); + var allocator = new TransientAllocator(backing, aliasing: false); + allocator.BeginFrame(); + allocator.Acquire(Half, 0, 0); + int second = allocator.Acquire(Half, 1, 1).TextureId; + + for (int frame = 0; frame <= TransientAllocator.IdleFrames + 1; frame++) + { + allocator.BeginFrame(); + allocator.Acquire(Half, 0, 0); + } + + Assert.Equal(new[] { second }, backing.Destroyed); + Assert.Equal(1, allocator.PhysicalImageCount); + } + + [Fact] + public void TheFeedbackCopyPoolReleasesCopiesAfterTheTimelinePasses() + { + var clock = new FakeClock { FrameRecorded = 1 }; + int next = 1; + var destroyed = new List(); + var pool = new FeedbackCopyPool(clock, _ => next++, destroyed.Add, idleFrames: 3); + var desc = new FeedbackCopyDesc(16, 16, Format.R8G8B8A8Unorm, 1, 1, false); + var other = new FeedbackCopyDesc(8, 8, Format.R8G8B8A8Unorm, 1, 1, false); + + // Frame 1: two passes reuse one copy; the command stream orders them. + int a = pool.Acquire(desc); + pool.Release(a); + Assert.Equal(a, pool.Acquire(desc)); + Assert.NotEqual(a, pool.Acquire(other)); + pool.Release(a); + pool.EndFrame(); + + // Frame 2: frame 1 has not completed, so its copies wait. + clock.FrameRecorded = 2; + pool.Collect(); + Assert.Equal(1, pool.Retiring); + int b = pool.Acquire(desc); + Assert.NotEqual(a, b); + pool.Release(b); + pool.EndFrame(); + + // Frame 3: frame 1 completed, frame 2 did not. + clock.FrameRecorded = 3; + clock.FrameCompleted = 1; + pool.Collect(); + Assert.Equal(1, pool.Retiring); + Assert.Equal(a, pool.Acquire(desc)); + pool.Release(a); + pool.EndFrame(); + Assert.Equal(3, pool.Created); + + // Nothing taken for more than idleFrames frames once everything completed: destroyed. + clock.FrameCompleted = 100; + for (int frame = 0; frame < 6; frame++) + { + clock.FrameRecorded++; + pool.EndFrame(); + pool.Collect(); + } + Assert.Contains(a, destroyed); + Assert.Contains(b, destroyed); + Assert.Equal(1, pool.Live); // the 'other' copy is still taken + Assert.Equal(1, pool.InUse); + } + + [Fact] + public void ADiscardedImageTransitionsFromUndefinedEvenIntoItsCurrentLayout() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + var output = new List(); + tracker.Require(0, 1, 0, 1, ResourceUsage.ColorWrite, false, output); + output.Clear(); + Assert.Equal(0, tracker.Require(0, 1, 0, 1, ResourceUsage.ColorWrite, false, output)); + + tracker.Discard(); + Assert.Equal(1, tracker.Require(0, 1, 0, 1, ResourceUsage.ColorWrite, false, output)); + BarrierSides sides = output[0].Sides; + Assert.Equal(ImageLayout.Undefined, sides.OldLayout); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, sides.NewLayout); + // The previous lifetime's write stays on the source side. + Assert.Equal(PipelineStageFlags2.ColorAttachmentOutputBit, sides.SrcStage); + Assert.Equal(AccessFlags2.ColorAttachmentWriteBit, sides.SrcAccess); + } + + // ------------------------------------------------------------------ device + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.5, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + private readonly record struct ChainResult(byte[] Pixels, int AliasedLeases, int PhysicalImages, int SyncMessages); + + /// + /// A post chain like bloom's: pass 0 fills transient 1, passes 1 and 2 read the previous + /// transient into the next, pass 3 reads transient 3 into a persistent output. Lifetimes + /// [0,1], [1,2], [2,3]: with aliasing on, transient 3 takes transient 1's image. + /// + private unsafe ChainResult? RunChain(bool aliasing, int frames) + { + const int size = 16; + VulkanDevice seam = NewDevice(); + seam.TransientAliasingOverride = aliasing; + if (!seam.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + _output.WriteLine("Vulkan unavailable: " + failureReason); + seam.Dispose(); + return null; + } + + using (seam) + { + int fill = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(floor(uv.x * 16.0) / 16.0, floor(uv.y * 16.0) / 16.0, 0.25, 1.0); } + """, "chain-fill"); + int rotate = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D src; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) + { + vec4 t = texelFetch(src, ivec2(gl_FragCoord.xy), 0); + outColor = vec4(t.g, t.b, t.r * 0.5 + 0.25, 1.0); + } + """, "chain-rotate"); + seam.SetSamplerUnit(rotate, "src", 0); + + int[] transients = new int[3]; + int[] framebuffers = new int[4]; + for (int i = 0; i < 3; i++) + { + transients[i] = seam.CreateTransientTexture2D(size, size, EnumTextureInternalFormat.Rgba8, 2 + i); + framebuffers[i] = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffers[i], EnumFramebufferAttachment.ColorAttachment0, transients[i], 0); + seam.SetDrawBuffers(framebuffers[i], 1); + } + int output = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + framebuffers[3] = seam.CreateFramebuffer(size, size); + seam.AttachTexture(framebuffers[3], EnumFramebufferAttachment.ColorAttachment0, output, 0); + seam.SetDrawBuffers(framebuffers[3], 1); + Assert.Equal(3, seam.Transients.OptedInCount); + + var pixels = new byte[size * size * 4]; + int aliased = 0; + for (int frame = 0; frame < frames; frame++) + { + seam.BeginFrame(); + for (int pass = 0; pass < 3; pass++) seam.BindTransientForFrame(transients[pass], pass, pass + 1); + aliased += seam.Transients.AliasedLeaseCount; + + for (int pass = 0; pass < 4; pass++) + { + seam.BindFramebuffer(framebuffers[pass]); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + if (pass == 0) + { + seam.UseProgram(fill); + } + else + { + seam.UseProgram(rotate); + seam.BindTexture(0, transients[pass - 1]); + } + seam.DrawFullscreenTriangle(); + } + seam.BindTexture(0, 0); + + if (frame == frames - 1) + { + seam.BindFramebuffer(framebuffers[3]); + fixed (byte* destination = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + seam.Present(); + } + + int physical = seam.Transients.PhysicalImageCount; + AssertClean(seam); + int sync = 0; + List messages = MessagesOf(seam); + lock (messages) + { + foreach (string message in messages) + { + if (message.Contains("SYNC-", StringComparison.Ordinal)) sync++; + } + } + return new ChainResult(pixels, aliased, physical, sync); + } + } + + [SkippableFact] + public void APostChainReadsBackTheSameWithAliasingOnAndOff() + { + const int frames = 5; + ChainResult? off = RunChain(aliasing: false, frames); + Skip.If(off == null, "No usable Vulkan device."); + ChainResult? on = RunChain(aliasing: true, frames); + Assert.NotNull(on); + + _output.WriteLine("aliasing off: aliased leases " + off!.Value.AliasedLeases + ", physical images " + + off.Value.PhysicalImages); + _output.WriteLine("aliasing on: aliased leases " + on!.Value.AliasedLeases + ", physical images " + + on.Value.PhysicalImages); + + Assert.Equal(0, off.Value.AliasedLeases); + Assert.Equal(0, off.Value.PhysicalImages); + Assert.Equal(frames, on.Value.AliasedLeases); + Assert.Equal(2, on.Value.PhysicalImages); + + // Three rotations of (x/16, y/16, 0.25): a known value, so neither run is blank. + int x = 5, y = 9; + int index = (y * 16 + x) * 4; + byte r0 = 80, g0 = 144, b0 = 64; // fill as UNORM8 (5/16, 9/16, 0.25) + (byte r1, byte g1, byte b1) = (g0, b0, Half8(r0)); + (byte r2, byte g2, byte b2) = (g1, b1, Half8(r1)); + (byte r3, byte g3, byte b3) = (g2, b2, Half8(r2)); + _output.WriteLine("pixel " + x + "," + y + ": " + on.Value.Pixels[index] + "," + on.Value.Pixels[index + 1] + + "," + on.Value.Pixels[index + 2] + " expected about " + r3 + "," + g3 + "," + b3); + Assert.InRange(on.Value.Pixels[index], (byte)Math.Max(0, r3 - 2), (byte)Math.Min(255, r3 + 2)); + Assert.InRange(on.Value.Pixels[index + 1], (byte)Math.Max(0, g3 - 2), (byte)Math.Min(255, g3 + 2)); + Assert.InRange(on.Value.Pixels[index + 2], (byte)Math.Max(0, b3 - 2), (byte)Math.Min(255, b3 + 2)); + + Assert.Equal(off.Value.Pixels, on.Value.Pixels); + } + + private static byte Half8(byte value) => (byte)Math.Round(value / 255.0 * 0.5 * 255.0 + 0.25 * 255.0); + + [SkippableFact] + public void AliasingOnHasZeroSyncHazards() + { + ChainResult? on = RunChain(aliasing: true, frames: 8); + Skip.If(on == null, "No usable Vulkan device."); + Assert.Equal(0, on!.Value.SyncMessages); + } + + [SkippableFact] + public unsafe void ReadSelfCopiesArePooledAndRetiredOnTheTimeline() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, """ + #version 330 core + void main() { + gl_Position = vec4(-1 + ((gl_VertexID & 1) << 2), + -1 + ((gl_VertexID & 2) << 1), 0, 1); + } + """, """ + #version 330 core + uniform sampler2D atlas; + out vec4 color; + void main() { + color = texelFetch(atlas, ivec2(gl_FragCoord.x < 1.0 ? 1 : 0, 0), 0); + } + """, "readself-pool"); + byte[] original = { 255, 0, 0, 255, 0, 255, 0, 255 }; + int texture; + fixed (byte* pixels = original) + texture = seam.CreateTexture2D(2, 1, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + int framebuffer = seam.CreateFramebuffer(2, 1); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetSamplerUnit(program, "atlas", 0); + seam.BindTexture(0, texture); + FeedbackCopyPool pool = seam.ReadSelfCopiesForTests; + long copiesBefore = VulkanStats.ReadSelfCopies; + + const int frames = 12; + for (int frame = 0; frame < frames; frame++) + { + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetViewport(0, 0, 2, 1); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + // Two passes that read what they write: each swaps the texels, so the + // second only restores the original if its copy was refreshed. + seam.DrawFullscreenTriangle(); + seam.DrawFullscreenTriangle(); + if (frame == 0) Assert.Equal(1, pool.Created); + seam.Present(); + + var output = new byte[8]; + fixed (byte* destination = output) + seam.ReadDefaultFramebuffer(0, 0, 2, 1, (IntPtr)destination); + Assert.Equal(original, output); + } + + _output.WriteLine("copies created " + pool.Created + ", live " + pool.Live + ", retiring " + + pool.Retiring + ", destroyed " + pool.Destroyed); + // A copy released in one frame is reused once the timeline passed that + // frame, so the pool stays at the frames in flight plus one. + Assert.InRange(pool.Created, 1, 3); + Assert.Equal(pool.Created - pool.Destroyed, pool.Live); + Assert.True(VulkanStats.ReadSelfCopies - copiesBefore >= 2 * frames); + + seam.DeleteFramebuffer(framebuffer); + seam.DeleteTexture(texture); + AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index 36c0ea3a..77b2738a 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -338,7 +338,7 @@ private int Register(VulkanTexture texture) public int Create( uint width, uint height, Format format, uint layers = 1, bool cube = false, bool generateMipmaps = false, - ImageUsageFlags extraUsage = 0) + ImageUsageFlags extraUsage = 0, MemoryPoolClass poolClass = MemoryPoolClass.DeviceImages) { // GL tolerates a zero-sized texture - it creates nothing and carries on - // while Vulkan rejects the extent outright. The client asks for one when @@ -384,7 +384,7 @@ public int Create( MemoryRequirements requirements = VulkanAllocator.ImageRequirements(_context, image, out bool dedicated); MemoryAllocation allocation = _context.Allocator.Allocate( requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, - $"a {width}x{height} {format} image", MemoryPoolClass.DeviceImages, dedicated, default, image); + $"a {width}x{height} {format} image", poolClass, dedicated, default, image); if (api.BindImageMemory(_context.Device, image, allocation.Memory, allocation.Offset) != Result.Success) { api.DestroyImage(_context.Device, image, null); @@ -651,6 +651,8 @@ public void Delete(int textureId, FrameRing? ring = null) _uploads.EnterLock(); try { + // A texture served by a transient image this frame deletes its own image. + RestoreBindingLocked(textureId); VulkanTexture? texture = Get(textureId); if (texture == null) return; @@ -667,6 +669,80 @@ public void Delete(int textureId, FrameRing? ring = null) } } + // ------------------------------------------------------------ transient binds + + // Texture ids that resolve to a transient image for the current frame, and the + // texture each owns. See TransientAllocator.Bind. + private readonly Dictionary _reboundOriginals = new(); + + /// + /// Makes resolve to 's image until + /// . Every path that looks the id up (attachments, + /// samplers, readback) then uses that image, which is how an aliased transient + /// takes the place of a client texture for one frame. + /// + public void Rebind(int id, int physicalId) + { + _uploads.EnterLock(); + try + { + VulkanTexture? physical = Get(physicalId); + if (physical == null || id <= 0 || id >= _textures.Count || id == physicalId) return; + if (!_reboundOriginals.ContainsKey(id)) _reboundOriginals.Add(id, _textures[id]); + _textures[id] = physical; + } + finally + { + _uploads.ExitLock(); + } + } + + /// Undoes every . + public void RestoreBindings() + { + if (_reboundOriginals.Count == 0) return; + _uploads.EnterLock(); + try + { + foreach (KeyValuePair entry in _reboundOriginals) _textures[entry.Key] = entry.Value; + _reboundOriginals.Clear(); + } + finally + { + _uploads.ExitLock(); + } + } + + /// Undoes a of one id; nothing when it is not rebound. + public void RestoreBinding(int id) + { + if (_reboundOriginals.Count == 0) return; + _uploads.EnterLock(); + try + { + RestoreBindingLocked(id); + } + finally + { + _uploads.ExitLock(); + } + } + + private void RestoreBindingLocked(int id) + { + if (_reboundOriginals.Remove(id, out VulkanTexture? original)) _textures[id] = original; + } + + /// Whether currently resolves to another texture's image. + public bool IsRebound(int id) => _reboundOriginals.ContainsKey(id); + + /// The texture's contents stop mattering: its next use transitions from UNDEFINED. + public void DiscardContents(VulkanTexture texture) + { + ResourceStateTracker tracker = texture.Sync; + lock (tracker) tracker.Discard(); + } + // ------------------------------------------------------------------ barriers // Barriers for the immediate transitions below. Uploads record from any @@ -734,6 +810,9 @@ public void Dispose() if (_disposed) return; _disposed = true; + // A rebound id holds a transient image that has its own entry; dispose owners only. + foreach (KeyValuePair entry in _reboundOriginals) _textures[entry.Key] = entry.Value; + _reboundOriginals.Clear(); foreach (VulkanTexture? texture in _textures) texture?.Dispose(); _textures.Clear(); Samplers.Dispose(); diff --git a/Optimum.Render.Vulkan/Core/VulkanAllocator.cs b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs index a7686ba7..feec44ad 100644 --- a/Optimum.Render.Vulkan/Core/VulkanAllocator.cs +++ b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs @@ -334,6 +334,9 @@ internal sealed unsafe class VulkanAllocator : IDisposable private readonly ulong[] _heapBudget; private readonly ulong[] _classBytes = new ulong[PoolClassCount]; private ulong _reBarUsed; + // Block bytes of the Transient class, dedicated ones included, and their peak since the last take. + private ulong _transientBytes; + private ulong _transientPeak; private long _reBarMisses; private long _emptyBlocksFreed; private long _frame; @@ -581,6 +584,34 @@ private void NoteBlockCreated(MemoryBlock block) _heapUsed[block.HeapIndex] += block.Size; _classBytes[(int)(block.Dedicated ? MemoryPoolClass.Dedicated : block.Class)] += block.Size; if (block.Class == MemoryPoolClass.ReBar) _reBarUsed += block.Size; + if (block.Class == MemoryPoolClass.Transient) + { + _transientBytes += block.Size; + if (_transientBytes > _transientPeak) _transientPeak = _transientBytes; + } + } + + /// Block bytes of the Transient pool class, dedicated blocks included. + public ulong TransientHeapBytes + { + get + { + lock (_gate) return _transientBytes; + } + } + + /// + /// The Transient class's peak block bytes since the previous call (the stats + /// sample's heap_peak_mib); the next peak starts from the current use. + /// + public ulong TakeTransientHeapPeak() + { + lock (_gate) + { + ulong peak = Math.Max(_transientPeak, _transientBytes); + _transientPeak = _transientBytes; + return peak; + } } private void NoteBlockReleased(MemoryBlock block) @@ -589,6 +620,7 @@ private void NoteBlockReleased(MemoryBlock block) int index = (int)(block.Dedicated ? MemoryPoolClass.Dedicated : block.Class); _classBytes[index] -= Math.Min(_classBytes[index], block.Size); if (block.Class == MemoryPoolClass.ReBar) _reBarUsed -= Math.Min(_reBarUsed, block.Size); + if (block.Class == MemoryPoolClass.Transient) _transientBytes -= Math.Min(_transientBytes, block.Size); } private void NoteFilled(MemoryBlock block) diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index dceb89a1..3b4c4e83 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -208,6 +208,41 @@ public static void NoteUpload(long elapsedTicks) /// public static void NoteFeedbackSplit() => Interlocked.Increment(ref _feedbackSplits); + private static long _transientBytes; + private static long _aliasedBytesPeak; + private static long _transientLeases; + private static long _aliasedLeases; + private static long _readSelfCopies; + private static long _readSelfPool; + + /// + /// One finished frame's transients (Phase 2 step 4): bytes of transient images + /// (the opted-in textures plus the allocator's physical images), bytes of leases + /// served by an image an earlier lease of the frame used, the lease counts, and the + /// ReadSelf copies the pool holds. + /// + public static void NoteTransientFrame(ulong transientBytes, ulong aliasedBytes, int leases, int aliasedLeases, + int readSelfPool) + { + Interlocked.Exchange(ref _transientBytes, (long)Math.Min(transientBytes, long.MaxValue)); + long aliased = (long)Math.Min(aliasedBytes, long.MaxValue); + long peak = Interlocked.Read(ref _aliasedBytesPeak); + while (aliased > peak) + { + long seen = Interlocked.CompareExchange(ref _aliasedBytesPeak, aliased, peak); + if (seen == peak) break; + peak = seen; + } + Interlocked.Add(ref _transientLeases, leases); + Interlocked.Add(ref _aliasedLeases, aliasedLeases); + Interlocked.Exchange(ref _readSelfPool, readSelfPool); + } + + /// A draw sampled a colour attachment it writes and took a pooled ReadSelf copy. + public static void NoteReadSelfCopy() => Interlocked.Increment(ref _readSelfCopies); + + public static long ReadSelfCopies => Interlocked.Read(ref _readSelfCopies); + public static long MaskRestarts => Interlocked.Read(ref _maskRestarts); public static long FeedbackSplits => Interlocked.Read(ref _feedbackSplits); @@ -344,9 +379,32 @@ public static Result WaitDeviceIdle(Vk api, Device device) FormatPacingLine(FrameIntervals.Snapshot()) + "\n" + FormatWaitsLine(waitCounts, waitMs) + "\n" + FormatCountersLine(counters) + "\n" + - VulkanAllocator.FormatMemoryLine(memorySnapshot); + VulkanAllocator.FormatMemoryLine(memorySnapshot) + "\n" + + FormatTransientsLine(new TransientSample( + TransientBytes: (ulong)Interlocked.Read(ref _transientBytes), + AliasedBytes: (ulong)Interlocked.Exchange(ref _aliasedBytesPeak, 0), + HeapPeakBytes: memory?.TakeTransientHeapPeak() ?? 0, + Leases: Interlocked.Exchange(ref _transientLeases, 0), + AliasedLeases: Interlocked.Exchange(ref _aliasedLeases, 0), + ReadSelfCopies: Interlocked.Exchange(ref _readSelfCopies, 0), + ReadSelfPool: Interlocked.Read(ref _readSelfPool))); } + private static double Mib(ulong bytes) => bytes / (1024.0 * 1024.0); + + /// + /// stats.transients: transient image MiB at the last frame boundary, the + /// interval's largest aliased MiB in one frame, the Transient pool class's peak + /// block MiB, leases and aliased leases over the interval, ReadSelf copies taken + /// over the interval and the copies the pool holds. + /// + public static string FormatTransientsLine(TransientSample sample) => + string.Format(CultureInfo.InvariantCulture, + "stats.transients transient_mib={0:F1} aliased_mib={1:F1} heap_peak_mib={2:F1} leases={3} " + + "aliased_leases={4} readself_copies={5} readself_pool={6}", + Mib(sample.TransientBytes), Mib(sample.AliasedBytes), Mib(sample.HeapPeakBytes), sample.Leases, + sample.AliasedLeases, sample.ReadSelfCopies, sample.ReadSelfPool); + /// /// The allocator whose pool classes and heaps the stats.memory line /// reports; the device sets it at init and clears it at dispose. @@ -414,6 +472,16 @@ internal readonly record struct CounterSample( long MaskRestarts = 0, long FeedbackSplits = 0); +/// The values on the stats.transients line. +internal readonly record struct TransientSample( + ulong TransientBytes, + ulong AliasedBytes, + ulong HeapPeakBytes, + long Leases, + long AliasedLeases, + long ReadSelfCopies, + long ReadSelfPool); + /// Percentiles and spread of the frame-interval ring at one moment. internal readonly record struct FramePacingSnapshot( int Samples, double P50, double P95, double P99, double StdDev, int Stutters); diff --git a/Optimum.Render.Vulkan/Graph/FeedbackCopyPool.cs b/Optimum.Render.Vulkan/Graph/FeedbackCopyPool.cs new file mode 100644 index 00000000..fe2e8011 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/FeedbackCopyPool.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// The shape of a ReadSelf copy: copies with equal descriptions are interchangeable. +internal readonly record struct FeedbackCopyDesc(uint Width, uint Height, Format Format, uint MipLevels, uint Layers, bool Cube); + +/// +/// Pooled ReadSelf copies: the snapshot a draw samples when it reads a colour attachment +/// it is also writing (atlas composition). Replaces the permanent copy per source texture. +/// +/// A pass takes a copy () and gives it back when it ends +/// (). Within the frame being recorded a released copy is reused at +/// once: the commands that sampled it come earlier in the same command stream. A copy +/// released in an earlier frame is reused only after the Frame timeline completed the value +/// recorded when that frame ended (, ), and a +/// copy that stays free for frames is destroyed (the device +/// retires it on the timeline). +/// +internal sealed class FeedbackCopyPool +{ + private sealed class Copy + { + public int TextureId; + public FeedbackCopyDesc Desc; + public ulong RetiredAt; + public long FreeSince; + } + + private readonly ITimelineClock _clock; + private readonly Func _create; + private readonly Action _destroy; + private readonly Dictionary _inUse = new(); + private readonly List _releasedThisFrame = new(); + private readonly List _retiring = new(); + private readonly List _free = new(); + private long _frame; + + public FeedbackCopyPool(ITimelineClock clock, Func create, Action destroy, + int idleFrames = 120) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _create = create ?? throw new ArgumentNullException(nameof(create)); + _destroy = destroy ?? throw new ArgumentNullException(nameof(destroy)); + IdleFrames = idleFrames; + } + + public int IdleFrames { get; } + + /// Copies taken and not yet released. + public int InUse => _inUse.Count; + + /// Copies released in earlier frames whose timeline value has not completed. + public int Retiring => _retiring.Count; + + /// Copies ready for any frame. + public int Free => _free.Count + _releasedThisFrame.Count; + + /// Every copy the pool holds. + public int Live => _inUse.Count + _releasedThisFrame.Count + _retiring.Count + _free.Count; + + /// Copies created so far. + public long Created { get; private set; } + + /// Copies destroyed so far. + public long Destroyed { get; private set; } + + /// A copy of for one pass; its texture id. + public int Acquire(FeedbackCopyDesc desc) + { + Copy? copy = Take(_releasedThisFrame, desc) ?? Take(_free, desc); + if (copy == null) + { + copy = new Copy { TextureId = _create(desc), Desc = desc }; + Created++; + } + _inUse.Add(copy.TextureId, copy); + return copy.TextureId; + } + + /// Gives a copy back at the end of its pass. Unknown ids are ignored. + public void Release(int copyId) + { + if (_inUse.Remove(copyId, out Copy? copy)) _releasedThisFrame.Add(copy); + } + + /// + /// Ends the recorded frame: its released copies wait for the Frame value recorded now. + /// Call before the next frame reserves its value. + /// + public void EndFrame() + { + if (_releasedThisFrame.Count == 0) return; + ulong recorded = _clock.FrameRecorded; + foreach (Copy copy in _releasedThisFrame) + { + copy.RetiredAt = recorded; + _retiring.Add(copy); + } + _releasedThisFrame.Clear(); + } + + /// + /// Frees the copies whose timeline value completed and destroys copies free for more + /// than frames. Call once per frame after . + /// + public void Collect() + { + _frame++; + ulong completed = _clock.FrameCompleted; + int kept = 0; + for (int i = 0; i < _retiring.Count; i++) + { + Copy copy = _retiring[i]; + if (copy.RetiredAt <= completed) + { + copy.FreeSince = _frame; + _free.Add(copy); + } + else + { + _retiring[kept++] = copy; + } + } + _retiring.RemoveRange(kept, _retiring.Count - kept); + + kept = 0; + for (int i = 0; i < _free.Count; i++) + { + Copy copy = _free[i]; + if (_frame - copy.FreeSince > IdleFrames) + { + _destroy(copy.TextureId); + Destroyed++; + } + else + { + _free[kept++] = copy; + } + } + _free.RemoveRange(kept, _free.Count - kept); + } + + private static Copy? Take(List list, FeedbackCopyDesc desc) + { + for (int i = list.Count - 1; i >= 0; i--) + { + if (list[i].Desc != desc) continue; + Copy copy = list[i]; + list.RemoveAt(i); + return copy; + } + return null; + } +} diff --git a/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs b/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs index ec84d083..3209f20b 100644 --- a/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs +++ b/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs @@ -99,6 +99,22 @@ public void Reset(PipelineStageFlags2 priorStage) _split = null; } + /// + /// The contents stop mattering (an aliased transient starts a new lifetime): the + /// next use of every subresource transitions from UNDEFINED, even into the layout + /// it is already in. The uses recorded so far stay, so that barrier's source side + /// still names them. + /// + public void Discard() + { + if (_split == null) + { + _whole = _whole with { Layout = ImageLayout.Undefined }; + return; + } + for (int i = 0; i < _split.Length; i++) _split[i] = _split[i] with { Layout = ImageLayout.Undefined }; + } + /// /// Records a use of a subresource range and appends the barriers it needs to /// , as few rectangles as the state allows. diff --git a/Optimum.Render.Vulkan/Graph/TextureTransientBacking.cs b/Optimum.Render.Vulkan/Graph/TextureTransientBacking.cs new file mode 100644 index 00000000..1855bfb2 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/TextureTransientBacking.cs @@ -0,0 +1,54 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// over the device's texture table: images in the +/// Transient memory pool class, released through the device (descriptor eviction, then +/// destruction once the timelines passed), rebinding by texture id. +/// +internal sealed class TextureTransientBacking : ITransientBacking +{ + private readonly TextureManager _textures; + private readonly Action _release; + + /// The device's texture table. + /// The device's texture release (evicts descriptor sets, retires on the timeline). + public TextureTransientBacking(TextureManager textures, Action release) + { + _textures = textures; + _release = release; + } + + public int Create(TransientImageDesc desc) => + _textures.Create(desc.Width, desc.Height, desc.Format, layers: desc.Layers, + generateMipmaps: desc.MipLevels > 1, poolClass: MemoryPoolClass.Transient); + + public void Destroy(int textureId) => _release(textureId); + + public ulong BytesOf(int textureId) => _textures.Get(textureId)?.Allocation.Size ?? 0; + + public bool TryDescribe(int textureId, out TransientImageDesc desc) + { + VulkanTexture? texture = _textures.Get(textureId); + if (texture == null || texture.Cube || texture.Aspect != ImageAspectFlags.ColorBit) + { + desc = default; + return false; + } + desc = new TransientImageDesc(texture.Width, texture.Height, texture.Format, texture.MipLevels, texture.Layers); + return true; + } + + public void Discard(int textureId) + { + VulkanTexture? texture = _textures.Get(textureId); + if (texture != null) _textures.DiscardContents(texture); + } + + public void Rebind(int logicalTextureId, int physicalTextureId) => _textures.Rebind(logicalTextureId, physicalTextureId); + + public void RestoreBindings() => _textures.RestoreBindings(); +} diff --git a/Optimum.Render.Vulkan/Graph/TransientAllocator.cs b/Optimum.Render.Vulkan/Graph/TransientAllocator.cs new file mode 100644 index 00000000..4d141f8f --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/TransientAllocator.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// What a transient image has to be: two leases with equal descriptions may share one image. +internal readonly record struct TransientImageDesc(uint Width, uint Height, Format Format, uint MipLevels = 1, uint Layers = 1); + +/// +/// One transient lifetime served by a physical image for the current frame. +/// +/// The physical image (a texture id in the device's table). +/// The slot for this frame. +/// First pass of the lifetime, inclusive. +/// Last pass of the lifetime, inclusive. +/// An earlier lease of this frame already used the same image. +/// The image's memory size. +internal readonly record struct TransientLease(int TextureId, int Slot, int FirstPass, int LastPass, bool Aliased, ulong Bytes); + +/// +/// What needs from the device. An interface so placement +/// and pooling can be tested without one (TransientAllocatorTests). +/// +internal interface ITransientBacking +{ + /// Creates an image in the Transient memory pool class and returns its texture id. + int Create(TransientImageDesc desc); + + /// Releases an image; the device retires it on the timeline. + void Destroy(int textureId); + + ulong BytesOf(int textureId); + + /// The description of a texture that can be served by a transient image. + bool TryDescribe(int textureId, out TransientImageDesc desc); + + /// The image's contents stop mattering: its next use transitions from UNDEFINED. + void Discard(int textureId); + + /// Makes resolve to the physical image until . + void Rebind(int logicalTextureId, int physicalTextureId); + + /// Undoes every . + void RestoreBindings(); +} + +/// +/// Physical backing for frame-graph transients (Phase 2 step 4). +/// +/// The graph acquires a transient in frame order with its pass lifetime +/// () and gets the physical image that serves it this frame. Images +/// come from the Transient memory pool class and are kept across frames, one pool per +/// : the k-th slot of a description in a frame takes that +/// description's k-th image, so a frame shaped like the last one creates nothing. +/// +/// Aliasing off (the default): every lease gets its own image and keeps its +/// contents like any texture. Aliasing on (OPTIMUM_VULKAN_ALIAS=1): leases +/// are placed with , so leases whose pass lifetimes do not +/// overlap share an image, and every lease discards: its first use this frame transitions +/// from UNDEFINED (the previous lease's uses stay on that barrier's source side). +/// +/// Placement stays stable while a frame streams in: leases arrive with non-decreasing first +/// passes and their placement ids increase, so 's +/// start order is arrival order and a later lease never moves an earlier one. +/// +/// Framebuffer slots 2, 3, 4, 7, 8, 9, 10, 13, 14, 15, 18 and 21 (the post chain) opt in: +/// their colour textures are created in the Transient pool and registered +/// (), and the graph serves them through . +/// +internal sealed class TransientAllocator +{ + public const string AliasVariable = "OPTIMUM_VULKAN_ALIAS"; + + /// Frames a pooled image may go unused before it is released. + public const int IdleFrames = 120; + + /// The client framebuffer slots whose colour textures are transient. + public static readonly int[] PostChainSlots = { 2, 3, 4, 7, 8, 9, 10, 13, 14, 15, 18, 21 }; + + public static bool IsPostChainSlot(int slot) => Array.IndexOf(PostChainSlots, slot) >= 0; + + /// Aliasing is off unless OPTIMUM_VULKAN_ALIAS=1. + public static bool AliasingFromEnvironment() => Environment.GetEnvironmentVariable(AliasVariable) == "1"; + + private sealed class PhysicalImage + { + public int TextureId; + public ulong Bytes; + public long LastUsedFrame; + } + + private readonly ITransientBacking _backing; + private readonly Dictionary> _pools = new(); + private readonly Dictionary _formatIds = new(); + private readonly Dictionary _usedThisFrame = new(); + private readonly List _intervals = new(); + private readonly List _slotImages = new(); + private readonly List _leases = new(); + private readonly Dictionary _optedIn = new(); + private bool _aliasing; + private long _frame; + private int _lastFirstPass = -1; + + public TransientAllocator(ITransientBacking backing, bool aliasing) + { + _backing = backing ?? throw new ArgumentNullException(nameof(backing)); + _aliasing = aliasing; + } + + /// Whether leases share images. Changes only between frames. + public bool Aliasing + { + get => _aliasing; + set + { + if (_leases.Count > 0) throw new InvalidOperationException("aliasing changes between frames only"); + _aliasing = value; + } + } + + /// The leases handed out since , in order. + public IReadOnlyList Leases => _leases; + + /// Physical images held across frames. + public int PhysicalImageCount + { + get + { + int count = 0; + foreach (List pool in _pools.Values) count += pool.Count; + return count; + } + } + + /// Bytes of the physical images held. + public ulong PhysicalBytes + { + get + { + ulong bytes = 0; + foreach (List pool in _pools.Values) + { + foreach (PhysicalImage image in pool) bytes += image.Bytes; + } + return bytes; + } + } + + /// Bytes of the opted-in logical textures (their own images). + public ulong OptedInBytes + { + get + { + ulong bytes = 0; + foreach (int id in _optedIn.Keys) bytes += _backing.BytesOf(id); + return bytes; + } + } + + /// Bytes of this frame's leases served by an image an earlier lease already used. + public ulong AliasedBytes + { + get + { + ulong bytes = 0; + foreach (TransientLease lease in _leases) + { + if (lease.Aliased) bytes += lease.Bytes; + } + return bytes; + } + } + + public int AliasedLeaseCount + { + get + { + int count = 0; + foreach (TransientLease lease in _leases) + { + if (lease.Aliased) count++; + } + return count; + } + } + + /// Registers a client texture as a transient resource of framebuffer . + public void OptIn(int logicalTextureId, int framebufferSlot) + { + if (logicalTextureId <= 0) return; + _optedIn[logicalTextureId] = framebufferSlot; + } + + /// Drops a deleted texture from the opt-in set. + public void Forget(int logicalTextureId) => _optedIn.Remove(logicalTextureId); + + public bool IsOptedIn(int textureId) => _optedIn.ContainsKey(textureId); + + /// The framebuffer slot an opted-in texture belongs to, or -1. + public int SlotOf(int textureId) => _optedIn.TryGetValue(textureId, out int slot) ? slot : -1; + + public int OptedInCount => _optedIn.Count; + + /// + /// Ends the previous frame's leases and bindings, and releases images that went + /// unused for frames. Call once per frame, before the + /// first . + /// + public void BeginFrame() + { + _backing.RestoreBindings(); + _intervals.Clear(); + _slotImages.Clear(); + _usedThisFrame.Clear(); + _leases.Clear(); + _lastFirstPass = -1; + _frame++; + Trim(); + } + + /// + /// The physical image serving a transient with pass lifetime + /// [, ] this frame. + /// Leases arrive in frame order: a first pass below an earlier lease's is rejected. + /// + public TransientLease Acquire(TransientImageDesc desc, int firstPass, int lastPass) + { + if (firstPass < 0) throw new ArgumentOutOfRangeException(nameof(firstPass), firstPass, "negative pass"); + if (lastPass < firstPass) throw new ArgumentOutOfRangeException(nameof(lastPass), lastPass, "ends before it starts"); + if (firstPass < _lastFirstPass) + { + throw new InvalidOperationException( + "transients are acquired in frame order: first pass " + firstPass + " after " + _lastFirstPass); + } + _lastFirstPass = firstPass; + + int slot; + if (_aliasing) + { + _intervals.Add(new TransientInterval(_intervals.Count, BucketOf(desc), firstPass, lastPass)); + int[] slots = TransientPlacement.Place(_intervals); + slot = slots[slots.Length - 1]; + } + else + { + slot = _slotImages.Count; + } + + bool aliased = slot < _slotImages.Count; + PhysicalImage image; + if (aliased) + { + image = _slotImages[slot]; + } + else + { + // Placement numbers slots densely in start order, which is arrival order here. + if (slot != _slotImages.Count) + throw new InvalidOperationException("placement opened slot " + slot + " with " + _slotImages.Count + " open"); + image = Take(desc); + _slotImages.Add(image); + } + + image.LastUsedFrame = _frame; + if (_aliasing) _backing.Discard(image.TextureId); + + var lease = new TransientLease(image.TextureId, slot, firstPass, lastPass, aliased, image.Bytes); + _leases.Add(lease); + return lease; + } + + /// + /// Serves a client texture for [, ] + /// this frame and returns the texture id that now backs it. With aliasing off the texture + /// keeps its own image; with aliasing on its id resolves to a leased image until the next + /// . + /// + public int Bind(int logicalTextureId, int firstPass, int lastPass) + { + if (!_aliasing) return logicalTextureId; + if (!_backing.TryDescribe(logicalTextureId, out TransientImageDesc desc)) return logicalTextureId; + TransientLease lease = Acquire(desc, firstPass, lastPass); + _backing.Rebind(logicalTextureId, lease.TextureId); + return lease.TextureId; + } + + private PhysicalImage Take(TransientImageDesc desc) + { + if (!_pools.TryGetValue(desc, out List? pool)) + { + pool = new List(); + _pools.Add(desc, pool); + } + + _usedThisFrame.TryGetValue(desc, out int index); + _usedThisFrame[desc] = index + 1; + if (index < pool.Count) return pool[index]; + + int id = _backing.Create(desc); + var image = new PhysicalImage { TextureId = id, Bytes = _backing.BytesOf(id), LastUsedFrame = _frame }; + pool.Add(image); + return image; + } + + private SizeBucket BucketOf(TransientImageDesc desc) + { + if (!_formatIds.TryGetValue(desc, out int id)) + { + id = _formatIds.Count + 1; + _formatIds.Add(desc, id); + } + return new SizeBucket((int)desc.Width, (int)desc.Height, id, 0); + } + + /// Pools serve the k-th slot with the k-th image, so only trailing images can go. + private void Trim() + { + foreach (List pool in _pools.Values) + { + while (pool.Count > 0 && _frame - pool[pool.Count - 1].LastUsedFrame > IdleFrames) + { + _backing.Destroy(pool[pool.Count - 1].TextureId); + pool.RemoveAt(pool.Count - 1); + } + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index 09d7efb3..f4ae1440 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -163,7 +163,8 @@ public override List SetupDefaultFrameBuffers() ssao.ColorTextureIds = new int[2]; // GL_RGB in the vanilla path; the device promotes it, because RGB is // not a guaranteed colour-attachment format in Vulkan. - ssao.ColorTextureIds[0] = device.CreateTexture2DRaw(ssaoWidth, ssaoHeight, 6407, IntPtr.Zero, 0); + // A post-chain transient (Transient pool class); see TransientAllocator.PostChainSlots. + ssao.ColorTextureIds[0] = device.CreateTransientTexture2DRaw(ssaoWidth, ssaoHeight, 6407, 13); device.AttachTexture(ssao.FboId, EnumFramebufferAttachment.ColorAttachment0, ssao.ColorTextureIds[0], 0); device.SetDrawBuffers(ssao.FboId, 1); @@ -201,17 +202,17 @@ public override List SetupDefaultFrameBuffers() } list[13] = ssao; - list[14] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); - list[15] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); + list[14] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8, 14); + list[15] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8, 15); } - list[2] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8); - list[3] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8); - list[9] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8); - list[8] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8); - list[4] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f); - list[7] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba16f); - list[10] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f); + list[2] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8, 2); + list[3] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8, 3); + list[9] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8, 9); + list[8] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8, 8); + list[4] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f, 4); + list[7] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba16f, 7); + list[10] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f, 10); // Optimum: TAA history, render-resolution like Primary. Two slots so the // resolve reads last frame's parity while writing this frame's; never @@ -235,7 +236,7 @@ public override List SetupDefaultFrameBuffers() try { list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(width, height, - EnumTextureInternalFormat.Rgba16f); + EnumTextureInternalFormat.Rgba16f, OptimumTaaSharpenIndex); } catch (Exception error) { @@ -250,7 +251,7 @@ public override List SetupDefaultFrameBuffers() { list[OptimumFsrFramebufferIndex] = CreateOptimumColorTarget( ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y, - EnumTextureInternalFormat.Rgba8); + EnumTextureInternalFormat.Rgba8, OptimumFsrFramebufferIndex); } list[5] = CreateOptimumDepthTarget(width / 4, height / 4); @@ -354,16 +355,19 @@ private void SetupOptimumTextureSampler(int textureId, int filter, int wrap) device.SetTextureParameter(textureId, OptimumGlConstants.TextureWrapT, wrap); } - /// A single-colour-attachment target, as the post chain uses. - private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextureInternalFormat format) + /// + /// A single-colour-attachment target, as the post chain uses. Every caller is a post-chain + /// slot, so the colour texture is a transient (Transient pool class, registered with the + /// device's transient allocator under ). + /// + private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextureInternalFormat format, int slot) { FrameBufferRef target = new FrameBufferRef(); target.Width = width; target.Height = height; target.FboId = device.CreateFramebuffer(width, height); target.ColorTextureIds = new int[1]; - target.ColorTextureIds[0] = device.CreateTexture2D(width, height, format, - EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + target.ColorTextureIds[0] = device.CreateTransientTexture2D(width, height, format, slot); // setupAttachment uses linear filtering and edge clamping. FXAA and // the reduced-resolution blur passes require fractional texel samples. SetupOptimumTextureSampler(target.ColorTextureIds[0], 9729, 33071); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index ddfb92ed..1eea6a61 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -112,10 +112,14 @@ public sealed unsafe class VulkanDevice : IDisposable private readonly int[] _unitSamplerOverrides = new int[GlStateTracker.MaxTextureUnits]; // Atlas composition reads one tile while writing another in the same image. - // Reuse a snapshot image, but refresh its contents before each such draw. - private readonly Dictionary _feedbackCopies = new(); + // Each such draw takes a pooled ReadSelf copy, refreshed before the draw and + // released when the next draw's samplers are resolved (Phase 2 step 4). + private Graph.FeedbackCopyPool _readSelfCopies = null!; private readonly Dictionary _sampledTextureOverrides = new(); + // Physical backing for frame-graph transients (Transient pool class). + private Graph.TransientAllocator _transients = null!; + private int _nextProgramId = 1; private bool _frameActive; private bool _disposed; @@ -374,6 +378,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _textures.ScopeOpen = commandBuffer => _frameActive && _targets.RenderingActive && commandBuffer.Handle == Commands.Handle; _barriers = _textures.CreateBatcher(); + // Transients and ReadSelf copies live in the Transient pool class; both + // release through ReleaseTexture, which retires on the timeline. + _transients = new Graph.TransientAllocator(new Graph.TextureTransientBacking(_textures, ReleaseTexture), + TransientAliasingOverride ?? Graph.TransientAllocator.AliasingFromEnvironment()); + _readSelfCopies = new Graph.FeedbackCopyPool(_frames.Timeline, CreateReadSelfCopy, ReleaseTexture); // Colour write tier (C4): draw buffers and motion windows are write masks. _state.ColorWriteTier = _context.Capabilities.ColorWriteTier; _state.DynamicBlend = _context.Capabilities.DynamicColorBlend; @@ -696,7 +705,17 @@ public void BeginFrame() } _lastFrameStart = frameStart; + // The frame that ended: its ReadSelf copies wait on the Frame value it + // recorded (taken before the ring reserves the next), and its transient + // leases and bindings end. + ReleaseReadSelfCopies(); + _readSelfCopies.EndFrame(); + VulkanStats.NoteTransientFrame(_transients.PhysicalBytes + _transients.OptedInBytes, + _transients.AliasedBytes, _transients.Leases.Count, _transients.AliasedLeaseCount, _readSelfCopies.Live); + _transients.BeginFrame(); + FrameSlot slot = _frames.BeginFrame(); + _readSelfCopies.Collect(); _frameActive = true; _frameCounter++; Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); @@ -1363,6 +1382,62 @@ public int CreateTexture2DRaw(int width, int height, int glInternalFormat, IntPt return id; } + /// + /// A post-chain colour texture (framebuffer slots in + /// ): created in the Transient + /// memory pool class and registered with the transient allocator. Until the frame + /// graph binds it () it behaves like any texture. + /// + public int CreateTransientTexture2D(int width, int height, EnumTextureInternalFormat internalFormat, + int framebufferSlot) + { + int id = _textures.Create((uint)width, (uint)height, GlEnums.TextureFormatFrom(internalFormat), + poolClass: MemoryPoolClass.Transient); + RecordGlInternalFormat(id, (int)internalFormat); + _transients.OptIn(id, framebufferSlot); + return id; + } + + /// with a raw GL internal format token, no pixels. + public int CreateTransientTexture2DRaw(int width, int height, int glInternalFormat, int framebufferSlot) + { + Format format = GlEnums.TextureFormatFromGl(glInternalFormat); + int id = _textures.Create((uint)width, (uint)height, format, poolClass: MemoryPoolClass.Transient); + RecordGlInternalFormat(id, glInternalFormat); + RenderTrace.TextureCreated(id, width, height, format, IntPtr.Zero, 0); + _transients.OptIn(id, framebufferSlot); + return id; + } + + /// + /// Serves a texture for passes [, ] + /// of the current frame through the transient allocator and returns the texture id + /// that backs it (itself unless aliasing is on). Call after BeginFrame, in pass order. + /// + public int BindTransientForFrame(int textureId, int firstPass, int lastPass) => + _transients.Bind(textureId, firstPass, lastPass); + + /// The transient allocator the frame graph acquires physical images from. + internal Graph.TransientAllocator Transients => _transients; + + /// The ReadSelf copy pool. Tests only. + internal Graph.FeedbackCopyPool ReadSelfCopiesForTests => _readSelfCopies; + + /// Forces transient aliasing on or off before Initialize (default: OPTIMUM_VULKAN_ALIAS). + internal bool? TransientAliasingOverride { get; set; } + + private int CreateReadSelfCopy(Graph.FeedbackCopyDesc desc) => + _textures.Create(desc.Width, desc.Height, desc.Format, layers: desc.Layers, cube: desc.Cube, + generateMipmaps: desc.MipLevels > 1, poolClass: MemoryPoolClass.Transient); + + /// Gives the previous draw's ReadSelf copies back to the pool. + private void ReleaseReadSelfCopies() + { + if (_sampledTextureOverrides.Count == 0) return; + foreach (int copy in _sampledTextureOverrides.Values) _readSelfCopies.Release(copy); + _sampledTextureOverrides.Clear(); + } + public int CreateTextureCubeRaw(int size, int glInternalFormat, IntPtr[] facePixels, int bytesPerPixel) { Format format = GlEnums.TextureFormatFromGl(glInternalFormat); @@ -1427,8 +1502,9 @@ public void UploadTexture2DRaw( /// private void ReleaseTexture(int textureId) { - if (_feedbackCopies.Remove(textureId, out int copy)) ReleaseTexture(copy); - _sampledTextureOverrides.Remove(textureId); + if (_sampledTextureOverrides.Remove(textureId, out int copy)) _readSelfCopies?.Release(copy); + _transients?.Forget(textureId); + _textures.RestoreBinding(textureId); VulkanTexture? texture = _textures.Get(textureId); if (texture != null) _descriptors.Release(texture.Id); _textures.Delete(textureId, _frames); @@ -1995,7 +2071,7 @@ private bool SamplesBoundDepthWithoutWriting(ShaderProgramResources program) private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgramResources program) { - _sampledTextureOverrides.Clear(); + ReleaseReadSelfCopies(); if (program.Interface.Samplers.Count == 0) return; bool placeholderNeeded = false; @@ -2079,13 +2155,10 @@ private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, if (_sampledTextureOverrides.ContainsKey(textureId)) return; _targets.EndRendering(commandBuffer); - if (!_feedbackCopies.TryGetValue(textureId, out int copyId)) - { - copyId = _textures.Create(source.Width, source.Height, source.Format, - layers: source.Layers, cube: source.Cube, - generateMipmaps: source.MipLevels > 1); - _feedbackCopies.Add(textureId, copyId); - } + // A pooled ReadSelf copy for this pass (FeedbackCopyPool). + int copyId = _readSelfCopies.Acquire(new Graph.FeedbackCopyDesc(source.Width, source.Height, source.Format, + source.MipLevels, source.Layers, source.Cube)); + VulkanStats.NoteReadSelfCopy(); VulkanTexture copy = _textures.Get(copyId)!; copy.State = source.State; diff --git a/Optimum.Tests/transient-allocator-coverage-tests.cs b/Optimum.Tests/transient-allocator-coverage-tests.cs new file mode 100644 index 00000000..e0d0b723 --- /dev/null +++ b/Optimum.Tests/transient-allocator-coverage-tests.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Phase 2 step 4 (transient allocator) at source level: the post-chain slots opt in through +/// the Transient pool class, aliasing is env-gated and default off, an aliased lease discards, +/// ReadSelf copies are pooled instead of kept per texture, and the stats line exists. +/// The pixels are proven by Optimum.Render.Vulkan.Tests/TransientAllocatorTests.cs. +/// +public class TransientAllocatorCoverageTests +{ + [Fact] + public void ThePostChainSlotsOptIn() + { + string platform = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs"); + Assert.Contains("device.CreateTransientTexture2DRaw(ssaoWidth, ssaoHeight, 6407, 13);", platform); + foreach (int slot in new[] { 14, 15 }) + Assert.Contains("list[" + slot + "] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8, " + slot + ");", platform); + foreach (int slot in new[] { 2, 3, 8, 9, 4, 7, 10 }) + Assert.Contains("list[" + slot + "] = CreateOptimumColorTarget(", platform); + Assert.Contains("EnumTextureInternalFormat.Rgba16f, OptimumTaaSharpenIndex);", platform); + Assert.Contains("EnumTextureInternalFormat.Rgba8, OptimumFsrFramebufferIndex);", platform); + Assert.Contains("target.ColorTextureIds[0] = device.CreateTransientTexture2D(width, height, format, slot);", platform); + + string allocator = Read("Optimum.Render.Vulkan/Graph/TransientAllocator.cs"); + Assert.Contains("PostChainSlots = { 2, 3, 4, 7, 8, 9, 10, 13, 14, 15, 18, 21 };", allocator); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("poolClass: MemoryPoolClass.Transient);", device); + Assert.Contains("_transients.OptIn(id, framebufferSlot);", device); + } + + [Fact] + public void AliasingIsEnvGatedAndDiscardsOnFirstUse() + { + string allocator = Read("Optimum.Render.Vulkan/Graph/TransientAllocator.cs"); + Assert.Contains("public const string AliasVariable = \"OPTIMUM_VULKAN_ALIAS\";", allocator); + Assert.Contains("Environment.GetEnvironmentVariable(AliasVariable) == \"1\"", allocator); + Assert.Contains("int[] slots = TransientPlacement.Place(_intervals);", allocator); + Assert.Contains("if (_aliasing) _backing.Discard(image.TextureId);", allocator); + + string backing = Read("Optimum.Render.Vulkan/Graph/TextureTransientBacking.cs"); + Assert.Contains("poolClass: MemoryPoolClass.Transient", backing); + + string tracker = Read("Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs"); + Assert.Contains("public void Discard()", tracker); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("TransientAliasingOverride ?? Graph.TransientAllocator.AliasingFromEnvironment()", device); + Assert.Contains("_transients.BeginFrame();", device); + } + + [Fact] + public void ReadSelfCopiesArePooledOnTheTimeline() + { + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.DoesNotContain("_feedbackCopies", device); + Assert.Contains("int copyId = _readSelfCopies.Acquire(new Graph.FeedbackCopyDesc(", device); + Assert.Contains("_readSelfCopies.EndFrame();", device); + Assert.Contains("_readSelfCopies.Collect();", device); + Assert.Contains("new Graph.FeedbackCopyPool(_frames.Timeline, CreateReadSelfCopy, ReleaseTexture)", device); + + string pool = Read("Optimum.Render.Vulkan/Graph/FeedbackCopyPool.cs"); + Assert.Contains("if (copy.RetiredAt <= completed)", pool); + } + + [Fact] + public void StatsReportTransientAliasedAndHeapPeak() + { + string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); + Assert.Contains("\"stats.transients transient_mib={0:F1} aliased_mib={1:F1} heap_peak_mib={2:F1} leases={3} \"", stats); + Assert.Contains("HeapPeakBytes: memory?.TakeTransientHeapPeak() ?? 0,", stats); + + string doc = Read("docs/taa-acceptance.md"); + Assert.Contains("stats.transients", doc); + } + + private static string Read(string relativePath) + { + string? directory = AppContext.BaseDirectory; + while (directory != null && !File.Exists(Path.Combine(directory, "VintageStory.slnx"))) + { + directory = Path.GetDirectoryName(directory); + } + Assert.NotNull(directory); + return File.ReadAllText(Path.Combine(directory!, relativePath)); + } +} diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 8af34d68..a603a2c4 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -263,6 +263,7 @@ stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stut stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... +stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= ``` - The first line's "blocking uploads" counts every synchronous setup submission (uploads and @@ -291,6 +292,15 @@ stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar draw-buffer-excluded slot out of the scope, as the final composition does with Primary 1, or let it rejoin). The colour write tier is on the device-up validation log line; `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. +- `stats.transients` (Phase 2 step 4, `TransientAllocator` and `FeedbackCopyPool`): `transient_mib` + (at the last frame boundary: the post-chain colour textures of framebuffer slots 2, 3, 4, 7, 8, 9, + 10, 13, 14, 15, 18 and 21, which live in the Transient pool class, plus the allocator's physical + transient images), `aliased_mib` (the interval's largest per-frame bytes of leases served by an + image an earlier lease of the same frame used; 0 unless `OPTIMUM_VULKAN_ALIAS=1`), + `heap_peak_mib` (peak block MiB of the Transient pool class, dedicated blocks included), + `leases` and `aliased_leases` (over the interval), `readself_copies` (draws that sampled a colour + attachment they write and took a pooled copy) and `readself_pool` (copies the pool holds; a + released copy is reused after the Frame timeline passed the frame that released it). - `stats.memory`, a snapshot at sample time (Phase 1B step 5): `blocks` (live device allocations the allocator holds), `dedicated` (of them, one-resource blocks), `rebar_used` and `rebar_cap` (ReBAR class bytes and its cap, min(192 MiB, heap budget x 0.25)), `rebar_misses` From d91af21a62821accb986067129e87473f18ebf9a Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:46:08 +0200 Subject: [PATCH 118/226] wip(phase2-frame-graph): FrameGraph, PassRecorder, clear promotion and pass declarations in the device (compiles) --- .../PacingStatsTests.cs | 5 +- .../Core/RenderTargetManager.cs | 190 +++++++++++- Optimum.Render.Vulkan/Core/VulkanStats.cs | 54 +++- Optimum.Render.Vulkan/Graph/FrameGraph.cs | 282 ++++++++++++++++++ Optimum.Render.Vulkan/Graph/PassRecorder.cs | 275 +++++++++++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 98 +++++- docs/taa-acceptance.md | 11 +- 7 files changed, 895 insertions(+), 20 deletions(-) create mode 100644 Optimum.Render.Vulkan/Graph/FrameGraph.cs create mode 100644 Optimum.Render.Vulkan/Graph/PassRecorder.cs diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 2e62aed8..aeac3767 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -133,8 +133,9 @@ public void NewStatsLinesCarryStableKeyValueTokens() Assert.Equal( "stats.counters blocking_uploads=1 uploads=2 scopes=3 barriers=4 rebar_fallbacks=5 " + "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 barrier_commands=9 barriers_per_frame=2.0 " + - "mask_restarts=10 feedback_splits=11", - VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11))); + "mask_restarts=10 feedback_splits=11 passes=12 plan_hits=13 plan_misses=14 in_pass_clears=15 " + + "promoted_clears=16 standalone_clears=17 pass_splits=18", + VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18))); // The enum and the token table cannot drift apart. Assert.Equal(VulkanStats.WaitSiteCount, Enum.GetValues().Length); diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 89b4e6ee..25cd97bb 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -44,6 +44,12 @@ internal sealed class VulkanFramebuffer /// reset when the framebuffer is bound again. /// public uint SampledExclusion; + + /// + /// Bound colour slots the declared frame-graph pass leaves out of its scope (the + /// final composition writes Primary 0 and samples Primary 1). Cleared when the pass ends. + /// + public uint PassExclusion; } /// @@ -110,18 +116,32 @@ internal sealed unsafe class RenderTargetManager : IDisposable /// Runs right after vkCmdEndRendering, outside any scope (where a query pool may be reset). public Action? ScopeClosed; - public RenderTargetManager(VulkanContext context, TextureManager textures, GlStateTracker state) + /// The frame graph: declared passes, the plan and promoted clears (Phase 2 step 2). + private readonly FrameGraph _graph; + + /// Opens the one scope of each pass on the frame-graph path. + private readonly PassRecorder _recorder; + + public RenderTargetManager(VulkanContext context, TextureManager textures, GlStateTracker state, + FrameGraph? graph = null) { _context = context; _textures = textures; _state = state; _barriers = textures.CreateBatcher(); + _graph = graph ?? new FrameGraph { Enabled = false }; + _recorder = new PassRecorder(context, textures, _barriers, _graph); // Index 0 is the default framebuffer, installed separately. _framebuffers.Add(null); } public VulkanFramebuffer? Bound => _bound; + + public FrameGraph Graph => _graph; + + /// The declared pass, while one is current (frame-graph path only). + public PassDeclaration? DeclaredPass => _recorder.Declared; public bool RenderingActive => _renderingActive; public VulkanFramebuffer? Get(int id) => @@ -233,7 +253,8 @@ private void NoteFeedbackSplit() /// Whether colour slot is part of the scope the framebuffer opens. private static bool InScope(VulkanFramebuffer framebuffer, int index) => - framebuffer.Color[index].IsBound && ((framebuffer.SampledExclusion >> index) & 1) == 0; + framebuffer.Color[index].IsBound && + (((framebuffer.SampledExclusion | framebuffer.PassExclusion) >> index) & 1) == 0; private bool _needsRestart; @@ -293,6 +314,8 @@ public void Bind(CommandBuffer commandBuffer, int framebufferId) if (ReferenceEquals(framebuffer, _bound)) return; EndRendering(commandBuffer); + // A pass is declared on one target; binding another ends it. + if (_recorder.Declared != null) ClearPassDeclaration(); _bound = framebuffer; _needsRestart = false; @@ -310,10 +333,104 @@ public void Delete(int framebufferId) if (framebuffer == null) return; if (ReferenceEquals(framebuffer, _bound)) _bound = null; + if (ReferenceEquals(framebuffer, _recorder.DeclaredOn)) ClearPassDeclaration(); _framebuffers[framebufferId] = null; _freeIds.Push(framebufferId); } + // ------------------------------------------------------------------- passes + + /// + /// Declares a frame-graph pass on (0: the bound + /// target), binding it. The pass's scope opens lazily at its first draw or in-pass + /// clear, exactly once unless something forces a split. Re-declaring the current + /// pass (same name, target and slots) changes nothing. With the frame graph off this + /// only binds, so the same frame code drives both paths. + /// + public void DeclarePass(CommandBuffer commandBuffer, PassDeclaration declaration, int framebufferId) + { + if (!_graph.Enabled) + { + if (framebufferId > 0) Bind(commandBuffer, framebufferId); + return; + } + + VulkanFramebuffer? target = framebufferId > 0 ? Get(framebufferId) : _bound; + if (target == null) + { + EndPass(commandBuffer); + return; + } + + PassDeclaration? current = _recorder.Declared; + if (current != null && ReferenceEquals(_recorder.DeclaredOn, target) && ReferenceEquals(_bound, target) && + current.Name == declaration.Name && current.ColorSlots == declaration.ColorSlots) + { + return; + } + + EndPass(commandBuffer); + Bind(commandBuffer, target.Id); + + // A new pass is a new use of the target: every bound slot is back in, except + // the slots the pass leaves out so they can be sampled. + uint exclusion = 0; + for (int i = 0; i < target.Color.Length; i++) + { + if (target.Color[i].IsBound && ((declaration.ColorSlots >> i) & 1) == 0) exclusion |= 1u << i; + } + if (target.SampledExclusion != 0 || target.PassExclusion != exclusion) + { + target.SampledExclusion = 0; + target.PassExclusion = exclusion; + target.FormatsId = -1; + } + _recorder.Declare(declaration, target); + } + + /// Ends the current pass, declared or not: closes its scope. No-op with the frame graph off. + public void EndPass(CommandBuffer commandBuffer) + { + if (!_graph.Enabled) return; + EndRendering(commandBuffer); + ClearPassDeclaration(); + } + + private void ClearPassDeclaration() + { + VulkanFramebuffer? target = _recorder.DeclaredOn; + if (target != null && target.PassExclusion != 0) + { + target.PassExclusion = 0; + target.FormatsId = -1; + if (ReferenceEquals(target, _bound) && _renderingActive) _needsRestart = true; + } + _recorder.ClearDeclaration(); + } + + /// + /// Records the clears promoted into as clear-image commands, + /// closing an open scope first: the texture is about to be used some other way (sampled, + /// copied, read back, uploaded to) before any pass attached it. + /// + public void FlushPendingClears(CommandBuffer commandBuffer, VulkanTexture texture) + { + if (!_graph.HasPendingClears || !_graph.HasPendingClear(texture)) return; + EndRendering(commandBuffer); + _recorder.FlushClears(commandBuffer, texture); + } + + /// Every clear still pending at the end of the frame lands as a clear-image command. + public void FlushAllPendingClears(CommandBuffer commandBuffer) + { + if (!_graph.HasPendingClears) return; + EndRendering(commandBuffer); + _recorder.FlushClears(commandBuffer, null); + } + + /// A deleted texture's pending clears are dropped. + public void DropPendingClears(VulkanTexture texture) => _graph.Drop(texture); + // ------------------------------------------------------------------- scopes /// @@ -334,8 +451,12 @@ public void EnsureRendering(CommandBuffer commandBuffer) VulkanFramebuffer framebuffer = _bound; int highest = HighestScopeAttachment(framebuffer); int count = highest + 1; + bool graph = _graph.Enabled; var attachments = new RenderingAttachmentInfo[Math.Max(count, 0)]; + // Frame-graph path: the pass recorder queues the barriers and picks the load ops. + VulkanTexture?[]? scopeColour = graph ? new VulkanTexture?[attachments.Length] : null; + VulkanTexture? scopeDepth = null; for (int i = 0; i < count; i++) { @@ -365,7 +486,8 @@ public void EnsureRendering(CommandBuffer commandBuffer) // Blend state can change inside the scope, so the attachment is // declared for the widest colour use (read and write). - _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.ColorBlend); + if (graph) scopeColour![i] = texture; + else _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.ColorBlend); attachments[i] = new RenderingAttachmentInfo { @@ -392,7 +514,8 @@ public void EnsureRendering(CommandBuffer commandBuffer) ? ImageLayout.DepthReadOnlyOptimal : ImageLayout.DepthAttachmentOptimal; // Read-only depth may be sampled by the draws of this scope. - _textures.Require(_barriers, commandBuffer, depth, + if (graph) scopeDepth = depth; + else _textures.Require(_barriers, commandBuffer, depth, DepthReadOnly ? ResourceUsage.DepthReadOnlySampled : ResourceUsage.DepthWrite); depthAttachment = new RenderingAttachmentInfo { @@ -406,6 +529,12 @@ public void EnsureRendering(CommandBuffer commandBuffer) } } + if (graph) + { + _recorder.Prepare(commandBuffer, framebuffer, scopeColour!, scopeDepth, DepthReadOnly, + FormatsIdOf(framebuffer), _framebuffers, attachments, ref depthAttachment); + } + _barriers.Flush(commandBuffer); fixed (RenderingAttachmentInfo* attachmentsPtr = attachments) @@ -479,7 +608,8 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return; if (_state.ColorMask == 0) return; - EnsureRendering(commandBuffer); + if (_graph.Enabled && !ClearColorOnGraph(commandBuffer, attachment, r, g, b, a)) return; + if (!_graph.Enabled) EnsureRendering(commandBuffer); if (!_renderingActive) return; var clear = new ClearAttachment @@ -497,13 +627,61 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo _context.Api.CmdClearAttachments(commandBuffer, 1, &clear, 1, &rect); } + /// + /// The frame-graph half of a colour clear. A slot outside the scope (sampled or left + /// out by the pass) is not cleared, as the null attachment it opens with would not be. + /// Inside an open pass the clear stays vkCmdClearAttachments and is counted (returns + /// true with the scope open). With no pass open a full-mask clear is promoted into the + /// next scope attaching the image (returns false); a partial glColorMask clear opens + /// the scope and clears in it, as before. + /// + private bool ClearColorOnGraph(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) + { + VulkanFramebuffer target = _bound!; + if (!InScope(target, attachment)) return false; + + if (!_renderingActive || _needsRestart) + { + const ColorComponentFlags all = ColorComponentFlags.RBit | ColorComponentFlags.GBit | + ColorComponentFlags.BBit | ColorComponentFlags.ABit; + if (_state.ColorMask == all) + { + VulkanTexture? texture = _textures.Get(target.Color[attachment].TextureId); + if (texture == null) return false; + EndRendering(commandBuffer); + _graph.PromoteColorClear(texture, target.Color[attachment].Layer, r, g, b, a); + return false; + } + EnsureRendering(commandBuffer); + if (!_renderingActive) return false; + } + + _graph.NoteInPassClear(); + return true; + } + public void ClearDepth(CommandBuffer commandBuffer, float depth) { if (_bound == null || _bound.DepthTextureId <= 0) return; + if (_graph.Enabled) + { + VulkanTexture? texture = _textures.Get(_bound.DepthTextureId); + if (texture == null) return; + if (!_renderingActive || _needsRestart || DepthReadOnly) + { + // No pass open (or the scope is about to change): LOAD_OP_CLEAR on the next scope. + EndRendering(commandBuffer); + SetDepthReadOnly(false); + _graph.PromoteDepthClear(texture, depth); + return; + } + _graph.NoteInPassClear(); + } + // A read-only depth attachment cannot be cleared; a clear is a write. SetDepthReadOnly(false); - EnsureRendering(commandBuffer); + if (!_graph.Enabled) EnsureRendering(commandBuffer); if (!_renderingActive) return; var clear = new ClearAttachment diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index dceb89a1..a14c7442 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -208,6 +208,35 @@ public static void NoteUpload(long elapsedTicks) /// public static void NoteFeedbackSplit() => Interlocked.Increment(ref _feedbackSplits); + private static long _passes; + private static long _planHits; + private static long _planMisses; + private static long _inPassClears; + private static long _promotedClears; + private static long _standaloneClears; + private static long _passSplits; + + /// A frame-graph pass opened its scope (a declared pass, or a scope no declaration covered). + public static void NotePass() => Interlocked.Increment(ref _passes); + + /// A frame whose passes matched the plan solved from the previous frame exactly. + public static void NotePlanHit() => Interlocked.Increment(ref _planHits); + + /// A frame recorded conservatively because it did not match the plan. + public static void NotePlanMiss() => Interlocked.Increment(ref _planMisses); + + /// A clear recorded as vkCmdClearAttachments inside an open pass. + public static void NoteInPassClear() => Interlocked.Increment(ref _inPassClears); + + /// A clear issued with no pass open that became LOAD_OP_CLEAR. + public static void NotePromotedClear() => Interlocked.Increment(ref _promotedClears); + + /// A promoted clear recorded as a clear-image command (its image was used before a pass attached it). + public static void NoteStandaloneClear() => Interlocked.Increment(ref _standaloneClears); + + /// A second rendering scope inside one declared pass. + public static void NotePassSplit() => Interlocked.Increment(ref _passSplits); + public static long MaskRestarts => Interlocked.Read(ref _maskRestarts); public static long FeedbackSplits => Interlocked.Read(ref _feedbackSplits); @@ -332,7 +361,14 @@ public static Result WaitDeviceIdle(Vk api, Device device) BarrierCommands: Interlocked.Exchange(ref _barrierCommands, 0), Frames: frames, MaskRestarts: Interlocked.Exchange(ref _maskRestarts, 0), - FeedbackSplits: Interlocked.Exchange(ref _feedbackSplits, 0)); + FeedbackSplits: Interlocked.Exchange(ref _feedbackSplits, 0), + Passes: Interlocked.Exchange(ref _passes, 0), + PlanHits: Interlocked.Exchange(ref _planHits, 0), + PlanMisses: Interlocked.Exchange(ref _planMisses, 0), + InPassClears: Interlocked.Exchange(ref _inPassClears, 0), + PromotedClears: Interlocked.Exchange(ref _promotedClears, 0), + StandaloneClears: Interlocked.Exchange(ref _standaloneClears, 0), + PassSplits: Interlocked.Exchange(ref _passSplits, 0)); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -390,11 +426,14 @@ public static string FormatCountersLine(CounterSample counters) => string.Format(CultureInfo.InvariantCulture, "stats.counters blocking_uploads={0} uploads={1} scopes={2} barriers={3} rebar_fallbacks={4} " + "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7} " + - "barrier_commands={8} barriers_per_frame={9:F1} mask_restarts={10} feedback_splits={11}", + "barrier_commands={8} barriers_per_frame={9:F1} mask_restarts={10} feedback_splits={11} " + + "passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} " + + "standalone_clears={17} pass_splits={18}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, counters.BarrierCommands, counters.Frames > 0 ? counters.Barriers / (double)counters.Frames : 0.0, - counters.MaskRestarts, counters.FeedbackSplits); + counters.MaskRestarts, counters.FeedbackSplits, counters.Passes, counters.PlanHits, counters.PlanMisses, + counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits); private static long _lastSample; } @@ -412,7 +451,14 @@ internal readonly record struct CounterSample( long BarrierCommands = 0, long Frames = 0, long MaskRestarts = 0, - long FeedbackSplits = 0); + long FeedbackSplits = 0, + long Passes = 0, + long PlanHits = 0, + long PlanMisses = 0, + long InPassClears = 0, + long PromotedClears = 0, + long StandaloneClears = 0, + long PassSplits = 0); /// Percentiles and spread of the frame-interval ring at one moment. internal readonly record struct FramePacingSnapshot( diff --git a/Optimum.Render.Vulkan/Graph/FrameGraph.cs b/Optimum.Render.Vulkan/Graph/FrameGraph.cs new file mode 100644 index 00000000..1fdf4e23 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/FrameGraph.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// How a declared pass treats sampling and scope splits. +[Flags] +public enum PassFlags +{ + None = 0, + + /// + /// Mod-hosted stages sample what they like: at pass entry every render-target + /// texture outside the pass's attachments that is not already shader-readable + /// moves to SHADER_READ_ONLY_OPTIMAL, so an undeclared read does not split. + /// + OpenSampling = 1, + + /// + /// A split (a second vkCmdBeginRendering inside the pass) is expected here and + /// is not traced as a declaration violation. It is still counted. + /// + AllowSplit = 2, +} + +/// +/// One pass as the platform declares it: the target, which of its colour slots +/// take part (the rest are null attachments, so they can be sampled), what it +/// reads, which slots it overwrites completely every frame (transient for the +/// plan), and its sampling policy. Depth follows the target: the scope holds the +/// bound depth attachment, writable or read-only as the draws need. +/// +public sealed class PassDeclaration +{ + /// The bound framebuffer; for the default target. + public const int BoundFramebuffer = 0; + + public const int DefaultFramebuffer = -1; + + public string Name = ""; + + /// Render target id; declares on whatever is bound. + public int FramebufferId = BoundFramebuffer; + + /// Bit i: colour slot i is an attachment of the pass. Default: every bound slot. + public uint ColorSlots = uint.MaxValue; + + /// Texture ids the pass samples (not its own attachments), pre-transitioned at pass entry. + public int[] Reads = Array.Empty(); + + /// + /// Bit i: colour slot i is plainly written over its whole extent by the pass + /// and never needed from a previous frame, so an exactly matching plan may + /// load it DONT_CARE. + /// + public uint TransientSlots; + + public PassFlags Flags; +} + +/// A clear issued with no pass open, waiting for the next use of its image. +internal readonly record struct PendingClear(VulkanTexture Texture, uint Layer, bool Depth, float R, float G, float B, float A); + +/// +/// The streaming frame graph (Vulkan-native plan, Phase 2 step 2). The client's +/// frame is imperative, so passes are declared and recorded in frame order as they +/// happen: asks for a pass index when a pass +/// opens its one rendering scope (), and the load ops come +/// from the solved from the previous frame, applied only +/// while every pass so far matches it exactly. Store ops stay STORE: a streaming +/// recorder cannot know that the rest of the frame will still match (see +/// ). +/// +/// It also holds the clears issued with no pass open (clear promotion): they become +/// LOAD_OP_CLEAR on the next scope that attaches the image, or a standalone clear +/// command when something else touches the image first. +/// +/// OPTIMUM_VULKAN_FRAMEGRAPH=0 turns all of it off and keeps the scope +/// inference path exactly as it was. Render thread only. +/// +internal sealed class FrameGraph +{ + public const string Variable = "OPTIMUM_VULKAN_FRAMEGRAPH"; + + public static bool EnabledByEnvironment => Environment.GetEnvironmentVariable(Variable) != "0"; + + /// Change only between frames. + public bool Enabled { get; set; } = EnabledByEnvironment; + + private readonly Dictionary _names = new(StringComparer.Ordinal); + private readonly List _frame = new(); + private readonly List _pending = new(); + private FramePlan? _plan; + private bool _prefixMatches = true; + + // Totals for tests; VulkanStats carries the interval counters. + public long Passes { get; private set; } + public long DeclaredPasses { get; private set; } + public long Splits { get; private set; } + public long UndeclaredSplits { get; private set; } + public long PlanHits { get; private set; } + public long PlanMisses { get; private set; } + public long InPassClears { get; private set; } + public long PromotedClears { get; private set; } + public long StandaloneClears { get; private set; } + public long PlannedDontCareLoads { get; private set; } + + /// Passes opened in the frame being recorded. + public int PassesThisFrame => _frame.Count; + + /// The plan the current frame is matched against, null before the first frame ended. + public FramePlan? Plan => _plan; + + public int NameId(string name) + { + if (!_names.TryGetValue(name, out int id)) + { + id = _names.Count; + _names.Add(name, id); + } + return id; + } + + /// + /// A pass opens its scope: records its signature and returns its index in the + /// frame. is false for a scope no declaration + /// covered (the inference path inside a graph frame). + /// + public int OpenPass(PassSignature signature, bool declared) + { + int index = _frame.Count; + _prefixMatches = _prefixMatches && _plan != null && !_plan.IsConservative && _plan.MatchesPass(index, signature); + _frame.Add(signature); + Passes++; + if (declared) DeclaredPasses++; + VulkanStats.NotePass(); + if (RenderTrace.Enabled) + { + RenderTrace.Write("pass " + index + " name=" + signature.NameId + " attachments=" + signature.Attachments.Length + + " reads=" + signature.Reads.Length + " " + signature.Width + "x" + signature.Height + + " plan=" + (_prefixMatches ? "match" : "conservative")); + } + return index; + } + + /// + /// The load op for attachment of pass + /// when no clear was promoted into it: the plan's op + /// while the frame so far matches the plan, LOAD otherwise. + /// + public AttachmentLoadOp PlannedLoad(int pass, int attachment) + { + if (!_prefixMatches || _plan == null || pass < 0 || pass >= _plan.PassCount) return AttachmentLoadOp.Load; + AttachmentLoadOp op = _plan.LoadOp(pass, attachment); + if (op == AttachmentLoadOp.DontCare) PlannedDontCareLoads++; + return op; + } + + /// A scope reopened inside a pass that had already opened one. + public void NoteSplit(bool allowed) + { + Splits++; + if (!allowed) UndeclaredSplits++; + VulkanStats.NotePassSplit(); + } + + public void NoteInPassClear() + { + InPassClears++; + VulkanStats.NoteInPassClear(); + } + + /// + /// Ends the frame: a hit when every pass matched the plan the frame was recorded + /// against, then the plan for the next frame is solved from this one. + /// + public void EndFrame() + { + if (_frame.Count > 0) + { + if (_plan != null && !_plan.IsConservative && _plan.Matches(_frame)) + { + PlanHits++; + VulkanStats.NotePlanHit(); + } + else + { + PlanMisses++; + VulkanStats.NotePlanMiss(); + } + _plan = FramePlan.Build(_frame); + } + _frame.Clear(); + _prefixMatches = true; + } + + // ------------------------------------------------------------ clear promotion + + public bool HasPendingClears => _pending.Count > 0; + + public void PromoteColorClear(VulkanTexture texture, uint layer, float r, float g, float b, float a) + { + Replace(new PendingClear(texture, layer, false, r, g, b, a)); + } + + public void PromoteDepthClear(VulkanTexture texture, float depth) + { + Replace(new PendingClear(texture, 0, true, depth, 0, 0, 0)); + } + + private void Replace(PendingClear clear) + { + for (int i = 0; i < _pending.Count; i++) + { + PendingClear existing = _pending[i]; + if (ReferenceEquals(existing.Texture, clear.Texture) && existing.Layer == clear.Layer && + existing.Depth == clear.Depth) + { + _pending[i] = clear; + return; + } + } + _pending.Add(clear); + } + + public bool HasPendingClear(VulkanTexture texture) + { + for (int i = 0; i < _pending.Count; i++) + { + if (ReferenceEquals(_pending[i].Texture, texture)) return true; + } + return false; + } + + /// Takes the clear of one attachment view into a load op, if one is pending. + public bool TakeForLoad(VulkanTexture texture, uint layer, bool depth, out PendingClear clear) + { + for (int i = 0; i < _pending.Count; i++) + { + PendingClear candidate = _pending[i]; + if (!ReferenceEquals(candidate.Texture, texture) || candidate.Depth != depth) continue; + if (!depth && candidate.Layer != layer) continue; + _pending.RemoveAt(i); + clear = candidate; + PromotedClears++; + VulkanStats.NotePromotedClear(); + return true; + } + clear = default; + return false; + } + + /// Takes every clear pending on (null: on every texture) for standalone commands. + public void TakeStandalone(VulkanTexture? texture, List output) + { + for (int i = _pending.Count - 1; i >= 0; i--) + { + if (texture != null && !ReferenceEquals(_pending[i].Texture, texture)) continue; + output.Add(_pending[i]); + _pending.RemoveAt(i); + } + // Oldest first, so two clears never reorder. + output.Reverse(); + } + + public void NoteStandaloneClear() + { + StandaloneClears++; + VulkanStats.NoteStandaloneClear(); + } + + /// A deleted texture's clears are moot. + public void Drop(VulkanTexture texture) + { + for (int i = _pending.Count - 1; i >= 0; i--) + { + if (ReferenceEquals(_pending[i].Texture, texture)) _pending.RemoveAt(i); + } + } +} diff --git a/Optimum.Render.Vulkan/Graph/PassRecorder.cs b/Optimum.Render.Vulkan/Graph/PassRecorder.cs new file mode 100644 index 00000000..33656c53 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/PassRecorder.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Graph; + +/// +/// Opens the one rendering scope of a pass (Vulkan-native plan, Phase 2 step 2). +/// +/// owns the bound target and decides when a scope +/// has to open; on the frame-graph path it hands the attachment set to +/// , which, before vkCmdBeginRendering and outside any +/// scope: +/// +/// records the standalone clears a read or a read-only depth attachment needs +/// (a clear promoted into an image that is read before any pass attaches it); +/// queues the pass-entry barriers: every declared read (or, for an +/// pass, every render-target texture outside +/// the pass) to SHADER_READ_ONLY, every attachment to its attachment usage; +/// records the pass signature with (a second scope +/// in one declared pass is a split, counted, not a new pass); +/// chooses each attachment's load op: CLEAR when a clear was promoted into +/// it, otherwise the plan's op on the pass's first scope, LOAD on a split. +/// +/// The caller flushes the batcher once and begins rendering, so every barrier of +/// the pass is one vkCmdPipelineBarrier2. +/// +internal sealed unsafe class PassRecorder +{ + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly BarrierBatcher _barriers; + private readonly FrameGraph _graph; + private readonly List _clears = new(); + private readonly List _uses = new(); + + public PassRecorder(VulkanContext context, TextureManager textures, BarrierBatcher barriers, FrameGraph graph) + { + _context = context; + _textures = textures; + _barriers = barriers; + _graph = graph; + } + + public FrameGraph Graph => _graph; + + /// The declared pass, while one is current. + public PassDeclaration? Declared { get; private set; } + + /// The target the declared pass was declared on. + public VulkanFramebuffer? DeclaredOn { get; private set; } + + /// Whether the declared pass has opened its scope. + public bool Opened { get; private set; } + + public void Declare(PassDeclaration declaration, VulkanFramebuffer framebuffer) + { + Declared = declaration; + DeclaredOn = framebuffer; + Opened = false; + } + + public void ClearDeclaration() + { + Declared = null; + DeclaredOn = null; + Opened = false; + } + + /// + /// Records every clear pending on (null: on every + /// texture) as a clear-image command. No rendering scope may be open. + /// + public void FlushClears(CommandBuffer commandBuffer, VulkanTexture? texture) + { + if (!_graph.HasPendingClears) return; + _clears.Clear(); + _graph.TakeStandalone(texture, _clears); + foreach (PendingClear clear in _clears) + { + VulkanTexture target = clear.Texture; + _textures.Require(_barriers, commandBuffer, target, ResourceUsage.TransferDst); + _barriers.Flush(commandBuffer); + if (clear.Depth) + { + var value = new ClearDepthStencilValue(clear.R, 0); + var range = new ImageSubresourceRange(target.Aspect, 0, 1, 0, 1); + _context.Api.CmdClearDepthStencilImage(commandBuffer, target.Image, ImageLayout.TransferDstOptimal, + &value, 1, &range); + } + else + { + var value = new ClearColorValue(clear.R, clear.G, clear.B, clear.A); + var range = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, clear.Layer, 1); + _context.Api.CmdClearColorImage(commandBuffer, target.Image, ImageLayout.TransferDstOptimal, + &value, 1, &range); + } + _graph.NoteStandaloneClear(); + if (RenderTrace.Enabled) + { + RenderTrace.Write("standalone clear image=" + target.Image.Handle.ToString("x") + + (clear.Depth ? " depth=" + clear.R : " layer=" + clear.Layer + " rgba=" + clear.R + "," + clear.G + + "," + clear.B + "," + clear.A)); + } + } + _clears.Clear(); + } + + /// + /// Queues the barriers and chooses the load ops of the scope about to open on + /// . holds the texture + /// of every slot in the scope (null for a slot left out) and is filled into + /// ' load ops and clear values; the views and + /// layouts are the caller's. + /// + public void Prepare(CommandBuffer commandBuffer, VulkanFramebuffer framebuffer, VulkanTexture?[] colour, + VulkanTexture? depth, bool depthReadOnly, int formatsId, IReadOnlyList framebuffers, + RenderingAttachmentInfo[] attachments, ref RenderingAttachmentInfo depthAttachment) + { + PassDeclaration? declaration = ReferenceEquals(DeclaredOn, framebuffer) ? Declared : null; + bool split = declaration != null && Opened; + + // 1. Clears promoted into images this pass reads: they must land before the read. + if (_graph.HasPendingClears) + { + if (declaration != null) + { + foreach (int id in declaration.Reads) + { + VulkanTexture? read = _textures.Get(id); + if (read != null && !InScope(read, colour, depth)) FlushClears(commandBuffer, read); + } + if ((declaration.Flags & PassFlags.OpenSampling) != 0) + { + ForEachOpenSamplingCandidate(commandBuffer, framebuffers, colour, depth, flushClears: true); + } + } + // A read-only depth attachment cannot take LOAD_OP_CLEAR. + if (depth != null && depthReadOnly) FlushClears(commandBuffer, depth); + } + + // 2. Pass-entry barriers for the reads. + if (declaration != null) + { + foreach (int id in declaration.Reads) + { + VulkanTexture? read = _textures.Get(id); + if (read == null || InScope(read, colour, depth)) continue; + _textures.Require(_barriers, commandBuffer, read, ResourceUsage.SampleFragment); + } + if ((declaration.Flags & PassFlags.OpenSampling) != 0) + { + ForEachOpenSamplingCandidate(commandBuffer, framebuffers, colour, depth, flushClears: false); + } + } + + // 3. The signature, and the pass index the plan is consulted with. + uint transient = declaration?.TransientSlots ?? 0; + _uses.Clear(); + for (int i = 0; i < colour.Length; i++) + { + if (colour[i] == null) continue; + bool isTransient = ((transient >> i) & 1) != 0; + _uses.Add(new AttachmentUse(framebuffer.Color[i].TextureId, + isTransient ? ResourceUsage.ColorWrite : ResourceUsage.ColorBlend, isTransient)); + } + if (depth != null) + { + _uses.Add(new AttachmentUse(framebuffer.DepthTextureId, + depthReadOnly ? ResourceUsage.DepthReadOnlySampled : ResourceUsage.DepthWrite, false)); + } + + int passIndex; + if (split) + { + passIndex = -1; + bool allowed = (declaration!.Flags & PassFlags.AllowSplit) != 0; + _graph.NoteSplit(allowed); + if (!allowed && RenderTrace.Enabled) + { + RenderTrace.Write("pass split: '" + declaration.Name + "' reopened its scope on framebuffer " + + framebuffer.Id); + } + } + else + { + var signature = new PassSignature + { + NameId = _graph.NameId(declaration?.Name ?? "~implicit"), + Attachments = _uses.ToArray(), + Reads = declaration != null ? (int[])declaration.Reads.Clone() : Array.Empty(), + Width = (int)framebuffer.Width, + Height = (int)framebuffer.Height, + FormatsId = formatsId, + }; + passIndex = _graph.OpenPass(signature, declaration != null); + if (declaration != null) Opened = true; + } + + // 4. Attachment barriers and load ops. + int use = 0; + for (int i = 0; i < colour.Length; i++) + { + VulkanTexture? texture = colour[i]; + if (texture == null) continue; + + AttachmentUse attachment = _uses[use]; + _textures.Require(_barriers, commandBuffer, texture, attachment.Usage); + if (_graph.TakeForLoad(texture, framebuffer.Color[i].Layer, depth: false, out PendingClear clear)) + { + attachments[i].LoadOp = AttachmentLoadOp.Clear; + attachments[i].ClearValue = new ClearValue(new ClearColorValue(clear.R, clear.G, clear.B, clear.A)); + } + else + { + attachments[i].LoadOp = passIndex >= 0 ? _graph.PlannedLoad(passIndex, use) : AttachmentLoadOp.Load; + } + use++; + } + + if (depth != null) + { + _textures.Require(_barriers, commandBuffer, depth, _uses[use].Usage); + if (!depthReadOnly && _graph.TakeForLoad(depth, 0, depth: true, out PendingClear clear)) + { + depthAttachment.LoadOp = AttachmentLoadOp.Clear; + depthAttachment.ClearValue = new ClearValue(depthStencil: new ClearDepthStencilValue(clear.R, 0)); + } + else + { + depthAttachment.LoadOp = passIndex >= 0 ? _graph.PlannedLoad(passIndex, use) : AttachmentLoadOp.Load; + } + } + } + + private static bool InScope(VulkanTexture texture, VulkanTexture?[] colour, VulkanTexture? depth) + { + if (ReferenceEquals(texture, depth)) return true; + for (int i = 0; i < colour.Length; i++) + { + if (ReferenceEquals(colour[i], texture)) return true; + } + return false; + } + + /// + /// Every render-target texture outside the scope that is not already shader-readable: + /// what a mod-hosted stage might sample. + /// + private void ForEachOpenSamplingCandidate(CommandBuffer commandBuffer, IReadOnlyList framebuffers, + VulkanTexture?[] colour, VulkanTexture? depth, bool flushClears) + { + for (int f = 0; f < framebuffers.Count; f++) + { + VulkanFramebuffer? other = framebuffers[f]; + if (other == null) continue; + for (int i = 0; i <= other.Color.Length; i++) + { + int id = i < other.Color.Length ? other.Color[i].TextureId : other.DepthTextureId; + if (id <= 0) continue; + VulkanTexture? texture = _textures.Get(id); + if (texture == null || InScope(texture, colour, depth)) continue; + if (flushClears) + { + FlushClears(commandBuffer, texture); + } + else if (texture.Layout != ImageLayout.ShaderReadOnlyOptimal) + { + _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.SampleFragment); + } + } + } + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index ddfb92ed..8f6ad419 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -32,6 +32,12 @@ public sealed unsafe class VulkanDevice : IDisposable private TextureManager _textures = null!; private MeshManager _meshes = null!; private RenderTargetManager _targets = null!; + + /// + /// The streaming frame graph (Phase 2 step 2). On unless OPTIMUM_VULKAN_FRAMEGRAPH=0; + /// off, declarations only bind and every scope comes from inference as before. + /// + private readonly Graph.FrameGraph _graph = new(); private GraphicsPipelineCache _pipelines = null!; private DescriptorCache _descriptors = null!; @@ -365,7 +371,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _uploads = _frames.Uploads; _textures = new TextureManager(_context, _uploads); _meshes = new MeshManager(_context, _state, _uploads); - _targets = new RenderTargetManager(_context, _textures, _state); + _targets = new RenderTargetManager(_context, _textures, _state, _graph); // An inline upload records transfer commands into the frame command // buffer, which no rendering scope may enclose. _uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer); @@ -846,6 +852,11 @@ public void Present() TextureDump.NoteFrame(); if (TextureDump.Wanted) DumpRequestedTextures(); + // Clears no pass consumed land now: the image keeps them into the next frame. + _targets.FlushAllPendingClears(_frames.Current.CommandBuffer); + _targets.EndPass(_frames.Current.CommandBuffer); + if (_graph.Enabled) _graph.EndFrame(); + // Any open rendering scope has to close before the command buffer ends. _targets.EndRendering(_frames.Current.CommandBuffer); @@ -1401,18 +1412,26 @@ public int CreateTexture2DArray( public void UploadTexture2D( int textureId, int level, int x, int y, int width, int height, - EnumTexturePixelFormat pixelFormat, IntPtr pixels) => + EnumTexturePixelFormat pixelFormat, IntPtr pixels) + { + FlushPendingClears(textureId); _textures.Upload(textureId, level, x, y, (uint)width, (uint)height, pixels, pixelFormat == EnumTexturePixelFormat.Red ? 1 : 4); + } public void UploadTexture2DRaw( int textureId, int level, int x, int y, int width, int height, IntPtr pixels, int bytesPerPixel) { if (bytesPerPixel <= 0) return; + FlushPendingClears(textureId); _textures.Upload(textureId, level, x, y, (uint)width, (uint)height, pixels, bytesPerPixel); } - public void GenerateMipmaps(int textureId) => _textures.GenerateMipmaps(textureId); + public void GenerateMipmaps(int textureId) + { + FlushPendingClears(textureId); + _textures.GenerateMipmaps(textureId); + } public void DeleteTexture(int textureId) => ReleaseTexture(textureId); @@ -1430,7 +1449,11 @@ private void ReleaseTexture(int textureId) if (_feedbackCopies.Remove(textureId, out int copy)) ReleaseTexture(copy); _sampledTextureOverrides.Remove(textureId); VulkanTexture? texture = _textures.Get(textureId); - if (texture != null) _descriptors.Release(texture.Id); + if (texture != null) + { + _descriptors.Release(texture.Id); + _targets.DropPendingClears(texture); + } _textures.Delete(textureId, _frames); // Only a delete that found something is a delete. Deleting an id twice // (framebuffers share a depth texture) otherwise inflated the counter @@ -1465,12 +1488,18 @@ public void BindTexture(int unit, int textureId) } public void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, - int width, int height, IntPtr pixels) => + int width, int height, IntPtr pixels) + { + FlushPendingClears(textureId); _textures.Upload(textureId, 0, x, y, (uint)width, (uint)height, pixels, 4, (uint)layer); + } public void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int y, - int width, int height, short[] pixels) => + int width, int height, short[] pixels) + { + FlushPendingClears(textureId); _textures.UploadNormalizedShorts(textureId, level, x, y, width, height, pixels); + } public void BindTextureCube(int unit, int textureId) => BindTexture(unit, textureId); @@ -1563,6 +1592,55 @@ public void BindDefaultFramebuffer() public void DeleteFramebuffer(int framebufferId) => _targets.Delete(framebufferId); + /// Whether the frame graph records this device's frames. Change only between frames. + internal bool FrameGraphEnabled + { + get => _graph.Enabled; + set => _graph.Enabled = value; + } + + /// The frame graph's totals. Tests only. + internal Graph.FrameGraph FrameGraphForTests => _graph; + + /// The bound render target's id (0 before any bind). + internal int BoundFramebufferId => _targets.Bound?.Id ?? 0; + + /// The render target standing for the default framebuffer (0 when headless). + internal int DefaultFramebufferId => _defaultFramebuffer; + + /// + /// Declares the next frame-graph pass and binds its target + /// (: 0 the bound target, -1 the + /// default one). With the frame graph off it only binds. + /// + internal void DeclarePass(Graph.PassDeclaration declaration) + { + if (!_frameActive) return; + int id = declaration.FramebufferId == Graph.PassDeclaration.DefaultFramebuffer + ? _defaultFramebuffer + : declaration.FramebufferId; + if (id == 0 && declaration.FramebufferId == Graph.PassDeclaration.DefaultFramebuffer) + { + _targets.EndPass(Commands); + return; + } + _targets.DeclarePass(Commands, declaration, id); + } + + /// Ends the current pass (closes its scope). No-op with the frame graph off. + internal void EndPass() + { + if (_frameActive) _targets.EndPass(Commands); + } + + /// Lands the clears promoted into a texture before it is written some other way. + private void FlushPendingClears(int textureId) + { + if (!_frameActive || !_graph.HasPendingClears) return; + VulkanTexture? texture = _textures.Get(textureId); + if (texture != null) _targets.FlushPendingClears(Commands, texture); + } + public void ClearColor(int attachment, float r, float g, float b, float a) { if (RenderTrace.Enabled) @@ -2019,6 +2097,9 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra continue; } + // A clear promoted into it has to land before the read (frame graph). + _targets.FlushPendingClears(commandBuffer, texture); + // Sampled by this frame command buffer: a later upload to it this // frame must go inline, after this draw, as it would on GL. _uploads.NoteUse(commandBuffer, texture); @@ -2604,6 +2685,10 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta /// vkCmdBeginRendering calls of this device. Tests only. internal long ScopesOpenedForTests => _targets.ScopesOpened; + /// A texture's current layout. Tests only. + internal ImageLayout TextureLayoutForTests(int textureId) => + _textures.Get(textureId)?.Layout ?? ImageLayout.Undefined; + /// Restarts that reopened an identical attachment set; must stay 0. Tests only. internal long MaskRestartsForTests => _targets.MaskRestarts; @@ -2879,6 +2964,7 @@ private void ReadBack(VulkanTexture texture, int x, int y, uint width, uint heig { if (_frameActive) { + _targets.FlushPendingClears(Commands, texture); _targets.EndRendering(Commands); ReadbackTicket ticket = _readbacks.CopyToHost(texture, x, y, width, height, aspect, bytes); SubmitPartial(); diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 8af34d68..edd20531 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -261,7 +261,7 @@ unchanged from earlier builds; the other four carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... ``` @@ -289,7 +289,14 @@ stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar reopened an identical attachment set; draw buffers and motion windows are write masks since Phase 2 contract C4, so this must be 0) and `feedback_splits` (restarts that took a sampled, draw-buffer-excluded slot out of the scope, as the final composition does with Primary 1, or - let it rejoin). The colour write tier is on the device-up validation log line; + let it rejoin). Frame graph (Phase 2 step 2, `OPTIMUM_VULKAN_FRAMEGRAPH=0` turns it off): + `passes` (passes that opened their scope, declared or not), `pass_splits` (extra scopes + inside one declared pass; `scopes` = `passes` + `pass_splits`), `plan_hits` and + `plan_misses` (frames that did or did not match the load/store plan solved from the previous + frame), `in_pass_clears` (clears recorded as vkCmdClearAttachments inside an open pass), + `promoted_clears` (clears issued with no pass open that became LOAD_OP_CLEAR) and + `standalone_clears` (promoted clears whose image was used before a pass attached it, recorded + as a clear-image command). The colour write tier is on the device-up validation log line; `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. - `stats.memory`, a snapshot at sample time (Phase 1B step 5): `blocks` (live device allocations the allocator holds), `dedicated` (of them, one-resource blocks), `rebar_used` and From 98d3a63542f595f98e64d1d0573f6b5054a30a0f Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:51:43 +0200 Subject: [PATCH 119/226] wip(phase2-transients): keep the pinned post-chain helper text; tag transient slots after setup Verified: dotnet build VintageStory.slnx -c Release 0 errors; Optimum.Tests 1141 passed, 0 failed; Optimum.Render.Vulkan.Tests 602 passed, 0 failed, 0 SYNC- lines (sync,best). --- .../VulkanClientPlatform.FrameBuffers.cs | 37 ++++++++++++------- Optimum.Render.Vulkan/VulkanDevice.cs | 3 ++ .../transient-allocator-coverage-tests.cs | 17 ++++++--- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index f4ae1440..f4122635 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -202,17 +202,17 @@ public override List SetupDefaultFrameBuffers() } list[13] = ssao; - list[14] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8, 14); - list[15] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8, 15); + list[14] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); + list[15] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8); } - list[2] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8, 2); - list[3] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8, 3); - list[9] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8, 9); - list[8] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8, 8); - list[4] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f, 4); - list[7] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba16f, 7); - list[10] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f, 10); + list[2] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8); + list[3] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba8); + list[9] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8); + list[8] = CreateOptimumColorTarget(width / 4, height / 4, EnumTextureInternalFormat.Rgba8); + list[4] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f); + list[7] = CreateOptimumColorTarget(width / 2, height / 2, EnumTextureInternalFormat.Rgba16f); + list[10] = CreateOptimumColorTarget(width, height, EnumTextureInternalFormat.Rgba16f); // Optimum: TAA history, render-resolution like Primary. Two slots so the // resolve reads last frame's parity while writing this frame's; never @@ -236,7 +236,7 @@ public override List SetupDefaultFrameBuffers() try { list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(width, height, - EnumTextureInternalFormat.Rgba16f, OptimumTaaSharpenIndex); + EnumTextureInternalFormat.Rgba16f); } catch (Exception error) { @@ -251,7 +251,7 @@ public override List SetupDefaultFrameBuffers() { list[OptimumFsrFramebufferIndex] = CreateOptimumColorTarget( ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y, - EnumTextureInternalFormat.Rgba8, OptimumFsrFramebufferIndex); + EnumTextureInternalFormat.Rgba8); } list[5] = CreateOptimumDepthTarget(width / 4, height / 4); @@ -282,6 +282,15 @@ public override List SetupDefaultFrameBuffers() device.SetTextureParameter(textureId, OptimumGlConstants.TextureCompareMode, OptimumGlConstants.CompareRefToTexture); } + // The post chain's colour textures are transients; record the slot each one serves. + foreach (int transientSlot in Graph.TransientAllocator.PostChainSlots) + { + FrameBufferRef transientTarget = list[transientSlot]; + if (transientTarget == null || transientTarget.ColorTextureIds == null || + transientTarget.ColorTextureIds.Length == 0) continue; + device.OptInTransient(transientTarget.ColorTextureIds[0], transientSlot); + } + OptimumFinishDeviceFrameBufferSetup(list); return list; } @@ -358,16 +367,16 @@ private void SetupOptimumTextureSampler(int textureId, int filter, int wrap) /// /// A single-colour-attachment target, as the post chain uses. Every caller is a post-chain /// slot, so the colour texture is a transient (Transient pool class, registered with the - /// device's transient allocator under ). + /// device's transient allocator; SetupDefaultFrameBuffers tags its slot number). /// - private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextureInternalFormat format, int slot) + private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextureInternalFormat format) { FrameBufferRef target = new FrameBufferRef(); target.Width = width; target.Height = height; target.FboId = device.CreateFramebuffer(width, height); target.ColorTextureIds = new int[1]; - target.ColorTextureIds[0] = device.CreateTransientTexture2D(width, height, format, slot); + target.ColorTextureIds[0] = device.CreateTransientTexture2D(width, height, format, -1); // setupAttachment uses linear filtering and edge clamping. FXAA and // the reduced-resolution blur passes require fractional texel samples. SetupOptimumTextureSampler(target.ColorTextureIds[0], 9729, 33071); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 1eea6a61..cc805bec 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -1398,6 +1398,9 @@ public int CreateTransientTexture2D(int width, int height, EnumTextureInternalFo return id; } + /// Registers (or re-tags) a texture as the transient of client framebuffer slot . + public void OptInTransient(int textureId, int framebufferSlot) => _transients.OptIn(textureId, framebufferSlot); + /// with a raw GL internal format token, no pixels. public int CreateTransientTexture2DRaw(int width, int height, int glInternalFormat, int framebufferSlot) { diff --git a/Optimum.Tests/transient-allocator-coverage-tests.cs b/Optimum.Tests/transient-allocator-coverage-tests.cs index e0d0b723..a6fe72d1 100644 --- a/Optimum.Tests/transient-allocator-coverage-tests.cs +++ b/Optimum.Tests/transient-allocator-coverage-tests.cs @@ -17,13 +17,18 @@ public void ThePostChainSlotsOptIn() { string platform = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs"); Assert.Contains("device.CreateTransientTexture2DRaw(ssaoWidth, ssaoHeight, 6407, 13);", platform); - foreach (int slot in new[] { 14, 15 }) - Assert.Contains("list[" + slot + "] = CreateOptimumColorTarget(ssaoWidth, ssaoHeight, EnumTextureInternalFormat.Rgba8, " + slot + ");", platform); - foreach (int slot in new[] { 2, 3, 8, 9, 4, 7, 10 }) + // Every other post-chain slot is built by CreateOptimumColorTarget, whose texture is transient. + foreach (int slot in new[] { 2, 3, 4, 7, 8, 9, 10, 14, 15 }) Assert.Contains("list[" + slot + "] = CreateOptimumColorTarget(", platform); - Assert.Contains("EnumTextureInternalFormat.Rgba16f, OptimumTaaSharpenIndex);", platform); - Assert.Contains("EnumTextureInternalFormat.Rgba8, OptimumFsrFramebufferIndex);", platform); - Assert.Contains("target.ColorTextureIds[0] = device.CreateTransientTexture2D(width, height, format, slot);", platform); + Assert.Contains("list[OptimumTaaSharpenIndex] = CreateOptimumColorTarget(", platform); + Assert.Contains("list[OptimumFsrFramebufferIndex] = CreateOptimumColorTarget(", platform); + Assert.Contains("target.ColorTextureIds[0] = device.CreateTransientTexture2D(width, height, format, -1);", platform); + Assert.Contains("foreach (int transientSlot in Graph.TransientAllocator.PostChainSlots)", platform); + Assert.Contains("device.OptInTransient(transientTarget.ColorTextureIds[0], transientSlot);", platform); + int helper = platform.IndexOf("private FrameBufferRef CreateOptimumColorTarget(", StringComparison.Ordinal); + Assert.True(helper >= 0); + int helperEnd = platform.IndexOf("return target;", helper, StringComparison.Ordinal); + Assert.DoesNotContain("device.CreateTexture2D(", platform.Substring(helper, helperEnd - helper)); string allocator = Read("Optimum.Render.Vulkan/Graph/TransientAllocator.cs"); Assert.Contains("PostChainSlots = { 2, 3, 4, 7, 8, 9, 10, 13, 14, 15, 18, 21 };", allocator); From a8e66e59230132f1b0746b98b4f3d66463caf8fa Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:52:31 +0200 Subject: [PATCH 120/226] wip(phase2-frame-graph): platform pass declarations, two-plan cycle, frame graph tests (untested) --- .../FrameGraphFrameTests.cs | 480 ++++++++++++++++++ .../FrameGraphUnitTests.cs | 114 +++++ Optimum.Render.Vulkan/Graph/FrameGraph.cs | 41 +- .../Platform/VulkanClientPlatform.Frame.cs | 3 + .../VulkanClientPlatform.FrameBuffers.cs | 7 + .../Platform/VulkanClientPlatform.Graph.cs | 323 ++++++++++++ .../Platform/VulkanClientPlatform.cs | 7 + Optimum.Tests/frame-graph-coverage-tests.cs | 102 ++++ 8 files changed, 1066 insertions(+), 11 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/FrameGraphFrameTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/FrameGraphUnitTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs create mode 100644 Optimum.Tests/frame-graph-coverage-tests.cs diff --git a/Optimum.Render.Vulkan.Tests/FrameGraphFrameTests.cs b/Optimum.Render.Vulkan.Tests/FrameGraphFrameTests.cs new file mode 100644 index 00000000..2f13f5cd --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGraphFrameTests.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using static Optimum.Render.Vulkan.Tests.GpuTest; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 2 step 2 on a real device: the streaming frame graph. A declared TAA-shaped +/// frame (opaque scene with a motion window, history resolve, final composition that +/// writes Primary 0 while sampling Primary 1, blit) records exactly one rendering scope +/// per pass for five frames with the history accumulating (Present between frames, no +/// readback in the loop); the same frames are byte-identical with +/// OPTIMUM_VULKAN_FRAMEGRAPH off; clears are promoted, kept in the pass or landed +/// standalone as the plan describes, and a masked-out clear stays a no-op. +/// +public class FrameGraphFrameTests +{ + private readonly ITestOutputHelper _output; + + public FrameGraphFrameTests(ITestOutputHelper output) => _output = output; + + private const int Size = 8; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.5, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + private sealed class TaaScene + { + public int Primary, Colour, Glow, Motion, Depth; + public int HistoryAFb, HistoryA, HistoryBFb, HistoryB; + public int OutputFb, Output; + public int Scene, Resolve, Final, Blit; + } + + private bool TryCreate(bool frameGraph, out VulkanDevice? device) + { + VulkanDevice created = NewDevice(); + created.FrameGraphEnabled = frameGraph; + if (!created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + _output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + device = created; + return true; + } + + private static TaaScene CreateTaaScene(VulkanDevice seam) + { + int Texture(EnumTextureInternalFormat format) => + seam.CreateTexture2D(Size, Size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + var s = new TaaScene + { + Colour = Texture(EnumTextureInternalFormat.Rgba8), + Glow = Texture(EnumTextureInternalFormat.Rgba8), + Motion = Texture(EnumTextureInternalFormat.Rgba16f), + Depth = Texture(EnumTextureInternalFormat.DepthComponent32), + HistoryA = Texture(EnumTextureInternalFormat.Rgba16f), + HistoryB = Texture(EnumTextureInternalFormat.Rgba16f), + Output = Texture(EnumTextureInternalFormat.Rgba8), + }; + s.Primary = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(s.Primary, EnumFramebufferAttachment.ColorAttachment0, s.Colour, 0); + seam.AttachTexture(s.Primary, EnumFramebufferAttachment.ColorAttachment1, s.Glow, 0); + seam.AttachTexture(s.Primary, EnumFramebufferAttachment.ColorAttachment2, s.Motion, 0); + seam.AttachTexture(s.Primary, EnumFramebufferAttachment.DepthAttachment, s.Depth, 0); + seam.SetDrawBuffers(s.Primary, 0b011); + s.HistoryAFb = SingleTarget(seam, s.HistoryA); + s.HistoryBFb = SingleTarget(seam, s.HistoryB); + s.OutputFb = SingleTarget(seam, s.Output); + + s.Scene = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + in vec2 uv; + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + layout(location = 2) out vec4 outMotion; + void main(void) + { + outColor = vec4(1.0, 0.2, 0.0, 1.0); + outGlow = vec4(0.4, 0.0, 0.0, 1.0); + outMotion = vec4(0.25, 0.5, 0.0, 1.0); + } + """, "fg-scene"); + s.Resolve = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D sceneTex; + uniform sampler2D historyTex; + uniform sampler2D motionTex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) + { + outColor = vec4(mix(texture(historyTex, uv).r, texture(sceneTex, uv).r, 0.5), + texture(motionTex, uv).g, 0.0, 1.0); + } + """, "fg-resolve"); + s.Final = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D glowTex; + uniform sampler2D historyTex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(texture(historyTex, uv).r, texture(glowTex, uv).r, 0.2, 1.0); } + """, "fg-final"); + s.Blit = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D sceneTex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = texture(sceneTex, uv); } + """, "fg-blit"); + seam.SetSamplerUnit(s.Resolve, "sceneTex", 0); + seam.SetSamplerUnit(s.Resolve, "historyTex", 1); + seam.SetSamplerUnit(s.Resolve, "motionTex", 2); + seam.SetSamplerUnit(s.Final, "glowTex", 0); + seam.SetSamplerUnit(s.Final, "historyTex", 1); + seam.SetSamplerUnit(s.Blit, "sceneTex", 0); + return s; + } + + private static int SingleTarget(VulkanDevice seam, int texture) + { + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + return framebuffer; + } + + private static void BaseState(VulkanDevice seam) + { + seam.SetViewport(0, 0, Size, Size); + seam.SetScissorEnabled(false); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetColorMask(true, true, true, true); + } + + /// Frame 0: both histories cleared, nothing drawn. + private static void SeedFrame(VulkanDevice seam, TaaScene s) + { + seam.BeginFrame(); + BaseState(seam); + seam.DeclarePass(new PassDeclaration { Name = "Seed", FramebufferId = s.HistoryAFb }); + seam.ClearColor(0, 0f, 0f, 0f, 0f); + seam.DeclarePass(new PassDeclaration { Name = "Seed", FramebufferId = s.HistoryBFb }); + seam.ClearColor(0, 0f, 0f, 0f, 0f); + seam.Present(); + } + + /// + /// One TAA-shaped frame. Odd frames write history A and read B, even frames the reverse. + /// Without TAA the resolve is skipped and the final pass reads the seeded history A. + /// + private static void TaaFrame(VulkanDevice seam, TaaScene s, int frame, bool taa) + { + bool writeA = frame % 2 == 1; + int writeFb = writeA ? s.HistoryAFb : s.HistoryBFb; + int writeTex = taa ? writeA ? s.HistoryA : s.HistoryB : s.HistoryA; + int readTex = writeA ? s.HistoryB : s.HistoryA; + + seam.BeginFrame(); + BaseState(seam); + + // Opaque: every clear issued before the first draw, the motion one inside a motion window. + seam.DeclarePass(new PassDeclaration { Name = "Opaque", FramebufferId = s.Primary }); + seam.SetViewport(0, 0, Size, Size); + seam.SetDrawBuffers(s.Primary, 0b011); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0f, 0f, 0f, 1f); + seam.SetDrawBuffers(s.Primary, 0b111); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.SetDrawBuffers(s.Primary, 0b011); + seam.SetDepthMask(true); + seam.ClearDepth(1f); + seam.SetDepthTest(true); + seam.SetDepthFunc(0x203); + seam.UseProgram(s.Scene); + seam.SetDrawBuffers(s.Primary, 0b111); + seam.DrawFullscreenTriangle(); + seam.SetDrawBuffers(s.Primary, 0b011); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + + if (taa) + { + seam.DeclarePass(new PassDeclaration + { + Name = "TaaResolve", FramebufferId = writeFb, Reads = new[] { s.Colour, readTex, s.Motion }, + }); + seam.SetViewport(0, 0, Size, Size); + seam.UseProgram(s.Resolve); + seam.BindTexture(0, s.Colour); + seam.BindTexture(1, readTex); + seam.BindTexture(2, s.Motion); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + seam.BindTexture(1, 0); + seam.BindTexture(2, 0); + } + + // Final composition: writes Primary 0, samples Primary 1. + seam.DeclarePass(new PassDeclaration + { + Name = "FinalComposition", FramebufferId = s.Primary, ColorSlots = ~(1u << 1), + Reads = new[] { s.Glow, writeTex }, + }); + seam.SetViewport(0, 0, Size, Size); + seam.SetDrawBuffers(s.Primary, 0b001); + seam.UseProgram(s.Final); + seam.BindTexture(0, s.Glow); + seam.BindTexture(1, writeTex); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + seam.BindTexture(1, 0); + seam.EndPass(); + seam.SetDrawBuffers(s.Primary, 0b011); + + // Blit: a plain full overwrite, so an exactly matching plan loads it DONT_CARE. + seam.DeclarePass(new PassDeclaration + { + Name = "Blit", FramebufferId = s.OutputFb, Reads = new[] { s.Colour }, TransientSlots = 1, + }); + seam.SetViewport(0, 0, Size, Size); + seam.UseProgram(s.Blit); + seam.BindTexture(0, s.Colour); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + + seam.Present(); + } + + private static Dictionary ReadAll(VulkanDevice seam, TaaScene s) + { + seam.BeginFrame(); + var result = new Dictionary + { + ["colour"] = seam.ReadBackLevel0ForTests(s.Colour), + ["glow"] = seam.ReadBackLevel0ForTests(s.Glow), + ["motion"] = seam.ReadBackLevel0ForTests(s.Motion), + ["depth"] = seam.ReadBackLevel0ForTests(s.Depth), + ["historyA"] = seam.ReadBackLevel0ForTests(s.HistoryA), + ["historyB"] = seam.ReadBackLevel0ForTests(s.HistoryB), + ["output"] = seam.ReadBackLevel0ForTests(s.Output), + }; + seam.Present(); + return result; + } + + private static float HalfAt(byte[] texels, int pixel, int channel) => + (float)BitConverter.ToHalf(texels, pixel * 8 + channel * 2); + + [SkippableFact] + public void ADeclaredTaaFrameOpensOneScopePerPassAndAccumulatesHistory() + { + Skip.IfNot(TryCreate(frameGraph: true, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + FrameGraph graph = seam.FrameGraphForTests; + TaaScene scene = CreateTaaScene(seam); + + long standaloneBefore = graph.StandaloneClears; + SeedFrame(seam, scene); + // Nothing attached the seeded histories this frame: the clears landed as clear-image commands. + Assert.Equal(2, graph.StandaloneClears - standaloneBefore); + + long hitsBefore = graph.PlanHits; + long missesBefore = graph.PlanMisses; + long dontCareBefore = graph.PlannedDontCareLoads; + for (int frame = 1; frame <= 5; frame++) + { + long scopes = seam.ScopesOpenedForTests; + long passes = graph.Passes; + long splits = graph.Splits; + long promoted = graph.PromotedClears; + long inPass = graph.InPassClears; + long standalone = graph.StandaloneClears; + + TaaFrame(seam, scene, frame, taa: true); + + long scopesOpened = seam.ScopesOpenedForTests - scopes; + long passCount = graph.Passes - passes; + _output.WriteLine($"frame {frame}: passes={passCount} scopes={scopesOpened} splits={graph.Splits - splits} " + + $"promoted={graph.PromotedClears - promoted} inPass={graph.InPassClears - inPass} " + + $"standalone={graph.StandaloneClears - standalone}"); + Assert.Equal(4, passCount); + Assert.Equal(passCount, scopesOpened); + Assert.Equal(0, graph.Splits - splits); + // Colour, glow, motion and depth: every Opaque clear became LOAD_OP_CLEAR. + Assert.Equal(4, graph.PromotedClears - promoted); + Assert.Equal(0, graph.InPassClears - inPass); + Assert.Equal(0, graph.StandaloneClears - standalone); + } + + // Frames 1 and 2 have no frame of the same parity to match; 3 to 5 do. + Assert.Equal(3, graph.PlanHits - hitsBefore); + Assert.Equal(2, graph.PlanMisses - missesBefore); + Assert.Equal(3, graph.PlannedDontCareLoads - dontCareBefore); + + Dictionary texels = ReadAll(seam, scene); + int centre = Size / 2 * Size + Size / 2; + // h_n = (h_(n-1) + 1) / 2 from 0: A was written on frames 1, 3, 5, B on 2 and 4. + Assert.Equal(0.96875f, HalfAt(texels["historyA"], centre, 0)); + Assert.Equal(0.9375f, HalfAt(texels["historyB"], centre, 0)); + Assert.Equal(0.5f, HalfAt(texels["historyA"], centre, 1)); + // Final composition read the history it just wrote and Primary 1 while writing Primary 0. + Assert.Equal(247, texels["output"][centre * 4]); + Assert.Equal(102, texels["output"][centre * 4 + 1]); + Assert.Equal(51, texels["output"][centre * 4 + 2]); + Assert.Equal(texels["colour"], texels["output"]); + AssertClean(seam); + } + } + + [SkippableTheory] + [InlineData(true)] + [InlineData(false)] + public void TheDeclaredFrameIsPixelIdenticalWithTheFrameGraphOff(bool taa) + { + Dictionary? on = RenderFrames(frameGraph: true, taa); + Skip.If(on == null, "No usable Vulkan device."); + Dictionary off = RenderFrames(frameGraph: false, taa)!; + + foreach ((string name, byte[] texels) in on!) + { + Assert.True(texels.AsSpan().SequenceEqual(off[name]), name + " differs between the frame graph and scope inference"); + } + } + + private Dictionary? RenderFrames(bool frameGraph, bool taa) + { + if (!TryCreate(frameGraph, out VulkanDevice? device)) return null; + using (device) + { + VulkanDevice seam = device!; + TaaScene scene = CreateTaaScene(seam); + SeedFrame(seam, scene); + for (int frame = 1; frame <= 5; frame++) TaaFrame(seam, scene, frame, taa); + Dictionary texels = ReadAll(seam, scene); + _output.WriteLine((frameGraph ? "graph" : "inference") + ": scopes=" + seam.ScopesOpenedForTests + + " passes=" + seam.FrameGraphForTests.Passes); + AssertClean(seam); + return texels; + } + } + + /// + /// One frame of clears on two targets: a masked-out clear and an all-false colour-mask + /// clear (no-ops, nothing pending), a clear with no pass open under an additive draw + /// (LOAD_OP_CLEAR), a clear after the pass opened (vkCmdClearAttachments, counted) and a + /// clear of a texture sampled before any pass attaches it (a clear-image command first). + /// + [SkippableTheory] + [InlineData(true)] + [InlineData(false)] + public void ClearsArePromotedKeptInThePassOrLandedBeforeARead(bool frameGraph) + { + Skip.IfNot(TryCreate(frameGraph, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + FrameGraph graph = seam.FrameGraphForTests; + int Texture() => seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int c0 = Texture(), c1 = Texture(), source = Texture(), sink = Texture(); + int target = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment0, c0, 0); + seam.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment1, c1, 0); + int sourceFb = SingleTarget(seam, source); + int sinkFb = SingleTarget(seam, sink); + + int add = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(0.4, 0.0, 0.0, 0.0); } + """, "fg-add"); + int copy = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D tex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = texture(tex, uv); } + """, "fg-copy"); + seam.SetSamplerUnit(copy, "tex", 0); + + // Seed: c1 grey. The target has both draw buffers. + seam.BeginFrame(); + BaseState(seam); + seam.DeclarePass(new PassDeclaration { Name = "Seed", FramebufferId = target }); + seam.SetDrawBuffers(target, 0b11); + seam.ClearColor(1, 0.2f, 0.2f, 0.2f, 1f); + seam.Present(); + + seam.BeginFrame(); + BaseState(seam); + seam.DeclarePass(new PassDeclaration { Name = "Target", FramebufferId = target }); + seam.SetDrawBuffers(target, 0b01); + + (long promoted, long inPass, long standalone) Counters() => + (graph.PromotedClears, graph.InPassClears, graph.StandaloneClears); + var before = Counters(); + // Masked out by the draw buffers, then by an all-false colour mask: no-ops on every path. + seam.ClearColor(1, 1f, 1f, 1f, 1f); + seam.SetColorMask(false, false, false, false); + seam.ClearColor(0, 1f, 1f, 1f, 1f); + seam.SetColorMask(true, true, true, true); + Assert.Equal(before, Counters()); + Assert.False(graph.HasPendingClears); + + // No pass open: promoted into the scope the additive draw opens. + long scopes = seam.ScopesOpenedForTests; + seam.ClearColor(0, 0.2f, 0f, 0f, 1f); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetBlendFuncSeparate(0, 1, 1, 1, 1); + seam.UseProgram(add); + seam.DrawFullscreenTriangle(); + seam.SetBlend(false, EnumBlendMode.Standard); + Assert.Equal(1, seam.ScopesOpenedForTests - scopes); + + // The pass is open: this clear stays in it. + seam.SetDrawBuffers(target, 0b11); + seam.ClearColor(1, 0f, 1f, 0f, 1f); + seam.SetDrawBuffers(target, 0b01); + if (frameGraph) + { + Assert.Equal(before.promoted + 1, graph.PromotedClears); + Assert.Equal(before.inPass + 1, graph.InPassClears); + Assert.Equal(before.standalone, graph.StandaloneClears); + } + + // Cleared with no pass open, then sampled by a pass that does not attach it. + seam.DeclarePass(new PassDeclaration { Name = "ClearSource", FramebufferId = sourceFb }); + seam.ClearColor(0, 0f, 0f, 1f, 1f); + seam.DeclarePass(new PassDeclaration { Name = "Sink", FramebufferId = sinkFb, Reads = new[] { source } }); + seam.UseProgram(copy); + seam.BindTexture(0, source); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + if (frameGraph) + { + Assert.Equal(before.standalone + 1, graph.StandaloneClears); + Assert.Equal(0, graph.Splits); + } + seam.Present(); + + seam.BeginFrame(); + byte[] first = seam.ReadBackLevel0ForTests(c0); + byte[] second = seam.ReadBackLevel0ForTests(c1); + byte[] sampled = seam.ReadBackLevel0ForTests(sink); + seam.Present(); + + int centre = (Size / 2 * Size + Size / 2) * 4; + // 0.2 cleared + 0.4 added = 0.6 (153); alpha 1 + 0. + Assert.Equal(new byte[] { 153, 0, 0, 255 }, first.AsSpan(centre, 4).ToArray()); + Assert.Equal(new byte[] { 0, 255, 0, 255 }, second.AsSpan(centre, 4).ToArray()); + Assert.Equal(new byte[] { 0, 0, 255, 255 }, sampled.AsSpan(centre, 4).ToArray()); + AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/FrameGraphUnitTests.cs b/Optimum.Render.Vulkan.Tests/FrameGraphUnitTests.cs new file mode 100644 index 00000000..94ab510a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGraphUnitTests.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The frame graph's bookkeeping without a device: which plan a streamed frame is matched +/// against (the last two frames, so the TAA history ping-pong hits), the load op a pass +/// gets while the frame so far matches and after it stops matching, and the pending-clear +/// table behind clear promotion. +/// +public class FrameGraphUnitTests +{ + private static PassSignature Pass(int name, int resource, bool transient) => new() + { + NameId = name, + Attachments = new[] + { + new AttachmentUse(resource, transient ? ResourceUsage.ColorWrite : ResourceUsage.ColorBlend, transient), + }, + Width = 8, + Height = 8, + FormatsId = 1, + }; + + [Fact] + public void AFrameCycleOfTwoHitsThePlanFromTwoFramesAgo() + { + var graph = new FrameGraph { Enabled = true }; + var loads = new List(); + for (int frame = 0; frame < 6; frame++) + { + int resource = frame % 2 == 0 ? 10 : 11; + int index = graph.OpenPass(Pass(1, resource, transient: true), declared: true); + loads.Add(graph.PlannedLoad(index, 0)); + graph.EndFrame(); + } + + Assert.Equal(4, graph.PlanHits); + Assert.Equal(2, graph.PlanMisses); + Assert.Equal(new[] + { + AttachmentLoadOp.Load, AttachmentLoadOp.Load, AttachmentLoadOp.DontCare, + AttachmentLoadOp.DontCare, AttachmentLoadOp.DontCare, AttachmentLoadOp.DontCare, + }, loads); + } + + [Fact] + public void APassThatStopsMatchingMakesTheRestOfTheFrameConservative() + { + var graph = new FrameGraph { Enabled = true }; + graph.OpenPass(Pass(1, 10, transient: true), declared: true); + graph.OpenPass(Pass(2, 20, transient: true), declared: true); + graph.EndFrame(); + + int first = graph.OpenPass(Pass(1, 10, transient: true), declared: true); + Assert.Equal(AttachmentLoadOp.DontCare, graph.PlannedLoad(first, 0)); + int changed = graph.OpenPass(Pass(3, 30, transient: true), declared: true); + Assert.Equal(AttachmentLoadOp.Load, graph.PlannedLoad(changed, 0)); + // The same pass as last frame, but after a mismatch: no DONT_CARE. + int later = graph.OpenPass(Pass(2, 20, transient: true), declared: true); + Assert.Equal(AttachmentLoadOp.Load, graph.PlannedLoad(later, 0)); + graph.EndFrame(); + + Assert.Equal(0, graph.PlanHits); + Assert.Equal(2, graph.PlanMisses); + } + + [Fact] + public void PersistentAttachmentsAlwaysLoad() + { + var graph = new FrameGraph { Enabled = true }; + for (int frame = 0; frame < 3; frame++) + { + int index = graph.OpenPass(Pass(1, 10, transient: false), declared: true); + Assert.Equal(AttachmentLoadOp.Load, graph.PlannedLoad(index, 0)); + graph.EndFrame(); + } + Assert.Equal(2, graph.PlanHits); + } + + [Fact] + public void PendingClearsAreReplacedTakenInOrderAndDropped() + { + var graph = new FrameGraph { Enabled = true }; + var a = new VulkanTexture(null!); + var b = new VulkanTexture(null!); + + graph.PromoteColorClear(a, 0, 1f, 0f, 0f, 1f); + graph.PromoteColorClear(a, 0, 0f, 1f, 0f, 1f); + graph.PromoteColorClear(a, 1, 0f, 0f, 1f, 1f); + graph.PromoteDepthClear(b, 1f); + Assert.True(graph.HasPendingClear(a)); + Assert.True(graph.HasPendingClear(b)); + + // Layer 0's second clear replaced its first; layer 2 has none. + Assert.False(graph.TakeForLoad(a, 2, depth: false, out _)); + Assert.True(graph.TakeForLoad(a, 0, depth: false, out PendingClear taken)); + Assert.Equal(1f, taken.G); + Assert.Equal(1, graph.PromotedClears); + + var standalone = new List(); + graph.TakeStandalone(a, standalone); + Assert.Single(standalone); + Assert.Equal(1u, standalone[0].Layer); + Assert.False(graph.HasPendingClear(a)); + + graph.Drop(b); + Assert.False(graph.HasPendingClears); + } +} diff --git a/Optimum.Render.Vulkan/Graph/FrameGraph.cs b/Optimum.Render.Vulkan/Graph/FrameGraph.cs index 1fdf4e23..86ea14cc 100644 --- a/Optimum.Render.Vulkan/Graph/FrameGraph.cs +++ b/Optimum.Render.Vulkan/Graph/FrameGraph.cs @@ -92,8 +92,11 @@ internal sealed class FrameGraph private readonly Dictionary _names = new(StringComparer.Ordinal); private readonly List _frame = new(); private readonly List _pending = new(); - private FramePlan? _plan; - private bool _prefixMatches = true; + + // The plans of the last two frames: the TAA history ping-pong makes every frame's + // signature differ from the one before it but equal to the one before that. + private readonly FramePlan?[] _plans = new FramePlan?[2]; + private readonly bool[] _prefixMatches = { true, true }; // Totals for tests; VulkanStats carries the interval counters. public long Passes { get; private set; } @@ -110,8 +113,11 @@ internal sealed class FrameGraph /// Passes opened in the frame being recorded. public int PassesThisFrame => _frame.Count; - /// The plan the current frame is matched against, null before the first frame ended. - public FramePlan? Plan => _plan; + /// The plan solved from the previous frame, null before the first frame ended. + public FramePlan? Plan => _plans[0]; + + /// Whether every pass opened so far this frame matches one of the last two plans. + public bool PrefixMatchesPlan => _prefixMatches[0] || _prefixMatches[1]; public int NameId(string name) { @@ -131,7 +137,12 @@ public int NameId(string name) public int OpenPass(PassSignature signature, bool declared) { int index = _frame.Count; - _prefixMatches = _prefixMatches && _plan != null && !_plan.IsConservative && _plan.MatchesPass(index, signature); + for (int k = 0; k < _plans.Length; k++) + { + FramePlan? plan = _plans[k]; + _prefixMatches[k] = _prefixMatches[k] && plan != null && !plan.IsConservative && + plan.MatchesPass(index, signature); + } _frame.Add(signature); Passes++; if (declared) DeclaredPasses++; @@ -140,7 +151,7 @@ public int OpenPass(PassSignature signature, bool declared) { RenderTrace.Write("pass " + index + " name=" + signature.NameId + " attachments=" + signature.Attachments.Length + " reads=" + signature.Reads.Length + " " + signature.Width + "x" + signature.Height + - " plan=" + (_prefixMatches ? "match" : "conservative")); + " plan=" + (PrefixMatchesPlan ? "match" : "conservative")); } return index; } @@ -152,8 +163,9 @@ public int OpenPass(PassSignature signature, bool declared) /// public AttachmentLoadOp PlannedLoad(int pass, int attachment) { - if (!_prefixMatches || _plan == null || pass < 0 || pass >= _plan.PassCount) return AttachmentLoadOp.Load; - AttachmentLoadOp op = _plan.LoadOp(pass, attachment); + FramePlan? plan = _prefixMatches[0] ? _plans[0] : _prefixMatches[1] ? _plans[1] : null; + if (plan == null || pass < 0 || pass >= plan.PassCount) return AttachmentLoadOp.Load; + AttachmentLoadOp op = plan.LoadOp(pass, attachment); if (op == AttachmentLoadOp.DontCare) PlannedDontCareLoads++; return op; } @@ -180,7 +192,12 @@ public void EndFrame() { if (_frame.Count > 0) { - if (_plan != null && !_plan.IsConservative && _plan.Matches(_frame)) + bool hit = false; + foreach (FramePlan? plan in _plans) + { + hit |= plan != null && !plan.IsConservative && plan.Matches(_frame); + } + if (hit) { PlanHits++; VulkanStats.NotePlanHit(); @@ -190,10 +207,12 @@ public void EndFrame() PlanMisses++; VulkanStats.NotePlanMiss(); } - _plan = FramePlan.Build(_frame); + _plans[1] = _plans[0]; + _plans[0] = FramePlan.Build(_frame); } _frame.Clear(); - _prefixMatches = true; + _prefixMatches[0] = true; + _prefixMatches[1] = true; } // ------------------------------------------------------------ clear promotion diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs index 9bdb6d2e..2f7265aa 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -13,6 +13,9 @@ public partial class VulkanClientPlatform public override void BeginFrame() { device.BeginFrame(); + // Until a stage or a post method says otherwise, passes are named after the frame. + passContext = "Frame"; + passContextFlags = Graph.PassFlags.AllowSplit; } /// diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index 09d7efb3..4de5d23c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -531,10 +531,12 @@ public override void BindCurrentFrameBuffer(FrameBufferRef value) if (value == null) { device.BindDefaultFramebuffer(); + DeclareBoundPass(); return; } device.BindFramebuffer(value.FboId); device.SetViewport(0, 0, value.Width, value.Height); + DeclareBoundPass(); } public override void BindCurrentFrameBufferKeepViewport(FrameBufferRef value) @@ -542,9 +544,11 @@ public override void BindCurrentFrameBufferKeepViewport(FrameBufferRef value) if (value == null) { device.BindDefaultFramebuffer(); + DeclareBoundPass(); return; } device.BindFramebuffer(value.FboId); + DeclareBoundPass(); } public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] clearColor, bool clearDepthBuffer, bool clearColorBuffers) @@ -691,12 +695,15 @@ public override void ClearSsaoTarget() /// public override void BeginFinalCompositionDrawBuffers() { + DeclareFinalCompositionPass(); device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); device.SetDepthTest(false); } public override void RestoreWorldDrawBuffers(bool ssaoAttachments) { + // The attachment-subset pass ends before Primary 1 rejoins the draw buffers. + device.EndPass(); if (ssaoAttachments) { device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs new file mode 100644 index 00000000..aa7e2bcd --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -0,0 +1,323 @@ +using System.Collections.Generic; +using System.Globalization; +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 2 step 2: the platform declares the frame's passes in frame order. +// A pass is (context, bound target): the context is the render stage (from the C3 bracket) or +// the post method running (OIT merge, TAA resolve and sharpen, post-processing, final +// composition, blit, sky motion, liquid motion); every bind through the CurrentFrameBuffer +// setters declares the pass for the new target. Reads are the textures the base's pass body +// binds, so the pass opens with them already shader-readable. Mod-hosted stages sample +// anything and use OpenSampling and AllowSplit. With OPTIMUM_VULKAN_FRAMEGRAPH=0 the device +// ignores declarations and the old scope inference runs. +public partial class VulkanClientPlatform +{ + // ClientPlatformWindows' EnumFrameBuffer slots the post chain indexes. + private const int PrimaryIndex = 0; + private const int TransparentIndex = 1; + private const int BlurHorizontalMedResIndex = 2; + private const int BlurVerticalMedResIndex = 3; + private const int FindBrightIndex = 4; + private const int LiquidDepthIndex = 5; + private const int GodRaysIndex = 7; + private const int BlurVerticalLowResIndex = 8; + private const int BlurHorizontalLowResIndex = 9; + private const int LumaIndex = 10; + private const int ShadowFarIndex = 11; + private const int ShadowNearIndex = 12; + private const int SsaoIndex = 13; + private const int SsaoBlurVerticalIndex = 14; + private const int SsaoBlurHorizontalIndex = 15; + + private string passContext = "Frame"; + private PassFlags passContextFlags = PassFlags.AllowSplit; + + /// Forwards the render-stage bracket to the pass declarations. + private sealed class FrameGraphStageListener : IRenderStageListener + { + private readonly VulkanClientPlatform platform; + + public FrameGraphStageListener(VulkanClientPlatform platform) => this.platform = platform; + + public void OnBeginRenderStage(EnumRenderStage stage) + { + platform.SetPassContext(stage.ToString(), StageFlags(stage)); + } + + public void OnEndRenderStage(EnumRenderStage stage) + { + platform.GraphDevice?.EndPass(); + // The liquid motion pass runs right after the AfterOIT renderers (ClientMain.MainRenderLoop). + platform.SetPassContext(stage == EnumRenderStage.AfterOIT ? "LiquidMotion" : "Frame", PassFlags.AllowSplit); + } + } + + private VulkanDevice? GraphDevice => device; + + /// World stages draw declared targets; everything a mod hosts may sample anything. + internal static PassFlags StageFlags(EnumRenderStage stage) => stage switch + { + EnumRenderStage.Before or EnumRenderStage.ShadowFar or EnumRenderStage.ShadowFarDone or + EnumRenderStage.ShadowNear or EnumRenderStage.ShadowNearDone or EnumRenderStage.Opaque or + EnumRenderStage.OIT => PassFlags.AllowSplit, + _ => PassFlags.OpenSampling | PassFlags.AllowSplit, + }; + + /// Starts a context and declares its pass on the bound target. + private void SetPassContext(string context, PassFlags flags) + { + passContext = context; + passContextFlags = flags; + DeclareBoundPass(); + } + + /// Declares the (context, bound target) pass; a repeat of the current one changes nothing. + private void DeclareBoundPass() + { + if (device == null || !device.FrameGraphEnabled) return; + int index = FrameBufferIndexOf(device.BoundFramebufferId); + string target = index >= 0 + ? index.ToString(CultureInfo.InvariantCulture) + : device.BoundFramebufferId == device.DefaultFramebufferId + ? "Default" + : "fbo" + device.BoundFramebufferId.ToString(CultureInfo.InvariantCulture); + device.DeclarePass(new PassDeclaration + { + Name = passContext + "/" + target, + FramebufferId = PassDeclaration.BoundFramebuffer, + Reads = PassReads(passContext, index), + TransientSlots = PassTransientSlots(passContext, index), + Flags = passContextFlags, + }); + } + + /// + /// The final composition writes Primary 0 while sampling Primary 1: an attachment-subset + /// pass, Primary 1 out of the scope for the whole pass (one barrier each way per frame). + /// + private void DeclareFinalCompositionPass() + { + if (device == null || !device.FrameGraphEnabled) return; + device.DeclarePass(new PassDeclaration + { + Name = "FinalComposition/0", + FramebufferId = PassDeclaration.BoundFramebuffer, + ColorSlots = ~(1u << 1), + Reads = PassReads("FinalComposition", PrimaryIndex), + Flags = PassFlags.None, + }); + } + + private int FrameBufferIndexOf(int framebufferId) + { + List buffers = FrameBuffers; + if (buffers == null || framebufferId <= 0) return -1; + for (int i = 0; i < buffers.Count; i++) + { + if (buffers[i] != null && buffers[i].FboId == framebufferId) return i; + } + return -1; + } + + /// + /// The textures the base's pass body samples for (context, target). Where the base picks + /// one of several (the resolved or sharpened scene, either history parity) all candidates + /// are listed: the set stays the same from frame to frame, which the plan needs. + /// + internal int[] PassReads(string context, int target) + { + var reads = new List(); + switch (context) + { + case "MergeTransparent": + AddColour(reads, TransparentIndex, 0); + AddColour(reads, TransparentIndex, 1); + AddColour(reads, TransparentIndex, 2); + break; + case "SkyMotion": + AddColour(reads, TransparentIndex, 1); + break; + case "TaaResolve": + AddColour(reads, PrimaryIndex, 0); + AddColour(reads, PrimaryIndex, 1); + if (MotionAttachmentIndex >= 0) AddColour(reads, PrimaryIndex, MotionAttachmentIndex); + AddDepth(reads, PrimaryIndex); + for (int slot = 0; slot < 3; slot++) + { + AddColour(reads, OptimumTaaHistoryIndexA, slot); + AddColour(reads, OptimumTaaHistoryIndexB, slot); + } + break; + case "TaaSharpen": + AddColour(reads, OptimumTaaHistoryIndexA, 0); + AddColour(reads, OptimumTaaHistoryIndexB, 0); + break; + case "Post": + switch (target) + { + case FindBrightIndex: + case GodRaysIndex: + case LumaIndex: + AddPostScene(reads); + break; + case BlurHorizontalMedResIndex: + AddColour(reads, FindBrightIndex, 0); + break; + case BlurVerticalMedResIndex: + AddColour(reads, BlurHorizontalMedResIndex, 0); + break; + case BlurHorizontalLowResIndex: + AddColour(reads, BlurVerticalMedResIndex, 0); + break; + case BlurVerticalLowResIndex: + AddColour(reads, BlurHorizontalLowResIndex, 0); + break; + case SsaoIndex: + AddColour(reads, PrimaryIndex, 2); + AddColour(reads, PrimaryIndex, 3); + AddColour(reads, SsaoIndex, 1); + AddColour(reads, TransparentIndex, 1); + break; + case SsaoBlurHorizontalIndex: + AddColour(reads, SsaoIndex, 0); + AddColour(reads, SsaoBlurVerticalIndex, 0); + AddDepth(reads, PrimaryIndex); + break; + case SsaoBlurVerticalIndex: + AddColour(reads, SsaoBlurHorizontalIndex, 0); + break; + } + break; + case "FinalComposition": + AddColour(reads, LumaIndex, 0); + AddColour(reads, BlurVerticalLowResIndex, 0); + AddColour(reads, GodRaysIndex, 0); + AddColour(reads, PrimaryIndex, 1); + AddColour(reads, OptimumTaaHistoryIndexA, 1); + AddColour(reads, OptimumTaaHistoryIndexB, 1); + AddColour(reads, SsaoBlurVerticalIndex, 0); + break; + case "Blit": + AddColour(reads, PrimaryIndex, 0); + AddColour(reads, OptimumFsrFramebufferIndex, 0); + break; + case "Before": + case "ShadowFar": + case "ShadowNear": + case "Opaque": + case "OIT": + AddDepth(reads, ShadowFarIndex); + AddDepth(reads, ShadowNearIndex); + AddDepth(reads, LiquidDepthIndex); + break; + } + return reads.ToArray(); + } + + /// + /// Slots a pass overwrites completely with blending off and never reads from an earlier + /// frame: the bloom blur chain and the SSAO blur targets. The plan may load them DONT_CARE. + /// + internal static uint PassTransientSlots(string context, int target) + { + if (context != "Post") return 0; + return target switch + { + FindBrightIndex or BlurHorizontalMedResIndex or BlurVerticalMedResIndex or BlurHorizontalLowResIndex or + BlurVerticalLowResIndex or SsaoBlurHorizontalIndex or SsaoBlurVerticalIndex => 1u, + _ => 0u, + }; + } + + /// Every texture the post chain may read as the scene: Primary, the TAA histories, the sharpen output. + private void AddPostScene(List reads) + { + AddColour(reads, PrimaryIndex, 0); + AddColour(reads, PrimaryIndex, 1); + AddColour(reads, OptimumTaaHistoryIndexA, 0); + AddColour(reads, OptimumTaaHistoryIndexA, 1); + AddColour(reads, OptimumTaaHistoryIndexB, 0); + AddColour(reads, OptimumTaaHistoryIndexB, 1); + AddColour(reads, OptimumTaaSharpenIndex, 0); + } + + private void AddColour(List reads, int index, int slot) + { + List buffers = FrameBuffers; + if (buffers == null || index < 0 || index >= buffers.Count) return; + FrameBufferRef buffer = buffers[index]; + if (buffer?.ColorTextureIds == null || slot < 0 || slot >= buffer.ColorTextureIds.Length) return; + int id = buffer.ColorTextureIds[slot]; + if (id > 0 && !reads.Contains(id)) reads.Add(id); + } + + private void AddDepth(List reads, int index) + { + List buffers = FrameBuffers; + if (buffers == null || index < 0 || index >= buffers.Count || buffers[index] == null) return; + int id = buffers[index].DepthTextureId; + if (id > 0 && !reads.Contains(id)) reads.Add(id); + } + + // ------------------------------------------------------------ post methods + + public override void MergeTransparentRenderPass() + { + SetPassContext("MergeTransparent", PassFlags.None); + base.MergeTransparentRenderPass(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + public override bool RenderOptimumSkyMotion() + { + SetPassContext("SkyMotion", PassFlags.None); + bool drawn = base.RenderOptimumSkyMotion(); + SetPassContext("Frame", PassFlags.AllowSplit); + return drawn; + } + + public override void RenderPostprocessingEffects(float[] projectMatrix) + { + SetPassContext("Post", PassFlags.None); + base.RenderPostprocessingEffects(projectMatrix); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + public override bool RenderOptimumTaaResolve() + { + string outer = passContext; + PassFlags outerFlags = passContextFlags; + SetPassContext("TaaResolve", PassFlags.None); + bool resolved = base.RenderOptimumTaaResolve(); + SetPassContext(outer, outerFlags); + return resolved; + } + + public override int RenderOptimumTaaSharpen(int resolvedScene) + { + string outer = passContext; + PassFlags outerFlags = passContextFlags; + SetPassContext("TaaSharpen", PassFlags.None); + int sharpened = base.RenderOptimumTaaSharpen(resolvedScene); + SetPassContext(outer, outerFlags); + return sharpened; + } + + public override void RenderFinalComposition() + { + SetPassContext("FinalComposition", PassFlags.None); + base.RenderFinalComposition(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + public override void BlitPrimaryToDefault() + { + SetPassContext("Blit", PassFlags.None); + base.BlitPrimaryToDefault(); + SetPassContext("Frame", PassFlags.AllowSplit); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 049f711a..9d58fb7e 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -127,6 +127,10 @@ public partial class VulkanClientPlatform : ClientPlatformWindows // Phase 2: render-stage bracket from ClientMain.TriggerRenderStage (contract C3). new(true, "BeginRenderStage", new[] { "EnumRenderStage" }), new(true, "EndRenderStage", new[] { "EnumRenderStage" }), + // Phase 2 step 2: the TAA post methods declare their frame-graph passes. + new(true, "RenderOptimumSkyMotion", Array.Empty()), + new(true, "RenderOptimumTaaResolve", Array.Empty()), + new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), }; /// @@ -276,6 +280,8 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig } this.device = device; + // Phase 2 step 2: the stage bracket drives the frame graph's pass declarations. + RenderStageListener = new FrameGraphStageListener(this); OptimumRender.ActiveBackend = EnumRenderBackend.Vulkan; OptimumForkGraphics.Active = new VulkanForkGraphics(device); return true; @@ -314,6 +320,7 @@ public override void ShutdownGraphics() } device = null; + RenderStageListener = null; OptimumRender.ActiveBackend = EnumRenderBackend.OpenGL; OptimumRender.NoGraphicsApiWindow = false; OptimumRenderBootstrap.ClearCrashMarker(); diff --git a/Optimum.Tests/frame-graph-coverage-tests.cs b/Optimum.Tests/frame-graph-coverage-tests.cs new file mode 100644 index 00000000..017f0d92 --- /dev/null +++ b/Optimum.Tests/frame-graph-coverage-tests.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 2 step 2 (the streaming frame graph) at source level: the env +/// switch, one scope per pass through the pass recorder, clear promotion with its standalone +/// fallback, the platform declaring passes from the stage bracket, its binds and its post +/// methods, and the stats tokens. The pixels and scope counts are proven by +/// Optimum.Render.Vulkan.Tests/FrameGraphFrameTests.cs. +/// +public class FrameGraphCoverageTests +{ + [Fact] + public void TheFrameGraphHasAnOffSwitchAndSolvesLoadOpsFromThePlan() + { + string graph = Read("Optimum.Render.Vulkan/Graph/FrameGraph.cs"); + Assert.Contains("public const string Variable = \"OPTIMUM_VULKAN_FRAMEGRAPH\";", graph); + Assert.Contains("Environment.GetEnvironmentVariable(Variable) != \"0\"", graph); + Assert.Contains("plan.MatchesPass(index, signature)", graph); + Assert.Contains("_plans[0] = FramePlan.Build(_frame);", graph); + + string recorder = Read("Optimum.Render.Vulkan/Graph/PassRecorder.cs"); + Assert.Contains("_graph.PlannedLoad(passIndex, use)", recorder); + Assert.Contains("attachments[i].LoadOp = AttachmentLoadOp.Clear;", recorder); + Assert.Contains("CmdClearColorImage(", recorder); + Assert.Contains("_graph.NoteSplit(allowed);", recorder); + } + + [Fact] + public void ScopesOpenThroughThePassRecorderAndClearsArePromoted() + { + string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); + Assert.Contains("_recorder.Prepare(commandBuffer, framebuffer, scopeColour!, scopeDepth, DepthReadOnly,", targets); + Assert.Contains("public void DeclarePass(CommandBuffer commandBuffer, PassDeclaration declaration, int framebufferId)", targets); + Assert.Contains("_graph.PromoteColorClear(texture, target.Color[attachment].Layer, r, g, b, a);", targets); + Assert.Contains("_graph.PromoteDepthClear(texture, depth);", targets); + Assert.Contains("_graph.NoteInPassClear();", targets); + // The masked-out clear stays a no-op before either path. + Assert.Contains("if (_state.ColorMask == 0) return;", targets); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("_targets.FlushAllPendingClears(_frames.Current.CommandBuffer);", device); + Assert.Contains("if (_graph.Enabled) _graph.EndFrame();", device); + Assert.Contains("_targets.FlushPendingClears(commandBuffer, texture);", device); + Assert.Contains("_targets.FlushPendingClears(Commands, texture);", device); + } + + [Fact] + public void ThePlatformDeclaresThePassesOfTheFrame() + { + string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); + foreach (string member in new[] + { + "public override void MergeTransparentRenderPass()", + "public override bool RenderOptimumSkyMotion()", + "public override void RenderPostprocessingEffects(float[] projectMatrix)", + "public override bool RenderOptimumTaaResolve()", + "public override int RenderOptimumTaaSharpen(int resolvedScene)", + "public override void RenderFinalComposition()", + "public override void BlitPrimaryToDefault()", + }) + { + Assert.Contains(member, graph); + } + Assert.Contains("ColorSlots = ~(1u << 1),", graph); + Assert.Contains("PassFlags.OpenSampling | PassFlags.AllowSplit", graph); + + string buffers = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs"); + Assert.Contains("DeclareBoundPass();", buffers); + Assert.Contains("DeclareFinalCompositionPass();", buffers); + Assert.Contains("device.EndPass();", buffers); + + string main = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); + Assert.Contains("RenderStageListener = new FrameGraphStageListener(this);", main); + Assert.Contains("new(true, \"RenderOptimumTaaResolve\", Array.Empty()),", main); + } + + [Fact] + public void TheStatsLineCarriesTheFrameGraphCounters() + { + string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); + Assert.Contains("\"passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} \"", stats); + Assert.Contains("\"standalone_clears={17} pass_splits={18}\"", stats); + + string doc = Read("docs/taa-acceptance.md"); + Assert.Contains("OPTIMUM_VULKAN_FRAMEGRAPH=0", doc); + } + + private static string Read(string relativePath) + { + string? directory = AppContext.BaseDirectory; + while (directory != null && !File.Exists(Path.Combine(directory, "VintageStory.slnx"))) + { + directory = Path.GetDirectoryName(directory); + } + Assert.NotNull(directory); + return File.ReadAllText(Path.Combine(directory!, relativePath)); + } +} From 9fa87dc79a1509fe75533802db2f747504bac7e1 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:53:50 +0200 Subject: [PATCH 121/226] wip(phase2-frame-graph): attachments that load keep read access; frame graph GPU and unit tests pass --- Optimum.Render.Vulkan/Graph/PassRecorder.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Optimum.Render.Vulkan/Graph/PassRecorder.cs b/Optimum.Render.Vulkan/Graph/PassRecorder.cs index 33656c53..e555344e 100644 --- a/Optimum.Render.Vulkan/Graph/PassRecorder.cs +++ b/Optimum.Render.Vulkan/Graph/PassRecorder.cs @@ -206,7 +206,6 @@ public void Prepare(CommandBuffer commandBuffer, VulkanFramebuffer framebuffer, if (texture == null) continue; AttachmentUse attachment = _uses[use]; - _textures.Require(_barriers, commandBuffer, texture, attachment.Usage); if (_graph.TakeForLoad(texture, framebuffer.Color[i].Layer, depth: false, out PendingClear clear)) { attachments[i].LoadOp = AttachmentLoadOp.Clear; @@ -216,6 +215,12 @@ public void Prepare(CommandBuffer commandBuffer, VulkanFramebuffer framebuffer, { attachments[i].LoadOp = passIndex >= 0 ? _graph.PlannedLoad(passIndex, use) : AttachmentLoadOp.Load; } + // LOAD reads the attachment: a plain-write declaration only drops the read + // access when the contents are not loaded (sync validation: read-after-write). + ResourceUsage usage = attachment.Usage == ResourceUsage.ColorWrite && attachments[i].LoadOp == AttachmentLoadOp.Load + ? ResourceUsage.ColorBlend + : attachment.Usage; + _textures.Require(_barriers, commandBuffer, texture, usage); use++; } From a302571629451e3d57f414d0485612a6dff5fcae Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:51:43 +0200 Subject: [PATCH 122/226] wip(taa): pin the anti-flicker + 3x3 nearest-depth resolve (2026-09-11 distant foliage fix) in tests, script and docs Guarded: taa-resolve.fsh single-sample disocclusion test (3.7% -> 1.1% distant leaf rejection per frame) and fixed blend weight must not come back. Where: Optimum.Render.Vulkan.Tests/TaaResolveTests AntiFlickerWeightsFollowTheLuminanceDifference (0.3x/1.2x blendAlpha step response, 1 and 4 frames), FlippingSubPixelLeafKeepsItsHistory (old per-sample line resets a leaf flipping between two pixels, shipped line keeps it), DisocclusionLargerThanTheNeighbourhoodStillResets, MotionComesFromTheNearestDepthTapAtAnEdge; Optimum.Tests/taa-antiflicker-coverage-tests.cs (shader lines, never-revert header, docs, taa-rejection.py --self-test); scripts/dev/taa-rejection.py (parity dump gate, 3x3 leaf-far <= 1.5%); docs/temporal-frame-contract.md section 4 note (contract stays v1); TAA-PLAN.md follow-up; docs/taa-acceptance.md row A19; docs/vulkan-acceptance.md M1.7 required step. Updated source tests that pinned old shader lines, intent kept: temporal-contract-tests (validity rule now at closestDepth, reactive from the centre pixel), taa-sky-decal (far-minus-near on the closest tap), taa-pipeline (tolerance and sky test on closestDepth). No existing GPU test changed. Verified: dotnet build VintageStory.slnx -c Release 0 errors; Optimum.Tests 1142 passed 34 skipped 0 failed; Optimum.Render.Vulkan.Tests 594 passed 0 failed (sync,best). Not run in game. --- .../TaaResolveTests.cs | 440 +++++++++++++++++- .../taa-antiflicker-coverage-tests.cs | 234 ++++++++++ Optimum.Tests/taa-pipeline-coverage-tests.cs | 10 +- .../taa-sky-decal-motion-coverage-tests.cs | 9 +- Optimum.Tests/temporal-contract-tests.cs | 18 +- TAA-PLAN.md | 29 ++ docs/taa-acceptance.md | 11 + docs/temporal-frame-contract.md | 35 +- docs/vulkan-acceptance.md | 9 +- scripts/dev/taa-rejection.py | 259 +++++++++++ 10 files changed, 1041 insertions(+), 13 deletions(-) create mode 100644 Optimum.Tests/taa-antiflicker-coverage-tests.cs create mode 100644 scripts/dev/taa-rejection.py diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index c46dd5e5..6a02976b 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -642,12 +642,436 @@ public unsafe void NanInHistoryIsTreatedAsAReset() } } + // ------------------------------- 2026-09-11: distant foliage jitter was the resolve + // + // The four tests below pin the fix for the distant-foliage flicker (TAA-PLAN.md + // "Follow-up 2026-09-11"): a single-sample disocclusion test rejected history on + // ~3.7% of distant leaf pixels per frame and a fixed blend weight let the clip box + // drag the history; the 3x3 nearest-depth test and the anti-flicker weight took it + // to ~1.1%. Do not revert either half. The temporal ones run several resolves + // ping-ponged between two history sets with no readback inside the loop (this + // harness has no swapchain; each resolve is its own submitted frame). + + /// + /// Anti-flicker current weight: a pixel that survived rejection takes + /// mix(1.2, 0.3, w * w) * blendAlpha of the current frame, with + /// w = 1 - |lumCur - lumHist| / max(lumCur, max(lumHist, 0.2)) on the rectified + /// luminance. Columns cycle 0.5 / 1.0 / 0.0, so every 3x3 box spans [0, 1] and no + /// history value in play is clipped. Class 0.5 starts converged (history equals + /// current: w = 1, weight 0.3 x blendAlpha); class 1.0 starts from a history of 0 + /// (w = 0, weight 1.2 x blendAlpha). Glow blends as mix(historyGlow, glow, alpha) + /// with no clip and no luminance weighting, so a glow step from 0 to 1 reads the + /// weight itself; the colour step response is checked against the same + /// recurrence. One frame and four frames, each also required to sit clearly apart + /// from what the old fixed blendAlpha weight produces. + /// + [SkippableFact] + public void AntiFlickerWeightsFollowTheLuminanceDifference() + { + const float blendAlpha = 0.1f; + // The model rounds the UNORM8 glow per frame exactly as the target does, so it + // matches to the LSB; 1.5 LSB leaves room for rounding at .5 only. The fixed + // weight is 5 LSB away in the closest case (large change, one frame). + const double glowTolerance = 1.5 / 255.0, colourTolerance = 0.004; + static float Scene(int x) => (x % 3) switch { 0 => 0.5f, 1 => 1.0f, _ => 0.0f }; + static float Seed(int x) => x % 3 == 0 ? 0.5f : 0.0f; + + foreach (int frames in new[] { 1, 4 }) + { + TemporalRun? run = RunTemporal(frames, new TaaUniforms { BlendAlpha = blendAlpha }, + (textures, history) => + { + UploadRgba16F(textures, history.Color, (x, _) => Seed(x), (x, _) => Seed(x), (x, _) => Seed(x), (_, _) => 1f); + UploadFlatRgba8(textures, history.Glow, 0, 0, 0, 255); + // Identity camera: window depth 0.5 is linear depth 0, what the resolve writes back. + UploadFlatR32F(textures, history.Depth, 0f); + }, + (frame, textures, inputs) => + { + if (frame > 0) return; + UploadRgba16F(textures, inputs.SceneTex, (x, _) => Scene(x), (x, _) => Scene(x), (x, _) => Scene(x), (_, _) => 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 255, 255, 255, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + }); + Skip.If(run == null, "No usable Vulkan device."); + + foreach ((string name, int column, float current, float seed) in new[] + { + ("converged", 0, 0.5f, 0.5f), + ("large change", 1, 1.0f, 0.0f), + }) + { + (double colour, double glow) expected = AntiFlickerModel(current, seed, frames, blendAlpha, antiFlicker: true); + (double colour, double glow) fixedWeight = AntiFlickerModel(current, seed, frames, blendAlpha, antiFlicker: false); + double worstGlow = 0, worstColour = 0, glowAtCentre = 0; + for (int y = 2; y < Size - 2; y++) + for (int x = 3; x < Size - 3; x++) + { + if (x % 3 != column) continue; + double glow = ReadByteChannel(run!.Glow, x, y, 0); + if (y == Size / 2) glowAtCentre = glow; + worstGlow = Math.Max(worstGlow, Math.Abs(glow - expected.glow)); + worstColour = Math.Max(worstColour, Math.Abs(ReadHalf(run.Color, x, y, 0, 8) - expected.colour)); + } + _output.WriteLine($"{frames} frame(s), {name}: glow {glowAtCentre:F4} (anti-flicker model {expected.glow:F4}, fixed-weight model {fixedWeight.glow:F4}), " + + $"colour model {expected.colour:F4}; worst glow error {worstGlow:F4}, worst colour error {worstColour:F4}"); + + Assert.True(Math.Abs(expected.glow - fixedWeight.glow) > 2 * glowTolerance, + $"{name}: the case cannot tell the anti-flicker weight from a fixed one"); + Assert.True(worstGlow <= glowTolerance, + $"{name}, {frames} frame(s): glow is {worstGlow:F4} off the anti-flicker step response"); + Assert.True(worstColour <= colourTolerance, + $"{name}, {frames} frame(s): colour is {worstColour:F4} off the anti-flicker step response"); + + if (frames == 1) + { + double weight = column == 0 ? 0.3 * blendAlpha : 1.2 * blendAlpha; + Assert.InRange(glowAtCentre, weight - glowTolerance, weight + glowTolerance); + } + } + } + } + + /// + /// A sub-pixel leaf in front of a far background lands in pixel P in one jitter + /// phase and in P + (1, 0) in the next, so the depth of both pixels flips between + /// 8 and 120 blocks every frame. The old single-sample test reset both pixels + /// every frame; the 3x3 nearest-depth test sees the leaf in both windows and keeps + /// the history. Glow marks the last three frames (R = last, G = the one before, + /// B = the one before that, each 1 only in its frame): a reset in frame k leaves + /// that channel at 1 - (weights after k), a kept pixel at weight * (1 - ...). + /// The same scenario runs on the shipped shader and on a copy with only the + /// disocclusion line put back to the old per-sample comparison. + /// + [SkippableFact] + public void FlippingSubPixelLeafKeepsItsHistory() + { + TemporalRun? nearest = RunLeafFlip(null); + Skip.If(nearest == null, "No usable Vulkan device."); + TemporalRun perSample = RunLeafFlip(WithPerSampleDisocclusion)!; + + foreach (int x in new[] { LeafX, LeafX + 1 }) + { + float r = ReadByteChannel(nearest!.Glow, x, LeafY, 0); + float g = ReadByteChannel(nearest.Glow, x, LeafY, 1); + float b = ReadByteChannel(nearest.Glow, x, LeafY, 2); + float pr = ReadByteChannel(perSample.Glow, x, LeafY, 0); + float pg = ReadByteChannel(perSample.Glow, x, LeafY, 1); + float pb = ReadByteChannel(perSample.Glow, x, LeafY, 2); + _output.WriteLine($"pixel ({x},{LeafY}): 3x3 nearest glow=({r:F3},{g:F3},{b:F3}), per-sample glow=({pr:F3},{pg:F3},{pb:F3})"); + + // The old test: reset in the last frame (every channel equals that frame's glow). + Assert.True(pr >= 0.99f && pg <= 0.01f && pb <= 0.01f, + $"the per-sample reference no longer resets the flipping pixel ({x},{LeafY}); the scenario does not reproduce the bug"); + // The shipped test: kept in each of the last three frames. + Assert.InRange(r, 0.015f, 0.25f); + Assert.InRange(g, 0.015f, 0.25f); + Assert.InRange(b, 0.015f, 0.25f); + } + + // Not asserted, printed for the record: the background pixels just outside the + // leaf's two positions see it enter their current 3x3 while their history + // window never held it, so the nearest-depth test resets them instead. + foreach (int x in new[] { LeafX - 1, LeafX + 2 }) + { + _output.WriteLine($"fringe pixel ({x},{LeafY}): 3x3 nearest R={ReadByteChannel(nearest!.Glow, x, LeafY, 0):F3}, per-sample R={ReadByteChannel(perSample.Glow, x, LeafY, 0):F3}"); + } + } + + /// + /// The 3x3 test must not hide a real disocclusion: an 8x8 block at 8 blocks in + /// front of a background at 120 is present for three frames and gone in the + /// fourth. Every pixel of the block's interior has only background in its current + /// 3x3 and only the block in its history 3x3, so it resets (glow marks the last + /// frame: 1 after a reset) and shows the background colour at once. A pixel far + /// from the block keeps its history with the converged weight 0.3 x blendAlpha. + /// + [SkippableFact] + public void DisocclusionLargerThanTheNeighbourhoodStillResets() + { + const int frames = 4; + const float blendAlpha = 0.1f, blockColour = 0.9f, backgroundColour = 0.4f; + PerspectiveCamera camera = CreatePerspective(); + float nearDepth = camera.WindowDepth(LeafLinearDepth), farDepth = camera.WindowDepth(BackgroundLinearDepth); + static bool InBlock(int x, int y) => x >= 12 && x < 20 && y >= 12 && y < 20; + + TemporalRun? run = RunTemporal(frames, camera.Uniforms(blendAlpha), + (textures, history) => + { + UploadRgba16F(textures, history.Color, (x, y) => InBlock(x, y) ? blockColour : backgroundColour, + (x, y) => InBlock(x, y) ? blockColour : backgroundColour, + (x, y) => InBlock(x, y) ? blockColour : backgroundColour, (_, _) => 1f); + UploadFlatRgba8(textures, history.Glow, 0, 0, 0, 255); + UploadR32F(textures, history.Depth, (x, y) => InBlock(x, y) ? LeafLinearDepth : BackgroundLinearDepth); + }, + (frame, textures, inputs) => + { + bool present = frame < frames - 1; + bool Block(int x, int y) => present && InBlock(x, y); + UploadRgba16F(textures, inputs.SceneTex, (x, y) => Block(x, y) ? blockColour : backgroundColour, + (x, y) => Block(x, y) ? blockColour : backgroundColour, + (x, y) => Block(x, y) ? blockColour : backgroundColour, (_, _) => 1f); + UploadR32F(textures, inputs.DepthTex, (x, y) => Block(x, y) ? nearDepth : farDepth); + UploadRgba16F(textures, inputs.MotionTex, (_, _) => 0f, (_, _) => 0f, (_, _) => 0f, + (x, y) => Block(x, y) ? nearDepth : farDepth); + byte mark = frame == frames - 1 ? (byte)255 : (byte)0; + UploadFlatRgba8(textures, inputs.GlowTex, mark, mark, mark, 255); + }); + Skip.If(run == null, "No usable Vulkan device."); + + for (int y = 13; y < 19; y++) + for (int x = 13; x < 19; x++) + { + float glow = ReadByteChannel(run!.Glow, x, y, 0); + float colour = ReadHalf(run.Color, x, y, 0, 8); + Assert.True(glow >= 254f / 255f, $"disoccluded pixel ({x},{y}) kept its history: glow {glow:F3}"); + Assert.InRange(colour, backgroundColour - 0.01f, backgroundColour + 0.01f); + } + + foreach ((int x, int y) in new[] { (4, 4), (27, 27) }) + { + float glow = ReadByteChannel(run!.Glow, x, y, 0); + _output.WriteLine($"control pixel ({x},{y}): glow {glow:F4}, converged weight {0.3 * blendAlpha:F4}"); + Assert.InRange(glow, 0.3f * blendAlpha - 2.5f / 255f, 0.3f * blendAlpha + 2.5f / 255f); + } + } + + /// + /// At a depth edge the motion vector comes from the nearest-depth tap of the 3x3. + /// Foreground (8 blocks, columns >= 16) moved left by 4 px, so it writes + /// mv = (+4, 0); the background (120 blocks) is static. Column 15 is background, + /// but its 3x3 holds foreground taps, so it reprojects with +4 and reads history + /// column 19, the only bright column of the history glow. Column 14 (no foreground + /// in its 3x3) reads its own column, column 16 (foreground) reads column 20: both + /// dark. A resolve that used the pixel's own vector would leave column 15 dark. + /// + [SkippableFact] + public void MotionComesFromTheNearestDepthTapAtAnEdge() + { + const int edge = 16, shift = 4; + PerspectiveCamera camera = CreatePerspective(); + float nearDepth = camera.WindowDepth(LeafLinearDepth), farDepth = camera.WindowDepth(BackgroundLinearDepth); + + TemporalRun? run = RunTemporal(1, camera.Uniforms(0.05f), + (textures, history) => + { + UploadFlatRgba16F(textures, history.Color, 0.5f, 0.5f, 0.5f, 1f); + UploadRgba8(textures, history.Glow, (x, _) => x == edge - 1 + shift ? (byte)255 : (byte)0, + (_, _) => 0, (_, _) => 0, (_, _) => 255); + // Last frame the foreground started at column edge + shift. + UploadR32F(textures, history.Depth, (x, _) => x >= edge + shift ? LeafLinearDepth : BackgroundLinearDepth); + }, + (_, textures, inputs) => + { + UploadFlatRgba16F(textures, inputs.SceneTex, 0.5f, 0.5f, 0.5f, 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadR32F(textures, inputs.DepthTex, (x, _) => x >= edge ? nearDepth : farDepth); + UploadRgba16F(textures, inputs.MotionTex, (x, _) => x >= edge ? shift : 0f, (_, _) => 0f, (_, _) => 0f, + (x, _) => x >= edge ? nearDepth : farDepth); + }); + Skip.If(run == null, "No usable Vulkan device."); + + for (int y = 4; y < Size - 4; y++) + { + float edgePixel = ReadByteChannel(run!.Glow, edge - 1, y, 0); + float background = ReadByteChannel(run.Glow, edge - 2, y, 0); + float foreground = ReadByteChannel(run.Glow, edge, y, 0); + if (y == Size / 2) + _output.WriteLine($"row {y}: column {edge - 2} glow {background:F3}, column {edge - 1} glow {edgePixel:F3}, column {edge} glow {foreground:F3}"); + Assert.True(edgePixel >= 0.9f, $"column {edge - 1} row {y} did not reproject with the nearest tap's vector (glow {edgePixel:F3})"); + Assert.True(background <= 0.05f, $"column {edge - 2} row {y} moved although its 3x3 holds no foreground (glow {background:F3})"); + Assert.True(foreground <= 0.05f, $"column {edge} row {y} did not use its own vector (glow {foreground:F3})"); + } + } + + private const int LeafX = 16, LeafY = 16; + private const float LeafLinearDepth = 8f, BackgroundLinearDepth = 120f; + + /// The disocclusion line the shipped resolve carries, and the per-sample line it replaced. + private const string NearestDepthRejection = + "if (abs(historyNearest - closestLinearDepth) > depthTolerance) { alpha = 1.0; rejected = true; }"; + private const string PerSampleRejection = + "if (abs(historyLinear - linearDepth) > 0.5 + 0.08 * linearDepth) { alpha = 1.0; rejected = true; }"; + + private static string WithPerSampleDisocclusion(string fragment) + { + Assert.Contains(NearestDepthRejection, fragment); + return fragment.Replace(NearestDepthRejection, PerSampleRejection, StringComparison.Ordinal); + } + + private TemporalRun? RunLeafFlip(Func? fragmentTransform) + { + const int frames = 6; + const float leafColour = 0.9f, backgroundColour = 0.4f; + PerspectiveCamera camera = CreatePerspective(); + float nearDepth = camera.WindowDepth(LeafLinearDepth), farDepth = camera.WindowDepth(BackgroundLinearDepth); + + return RunTemporal(frames, camera.Uniforms(0.1f), + (textures, history) => + { + // The phase before frame 0: the leaf sat in the right-hand pixel. + bool Leaf(int x, int y) => x == LeafX + 1 && y == LeafY; + UploadRgba16F(textures, history.Color, (x, y) => Leaf(x, y) ? leafColour : backgroundColour, + (x, y) => Leaf(x, y) ? leafColour : backgroundColour, + (x, y) => Leaf(x, y) ? leafColour : backgroundColour, (_, _) => 1f); + UploadFlatRgba8(textures, history.Glow, 0, 0, 0, 255); + UploadR32F(textures, history.Depth, (x, y) => Leaf(x, y) ? LeafLinearDepth : BackgroundLinearDepth); + }, + (frame, textures, inputs) => + { + int leafX = frame % 2 == 0 ? LeafX : LeafX + 1; + bool Leaf(int x, int y) => x == leafX && y == LeafY; + UploadRgba16F(textures, inputs.SceneTex, (x, y) => Leaf(x, y) ? leafColour : backgroundColour, + (x, y) => Leaf(x, y) ? leafColour : backgroundColour, + (x, y) => Leaf(x, y) ? leafColour : backgroundColour, (_, _) => 1f); + UploadR32F(textures, inputs.DepthTex, (x, y) => Leaf(x, y) ? nearDepth : farDepth); + UploadRgba16F(textures, inputs.MotionTex, (_, _) => 0f, (_, _) => 0f, (_, _) => 0f, + (x, y) => Leaf(x, y) ? nearDepth : farDepth); + UploadFlatRgba8(textures, inputs.GlowTex, + frame == frames - 1 ? (byte)255 : (byte)0, + frame == frames - 2 ? (byte)255 : (byte)0, + frame == frames - 3 ? (byte)255 : (byte)0, 255); + }, + fragmentTransform); + } + + /// + /// The resolve's colour and glow recurrence for a grey pixel whose history stays + /// inside the neighbourhood box, with the glow stored as UNORM8 every frame. + /// + private static (double colour, double glow) AntiFlickerModel( + double current, double history, int frames, double blendAlpha, bool antiFlicker) + { + double glow = 0; + for (int i = 0; i < frames; i++) + { + double alpha = blendAlpha; + if (antiFlicker) + { + double w = 1.0 - Math.Abs(current - history) / Math.Max(current, Math.Max(history, 0.2)); + alpha = blendAlpha * 1.2 + (blendAlpha * 0.3 - blendAlpha * 1.2) * w * w; + } + double wCur = alpha / (1.0 + current), wHist = (1.0 - alpha) / (1.0 + history); + history = (current * wCur + history * wHist) / Math.Max(wCur + wHist, 1e-5); + glow = Math.Round((glow * (1.0 - alpha) + alpha) * 255.0) / 255.0; + } + return (history, glow); + } + + private sealed class TemporalRun + { + public byte[] Color = Array.Empty(); + public byte[] Glow = Array.Empty(); + } + + /// + /// A fresh context, resolves ping-ponged between two + /// history sets (the first seeded by , inputs + /// uploaded per frame by ), and one readback of the + /// last write after the loop. Null when there is no usable device. + /// + private TemporalRun? RunTemporal(int frames, TaaUniforms uniforms, + Action seedHistory, + Action uploadFrame, + Func? fragmentTransform = null) + { + var messages = new List(); + if (!TryCreateContext(_output, messages, out VulkanContext? context)) return null; + + using (context) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new DescriptorCache(context!); + using ShaderProgramResources program = LoadProgram(context!, compiler, state, fragmentTransform); + + var inputs = CreateInputSet(textures); + TaaAttachmentSet history = CreateAttachmentSet(textures, targets); + TaaAttachmentSet current = CreateAttachmentSet(textures, targets); + seedHistory(textures, history); + + for (int frame = 0; frame < frames; frame++) + { + uploadFrame(frame, textures, inputs); + inputs.HistoryColor = history.Color; + inputs.HistoryGlow = history.Glow; + inputs.HistoryDepth = history.Depth; + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, current); + (history, current) = (current, history); + } + + var run = new TemporalRun + { + Color = ReadTextureBytes(context!, commands, textures, history.Color, 8), + Glow = ReadTextureBytes(context!, commands, textures, history.Glow, 4), + }; + + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + return run; + } + } + + /// + /// A real perspective (fov 70, near 0.1, far 200) with an identity view, so the + /// resolve's linear depth is the distance the test names and nearer window depth + /// means nearer linear depth, as in the game. Motion is always written in these + /// tests, so the matrices only feed the depth reconstruction. + /// + private sealed class PerspectiveCamera + { + private readonly double[] _projection; + public readonly float[] Projection; + public readonly float[] InverseProjection; + + public PerspectiveCamera(double[] projection, double[] inverse) + { + _projection = projection; + Projection = Array.ConvertAll(projection, v => (float)v); + InverseProjection = Array.ConvertAll(inverse, v => (float)v); + } + + public float WindowDepth(double linear) + { + double clipZ = _projection[10] * -linear + _projection[14]; + double clipW = _projection[11] * -linear + _projection[15]; + return (float)(clipZ / clipW * 0.5 + 0.5); + } + + public TaaUniforms Uniforms(float blendAlpha) => new() + { + BlendAlpha = blendAlpha, + InvViewProjJittered = InverseProjection, + PrevViewProj = Projection, + ViewMatrix = Identity4, + }; + } + + private static PerspectiveCamera CreatePerspective() + { + const double near = 0.1, far = 200.0, fov = 70.0 * Math.PI / 180.0; + double[] projection = Vintagestory.API.MathTools.Mat4d.Perspective(Vintagestory.API.MathTools.Mat4d.Create(), fov, 1.0, near, far); + double[] inverse = Vintagestory.API.MathTools.Mat4d.Invert(Vintagestory.API.MathTools.Mat4d.Create(), projection)!; + return new PerspectiveCamera(projection, inverse); + } + // ------------------------------------------------------------------ setup private static ShaderProgramResources LoadProgram( - VulkanContext context, ShaderCompiler compiler, GlStateTracker state) + VulkanContext context, ShaderCompiler compiler, GlStateTracker state, + Func? fragmentTransform = null) { Dictionary files = ShaderCorpus.LoadShaderFiles(); + if (fragmentTransform != null) + { + files["taa-resolve.fsh"] = fragmentTransform(files["taa-resolve.fsh"]); + } Dictionary includes = ShaderCorpus.LoadIncludes(); List stages = ShaderCorpus.BuildProgram( "taa-resolve", files, includes, ShaderCorpus.Variants().First()); @@ -1088,6 +1512,20 @@ private static unsafe void UploadFlatR32F(TextureManager textures, int textureId } } + private static unsafe void UploadR32F(TextureManager textures, int textureId, Func value) + { + var data = new float[Size * Size]; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + data[y * (int)Size + x] = value(x, y); + } + fixed (float* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 4); + } + } + // --------------------------------------------------------------- readback private static unsafe byte[] ReadTextureBytes( diff --git a/Optimum.Tests/taa-antiflicker-coverage-tests.cs b/Optimum.Tests/taa-antiflicker-coverage-tests.cs new file mode 100644 index 00000000..553f1bbb --- /dev/null +++ b/Optimum.Tests/taa-antiflicker-coverage-tests.cs @@ -0,0 +1,234 @@ +using System; +using System.Diagnostics; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the 2026-09-11 distant-foliage fix in taa-resolve.fsh +/// (TAA-PLAN.md "Follow-up 2026-09-11: distant foliage jitter was the resolve"). +/// +/// Root cause: a single-sample disocclusion test rejected history on ~3.7% of +/// distant leaf pixels per frame on both backends (a sub-pixel leaf hits the leaf +/// in one jitter phase and the far background in the next), and a fixed current +/// weight let the clip box drag the history. The 3x3 nearest-depth test and the +/// anti-flicker weight took it to ~1.1%; the user confirmed the flicker gone on +/// Vulkan. These tests pin the shader text, the "do not revert" guard, the +/// documents that record the finding and the rejection-rate script. The numbers +/// are proven by Optimum.Render.Vulkan.Tests/TaaResolveTests. +/// +public class TaaAntiFlickerCoverageTests +{ + private static readonly string[] GpuTests = + { + "AntiFlickerWeightsFollowTheLuminanceDifference", + "FlippingSubPixelLeafKeepsItsHistory", + "DisocclusionLargerThanTheNeighbourhoodStillResets", + "MotionComesFromTheNearestDepthTapAtAnEdge", + }; + + [Fact] + public void TheCurrentWeightFollowsTheLuminanceDifferenceForKeptPixelsOnly() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + + // Every rejection path marks the pixel, and only unmarked pixels are reweighted. + Assert.Contains("bool rejected = resetHistory != 0 || offscreen;", resolve); + int nanReset = resolve.IndexOf("historyLinear = linearDepth;", StringComparison.Ordinal); + Assert.True(nanReset >= 0); + Assert.True(resolve.IndexOf("rejected = true;", nanReset, StringComparison.Ordinal) + < resolve.IndexOf("float historyNearest", StringComparison.Ordinal), + "the NaN-history reset no longer marks the pixel as rejected"); + + const string weight = "alpha = mix(blendAlpha * 1.2, blendAlpha * 0.3, unbiasedWeight * unbiasedWeight);"; + string[] ordered = + { + "vec3 histYcc = clipToBox(clipMin, clipMax, rgbToYCoCg(history.rgb), clipKeep);", + "vec3 curYcc = rgbToYCoCg(current.rgb);", + "if (!rejected)", + "float lumCur = max(curYcc.x, 0.0);", + "float lumHist = max(histYcc.x, 0.0);", + "float unbiasedDiff = abs(lumCur - lumHist) / max(lumCur, max(lumHist, 0.2));", + "float unbiasedWeight = 1.0 - unbiasedDiff;", + weight, + "alpha = max(alpha, reactive);", + "float wCur = alpha / (1.0 + curYcc.x);", + }; + AssertInOrder(resolve, ordered); + + // Reactive is applied once, after the weighting, never before it. + Assert.Equal(1, Count(resolve, "alpha = max(alpha, reactive);")); + Assert.Equal(1, Count(resolve, weight)); + } + + [Fact] + public void TheDisocclusionTestComparesTheNearestDepthOnBothSides() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + + // Current side: the nearest window depth of the 3x3, its tap and its linear depth. + AssertInOrder(resolve, new[] + { + "float closestDepth = 2.0;", + "ivec2 closestPixel = pixel;", + "for (int y = -1; y <= 1; y++)", + "float tapDepth = texelFetch(depthTex, p, 0).r;", + "if (tapDepth < closestDepth) { closestDepth = tapDepth; closestPixel = p; }", + "vec4 current = filteredWeight > 1e-4 ? filtered / filteredWeight : centreSample;", + }); + Assert.Contains("vec4 closestH = invViewProjJittered * vec4(closestNdc, closestDepth * 2.0 - 1.0, 1.0);", resolve); + Assert.Contains("float closestLinearDepth = -(viewMatrix * vec4(closestWorld, 1.0)).z;", resolve); + + // History side: the nearest finite depth of the 3x3 around the reprojected point. + AssertInOrder(resolve, new[] + { + "float historyNearest = historyLinear;", + "for (int hx = -1; hx <= 1; hx++)", + "float h = texture(historyDepth, historyUv + vec2(hx, hy) * invSize).r;", + "if (!isnan(h) && !isinf(h)) historyNearest = min(historyNearest, h);", + "float depthTolerance = 0.5 + 0.08 * closestLinearDepth;", + "if (abs(historyNearest - closestLinearDepth) > depthTolerance) { alpha = 1.0; rejected = true; }", + }); + + // Not the single-sample test it replaced. + Assert.DoesNotContain("float depthTolerance = 0.5 + 0.08 * linearDepth;", resolve); + Assert.DoesNotContain("abs(historyLinear - linearDepth)", resolve); + + // The motion vector comes from the same tap; the lookup anchor, reactive and the + // stored history depth stay this pixel's own (contract v1 unchanged). + Assert.Contains("vec4 motion = texelFetch(motionTex, closestPixel, 0);", resolve); + Assert.Contains("vec2 currentUnjittered = closestCentre - jitterPx;", resolve); + Assert.Contains("prevViewProj * vec4(closestWorld + cameraDelta, 1.0)", resolve); + Assert.Contains("vec2 historyUv = (pixelCentre + mv) * invSize;", resolve); + Assert.Contains("float reactive = clamp(texelFetch(motionTex, pixel, 0).b, 0.0, 1.0);", resolve); + Assert.Equal(2, Count(resolve, "outDepth = vec4(linearDepth);")); + Assert.DoesNotContain("outDepth = vec4(closestLinearDepth)", resolve); + } + + [Fact] + public void TheShaderSaysNeverToRevertAndNamesTheTestsThatExist() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + string gpu = Read("Optimum.Render.Vulkan.Tests/TaaResolveTests.cs"); + + Assert.Contains("2026-09-11: distant foliage jitter was THIS test", resolve); + Assert.Contains("DO NOT REVERT to a single-sample depth test.", resolve); + Assert.Contains("DO NOT REVERT to a fixed blend weight.", resolve); + Assert.Contains("~3.7%", resolve); + Assert.Contains("~1.1%", resolve); + Assert.Contains("scripts/dev/taa-rejection.py", resolve); + Assert.Contains("TaaAntiFlickerCoverageTests", resolve); + + foreach (string test in GpuTests) + { + Assert.Contains("TaaResolveTests." + test, resolve); + Assert.Contains("public void " + test + "()", gpu); + } + } + + [Fact] + public void TheFindingIsRecordedInThePlanTheContractAndBothAcceptanceDocuments() + { + string contract = Read("docs/temporal-frame-contract.md"); + string section4 = Between(contract, "## 4. The resolve's own inputs", "## 5. Reset"); + Assert.Contains("Note (2026-09-11): anti-flicker weighting and nearest-depth disocclusion.", section4); + Assert.Contains("the contract stays **v1**", section4); + Assert.Contains("~3.7%", section4); + Assert.Contains("~1.1%", section4); + Assert.Contains("**Never revert**", section4); + Assert.Contains("`alpha = mix(blendAlpha * 1.2, blendAlpha * 0.3, w * w)`", section4); + Assert.Contains("`0.5 + 0.08 * closestLinearDepth`", section4); + Assert.Contains("scripts/dev/taa-rejection.py", section4); + foreach (string test in GpuTests) + { + Assert.Contains(test, section4); + } + + string plan = Read("TAA-PLAN.md"); + string followUp = Between(plan, "## Follow-up 2026-09-11: distant foliage jitter was the resolve", "## Follow-up (not part of this plan)"); + foreach (string needle in new[] { "3.7%", "1.1%", "scripts/dev/taa-rejection.py", "Do not revert" }) + { + Assert.Contains(needle, followUp); + } + foreach (string test in GpuTests) + { + Assert.Contains(test, followUp); + } + + string taaAcceptance = Read("docs/taa-acceptance.md"); + string row = Between(taaAcceptance, "### A19. distant foliage stability", "## 3. Performance"); + Assert.Contains("- Scene:", row); + Assert.Contains("- Commands:", row); + Assert.Contains("- Pass:", row); + Assert.Contains("- Record:", row); + Assert.Contains("scripts/dev/parity-capture.sh", row); + Assert.Contains("python3 scripts/dev/taa-rejection.py", row); + Assert.Contains("<= 1.5 percent", row); + Assert.Contains("default two frames in flight", row); + + string vulkanAcceptance = Read("docs/vulkan-acceptance.md"); + string m17 = Between(vulkanAcceptance, "#### M1.7 TAA still-frame stability", "#### M1.8"); + Assert.Contains("python3 scripts/dev/taa-rejection.py", m17); + Assert.Contains("**required**", m17); + } + + [Fact] + public void TaaRejectionSelfTestPasses() + { + string script = PatchReader.FindRepositoryFile("scripts/dev/taa-rejection.py"); + string root = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(script)))!; + var start = new ProcessStartInfo("python3") + { + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add("scripts/dev/taa-rejection.py"); + start.ArgumentList.Add("--self-test"); + using Process process = Process.Start(start)!; + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + Assert.True(process.WaitForExit(180_000), "taa-rejection.py --self-test did not finish"); + Assert.True(process.ExitCode == 0, "taa-rejection.py --self-test exited " + process.ExitCode + ": " + stdout + stderr); + Assert.Contains("taa-rejection.py self-test: ok", stdout); + } + + // ------------------------------------------------------------------ helpers + + private static void AssertInOrder(string source, string[] needles) + { + int last = -1; + foreach (string needle in needles) + { + int index = source.IndexOf(needle, last + 1, StringComparison.Ordinal); + Assert.True(index > last, "missing or out of order in taa-resolve.fsh: " + needle); + last = index; + } + } + + private static string Between(string source, string start, string end) + { + int from = source.IndexOf(start, StringComparison.Ordinal); + Assert.True(from >= 0, "missing: " + start); + int to = source.IndexOf(end, from + start.Length, StringComparison.Ordinal); + Assert.True(to > from, "missing after '" + start + "': " + end); + return source.Substring(from, to - from); + } + + private static int Count(string source, string value) + { + int count = 0; + int offset = 0; + while ((offset = source.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index dfa65183..8503ac03 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -326,9 +326,11 @@ public void TaaDebugValidityUsesTheResolvePassDepthTolerance() // The resolve pass accepts a writer whose recorded depth is within a // value-scaled tolerance; the debug validity view has to use the same // expression or it paints red where the resolve reprojects happily. - Assert.Contains("abs(motion.a - depth) <= max(2e-4, 8e-4 * depth)", resolve); + // The resolve evaluates it at the nearest-depth tap of its 3x3 (2026-09-11); + // the expression is what has to match. + Assert.Contains("abs(motion.a - closestDepth) <= max(2e-4, 8e-4 * closestDepth)", resolve); Assert.Contains("abs(motion.a - sceneDepth) <= max(2e-4, 8e-4 * sceneDepth)", debug); - Assert.Equal(Tolerance(resolve, "depth"), Tolerance(debug, "sceneDepth")); + Assert.Equal(Tolerance(resolve, "closestDepth"), Tolerance(debug, "sceneDepth")); Assert.DoesNotContain("abs(motion.a - sceneDepth) < 1e-4", debug); } @@ -454,7 +456,9 @@ public void TheEntityMotionWindowOnlyWrapsTheGamesOwnEntityRenderers() public void SkyPixelsReprojectAsDirections() { string resolve = Read("sources/shaders/taa-resolve.fsh"); - Assert.Contains("bool sky = depth >= 0.999999;", resolve); + // Sky is decided on the tap the vector comes from (the nearest depth in the + // 3x3): a pixel next to a finite surface reprojects as that surface. + Assert.Contains("bool sky = closestDepth >= 0.999999;", resolve); // Far point minus near point: the far point alone carries the eye offset // of CameraMatrixOrigin (see TaaSkyDecalMotionCoverageTests). Assert.Contains("prevViewProj * vec4(skyDirection, 0.0)", resolve); diff --git a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs index 3f86731a..31b52f68 100644 --- a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -45,10 +45,15 @@ public void TheSkyDirectionIsFarMinusNearInBothConsumers() Assert.Contains("if ((farH.w < 0.0) != (nearH.w < 0.0)) direction = -direction;", sky); Assert.DoesNotContain("farH.w < 0.0 ? -farH.xyz : farH.xyz", sky); - Assert.Contains("vec4 nearH = invViewProjJittered * vec4(ndc, -1.0, 1.0);", resolve); - Assert.Contains("vec3 skyDirection = worldH.xyz * nearH.w - nearH.xyz * worldH.w;", resolve); + // The resolve reprojects the nearest-depth tap of its 3x3 (2026-09-11), so + // its far and near points are that tap's; the direction is still far minus + // near with the same sign rule. + Assert.Contains("vec4 nearH = invViewProjJittered * vec4(closestNdc, -1.0, 1.0);", resolve); + Assert.Contains("vec3 skyDirection = closestH.xyz * nearH.w - nearH.xyz * closestH.w;", resolve); + Assert.Contains("if ((closestH.w < 0.0) != (nearH.w < 0.0)) skyDirection = -skyDirection;", resolve); Assert.Contains("prevViewProj * vec4(skyDirection, 0.0)", resolve); Assert.DoesNotContain("prevViewProj * vec4(world, 0.0)", resolve); + Assert.DoesNotContain("prevViewProj * vec4(closestWorld, 0.0)", resolve); } /// diff --git a/Optimum.Tests/temporal-contract-tests.cs b/Optimum.Tests/temporal-contract-tests.cs index 062cb518..c69e4d34 100644 --- a/Optimum.Tests/temporal-contract-tests.cs +++ b/Optimum.Tests/temporal-contract-tests.cs @@ -375,20 +375,32 @@ public void TheWriterDepthToleranceIsTheOneTheContractStates() const string tolerance = "bool written = motion.a > 0.0 && abs(motion.a - depth) <= max(2e-4, 8e-4 * depth);"; + // The reference resolve evaluates the rule at the nearest-depth tap of its + // 3x3 (2026-09-11, section 4): `motion` is read at closestPixel and + // `closestDepth` is the depth attachment at that same tap, so the rule is + // still "a writer's a against the depth of the pixel it wrote", unchanged. + string atClosestTap = tolerance.Replace("depth", "closestDepth", StringComparison.Ordinal); string resolve = Read("sources/shaders/taa-resolve.fsh"); - Assert.True(resolve.Contains(tolerance, StringComparison.Ordinal), + Assert.True(resolve.Contains(atClosestTap, StringComparison.Ordinal), $"taa-resolve.fsh no longer carries the writer-depth validity test {Doc} section 3.2 freezes. " + "Change the document and bump the contract version before changing this line."); + Assert.True(resolve.Contains("vec4 motion = texelFetch(motionTex, closestPixel, 0);", StringComparison.Ordinal) + && resolve.Contains("float tapDepth = texelFetch(depthTex, p, 0).r;", StringComparison.Ordinal) + && resolve.Contains("if (tapDepth < closestDepth) { closestDepth = tapDepth; closestPixel = p; }", StringComparison.Ordinal), + $"taa-resolve.fsh no longer reads the motion vector and its depth from the same tap; {Doc} section 3.2 compares a writer against its own pixel."); string doc = ReadDoc(); Assert.True(doc.Contains(tolerance, StringComparison.Ordinal), $"{Doc} section 3.2 no longer quotes the writer-depth validity test."); + Assert.True(doc.Contains(atClosestTap, StringComparison.Ordinal), + $"{Doc} section 4 no longer states that the resolve applies the validity test at the nearest-depth tap."); // And the channel semantics it rests on. Assert.True(resolve.Contains("uniform sampler2D motionTex;", StringComparison.Ordinal), $"taa-resolve.fsh no longer reads the motion attachment {Doc} section 3.2 describes."); - Assert.True(resolve.Contains("float reactive = clamp(motion.b, 0.0, 1.0);", StringComparison.Ordinal), - $"taa-resolve.fsh no longer reads reactive from motion.b, which {Doc} section 3.2 freezes."); + // Reactive stays this pixel's own value, not the nearest tap's. + Assert.True(resolve.Contains("float reactive = clamp(texelFetch(motionTex, pixel, 0).b, 0.0, 1.0);", StringComparison.Ordinal), + $"taa-resolve.fsh no longer reads reactive from this pixel's motion.b, which {Doc} section 3.2 freezes."); Assert.True(resolve.Contains("vec2 historyUv = (pixelCentre + mv) * invSize;", StringComparison.Ordinal), $"taa-resolve.fsh no longer anchors the history lookup where {Doc} section 3.2 says it does."); } diff --git a/TAA-PLAN.md b/TAA-PLAN.md index b52b89e6..289734c5 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -815,6 +815,35 @@ and this section, in that order. - https://github.com/godotengine/godot/pull/61319 - https://mods.vintagestory.at/show/mod/35005 +## Follow-up 2026-09-11: distant foliage jitter was the resolve +- **Symptom:** distant foliage shimmered with TAA on, on both backends. +- **Root cause** (parity dumps, both backends): `taa-resolve.fsh`'s single-sample disocclusion test + (this pixel's linear depth against the one history depth under `historyUv`) rejected history on + **3.7%** of distant leaf pixels per frame. A sub-pixel leaf hits the leaf in one jitter phase and + the far background in the next, so the two depths disagree by tens of blocks and the pixel resets + to the raw aliased sample. On top of that the fixed current weight (`blendAlpha` for every kept + pixel) let the neighbourhood clip box, moved every frame by the same leaf, drag the history. +- **Fix** (`sources/shaders/taa-resolve.fsh`, taken from `diag/taa-trace` b0a473f without its + instrumentation): the 3x3 loop tracks the nearest window depth; the motion vector, the sky test and + the camera fallback use that tap; the disocclusion test compares its linear depth against the + nearest finite history depth in a 3x3 around `historyUv`, tolerance `0.5 + 0.08 * closestLinearDepth`; + kept pixels take `mix(1.2, 0.3, w * w) * blendAlpha` of the current frame with + `w = 1 - |lumCur - lumHist| / max(lumCur, max(lumHist, 0.2))` (Playdead INSIDE TAA), then reactive. + Contract unchanged (v1); note under section 4 of `docs/temporal-frame-contract.md`. +- **Numbers:** leaf-far rejection **3.7% -> 1.1%** per frame. The user confirmed on Vulkan that the + distant-foliage flicker is gone. +- **Tests:** `TaaResolveTests.AntiFlickerWeightsFollowTheLuminanceDifference` (0.3 x / 1.2 x + blendAlpha step responses), `FlippingSubPixelLeafKeepsItsHistory` (the old per-sample line resets + a leaf flipping between two pixels, the shipped one keeps it), + `DisocclusionLargerThanTheNeighbourhoodStillResets`, `MotionComesFromTheNearestDepthTapAtAnEdge`; + `Optimum.Tests/taa-antiflicker-coverage-tests.cs` pins the shader lines, this entry, the contract + note, the acceptance rows and the script's self-test. +- **Script:** `python3 scripts/dev/taa-rejection.py [--max-leaf-far 1.5]` prints + single-sample and 3x3 rejection rates per region (near/mid/far by linear-depth p50/p90, leaf-mid, + leaf-far) and fails when the 3x3 leaf-far rate is above 1.5 percent. Required by + `docs/taa-acceptance.md` row A19 and `docs/vulkan-acceptance.md` M1.7. +- **Do not revert** to a single-sample depth test or a fixed blend weight. + ## Follow-up (not part of this plan): shader patch system Shaders ship as whole-file overrides (`sources/shaders/*` copied over vanilla by name, since v0.1.0; P3 adds chunktopsoil, entityanimated and the vertexwarp include). A game update that changes a vanilla diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 8af34d68..b6f5e782 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -15,6 +15,8 @@ Tooling used by this document: | `scripts/dev/perf-capture.sh` | launch, warm up, record 30 s of frame times, close, print mean, 1% low and stddev | | `scripts/dev/pacing-gate.sh` | pass/fail on a captured run's pacing logs (section 3, P3) | | `scripts/dev/luma-diff.py` | still-frame luminance diff (parity skill section 2c) | +| `scripts/dev/parity-capture.sh` | one parity dump of every attachment on one backend (config restored on exit) | +| `scripts/dev/taa-rejection.py` | history rejection rates per region from a parity dump; fails above 1.5 percent 3x3 leaf-far rejection (row A19) | ## 0. Preconditions for every row @@ -178,6 +180,15 @@ measurement to record. "TAA off byte-identical" is checked once, in row A18, not - Pass: `cmp` reports identical files, or the luminance diff is exactly 0.000. - Record: the `cmp` result and the diff value, per backend. +### A19. distant foliage stability (resolve rejection rate) +Added 2026-09-11 (TAA-PLAN.md "Follow-up 2026-09-11: distant foliage jitter was the resolve"): a +single-sample disocclusion test rejected history on ~3.7% of distant leaf pixels per frame; the 3x3 +nearest-depth test measures ~1.1%. This row keeps it that way. +- Scene: a tree line or forest edge 60 or more blocks away, camera parked, section 0 applied (wind stilled), `Taa: true`, the default two frames in flight. +- Commands: per backend `scripts/dev/parity-capture.sh --renderer vulkan --world "" --frame 600 --out /tmp/taa-rej-vulkan` (and `--renderer opengl ... --out /tmp/taa-rej-opengl`), then `python3 scripts/dev/taa-rejection.py /tmp/taa-rej-`; then `RENDERER= scripts/dev/run-client.sh ""`, `scripts/dev/client-renderer.sh`, and look at the same distant foliage. +- Pass: `taa-rejection.py` exits 0 on both dumps (3x3 nearest-depth leaf-far rejection <= 1.5 percent), and the user's eyes on distant foliage see no shimmer on either backend at the default two frames in flight. +- Record: both renderer lines, both rejection tables (single-sample and 3x3, per region), the user's verdict and date. + ## 3. Performance ### P1. Performance on the Arc 140V diff --git a/docs/temporal-frame-contract.md b/docs/temporal-frame-contract.md index 939750fa..2889e698 100644 --- a/docs/temporal-frame-contract.md +++ b/docs/temporal-frame-contract.md @@ -211,7 +211,8 @@ a = writerDepth window depth in [0,1] at write time covers precision; the floor covers depths near the near plane. Where the test fails, the resolve falls back to camera reprojection. **This is the contract for unknown writers**: mod geometry, uninstrumented renderers and sky all land in the fallback without relying on undefined - unwritten-output contents. + unwritten-output contents. The reference resolve evaluates this rule at the nearest-depth tap of + its 3x3 (note under §4, 2026-09-11); the rule itself is unchanged. Consequence, measured (P4 finding (o)): a draw whose depth offset moves the depth buffer further than the tolerance — a decal at one block's distance moves it ~1.3e-3 against a tolerance of @@ -287,6 +288,38 @@ MRT outputs: `outColor` (colour), `outGlow` (glow), `outDepth` (linear view dept history attachments. The camera fallback reprojects a finite surface as `world + cameraDelta` and sky (`depth >= 0.999999`) as a **direction** with `w = 0`, so camera translation cannot move it. +**Note (2026-09-11): anti-flicker weighting and nearest-depth disocclusion.** A change inside the +reference consumer only: the contract stays **v1** (motion-vector semantics, the §3.2 validity rule, +history formats and slot layout are unchanged). Root cause of the distant-foliage jitter, measured on +parity dumps of both backends: the resolve's single-sample disocclusion test (this pixel's linear +depth against the one history depth under `historyUv`) rejected history on **~3.7%** of distant leaf +pixels per frame, because a sub-pixel leaf hits the leaf in one jitter phase and the far background in +the next; and a fixed current weight let the neighbourhood clip box, moved every frame by that leaf, +drag the history with it. What the resolve does since: + +- **Nearest-depth tap.** The 3x3 loop keeps the tap with the smallest window depth (`closestPixel`, + `closestDepth`). The motion vector comes from that tap: the validity rule is evaluated there as + `bool written = motion.a > 0.0 && abs(motion.a - closestDepth) <= max(2e-4, 8e-4 * closestDepth);` + (`motion` and `closestDepth` are read at the same tap, so it is still a writer against its own + pixel), and the camera fallback, the sky test and the far-minus-near sky direction use that tap's + reconstructed point. `historyUv` stays anchored at this pixel's centre, reactive stays this pixel's + own `motion.b`, and the history still stores this pixel's own linear depth. +- **3x3 nearest-depth disocclusion.** The nearest finite history depth in the 3x3 around `historyUv` + against the nearest tap's linear depth, tolerance `0.5 + 0.08 * closestLinearDepth`. +- **Anti-flicker current weight** (Playdead INSIDE TAA). For pixels not rejected (reset, off-screen, + NaN history, disocclusion): `alpha = mix(blendAlpha * 1.2, blendAlpha * 0.3, w * w)` with + `w = 1 - |lumCur - lumHist| / max(lumCur, max(lumHist, 0.2))` on the rectified YCoCg luminance, + then `alpha = max(alpha, reactive)`. Rejected pixels keep `alpha = 1`. + +Measured: leaf-far rejection **~3.7% -> ~1.1%** per frame; the user confirmed on Vulkan that the +distant-foliage flicker is gone. **Never revert** to a single-sample depth test or a fixed blend +weight. Pinned by `TaaResolveTests.AntiFlickerWeightsFollowTheLuminanceDifference`, +`FlippingSubPixelLeafKeepsItsHistory`, `DisocclusionLargerThanTheNeighbourhoodStillResets` and +`MotionComesFromTheNearestDepthTapAtAnEdge` (GPU), `Optimum.Tests/taa-antiflicker-coverage-tests.cs` +(source), and gated in the game by `python3 scripts/dev/taa-rejection.py ` (3x3 +leaf-far rejection <= 1.5 percent; `docs/taa-acceptance.md` row A19). External consumers (FSR, XeSS, +DLSS) do their own dilation and rejection and are not bound by this. + --- ## 5. Reset diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 93411206..2f1b6b52 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -226,10 +226,13 @@ All numbers first, then eyes. Each row names the plan's definition of done verba #### M1.7 TAA still-frame stability - Commands: `Taa: true`; `docs/taa-acceptance.md` section 1 (seven screenshot pairs per backend, - `scripts/dev/luma-diff.py --median`). + `scripts/dev/luma-diff.py --median`); **required**: one parity dump per backend with distant + foliage in frame (`scripts/dev/parity-capture.sh`) and `python3 scripts/dev/taa-rejection.py + ` on each (`docs/taa-acceptance.md` row A19; guards the 2026-09-11 distant-foliage fix). - Pass: the Vulkan median is within 0.3 of the OpenGL median (reference VK 1.84 / GL 1.87, - `docs/taa-acceptance.md` section 1). -- Record: both renderer lines, the fourteen diffs, both medians. + `docs/taa-acceptance.md` section 1), and `taa-rejection.py` exits 0 on both dumps (3x3 nearest-depth + leaf-far rejection <= 1.5 percent). +- Record: both renderer lines, the fourteen diffs, both medians, both rejection tables. #### M1.8 TAA acceptance rows re-pass - Commands: `docs/taa-acceptance.md` rows A11, A13, A14, A15, A17, A18. diff --git a/scripts/dev/taa-rejection.py b/scripts/dev/taa-rejection.py new file mode 100644 index 00000000..6dad85e8 --- /dev/null +++ b/scripts/dev/taa-rejection.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""TAA history rejection rates from one OPTIMUM_PARITY_DUMP directory. + +Guards the 2026-09-11 finding (TAA-PLAN.md "Follow-up 2026-09-11: distant +foliage jitter was the resolve"): a single-sample disocclusion test in +taa-resolve.fsh threw the history away on ~3.7% of distant leaf pixels per +frame on both backends, because a sub-pixel leaf hits the leaf in one jitter +phase and the far background in the next. The 3x3 nearest-depth test that +replaced it measured ~1.1%. Do not revert to a single-sample depth test. + +Inputs, by the file names both backends share (OptimumParityDump.FileNameFormat): + 19-OptimumTaaHistoryA-color2-r32f.pfm linear view depth, one history slot + 20-OptimumTaaHistoryB-color2-r32f.pfm linear view depth, the other slot + (this frame's and last frame's, in an order set by the frame parity; both + tests below are symmetric, so the order does not matter) + 0-Primary-depth-depth.pfm window depth; >= 0.999999 is sky, excluded + 0-Primary-color2-rgba16f.alpha.pfm leaf mask: alpha > 0.5 +PFM, float32, negative scale = little-endian, GL row order; channel 0 is used. + +Tests, per pixel, over the pixels where both history depths are finite and +positive and the window depth is finite and not sky: + single-sample |a - b| > 0.5 + 0.08 * min(a, b) + 3x3 nearest the same on min3x3(a) and min3x3(b) (a 3x3 minimum filter on + both sides first; non-finite taps ignored, edges clamped) + +Regions, by the quantiles p50 and p90 of min(a, b) over those pixels: + near <= p50 < mid <= p90 < far; leaf-mid and leaf-far are the leaf-masked + parts of mid and far. + +Usage: + scripts/dev/taa-rejection.py [--max-leaf-far 1.5] + scripts/dev/taa-rejection.py --self-test +Exit: 0 pass; 1 the 3x3 leaf-far rejection rate (percent) is above +--max-leaf-far; 2 usage or input error (missing file, shape mismatch, no +leaf-far pixels to judge). numpy only. +""" +import argparse +import io +import math +import os +import sys +import tempfile + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from ssim import ParityError, read_image, write_pfm # noqa: E402 (same directory, PFM io) + +HISTORY_A = "19-OptimumTaaHistoryA-color2-r32f.pfm" +HISTORY_B = "20-OptimumTaaHistoryB-color2-r32f.pfm" +DEPTH = "0-Primary-depth-depth.pfm" +LEAF = "0-Primary-color2-rgba16f.alpha.pfm" +REGIONS = ("near", "mid", "far", "leaf-mid", "leaf-far") +SKY_DEPTH = 0.999999 + + +def min3x3(plane): + """3x3 minimum filter; non-finite taps count as +inf, edges clamp.""" + finite = np.where(np.isfinite(plane), plane, np.inf) + padded = np.pad(finite, 1, mode="edge") + height, width = plane.shape + out = np.full(plane.shape, np.inf) + for dy in range(3): + for dx in range(3): + out = np.minimum(out, padded[dy:dy + height, dx:dx + width]) + return out + + +def rejected(a, b): + """The resolve's disocclusion comparison: |a - b| > 0.5 + 0.08 * min(a, b).""" + with np.errstate(invalid="ignore"): + return np.abs(a - b) > 0.5 + 0.08 * np.minimum(a, b) + + +def analyse(a, b, depth, alpha): + """Returns {pixels, p50, p90, rows: {region: (pixels, single hits, 3x3 hits)}}.""" + shapes = sorted({a.shape, b.shape, depth.shape, alpha.shape}) + if len(shapes) != 1: + raise ParityError("attachment shapes differ: %s" % shapes) + with np.errstate(invalid="ignore"): + valid = (np.isfinite(a) & np.isfinite(b) & np.isfinite(depth) + & (a > 0) & (b > 0) & (depth < SKY_DEPTH)) + count = int(valid.sum()) + if count == 0: + raise ParityError("no finite, non-sky history pixels") + reference = np.minimum(a, b) + p50, p90 = (float(v) for v in np.quantile(reference[valid], [0.5, 0.9])) + leaf = np.isfinite(alpha) & (alpha > 0.5) + mid = valid & (reference > p50) & (reference <= p90) + far = valid & (reference > p90) + masks = { + "near": valid & (reference <= p50), + "mid": mid, + "far": far, + "leaf-mid": mid & leaf, + "leaf-far": far & leaf, + } + single = rejected(a, b) & valid + nearest = rejected(min3x3(a), min3x3(b)) & valid + rows = {} + for name in REGIONS: + mask = masks[name] + rows[name] = (int(mask.sum()), int((single & mask).sum()), int((nearest & mask).sum())) + return {"pixels": count, "p50": p50, "p90": p90, "rows": rows} + + +def percent(hits, pixels): + return float("nan") if pixels == 0 else 100.0 * hits / pixels + + +def _format(value): + return "-" if math.isnan(value) else "%.2f%%" % value + + +def load(directory, name): + path = os.path.join(directory, name) + if not os.path.isfile(path): + raise ParityError("missing %s" % path) + return read_image(path)[0][:, :, 0] + + +def run(directory, max_leaf_far=1.5, out=sys.stdout): + if not os.path.isdir(directory): + raise ParityError("not a directory: %s" % directory) + result = analyse(load(directory, HISTORY_A), load(directory, HISTORY_B), + load(directory, DEPTH), load(directory, LEAF)) + out.write("taa-rejection: %s\n" % directory) + out.write("%d pixels judged; linear depth p50 %.4g, p90 %.4g blocks\n\n" + % (result["pixels"], result["p50"], result["p90"])) + out.write("| region | pixels | single-sample rejected | 3x3 nearest rejected |\n") + out.write("|---|---|---|---|\n") + for name in REGIONS: + pixels, single, nearest = result["rows"][name] + out.write("| %s | %d | %s | %s |\n" % (name, pixels, _format(percent(single, pixels)), + _format(percent(nearest, pixels)))) + pixels, _, nearest = result["rows"]["leaf-far"] + if pixels == 0: + raise ParityError("no leaf pixels beyond p90 (%.4g blocks): the dump has no distant foliage to judge" + % result["p90"]) + rate = percent(nearest, pixels) + ok = rate <= max_leaf_far + out.write("\n3x3 nearest-depth leaf-far rejection %.2f%% (max %.2f%%): %s\n" + % (rate, max_leaf_far, "ok" if ok else "FAIL")) + return 0 if ok else 1 + + +# ----------------------------------------------------------------- self-test + +def synthetic_dump(leaf_moves): + """64x64: a near-to-mid ground gradient (2..100 blocks) under six far rows at + 300 blocks holding sub-pixel leaves at 250 blocks, a sky column, and a 12x12 + genuine disocclusion in the near field. With leaf_moves the other history slot + has each leaf one pixel to the right (the next jitter phase); without it the + leaves are gone from the other slot.""" + size = 64 + gradient = 2.0 + np.arange(size, dtype=np.float64) * (98.0 / 57.0) + a = np.repeat(gradient[:, None], size, axis=1) + a[58:, :] = 300.0 + b = a.copy() + depth = np.full((size, size), 0.5) + alpha = np.zeros((size, size)) + for y in (59, 62): + for x in range(2, size - 4, 4): + a[y, x] = 250.0 + alpha[y, x] = 1.0 + if leaf_moves: + b[y, x + 1] = 250.0 + b[10:22, 10:22] = 1.0 + depth[:, size - 1] = 1.0 + a[:, size - 1] = np.nan + b[:, size - 1] = np.nan + return a, b, depth, alpha + + +def write_dump(directory, arrays): + os.makedirs(directory) + for name, array in zip((HISTORY_A, HISTORY_B, DEPTH, LEAF), arrays): + write_pfm(os.path.join(directory, name), array.astype(np.float32)) + + +def self_test(): + moving = synthetic_dump(leaf_moves=True) + result = analyse(*moving) + leaves = int(moving[3].sum()) + assert result["pixels"] == 63 * 64, result["pixels"] + + # 1. a flipping sub-pixel leaf fails the single-sample test and passes the 3x3 test + pixels, single, nearest = result["rows"]["leaf-far"] + assert pixels == leaves, (pixels, leaves) + assert single == leaves, result["rows"]["leaf-far"] + assert nearest == 0, result["rows"]["leaf-far"] + + # 2. a disocclusion larger than 3x3 is rejected by both tests + _, single, nearest = result["rows"]["near"] + assert single >= 144, result["rows"]["near"] + assert nearest >= 100, result["rows"]["near"] + + with tempfile.TemporaryDirectory(prefix="optimum-taa-rejection-self-test-") as root: + # 3. through the files: the moving leaf passes the gate + passing = os.path.join(root, "moving") + write_dump(passing, moving) + sink = io.StringIO() + assert run(passing, out=sink) == 0, sink.getvalue() + assert "| leaf-far | %d | 100.00%% | 0.00%% |" % leaves in sink.getvalue(), sink.getvalue() + assert ": ok" in sink.getvalue(), sink.getvalue() + + # 4. a leaf that vanishes from the other slot is rejected by the 3x3 test too: exit 1 + failing = os.path.join(root, "vanishing") + write_dump(failing, synthetic_dump(leaf_moves=False)) + sink = io.StringIO() + assert run(failing, out=sink) == 1, sink.getvalue() + assert "FAIL" in sink.getvalue(), sink.getvalue() + sink = io.StringIO() + assert run(failing, max_leaf_far=100.0, out=sink) == 0, sink.getvalue() + + # 5. no foliage to judge, and a missing file, are input errors + a, b, depth, _ = moving + bare = os.path.join(root, "bare") + write_dump(bare, (a, b, depth, np.zeros_like(depth))) + for directory in (bare, os.path.join(root, "absent")): + try: + run(directory, out=io.StringIO()) + except ParityError: + pass + else: + raise AssertionError("expected an input error for %s" % directory) + os.remove(os.path.join(bare, LEAF)) + try: + run(bare, out=io.StringIO()) + except ParityError as error: + assert "missing" in str(error), error + else: + raise AssertionError("expected a missing-file error") + + print("taa-rejection.py self-test: ok") + return 0 + + +def main(argv): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("dump_dir", nargs="?") + parser.add_argument("--max-leaf-far", type=float, default=1.5, + help="maximum 3x3 nearest-depth leaf-far rejection rate, percent (default 1.5)") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args(argv) + try: + if args.self_test: + return self_test() + if not args.dump_dir: + parser.print_usage(sys.stderr) + return 2 + return run(args.dump_dir, args.max_leaf_far) + except ParityError as error: + print("taa-rejection.py: %s" % error, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From a6345480472b92610f60c2bfa11962439c5ecced Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:55:13 +0200 Subject: [PATCH 123/226] wip(phase2-frame-graph): keep the single MotionAttachmentIndex guard the TAA coverage test counts --- Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index aa7e2bcd..2e929ea2 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -144,7 +144,8 @@ internal int[] PassReads(string context, int target) case "TaaResolve": AddColour(reads, PrimaryIndex, 0); AddColour(reads, PrimaryIndex, 1); - if (MotionAttachmentIndex >= 0) AddColour(reads, PrimaryIndex, MotionAttachmentIndex); + // Absent motion attachment: the index stays -1. + if (MotionAttachmentIndex > -1) AddColour(reads, PrimaryIndex, MotionAttachmentIndex); AddDepth(reads, PrimaryIndex); for (int slot = 0; slot < 3; slot++) { From f8b39abde4ea26f82a36208be4d78c3d9e6ea8a0 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 22:56:18 +0200 Subject: [PATCH 124/226] wip(phase2-frame-graph): composition feedback test covers both paths (graph: promoted clear, no split) --- .../MotionWindowTests.cs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs index a299019f..37d2bfe4 100644 --- a/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs +++ b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs @@ -51,6 +51,13 @@ void main(void) /// The OPTIMUM_VULKAN_COLOR_WRITE_TIER tokens. public static TheoryData Tiers => new() { "enable", "mask", "pipeline" }; + /// Every tier with the frame graph on and off (OPTIMUM_VULKAN_FRAMEGRAPH). + public static TheoryData TiersWithFrameGraph => new() + { + { "enable", true }, { "mask", true }, { "pipeline", true }, + { "enable", false }, { "mask", false }, { "pipeline", false }, + }; + private static ColorWriteTier Tier(string token) => DeviceCaps.ParseColorWriteTier(token) ?? throw new ArgumentException("unknown tier " + token); @@ -310,19 +317,23 @@ void main(void) /// /// The final composition shape: draw buffers select Primary 0 only while the - /// program samples Primary 1. The sampled slot leaves the scope (one feedback - /// split), colour receives glow's texels exactly, glow keeps them; selecting - /// glow again lets it rejoin and a write lands. Validation stays clean. + /// program samples Primary 1. The sampled slot leaves the scope, colour receives + /// glow's texels exactly, glow keeps them; selecting glow again lets it rejoin and + /// a write lands. Validation stays clean. With scope inference the clear opens the + /// scope with glow in it, so the sample splits it (one feedback split); on the + /// frame graph the clear is promoted into the scope the draw opens, which already + /// leaves glow out, so nothing splits. /// [SkippableTheory] - [MemberData(nameof(Tiers))] - public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken) + [MemberData(nameof(TiersWithFrameGraph))] + public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken, bool frameGraph) { ColorWriteTier tier = Tier(tierToken); Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable."); using (device) { VulkanDevice seam = device!; + seam.FrameGraphEnabled = frameGraph; int compose = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ #version 330 core uniform sampler2D glowTex; @@ -340,7 +351,7 @@ public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken seam.BindFramebuffer(scene.Framebuffer); BaseState(seam); seam.SetDrawBuffers(scene.Framebuffer, 0b0001); - seam.ClearColor(0, 0f, 0f, 0f, 1f); // opens the scope with glow in it, masked + seam.ClearColor(0, 0f, 0f, 0f, 1f); // inference: opens the scope with glow in it, masked seam.UseProgram(compose); seam.SetSamplerUnit(compose, "glowTex", 0); seam.BindTexture(0, scene.Glow); @@ -358,8 +369,8 @@ public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken byte[] glowAfterWrite = device.ReadBackLevel0ForTests(scene.Glow); seam.Present(); - _output.WriteLine($"tier={tier} splits_after_compose={splitsAfterCompose} mask_restarts={maskRestarts}"); - Assert.Equal(1, splitsAfterCompose); + _output.WriteLine($"tier={tier} frameGraph={frameGraph} splits_after_compose={splitsAfterCompose} mask_restarts={maskRestarts}"); + Assert.Equal(frameGraph ? 0 : 1, splitsAfterCompose); Assert.Equal(0, maskRestarts); AssertEveryPixel(composed, 4, new byte[] { 51, 102, 153, 255 }, "colour = sampled glow"); AssertEveryPixel(glowAfterCompose, 4, new byte[] { 51, 102, 153, 255 }, "glow untouched by composition"); From ac06dce1abb3b922bba82e0e13e5476ac9eba3d1 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 23:17:52 +0200 Subject: [PATCH 125/226] wip(phase2-review): a slot the declared pass leaves out is outside the scope (no split, no ReadSelf copy, clear promoted) PassExclusion was ignored by ExcludeSampledAttachment, IsAttachmentOfBound and ClearColorOnGraph: sampling Primary 1 inside the final composition split the pass (and took a ReadSelf copy with its draw buffer on), and a clear on it was dropped. Verified: PassExclusionTests fails before the fix (scopes=3 splits=2 copies=1, clear lost) and passes after (scopes=1 splits=0 copies=0) on both frame-graph settings; filtered FrameGraphFrameTests, MotionWindowTests, RenderTargetTests pass. --- .../PassExclusionTests.cs | 159 ++++++++++++++++++ .../Core/RenderTargetManager.cs | 24 ++- Optimum.Tests/frame-graph-coverage-tests.cs | 12 ++ 3 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/PassExclusionTests.cs diff --git a/Optimum.Render.Vulkan.Tests/PassExclusionTests.cs b/Optimum.Render.Vulkan.Tests/PassExclusionTests.cs new file mode 100644 index 00000000..7afcba58 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PassExclusionTests.cs @@ -0,0 +1,159 @@ +using System; +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using static Optimum.Render.Vulkan.Tests.GpuTest; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Phase 2 review (2026-09-11): an attachment-subset pass (the final composition leaves +/// Primary 1 out of its scope through ) must treat +/// the left-out slot as what it is, a texture outside the scope: +/// +/// sampling it with its draw buffer off does not close the pass's open scope (it was +/// never in it, so there is nothing to exclude and no split); +/// sampling it with its draw buffer on is not attachment feedback, so it takes no +/// ReadSelf copy and no split; +/// a clear on it with its draw buffer on is not dropped: GL clears the texture, so +/// the clear is promoted and lands before the next use. +/// +/// The same frame with the frame graph off (the slot then stays in the scope) gives the +/// same pixels. +/// +public class PassExclusionTests +{ + private readonly ITestOutputHelper _output; + + public PassExclusionTests(ITestOutputHelper output) => _output = output; + + private const int Size = 8; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.5, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + public static TheoryData FrameGraph => new() { true, false }; + + [SkippableTheory] + [MemberData(nameof(FrameGraph))] + public void ALeftOutSlotIsSampledAndClearedLikeATextureOutsideTheScope(bool frameGraph) + { + VulkanDevice created = NewDevice(); + created.FrameGraphEnabled = frameGraph; + if (!created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + created.Dispose(); + Skip.If(true, "Vulkan unavailable: " + failureReason); + } + + using VulkanDevice seam = created; + FrameGraph graph = seam.FrameGraphForTests; + int Texture() => seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int c0 = Texture(), c1 = Texture(); + int target = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment0, c0, 0); + seam.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment1, c1, 0); + + int constant = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(1.0, 0.0, 0.0, 1.0); } + """, "px-constant"); + int copy = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D tex; + in vec2 uv; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = texture(tex, uv); } + """, "px-copy"); + seam.SetSamplerUnit(copy, "tex", 0); + + // Seed: c0 black, c1 grey. + seam.BeginFrame(); + BaseState(seam); + seam.DeclarePass(new PassDeclaration { Name = "Seed", FramebufferId = target }); + seam.SetDrawBuffers(target, 0b11); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, 0.2f, 0.2f, 0.2f, 1f); + seam.Present(); + + seam.BeginFrame(); + BaseState(seam); + long scopesBefore = seam.ScopesOpenedForTests; + long splitsBefore = graph.Splits; + long feedbackBefore = seam.FeedbackSplitsForTests; + long copiesBefore = seam.ReadSelfCopiesForTests.Created; + + seam.DeclarePass(new PassDeclaration + { + Name = "Compose", FramebufferId = target, ColorSlots = ~(1u << 1), Reads = new[] { c1 }, + }); + + // Draw buffer of the left-out slot off: the first draw opens the scope, the second samples the slot. + seam.SetDrawBuffers(target, 0b01); + seam.UseProgram(constant); + seam.DrawFullscreenTriangle(); + seam.UseProgram(copy); + seam.BindTexture(0, c1); + seam.DrawFullscreenTriangle(); + long splitsAfterSample = graph.Splits - splitsBefore; + + // Draw buffer on: sampling the left-out slot is still not feedback. + seam.SetDrawBuffers(target, 0b11); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + long splitsAfterDrawBufferOn = graph.Splits - splitsBefore; + long copies = seam.ReadSelfCopiesForTests.Created - copiesBefore; + long scopes = seam.ScopesOpenedForTests - scopesBefore; + long feedback = seam.FeedbackSplitsForTests - feedbackBefore; + + // A clear on the left-out slot with its draw buffer on clears it, as GL does. + seam.ClearColor(1, 0f, 0f, 1f, 1f); + seam.EndPass(); + seam.SetDrawBuffers(target, 0b01); + seam.Present(); + + seam.BeginFrame(); + byte[] first = seam.ReadBackLevel0ForTests(c0); + byte[] second = seam.ReadBackLevel0ForTests(c1); + seam.Present(); + + _output.WriteLine($"frameGraph={frameGraph} scopes={scopes} splits_after_sample={splitsAfterSample} " + + $"splits_after_draw_buffer_on={splitsAfterDrawBufferOn} feedback_splits={feedback} readself_copies={copies}"); + + int centre = (Size / 2 * Size + Size / 2) * 4; + Assert.Equal(new byte[] { 51, 51, 51, 255 }, first.AsSpan(centre, 4).ToArray()); + Assert.Equal(new byte[] { 0, 0, 255, 255 }, second.AsSpan(centre, 4).ToArray()); + if (frameGraph) + { + Assert.Equal(0, splitsAfterSample); + Assert.Equal(0, splitsAfterDrawBufferOn); + Assert.Equal(0, feedback); + Assert.Equal(0, copies); + Assert.Equal(1, scopes); + } + AssertClean(seam); + } + + private static void BaseState(VulkanDevice seam) + { + seam.SetViewport(0, 0, Size, Size); + seam.SetScissorEnabled(false); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetColorMask(true, true, true, true); + } +} diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 25cd97bb..d0ae4b85 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -233,7 +233,8 @@ public void ExcludeSampledAttachment(CommandBuffer commandBuffer, int textureId) slots |= 1u << i; } - uint newlyExcluded = slots & ~framebuffer.SampledExclusion; + // A slot the declared pass already leaves out is not in the scope: nothing to exclude, no split. + uint newlyExcluded = slots & ~(framebuffer.SampledExclusion | framebuffer.PassExclusion); if (newlyExcluded == 0) return; framebuffer.SampledExclusion |= newlyExcluded; @@ -298,6 +299,8 @@ public bool IsAttachmentOfBound(int textureId) for (int i = 0; i < _bound.Color.Length; i++) { if (_bound.Color[i].TextureId != textureId) continue; + // Left out of the declared pass's scope: sampled directly, not feedback. + if (((_bound.PassExclusion >> i) & 1) != 0) continue; if ((_bound.DrawBufferMask & (1u << i)) != 0) return true; } return false; @@ -628,8 +631,8 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo } /// - /// The frame-graph half of a colour clear. A slot outside the scope (sampled or left - /// out by the pass) is not cleared, as the null attachment it opens with would not be. + /// The frame-graph half of a colour clear. A slot left out by the declared pass is not in + /// the scope, but its draw buffer is on, so its texture is cleared through a promoted clear. /// Inside an open pass the clear stays vkCmdClearAttachments and is counted (returns /// true with the scope open). With no pass open a full-mask clear is promoted into the /// next scope attaching the image (returns false); a partial glColorMask clear opens @@ -638,7 +641,20 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo private bool ClearColorOnGraph(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) { VulkanFramebuffer target = _bound!; - if (!InScope(target, attachment)) return false; + if (!InScope(target, attachment)) + { + // Left out by the declared pass while its draw buffer is on: GL clears the + // texture, so the clear is promoted and lands before the texture's next use. + if (((target.PassExclusion >> attachment) & 1) != 0) + { + VulkanTexture? excluded = _textures.Get(target.Color[attachment].TextureId); + if (excluded != null) + { + _graph.PromoteColorClear(excluded, target.Color[attachment].Layer, r, g, b, a); + } + } + return false; + } if (!_renderingActive || _needsRestart) { diff --git a/Optimum.Tests/frame-graph-coverage-tests.cs b/Optimum.Tests/frame-graph-coverage-tests.cs index 017f0d92..780d98d7 100644 --- a/Optimum.Tests/frame-graph-coverage-tests.cs +++ b/Optimum.Tests/frame-graph-coverage-tests.cs @@ -48,6 +48,18 @@ public void ScopesOpenThroughThePassRecorderAndClearsArePromoted() Assert.Contains("_targets.FlushPendingClears(Commands, texture);", device); } + [Fact] + public void ASlotTheDeclaredPassLeavesOutIsTreatedAsOutsideTheScope() + { + // Phase 2 review: sampling a left-out slot neither splits nor takes a ReadSelf copy, + // and a clear on it (draw buffer on) is promoted instead of dropped. + // GPU proof: Optimum.Render.Vulkan.Tests/PassExclusionTests.cs. + string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); + Assert.Contains("uint newlyExcluded = slots & ~(framebuffer.SampledExclusion | framebuffer.PassExclusion);", targets); + Assert.Contains("if (((_bound.PassExclusion >> i) & 1) != 0) continue;", targets); + Assert.Contains("if (((target.PassExclusion >> attachment) & 1) != 0)", targets); + } + [Fact] public void ThePlatformDeclaresThePassesOfTheFrame() { From dfb3b0bad75fe3719497ee2f69f7016e3dc373a9 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 23:52:38 +0200 Subject: [PATCH 126/226] wip(phase2): Milestone 1 exit numbers; perf-capture writes to an absolute output directory Verified on the deployed 6568556 with the fixed scene: OpenGL 6.075 ms mean / 0.115 stddev (both runs identical, so the earlier bimodal OpenGL pacing was the moving world), Vulkan 7.594 / 0.333 over 60 s and 7.700 / 0.372 over 10 minutes. Pacing gate fails only stddev_vs_baseline (0.365 vs 0.151); p99, blocking uploads (0 of 87 and 0 of 626 samples), dropped mesh writes and uniform overflows pass. Per frame at ~130 fps: 22.3 passes == 22.3 scopes, 0 pass splits, 0 mask restarts, plan hits 133/s with 0 misses, 69.6 barriers in 37.2 commands, 5.43 ms of the frame spent in the frame-pacing wait (GPU-bound). taa-rejection.py 1.05 % distant-leaf rejection on both backends. The 10 in-game validation errors were MangoHud's overlay render pass, not Optimum. perf-capture.sh resolved --out relatively, so run-client.sh (which changes directory before opening the log) wrote nowhere and every pacing run died before the world loaded. --- docs/vulkan-acceptance.md | 43 +++++++++++++++++++++++++++++++++++++ scripts/dev/perf-capture.sh | 6 ++++++ 2 files changed, 49 insertions(+) diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 9e6a18b1..4bb31950 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -244,6 +244,49 @@ All numbers first, then eyes. Each row names the plan's definition of done verba - Pass: the user judges it in game on both backends. - Record: both renderer lines, the user's verdict and date. +### Milestone 1 exit results (2026-09-11, commit 6568556 deployed) + +RTX 4070 Laptop, driver 615.71.09, X11. Section 0's fixed scene was applied to the save for the first +time (creative, 12:00, clear sky, precipitation -1, wind still) and it removed the pacing noise: the two +OpenGL runs came out identical (6.075 ms mean / 0.115 stddev and 6.075 / 0.108), so the bimodal OpenGL +pacing seen in Phase 0 and Phase 1 was the moving world, not the build. Evidence under +`docs/gpu-verification-2026-09-11/m1/` (local). + +| | OpenGL | Vulkan 60 s | Vulkan 10 min | +|---|---|---|---| +| mean | 6.075 ms | 7.594 ms | 7.700 ms | +| 1% low | 6.43 ms | 8.94 ms | 9.44 ms | +| stddev | 0.115 ms | 0.333 ms | 0.372 ms | +| worst | 10.5 ms | 47.5 ms | 102.2 ms | + +- **M1.1 pacing gate: FAIL on one rule, both pairs.** `p99_vs_mean` 8.78 ≤ 11.38 PASS; + `stddev_vs_baseline` 0.365 vs 0.151 FAIL (0.375 vs 0.143 on the second pair); blocking uploads, + dropped mesh writes and uniform overflows PASS. Vulkan costs about 25 % more frame time than OpenGL + on this scene. +- **M1.2 PASS.** Blocking uploads 0 in every sample (87 and 626 samples); frame-pacing waits about one + per frame. +- **M1.3 PASS** (Phase 1 design, unchanged): acquire after the render submit, wait stage TRANSFER. +- **M1.4 PASS.** Per second at ~130 fps: passes 2904 and scopes 2904 (equal), pass splits 0, motion-mask + restarts 0, frame-plan hits 133 with 0 misses. Per frame: 22.3 passes, 69.6 barriers in 37.2 barrier + commands, 67.3 dynamic-state commands, 3.45 uploads, 18.2 promoted clears. +- **Where the time goes:** the frame-pacing wait is 5.43 ms of the 7.67 ms frame, so the Vulkan frame is + GPU-bound on this scene, not CPU-bound; Phase 0's flush-per-frame and 1400 dynamic-state commands per + frame are gone. Reducing the gap is Phase 4 work (per-pass timestamps first). +- **M1.5 validation:** the GPU suite (620 tests) runs `sync,best` with zero hazards. In game, the 10 + `[error]` lines were MangoHud's overlay render pass (`vkCmdBeginRenderPass`, `loadOp LOAD` on the + swapchain image); Optimum creates no render pass. **Validation runs set `MANGOHUD=0`.** +- **M1.7 PASS** (frame 300 dumps): `taa-rejection.py` distant-leaf history rejection 1.05 % on both + backends against the 1.5 % limit; the pre-fix single-sample test would reject 3.69-3.73 %. +- **SSAO alpha gap closed:** `13-SSAO-color1` alpha is 1.0000 on both backends (was 1.0 GL / 0.0 VK). +- **M1.6 per-attachment parity: not gated, dropped as a routine row.** Two OpenGL launches of one save + differ at SSIM 0.864 on the primary colour, so the matrix cannot separate a backend gap from chunk + streaming, weather and entities; the frame-1800 retry was lost to a shutdown race (the previous run's + kill caught the next launch two seconds in). Parity returns in Phase 3, where the shaders change + pixels, as a targeted comparison rather than a 39-attachment matrix. +- **Testing policy (user, 2026-09-11): the matrix is too heavy.** Milestone rows are the pacing gate on + one 60 s run per backend, the Vulkan stats counters, one `taa-rejection.py` dump per backend and the + GPU suite's validation. No 10-minute sessions, no multi-launch SSIM matrices. + ## 3. Methods ### Parity dump and SSIM diff --git a/scripts/dev/perf-capture.sh b/scripts/dev/perf-capture.sh index a171b93a..4d86a1e6 100755 --- a/scripts/dev/perf-capture.sh +++ b/scripts/dev/perf-capture.sh @@ -87,6 +87,12 @@ FPS_LOG="$OUT_DIR/fps.log" VK_STATS="$OUT_DIR/vulkan-stats.log" mkdir -p "$OUT_DIR" || exit 1 +# run-client.sh changes into the client directory before it opens CLIENT_LOG, so a relative +# --out would put the log (and the fps and stats logs the client writes) somewhere else. +OUT_DIR="$(cd -- "$OUT_DIR" && pwd)" || exit 1 +LOG="$OUT_DIR/client.log" +FPS_LOG="$OUT_DIR/fps.log" +VK_STATS="$OUT_DIR/vulkan-stats.log" rm -f "$LOG" "$FPS_LOG" "$VK_STATS" # 1. TAA on/off through the config file, before the launch rewrites Renderer. From b348c4e450af013a60bdb191993f4b9a613205be Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Fri, 11 Sep 2026 23:54:15 +0200 Subject: [PATCH 127/226] docs(vulkan-acceptance): Milestone 1 accepted by the user at 6568556 Decision row: M1.2/M1.3/M1.4/M1.7 pass, validation clean (MANGOHUD=0 in game, 620-test GPU suite with sync,best), SSAO alpha gap closed; M1.1 fails only stddev_vs_baseline and the ~25 % frame-time gap to OpenGL is GPU-bound, carried to Phase 4; M1.6 parity dropped as a routine row. --- docs/vulkan-acceptance.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 4bb31950..53345cc2 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -351,6 +351,7 @@ One entry per phase exit or milestone, appended, never edited after the fact. | date | phase / milestone | commit | rows passed | rows failed or deferred (with reason) | evidence paths | decision | |---|---|---|---|---|---|---| | 2026-09-11 | Phase 0 exit | 906b40f deployed (Phase 0 merged at cdd7412) | V0.1 and V0.2 recorded, V0.3 and V0.4 recorded, V0.5 pass (build 0 errors, Optimum.Tests 1056, GPU 386 with sync,best, check-patches 0 conflicts) | section 0 fixed scene not applied; Vulkan fails the pacing gate on p99, stddev and blocking uploads (the Milestone 1 target, not a Phase 0 gate) | `docs/gpu-verification-2026-09-11/phase0/` | Phase 0 accepted; Phase 1A and 1B start. M1.6 changed to a noise-floor rule. User observed no Vulkan jitter on these two runs (driver 615.71.09, sky-direction fix not deployed); correction 2026-09-11 evening: the distance jitter was back on Vulkan in every later run, and OpenGL never shows it. | +| 2026-09-11 | Milestone 1 (Phase 2 exit) | 6568556 deployed | M1.2, M1.3, M1.4, M1.7 pass; validation clean with `MANGOHUD=0` and in the 620-test GPU suite; SSAO alpha gap closed; fixed scene removed the OpenGL pacing bimodality | M1.1 fails `stddev_vs_baseline` (0.365 vs 0.151) and Vulkan costs ~25 % more frame time than OpenGL, GPU-bound (5.43 ms of 7.67 in the frame-pacing wait) - carried to Phase 4; M1.6 per-attachment parity dropped as a routine row; M1.8 TAA rows not re-run | `docs/gpu-verification-2026-09-11/m1/` | **Accepted by the user** after watching a 10-minute Vulkan session: `feat/vulkan-native` merges into `main`, DLSS and the latency seams start on new branches. Testing policy tightened (no long sessions, no SSIM matrices). | | 2026-09-11 | Phase 1 exit (1A + 1B) | f373c4a deployed | both renderers start; forced-install-failure fallback renders on OpenGL; sync,best validation 0 errors; Vulkan blocking uploads 0 in all samples; Vulkan pacing better than Phase 0 on mean, p99 and stddev; build 0 errors, Optimum.Tests 1128, GPU 494 | Vulkan p99 fails 1.5 x mean; 10-minute session, window resize/alt-tab/minimise loop, sun glare and fork bridge on screen carried to Milestone 1; OpenGL pacing found bimodal between launches (A/B/A), not a regression | `docs/gpu-verification-2026-09-11/phase1/` | Phase 1 accepted; Phase 2 (frame graph) starts; M1.1 now interleaves runs | ## 6. Vendor matrix From 41373cfc5fecf791e26aa25bdaf58121b3a80060 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 18:44:37 +0200 Subject: [PATCH 128/226] fix(taa): the jittered AO is shaded into the scene before the resolve AO is computed from the jittered G-buffer. Applied where vanilla applies it - in Final, after the TAA resolve - it never reaches the history and carries the raw camera jitter onto the whole frame, worst towards the horizon. The 3x3 nearest-depth test and the anti-flicker weighting in taa-resolve.fsh only damp that; they do not remove it. Backported from feat/dlss, where the same placement fixed the upscaled path (e582ed0), and adapted to the in-house resolve: - RenderPostprocessingEffects computes SSAO first and, while TAA runs, multiplies it into Primary colour 0 with the new scene-ssao program before RenderOptimumTaaResolve. Final skips its own AO multiply when that ran, and the flag is written every frame so Vulkan never reads a stale uniform. - The SSAO sample dither advances per frame under TAAMOTION (8c33fa3), so the resolve converges it instead of fighting a screen-locked pattern. - The Vulkan frame graph declares the AO multiply as its own colour-0 pass. Verified: build 0 errors; extract-patches and check-patches clean (0 conflict); source tests for scene SSAO, the SSAO dither, the TAA pipeline and sharpen and the frame graph 50/50; SceneSsaoTests, SsaoTemporalDitherTests and TaaResolveTests on the GPU 18/18. Not yet judged in game. --- Optimum.Patcher/Program.cs | 6 + Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs | 107 ++++++++ .../SsaoTemporalDitherTests.cs | 249 ++++++++++++++++++ .../Platform/VulkanClientPlatform.Graph.cs | 16 ++ ...-platform-windows-vanilla-regions-tests.cs | 1 + Optimum.Tests/scene-ssao-coverage-tests.cs | 75 ++++++ .../ssao-temporal-dither-coverage-tests.cs | 150 +++++++++++ TAA-PLAN.md | 7 + .../ClientPlatformWindows.cs.patch | 198 +++++++++++--- .../ShaderPrograms.cs.patch | 9 +- .../ShaderRegistry.cs.patch | 12 +- scripts/package.ps1 | 13 +- sources/shaders/final.fsh | 5 + sources/shaders/scene-ssao.fsh | 14 + sources/shaders/scene-ssao.vsh | 9 + sources/shaders/ssao.fsh | 184 +++++++++++++ 16 files changed, 1009 insertions(+), 46 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SsaoTemporalDitherTests.cs create mode 100644 Optimum.Tests/scene-ssao-coverage-tests.cs create mode 100644 Optimum.Tests/ssao-temporal-dither-coverage-tests.cs create mode 100644 sources/shaders/scene-ssao.fsh create mode 100644 sources/shaders/scene-ssao.vsh create mode 100644 sources/shaders/ssao.fsh diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 46a0c9f3..02cc3d55 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -339,6 +339,10 @@ "OptimumTaaSharpenIndex", "OptimumFsrBlitActive", "RenderOptimumTaaSharpen", + // TAA: the jittered AO multiplied into the scene before the resolve, and + // the flag Final reads so the AO is never applied twice. + "optimumSsaoInScene", + "ApplyOptimumSceneSsao", // Phase 0 parity: the per-attachment dump (OPTIMUM_PARITY_DUMP) called from // window_RenderFrame, its in-world frame counter, slot names, the single // device-readback call site and the glGetTexImage body. @@ -386,6 +390,8 @@ "TaaResolve", // TAA P5: the post-resolve sharpen pass program. "TaaSharpen", + // TAA: the AO multiply into the scene before the resolve. + "SceneSsao", // TAA P4: the liquid velocity pass program. "ChunkLiquidMotion", // TAA P4: the sky / volumetric-cloud motion pass program. diff --git a/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs b/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs new file mode 100644 index 00000000..33ba49c5 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Runtime.InteropServices; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The AO multiply the TAA path runs before the resolve: scene-ssao draws into Primary +/// with only colour 0 selected and the Multiply blend, so the resolve accumulates the +/// occlusion together with the scene. Every other Primary attachment and the depth have +/// to come out untouched, frame after frame. +/// +public class SceneSsaoTests(ITestOutputHelper output) +{ + [SkippableTheory] + [InlineData(1)] + [InlineData(2)] + public unsafe void OcclusionMultipliesOnlySceneColourBeforeTheResolve(int quality) + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + VulkanDevice seam = device!; + const int size = 8, frames = 8; + var files = ShaderCorpus.LoadShaderFiles(); + int program = VulkanDeviceIntegrationTests.LinkProgram( + seam, + files["scene-ssao.vsh"], + files["scene-ssao.fsh"].Replace("#version 330 core", "#version 330 core\n#define SSAOLEVEL " + quality), + "scene-ssao" + quality); + + // Rows alternate between two AO levels, so SSAOLEVEL 2's min with the row + // above is visible: every row sees the darker of the two. + var ao = new byte[size * size * 4]; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + for (int c = 0; c < 4; c++) ao[(y * size + x) * 4 + c] = (byte)(y % 2 == 0 ? 64 : 192); + int occlusion; + fixed (byte* data = ao) occlusion = seam.CreateTexture2DRaw(size, size, 0x8058, (IntPtr)data, 4); + + var colors = new int[frames][]; + var depths = new int[frames]; + var targets = new int[frames]; + for (int i = 0; i < frames; i++) + { + targets[i] = seam.CreateFramebuffer(size, size); + colors[i] = new int[5]; + for (int slot = 0; slot < 5; slot++) + { + colors[i][slot] = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4); + seam.AttachTexture(targets[i], (EnumFramebufferAttachment)(36064 + slot), colors[i][slot], 0); + } + depths[i] = seam.CreateTexture2D(size, size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + seam.AttachTexture(targets[i], EnumFramebufferAttachment.DepthAttachment, depths[i], 0); + } + + seam.SetViewport(0, 0, size, size); + seam.SetCullFace(false); + seam.SetDepthTest(false); + seam.SetSamplerUnit(program, "ssaoScene", 0); + seam.SetUniform(program, seam.GetUniformLocation(program, "invRenderHeight"), 1f / size); + for (int i = 0; i < frames; i++) + { + seam.BeginFrame(); + seam.BindFramebuffer(targets[i]); + seam.SetDrawBuffers(targets[i], 31); + seam.ClearColor(0, (i + 1) / 16f, (i + 1) / 16f, (i + 1) / 16f, 1); + for (int slot = 1; slot < 5; slot++) seam.ClearColor(slot, slot / 8f, slot / 8f, slot / 8f, 1); + seam.ClearDepth(0.375f); + seam.SetDrawBuffers(targets[i], 1); + seam.SetBlend(true, EnumBlendMode.Multiply); + seam.UseProgram(program); + seam.BindTexture(0, occlusion); + seam.DrawFullscreenTriangle(); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetDrawBuffers(targets[i], 31); + seam.Present(); + } + + // The sequence completes before any readback or CPU wait. + seam.BeginFrame(); + for (int i = 0; i < frames; i++) + { + for (int slot = 0; slot < 5; slot++) + { + byte[] pixels = seam.ReadBackLevel0ForTests(colors[i][slot]); + for (int y = 1; y < size; y++) + for (int x = 0; x < size; x++) + { + double factor = quality == 2 || y % 2 == 0 ? 64 : 192; + double expected = slot == 0 ? Math.Round((i + 1) / 16.0 * 255) * factor / 255 : slot / 8.0 * 255; + int offset = (y * size + x) * 4; + for (int c = 0; c < 3; c++) Assert.InRange((double)pixels[offset + c], expected - 1.1, expected + 1.1); + Assert.Equal(255, pixels[offset + 3]); + } + } + foreach (float depth in MemoryMarshal.Cast(seam.ReadBackLevel0ForTests(depths[i]))) + Assert.Equal(0.375f, depth); + } + seam.Present(); + GpuTest.AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/SsaoTemporalDitherTests.cs b/Optimum.Render.Vulkan.Tests/SsaoTemporalDitherTests.cs new file mode 100644 index 00000000..f253d13e --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SsaoTemporalDitherTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// GTAO roadmap step 2. Vanilla's ssao.fsh rotates its sample kernel with a Bayer-128 +/// dither locked to the screen grid: under a jittered camera every surface point draws +/// a different kernel every frame, and a temporal accumulator can only fight that, never +/// average it. Optimum's override advances the dither by the golden ratio per frame, +/// using the temporal pipeline's own frame index, so successive frames sample +/// complementary spiral directions. +/// +/// The two things worth pinning are both here, on the real shader, run on the device: +/// with the temporal pipeline on (TAAMOTION 1) the AO of a fixed scene changes between +/// consecutive frames, and with it off (TAAMOTION 0) it does not change at all - the +/// override has to be byte-identical to vanilla there, because a per-frame-varying +/// dither with nothing accumulating behind it is strictly worse than a fixed one. +/// +public class SsaoTemporalDitherTests(ITestOutputHelper output) +{ + private const int Size = 128; + private const int GlRgba32f = 0x8814; + private const int GlRgba8 = 0x8058; + private const int KernelSize = 64; + + /// Frame index the AO is rendered at, in order. The third repeats the first. + private static readonly float[] FrameIndices = [0f, 1f, 0f, 2f]; + + [SkippableTheory] + [InlineData(1)] + [InlineData(2)] + public void TheAoVariesPerFrameOnlyWhileTheTemporalPipelineIsOn(int quality) + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + + using (device) + { + VulkanDevice seam = device!; + var files = ShaderCorpus.LoadShaderFiles(); + + byte[][] withTemporal = RenderSequence(seam, files, quality, taaMotion: 1); + byte[][] withoutTemporal = RenderSequence(seam, files, quality, taaMotion: 0); + + // The scene really is occluded: several AO levels, not a flat 255. Printed + // because a scene that stops occluding would make every comparison below + // trivially pass with the temporal term removed. + var seen = new SortedSet(); + for (int i = 0; i < Size * Size; i++) seen.Add(withTemporal[0][i * 4]); + output.WriteLine("distinct AO values in frame 0: " + seen.Count + " -> " + string.Join(",", seen)); + Assert.True(seen.Count > 1, "the test scene produced no occlusion at all"); + + int changed = Differing(withTemporal[0], withTemporal[1]); + int repeated = Differing(withTemporal[0], withTemporal[2]); + int changedAgain = Differing(withTemporal[1], withTemporal[3]); + output.WriteLine($"SSAOLEVEL {quality}: temporal on - frame 0 vs 1: {changed} px differ, " + + $"1 vs 2: {changedAgain} px differ, frame 0 vs the frame that repeats index 0: {repeated} px differ"); + + // A different frame index really does rotate the kernel: a good share of the + // scene lands on a different occlusion value. The threshold is a tenth of the + // image, far below what the pass actually moves, so it fails on "the uniform + // is ignored", not on a driver's rounding. + Assert.True(changed > Size * Size / 10, $"AO did not vary between frames (only {changed} px)"); + Assert.True(changedAgain > Size * Size / 10, $"AO did not vary between frames (only {changedAgain} px)"); + // ... and the same index gives the same AO, so what varies is the frame index + // and not the device. + Assert.Equal(0, repeated); + + int off = Differing(withoutTemporal[0], withoutTemporal[1]); + int offAgain = Differing(withoutTemporal[1], withoutTemporal[3]); + output.WriteLine($"SSAOLEVEL {quality}: temporal off - frame 0 vs 1: {off} px differ, 1 vs 3: {offAgain} px differ"); + Assert.Equal(0, off); + Assert.Equal(0, offAgain); + + GpuTest.AssertClean(seam); + } + } + + /// + /// Renders the same scene once per entry in , each into its + /// own target, and reads them all back afterwards - the sequence has to complete before + /// any CPU wait, or the readback is what makes the frames differ. + /// + private static byte[][] RenderSequence( + VulkanDevice seam, Dictionary files, int quality, int taaMotion) + { + string defines = "#version 330 core\n#define SSAOLEVEL " + quality + "\n#define TAAMOTION " + taaMotion + "\n"; + int program = VulkanDeviceIntegrationTests.LinkProgram( + seam, + files["ssao.vsh"], + files["ssao.fsh"].Replace("#version 330 core", defines), + "ssao" + quality + "-taa" + taaMotion); + + (float[] positions, float[] normals) = Scene(); + int gPosition = UploadFloat(seam, positions); + int gNormal = UploadFloat(seam, normals); + int revealage = UploadOpaqueRed(seam); + // The pass never reads texNoise (the dither replaced it), but the sampler is + // declared, so it still needs something bound. + int noise = UploadFloat(seam, new float[Size * Size * 4]); + + int frames = FrameIndices.Length; + var targets = new int[frames]; + var colors = new int[frames]; + for (int i = 0; i < frames; i++) + { + targets[i] = seam.CreateFramebuffer(Size, Size); + colors[i] = seam.CreateTexture2DRaw(Size, Size, GlRgba8, IntPtr.Zero, 4); + seam.AttachTexture(targets[i], EnumFramebufferAttachment.ColorAttachment0, colors[i], 0); + } + + seam.SetViewport(0, 0, Size, Size); + seam.SetCullFace(false); + seam.SetDepthTest(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetSamplerUnit(program, "gPosition", 0); + seam.SetSamplerUnit(program, "gNormal", 1); + seam.SetSamplerUnit(program, "texNoise", 2); + seam.SetSamplerUnit(program, "revealage", 3); + seam.SetUniform(program, seam.GetUniformLocation(program, "screenSize"), (float)Size, (float)Size); + seam.SetUniformMatrix(program, seam.GetUniformLocation(program, "projection"), Projection()); + seam.SetUniformArray3(program, seam.GetUniformLocation(program, "samples"), KernelSize, Kernel()); + + // -1 when the shader was built without the temporal pipeline: the uniform is + // inside #if TAAMOTION == 1, exactly like the pass's own set is inside + // "if (OptimumConfig.EffectiveTaa)". + int frameIndexLocation = seam.GetUniformLocation(program, "temporalFrameIndex"); + Assert.Equal(taaMotion == 1, frameIndexLocation >= 0); + + for (int i = 0; i < frames; i++) + { + seam.BeginFrame(); + seam.BindFramebuffer(targets[i]); + seam.SetDrawBuffers(targets[i], 1); + seam.ClearColor(0, 0, 0, 0, 1); + seam.UseProgram(program); + if (frameIndexLocation >= 0) seam.SetUniform(program, frameIndexLocation, FrameIndices[i]); + seam.BindTexture(0, gPosition); + seam.BindTexture(1, gNormal); + seam.BindTexture(2, noise); + seam.BindTexture(3, revealage); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + + var readback = new byte[frames][]; + seam.BeginFrame(); + for (int i = 0; i < frames; i++) readback[i] = seam.ReadBackLevel0ForTests(colors[i]); + seam.Present(); + return readback; + } + + /// + /// A view-space G-buffer the projection above reproduces exactly: a wall at 10 m + /// with 4x4 pixel blocks raised to 9.7 m, normals facing the camera. The raised + /// blocks put occluders within the shader's depth window all over the image, so + /// the AO of most pixels depends on which way the kernel points. + /// + private static (float[] Positions, float[] Normals) Scene() + { + var positions = new float[Size * Size * 4]; + var normals = new float[Size * Size * 4]; + for (int y = 0; y < Size; y++) + { + for (int x = 0; x < Size; x++) + { + bool raised = ((x / 4) + (y / 4)) % 2 == 0; + float distance = raised ? 9.7f : 10f; + float ndcX = (x + 0.5f) / Size * 2f - 1f; + float ndcY = (y + 0.5f) / Size * 2f - 1f; + int texel = (y * Size + x) * 4; + positions[texel] = ndcX * distance * TanHalfFov; + positions[texel + 1] = ndcY * distance * TanHalfFov; + positions[texel + 2] = -distance; + positions[texel + 3] = 0f; // attenuate + normals[texel + 2] = 1f; + normals[texel + 3] = 0f; // leavesHack off + } + } + return (positions, normals); + } + + private const float TanHalfFov = 0.7002075f; // tan(70 deg / 2) + + /// Column-major perspective, 70 degrees, square, near 0.1, far 100. + private static float[] Projection() + { + var m = new float[16]; + float f = 1f / TanHalfFov; + const float near = 0.1f, far = 100f; + m[0] = f; + m[5] = f; + m[10] = (far + near) / (near - far); + m[11] = -1f; + m[14] = 2f * far * near / (near - far); + return m; + } + + /// + /// A hemisphere kernel in the shape the client uploads: directions in the +z + /// hemisphere, pulled towards the origin quadratically so near samples dominate. + /// + private static float[] Kernel() + { + var random = new Random(7); + var kernel = new float[KernelSize * 3]; + for (int i = 0; i < KernelSize; i++) + { + double x = random.NextDouble() * 2.0 - 1.0; + double y = random.NextDouble() * 2.0 - 1.0; + double z = random.NextDouble(); + double length = Math.Sqrt(x * x + y * y + z * z); + double scale = 0.1 + 0.9 * ((double)i / KernelSize) * ((double)i / KernelSize); + kernel[i * 3] = (float)(x / length * scale); + kernel[i * 3 + 1] = (float)(y / length * scale); + kernel[i * 3 + 2] = (float)(z / length * scale); + } + return kernel; + } + + private static unsafe int UploadFloat(VulkanDevice seam, float[] texels) + { + fixed (float* data = texels) return seam.CreateTexture2DRaw(Size, Size, GlRgba32f, (IntPtr)data, 16); + } + + /// revealage = 1 everywhere, which is "nothing transparent here". + private static unsafe int UploadOpaqueRed(VulkanDevice seam) + { + var texels = new byte[Size * Size * 4]; + for (int i = 0; i < Size * Size; i++) texels[i * 4] = 255; + fixed (byte* data = texels) return seam.CreateTexture2DRaw(Size, Size, GlRgba8, (IntPtr)data, 4); + } + + private static int Differing(byte[] a, byte[] b) + { + Assert.Equal(a.Length, b.Length); + int count = 0; + for (int texel = 0; texel < a.Length / 4; texel++) + { + if (a[texel * 4] != b[texel * 4]) count++; + } + return count; + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index 2e929ea2..65c0aee1 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -102,6 +102,22 @@ private void DeclareBoundPass() private void DeclareFinalCompositionPass() { if (device == null || !device.FrameGraphEnabled) return; + if (passContext == "Post") + { + // The AO multiply before the TAA resolve shares the colour-0 mask, but + // samples only the blurred AO and preserves every other Primary attachment. + var reads = new List(); + AddColour(reads, SsaoBlurVerticalIndex, 0); + device.DeclarePass(new PassDeclaration + { + Name = "SceneSsao/0", + FramebufferId = PassDeclaration.BoundFramebuffer, + ColorSlots = 1u, + Reads = reads.ToArray(), + Flags = PassFlags.None, + }); + return; + } device.DeclarePass(new PassDeclaration { Name = "FinalComposition/0", diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index 8331b3cc..d51a4eee 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -58,6 +58,7 @@ public class ClientPlatformWindowsVanillaRegionsTests "optimumMotionWriteActive", "optimumParityDumpDone", "optimumParityWorldFrames", "optimumTaaDisabled", "optimumTaaResolvedThisFrame", "optimumTaaShaderReloadPending", "optimumTaaTargetsReady", "taaResolvedColorTexture", "taaResolvedGlowTexture", + "optimumSsaoInScene", "ApplyOptimumSceneSsao", // Vanilla members with an Optimum edit (the patcher transplant targets and the members // it virtualizes in place: base edits, FSR/TAA/post chain, frame pacing, mesh bulk copy), diff --git a/Optimum.Tests/scene-ssao-coverage-tests.cs b/Optimum.Tests/scene-ssao-coverage-tests.cs new file mode 100644 index 00000000..3a77b038 --- /dev/null +++ b/Optimum.Tests/scene-ssao-coverage-tests.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// AO is derived from the jittered G-buffer. Applied after the TAA resolve (vanilla's +/// place, in Final) it never reaches the history and moves the whole frame by the raw +/// camera jitter. With TAA on it is multiplied into the scene before the resolve, and +/// Final skips its own multiply so the AO is applied exactly once. +/// +public class SceneSsaoCoverageTests +{ + [Fact] + public void JitteredAoIsComposedBeforeTheResolveAndIsNotAppliedTwice() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + int start = platform.IndexOf("public override void RenderPostprocessingEffects", StringComparison.Ordinal); + Assert.True(start > 0); + string post = platform[start..platform.IndexOf("public override void ClearSsaoTarget", start, StringComparison.Ordinal)]; + + int reset = post.IndexOf("optimumSsaoInScene = false;", StringComparison.Ordinal); + int ssao = post.IndexOf("ssao.Use();", StringComparison.Ordinal); + int apply = post.IndexOf("ApplyOptimumSceneSsao();", StringComparison.Ordinal); + int resolve = post.IndexOf("RenderOptimumTaaResolve();", StringComparison.Ordinal); + Assert.True(reset >= 0 && reset < ssao, "the flag is cleared before the SSAO pass"); + Assert.True(ssao < apply, "the AO is computed before it is composed"); + Assert.True(apply < resolve, "the AO is composed before the resolve"); + Assert.Equal(1, Count(post, "ssao.Use();")); + Assert.Contains("if (OptimumTaaRequested && TaaTargetsReady)", post); + + Assert.Contains("final.Uniform(\"optimumSsaoInScene\", optimumSsaoInScene ? 1 : 0);", platform); + Assert.Contains("optimumSsaoInScene = true;", platform); + Assert.Contains("if (optimumSsaoInScene == 0)", Read("sources/shaders/final.fsh")); + Assert.Contains("uniform int optimumSsaoInScene;", Read("sources/shaders/final.fsh")); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"optimumSsaoInScene\"", patcher); + Assert.Contains("\"ApplyOptimumSceneSsao\"", patcher); + Assert.Contains("\"SceneSsao\"", patcher); + string registry = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + Assert.Contains("RegisterOptimumShaderProgram(\"scene-ssao\"", registry); + Assert.Contains("shaderProgram == ShaderPrograms.SceneSsao", registry); + + string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); + Assert.Contains("Name = \"SceneSsao/0\"", graph); + Assert.Contains("ColorSlots = 1u", graph); + } + + [Fact] + public void TheSceneSsaoShaderShipsInTheWindowsPackage() + { + string package = Read("scripts/package.ps1"); + Assert.Contains("'assets/game/shaders/scene-ssao.vsh'", package); + Assert.Contains("'assets/game/shaders/scene-ssao.fsh'", package); + } + + private static int Count(string text, string needle) + { + int count = 0; + for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) + { + count++; + } + return count; + } + + private static string Read(string path) + { + string root = Directory.GetCurrentDirectory(); + while (!Directory.Exists(Path.Combine(root, "Optimum.Patcher"))) root = Directory.GetParent(root)!.FullName; + return File.ReadAllText(Path.Combine(root, path)); + } +} diff --git a/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs b/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs new file mode 100644 index 00000000..29df609f --- /dev/null +++ b/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs @@ -0,0 +1,150 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// GTAO roadmap step 2: vanilla's SSAO rotates its sample kernel with a Bayer-128 +/// dither locked to the pixel grid, so under a jittered camera every surface point +/// draws a different kernel every frame and no temporal accumulator can average it. +/// Optimum's override advances that dither by the golden ratio per frame, using the +/// pipeline's own frame index - and only while a temporal consumer owns the frame, +/// because a per-frame-varying dither with nothing accumulating behind it is +/// strictly worse than the fixed one. +/// +public class SsaoTemporalDitherCoverageTests +{ + [Fact] + public void TheOverrideExistsAndOnlyAddsTheTemporalDither() + { + string shader = Read("sources/shaders/ssao.fsh"); + + // The vanilla dither is still the base: the temporal term is added to it, + // it does not replace it. + Assert.Contains("float dither = bayer128(texcoord * screenSize);", shader); + Assert.Contains("dither = fract(dither + fract(temporalFrameIndex * (PHI - 1.0)));", shader); + Assert.Contains("uniform float temporalFrameIndex;", shader); + } + + /// + /// Both the uniform and the frame-varying term sit inside #if TAAMOTION == 1, + /// which ShaderRegistry stamps from OptimumConfig.EffectiveTaa. With + /// TAAMOTION 0 the file preprocesses back to vanilla. + /// + [Fact] + public void TheFrameVaryingTermIsGatedOnTheTemporalPipeline() + { + string shader = Read("sources/shaders/ssao.fsh"); + foreach (string guarded in new[] + { + "uniform float temporalFrameIndex;", + "dither = fract(dither + fract(temporalFrameIndex * (PHI - 1.0)));" + }) + { + int at = shader.IndexOf(guarded, StringComparison.Ordinal); + Assert.True(at > 0, guarded + " missing"); + int opened = shader.LastIndexOf("#if TAAMOTION == 1", at, StringComparison.Ordinal); + int closed = shader.LastIndexOf("#endif", at, StringComparison.Ordinal); + Assert.True(opened > 0 && opened > closed, guarded + " is not inside #if TAAMOTION == 1"); + } + + Assert.Contains( + "#define TAAMOTION \" + (taaMotion ? 1 : 0)", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs")); + Assert.Contains( + "bool taaMotion = OptimumConfig.EffectiveTaa;", + Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs")); + } + + /// + /// Every line the shader changes has to preprocess away when TAAMOTION is 0: the + /// override must be the vanilla file plus guarded blocks, nothing removed and + /// nothing rewritten, so a game update stays a small re-apply. + /// + [Fact] + public void WithoutATemporalConsumerTheOverrideIsTheVanillaShader() + { + string vanillaPath = Path.Combine( + Root(), ".vanilla", "win-x64", "vintagestory", "assets", "game", "shaders", "ssao.fsh"); + // The vanilla shaders are proprietary and never committed: a checkout that + // has not bootstrapped has nothing to compare against. + if (!File.Exists(vanillaPath)) return; + + string vanilla = File.ReadAllText(vanillaPath); + string preprocessed = StripTaaMotionBlocks(Read("sources/shaders/ssao.fsh")); + Assert.Equal(vanilla.Replace("\r\n", "\n"), preprocessed.Replace("\r\n", "\n")); + } + + /// Drops every #if TAAMOTION == 1 ... #endif block, lines included. + private static string StripTaaMotionBlocks(string shader) + { + var kept = new System.Text.StringBuilder(); + bool skipping = false; + foreach (string line in shader.Split('\n')) + { + string trimmed = line.TrimEnd('\r').Trim(); + if (!skipping && trimmed == "#if TAAMOTION == 1") { skipping = true; continue; } + if (skipping) + { + if (trimmed == "#endif") skipping = false; + continue; + } + kept.Append(line).Append('\n'); + } + Assert.False(skipping, "unterminated #if TAAMOTION block"); + return kept.ToString().TrimEnd('\n'); + } + + [Fact] + public void ThePassSetsTheFrameIndexUnderTheSameCondition() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + int start = platform.IndexOf("public override void RenderPostprocessingEffects", StringComparison.Ordinal); + Assert.True(start > 0); + string post = platform[start..platform.IndexOf("public override void ClearSsaoTarget", start, StringComparison.Ordinal)]; + + // Same clock as the jitter and the resolve: OptimumTemporal's frame index, + // wrapped only so it stays exact in a float. + Assert.Contains("if (OptimumConfig.EffectiveTaa)", post); + Assert.Contains( + "ssao.Uniform(\"temporalFrameIndex\", (float)(OptimumTemporal.Frame.FrameIndex & 1023L));", + post); + // Set on the bound SSAO program, before the draw that reads it. + int set = post.IndexOf("ssao.Uniform(\"temporalFrameIndex\"", StringComparison.Ordinal); + Assert.True(post.IndexOf("ssao.Use();", StringComparison.Ordinal) < set); + Assert.True(set < post.IndexOf("RenderFullscreenTriangle(screenQuad);", set, StringComparison.Ordinal)); + Assert.True(set < post.IndexOf("ssao.Stop();", StringComparison.Ordinal)); + + // The method is a Cecil transplant; an edited body only ships if it is listed. + Assert.Contains( + "new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"RenderPostprocessingEffects\", 1)", + Read("Optimum.Patcher/Program.cs")); + } + + /// + /// The override only does anything if it reaches the install. The deploy and the + /// Linux/macOS packagers copy sources/shaders wholesale and then verify every file + /// arrived; the Windows packager names its files one by one, so ssao.fsh has to be + /// in that list or a release silently runs vanilla's shader. + /// + [Fact] + public void TheShaderShipsInTheDeployAndPackagingLists() + { + string makefile = Read("Makefile"); + Assert.Contains("for f in sources/shaders/*;", makefile); + Assert.Contains("did not reach", makefile); + Assert.Contains("'assets/game/shaders/ssao.fsh'", Read("scripts/package.ps1")); + Assert.Contains("shader source file(s) never reached the staged assets", Read("scripts/package-linux.sh")); + Assert.Contains("shader source file(s) never reached the staged assets", Read("scripts/package-macos.sh")); + } + + private static string Root() + { + string root = Directory.GetCurrentDirectory(); + while (!Directory.Exists(Path.Combine(root, "Optimum.Patcher"))) root = Directory.GetParent(root)!.FullName; + return root; + } + + private static string Read(string path) => File.ReadAllText(Path.Combine(Root(), path)); +} diff --git a/TAA-PLAN.md b/TAA-PLAN.md index 289734c5..eb3c69e3 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -851,3 +851,10 @@ shader is silently shadowed. Needed later: emit `patches/shaders/*.patch` agains (`.vanilla/archives/vs_client_*.tar.gz`) from `scripts/extract-patches.sh`, verify in `scripts/check-patches.sh`, and keep overrides additive (vanilla functions untouched, Optimum twins beside them) so patches stay small. Raised by the user on 2026-09-10 during P3. + +One more override since 2026-09-12: `sources/shaders/ssao.fsh` (GTAO roadmap step 2, the temporally +varying dither). It is additive in the strict sense - two `#if TAAMOTION == 1` blocks, a uniform +declaration and one `dither = fract(...)` line, nothing else touched - so with no temporal consumer +it preprocesses back to vanilla and the diff against +`.vanilla/win-x64/vintagestory/assets/game/shaders/ssao.fsh` stays a handful of added lines to +re-apply after a game update. Pinned by `Optimum.Tests/ssao-temporal-dither-coverage-tests.cs`. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 3692f97a..4fe0f17e 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..75f35b5 100644 +index 6edf0c9..98c07a1 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -1265,7 +1265,7 @@ index 6edf0c9..75f35b5 100644 + /// member for it) attachments. + /// + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) -+ { + { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1317,7 +1317,7 @@ index 6edf0c9..75f35b5 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) - { ++ { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. @@ -1885,19 +1885,82 @@ index 6edf0c9..75f35b5 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3199,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3199,95 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { return; } +- int x = ((NativeWindow)window).ClientSize.X; +- int y = ((NativeWindow)window).ClientSize.Y; + // Mono.Cecil transplant. + // The pass structure is API-neutral - it is framebuffer selection, a + // fullscreen triangle and uniforms, all of which are platform virtuals. + // The SSAO clear and the final blend enable are too (ClearSsaoTarget, + // SetBlendEnabled). - int x = ((NativeWindow)window).ClientSize.X; - int y = ((NativeWindow)window).ClientSize.Y; ++ int x = ((NativeWindow)window).ClientSize.X; ++ int y = ((NativeWindow)window).ClientSize.Y; ++ // Optimum TAA: AO is derived from the jittered G-buffer, so it is computed ++ // before the resolve and multiplied into the scene the resolve accumulates. ++ // Applied where vanilla applies it - in Final, after the resolve - the AO ++ // bypasses the history and carries the raw camera jitter onto the whole ++ // frame, worst on fine distant detail. ++ optimumSsaoInScene = false; ++ if (RenderSSAO && projectMatrix != null) ++ { ++ GlToggleBlend(on: false); ++ LoadFrameBuffer(EnumFrameBuffer.SSAO); ++ ClearSsaoTarget(); ++ ShaderProgramSsao ssao = ShaderPrograms.Ssao; ++ ssao.Use(); ++ ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; ++ ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; ++ ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; ++ float num = ((ssaaLevel == 1f) ? 0.5f : 1f); ++ ssao.Uniform("screenSize", ssaaLevel * (float)x * num, ssaaLevel * (float)y * num); ++ ssao.Revealage2D = frameBuffers[1].ColorTextureIds[1]; ++ ssao.Projection = projectMatrix; ++ ssao.SamplesArray(64, ssaoKernel); ++ // Optimum TAA: the clock the sample kernel's dither rotates with. ++ // Vanilla's Bayer-128 is locked to the pixel grid, so a jittered camera ++ // hands every surface point a different kernel every frame and the ++ // resolve cannot average it; the TAAMOTION variant of ssao.fsh advances ++ // the dither by the golden ratio per frame instead. Set under exactly ++ // the condition that compiles the uniform in (ShaderRegistry stamps ++ // TAAMOTION from EffectiveTaa), so with TAA off the pass is vanilla to ++ // the byte. Wrapped to 1024 so the index stays exact in a float. ++ if (OptimumConfig.EffectiveTaa) ++ { ++ ssao.Uniform("temporalFrameIndex", (float)(OptimumTemporal.Frame.FrameIndex & 1023L)); ++ } ++ RenderFullscreenTriangle(screenQuad); ++ ssao.Stop(); ++ ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; ++ bilateralblur.Use(); ++ int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); ++ for (int i = 0; i < num2; i++) ++ { ++ FrameBufferRef frameBufferRef = frameBuffers[15]; ++ LoadFrameBuffer(EnumFrameBuffer.SSAOBlurHorizontal); ++ bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); ++ bilateralblur.IsVertical = 0; ++ bilateralblur.InputTexture2D = frameBuffers[(i == 0) ? 13 : 14].ColorTextureIds[0]; ++ bilateralblur.DepthTexture2D = frameBuffers[0].DepthTextureId; ++ RenderFullscreenTriangle(screenQuad); ++ LoadFrameBuffer(EnumFrameBuffer.SSAOBlurVertical); ++ bilateralblur.IsVertical = 1; ++ bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); ++ bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; ++ RenderFullscreenTriangle(screenQuad); ++ } ++ bilateralblur.Stop(); ++ GlToggleBlend(on: true); ++ GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); ++ if (OptimumTaaRequested && TaaTargetsReady) ++ { ++ ApplyOptimumSceneSsao(); ++ } ++ } + // Optimum TAA: resolve first, so bloom, god rays and the final input read + // the temporally stable image instead of the jittered one. + RenderOptimumTaaResolve(); @@ -1922,7 +1985,7 @@ index 6edf0c9..75f35b5 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,45 +3238,48 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3299,108 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -1964,25 +2027,44 @@ index 6edf0c9..75f35b5 100644 RenderFullscreenTriangle(screenQuad); godrays.Stop(); - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); -+ GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); - } - if (RenderSSAO && projectMatrix != null) - { - GlToggleBlend(on: false); - LoadFrameBuffer(EnumFrameBuffer.SSAO); +- } +- if (RenderSSAO && projectMatrix != null) +- { +- GlToggleBlend(on: false); +- LoadFrameBuffer(EnumFrameBuffer.SSAO); - GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); -+ ClearSsaoTarget(); - ShaderProgramSsao ssao = ShaderPrograms.Ssao; - ssao.Use(); - ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; - ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; - ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -@@ -1915,35 +3308,46 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; - RenderFullscreenTriangle(screenQuad); - } - bilateralblur.Stop(); - GlToggleBlend(on: true); +- ShaderProgramSsao ssao = ShaderPrograms.Ssao; +- ssao.Use(); +- ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; +- ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; +- ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; +- float num = ((ssaaLevel == 1f) ? 0.5f : 1f); +- ssao.Uniform("screenSize", ssaaLevel * (float)x * num, ssaaLevel * (float)y * num); +- ssao.Revealage2D = frameBuffers[1].ColorTextureIds[1]; +- ssao.Projection = projectMatrix; +- ssao.SamplesArray(64, ssaoKernel); +- RenderFullscreenTriangle(screenQuad); +- ssao.Stop(); +- ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; +- bilateralblur.Use(); +- int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); +- for (int i = 0; i < num2; i++) +- { +- FrameBufferRef frameBufferRef = frameBuffers[15]; +- LoadFrameBuffer(EnumFrameBuffer.SSAOBlurHorizontal); +- bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); +- bilateralblur.IsVertical = 0; +- bilateralblur.InputTexture2D = frameBuffers[(i == 0) ? 13 : 14].ColorTextureIds[0]; +- bilateralblur.DepthTexture2D = frameBuffers[0].DepthTextureId; +- RenderFullscreenTriangle(screenQuad); +- LoadFrameBuffer(EnumFrameBuffer.SSAOBlurVertical); +- bilateralblur.IsVertical = 1; +- bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); +- bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; +- RenderFullscreenTriangle(screenQuad); +- } +- bilateralblur.Stop(); +- GlToggleBlend(on: true); - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); } @@ -2022,13 +2104,44 @@ index 6edf0c9..75f35b5 100644 + { + GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); + } ++ ++ /// ++ /// Optimum TAA: whether this frame's AO was multiplied into the scene before the ++ /// resolve. Tracks the application, not the resolve's success: a resolve that ++ /// stands down after the multiply must still keep Final from applying it again. ++ /// ++ private bool optimumSsaoInScene; ++ ++ /// Optimum TAA: multiply the jittered AO into Primary colour 0 before the resolve, touching nothing else. ++ private void ApplyOptimumSceneSsao() ++ { ++ ShaderProgram composite = ShaderPrograms.SceneSsao; ++ if (composite == null || composite.LoadError) ++ { ++ return; ++ } ++ LoadFrameBuffer(EnumFrameBuffer.Primary); ++ BeginFinalCompositionDrawBuffers(); ++ // Multiply is dst * (1 - srcAlpha): the pass writes 1 - AO into alpha and ++ // never samples Primary, so there is no attachment feedback loop. ++ GlToggleBlend(on: true, EnumBlendMode.Multiply); ++ composite.Use(); ++ composite.BindTexture2D("ssaoScene", frameBuffers[14].ColorTextureIds[0], 0); ++ composite.Uniform("invRenderHeight", 1f / (float)frameBuffers[0].Height); ++ RenderFullscreenTriangle(screenQuad); ++ composite.Stop(); ++ GlToggleBlend(on: true); ++ GlEnableDepthTest(); ++ RestoreWorldDrawBuffers(RenderSSAO); ++ optimumSsaoInScene = true; ++ } + public override void RenderFinalComposition() { //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,19 +3357,21 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3410,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2054,7 +2167,16 @@ index 6edf0c9..75f35b5 100644 if (RenderSSAO) { final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; -@@ -1987,24 +3393,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + } ++ // Optimum TAA: written every frame, never conditionally - a declared uniform ++ // left unset reads back as whatever the Vulkan uniform ring last held. ++ final.Uniform("optimumSsaoInScene", optimumSsaoInScene ? 1 : 0); + final.Uniform("invFrameSizeIn", 1f / ((float)((NativeWindow)window).ClientSize.X * ssaaLevel), 1f / ((float)((NativeWindow)window).ClientSize.Y * ssaaLevel)); + final.GammaLevel = ClientSettings.GammaLevel; + final.ExtraGamma = ClientSettings.ExtraGammaLevel; + final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; + final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; +@@ -1987,24 +3449,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2111,7 +2233,7 @@ index 6edf0c9..75f35b5 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3448,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3504,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2597,7 +2719,7 @@ index 6edf0c9..75f35b5 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4084,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4140,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -2637,7 +2759,7 @@ index 6edf0c9..75f35b5 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4482,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4538,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -2690,7 +2812,7 @@ index 6edf0c9..75f35b5 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4577,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4633,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2735,7 +2857,7 @@ index 6edf0c9..75f35b5 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4614,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4670,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2756,7 +2878,7 @@ index 6edf0c9..75f35b5 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4633,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4689,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2777,7 +2899,7 @@ index 6edf0c9..75f35b5 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4652,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4708,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2798,7 +2920,7 @@ index 6edf0c9..75f35b5 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4671,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4727,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2819,7 +2941,7 @@ index 6edf0c9..75f35b5 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4694,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4750,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2840,7 +2962,7 @@ index 6edf0c9..75f35b5 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5256,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5312,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -2864,7 +2986,7 @@ index 6edf0c9..75f35b5 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +5615,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +5671,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch index 1258ef57..3698b89d 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -index f19d524..1e3972a 100644 +index f19d524..ea5a1d2 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -@@ -40,10 +40,33 @@ public static class ShaderPrograms +@@ -40,10 +40,38 @@ public static class ShaderPrograms public static ShaderProgramEntityanimated Entityanimated; @@ -21,6 +21,11 @@ index f19d524..1e3972a 100644 + // and the post chain simply keeps reading the unsharpened resolve. + public static ShaderProgram TaaSharpen; + ++ // Optimum TAA: multiplies the jittered AO into the scene before the resolve ++ // (scene-ssao). A failed compile marks LoadError and Final keeps applying ++ // AO the vanilla way. ++ public static ShaderProgram SceneSsao; ++ + // Optimum TAA (P4): the liquid velocity pass (TAA-PLAN.md accuracy rule 7). + // Registered like the other Optimum-only programs, so a failed compile marks + // LoadError instead of failing the whole shader load. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index 60bf04b9..ecf70d55 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..d077eb4 100644 +index 4a24e75..b2f4276 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index 4a24e75..d077eb4 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,199 @@ public class ShaderRegistry +@@ -181,39 +183,201 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -25,6 +25,8 @@ index 4a24e75..d077eb4 100644 + RegisterOptimumShaderProgram("taa-resolve", ShaderPrograms.TaaResolve = new ShaderProgram()); + // Optimum TAA (P5): the post-resolve sharpen pass. + RegisterOptimumShaderProgram("taa-sharpen", ShaderPrograms.TaaSharpen = new ShaderProgram()); ++ // Optimum TAA: the AO multiply into the scene before the resolve. ++ RegisterOptimumShaderProgram("scene-ssao", ShaderPrograms.SceneSsao = new ShaderProgram()); + // Optimum TAA (P4): the liquid velocity pass. + RegisterOptimumShaderProgram("chunkliquidmotion", ShaderPrograms.ChunkLiquidMotion = new ShaderProgram()); + // Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. @@ -195,7 +197,7 @@ index 4a24e75..d077eb4 100644 + bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); + OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); + } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.TaaSharpen || shaderProgram == ShaderPrograms.ChunkLiquidMotion || shaderProgram == ShaderPrograms.TaaSkyMotion) ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.TaaSharpen || shaderProgram == ShaderPrograms.SceneSsao || shaderProgram == ShaderPrograms.ChunkLiquidMotion || shaderProgram == ShaderPrograms.TaaSkyMotion) + { + shaderProgram.LoadError |= !compiled; + } @@ -223,7 +225,7 @@ index 4a24e75..d077eb4 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +458,11 @@ public class ShaderRegistry +@@ -296,11 +460,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -236,7 +238,7 @@ index 4a24e75..d077eb4 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +495,48 @@ public class ShaderRegistry +@@ -333,10 +497,48 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/scripts/package.ps1 b/scripts/package.ps1 index 8cb1e1d1..fe1d2b9d 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -461,7 +461,18 @@ try { 'assets/game/shaders/fsr-easu.vsh', 'assets/game/shaders/fsr-easu.fsh', 'assets/game/shaders/fsr-rcas.vsh', - 'assets/game/shaders/fsr-rcas.fsh' + 'assets/game/shaders/fsr-rcas.fsh', + # TAA: the jittered AO multiplied into the scene before the resolve. + # Without it Final applies AO after the resolve and the whole frame + # carries the raw camera jitter. + 'assets/game/shaders/scene-ssao.vsh', + 'assets/game/shaders/scene-ssao.fsh', + # SSAO: Optimum's override only adds the temporally varying dither + # (GTAO roadmap step 2) and preprocesses back to vanilla without a + # temporal consumer - but if it never ships, vanilla's screen-locked + # dither runs under a jittered camera, which is the failure the + # override exists to remove and is silent on screen. + 'assets/game/shaders/ssao.fsh' )) { if (-not (Test-Path (Join-Path $stageDir $requiredStageFile))) { throw "Required package file not found: $requiredStageFile" diff --git a/sources/shaders/final.fsh b/sources/shaders/final.fsh index b781cc5d..af156db0 100644 --- a/sources/shaders/final.fsh +++ b/sources/shaders/final.fsh @@ -6,6 +6,7 @@ uniform sampler2D glowParts; uniform sampler2D bloomParts; uniform sampler2D godrayParts; uniform sampler2D ssaoScene; +uniform int optimumSsaoInScene; uniform float gammaLevel; uniform float brightnessLevel; @@ -102,12 +103,16 @@ void main(void) #endif #if SSAOLEVEL > 0 + // Optimum TAA: skipped when the AO was already multiplied into the scene + // before the resolve, so it is never applied twice. + if (optimumSsaoInScene == 0) { #if SSAOLEVEL > 1 float ssao = min(texture(ssaoScene, texCoord).r, texture(ssaoScene, texCoord - vec2(0, invFrameSize.y*1)).r); #else float ssao = texture(ssaoScene, texCoord).r; #endif color.rgb *= min(1, ssao + bloomSub); + } #endif #if GODRAYS > 0 diff --git a/sources/shaders/scene-ssao.fsh b/sources/shaders/scene-ssao.fsh new file mode 100644 index 00000000..6c68d64f --- /dev/null +++ b/sources/shaders/scene-ssao.fsh @@ -0,0 +1,14 @@ +#version 330 core +uniform sampler2D ssaoScene; +uniform float invRenderHeight; +in vec2 texCoord; +layout(location = 0) out vec4 outColor; +void main() +{ + float ao = texture(ssaoScene, texCoord).r; + #if SSAOLEVEL > 1 + ao = min(ao, texture(ssaoScene, texCoord - vec2(0.0, invRenderHeight)).r); + #endif + // EnumBlendMode.Multiply: dstRGB * (1 - srcAlpha). RGB is not read. + outColor = vec4(0.0, 0.0, 0.0, 1.0 - clamp(ao, 0.0, 1.0)); +} diff --git a/sources/shaders/scene-ssao.vsh b/sources/shaders/scene-ssao.vsh new file mode 100644 index 00000000..fe749c7b --- /dev/null +++ b/sources/shaders/scene-ssao.vsh @@ -0,0 +1,9 @@ +#version 330 core +out vec2 texCoord; +void main() +{ + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); +} diff --git a/sources/shaders/ssao.fsh b/sources/shaders/ssao.fsh new file mode 100644 index 00000000..50d48d24 --- /dev/null +++ b/sources/shaders/ssao.fsh @@ -0,0 +1,184 @@ +#version 330 core + +uniform sampler2D gPosition; +uniform sampler2D gNormal; +uniform sampler2D texNoise; +uniform vec3[64] samples; +uniform vec2 screenSize; +uniform sampler2D revealage; + +in vec2 texcoord; +out vec4 outOcclusion; + +#if SSAOLEVEL == 2 +int kernelSize = 24; +float radius = 0.9; +#else +int kernelSize = 20; +float radius = 0.9; +#endif + +float bias = 0.01; + +uniform mat4 projection; +#if TAAMOTION == 1 +// Optimum (GTAO roadmap step 2): the temporal pipeline's own frame counter - +// OptimumTemporal.Frame.FrameIndex, wrapped so it stays exact in a float. One +// clock for the whole pipeline: the camera jitter, the TAA resolve and the +// upscaler all step on this index, and the dither below steps with them. +// Declared only while a temporal consumer owns the frame, so the SSAO pass can +// leave it unset and this file still preprocesses back to vanilla. +uniform float temporalFrameIndex; +#endif + + +// Useful numbers +#define PI radians(180.0) +#define TAU PI * 2.0 +#define RCPPI 1.0 / PI +#define PHI sqrt(5.0) * 0.5 + 0.5 +#define GOLDEN_ANGLE TAU / PHI / PHI +#define LOG2 log(2.0) + +#define cubicSmooth(x) (x * x) * (3.0 - 2.0 * x) + + +float bayer2(vec2 a){ + a = floor(a); + return fract(dot(a, vec2(0.5, a.y * 0.75))); +} + +float bayer4(vec2 a) { return bayer2( 0.5 *a) * 0.25 + bayer2(a); } +float bayer8(vec2 a) { return bayer4( 0.5 *a) * 0.25 + bayer2(a); } +float bayer16(vec2 a) { return bayer4( 0.25 *a) * 0.0625 + bayer4(a); } +float bayer32(vec2 a) { return bayer8( 0.25 *a) * 0.0625 + bayer4(a); } +float bayer64(vec2 a) { return bayer8( 0.125*a) * 0.015625 + bayer8(a); } +float bayer128(vec2 a) { return bayer16(0.125*a) * 0.015625 + bayer8(a); } + +// Fermats golden spiral, input a dither pattern as the index and its size as the total to generate coordinates following the spiral. +vec2 goldenSpiralN(float index, float total) { + float theta = index * GOLDEN_ANGLE; + return vec2(sin(theta), cos(theta)) * sqrt(index / total); +} + +vec2 goldenSpiralS(float index, float total) { + float theta = index * GOLDEN_ANGLE; + return vec2(sin(theta), cos(theta)) * pow(index / total, 2.0); +} + +// Useful tool to convert 2D offset patterns into 3D. Looks great with screen space stuff, more complicated things such as path tracing should use a rand. +vec3 sphereMap(vec2 a) { + float phi = a.y * 2.0 * PI; + float cosTheta = 1.0 - a.x; + float sinTheta = sqrt(1.0 - cosTheta * cosTheta); + + return vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); +} + + +void main() +{ + float wboitatn = max(0.0, 1 - texture(revealage, texcoord).r) * 0.75; + + // tile noise texture over screen based on screen dimensions divided by noise size + vec2 noiseScale = vec2(screenSize.x/8.0, screenSize.y/8.0); + + vec4 texVal = texture(gPosition, texcoord); + + vec3 fragPos = texVal.xyz; + float attenuate = texVal.w + wboitatn; + + texVal = texture(gNormal, texcoord); + vec3 normal = normalize(texVal.xyz); + bool leavesHack = texVal.w > 0; + + // This seems to completely fix any distant ssao flickering artifacts while perservering everything else + // Tyron Mar 9: Completely borks fragments behind leaves, during heavy rain + // Tyron Mar10: Breaks distant cliff walls, changed 90 to 150 + if (!leavesHack) { + fragPos += normal * clamp(-fragPos.z/150 - 0.05, 0, 10); + } + + + float distanceFade = clamp(1.2 - (-fragPos.z) / 250, 0, 1); + + if (fragPos.x == 0 || distanceFade == 0) { + outOcclusion = vec4(1); + return; + } + + //vec3 randomVec = texture(texNoise, texcoord * noiseScale).xyz; + + const float ditherSize = pow(64.0, 2.0); + float dither = bayer128(texcoord * screenSize); +#if TAAMOTION == 1 + // Give that screen-locked dither a temporal dimension. Vanilla's Bayer-128 + // is fixed to the pixel grid, so under a jittered camera every surface point + // draws a different kernel every frame and nothing can average it. Advancing + // the dither by the golden ratio per frame makes successive frames sample + // complementary spiral directions instead, which is what a temporal + // accumulator converges on. Gated on TAAMOTION (stamped from + // OptimumConfig.EffectiveTemporalPipeline): with no accumulator behind it a + // per-frame-varying dither is strictly worse than the fixed one. + dither = fract(dither + fract(temporalFrameIndex * (PHI - 1.0))); +#endif + vec3 randomVec = sphereMap(goldenSpiralN(ditherSize + dither, ditherSize)); + + + vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal)); + vec3 bitangent = cross(normal, tangent); + mat3 TBN = mat3(tangent, bitangent, normal); + + float occlusion = 0.0; + + for( int i = 0; i < kernelSize; ++i) + { + vec3 sample = TBN * samples[i]; + sample = fragPos + sample * radius; + + vec4 offset = vec4(sample, 1.0); + offset = projection * offset; + offset.xyz /= offset.w; + offset.xyz = offset.xyz * 0.5 + 0.5; + + offset.x = clamp(offset.x, texcoord.x - 0.04, texcoord.x + 0.04); + offset.y = clamp(offset.y, texcoord.y - 0.04, texcoord.y + 0.04); + + float sampleDepth = texture(gPosition, offset.xy).z; + float depthDiff = sampleDepth - (sample.z + bias); + float rangeCheck = 0; + + if (leavesHack) { + + if (depthDiff >= 0.02 && depthDiff < 0.2 && abs(dot(texture(gNormal, offset.xy).rgb, normal) - 1) > 0.25) { + rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth)); + } + + } else { + + if (depthDiff > 0 && depthDiff < 0.2) { + rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth)); + } + + } + + occlusion += rangeCheck; + } + + float occ = clamp(1.0 - min(1, occlusion / kernelSize * distanceFade) * (1-attenuate), 0, 1); + + // Some distant geometry gets overly dark, lets clamp the lower limit + #if SSAOLEVEL == 2 + occ = max(occ, 0.5); + #else + occ = max(occ, 0.7); + #endif + + // We need MOAR SSAO >:D + if (!leavesHack) { + occ = 1 - (1-occ) * 1.4; + } + + + outOcclusion = vec4(occ, occ, occ, 1); +} \ No newline at end of file From 766aada95d502a6249079a2b33b5b92186f892d8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 18:52:12 +0200 Subject: [PATCH 129/226] feat(headless): the headless render harness, backported from feat/dlss-g The real client and the real renderer with the window never mapped, so a temporal artefact can be measured from captured frames instead of judged by eye - the tool the whole-frame jitter work needs. - OPTIMUM_HEADLESS creates the window with StartVisible/StartFocused false: a real surface and swapchain on both backends, never mapped or focused. - OptimumHeadlessTick runs in window_RenderFrame beside the parity dump, after the post chain and the final blit: a chat-command script on an in-world frame, a pinned simulated step, frames read back through ReadDefaultFramebuffer and written as PPM, and a clean close from the render thread once the capture and the parity dump are written. - A capture runs silent: the mixer is created muted and the persisted sound settings are never written. - VulkanClientPlatform.ReadDefaultFramebuffer converts the device's R8G8B8A8 texels to the B G R A the OpenGL body returns (PixelOrder), which also fixes Vulkan screenshots and AVI recordings coming out red/blue swapped. - scripts/dev/headless-capture.sh drives one capture end to end, and docs/vulkan-acceptance.md documents the harness and the determinism guard a shimmer number needs. Taken from 651db9b, c1cc73f, 3408934, eb6ddcd and the readback half of ca3a34b. Left out: the upscaler capture test and the frame-generation UI target, neither of which exists on this branch. Verified: build 0 errors; extract-patches and check-patches clean (0 conflict); Optimum.Tests all green apart from six host-environment failures unrelated to this change (numpy missing, CRLF checkout, pacing-gate path translation, _ref/ absent); GPU tests for the harness, the leaf readback routing, SSAO, the TAA resolve and the frame graph 67/67. The capture script has not been run on this host. --- Optimum.Patcher/Program.cs | 23 + .../HeadlessCaptureTests.cs | 275 ++++++++ .../PlatformLeafRoutingTests.cs | 14 +- Optimum.Render.Vulkan/Core/PixelOrder.cs | 42 ++ .../Platform/VulkanClientPlatform.Leaf.cs | 20 + .../Platform/VulkanClientPlatform.cs | 5 + Optimum.Render.Vulkan/VulkanDevice.cs | 18 +- ...-platform-windows-vanilla-regions-tests.cs | 10 + .../headless-harness-coverage-tests.cs | 499 ++++++++++++++ docs/vulkan-acceptance.md | 48 ++ .../ClientPlatformAbstract.cs.patch | 21 +- .../ClientPlatformWindows.cs.patch | 638 +++++++++++++++--- .../ClientProgram.cs.patch | 18 +- scripts/dev/headless-capture.sh | 317 +++++++++ .../Client/optimum-render-device.cs | 306 ++++++++- 15 files changed, 2166 insertions(+), 88 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/HeadlessCaptureTests.cs create mode 100644 Optimum.Render.Vulkan/Core/PixelOrder.cs create mode 100644 Optimum.Tests/headless-harness-coverage-tests.cs create mode 100755 scripts/dev/headless-capture.sh diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 02cc3d55..bf8b761c 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -86,6 +86,10 @@ "RenderOptimumTaaSharpen", "OptimumFsrBlitActive", "DisableOptimumTaa", + // Headless render harness: the channel order ReadDefaultFramebuffer leaves + // in the caller's buffer. B G R A on both backends: the OpenGL path reads + // GL_BGRA and VulkanClientPlatform converts its R8G8B8A8 texels to match. + "OptimumDefaultFramebufferIsBgra", // Phase 1A step 3: the program, uniform and UBO operations ShaderProgramBase and // UBO call. Neutral bodies; ClientPlatformWindows overrides them. SetUniform and // SetUniformMatrix inject every overload the donor declares. @@ -352,6 +356,20 @@ "OptimumParitySlotName", "OptimumParityDumpAttachment", "OptimumParityReadTextureGl", + // Headless render harness: the per-frame hook window_RenderFrame calls next + // to the parity dump, its own in-world frame counter, the chat-command + // script dispatch, the presented-frame readback and the clean close from + // the render thread once the run's artefacts are written. + "optimumHeadlessWorldFrames", + "optimumHeadlessCommandsDone", + "optimumHeadlessCaptureDone", + "optimumHeadlessFramesWritten", + "optimumHeadlessExitRequested", + "OptimumHeadlessTick", + "OptimumHeadlessExitIfDone", + "OptimumHeadlessRunCommands", + "OptimumHeadlessRunCommand", + "OptimumHeadlessCaptureFrame", // Phase 1A step 3: overrides of ClientPlatformAbstract's program, uniform and // UBO virtuals, holding the device branch and GL lines ShaderProgramBase and UBO // used to call directly. Every SetUniform/SetUniformMatrix overload is injected. @@ -868,6 +886,11 @@ new[] { "Vintagestory.API.Client.EnumFrameBuffer" }), // Vulkan backend: startup capability reporting, which cannot ask GL. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "Start", 0), + // Optimum (headless render harness): a capture runs silent, so the mixer is + // created muted and every later attempt to restore the volume is answered with + // silence. Both bodies are vanilla's apart from that one condition. + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "StartAudio", 0), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "set_MasterSoundLevel", 1), // Vulkan backend: uniform buffers, whose handles UBO carries across. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "CreateUBO", 4), new("Vintagestory.Client.NoObf.UBO", "Bind", 0), diff --git a/Optimum.Render.Vulkan.Tests/HeadlessCaptureTests.cs b/Optimum.Render.Vulkan.Tests/HeadlessCaptureTests.cs new file mode 100644 index 00000000..7b81b99e --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/HeadlessCaptureTests.cs @@ -0,0 +1,275 @@ +using System; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Optimum.Render.Vulkan; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The headless render harness's capture path on a device that has no surface at +/// all - no window, no swapchain, Initialize(IntPtr.Zero, ...). +/// +/// This is the claim the harness rests on: the frames it writes come from +/// , the same polymorphic call +/// the in-game screenshot makes, which is a device-side copy of whatever target +/// is bound - never an OS window capture. So a window that was never mapped (and, +/// here, a device with no surface whatsoever) still produces frames. +/// +/// Several consecutive frames are rendered with a Present between them and a +/// per-frame value baked into the pattern, so a capture that silently reused one +/// frame, or wrote the same file every time, fails. The files are decoded back +/// and checked byte for byte in GL row order; the size is odd and non-square so a +/// transposed or flipped image cannot pass, and one channel pair is asymmetric so +/// the BGRA/RGBA distinction the writer takes as an argument is real. +/// +public class HeadlessCaptureTests +{ + private const int Width = 11; + private const int Height = 5; + private const int Frames = 4; + + private readonly ITestOutputHelper _output; + + public HeadlessCaptureTests(ITestOutputHelper output) => _output = output; + + private const string Vertex = """ + #version 330 core + void main() { + gl_Position = vec4(-1 + ((gl_VertexID & 1) << 2), + -1 + ((gl_VertexID & 2) << 1), 0, 1); + } + """; + + // Red carries x, green carries the frame index, blue carries y: three + // distinguishable axes, so a transpose, a flip, a stale frame and a channel + // swap each break a different assertion. + private const string Fragment = """ + #version 330 core + uniform float frameIndex; + layout(location = 0) out vec4 color; + void main() { + int x = int(gl_FragCoord.x); + int y = int(gl_FragCoord.y); + color = vec4(float(x * 23) / 255.0, (frameIndex * 40.0) / 255.0, + float(y * 37) / 255.0, 1.0); + } + """; + + [SkippableFact] + public void OffscreenModeProducesFramesWithoutASurface() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + string directory = Path.Combine(Path.GetTempPath(), "optimum-headless-tests-" + Guid.NewGuid().ToString("N")); + long[] plan = OptimumHeadless.PlanFrames(0, Frames, 1); + try + { + using (device) + { + VulkanDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, Vertex, Fragment, "headless-capture"); + + int color = seam.CreateTexture2D(Width, Height, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Width, Height); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, color, 0); + seam.SetDrawBuffers(framebuffer, 1); + Assert.True(seam.CheckFramebufferComplete(framebuffer, out string status), status); + + byte[] pixels = new byte[Width * Height * 4]; + for (long frame = 0; frame < Frames; frame++) + { + Assert.True(OptimumHeadless.ShouldCapture(plan, frame)); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, Width, Height); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetDepthTest(false); + seam.UseProgram(program); + seam.SetUniform(program, seam.GetUniformLocation(program, "frameIndex"), (float)frame); + seam.DrawFullscreenTriangle(); + + // Exactly what ClientPlatformWindows.OptimumHeadlessCaptureFrame + // does: read the bound target back inside the frame, then write. + GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + seam.ReadDefaultFramebuffer(0, 0, Width, Height, handle.AddrOfPinnedObject()); + } + finally + { + handle.Free(); + } + + // bgra: false - this reads at the device level, where texels come + // back in the target's own R8G8B8A8 order. The client goes through + // VulkanClientPlatform.ReadDefaultFramebuffer, which converts that + // to the GL path's B G R A (PixelOrder.SwapRedAndBlue) and so + // passes bgra: true; both spellings write the same file, which is + // what BgraAndRgbaPixelsWriteTheSameFile holds. + Assert.True(OptimumParityDump.WriteFrame( + Path.Combine(directory, OptimumHeadless.FrameFileName(frame)), + Width, Height, pixels, bgra: false)); + + // No readback in the presentation path: the frame is closed the + // way the client closes it, so the next one is genuinely new. + seam.Present(); + Assert.Equal(frame >= Frames - 1, OptimumHeadless.CaptureFinished(plan, frame)); + } + + GpuTest.AssertClean(seam); + } + + for (long frame = 0; frame < Frames; frame++) + { + string path = Path.Combine(directory, OptimumHeadless.FrameFileName(frame)); + Assert.True(File.Exists(path), path + " was not written"); + byte[] rgb = ReadPpm(path); + for (int y = 0; y < Height; y++) + for (int x = 0; x < Width; x++) + { + int texel = y * Width + x; + Assert.Equal((byte)(x * 23), rgb[texel * 3]); + Assert.Equal((byte)(frame * 40), rgb[texel * 3 + 1]); + Assert.Equal((byte)(y * 37), rgb[texel * 3 + 2]); + } + } + } + finally + { + if (Directory.Exists(directory)) + { + try { Directory.Delete(directory, recursive: true); } catch (IOException) { } + } + } + } + + /// + /// The conversion VulkanClientPlatform.ReadDefaultFramebuffer applies on + /// top of the device readback. + /// + /// The OpenGL body of that virtual is + /// glReadPixels(..., GL_BGRA, GL_UNSIGNED_BYTE, ...), and its callers + /// depend on it: Screenshot.GrabScreenshot - the screenshot key and the + /// AVI recorder - decodes into an SKBitmap declared + /// SKColorType.Bgra8888, and the harness writes its PPMs from the same + /// call. The device's default colour target is R8G8B8A8, so without the swap + /// every Vulkan screenshot came out with red and blue exchanged. A greyscale + /// pattern cannot see that, so this one is saturated red and blue, with green + /// and alpha left where they are to catch a rotation rather than a swap. + /// + [Fact] + public unsafe void TheClientSeamTurnsTheDevicesRgbaIntoTheGlPathsBgra() + { + byte[] texels = [255, 17, 0, 255, 0, 34, 255, 200]; + fixed (byte* data = texels) + { + PixelOrder.SwapRedAndBlue((IntPtr)data, 2); + } + + // Red in, B G R A out - and back again, because the conversion is its own + // inverse, which is what lets one writer serve both backends. + Assert.Equal([0, 17, 255, 255, 255, 34, 0, 200], texels); + fixed (byte* data = texels) + { + PixelOrder.SwapRedAndBlue((IntPtr)data, 2); + } + Assert.Equal([255, 17, 0, 255, 0, 34, 255, 200], texels); + + // Nothing to convert is not a crash. + PixelOrder.SwapRedAndBlue(IntPtr.Zero, 4); + fixed (byte* data = texels) + { + PixelOrder.SwapRedAndBlue((IntPtr)data, 0); + PixelOrder.SwapRedAndBlue((IntPtr)data, -1); + } + Assert.Equal([255, 17, 0, 255, 0, 34, 255, 200], texels); + } + + /// + /// The BGRA half of the same writer, on bytes rather than a GPU: the OpenGL + /// path reads GL_BGRA and the Vulkan one RGBA, and the file must come out the + /// same either way, or every cross-backend comparison is a red/blue swap. + /// + [Fact] + public void BgraAndRgbaPixelsWriteTheSameFile() + { + string directory = Path.Combine(Path.GetTempPath(), "optimum-headless-tests-" + Guid.NewGuid().ToString("N")); + try + { + byte[] rgba = new byte[Width * Height * 4]; + byte[] bgra = new byte[Width * Height * 4]; + for (int texel = 0; texel < Width * Height; texel++) + { + byte r = (byte)(texel * 7); + byte g = (byte)(texel * 13 + 1); + byte b = (byte)(texel * 29 + 2); + rgba[texel * 4] = r; rgba[texel * 4 + 1] = g; rgba[texel * 4 + 2] = b; rgba[texel * 4 + 3] = 255; + bgra[texel * 4] = b; bgra[texel * 4 + 1] = g; bgra[texel * 4 + 2] = r; bgra[texel * 4 + 3] = 255; + } + + string fromRgba = Path.Combine(directory, "rgba.ppm"); + string fromBgra = Path.Combine(directory, "bgra.ppm"); + Assert.True(OptimumParityDump.WriteFrame(fromRgba, Width, Height, rgba, bgra: false)); + Assert.True(OptimumParityDump.WriteFrame(fromBgra, Width, Height, bgra, bgra: true)); + Assert.Equal(File.ReadAllBytes(fromRgba), File.ReadAllBytes(fromBgra)); + + byte[] written = ReadPpm(fromBgra); + for (int texel = 0; texel < Width * Height; texel++) + { + Assert.Equal((byte)(texel * 7), written[texel * 3]); + Assert.Equal((byte)(texel * 13 + 1), written[texel * 3 + 1]); + Assert.Equal((byte)(texel * 29 + 2), written[texel * 3 + 2]); + } + + // A buffer that does not describe the frame is refused, not written. + Assert.False(OptimumParityDump.WriteFrame(Path.Combine(directory, "short.ppm"), + Width, Height, new byte[4], bgra: false)); + Assert.False(File.Exists(Path.Combine(directory, "short.ppm"))); + } + finally + { + if (Directory.Exists(directory)) + { + try { Directory.Delete(directory, recursive: true); } catch (IOException) { } + } + } + } + + private static byte[] ReadPpm(string path) => ReadPpm(path, Width, Height); + + /// + /// Decodes a P6 PPM and holds it to the size it is supposed to be: the header + /// is where a capture that used the wrong extent - the render size instead of + /// the display size, say - shows up first. + /// + private static byte[] ReadPpm(string path, int width, int height) + { + Assert.True(File.Exists(path), path + " was not written"); + byte[] file = File.ReadAllBytes(path); + int offset = 0; + string[] header = new string[4]; + for (int i = 0; i < 4; i++) + { + int start = offset; + while (file[offset] != (byte)' ' && file[offset] != (byte)'\n') offset++; + header[i] = Encoding.ASCII.GetString(file, start, offset - start); + offset++; + } + Assert.Equal("P6", header[0]); + Assert.Equal(width.ToString(CultureInfo.InvariantCulture), header[1]); + Assert.Equal(height.ToString(CultureInfo.InvariantCulture), header[2]); + Assert.Equal("255", header[3]); + Assert.Equal(offset + width * height * 3, file.Length); + return file.AsSpan(offset).ToArray(); + } +} diff --git a/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs index 242c495d..91ff59ff 100644 --- a/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs +++ b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs @@ -156,10 +156,18 @@ public unsafe void TextureUploadRegionClearAndReadbackReachTheDevice() int row = size / 2 * size; int left = (row + 2) * 4; int right = (row + size - 3) * 4; - _output.WriteLine($"left RGBA = {read[left]}, {read[left + 1]}, {read[left + 2]}, {read[left + 3]}"); - _output.WriteLine($"right RGBA = {read[right]}, {read[right + 1]}, {read[right + 2]}, {read[right + 3]}"); + // The platform's readback is the client's seam, and its contract is the + // OpenGL body's: glReadPixels(..., GL_BGRA, ...). So the R=10, G=200, B=30 + // texel that went in comes back B, G, R, A = 30, 200, 10, 255. Asserting + // RGBA here is what let every Vulkan screenshot and AVI recording ship with + // red and blue exchanged, because Screenshot.GrabScreenshot decodes into an + // SKBitmap declared Bgra8888 (wave-1 review, 2026-09-12). The device-level + // VulkanDevice.ReadDefaultFramebuffer still hands texels back in their + // stored order; the conversion is VulkanClientPlatform's. + _output.WriteLine($"left BGRA = {read[left]}, {read[left + 1]}, {read[left + 2]}, {read[left + 3]}"); + _output.WriteLine($"right BGRA = {read[right]}, {read[right + 1]}, {read[right + 2]}, {read[right + 3]}"); Assert.Equal(new byte[] { 0, 0, 0, 0 }, read[left..(left + 4)]); - Assert.Equal(new byte[] { 10, 200, 30, 255 }, read[right..(right + 4)]); + Assert.Equal(new byte[] { 30, 200, 10, 255 }, read[right..(right + 4)]); fork.DeleteFramebuffer(framebuffer); platform.GLDeleteTexture(texture); diff --git a/Optimum.Render.Vulkan/Core/PixelOrder.cs b/Optimum.Render.Vulkan/Core/PixelOrder.cs new file mode 100644 index 00000000..8ba8ce2e --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PixelOrder.cs @@ -0,0 +1,42 @@ +using System; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The one channel-order conversion the backend owes the client. +/// +/// The client's readback seam is ClientPlatformAbstract.ReadDefaultFramebuffer, +/// and the OpenGL body of it is +/// glReadPixels(..., GL_BGRA, GL_UNSIGNED_BYTE, ...). Its vanilla caller, +/// Screenshot.GrabScreenshot - behind the in-game screenshot key and the AVI +/// recorder - hands that straight to an SKBitmap declared +/// SKColorType.Bgra8888, and Optimum's headless harness writes its PPMs from the +/// same call. The Vulkan device's default colour target is R8G8B8A8_UNORM and its +/// readback copies texels untouched, so without this conversion every Vulkan screenshot, +/// recording and captured frame came out with red and blue exchanged. +/// +/// It lives here, one level above VulkanDevice.ReadDefaultFramebuffer, on +/// purpose: that method is also the backend's general "read the bound target back" +/// operation, which the GPU tests use to inspect attachments in their stored order. +/// Converting there would have changed what every one of those reads means. +/// +internal static class PixelOrder +{ + /// + /// Exchanges the first and third byte of each four-byte texel in place: R G B A + /// becomes B G R A, and back again. Does nothing for a null pointer or a + /// non-positive count. + /// + public static unsafe void SwapRedAndBlue(IntPtr texels, long count) + { + if (texels == IntPtr.Zero || count <= 0L) return; + byte* bytes = (byte*)texels; + for (long i = 0; i < count; i++) + { + byte* texel = bytes + i * 4; + byte first = texel[0]; + texel[0] = texel[2]; + texel[2] = first; + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index 4e524b7a..5c1aa65c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -1,5 +1,7 @@ using System; using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; using Vintagestory.API.Client; using Vintagestory.API.Config; using Vintagestory.Client.NoObf; @@ -203,10 +205,28 @@ public override void DeleteOcclusionQuery(int queryId) /// The device reads back the colour target it has bound, which is the same image GL /// would read from the bound framebuffer and in the same orientation - the one flip /// happens at present, after this. + /// + /// Channel order is converted here. The OpenGL body of this virtual is + /// glReadPixels(..., GL_BGRA, ...), and its callers depend on that: + /// Screenshot.GrabScreenshot - the screenshot key and the AVI recorder - decodes + /// into an SKBitmap declared Bgra8888, and the headless harness writes + /// its frames from the same call. The device's default colour target is + /// R8G8B8A8_UNORM, so without this every Vulkan screenshot and recording comes + /// out with red and blue exchanged. The conversion sits here rather than in + /// VulkanDevice.ReadDefaultFramebuffer because that method is also the + /// backend's general "read the bound target" operation, which the GPU tests use to + /// inspect attachments in their stored order. + /// + /// A target that is already B G R A needs nothing done, which is why the format + /// is asked rather than assumed; is + /// left at the base's true either way, because this method makes it true. /// public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) { device.ReadDefaultFramebuffer(x, y, width, height, destination); + if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; + if (device.DefaultColorFormat is Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb) return; + PixelOrder.SwapRedAndBlue(destination, (long)width * height); } public override string GraphicsBackendName => device.BackendName; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 9d58fb7e..e3e61682 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -124,6 +124,11 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "DeleteOcclusionQuery", new[] { "Int32" }), new(true, "ReadDefaultFramebuffer", new[] { "Int32", "Int32", "Int32", "Int32", "IntPtr" }), new(true, "get_GraphicsBackendName", Array.Empty()), + // Headless render harness: the channel order ReadDefaultFramebuffer leaves + // behind. Not overridden here - the platform converts the device's R8G8B8A8 + // texels to the GL path's B G R A - but the virtual has to exist in the + // patched lib, because the harness reads it to decide how to write a frame. + new(true, "get_OptimumDefaultFramebufferIsBgra", Array.Empty()), // Phase 2: render-stage bracket from ClientMain.TriggerRenderStage (contract C3). new(true, "BeginRenderStage", new[] { "EnumRenderStage" }), new(true, "EndRenderStage", new[] { "EnumRenderStage" }), diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 50cf9ade..ec1a2f94 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -3120,13 +3120,23 @@ private void RecordGlInternalFormat(int textureId, int glInternalFormat) /// /// Reads back the bound target's first colour attachment, four bytes per - /// pixel, rows bottom-up. + /// pixel, rows bottom-up, in the target's own channel order. /// /// Bottom-up is not an accident: it is what glReadPixels produces, and /// the existing screenshot and AVI paths already expect it. Because the /// backend never flips Y, the image in memory is laid out exactly as GL laid /// it out, so those paths keep working untouched. The game reads pixels /// mid-frame and carries on drawing; keeps the frame open. + /// + /// Channels are not converted here. The texels come back in the + /// target's own order, which for the default colour target is R G B A. The + /// client's seam is one level up: the OpenGL body of + /// ClientPlatformAbstract.ReadDefaultFramebuffer reads + /// GL_BGRA, so VulkanClientPlatform.ReadDefaultFramebuffer + /// converts (see ) and everything that speaks to + /// the platform - the screenshot key, the AVI recorder, the headless harness - + /// gets one answer. Callers of this method, the GPU tests among them, read the + /// bound target as it is stored. /// public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) { @@ -3142,6 +3152,12 @@ public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr d (ulong)width * (ulong)height * 4, destination); } + /// + /// The format of the default colour target, so the platform above knows the + /// channel order the readback hands back rather than assuming one. + /// + internal Format DefaultColorFormat => DefaultColorTexture()?.Format ?? Format.R8G8B8A8Unorm; + // ------------------------------------------------------------------- teardown public void Dispose() diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index d51a4eee..a896b945 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -59,6 +59,16 @@ public class ClientPlatformWindowsVanillaRegionsTests "optimumTaaDisabled", "optimumTaaResolvedThisFrame", "optimumTaaShaderReloadPending", "optimumTaaTargetsReady", "taaResolvedColorTexture", "taaResolvedGlowTexture", "optimumSsaoInScene", "ApplyOptimumSceneSsao", + // Headless render harness: the per-frame hook, its own in-world frame counter, + // the chat-command script dispatch, the presented-frame readback and the clean + // close from the render thread. + "OptimumHeadlessTick", "OptimumHeadlessRunCommands", "OptimumHeadlessRunCommand", + "OptimumHeadlessCaptureFrame", "optimumHeadlessWorldFrames", "optimumHeadlessCommandsDone", + "optimumHeadlessCaptureDone", "optimumHeadlessFramesWritten", + "OptimumHeadlessExitIfDone", "optimumHeadlessExitRequested", + // The headless harness runs silent: the mixer is created muted and every + // attempt to restore the volume is answered with silence. + "StartAudio", "MasterSoundLevel", // Vanilla members with an Optimum edit (the patcher transplant targets and the members // it virtualizes in place: base edits, FSR/TAA/post chain, frame pacing, mesh bulk copy), diff --git a/Optimum.Tests/headless-harness-coverage-tests.cs b/Optimum.Tests/headless-harness-coverage-tests.cs new file mode 100644 index 00000000..461e6a31 --- /dev/null +++ b/Optimum.Tests/headless-harness-coverage-tests.cs @@ -0,0 +1,499 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The headless render harness's lib side: the two patched call sites, the +/// members the Cecil transplant has to carry, and the Cecil rules the new bodies +/// have to obey. Everything here reads the patch (or the working tree when the +/// patch is not there yet), so a body that is silently dropped from the patch +/// fails a test rather than a run. +/// +/// Silence is part of the same contract: the harness runs in the user's own session +/// with no window, and it must not play sound at them either. The mixer is created +/// muted and every later attempt to restore the volume is answered with silence, +/// while the persisted sound settings are never touched - a capture that changed +/// what the user hears the next time they play would be the harness leaking into the +/// session it borrowed. +/// +public class HeadlessHarnessCoverageTests +{ + private const string PlatformPatch = "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"; + private const string PlatformSource = "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"; + + [Fact] + public void ClientProgramHidesTheWindowOnlyUnderTheEnvironmentSwitch() + { + string program = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch", + "build/VintagestoryLib/Vintagestory.Client/ClientProgram.cs"); + + int guard = program.IndexOf("if (Vintagestory.API.Config.OptimumHeadless.Enabled)", StringComparison.Ordinal); + Assert.True(guard >= 0, "ClientProgram has no headless guard"); + + int visible = program.IndexOf("val2.StartVisible = false;", StringComparison.Ordinal); + int focused = program.IndexOf("val2.StartFocused = false;", StringComparison.Ordinal); + Assert.True(visible > guard, "StartVisible is set outside the headless guard"); + Assert.True(focused > guard, "StartFocused is set outside the headless guard"); + + // Inside the same block, and before the window is opened. + int open = program.IndexOf("AttemptToOpenWindow(", StringComparison.Ordinal); + Assert.True(open > focused, "the window is opened before the headless settings are applied"); + Assert.True(focused - guard < 400, "the headless settings drifted out of their guard block"); + } + + [Fact] + public void RenderFrameCallsTheHarnessBesideTheParityDumpAndBeforeEndFrame() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + + int parity = platform.IndexOf("OptimumRunParityDump();", StringComparison.Ordinal); + // Before anything that searches from it: IndexOf(_, -1) throws + // ArgumentOutOfRangeException, which says nothing about what is missing. + Assert.True(parity >= 0, "the parity dump call site is gone"); + + int guard = platform.IndexOf("if (Vintagestory.API.Config.OptimumHeadless.Active)", StringComparison.Ordinal); + int tick = platform.IndexOf("OptimumHeadlessTick();", StringComparison.Ordinal); + int endFrame = platform.IndexOf("EndFrame();", parity, StringComparison.Ordinal); + + Assert.True(guard > parity, "the harness is not gated behind OptimumHeadless.Active after the dump"); + Assert.True(tick > guard, "OptimumHeadlessTick is not inside its guard"); + Assert.True(endFrame > tick, "the harness runs after presentation instead of before it"); + } + + [Fact] + public void TheHarnessCapturesThroughTheBackendAgnosticReadbackAndAsksTheChannelOrder() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + string capture = Body(platform, "private void OptimumHeadlessCaptureFrame(long worldFrame)"); + + // The polymorphic readback, not an OS capture: this is the whole reason a + // never-mapped window still produces frames. + Assert.Contains("ReadDefaultFramebuffer(0, 0, width, height, handle.AddrOfPinnedObject());", capture); + Assert.DoesNotContain("GrabScreenshot", capture); + Assert.DoesNotContain("SaveScreenshot", capture); + + // The channel order is asked, never assumed - GL reads GL_BGRA and the + // device's default target is R8G8B8A8. + Assert.Contains("OptimumHeadless.WriteFrame(worldFrame, width, height, pixels, OptimumDefaultFramebufferIsBgra)", capture); + } + + /// + /// The capture is sized by the window, which is the size BlitPrimaryToDefault + /// blits the finished frame into EnumFrameBuffer.Default at. The tick is called + /// from window_RenderFrame after the whole render, where that blit has already + /// happened; that placement is pinned by + /// RenderFrameCallsTheHarnessBesideTheParityDumpAndBeforeEndFrame above, and the size + /// is pinned here, so a capture never reads a render-resolution target instead. + /// + [Fact] + public void TheCaptureIsSizedByTheWindow() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + string capture = Body(platform, "private void OptimumHeadlessCaptureFrame(long worldFrame)"); + + // The window's client size, which is the size Default is blitted at. + Assert.Contains("int width = ((NativeWindow)window).ClientSize.X;", capture); + Assert.Contains("int height = ((NativeWindow)window).ClientSize.Y;", capture); + // And the buffer it allocates is that size, so a short read cannot pass. + Assert.Contains("byte[] pixels = new byte[width * height * 4];", capture); + // Not the size of a render target. + Assert.DoesNotContain("frameBuffers[0]", capture); + } + + [Fact] + public void TheCommandScriptIsRoutedTheWayTheChatHudRoutesTypedCommands() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + string run = Body(platform, "private void OptimumHeadlessRunCommand(ClientMain game, string line)"); + + // The client prefix runs locally (.cam drives the scripted camera) and + // everything else goes to the server (/time, /weather set the fixed scene). + Assert.Contains("Vintagestory.Common.ChatCommandApi.ClientCommandPrefix", run); + Assert.Contains("game.api.chatcommandapi.Execute(commandName, game.player, game.currentGroupid, arguments, null);", run); + Assert.Contains("game.api.SendChatMessage(line, game.currentGroupid, null);", run); + } + + [Fact] + public void TheHarnessWaitsForTheWorldAndPinsTheSimulatedStep() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + string tick = Body(platform, "private void OptimumHeadlessTick()"); + + // The same "the player is actually in the world" gate the parity dump uses. + Assert.Contains("game == null || !game.BlocksReceivedAndLoaded", tick); + // Reproducibility: the field vanilla's own cinematic recorder sets. + Assert.Contains("game.DeltaTimeLimiter = OptimumHeadless.FixedDeltaTime;", tick); + // Its own frame counter, so the parity dump's is untouched. + Assert.Contains("optimumHeadlessWorldFrames = worldFrame + 1;", tick); + // The line scripts/dev/headless-capture.sh waits for. + Assert.Contains("\"[Optimum] headless: \"", tick); + } + + [Fact] + public void EveryNewLibMemberIsListedForTheCecilTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + string[] platformMembers = + { + "optimumHeadlessWorldFrames", "optimumHeadlessCommandsDone", "optimumHeadlessCaptureDone", + "optimumHeadlessFramesWritten", "OptimumHeadlessTick", "OptimumHeadlessRunCommands", + "OptimumHeadlessRunCommand", "OptimumHeadlessCaptureFrame", + }; + foreach (string member in platformMembers) + { + Assert.Contains("\"" + member + "\"", patcher); + } + + // The channel-order virtual, injected into the abstract platform: the + // harness reads it, and a backend whose readback cannot produce BGRA would + // override it (none does today - the Vulkan device converts instead). + Assert.Contains("\"OptimumDefaultFramebufferIsBgra\"", patcher); + + // Both patched bodies live in methods the patcher already replaces whole. + Assert.Contains("new(\"Vintagestory.Client.ClientProgram\", \"Start\", 2)", patcher); + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"window_RenderFrame\", 1)", patcher); + } + + /// + /// The channel order is one answer on both backends, and the Vulkan device is + /// what makes it so. + /// + /// The harness originally answered "the Vulkan + /// readback is RGBA" through an override of this virtual. That made the harness + /// correct and left every other caller wrong - the OpenGL body of + /// ReadDefaultFramebuffer reads GL_BGRA, and its only vanilla + /// caller, Screenshot.GrabScreenshot (the screenshot key and the AVI + /// recorder), decodes into an SKBitmap declared Bgra8888, so every + /// Vulkan screenshot came out red/blue swapped. The conversion moved into + /// VulkanClientPlatform.ReadDefaultFramebuffer (Leaf.cs), where it fixes + /// all of them at once, and the platform inherits the base's "true". This test + /// is what keeps the override from coming back without the seam conversion + /// being undone with it - including in the comments, which told the next reader + /// the override still existed long after the override was gone. + /// + [Fact] + public void BothBackendsAnswerTheChannelOrderAndTheDeviceConvertsToIt() + { + string abstractPlatform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + Assert.Contains("public virtual bool OptimumDefaultFramebufferIsBgra", abstractPlatform); + // The comment on it has to say what the code does: no platform overrides it. + Assert.DoesNotContain("VulkanClientPlatform overrides this to false", abstractPlatform); + Assert.Contains("no platform overrides this", abstractPlatform); + // The base says BGRA, which is what the OpenGL body really produces. + Assert.Contains("GL.ReadPixels(x, y, width, height, (PixelFormat)32993", + ReadPatchedOrSource(PlatformPatch, PlatformSource)); + + // Nothing overrides it any more: the platform's readback converts instead. + string vulkan = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs"); + Assert.DoesNotContain("override bool OptimumDefaultFramebufferIsBgra", vulkan); + + string leaf = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs"); + int at = leaf.IndexOf( + "public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)", + StringComparison.Ordinal); + Assert.True(at > 0, "VulkanClientPlatform.ReadDefaultFramebuffer is gone"); + string body = leaf[at..leaf.IndexOf("\n }", at, StringComparison.Ordinal)]; + Assert.Contains("device.ReadDefaultFramebuffer(x, y, width, height, destination);", body); + Assert.Contains("PixelOrder.SwapRedAndBlue(destination, (long)width * height);", body); + // A target that is already BGRA is left alone, so the format is asked. + Assert.Contains("device.DefaultColorFormat is Format.B8G8R8A8Unorm", body); + + // ... and the conversion itself is the R <-> B swap, not something else. + string pixelOrder = Read("Optimum.Render.Vulkan/Core/PixelOrder.cs"); + Assert.Contains("texel[0] = texel[2];", pixelOrder); + Assert.Contains("texel[2] = first;", pixelOrder); + + // The device stays untouched: it is the general "read the bound target" + // operation the GPU tests inspect attachments with, in their stored order. + string deviceFile = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + int deviceAt = deviceFile.IndexOf( + "public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)", + StringComparison.Ordinal); + Assert.True(deviceAt > 0, "VulkanDevice.ReadDefaultFramebuffer is gone"); + Assert.DoesNotContain("SwapRedAndBlue", + deviceFile[deviceAt..deviceFile.IndexOf("\n }", deviceAt, StringComparison.Ordinal)]); + } + + [Fact] + public void TheNewLibBodiesObeyTheCecilRules() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + string[] bodies = + { + Body(platform, "private void OptimumHeadlessTick()"), + Body(platform, "private void OptimumHeadlessRunCommands(ClientMain game)"), + Body(platform, "private void OptimumHeadlessRunCommand(ClientMain game, string line)"), + Body(platform, "private void OptimumHeadlessCaptureFrame(long worldFrame)"), + }; + foreach (string body in bodies) + { + // No lambdas, no LINQ predicates, no local functions: the transplant + // cannot carry the compiler-generated types any of those produce. + Assert.DoesNotContain("=>", body); + Assert.DoesNotContain("delegate", body); + Assert.DoesNotContain(".Where(", body); + Assert.DoesNotContain(".Select(", body); + Assert.DoesNotContain(".Any(", body); + Assert.DoesNotContain("foreach", body); + } + } + + [Fact] + public void AnAbsurdFrameCountIsClampedInsteadOfAllocated() + { + // PlanFrames runs inside a static initialiser, so an unbounded count from + // the environment does not produce a bad capture, it takes the client down + // with an OutOfMemoryException wrapped in a TypeInitializationException. + const long max = Vintagestory.API.Config.OptimumHeadless.MaxFrames; + long[] clamped = Vintagestory.API.Config.OptimumHeadless.PlanFrames(0, long.MaxValue, 1); + Assert.Equal(max, clamped.LongLength); + Assert.Equal(0L, clamped[0]); + + // A clamped stride and first still produce an ascending, non-overflowing + // list: long.MaxValue anywhere must not wrap into negative frame indices. + long[] wild = Vintagestory.API.Config.OptimumHeadless.PlanFrames(long.MaxValue, 4, long.MaxValue); + Assert.Equal(4, wild.Length); + for (int i = 0; i < wild.Length; i++) + { + Assert.True(wild[i] >= 0L, "frame " + i + " overflowed to " + wild[i]); + if (i > 0) Assert.True(wild[i] > wild[i - 1], "frames stopped ascending at " + i); + } + } + + [Fact] + public void TheRendererRewriteInTheCaptureScriptIsAtomic() + { + // The script edits the user's live ModConfig/optimum.json. Truncating it in + // place leaves a broken config behind if anything dies mid-write, so the new + // file is written beside it and renamed over it - on both the set and the + // restore, which share this one function. + string script = Read("scripts/dev/headless-capture.sh"); + int start = script.IndexOf("set_renderer() {", StringComparison.Ordinal); + Assert.True(start >= 0, "headless-capture.sh no longer has a set_renderer function"); + int end = script.IndexOf("\n}", start, StringComparison.Ordinal); + string body = script.Substring(start, end - start); + Assert.Contains("os.replace(", body); + Assert.DoesNotContain("open(path, \"w\")", body); + // And the restore on exit goes through the same function. + Assert.Contains("set_renderer \"$SAVED_RENDERER\"", script); + } + + [Fact] + public void TheHarnessIsDocumentedWhereItWouldBeLookedFor() + { + string acceptance = Read("docs/vulkan-acceptance.md"); + int methods = acceptance.IndexOf("## 3. Methods", StringComparison.Ordinal); + int evidence = acceptance.IndexOf("## 4. Evidence rules", StringComparison.Ordinal); + int harness = acceptance.IndexOf("### Headless render harness", StringComparison.Ordinal); + Assert.True(harness > methods && harness < evidence, + "the headless harness is not documented among the acceptance methods"); + // A shimmer number is only comparable between runs if the method says what is + // pinned and how a drifted run is rejected. + Assert.Contains("determinism guard", acceptance, StringComparison.OrdinalIgnoreCase); + Assert.Contains("rejected, not reported", acceptance); + + string script = Read("scripts/dev/headless-capture.sh"); + // It must refuse to report a capture it cannot attribute to a backend. + Assert.Contains("[Optimum] Vulkan renderer", script); + Assert.Contains("[Optimum] OpenGL renderer:", script); + // And it must never pattern-kill anything itself. + Assert.Contains("kill-client.sh", script); + Assert.DoesNotContain("pkill -f", script); + } + + [Fact] + public void AFrameListAndACadenceBothSelectFramesAndBothEndOnTheLastOne() + { + long[] list = Vintagestory.API.Config.OptimumHeadless.ParseFrameList("30, 10,10; 20 , -5, oops"); + Assert.Equal(new long[] { 10, 20, 30 }, list); + Assert.Empty(Vintagestory.API.Config.OptimumHeadless.ParseFrameList(" ")); + + long[] cadence = Vintagestory.API.Config.OptimumHeadless.PlanFrames(31, 4, 15); + Assert.Equal(new long[] { 31, 46, 61, 76 }, cadence); + // A nonsense stride still produces consecutive frames rather than nothing. + Assert.Equal(new long[] { 5, 6, 7 }, Vintagestory.API.Config.OptimumHeadless.PlanFrames(5, 3, 0)); + Assert.Empty(Vintagestory.API.Config.OptimumHeadless.PlanFrames(0, 0, 1)); + Assert.Empty(Vintagestory.API.Config.OptimumHeadless.PlanFrames(0, -5, 1)); + + foreach (long[] frames in new[] { list, cadence }) + { + Assert.True(Vintagestory.API.Config.OptimumHeadless.ShouldCapture(frames, frames[0])); + Assert.True(Vintagestory.API.Config.OptimumHeadless.ShouldCapture(frames, frames[^1])); + Assert.False(Vintagestory.API.Config.OptimumHeadless.ShouldCapture(frames, frames[0] - 1)); + Assert.False(Vintagestory.API.Config.OptimumHeadless.ShouldCapture(frames, frames[^1] + 1)); + Assert.False(Vintagestory.API.Config.OptimumHeadless.CaptureFinished(frames, frames[^1] - 1)); + Assert.True(Vintagestory.API.Config.OptimumHeadless.CaptureFinished(frames, frames[^1])); + } + + // Six digits, zero padded: two captures of the same list pair by file name + // under scripts/dev/ssim.py. + Assert.Equal("frame-000000.ppm", Vintagestory.API.Config.OptimumHeadless.FrameFileName(0)); + Assert.Equal("frame-000123.ppm", Vintagestory.API.Config.OptimumHeadless.FrameFileName(123)); + } + + [Fact] + public void TheCommandScriptDropsBlanksAndCommentsAndSurvivesAMissingFile() + { + string path = Path.Combine(Path.GetTempPath(), "optimum-headless-commands-" + Guid.NewGuid().ToString("N")); + try + { + File.WriteAllText(path, "# the fixed scene\n/time set 10\n\n \n.cam load 1,2,3\n .cam play 20 \n"); + Assert.Equal(new[] { "/time set 10", ".cam load 1,2,3", ".cam play 20" }, + Vintagestory.API.Config.OptimumHeadless.ReadCommands(path)); + } + finally + { + File.Delete(path); + } + + Assert.Empty(Vintagestory.API.Config.OptimumHeadless.ReadCommands(path)); + Assert.Empty(Vintagestory.API.Config.OptimumHeadless.ReadCommands(null!)); + } + + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "not found: " + signature); + int open = source.IndexOf('{', start); + Assert.True(open >= 0, "no body: " + signature); + int depth = 0; + for (int offset = open; offset < source.Length; offset++) + { + if (source[offset] == '{') depth++; + else if (source[offset] == '}' && --depth == 0) return source.Substring(start, offset - start + 1); + } + Assert.Fail("unbalanced body: " + signature); + return string.Empty; + } + + // ---- the harness makes no sound ---------------------------------------- + + [Fact] + public void TheMixerIsCreatedMutedWhileHeadless() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + int start = platform.IndexOf("public void StartAudio()", StringComparison.Ordinal); + Assert.True(start > 0, "StartAudio must exist"); + string body = platform.Substring(start, 700); + int created = body.IndexOf("audio = new AudioOpenAl(logger);", StringComparison.Ordinal); + int muted = body.IndexOf("audio.MasterSoundLevel = 0f;", StringComparison.Ordinal); + Assert.True(created > 0 && muted > created, + "the mixer must be created and then muted while the headless harness runs"); + Assert.Contains("Vintagestory.API.Config.OptimumHeadless.Enabled", body); + } + + [Fact] + public void RestoringTheVolumeIsAnsweredWithSilenceWhileHeadless() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains( + "audio.MasterSoundLevel = (Vintagestory.API.Config.OptimumHeadless.Enabled ? 0f : value);", + platform); + } + + [Fact] + public void ThePersistedSoundSettingsAreNeverWritten() + { + // Only the running mixer is silenced: nothing in the headless path may assign + // ClientSettings' sound levels, or a capture would change what the user hears + // the next time they play. + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + int start = platform.IndexOf("public void StartAudio()", StringComparison.Ordinal); + string body = platform.Substring(start, 700); + Assert.DoesNotContain("ClientSettings.MasterSoundLevel =", body); + Assert.DoesNotContain("ClientSettings.SoundLevel =", body); + } + + [Fact] + public void ThePatcherCarriesBothBodies() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"StartAudio\", 0", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"set_MasterSoundLevel\", 1", patcher); + } + + /// + /// The harness closes the client itself, and it does it from the render thread. + /// + /// A never-mapped window cannot be sent a close event, so before this the + /// only way to stop a capture was SIGTERM - and its handler calls WindowExit, + /// and therefore Close(), from a signal thread while the render thread is still + /// inside a frame. Every headless run ended in a crash report (2026-09-12: + /// ShaderProgramBase.Use on a program whose graphics were already gone), which + /// is what makes a harness useless as evidence: nobody can tell that crash from + /// a real one. The exit therefore has to sit in the per-frame tick, and it has + /// to wait for both artefacts - the parity dump's frame can be later than the + /// last captured one. + /// + [Fact] + public void TheHarnessClosesItselfFromTheRenderThreadAndOnlyOnceBothArtefactsAreWritten() + { + string platform = ReadPatchedOrSource(PlatformPatch, PlatformSource); + + // The exit is reached from the per-frame tick, which runs on the render + // thread inside window_RenderFrame - not from a signal handler. + Assert.Contains("OptimumHeadlessExitIfDone();", platform); + Assert.Contains("private void OptimumHeadlessExitIfDone()", platform); + Assert.Contains("WindowExit(\"headless capture finished\", EnumExitMode.SoftExit)", platform); + + // Opt-in, and it fires once. + Assert.Contains("if (!OptimumHeadless.ExitWhenDone || optimumHeadlessExitRequested)", platform); + Assert.Contains("optimumHeadlessExitRequested = true;", platform); + + // Both artefacts gate it, and a run that asked for neither never exits here. + Assert.Contains("if (OptimumHeadless.CaptureEnabled && !optimumHeadlessCaptureDone)", platform); + Assert.Contains( + "if (Vintagestory.API.Config.OptimumParityDump.Enabled && !optimumParityDumpDone)", platform); + Assert.Contains( + "if (!OptimumHeadless.CaptureEnabled && !Vintagestory.API.Config.OptimumParityDump.Enabled)", + platform); + + // The flag exists on the API side and is read from the environment. + string api = Read("sources/VintagestoryApi/Client/optimum-render-device.cs"); + Assert.Contains("ExitWhenDone = ResolveFlag(\"OPTIMUM_HEADLESS_EXIT_WHEN_DONE\")", api); + + // The patcher carries the new members, or the transplant drops them silently. + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"optimumHeadlessExitRequested\"", patcher); + Assert.Contains("\"OptimumHeadlessExitIfDone\"", patcher); + + // The capture script asks for it, waits for the clean exit before signalling, + // and says which of the two happened. + string script = Read("scripts/dev/headless-capture.sh"); + Assert.Contains("export OPTIMUM_HEADLESS_EXIT_WHEN_DONE=1", script); + Assert.Contains("if wait_for_exit 60; then", script); + Assert.Contains("CLOSE_HOW=\"closed itself\"", script); + Assert.Contains("CLOSE_HOW=\"signalled\"", script); + // And it reports a crash report rather than leaving it in the log. + Assert.Contains("Critical error occurred", script); + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 53345cc2..5ce6b731 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -326,6 +326,54 @@ pacing seen in Phase 0 and Phase 1 was the moving world, not the build. Evidence `scripts/dev/luma-diff.py --median a1 b1 a2 b2 ...` in explicit pair order, compare medians. Used for the TAA still-frame row only; it is not flicker evidence. +### Headless render harness +- `scripts/dev/headless-capture.sh --renderer vulkan|opengl --world --out [--commands ] + [--frames |--count ] [--fixed-dt ] [--parity-dump]` runs one capture end to end: renderer + written for the run and restored on exit, renderer line required, frames written, the client closed + from its own render thread. +- What it covers. `OPTIMUM_HEADLESS=1` creates the window with `StartVisible=false` and + `StartFocused=false`: a real window with a real surface and a real swapchain, never mapped and never + focused, on both backends (there is no surfaceless GL path in this client, so this is the only offscreen + mode that is symmetric). The frame loop, the swapchain and every rendering path are unchanged - the + harness is one call in `window_RenderFrame`, beside the parity dump, after the post chain and the final + blit. Frames come from `ReadDefaultFramebuffer`, the same polymorphic call the in-game screenshot makes + and a device-side copy on Vulkan, so no OS window capture is involved and no compositor is needed; they + are written as `frame-NNNNNN.ppm` at a chosen frame list or cadence, which `scripts/dev/ssim.py` reads + and which pairs between two captures by name. `OPTIMUM_HEADLESS_COMMANDS` feeds a file of chat lines on + an in-world frame, routed the way the chat HUD routes what a human types, so `/time` and `/weather` fix + the scene and `.cam load` / `.cam play` drive vanilla's own keyframed camera (`SystemCinematicCamera`). + `OPTIMUM_HEADLESS_FIXED_DT` pins `ClientMain.DeltaTimeLimiter`, the field vanilla's own recorder sets, + so the simulated step is constant. `OPTIMUM_HEADLESS_EXIT_WHEN_DONE` closes the client from the render + thread once the capture and the parity dump are written - a never-mapped window cannot be sent a close + event, and SIGTERM closes it from a signal thread in the middle of a frame. A capture runs silent: the + mixer is created muted and the persisted sound settings are never written. A permanently unfocused + window falls under the existing 30 FPS background cap, so a run does not take the machine. +- What it does not cover. A display server is still required - real, nested or Xvfb - because GLFW asks + for the screen size before any window exists and Vulkan needs a WSI surface; "headless" here means no + visible window, not no display. Reproducibility is frame-for-frame repeatable, not bit-exact: a fixed + step does not pin chunk streaming, particle or mob RNG, which is the same standard V0.1 sets for GL-vs-GL + noise. The per-attachment dump `scripts/dev/taa-rejection.py` reads is still `OPTIMUM_PARITY_DUMP` + (composed in by `--parity-dump`, not replaced). No camera path is checked in yet - one has to be + authored per scene with `.cam p` and `.cam save`. +- The determinism guard a shimmer number needs. A shimmer number is a difference between consecutive + captured frames, so anything that moves for a reason other than the effect under test is measured as + shimmer. Two captures are comparable only when all of this is pinned and recorded beside the number: + the world (save file and seed), the camera path (the checked-in `.cam` file, played from a fixed + in-world frame), the step (`--fixed-dt`, same value), the frame list (same indices, not the same + count), the graphics settings that change what is drawn (`ssaa`, `fxaa`, `ssaoQuality`, `bloom`, + `godRays`, `mipMapLevel`, render resolution, TAA and its sharpness), the time and weather the command + script sets, and the backend and GPU/driver the run actually used (from the renderer line, not from what + was asked for). What is not pinned, and therefore may never be read as a signal: chunk streaming order + and the pop-in it causes, particle and mob RNG, wind phase, and anything before the first frame the + world has finished loading - so the capture starts well after the command script, and mobs and weather + are commanded off rather than hoped away. +- A run that drifted is rejected, not reported. The check is mechanical: capture the same scene twice on + the same backend and settings and compare the two runs frame by frame (`scripts/dev/ssim.py`). That + self-pair is the noise floor, and the shimmer number is only meaningful above it. A run whose self-pair + falls below the floor, or whose recorded settings, camera file, frame list or renderer line differ from + the reference run's, is thrown away and re-run - it is not published with a caveat. Frames that fail to + pair by name (a short or ragged capture) are the same failure and get the same treatment. + ## 4. Evidence rules From the plan's "Verification and evidence rules": diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 3c875b53..0162b0e2 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..49a9a11 100644 +index d6eb844..7420dff 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,416 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,433 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -22,6 +22,23 @@ index d6eb844..49a9a11 100644 + { + } + ++ // Optimum (headless render harness): the channel order ReadDefaultFramebuffer ++ // leaves in the caller's buffer. The OpenGL path reads GL_BGRA, because that is ++ // what the screenshot path's SKBitmap wants. Every backend owes its callers the ++ // same order, so no platform overrides this: the Vulkan device's default colour ++ // target is R8G8B8A8_UNORM, and VulkanClientPlatform.ReadDefaultFramebuffer ++ // swaps red and blue on the way out rather than answering false here - answering ++ // false would fix the headless frames and leave the screenshot key and the AVI ++ // recorder swapped. The headless frame writer reads this so a backend that ever ++ // does hand back R G B A can say so. ++ public virtual bool OptimumDefaultFramebufferIsBgra ++ { ++ get ++ { ++ return true; ++ } ++ } ++ + // Optimum TAA/FSR (Vulkan-native plan, Phase 1A step 2): the temporal members the + // renderers call, declared here so callers need no cast to a concrete platform. The + // bodies are neutral (TAA off, no motion window, nothing resolved); the OpenGL bodies diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 4fe0f17e..830d78b6 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..98c07a1 100644 +index 6edf0c9..4359768 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -250,7 +250,24 @@ index 6edf0c9..98c07a1 100644 private int ShadowMapQuality; private float ssaaLevel; -@@ -256,10 +458,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -200,11 +402,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + { + return audio.MasterSoundLevel; + } + set + { +- audio.MasterSoundLevel = value; ++ // Optimum: while the headless harness is capturing, every path that would ++ // restore the volume (the settings watcher, a settings reload, the sound ++ // options page) is answered with silence instead. Only the running mixer ++ // is affected; ClientSettings keeps whatever the user chose. ++ audio.MasterSoundLevel = (Vintagestory.API.Config.OptimumHeadless.Enabled ? 0f : value); + } + } + + public override AssetManager AssetManager => assetManager; + +@@ -256,10 +462,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -274,7 +291,7 @@ index 6edf0c9..98c07a1 100644 get { return serverRunning; -@@ -278,34 +493,54 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,34 +497,54 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -290,9 +307,9 @@ index 6edf0c9..98c07a1 100644 - GL.BindFramebuffer((FramebufferTarget)36160, value.FboId); - GL.Viewport(0, 0, value.Width, value.Height); + BindCurrentFrameBuffer(value); - } - } - ++ } ++ } ++ + /// + /// Optimum (Vulkan-native plan, Phase 1A step 4): the GL half of the + /// setter - bind, and size the viewport to the target. @@ -303,11 +320,11 @@ index 6edf0c9..98c07a1 100644 + { + GL.BindFramebuffer((FramebufferTarget)36160, 0); + return; -+ } + } + GL.BindFramebuffer((FramebufferTarget)36160, value.FboId); + GL.Viewport(0, 0, value.Width, value.Height); -+ } -+ + } + private FrameBufferRef CurrentFrameBufferKeepVw { get @@ -337,7 +354,27 @@ index 6edf0c9..98c07a1 100644 public override bool GlDebugMode { get -@@ -478,40 +713,140 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -379,10 +618,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + public void StartAudio() + { + if (audio == null) + { + audio = new AudioOpenAl(logger); ++ // Optimum (headless render harness): a capture runs in the user's session ++ // with no window, so it must not play sound at them either. The mixer is ++ // still created - AvailableAudioDevices, CurrentAudioDevice and the ++ // settings watchers all dereference it - it just runs silent, and the ++ // persisted sound settings are never written. ++ if (Vintagestory.API.Config.OptimumHeadless.Enabled) ++ { ++ audio.MasterSoundLevel = 0f; ++ } + } + } + + public override void AddAudioSettingsWatchers() + { +@@ -478,40 +726,148 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -467,6 +504,14 @@ index 6edf0c9..98c07a1 100644 + { + OptimumRunParityDump(); + } ++ // Optimum: the headless render harness (OptimumHeadless) - the scripted chat ++ // commands and the presented-frame capture, in the same place and for the ++ // same reason as the dump above: after the post chain and the final blit, ++ // before presentation. Off, this is one static bool check. ++ if (Vintagestory.API.Config.OptimumHeadless.Active) ++ { ++ OptimumHeadlessTick(); ++ } + EndFrame(); ScreenManager.FrameProfiler.End(); } @@ -484,7 +529,7 @@ index 6edf0c9..98c07a1 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +866,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +887,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -497,7 +542,7 @@ index 6edf0c9..98c07a1 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1037,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1058,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -542,7 +587,7 @@ index 6edf0c9..98c07a1 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1149,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1170,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -562,7 +607,7 @@ index 6edf0c9..98c07a1 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1385,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1406,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -575,7 +620,7 @@ index 6edf0c9..98c07a1 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,11 +1512,509 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,150 +1533,900 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -591,6 +636,341 @@ index 6edf0c9..98c07a1 100644 + private bool optimumParityDumpDone; + + /// ++ /// Optimum (headless render harness): frames rendered while the player is in the ++ /// world, counted independently of the parity dump's counter so the two never ++ /// interfere. No initializer: an injected field starts at the CLR default. ++ /// ++ private long optimumHeadlessWorldFrames; ++ ++ private bool optimumHeadlessCommandsDone; ++ ++ private bool optimumHeadlessCaptureDone; ++ ++ private int optimumHeadlessFramesWritten; ++ ++ private bool optimumHeadlessExitRequested; ++ ++ /// ++ /// Optimum (headless render harness, ): everything ++ /// the harness does per frame. Called from window_RenderFrame after the post ++ /// chain and the final blit and before presentation, only when one of the ++ /// harness environment variables is set. ++ /// ++ /// In order, once the player is actually in the world: pin the simulated frame ++ /// step if one was asked for, dispatch the chat-command script once on its ++ /// frame, then read the presented image back on each frame the capture asked ++ /// for. The frame loop itself is untouched - nothing here changes what is ++ /// rendered or when. ++ /// ++ private void OptimumHeadlessTick() + { +- //IL_001b: Unknown result type (might be due to invalid IL or missing references) +- //IL_0072: Unknown result type (might be due to invalid IL or missing references) +- //IL_008c: Unknown result type (might be due to invalid IL or missing references) +- //IL_00ad: Unknown result type (might be due to invalid IL or missing references) +- //IL_0211: Unknown result type (might be due to invalid IL or missing references) +- //IL_02af: Unknown result type (might be due to invalid IL or missing references) +- //IL_0358: Unknown result type (might be due to invalid IL or missing references) +- //IL_0576: Unknown result type (might be due to invalid IL or missing references) +- //IL_0665: Unknown result type (might be due to invalid IL or missing references) +- //IL_0b13: Unknown result type (might be due to invalid IL or missing references) +- //IL_0b88: Unknown result type (might be due to invalid IL or missing references) +- //IL_0bfe: Unknown result type (might be due to invalid IL or missing references) +- //IL_0c62: Unknown result type (might be due to invalid IL or missing references) +- //IL_0ccf: Unknown result type (might be due to invalid IL or missing references) +- //IL_0d44: Unknown result type (might be due to invalid IL or missing references) +- //IL_0db2: Unknown result type (might be due to invalid IL or missing references) +- //IL_0a8c: Unknown result type (might be due to invalid IL or missing references) +- SetupSSAO = ClientSettings.SSAOQuality > 0; +- if (ClientSettings.IsNewSettingsFile && ((NativeWindow)window).ClientSize.X > 1920) ++ Vintagestory.Client.ScreenManager screenManager = Vintagestory.Client.ClientProgram.screenManager; ++ if (screenManager == null) + { +- ClientSettings.SSAA = 0.5f; ++ return; + } +- List list = new List(31); +- for (int i = 0; i <= 24; i++) ++ Vintagestory.Client.GuiScreenRunningGame runningScreen = screenManager.CurrentScreen as Vintagestory.Client.GuiScreenRunningGame; ++ if (runningScreen == null) + { +- list.Add(null); ++ return; + } +- ShadowMapQuality = ClientSettings.ShadowMapQuality; +- ssaaLevel = ClientSettings.SSAA; +- int num = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); +- int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); +- if (num == 0 || num2 == 0) ++ ClientMain game = runningScreen.runningGame; ++ if (game == null || !game.BlocksReceivedAndLoaded) + { +- return list; ++ return; + } +- PixelFormat val = (PixelFormat)6408; +- CheckGlError("sdfb-begin"); +- FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef +- { +- FboId = GL.GenFramebuffer(), +- Width = num, +- Height = num2 +- }); +- FrameBufferRef frameBufferRef3 = frameBufferRef; +- frameBufferRef3.DepthTextureId = GL.GenTexture(); +- if (frameBufferRef3.FboId == 0) ++ ++ // Reasserted every frame rather than set once: this is the same field ++ // vanilla's cinematic recorder writes, and it resets it when a path stops. ++ if (OptimumHeadless.FixedDeltaTime > 0f) + { +- base.XPlatInterface.ShowMessageBox("Fatal error", "Unable to generate a new framebuffer. This shouldn't happen, ever. Maybe a restart resolves the problem?"); ++ game.DeltaTimeLimiter = OptimumHeadless.FixedDeltaTime; + } +- CurrentFrameBufferKeepVw = frameBufferRef3; +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.DepthTextureId); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33191, num, num2, 0, (PixelFormat)6402, (PixelType)5126, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, frameBufferRef3.DepthTextureId, 0); +- GL.DepthFunc((DepthFunction)513); +- frameBufferRef3.ColorTextureIds = ArrayUtil.CreateFilled(SetupSSAO ? 4 : 2, (int n) => GL.GenTexture()); +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[1]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); +- if (SetupSSAO) ++ ++ long worldFrame = optimumHeadlessWorldFrames; ++ optimumHeadlessWorldFrames = worldFrame + 1; ++ ++ if (!optimumHeadlessCommandsDone && worldFrame >= OptimumHeadless.CommandFrame) + { +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[3]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); +- DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; +- GL.DrawBuffers(4, array2); ++ optimumHeadlessCommandsDone = true; ++ if (OptimumHeadless.CommandScriptPath != null) ++ { ++ OptimumHeadlessRunCommands(game); ++ } + } +- else ++ ++ if (!optimumHeadlessCaptureDone && OptimumHeadless.CaptureEnabled) + { +- DrawBuffersEnum[] array3 = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] ++ if (OptimumHeadless.ShouldCapture(worldFrame)) + { +- (DrawBuffersEnum)36064, +- (DrawBuffersEnum)36065 +- }; +- GL.DrawBuffers(2, array3); ++ OptimumHeadlessCaptureFrame(worldFrame); ++ } ++ if (OptimumHeadless.CaptureFinished(worldFrame)) ++ { ++ optimumHeadlessCaptureDone = true; ++ // The line scripts/dev/headless-capture.sh waits for. ++ logger.Notification("[Optimum] headless: " + optimumHeadlessFramesWritten + " frames -> " + OptimumHeadless.FrameDirectory); ++ } + } +- CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); +- frameBufferRef = (list[1] = new FrameBufferRef ++ ++ OptimumHeadlessExitIfDone(); ++ } ++ ++ /// ++ /// Optimum (headless harness): closes the client from the render thread once ++ /// everything the run asked for has been written, when ++ /// is set. ++ /// ++ /// This is the whole point of the flag: an unmapped window cannot receive ++ /// the window manager's close event, so without it the harness has to fall ++ /// through to SIGTERM, whose handler runs on a signal thread and closes the ++ /// window out from under a frame the render thread is still inside. That race ++ /// wrote a crash report on every headless run. Here the close is ++ /// on the render thread between frames - the same call ++ /// the main menu's quit button makes, and the only one the game loop agrees ++ /// with. ++ /// ++ /// "Everything the run asked for" is both artefacts, not just the frames: ++ /// the parity dump's frame can be later than the last captured one, so exiting ++ /// on the capture alone would truncate it. ++ /// ++ private void OptimumHeadlessExitIfDone() ++ { ++ if (!OptimumHeadless.ExitWhenDone || optimumHeadlessExitRequested) + { +- FboId = GL.GenFramebuffer(), +- Width = num, +- Height = num2 +- }); +- frameBufferRef3 = frameBufferRef; +- frameBufferRef3.ColorTextureIds = new int[3] ++ return; ++ } ++ if (OptimumHeadless.CaptureEnabled && !optimumHeadlessCaptureDone) + { +- GL.GenTexture(), +- GL.GenTexture(), +- GL.GenTexture() +- }; +- CurrentFrameBufferKeepVw = frameBufferRef3; +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[1]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33325, num, num2, 0, (PixelFormat)6403, (PixelType)5123, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); +- GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); +- GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); +- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); +- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); +- DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; +- GL.DrawBuffers(3, array5); +- ClearFrameBuffer(EnumFrameBuffer.Transparent); +- CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); +- if (SetupSSAO) ++ return; ++ } ++ if (Vintagestory.API.Config.OptimumParityDump.Enabled && !optimumParityDumpDone) + { +- _ = ClientSettings.SSAOQuality; +- float num3 = 0.5f; ++ return; ++ } ++ if (!OptimumHeadless.CaptureEnabled && !Vintagestory.API.Config.OptimumParityDump.Enabled) ++ { ++ // Nothing was asked for, so there is nothing to be done with; a run like ++ // that is a scripted session and closes when its driver says so. ++ return; ++ } ++ optimumHeadlessExitRequested = true; ++ logger.Notification("[Optimum] headless: capture complete, closing the client"); ++ WindowExit("headless capture finished", EnumExitMode.SoftExit); ++ } ++ ++ /// ++ /// Optimum (headless harness): dispatches the command script's lines, in order, ++ /// once. A failing line is logged and the rest still run - a capture with one ++ /// command missing is worth looking at, a dead client is not. ++ /// ++ private void OptimumHeadlessRunCommands(ClientMain game) ++ { ++ string[] commands = OptimumHeadless.ReadCommands(); ++ int dispatched = 0; ++ for (int i = 0; i < commands.Length; i++) ++ { ++ string line = commands[i]; ++ try ++ { ++ OptimumHeadlessRunCommand(game, line); ++ dispatched = dispatched + 1; ++ } ++ catch (Exception error) ++ { ++ logger.Error("[Optimum] headless: command failed: " + line + ": " + error.Message); ++ } ++ } ++ logger.Notification("[Optimum] headless: " + dispatched + " commands dispatched"); ++ } ++ ++ /// ++ /// Optimum (headless harness): one chat line, routed exactly the way the chat ++ /// HUD routes what a human types - the client command prefix runs the command ++ /// locally (that is how .cam is driven), anything else goes to the server ++ /// as chat (that is how /time, /weather and /gamemode set ++ /// the fixed scene). ++ /// ++ private void OptimumHeadlessRunCommand(ClientMain game, string line) ++ { ++ if (line.StartsWith(Vintagestory.Common.ChatCommandApi.ClientCommandPrefix, StringComparison.Ordinal)) ++ { ++ string rest = line.Substring(1); ++ int space = rest.IndexOf(' '); ++ string commandName = rest; ++ string arguments = ""; ++ if (space > 0) ++ { ++ commandName = rest.Substring(0, space); ++ arguments = rest.Substring(space + 1); ++ } ++ game.api.chatcommandapi.Execute(commandName, game.player, game.currentGroupid, arguments, null); ++ return; ++ } ++ game.api.SendChatMessage(line, game.currentGroupid, null); ++ } ++ ++ /// ++ /// Optimum (headless harness): reads the presented image back through the same ++ /// polymorphic call the in-game screenshot uses - GL.ReadPixels on the OpenGL ++ /// path, a device-side copy on the Vulkan one - and writes it as a PPM. No OS ++ /// window capture anywhere in here, which is why it works on a window that was ++ /// never mapped. ++ /// ++ private void OptimumHeadlessCaptureFrame(long worldFrame) ++ { ++ int width = ((NativeWindow)window).ClientSize.X; ++ int height = ((NativeWindow)window).ClientSize.Y; ++ if (width <= 0 || height <= 0) ++ { ++ return; ++ } ++ try ++ { ++ byte[] pixels = new byte[width * height * 4]; ++ GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); ++ try ++ { ++ ReadDefaultFramebuffer(0, 0, width, height, handle.AddrOfPinnedObject()); ++ } ++ finally ++ { ++ handle.Free(); ++ } ++ if (OptimumHeadless.WriteFrame(worldFrame, width, height, pixels, OptimumDefaultFramebufferIsBgra)) ++ { ++ optimumHeadlessFramesWritten = optimumHeadlessFramesWritten + 1; ++ } ++ } ++ catch (Exception error) ++ { ++ logger.Error("[Optimum] headless: frame " + worldFrame + " failed: " + error.Message); ++ } ++ } ++ ++ /// + /// Optimum: the per-attachment parity dump (). + /// Called from window_RenderFrame only when OPTIMUM_PARITY_DUMP is set; dumps + /// every attachment of every framebuffer slot once, on in-world frame @@ -1081,17 +1461,42 @@ index 6edf0c9..98c07a1 100644 + } + + public virtual List SetupDefaultFrameBuffers() - { - //IL_001b: Unknown result type (might be due to invalid IL or missing references) - //IL_0072: Unknown result type (might be due to invalid IL or missing references) - //IL_008c: Unknown result type (might be due to invalid IL or missing references) - //IL_00ad: Unknown result type (might be due to invalid IL or missing references) -@@ -1187,10 +2047,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); - if (num == 0 || num2 == 0) - { - return list; - } ++ { ++ //IL_001b: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0072: Unknown result type (might be due to invalid IL or missing references) ++ //IL_008c: Unknown result type (might be due to invalid IL or missing references) ++ //IL_00ad: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0211: Unknown result type (might be due to invalid IL or missing references) ++ //IL_02af: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0358: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0576: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0665: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0b13: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0b88: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0bfe: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0c62: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0ccf: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0d44: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0db2: Unknown result type (might be due to invalid IL or missing references) ++ //IL_0a8c: Unknown result type (might be due to invalid IL or missing references) ++ SetupSSAO = ClientSettings.SSAOQuality > 0; ++ if (ClientSettings.IsNewSettingsFile && ((NativeWindow)window).ClientSize.X > 1920) ++ { ++ ClientSettings.SSAA = 0.5f; ++ } ++ List list = new List(31); ++ for (int i = 0; i <= 24; i++) ++ { ++ list.Add(null); ++ } ++ ShadowMapQuality = ClientSettings.ShadowMapQuality; ++ ssaaLevel = ClientSettings.SSAA; ++ int num = (int)((float)((NativeWindow)window).ClientSize.X * ssaaLevel); ++ int num2 = (int)((float)((NativeWindow)window).ClientSize.Y * ssaaLevel); ++ if (num == 0 || num2 == 0) ++ { ++ return list; ++ } + // Optimum: TAA. Read once per (re-)build; a mid-session config change + // only takes effect on the next RebuildFrameBuffers. optimumTaaDisabled + // is checked as well as EffectiveTaa: DisableOptimumTaa sets both, and @@ -1099,34 +1504,74 @@ index 6edf0c9..98c07a1 100644 + // even if the process-wide config flag is ever reset. + bool taaRequested = !optimumTaaDisabled && Vintagestory.API.Config.OptimumConfig.EffectiveTaa; + int motionAttachmentIndex = -1; - PixelFormat val = (PixelFormat)6408; - CheckGlError("sdfb-begin"); - FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef - { - FboId = GL.GenFramebuffer(), -@@ -1210,11 +2077,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); - GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, frameBufferRef3.DepthTextureId, 0); - GL.DepthFunc((DepthFunction)513); -- frameBufferRef3.ColorTextureIds = ArrayUtil.CreateFilled(SetupSSAO ? 4 : 2, (int n) => GL.GenTexture()); ++ PixelFormat val = (PixelFormat)6408; ++ CheckGlError("sdfb-begin"); ++ FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef ++ { ++ FboId = GL.GenFramebuffer(), ++ Width = num, ++ Height = num2 ++ }); ++ FrameBufferRef frameBufferRef3 = frameBufferRef; ++ frameBufferRef3.DepthTextureId = GL.GenTexture(); ++ if (frameBufferRef3.FboId == 0) ++ { ++ base.XPlatInterface.ShowMessageBox("Fatal error", "Unable to generate a new framebuffer. This shouldn't happen, ever. Maybe a restart resolves the problem?"); ++ } ++ CurrentFrameBufferKeepVw = frameBufferRef3; ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.DepthTextureId); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33191, num, num2, 0, (PixelFormat)6402, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9728); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33071); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33071); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, frameBufferRef3.DepthTextureId, 0); ++ GL.DepthFunc((DepthFunction)513); + frameBufferRef3.ColorTextureIds = new int[SetupSSAO ? 4 : 2]; + for (int j = 0; j < frameBufferRef3.ColorTextureIds.Length; j++) + { + frameBufferRef3.ColorTextureIds[j] = GL.GenTexture(); + } - GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); - GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); -@@ -1251,10 +2122,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - (DrawBuffersEnum)36064, - (DrawBuffersEnum)36065 - }; - GL.DrawBuffers(2, array3); - } ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[1]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, (ssaaLevel <= 1f) ? 9728 : 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, (ssaaLevel <= 1f) ? 9728 : 9729); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); ++ if (SetupSSAO) ++ { ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[3]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); ++ DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; ++ GL.DrawBuffers(4, array2); ++ } ++ else ++ { ++ DrawBuffersEnum[] array3 = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] ++ { ++ (DrawBuffersEnum)36064, ++ (DrawBuffersEnum)36065 ++ }; ++ GL.DrawBuffers(2, array3); ++ } + if (taaRequested) + { + // Optimum: TAA motion attachment, appended after the SSAO G-buffer @@ -1155,12 +1600,51 @@ index 6edf0c9..98c07a1 100644 + } + } + optimumMotionAttachmentIndex = motionAttachmentIndex; - CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); - frameBufferRef = (list[1] = new FrameBufferRef - { - FboId = GL.GenFramebuffer(), - Width = num, -@@ -1436,10 +2335,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ++ CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Primary); ++ frameBufferRef = (list[1] = new FrameBufferRef ++ { ++ FboId = GL.GenFramebuffer(), ++ Width = num, ++ Height = num2 ++ }); ++ frameBufferRef3 = frameBufferRef; ++ frameBufferRef3.ColorTextureIds = new int[3] ++ { ++ GL.GenTexture(), ++ GL.GenTexture(), ++ GL.GenTexture() ++ }; ++ CurrentFrameBufferKeepVw = frameBufferRef3; ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[0]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5123, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[0], 0); ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[1]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)33325, num, num2, 0, (PixelFormat)6403, (PixelType)5123, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); ++ GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); ++ GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); ++ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); ++ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); ++ DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; ++ GL.DrawBuffers(3, array5); ++ ClearFrameBuffer(EnumFrameBuffer.Transparent); ++ CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); ++ if (SetupSSAO) ++ { ++ _ = ClientSettings.SSAOQuality; ++ float num3 = 0.5f; + FrameBufferRef obj = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), + Width = (int)((float)num * num3), + Height = (int)((float)num2 * num3) +@@ -1436,10 +2569,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1236,7 +1720,7 @@ index 6edf0c9..98c07a1 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2512,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2746,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1251,7 +1735,7 @@ index 6edf0c9..98c07a1 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2535,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2769,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1346,7 +1830,7 @@ index 6edf0c9..98c07a1 100644 } } } -@@ -1591,11 +2629,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2863,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1365,7 +1849,7 @@ index 6edf0c9..98c07a1 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2664,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2898,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1452,7 +1936,7 @@ index 6edf0c9..98c07a1 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +2773,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3007,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -1538,7 +2022,7 @@ index 6edf0c9..98c07a1 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +2853,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3087,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -1624,7 +2108,7 @@ index 6edf0c9..98c07a1 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +2935,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3169,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -1885,7 +2369,7 @@ index 6edf0c9..98c07a1 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3199,95 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3433,95 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -1985,7 +2469,7 @@ index 6edf0c9..98c07a1 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3299,108 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3533,108 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2141,7 +2625,7 @@ index 6edf0c9..98c07a1 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3410,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3644,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2176,7 +2660,7 @@ index 6edf0c9..98c07a1 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3449,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3683,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2233,7 +2717,7 @@ index 6edf0c9..98c07a1 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3504,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3738,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -2719,7 +3203,7 @@ index 6edf0c9..98c07a1 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4140,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4374,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -2759,7 +3243,7 @@ index 6edf0c9..98c07a1 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4538,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4772,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -2812,7 +3296,7 @@ index 6edf0c9..98c07a1 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4633,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4867,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -2857,7 +3341,7 @@ index 6edf0c9..98c07a1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4670,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +4904,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2878,7 +3362,7 @@ index 6edf0c9..98c07a1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4689,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +4923,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2899,7 +3383,7 @@ index 6edf0c9..98c07a1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4708,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +4942,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2920,7 +3404,7 @@ index 6edf0c9..98c07a1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4727,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +4961,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -2941,7 +3425,7 @@ index 6edf0c9..98c07a1 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4750,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +4984,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -2962,7 +3446,7 @@ index 6edf0c9..98c07a1 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5312,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5546,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -2986,7 +3470,7 @@ index 6edf0c9..98c07a1 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +5671,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +5905,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch b/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch index 7cf281f1..08ae047e 100644 --- a/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client/ClientProgram.cs b/VintagestoryLib/Vintagestory.Client/ClientProgram.cs -index afa4d5f..e5a9ad6 100644 +index afa4d5f..b05b230 100644 --- a/VintagestoryLib/Vintagestory.Client/ClientProgram.cs +++ b/VintagestoryLib/Vintagestory.Client/ClientProgram.cs @@ -46,10 +46,45 @@ public class ClientProgram @@ -146,7 +146,7 @@ index afa4d5f..e5a9ad6 100644 3 => 3, 2 => 2, 1 => 3, -@@ -295,37 +368,89 @@ public class ClientProgram +@@ -295,37 +368,101 @@ public class ClientProgram }; if (RuntimeEnv.OS == OS.Mac) { @@ -158,6 +158,18 @@ index afa4d5f..e5a9ad6 100644 + { + val2.API = ContextAPI.NoAPI; + OptimumRender.NoGraphicsApiWindow = true; ++ } ++ // Optimum (headless render harness): OPTIMUM_HEADLESS asks for the real ++ // client and the real renderer with the window never mapped. It is still a ++ // real window with a real surface and swapchain - there is no surfaceless ++ // GL path in this client, so this is the only offscreen mode that is ++ // symmetric across both backends - it simply never appears and never takes ++ // focus, which also puts the run under the existing background FPS cap. ++ if (Vintagestory.API.Config.OptimumHeadless.Enabled) ++ { ++ val2.StartVisible = false; ++ val2.StartFocused = false; ++ Console.WriteLine("[Optimum] headless: the window stays hidden"); + } GLFW.SetErrorCallback(new ErrorCallback(GlfwErrorCallback)); GameWindowNative gameWindowNative = AttemptToOpenWindow(gameWindowSettings, val2, num3, num4, 3); @@ -253,7 +265,7 @@ index afa4d5f..e5a9ad6 100644 { ((GameWindow)gameWindowNative).Run(); } -@@ -337,14 +462,55 @@ public class ClientProgram +@@ -337,14 +474,55 @@ public class ClientProgram } Thread.CurrentThread.Priority = ThreadPriority.Normal; ScreenManager.Platform.Logger.Debug("After gamewindow.Run()"); diff --git a/scripts/dev/headless-capture.sh b/scripts/dev/headless-capture.sh new file mode 100755 index 00000000..8bb2ab4c --- /dev/null +++ b/scripts/dev/headless-capture.sh @@ -0,0 +1,317 @@ +#!/bin/bash +# Headless frame capture: the real client and the real renderer, no visible +# window, frames on disk. +# +# Launches the deployed client on one renderer with OPTIMUM_HEADLESS=1 (the +# window is created with StartVisible/StartFocused false - a real window with a +# real surface and swapchain, never mapped and never focused), waits for the +# world, requires the matching renderer line in the log, waits for the client's +# "[Optimum] headless: frames -> " line, and then waits for the client to +# close itself: OPTIMUM_HEADLESS_EXIT_WHEN_DONE makes it call WindowExit from the +# render thread once the capture and the parity dump are written. That is the only +# clean close a never-mapped window has - no window manager can send a close event +# to it, so the fallback is SIGTERM, whose handler closes the window from a signal +# thread while the render thread is still inside a frame and ends the run in a crash +# report. scripts/dev/kill-client.sh stays as the fallback if it does not go. +# +# Frames are written by the client itself through ReadDefaultFramebuffer - the +# same polymorphic call the in-game screenshot makes, a device-side readback on +# Vulkan - so nothing here touches the desktop and no compositor is involved. +# Each selected in-world frame lands as frame-NNNNNN.ppm, which is what +# scripts/dev/ssim.py reads; two captures of the same frame list pair by name: +# scripts/dev/ssim.py +# +# The scene and the camera come from a chat-command script (--commands), fed to +# the client on an in-world frame exactly as if a human had typed it: a line +# starting with "." runs locally (".cam load ", ".cam play " - +# vanilla's own keyframed camera), anything else goes to the server ("/time set", +# "/weather", "/gamemode"). Author a path once in game with ".cam p" at each +# point and ".cam save" (which puts the point string on the clipboard), then keep +# it in the script file. --fixed-dt pins ClientMain.DeltaTimeLimiter, so every +# simulated frame advances by the same amount however long it really took; that +# makes a sequence repeatable, not bit-exact (chunk streaming, particle and mob +# RNG are not pinned by it). +# +# The run stays out of the way on its own: a permanently unfocused window falls +# under the client's existing background FPS cap (30 FPS), so a capture of N +# frames takes at least N/30 seconds and does not take the machine. +# +# Requires a display (real, nested or Xvfb): GLFW asks for the screen size before +# any window exists and Vulkan needs a WSI surface. "Headless" here means no +# visible window, not no display server. +# +# Usage: +# scripts/dev/headless-capture.sh --renderer vulkan|opengl --world \ +# --out [--commands ] [--frames |--count ] +# Options: +# --renderer required; written into optimum.json "Renderer" for the +# run and the previous value restored on exit +# --world required; bare save name (not the .vcdbs file name) +# --out required; created if missing, must hold no frames +# --commands chat-command script; dispatched once, on --command-frame +# --command-frame in-world frame the script runs on (default 30) +# --frames explicit in-world frames to write +# --count or: this many frames (default 60 when --frames is unset) +# --stride with --count: every s-th frame (default 1) +# --first with --count: the first frame (default: the frame after +# the command script has run) +# --fixed-dt pin the simulated frame step (default 0.0166667; 0 = off) +# --parity-dump also write the per-attachment dump of --parity-frame, +# which is what scripts/dev/taa-rejection.py reads +# --parity-frame in-world frame for that dump (default: the first captured frame) +# --wait seconds to wait for the world (default 180) +# --capture-wait seconds to wait for the frames after it (default 300) +# +# Exit: 0 frames written on the requested renderer, 1 failure, 2 usage. +# This script never pattern-kills anything: closing goes +# through scripts/dev/kill-client.sh, which is the only place that owns that pattern. +set -euo pipefail + +RENDERER_ARG="" +WORLD="" +OUT_DIR="" +COMMANDS="" +COMMAND_FRAME=30 +FRAME_LIST="" +COUNT="" +STRIDE=1 +FIRST="" +FIXED_DT="0.0166667" +PARITY_DUMP=0 +PARITY_FRAME="" +WAIT_FOR_WORLD=180 +WAIT_FOR_CAPTURE=300 + +while [[ $# -gt 0 ]]; do + case "$1" in + --renderer) RENDERER_ARG="${2:-}"; shift 2;; + --world) WORLD="${2:-}"; shift 2;; + --out) OUT_DIR="${2:-}"; shift 2;; + --commands) COMMANDS="${2:-}"; shift 2;; + --command-frame) COMMAND_FRAME="${2:-}"; shift 2;; + --frames) FRAME_LIST="${2:-}"; shift 2;; + --count) COUNT="${2:-}"; shift 2;; + --stride) STRIDE="${2:-}"; shift 2;; + --first) FIRST="${2:-}"; shift 2;; + --fixed-dt) FIXED_DT="${2:-}"; shift 2;; + --parity-dump) PARITY_DUMP=1; shift;; + --parity-frame) PARITY_FRAME="${2:-}"; shift 2;; + --wait) WAIT_FOR_WORLD="${2:-}"; shift 2;; + --capture-wait) WAIT_FOR_CAPTURE="${2:-}"; shift 2;; + -h|--help) sed -n '2,62p' "${BASH_SOURCE[0]}"; exit 0;; + *) echo "unknown argument: $1" >&2; exit 2;; + esac +done + +case "$RENDERER_ARG" in + vulkan) EXPECTED_LINE="[Optimum] Vulkan renderer";; + opengl) EXPECTED_LINE="[Optimum] OpenGL renderer:";; + *) echo "--renderer vulkan|opengl is required (got '${RENDERER_ARG}')" >&2; exit 2;; +esac +if [[ -z "$WORLD" ]]; then echo "--world is required" >&2; exit 2; fi +if [[ -z "$OUT_DIR" ]]; then echo "--out is required" >&2; exit 2; fi +if [[ -n "$COMMANDS" && ! -f "$COMMANDS" ]]; then echo "no such command script: $COMMANDS" >&2; exit 2; fi +if [[ -n "$FRAME_LIST" && -n "$COUNT" ]]; then echo "--frames and --count are exclusive" >&2; exit 2; fi +if [[ -n "$FRAME_LIST" ]] && ! [[ "$FRAME_LIST" =~ ^[0-9]+([,[:space:]]+[0-9]+)*$ ]]; then + echo "--frames takes non-negative integers separated by commas (got '${FRAME_LIST}')" >&2; exit 2 +fi +if [[ -z "$FRAME_LIST" && -z "$COUNT" ]]; then COUNT=60; fi +for pair in "COUNT:$COUNT" "STRIDE:$STRIDE" "COMMAND_FRAME:$COMMAND_FRAME" "FIRST:$FIRST" \ + "PARITY_FRAME:$PARITY_FRAME" "WAIT_FOR_WORLD:$WAIT_FOR_WORLD" "WAIT_FOR_CAPTURE:$WAIT_FOR_CAPTURE"; do + value="${pair#*:}" + if [[ -n "$value" ]] && ! [[ "$value" =~ ^[0-9]+$ ]]; then + echo "${pair%%:*} takes a non-negative integer (got '${value}')" >&2; exit 2 + fi +done +if ! [[ "$FIXED_DT" =~ ^[0-9]*\.?[0-9]+$ ]]; then + echo "--fixed-dt takes seconds, e.g. 0.0166667 (got '${FIXED_DT}')" >&2; exit 2 +fi + +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +DATA_PATH="${DATA_PATH:-$HOME/.config/OptimumVintagestoryData}" +CONFIG="$DATA_PATH/ModConfig/optimum.json" + +mkdir -p "$OUT_DIR" || exit 1 +OUT_DIR="$(cd -- "$OUT_DIR" && pwd)" || exit 1 # the client requires an absolute path +if compgen -G "$OUT_DIR/frame-*.ppm" >/dev/null || compgen -G "$OUT_DIR/*.p[pgf]m" >/dev/null; then + echo "$OUT_DIR already holds frames; use an empty directory so stale files cannot pair" >&2 + exit 1 +fi +LOG="$OUT_DIR/client.log" +rm -f "$LOG" + +# The first frame the capture writes, needed here only to default --parity-frame +# to something inside the captured range. +if [[ -n "$FRAME_LIST" ]]; then + FIRST_CAPTURED="${FRAME_LIST%%,*}" + FIRST_CAPTURED="${FIRST_CAPTURED//[[:space:]]/}" +elif [[ -n "$FIRST" ]]; then + FIRST_CAPTURED="$FIRST" +elif [[ -n "$COMMANDS" ]]; then + FIRST_CAPTURED=$((COMMAND_FRAME + 1)) +else + FIRST_CAPTURED=0 +fi +if [[ -z "$PARITY_FRAME" ]]; then PARITY_FRAME="$FIRST_CAPTURED"; fi + +# 1. Renderer for this run, restored on exit whatever happens next. +SAVED_RENDERER="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1])).get("Renderer")))' "$CONFIG")" || { + echo "cannot read $CONFIG; not launching" >&2; exit 1; } +# Rewrites Renderer in the live config without ever leaving it truncated: the new +# file is written beside it and renamed over it, which is atomic on the same +# filesystem. Used for both the set and the restore in cleanup(). +set_renderer() { + python3 -c 'import json,os,sys,tempfile +path, value = sys.argv[1], json.loads(sys.argv[2]) +with open(path) as handle: + data = json.load(handle) +if value is None: + data.pop("Renderer", None) +else: + data["Renderer"] = value +directory = os.path.dirname(os.path.abspath(path)) +fd, tmp = tempfile.mkstemp(dir=directory, prefix=".optimum.json.") +try: + with os.fdopen(fd, "w") as handle: + json.dump(data, handle, indent=2) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) +except BaseException: + os.unlink(tmp) + raise' "$CONFIG" "$1" +} +client_alive() { + # No -q: grep reads all of ps's output, so pipefail never sees ps die of SIGPIPE. + ps -eo cmd | grep "dotnet [V]intagestory.dll" >/dev/null +} + +wait_for_exit() { + local deadline=$((SECONDS + $1)) + while client_alive; do + if (( SECONDS >= deadline )); then return 1; fi + sleep 1 + done + return 0 +} + +LAUNCHED=0 +CLOSED=0 +cleanup() { + if (( LAUNCHED == 1 && CLOSED == 0 )); then + bash "$REPO/scripts/dev/kill-client.sh" >/dev/null 2>&1 || true + CLOSED=1 + fi + if (( LAUNCHED == 1 )); then + wait_for_exit 60 || echo "the client is still running after 60 s; restoring Renderer anyway" >&2 + fi + set_renderer "$SAVED_RENDERER" || echo "failed to restore Renderer=$SAVED_RENDERER in $CONFIG" >&2 +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +set_renderer "\"$RENDERER_ARG\"" || { echo "failed to set Renderer=$RENDERER_ARG in $CONFIG; not launching" >&2; exit 1; } +echo "config: Renderer=$RENDERER_ARG (was $SAVED_RENDERER)" + +# 2. Launch headless. RENDERER is unset so run-client.sh does not rewrite the +# config a second time. +export OPTIMUM_HEADLESS=1 +export OPTIMUM_HEADLESS_FRAMES="$OUT_DIR" +export OPTIMUM_HEADLESS_COMMAND_FRAME="$COMMAND_FRAME" +if [[ -n "$COMMANDS" ]]; then + export OPTIMUM_HEADLESS_COMMANDS="$(cd -- "$(dirname -- "$COMMANDS")" && pwd)/$(basename -- "$COMMANDS")" +fi +if [[ -n "$FRAME_LIST" ]]; then + export OPTIMUM_HEADLESS_FRAME_LIST="${FRAME_LIST// /}" +else + export OPTIMUM_HEADLESS_FRAME_COUNT="$COUNT" + export OPTIMUM_HEADLESS_FRAME_STRIDE="$STRIDE" + if [[ -n "$FIRST" ]]; then export OPTIMUM_HEADLESS_FIRST_FRAME="$FIRST"; fi +fi +if [[ "$FIXED_DT" != "0" ]]; then export OPTIMUM_HEADLESS_FIXED_DT="$FIXED_DT"; fi +# The client closes itself from the render thread once the capture and the dump +# are written. Without this the only way to stop an unmapped window is SIGTERM, +# whose handler closes the window from a signal thread while the render thread is +# mid-frame, and every run ends in a crash report that nobody can tell from a real +# one. kill-client.sh stays as the fallback in cleanup(). +export OPTIMUM_HEADLESS_EXIT_WHEN_DONE=1 +if (( PARITY_DUMP == 1 )); then + export OPTIMUM_PARITY_DUMP="$OUT_DIR" + export OPTIMUM_PARITY_FRAME="$PARITY_FRAME" +fi + +LAUNCHED=1 +env -u RENDERER CLIENT_LOG="$LOG" bash "$REPO/scripts/dev/run-client.sh" "$WORLD" || exit 1 + +# wait_for : polls the log, fails early when the client exits. +wait_for() { + local needle="$1" deadline=$((SECONDS + $2)) + while (( SECONDS < deadline )); do + if grep -qF -- "$needle" "$LOG" 2>/dev/null; then return 0; fi + if ! client_alive; then + grep -qF -- "$needle" "$LOG" 2>/dev/null && return 0 + echo "the client exited before '$needle' appeared; see $LOG" >&2 + return 1 + fi + sleep 2 + done + echo "no '$needle' line within $2 s; see $LOG" >&2 + return 1 +} + +# 3. The world, then the renderer. A launch is not a verification (rule 1). +wait_for "[Client Chat] Welcome" "$WAIT_FOR_WORLD" || exit 1 + +RENDERER_LINE="$(grep -m1 -E "\[Optimum\] (Vulkan renderer|OpenGL renderer:)" "$LOG" || true)" +if [[ -z "$RENDERER_LINE" ]]; then + echo "no '[Optimum] renderer' line in $LOG; refusing to report a capture" >&2 + exit 1 +fi +if [[ "$RENDERER_LINE" != *"$EXPECTED_LINE"* ]]; then + echo "asked for $RENDERER_ARG but the log says: $RENDERER_LINE (silent fallback); refusing to report a capture" >&2 + exit 1 +fi + +# 4. The frames. +wait_for "[Optimum] headless: " "$WAIT_FOR_CAPTURE" || exit 1 +wait_for " frames -> " "$WAIT_FOR_CAPTURE" || exit 1 +CAPTURE_LINE="$(grep -m1 -F " frames -> " "$LOG" || true)" +COMMAND_LINE="$(grep -m1 -F " commands dispatched" "$LOG" || true)" + +# 5. Close the client before reporting: never leave the game running (rule 5). +# OPTIMUM_HEADLESS_EXIT_WHEN_DONE means the client is already closing itself +# cleanly from the render thread, so wait for that first and only signal it if +# it does not go. Signalling a client that is already tearing down is what put +# a crash report at the end of every headless run. +if wait_for_exit 60; then + CLOSED=1 + CLOSE_HOW="closed itself" +else + echo "the client did not close itself within 60 s; signalling it" >&2 + bash "$REPO/scripts/dev/kill-client.sh" + CLOSED=1 + CLOSE_HOW="signalled" +fi + +FILES=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'frame-*.ppm' | wc -l) +echo "" +echo "renderer $RENDERER_LINE" +if [[ -n "$COMMAND_LINE" ]]; then echo "commands $COMMAND_LINE"; fi +echo "capture $CAPTURE_LINE" +echo "frames $FILES in $OUT_DIR" +echo "log $LOG" +echo "shutdown $CLOSE_HOW" +# A crash report in the log is the one thing that makes a capture untrustworthy +# without looking at it: report it here rather than letting it sit in the log. +CRASHES=$(grep -c "Critical error occurred" "$LOG" || true) +if (( CRASHES > 0 )); then + echo "crashes $CRASHES critical error(s) in the log - the frames may still be fine, the shutdown was not" >&2 +fi +if (( FILES == 0 )); then + echo "the capture line appeared but no frames were written" >&2 + exit 1 +fi +echo "compare scripts/dev/ssim.py $OUT_DIR" +if (( PARITY_DUMP == 1 )); then + echo "rejection scripts/dev/taa-rejection.py $OUT_DIR" +fi diff --git a/sources/VintagestoryApi/Client/optimum-render-device.cs b/sources/VintagestoryApi/Client/optimum-render-device.cs index fac7bd97..d96e190a 100644 --- a/sources/VintagestoryApi/Client/optimum-render-device.cs +++ b/sources/VintagestoryApi/Client/optimum-render-device.cs @@ -180,7 +180,35 @@ public static int Write(string directory, int slotIndex, string slotName, string return 2; } - private static void WriteNetpbm(string path, int width, int height, byte[] rgba, int firstChannel, int channels) + /// + /// Writes one whole frame as a binary PPM (P6) - the encoding + /// scripts/dev/ssim.py reads, so two captures pair by file name. + /// + /// is four bytes per texel in GL row order + /// (bottom-up, the first row in the file is GL row 0), exactly as + /// ReadDefaultFramebuffer hands it back. says + /// which order those four are in: the OpenGL path reads GL_BGRA, the + /// Vulkan device's default colour target is R8G8B8A8_UNORM and its + /// readback hands the texels back untouched, so the two backends differ here + /// and the written file must not. + /// + /// Returns false when the arguments do not describe a frame; it never throws + /// for that reason alone. + /// + public static bool WriteFrame(string path, int width, int height, byte[] pixels, bool bgra) + { + if (path == null || pixels == null || width <= 0 || height <= 0) return false; + if (pixels.LongLength < (long)width * height * 4) return false; + string directory = System.IO.Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) System.IO.Directory.CreateDirectory(directory); + // BGRA: start at B's neighbour R (index 2) and walk backwards, so the file + // gets R, G, B either way. + WriteNetpbm(path, width, height, pixels, bgra ? 2 : 0, 3, bgra ? -1 : 1); + return true; + } + + private static void WriteNetpbm(string path, int width, int height, byte[] rgba, int firstChannel, int channels, + int step = 1) { using var file = new System.IO.FileStream(path, System.IO.FileMode.Create, System.IO.FileAccess.Write); byte[] header = System.Text.Encoding.ASCII.GetBytes( @@ -194,7 +222,7 @@ private static void WriteNetpbm(string path, int width, int height, byte[] rgba, { for (int c = 0; c < channels; c++) { - row[x * channels + c] = rgba[source + x * 4 + firstChannel + c]; + row[x * channels + c] = rgba[source + x * 4 + firstChannel + c * step]; } } file.Write(row, 0, row.Length); @@ -225,6 +253,280 @@ private static void WritePfm(string path, int width, int height, float[] data, i } } +/// +/// The headless render harness: the real client and the real renderer, no visible +/// window, frames on disk. +/// +/// Every knob is an environment variable, read once, so a run that sets none of +/// them pays one static bool check per frame and behaves exactly as before. The +/// frame loop is not changed by any of this - the harness only makes the window +/// invisible, types the chat commands a human would have typed, and reads the +/// presented image back on the frames it was asked for. +/// +/// +/// OPTIMUM_HEADLESS=1 - the window is created with +/// StartVisible=false and StartFocused=false. It is still a real +/// window with a real surface and a real swapchain (there is no surfaceless GL +/// path in this client, so this is the only offscreen mode that is symmetric +/// across both backends), it is simply never mapped. Because it is also never +/// focused, the existing background FPS limiter caps the run at 30 FPS, which is +/// what keeps it usable while the machine is in use. +/// OPTIMUM_HEADLESS_COMMANDS=<file> - a newline-separated list of +/// chat commands, dispatched once, on in-world frame +/// OPTIMUM_HEADLESS_COMMAND_FRAME (default 30). Blank lines and lines +/// starting with # are ignored. A line starting with the client command +/// prefix (.) runs locally - that is how the scripted camera +/// (.cam load <points>, .cam play <seconds>) is driven - +/// and anything else is sent to the server as chat, which is how the fixed scene +/// (/time set, /weather, /gamemode) is set. +/// OPTIMUM_HEADLESS_FIXED_DT=<seconds> - pins +/// ClientMain.DeltaTimeLimiter, the field vanilla's own cinematic recorder +/// sets, so every simulated frame advances by the same amount regardless of how +/// long it actually took. This is what makes a sequence repeatable; it is not +/// bit-exact (chunk streaming, particle and mob RNG are not pinned by it). +/// OPTIMUM_HEADLESS_FRAMES=<abs dir> plus either +/// OPTIMUM_HEADLESS_FRAME_LIST=0,30,60 (an explicit list of in-world frame +/// indices) or OPTIMUM_HEADLESS_FRAME_COUNT=<n> with +/// OPTIMUM_HEADLESS_FRAME_STRIDE=<s> (default 1) and +/// OPTIMUM_HEADLESS_FIRST_FRAME=<f> (default: the frame after the +/// command script runs) - a cadence. Each selected frame is written as +/// frame-NNNNNN.ppm, so two captures of the same list pair by name under +/// scripts/dev/ssim.py. Negative or unparsable numbers fall back to the +/// default and a count above is clamped +/// to it. +/// +/// +/// What this does not cover: an X server (real, nested or Xvfb) still has to be +/// there - GLFW queries the screen size before any window exists and Vulkan needs +/// a WSI surface - and the per-attachment dump that +/// scripts/dev/taa-rejection.py reads is still OPTIMUM_PARITY_DUMP, +/// which composes with this rather than being replaced by it. +/// +public static class OptimumHeadless +{ + /// True when OPTIMUM_HEADLESS asks for an invisible window. + public static readonly bool Enabled = ResolveFlag("OPTIMUM_HEADLESS"); + + /// The chat-command script, or null when there is none. + public static readonly string CommandScriptPath = ResolveExistingFile("OPTIMUM_HEADLESS_COMMANDS"); + + /// The in-world frame the command script is dispatched on, counted from 0. + public static readonly long CommandFrame = ResolveLong("OPTIMUM_HEADLESS_COMMAND_FRAME", 30L); + + /// Seconds per simulated frame, or 0 when the wall clock keeps driving it. + public static readonly float FixedDeltaTime = ResolveFixedDeltaTime(); + + /// The absolute directory frames are written to, or null when no frames are wanted. + public static readonly string FrameDirectory = ResolveDirectory("OPTIMUM_HEADLESS_FRAMES"); + + /// The in-world frames to write, ascending and without duplicates. Never null. + public static readonly long[] Frames = ResolveFrames(); + + /// True when frames will be written. + public static readonly bool CaptureEnabled = FrameDirectory != null && Frames.Length > 0; + + /// + /// True when OPTIMUM_HEADLESS_EXIT_WHEN_DONE asks the client to close + /// itself once the capture (and the parity dump, if one was asked for) has + /// finished, instead of waiting to be signalled. + /// + /// Why this exists. A headless window is never mapped, so the + /// clean close a human gets - the window manager's close event - cannot be + /// delivered to it: scripts/dev/kill-client.sh finds nothing to send + /// alt+F4 to and falls through to SIGTERM. SIGTERM lands on a signal-handler + /// thread, which calls WindowExit - and therefore Close() - from + /// off the render thread, while that thread is still inside a frame. The + /// result is a shutdown race that writes a crash report on every run + /// (2026-09-12: ShaderProgramBase.Use dereferencing a program whose + /// graphics were already torn down), which is exactly the noise that makes a + /// harness useless as evidence - nobody can tell that crash from a real one. + /// + /// Closing from here instead is the path the main menu's quit button + /// already takes: WindowExit on the render thread, between frames, with + /// the game loop agreeing to stop rather than being interrupted. + /// + public static readonly bool ExitWhenDone = ResolveFlag("OPTIMUM_HEADLESS_EXIT_WHEN_DONE"); + + /// + /// True when the client has to do anything at all per frame for the harness. + /// The single test the render loop makes. + /// + public static readonly bool Active = Enabled || CaptureEnabled || CommandScriptPath != null + || FixedDeltaTime > 0f; + + private static bool ResolveFlag(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) return false; + value = value.Trim(); + return value != "0" && !value.Equals("false", StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveExistingFile(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) return null; + string full = System.IO.Path.GetFullPath(value); + return System.IO.File.Exists(full) ? full : null; + } + + private static string ResolveDirectory(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value) || !System.IO.Path.IsPathRooted(value)) return null; + return System.IO.Path.GetFullPath(value); + } + + private static long ResolveLong(string name, long fallback) + { + string value = Environment.GetEnvironmentVariable(name); + return long.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out long parsed) && parsed >= 0 ? parsed : fallback; + } + + private static float ResolveFixedDeltaTime() + { + string value = Environment.GetEnvironmentVariable("OPTIMUM_HEADLESS_FIXED_DT"); + if (!float.TryParse(value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float seconds)) return 0f; + // A negative or absurd step would make the simulation meaningless rather + // than reproducible; one second per frame is already far past useful. + return seconds > 0f && seconds <= 1f ? seconds : 0f; + } + + private static long[] ResolveFrames() + { + string list = Environment.GetEnvironmentVariable("OPTIMUM_HEADLESS_FRAME_LIST"); + if (!string.IsNullOrWhiteSpace(list)) return ParseFrameList(list); + + long count = ResolveLong("OPTIMUM_HEADLESS_FRAME_COUNT", 0L); + if (count <= 0L) return new long[0]; + // Default: the first frame after the command script has run, so a camera + // started by the script is already moving on frame one of the capture. + return PlanFrames( + ResolveLong("OPTIMUM_HEADLESS_FIRST_FRAME", CommandScriptPath != null ? CommandFrame + 1L : 0L), + count, + ResolveLong("OPTIMUM_HEADLESS_FRAME_STRIDE", 1L)); + } + + /// + /// The most frames one capture may plan. Past this the number is a typo or a + /// stray environment variable, not a request: the array alone would be + /// gigabytes, and it is allocated in a static initialiser, so failing it takes + /// the client down with a TypeInitializationException instead of a bad capture. + /// Clamping caps first and stride too, which keeps + /// first + i * stride far away from overflowing. + /// + public const long MaxFrames = 100000L; + + /// A cadence: frames from , every . + public static long[] PlanFrames(long first, long count, long stride) + { + if (count <= 0L) return new long[0]; + if (count > MaxFrames) count = MaxFrames; + if (stride <= 0L) stride = 1L; + if (stride > MaxFrames) stride = MaxFrames; + if (first < 0L) first = 0L; + if (first > MaxFrames * MaxFrames) first = MaxFrames * MaxFrames; + long[] frames = new long[count]; + for (long i = 0; i < count; i++) frames[i] = first + i * stride; + return frames; + } + + /// + /// An explicit frame list: comma, space or semicolon separated, negatives and + /// unparsable entries dropped, ascending and without duplicates. + /// + public static long[] ParseFrameList(string list) + { + if (string.IsNullOrWhiteSpace(list)) return new long[0]; + string[] parts = list.Split(new char[] { ',', ' ', ';' }, StringSplitOptions.RemoveEmptyEntries); + var frames = new System.Collections.Generic.List(parts.Length); + for (int i = 0; i < parts.Length; i++) + { + if (long.TryParse(parts[i].Trim(), System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out long frame) && frame >= 0 + && !frames.Contains(frame)) + { + frames.Add(frame); + } + } + frames.Sort(); + return frames.ToArray(); + } + + /// True when this in-world frame is one of the frames to write. + public static bool ShouldCapture(long worldFrame) => ShouldCapture(Frames, worldFrame); + + /// True when this in-world frame is one of (ascending). + public static bool ShouldCapture(long[] frames, long worldFrame) + { + if (frames == null) return false; + for (int i = 0; i < frames.Length; i++) + { + if (frames[i] == worldFrame) return true; + if (frames[i] > worldFrame) return false; + } + return false; + } + + /// True once this in-world frame is at or past the last frame to write. + public static bool CaptureFinished(long worldFrame) => CaptureFinished(Frames, worldFrame); + + /// True once this in-world frame is at or past the last of . + public static bool CaptureFinished(long[] frames, long worldFrame) + { + return frames == null || frames.Length == 0 || worldFrame >= frames[frames.Length - 1]; + } + + /// The one file name both backends write, so two captures pair by name. + public static string FrameFileName(long worldFrame) + { + return "frame-" + worldFrame.ToString("D6", System.Globalization.CultureInfo.InvariantCulture) + ".ppm"; + } + + /// + /// The command script's lines, blank lines and # comments removed. + /// Empty when there is no script or it cannot be read - a capture that loses + /// its scene is worth a warning, not a crashed client. + /// + public static string[] ReadCommands() => ReadCommands(CommandScriptPath); + + /// The command lines of one script file; empty when it cannot be read. + public static string[] ReadCommands(string path) + { + if (path == null) return new string[0]; + string[] lines; + try + { + lines = System.IO.File.ReadAllLines(path); + } + catch (Exception) + { + return new string[0]; + } + var commands = new System.Collections.Generic.List(lines.Length); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0 || line[0] == '#') continue; + commands.Add(line); + } + return commands.ToArray(); + } + + /// + /// Writes one presented frame into . Returns + /// false when nothing was written. + /// + public static bool WriteFrame(long worldFrame, int width, int height, byte[] pixels, bool bgra) + { + if (FrameDirectory == null) return false; + return OptimumParityDump.WriteFrame( + System.IO.Path.Combine(FrameDirectory, FrameFileName(worldFrame)), width, height, pixels, bgra); + } +} + /// /// The OpenGL constants the routed client code passes across the seam. /// From f83bda2df08f5cc22f66e087561df357b651daf7 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 18:56:56 +0200 Subject: [PATCH 130/226] docs(plan): the Vulkan-native plan of record, with the full-native roadmap Brings docs/vulkan-native-plan.md onto feat/vulkan-taa, excepted from the docs/* ignore rule like the acceptance checklists, and updates it for this branch's scope. - Roadmap (2026-09-15): fully Vulkan-native, then XeGTAO, a general refactor, optimisation and simplification, validation against Vulkan best practice, and the history and documentation cleanup for the upstream review; the open review blockers land alongside. - Decision 7: full native, no OpenGL mimicry. New Phase 3b retires the GL-emulation layer (GlStateTracker, GL ids, texture units, uniforms by location) from the Vulkan path in favour of native render systems. - Decision 8: mods get published documentation for Vulkan-native support instead of a compatibility layer; Phase 5 carries it. - Phase 4 moves to the optimisation step; the latency, DLSS, frame generation, HDR and ray-tracing sections stay on feat/dlss-g. - Status: the AO-before-resolve and dither backport (f202d02), the headless harness (af082c5), and a correction to the distant-foliage record, which damped the whole-frame jitter rather than removing it. --- .gitignore | 3 + docs/vulkan-native-plan.md | 852 +++++++++++++++++++++++++++++++++++++ 2 files changed, 855 insertions(+) create mode 100644 docs/vulkan-native-plan.md diff --git a/.gitignore b/.gitignore index bfe32685..682cf829 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,9 @@ docs/* # allowlist, and Optimum.Tests/parity-dump-coverage-tests.cs reads both. !docs/vulkan-acceptance.md !docs/parity-allowlist.md +# ...and the Vulkan-native plan: the design, the decisions behind it and the roadmap, for anyone +# picking the work up. +!docs/vulkan-native-plan.md build-linux.sh build-macos.sh build-windows.ps1 diff --git a/docs/vulkan-native-plan.md b/docs/vulkan-native-plan.md new file mode 100644 index 00000000..04334174 --- /dev/null +++ b/docs/vulkan-native-plan.md @@ -0,0 +1,852 @@ + + +# Plan: from OpenGL-under-Vulkan emulation to a proper Vulkan backend + +## Roadmap (user, 2026-09-15) + +This branch, `feat/vulkan-taa`, carries the Vulkan backend and TAA as their own pull request. It starts at +`c236676` (Milestone 1 on `main`, before the latency and DLSS work); upscaling, latency and frame +generation stay on `feat/dlss-g` for later pull requests. The work runs in this order: + +1. **Fully Vulkan-native, no OpenGL mimicry** (decisions 7 and 8): native shaders with offline SPIR-V + (Phase 3), the GL-emulation layer retired from the Vulkan path (Phase 3b), and the mod API and fork + ports on the native model, with published documentation for adding Vulkan-native support to a mod + (Phase 5). +2. **XeGTAO** replaces the vanilla SSAO, as native compute. +3. **General refactor.** +4. **Optimisation, streamlining and simplification**, including what Phase 4 lists. +5. **Validation against Vulkan best practice**: the validation layer's best-practices checks, the NVIDIA, + AMD and Intel sets included, and a review against the Khronos and vendor guidance. +6. **Cleanup of the commit history and documentation** for the upstream review. + +The upstream review's remaining blockers land alongside, where they fall: `libshaderc_shared.so` in the +application root, `run-client.sh` without a hard `prime-run`, `numpy` in the prerequisites, the three +swapchain resize tests, the runtime donor drift, and a device-idle wait before the window is released. + +**Where the branch is (2026-09-15).** Two backports from the DLSS line are in, neither judged in game yet: +- `f202d02`: the jittered AO is shaded into the scene before the TAA resolve, and the SSAO dither advances + per frame. On the DLSS line this pair (`e582ed0`, `8c33fa3`) removed the whole-frame jitter that the + resolve's 3x3 nearest-depth test and anti-flicker weighting had only damped. +- `af082c5`: the headless render harness (its roadmap item below). + +## Context + +Optimum's Vulkan backend (`Optimum.Render.Vulkan/**`, ~12k lines) sits behind the GL-shaped +seam `IOptimumGraphicsDevice` and reproduces OpenGL semantics call by call: state toggles are +recorded and resolved per draw, rendering scopes are inferred from framebuffer/draw-buffer +changes, every layout transition is an `ALL_COMMANDS` barrier, every texture upload is a +synchronous submit-and-wait that first flushes the half-recorded frame, presentation is a single +submission that waits for the swapchain image at `ALL_COMMANDS`, and the indirect scratch is a +wrapping ring sized by heuristic. It passes sync validation and renders the same pixels as +OpenGL, but it cannot pipeline: the CPU and GPU serialise on uploads and on presentation, frame +delivery is uneven, and the TAA work (jittered frames, history ping-pong, motion windows that +toggle draw-buffer masks dozens of times per frame) multiplies the scope restarts and barriers. + +The user's intent (2026-09-11): this was never meant to be an OpenGL emulator. It must become a +proper Vulkan backend: explicit frame structure, explicit synchronisation, asynchronous resource +streaming, decoupled presentation, and a design that the planned temporal work (FSR/XeSS/DLSS, +frame generation) can attach to. + +Built from a survey of the seam usage, the backend internals and tests, and the frame, temporal and +packaging constraints, and from the designs for platform integration, the renderer core and the shaders, +reconciled below. Every file:line fact quoted was re-checked in the tree. + +## Decisions taken with the user (2026-09-11 and 2026-09-15) + +1. **Scope: the client drives a frame graph.** The patched client announces frame and stage + boundaries; the platform declares passes, uploads and readbacks; the GL-shaped seam is not + the design centre any more. +2. **Mods: Vulkan-aware mods only.** Mods rendering through the game API land inside declared + passes and work; mods touching raw GL or Harmony-patching the platform's graphics members are + routed to OpenGL by the launcher scan. A mod-facing pass API is part of the new contract. +3. **Shaders: Vulkan-native GLSL for the vanilla program set**, explicit sets and bindings, + compiled offline to SPIR-V. The runtime rewriter stays only for mod shaders. +4. **Milestone 1 = stable frame delivery with TAA**: explicit sync, asynchronous uploads, + decoupled presentation and a declared post/TAA graph, measured by frame-time variance and a + zero blocking-upload counter, then judged in game. +5. **Integration shape: substitute the platform, do not branch the calls.** The game's own + graphics boundary is `ClientPlatformAbstract` (147 abstract/virtual members, ~100 graphics). + `ScreenManager.Platform` is a public static field typed to the abstract class + (`build/VintagestoryLib/Vintagestory.Client/ScreenManager.cs:29`); the sealed OpenGL class + `ClientPlatformWindows` is instantiated at one line (`ClientProgram.cs:214`). The patcher + unseals it and `Optimum.Render.Vulkan.dll` ships `VulkanClientPlatform : ClientPlatformWindows` + overriding the graphics virtuals; windowing, input, audio, frame pacing and the embedded + server stay in the base. OpenGL runs the base class, so "OFF is vanilla" is checkable. +6. **Why not leave Optimum:** any alternative against the closed client re-creates the same + Cecil/Harmony layer; the patcher, launcher scan, packaging, contracts, frozen temporal + contract and the GPU test suite carry over. +7. **Full native, no OpenGL mimicry (user, 2026-09-15).** Decisions 1 and 3 stop short: after native + shaders the device would still take GL-shaped calls - texture units, uniform locations, glEnable-style + state resolved per draw by `GlStateTracker`. The Vulkan backend has to reach its full performance + potential, so the render systems record pipelines and descriptor sets directly and the GL-shaped + members of `ClientPlatformAbstract` stop being the device's contract (Phase 3b). The OpenGL path stays + vanilla. +8. **Mods get documentation, not a compatibility layer (user, 2026-09-15).** Supersedes decision 2's + "mods rendering through the game API land inside declared passes and work": Optimum publishes how to add + Vulkan-native support to a mod - declaring passes, writing motion, shipping native shaders, drawing + through the native renderers (Phase 5). + +## Constraints + +- **Cecil transplants** (`VULKAN-BACKEND-PLAN.md` §0, `Optimum.Tests/cecil-transplant-lambda-tests.cs`): + no lambdas cached in compiler-generated classes, no LINQ predicates, no non-capturing lambdas, + no hidden-helper lowering; injected types only as simple data holders; every changed member + listed in `Optimum.Patcher/Program.cs`. Hence all renderer logic lives in + `Optimum.Render.Vulkan` and the contracts assembly; the lib gains only virtual calls. +- **Temporal contract v1 is frozen** (`docs/temporal-frame-contract.md`, + `Optimum.Tests/temporal-contract-tests.cs`). A change is a v2 bump with tests, never silent. +- **Hardware floor**: Vulkan 1.3 + dynamicRendering, synchronization2, timelineSemaphore, + scalarBlockLayout, independentBlend, multiDrawIndirect. Targets: Intel Arc 140V (Windows), + NVIDIA and Intel Mesa (Linux), AMD RDNA. Everything above the floor is an optional tier with a + fallback and an env override that forces the fallback, so every tier is testable. +- **Verification rules**: a launch is not a verification; diff both paths; verify + in game on both backends at phase exits only; every fix has a GPU readback test and a + source-coverage test; temporal and pacing claims are proven by numbers and logs, never by + screenshot pairs. +- **Assembly identity (verified)**: vanilla and donor `VintagestoryLib.dll` are both unsigned and + both `AssemblyVersion 1.22.7.0` (`build/VintagestoryLib/Properties/AssemblyInfo.cs:17`), so + the renderer can compile against the donor and bind to the patched DLL at runtime, exactly as + it already does for `VintagestoryAPI`. + +## Step 0: branching + +Historical: `feat/vulkan-native` started from `main` at `94e2cc0` after TAA merged, with the sky-direction +fix on its own branch, and merged back into `main` at Milestone 1 (`c236676`). `feat/vulkan-taa` starts +there. Never `git stash`. WIP commits use the `wip:` prefix. + +--- + +## Architecture (recommended approach) + +### A. Integration: `VulkanClientPlatform` + +**Shape.** `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs`, +`public class VulkanClientPlatform : ClientPlatformWindows`, owning the native renderer +privately. It overrides every graphics virtual (framebuffers, fixed-function state, meshes, +textures, shaders/uniforms/UBOs, post chain, TAA/FSR members, screenshots/queries/diagnostics) +and inherits windowing, input, audio, assets/logging, the singleplayer server, CPU bitmaps, AVI +and the frame-pacing block of `window_RenderFrame`. + +**No base-field widening is needed** (checked member by member): every private field of the base +is either exposed through an abstract property the subclass overrides (`CurrentFrameBuffer`, +`FrameBuffers`), or read only by methods the subclass overrides, or produced by a virtual the +subclass overrides (`frameBuffers = SetupDefaultFrameBuffers()` in `Start()`). If a step ever +needs `protected`, the design has drifted; the escape is a transplanted accessor, never +attribute surgery. + +**Base edits** (all in methods that are already Cecil targets; everything else in +`ClientPlatformWindows` reverts to vanilla and its 87 `OptimumRender.Device` branches are deleted): + +| Site | Edit | +|---|---| +| `ClientPlatformWindows.window_RenderFrame` | device branch becomes `BeginFrame(); frameHandler.OnNewFrame(dt); EndFrame();` (`BeginFrame` empty virtual on the abstract; `EndFrame` base override = `SwapBuffers`) | +| `ClientPlatformWindows.Start()` | thick-line GL probe becomes `SupportsThickLines = ProbeThickLineSupport();` | +| `ClientPlatformWindows.Window_Resize()` | `OnWindowSizeChanged(w, h)` before `RebuildFrameBuffers()` | +| `ScreenManager.Render` | the `GL.ClearBuffer`/`GL.DepthRange` pair becomes `Platform.ClearDefaultDepth(1f); Platform.SetDepthRange(0f, 20000f);` | +| `ClientMain.TriggerRenderStage` | `Platform.BeginRenderStage(stage)` / `EndRenderStage(stage)` around `eventManager?.TriggerRenderStage` (brackets every vanilla and mod renderer) | +| `ClientProgram.Start` | probe before construction; `OptimumRenderBootstrap.CreatePlatform(logger)` returns `object`, `as ClientPlatformWindows`; new injected `ConfigureClientPlatform(p)` holds the wiring now inlined at lines 215-255; after the window opens, `p.InitializeGraphics(hwnd, w, h, out reason)`; on failure reopen the window for OpenGL, construct the base platform, `ConfigureClientPlatform`, and assign `ScreenManager.Platform` (static, and `screenManager.Start` has not run yet); `p.ShutdownGraphics()` in the `finally` | + +**Virtualized in place** (GL bodies stay in `ClientPlatformWindows`): `SetupDefaultFrameBuffers`, +`DisposeFrameBuffers`, `RenderFullscreenTriangle`, `GetGraphicsCardRenderer`. + +**New virtuals on `ClientPlatformAbstract`** (base bodies: the verbatim GL lines they replace, +placed as overrides in `ClientPlatformWindows`; empty where GL has nothing to do): +`BeginFrame`, `EndFrame`, `InitializeGraphics`, `ShutdownGraphics`, `OnWindowSizeChanged`, +`ProbeThickLineSupport`, `BeginRenderStage`/`EndRenderStage`, `SetDepthRange`, +`ClearDefaultDepth`, `DeleteMeshHandle`; UBO ops (`UpdateUBO`, `BindUBO`, `UnbindUBO`, +`DeleteUBO`); program ops (`UseShaderProgram`, `DisposeShaderProgram`, `BindSampler`, 14 +`SetUniform*` primitives, `BindProgramTexture2D/Cube`); texture leaf ops (`SetTextureLodBias`, +`SetSamplerLodBias`, `SetTextureDepthCompare`, `ClearTextureRegion`, +`LoadTextureFromRgbaPointer`, `CreateTexture2DArray`); occlusion queries (`Gen`, `Begin`, `End`, +`TryGetResult`, `Delete`); `ReadDefaultFramebuffer`; `GraphicsBackendName`. These retire the +48 seam sites outside the platform class (`ShaderProgramBase.cs` 23, `UBO.cs` 6, +`SystemRenderOITLayers.cs` 5, `SystemRenderSunMoon.cs` 3, `ClientMain.cs` 2, `ChunkRenderer.cs` +2, and one each in `ScreenManager`, `VAO`, `SystemRenderFrameBufferDebug`, `SvgLoader`, +`ShaderRegistry`, `InventoryItemRenderer`, `ClientSystemStartup`, `Screenshot`). + +**TAA/FSR members** (`BeginMotionWrite`, `EndMotionWrite`, `BeginMotionOnlyWrite`, +`EndMotionOnlyWrite`, `RenderOptimumSkyMotion`, `RenderOptimumTaaResolve`, +`RenderOptimumTaaSharpen`, `DisableOptimumTaa`, `OptimumFsrBlitActive`, `TaaHistory`, and their +state fields) are declared virtual on the abstract class with the state fields injected there; +the GL bodies stay in `ClientPlatformWindows` as overrides. The seven cast sites +(`ChunkRenderer.cs:379,633,708`, `SystemRenderEntities.cs:315`, `SystemRenderDecals.cs:440`, +`SystemRenderParticles.cs:140`, `ClientMain.cs:1402`) become plain virtual calls; zero +`as/is ClientPlatformWindows` remain in the lib. + +**Patcher capabilities** (`Optimum.Patcher/Program.cs`, `MemberInjector.cs`, +`SelfConsistencyVerifier.cs`): `typesToUnseal` (clears `TypeAttributes.Sealed`), +`methodsToVirtualize` (sets `Virtual|NewSlot|HideBySig`, keeps visibility), and a verifier that +fails the patch if any method body still reaches a virtualized method with `call` instead of +`callvirt` (the one silent failure mode: a non-transplanted caller would bypass the override). +`MemberInjector.CloneMethod` already copies `MethodAttributes`, so injected virtuals arrive +virtual and cross-assembly overrides bind by name and signature. + +**What stays in the contracts** (`VintagestoryApi/Client/optimum-render-device.cs` shrinks to +this): `OptimumRender.ActiveBackend/FallbackReason/IsVulkan/FallBackToOpenGL`, +`OptimumRender.NoGraphicsApiWindow` (the window is created before any platform exists), +`OptimumRenderBootstrap` (`ShouldTryVulkan` unchanged, device-level, before the window; +`CreatePlatform` new), `OptimumMotionWrite.BeginHook/EndHook` (the mod forks call them and +reference only the API), `OptimumTemporal*` (contract v1). `IOptimumGraphicsDevice` and +`OptimumRender.Device` are deleted at the end of Phase 1A. + +**Build wiring.** `Optimum.Render.Vulkan.csproj` gains a `ProjectReference` to +`build/VintagestoryLib/VintagestoryLib.csproj` with `Private=false` (compile against the donor +where `sealed` is removed and the virtuals exist; bind to the patched vanilla DLL at runtime). +`InitializeGraphics` reflects over the expected virtual set once and fails the install (OpenGL +fallback) rather than throwing `MissingMethodException` mid-frame. The reflective load through +`OptimumRenderBootstrap` (`Assembly.LoadFrom`) is unchanged. + +### B. Renderer core (`Optimum.Render.Vulkan`) + +Layout: `Device/` (the platform-facing entry points, `DeviceCaps` tier table), `Frame/` +(`FrameTimeline`, `FrameSlot`, `FrameRing`, `RingArena`, `RetireQueue`), `Transfer/` +(`UploadManager`, `ReadbackManager`, `ITransferBackend`), `Graph/` (`FrameGraph`, +`PassRecorder`, `ResourceStateTracker`, `BarrierBatcher`, `FramePlan`, `TransientAllocator`, +`GraphValidation`), `Present/` (`Swapchain`, `SwapchainRetirement`, `IPresentPath`), +`Pipelines/`, `Descriptors/`, `Resources/` (`TextureStore`, `SamplerCache`, `MeshStore`), +`State/`, `Shaders/`, `Diagnostics/`. + +**Keep verbatim** (hard-won, tested): `VulkanAllocator`'s free-range coalescing, the per-slot +uniform-ring with dynamic offsets, `DescriptorCache` (content-keyed, never-reused ids), +`MeshManager`'s layout derivation (`PruneCustomInts`, `FillQuadIndices`, +`WriteIndirectCommands`), `PipelineCache`'s write-mask masking of undeclared outputs, +`Swapchain.ChooseFormat`, `SamplerState.LodCeiling`, `RenderTrace`, `TextureDump`, +`GpuCheckpoints`, `GlEnums`, `VertexLayout`, the whole `Shaders/` rewriter path (mod shaders). +**Replace**: synchronous upload path (`VulkanCommands.SubmitAndWait`, `TextureManager.Upload`, +`FlushFrame`), `RenderTargetManager` (scope inference), the wrapping indirect ring, swapchain +recreation, `FrameRing`'s fence pacing, `AccessForLayout` guesswork. + +**Synchronisation.** Two timeline semaphores are the only clock: `Frame` (every graphics submit +signals `n`) and `Transfer`. `FramesInFlight` fixed at init (2 now; 3 later for frame +generation), every arena sized by it. `BeginFrame(n)`: `vkWaitSemaphores(Frame, n - FIF)` is the +**only CPU wait in steady state**; reset the slot's pool, arenas, descriptor arena, query range; +drain `RetireQueue` (entries keyed on both timeline values, destroyed exactly when both passed). +`vkQueuePresentKHR` cannot wait on a timeline, so `renderFinished[image]` stays binary. + +**Transfer.** `ITransferBackend`: (A, default) a second command buffer per slot from the graphics +pool, recorded from any thread under a lock, submitted first in the same `vkQueueSubmit`: zero +ownership transfers, zero extra semaphores, zero blocking. (B, opt-in after measurement) a +dedicated transfer queue with exclusive-mode release/acquire barriers and a `Transfer` timeline +wait folded into the frame submit. Staging: one persistently mapped slice per slot +(`FIF × 32 MiB`), bump-allocated; oversized or overflow uploads take a dedicated staging buffer +retired on the timeline and are counted. **No upload ever waits.** Mip generation is a blit +chain on the graphics upload buffer. Persistent-mapped meshes keep the contract's "reproduce the +GL race" default; `OPTIMUM_VULKAN_MESH_DOUBLE_BUFFER=1` gives a per-slot copy with dirty-range +replay for validation-clean test runs. Static meshes move off ReBAR to device-local memory via +staging (the named defect). + +**Readback in a frame.** `ReadbackManager.CopyToHost`: end the open pass, barrier, copy to the +slot's readback arena, `SubmitPartial()` (ends and submits the command buffer, begins a new one +**in the same slot**, arenas keep their cursors). `FlushFrame` and the mid-frame +`_frameCounter++` are deleted. Only the screenshot path waits, on that one timeline value. + +**Presentation.** Split submission: Submit A (upload CB + frame CB, signals `Frame@v_render`); +**then** `vkAcquireNextImageKHR`; Submit B (FSR or the flipped blit into the acquired image, +waits `Frame@v_render` at `COLOR_ATTACHMENT_OUTPUT` and the acquire semaphore at `TRANSFER` or +`COLOR_ATTACHMENT_OUTPUT`, signals the binary present semaphore and `Frame@v_present`); +present. The `ALL_COMMANDS` wait disappears and the CPU blocks on acquire only after the whole +frame is in flight. Present policy `BlitFromOwned` (default: the frame including GUI renders +into the owned default image, acquire at the end) or `DirectToSwapchain` (experiment, negative +viewport flip). Present mode: FIFO with FIFO_RELAXED promotion on missed vsyncs; MAILBOX or +IMMEDIATE with vsync off; `minImageCount = max(caps.min + 1, mailbox ? 3 : 2)`. Recreation +follows the Khronos `swapchain_recreation` sample: `oldSwapchain` always passed, no +`DeviceWaitIdle`; a `SwapchainSlot` owns its images, views, acquire-semaphore free list +(`imageCount + 1`) and per-image present semaphores and retires as one unit after the last +present submission that referenced it; `SUBOPTIMAL` rebuilds before the next acquire, +`OUT_OF_DATE` rebuilds and re-acquires once; zero extent parks the present path. + +**Frame graph.** Passes are declared and recorded **in frame order** (the client's frame is +imperative and pass existence is dynamic: bloom, SSAO, transparent pass, mod stages). Barriers +and layouts derive immediately from a per-subresource state tracker (layout, last write +stage/access, visibility, read stages, queue family; one entry per image, interval list only +when a pass touches a sub-range). Load/store ops, discards and transient aliasing come from a +**plan** computed from the previous frame's signature (ordered pass signatures: attachments, +depth usage, read set, extent, formats); a plan is applied only on an exact signature match, a +mismatch costs one conservative frame (LOAD/STORE, no aliasing). All barriers of a pass go into +one `vkCmdPipelineBarrier2` before `vkCmdBeginRendering`; stage/access come from the pass usage +table (colour write, depth write, depth read-only sampled, fragment/vertex sample, transfer, +indirect, vertex/index, present), never from the layout alone. Pass kinds: raster, blit, +compute (reserved), present. Mod-hosted stages (`AfterOIT`, `AfterFinalComposition`, +`AfterBlit`, `Ortho`) use `OpenSampling` (pre-transition every sampled-capable non-attachment) +and `AllowSplit`. + +The platform derives the fixed frame from `(render stage, bound target)` plus its own post +methods. M1 pass set: ShadowFar, ShadowNear, Before, Opaque (Primary with all attachments +declared once, motion mask 0 by default), OIT (Transparent target), MergeTransparent, AfterOIT, +LiquidMotion and SkyMotion (motion-only write masks), TaaResolve, TaaSharpen, SSAO, Bloom +chain, GodRays, Luma, FinalComposition (attachment-subset pass: writes Primary 0, samples +Primary 1, one barrier each way per frame), Blit (FSR or plain), AfterBlit, Ortho, Present. + +**Invariants pinned by tests**: one `vkCmdBeginRendering` per pass (`ScopesOpened == PassCount`); +no layout transition inside a scope; every sampled texture is in the pass's read set or the +pass is `OpenSampling`; a plan applies only on exact match; a clear on a zero-write-mask +attachment is a no-op on every path (the undefined-attachment-contents bug class); a resource is destroyed +only after every timeline value recorded against it passed; a swapchain's semaphores die with +it; ReBAR holds only per-frame dynamic data and a fall-through is logged and counted; the Y +flip happens exactly once. + +**Motion windows and draw-buffer masks are write masks, never scope restarts.** Effective mask +per attachment = `drawBufferEnabled ? colorMask : 0`, then masked by the program's written +outputs. Tiers: `VK_EXT_color_write_enable` (exact `glDrawBuffers`, zero extra pipelines) → +`VK_EXT_extended_dynamic_state3` (`ColorWriteMask`, and `ColorBlendEquation` collapses the blend +key) → write-mask set interned into the pipeline key (bounded: programs used inside a window +× 2). Each tier forceable by env and tested. + +**Clears** issued with no pass open become the next pass's `LOAD_OP_CLEAR` (standalone +`vkCmdClearColorImage` if read first); inside a pass they stay `vkCmdClearAttachments` and are +counted. `SnapshotColorAttachment`'s permanent shadow copies become pooled per-pass transient +copies (`ReadSelf`). Transient aliasing (post chain slots) is off by default +(`OPTIMUM_VULKAN_ALIAS=1`) until sync validation is clean on all targets. + +**Memory.** Pool classes: `DeviceImages` (128 MiB blocks), `DeviceBuffers` (64), `Staging` +(32), `ReBar` (16, per-frame dynamic data only, capped at min(192 MiB, budget × 0.25), miss = +logged fall-through), `Transient` (64), `Dedicated` (via `VkMemoryDedicatedRequirements` or +size ≥ block/4). `VK_EXT_memory_budget` reported per heap, pressure callback drops spare +blocks and cold descriptor entries; without it budget = heap × 0.7. No general defrag: empty +blocks freed after 120 empty frames; optional bounded relocation of static geometry only. + +**Draw submission.** Per-slot indirect ring in ReBAR, reset at `BeginFrame`, grown at frame +boundaries (replaces the wrapping ring). Bone matrices move to a storage-buffer ring with +dynamic offsets (lifts the 64 KiB UBO limit, tight packing under `scalarBlockLayout`); the +per-(frame, version) snapshot dedup stays; ring exhaustion grows and reports instead of +dropping. Dynamic state is dirty-masked (today 12 commands on every draw). `GetError()` becomes +a volatile counter read. Occlusion queries: per-slot pool + `vkCmdCopyQueryPoolResults` into a +host buffer, polled without any API wait (one frame late, like GL's availability polling). + +**Descriptors and pipelines.** M1 keeps the existing rewriter layout and `DescriptorCache`, and +adds a per-slot `DescriptorArena` for short-lived resources (GUI text, atlas tasks) reset +wholesale per frame. Pipeline key gains `RenderingFormatsId` from the **pass** (stable across +mask toggles) and loses the blend/write-mask dimensions where the dynamic tiers exist. Disk +pipeline cache (`vkGetPipelineCacheData`, keyed on device/driver/pipelineCacheUUID/build id), +SPIR-V cache for mod shaders, a manifest of used keys and a background warm-up with +`pipelineCreationCacheControl` land in Phase 4. + +**Diagnostics.** `VulkanStats` gains: blocking uploads (uploads that really waited), blocking +waits by site, acquire/present/fence wait ms, frame-time p50/p95/p99/stddev and stutter count +(>2 × p50) over the last 512 frames, passes vs BeginRendering, barriers, self-read copies, +transient/aliased bytes, plan hits/misses, heap used/budget, ReBAR fallbacks, pipeline and +descriptor hits/misses, dynamic-state commands, push-constant flushes, uniform ring use, and a +per-pass GPU time table from timestamp queries (Phase 4). `RenderTrace` gains pass/barrier/ +submit/acquire/present lines. `OPTIMUM_VULKAN_POISON=1` fills fresh images and buffers with +NaN/`0xDEADBEEF` so undefined reads are loud. The GPU test suite runs sync + best-practices +validation **by default** with a `NoSyncHazards` assertion. + +### C. Shaders + +**Sources.** `sources/shaders-vk/.vert|.frag` (GLSL 450, Optimum-authored, never an +asset; `.vert/.frag` so no packager glob over `sources/shaders/*` can pick them up) plus +`sources/shaders-vk/include/` (`bindings.glsl` single source of truth for sets, `globals.glsl`, +`warp.glsl`, `motion.glsl`, `fog/shadow/colormap/sky/oit/noise/vertexflagbits.glsl`), resolved +by glslc `-I`. The GLSL 330 assets in `sources/shaders/` keep shipping and keep being read: +`ShaderProgram.collectUniformNames` (`ShaderProgram.cs:56-66`) regexes `Shader.Code` for the +uniform-name set and texture declaration order, which is the client's oracle. The native path +supplies only placements and bindings; no `ShaderRegistry` change is needed. + +**Set convention** (frequency-ordered; mirrored in `Shaders/SetConvention.cs`, a test asserts +`bindings.glsl` and the C# agree): + +| Set | Update | Contents | +|---|---|---| +| 0 frame | once per frame | `FrameGlobals` UBO (every uniform `ShaderProgramBase.Use()` auto-binds plus the `OptimumTemporal` record) and the fixed frame textures `shadowMapFar/Near`, `sky`, `glow`, `liquidDepth` | +| 1 pass | once per pass | `PassParams` UBO (dynamic offset) and pass inputs (scene, glow, depth, motion, history×3, gbuffer, bloom, godrays) | +| 2 material | bound once | sampled textures and samplers as a plain array; bindless (partially bound, update-after-bind) is a Phase 4 option decided by the measured descriptor miss rate | +| 3 draw | dynamic offset per draw | `DrawData` UBO (model/prev-model matrices, per-draw warp/tint overrides, flags), `FaceData` SSBO, `Animation`/`AnimationPrev` SSBOs | +| push (≤128 B) | per draw | the few scalars written between draws of one program (material index, origin, z-offset, flags), chosen per program from a measured write-frequency profile | + +**Uniform placement.** `GetUniformLocation(program, name)` returns an index into the program's +placement table `(home: Push | Frame | Pass | Draw | SamplerUnit, offset, size)`, `-1` when the +variant compiled the name out (`HasUniform` keeps returning true, as GL does). `SetUniform*` +is a table lookup and a memcpy into the right shadow, flushed once per draw (push) or per +frame (frame). `Use()` is not touched in Phase 3; its ~50 frame-global writes per program use +land in the frame shadow (a debug tripwire flags a frame-global written with two different +values in one frame). Skipping the include block in `Use()` is a Phase 4 optimisation, +measured first. + +**Define matrix.** Code-path flags (FXAA, BLOOM, NORMALVIEW, FOAMEFFECT, SHINYEFFECT, +WAVINGSTUFF, GREEDYMESH*) and quality values (GODRAYS, SSAOLEVEL, SHADOWQUALITY, MINBRIGHT) +become specialization constants with gated varyings/samplers/outputs declared unconditionally +(outputs masked by `writtenOutputs`); DYNLIGHTS is removed (array fixed at `MAX_DYNLIGHTS`, +loop bound = the existing `pointLightQuantity` uniform); MAXANIMATEDELEMENTS is fixed; +TAAMOTION+TAAMOTIONLOCATION stays a **variant axis** (off / on@2 / on@4; output locations +cannot be specialized); USEOIT a variant on the two OIT programs; USESSBO fixed to 1 (the +`Chunkshadowmap_NoSSBOs` registration is the one 0 variant). Result ≤ 6 variants per program, +~600 SPIR-V blobs. A settings change becomes a pipeline-key change, not a shader reload. + +**Offline compile.** `tools/shader-compiler/Optimum.Shaders.Compiler.csproj`: `--build` +(glslc `--target-env=vulkan1.3 -O`, then SPIR-V reflection into `shaders.manifest.json`: +schema version, toolchain, per program per variant the defines, spec constants, stage blobs with +sha256, uniform placements, samplers, blocks, vertex inputs, fragment outputs, +`writtenOutputs`), `--verify` (recompile, compare hashes; the `make check-shaders-vk` gate), +`--single`. MSBuild target on `Optimum.Render.Vulkan.csproj` with a content-hash cache. +Deploy to `/Optimum/shaders-vk/` beside the DLL, never into `assets/` (the asset manager +must not read SPIR-V, the scanner scans `assets/*/shaders`, and a mod must not shadow engine +SPIR-V by asset priority); `Makefile` and every `scripts/package-*` copy it with the existing +`cmp -s` completeness check. Load once, verify hashes lazily, **fall back per program** to the +rewriter; one log line `[Optimum] shaders: N native, M rewritten, K failed`. +`OPTIMUM_VK_SHADER_SOURCE=` compiles the tree at runtime through shaderc for the dev loop; +a test asserts runtime and offline SPIR-V are byte-identical for a sample program. + +**Mod-shader adapter.** The rewriter targets the same four sets: loose uniforms → set 3 per-draw +block, samplers → set 2 plain array, SSBOs → set 3, and any loose uniform whose name matches a +`FrameGlobals` member → set 0 (so a mod shader including `fogandlight.fsh` keeps working +unchanged). Confined to `ProgramInterfaceLayout.Build` plus a frame-global name map; a test +compares the descriptor-set layouts of a native and an adapter program. + +**Temporal contract.** One writer: `include/motion.glsl` with +`optimumWriteMotion(mv, reactive, writerDepth)` and `optimumWriteReactiveOnly(reactive)` +(the "b without rg" rule becomes a signature property); a source test fails any other +assignment to `outMotion`. Every TAAMOTION variant's manifest entry lists the motion output at +`TAAMOTIONLOCATION` in `writtenOutputs`. The eight `Taa*Motion*Tests` gain native-vs-rewriter +differential cases (same inputs, motion attachment equal within 1 ULP of RGBA16F). Phase 3 +records explicitly whether the contract gets a dated v1 addendum (provenance only) or a v2. + +**Launcher scan v2** (`Optimum.Launcher/ShaderCompatibilityScanner.cs`): new +`ShaderAssetOverride` class reporting overridden vanilla program names (those use the rewriter; +an overridden `shaderincludes/*` forces all programs); new `PlatformInternals` indicator +(Harmony + `ClientPlatformWindows`/`ShaderProgramBase` strings) → `openGlRequired`; `RawOpenGL` +unchanged; `CurrentSchemaVersion` 2. `VULKAN-BACKEND-PLAN.md` §9 states that a Harmony patch on +a platform graphics member is not honoured on Vulkan. + +### D. Mod policy (decision 2 made concrete) + +Free, no mod change: everything through `IRenderAPI`/`IShaderAPI`/`ICoreClientAPI` (meshes, +textures, render-to-texture, GUI, fixed-function state, screenshots, shaders through the +rewriter), because it lands on the platform virtuals inside a declared pass; `RegisterRenderer` +renderers sit inside `BeginRenderStage`/`EndRenderStage`. Unsupported (launcher routes to +OpenGL): direct OpenTK GL; Harmony patches on platform graphics members. Vanilla-shader +overrides by mods bypass the native blob for that program only. Phase 5 adds the opt-in +mod-facing pass and motion-writer API in the contracts (`EnumOptimumPass`, `OptimumPassDecl` +data holders; no lib types). + +**Superseded in part by decisions 7 and 8 (2026-09-15).** "Free, no mod change" was a property of the +GL-shaped seam, which the full-native backend retires from the Vulkan path. Mods add Vulkan-native support +by following the published documentation. Direct OpenTK GL and Harmony patches on platform graphics members +still route to OpenGL. Whether the runtime rewriter survives for GLSL 330 mod shaders is decided in +Phase 3b. + +--- + +## Phases and exit criteria + +Every phase: `dotnet build VintageStory.slnx -c Release`; `dotnet test Optimum.Tests -c Release`; +`dotnet test Optimum.Render.Vulkan.Tests`; `bash scripts/extract-patches.sh && bash +scripts/check-patches.sh`; `make deploy`; then the in-game check listed, once per backend, +renderer confirmed with `scripts/dev/client-renderer.sh`, game closed with the kill script. +In-game runs happen only at phase exits. + +### Phase 0: foundations (no behaviour change) + +- Step 0 branches. +- Patcher: `typesToUnseal`, `methodsToVirtualize`, the `call`→`callvirt` verifier. + Tests: `Optimum.Tests/member-injector-tests.cs` (flags preserved, synthetic stray `call` + fails), `platform-substitution-coverage-tests.cs` (entries present). +- Diagnostics: `VulkanStats` counters above, `OPTIMUM_FPS_LOG` gains `stddev`, + `scripts/dev/perf-capture.sh` captures, `scripts/dev/pacing-gate.sh --renderer vulkan --fps + --stats --baseline ` judges (exit non-zero unless blocking uploads = 0 in + every sample, median window stddev ≤ baseline × 1.25, median window p99 ≤ 1.5 × median mean, + dropped mesh writes = 0, uniform overflows = 0), format-coverage test for the stats line, + `docs/taa-acceptance.md` §3 and `perf-capture.sh` parser updated. +- GPU tests default to `sync,best` with `ValidationAssert.NoSyncHazards`. +- GL-side attachment dump (`glGetTexImage` in `ClientPlatformWindows`, env + `OPTIMUM_PARITY_DUMP= OPTIMUM_PARITY_FRAME=`, Vulkan side reuses `TextureDump`), + `scripts/dev/parity-capture.sh`, `scripts/dev/ssim.py` (per attachment SSIM + mean abs diff), + `docs/parity-allowlist.md`, coverage test that the dumped slot list matches + `SetupDefaultFrameBuffers`. +- `docs/vulkan-acceptance.md` skeleton (preconditions, renderer line per row, rows per + milestone, methods, decision record, vendor matrix). + +Exit: builds and suites green; deploy output unchanged except stats; both dump paths executed in +the real client; GL-vs-GL noise floor recorded per attachment (two launches of one save are not +bit-deterministic: world time, weather, entities and particles move, so this is a floor that +Milestone 1's 0.98 threshold must stand above, not a 1.000 gate); Vulkan-vs-GL table recorded as +Milestone 1's starting point; baseline pacing numbers for both backends on the fixed scene recorded +in `docs/vulkan-acceptance.md`. + +**Phase 0 status (2026-09-11).** Merged on `feat/vulkan-native` at `cdd7412` (stages 1dcbb29, +3e1170c, 75a984f, 9588f3e; integration 4d1089f; review cdd7412). Build 0 errors; Optimum.Tests +1056 passed; GPU suite 386 passed with `sync,best` and zero sync hazards; real Cecil patch 257/257 +methods, 1 type unsealed, 4 methods virtualized, 0 non-virtual call sites. Deferred from the +Diagnostics list to the phase that builds the subsystem: heap used/budget (1B step 5), pipeline +and descriptor hits/misses and push-constant flushes (Phase 4), RenderTrace pass/barrier/submit/ +acquire/present lines (Phase 2). Known: poison-mode image clears count as blocking uploads +(diagnostic mode only); the GL `glGetTexImage` dump had never executed before the exit run. + +**Phase 0 exit run (2026-09-11, RTX 4070, driver 615.71.09, both backends; recorded in +`docs/vulkan-acceptance.md` and `docs/gpu-verification-2026-09-11/phase0/`).** Both dump paths +executed. Pacing, median per-second windows: OpenGL 6.08 ms mean / 8.51 p99 / 0.55 stddev; Vulkan +9.90 / 20.08 / 4.96, gate fails on p99, stddev and blocking uploads (median 50/s, max 193). Vulkan +per-second medians: flush-frame 151 (occlusion-query reads 101: `GetQueryResult` flushes every +frame), rendering scopes 4040, barriers 6766, dynamic-state commands 172256. Parity: GL-vs-GL +launches differ (far shadow map 0.947, Primary colour 0.968), so Milestone 1's parity rule is now +relative to the same session's GL-vs-GL floor. Real gap found: SSAO g-buffer colour1 alpha is 1.0 +on GL and 0.0 on Vulkan (unwritten channel); it goes into Phase 2 with the write-mask work. +User judged these two Vulkan runs free of the earlier distance jitter (sky-direction fix not deployed); in every later Vulkan run the jitter was back, and OpenGL never shows it, so the jitter is Vulkan-only and intermittent between sessions and remains the Milestone 1 target. +Phase 1B priority from these numbers: `QueryRing` (removes ~1 flush per frame) and the upload path +first, then present. + +### Phase 1A: platform substitution (re-plumbing; pixels unchanged) + +Runs in parallel with 1B (disjoint files). + +1. `VulkanClientPlatform` as a forwarding subclass over the existing `VulkanDevice`; + `SetupOptimumFrameBuffers` (lines 1629-1916) moves out as the `SetupDefaultFrameBuffers` + override; `ClientProgram.Start` per the table above; csproj donor reference. +2. TAA members to the abstract class; the seven casts become virtual calls; the 14 + `Optimum.Tests` files that read `ClientPlatformWindows.cs` are re-pointed at the abstract + class as a pure-move commit (bodies diffed textually). +3. Program/uniform/UBO virtuals; `ShaderProgramBase.cs` and `UBO.cs` revert to vanilla plus + `ScreenManager.Platform.`. Measure per-draw CPU on both backends before and after. +4. Remaining 19 leaf sites; delete `IOptimumGraphicsDevice`, `OptimumRender.Device`, + `OptimumRenderBootstrap.Install`; `ClientPlatformWindows` is branch-free; `Program.cs` entries + for it drop from 88 to the handful of edit sites. + +Tests: every abstract graphics member and every GL-touching `ClientPlatformWindows` method has +an override in `VulkanClientPlatform.cs` or is on the base-edit list (source test that greps +`GL.` per method); no lambda in the new `ClientProgram.Start` region; the fallback block +re-assigns `ScreenManager.Platform`; no `OptimumRender.Device` and no `ClientPlatformWindows` +cast under `build/VintagestoryLib/**`; `ClientPlatformWindows.cs` differs from `_ref/` only in +the listed regions; `PlatformSubstitutionTests` (construct headless, `is ClientPlatformWindows`, +`InitializeGraphics` on a hidden NoAPI window brings up the swapchain); every existing GPU +readback test driven through the platform gives identical pixels. + +Exit: all of the above green; in game, one screenshot per backend identical to Phase 0's; the +reopen-and-swap fallback exercised once with `OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE=1` and the +log showing `[Optimum] Vulkan unavailable, reopening for OpenGL`. + +### Phase 1B: synchronisation foundation (behind the current entry points) + +1. `FrameTimeline` + `RetireQueue`; `FrameRing` on timelines. Gate: existing multi-frame tests; + blocking waits = 1 per frame. +2. `UploadManager` + per-slot upload command buffer (backend A); texture, mip and bulk mesh + uploads route through it; delete `SubmitAndWait` and `FlushFrame`. Gate: **blocking + uploads = 0** during world load and a 10-minute session (the M1 headline number). +3. `ReadbackManager` + `SubmitPartial`; `QueryRing`. Gate: screenshot readback test; readback + mid-frame then more draws then present stays correct; sun glare still varies in game. +4. `Swapchain`/`SwapchainRetirement`/`IPresentPath` split submission. Gate: resize, alt-tab, + minimise loop clean under `sync,best`; acquire wait stage never `ALL_COMMANDS`; frame-time + stddev before/after recorded. +5. `VulkanAllocator` pool classes + budget; static meshes off ReBAR. Gate: allocator policy + tests; heap report; no chunk-streaming regression. +6. Per-slot indirect ring; descriptor arena; dirty-masked dynamic state; free `GetError`. + Gate: CPU frame time drop measured; draw counters unchanged. + +Tests (GPU, `Optimum.Render.Vulkan.Tests`): `AsyncTransferTests` (upload from a worker thread +while frames record, Present between frames, read back on N+2, `BlockingUploads == 0` over 60 +frames with atlas inserts, Cairo updates and chunk meshes interleaved), `PresentDecouplingTests` +(`VulkanContextOptions.AcquireDelayForTests`; recording time does not grow with the delay), +`SwapchainRecreationVisualTests`, `ConcurrentDeviceAccessTests`, `ReadbackMidFrameTests`, +`QueryRingTests`, `AllocatorPolicyTests`; pure unit tests `IndirectRingWrapTests`, +`TimelineLifetimeTests`, `PresentWaitStageTests`, `SwapchainRetirementTests`. + +**Phase 1 exit (2026-09-11, f373c4a).** 1A and 1B merged and reviewed (Optimum.Tests 1128, GPU 494, +patch run 197/197, dispatch verifier clean). In game on the RTX 4070: both renderers start; forced +install failure falls back to OpenGL and renders; sync,best validation 0 errors; Vulkan blocking +uploads 0 in all samples; Vulkan pacing 8.21 ms mean / 18.07 p99 / 3.74 stddev (Phase 0: 9.90 / +20.08 / 4.96). OpenGL pacing is bimodal between launches regardless of build (A/B/A), so M1.1 +interleaves runs. Carried to Milestone 1: 10-minute session, window loop, sun glare and fork bridge +on screen, the SSAO alpha gap, foliage NaN normals (Phase 3). User direction after this exit: less +testing. + +**TAA distant-foliage jitter resolved (2026-09-11, 22:33).** Root cause was the resolve shader, not the +backend: a single-sample depth disocclusion test dropped history on ~3.7% of distant leaf pixels per +frame (sub-pixel leaf vs far background across jitter phases), and a fixed blend weight let the moving +clip box drag history. Fix in `sources/shaders/taa-resolve.fsh`: 3x3 nearest-depth disocclusion with +motion from the nearest-depth tap, and luminance-based anti-flicker weighting (0.3x..1.2x blendAlpha). +User judged Vulkan "perfectly stable, better than it ever was" at the default two frames in flight. +Ported with GPU and source tests on `fix/taa-antiflicker-disocclusion`, merged after Phase 2. Also +found, unfixed: Vulkan `BuildMipMaps` keeps the atlas texture LOD bias where OpenGL resets it to 0 +(affects shadow, liquid and transparent terrain passes; shadow maps measured identical, so not visible). +**Correction (2026-09-15):** this damped the whole-frame jitter the user saw rather than removing it. On the +DLSS line the jitter went away once the jittered AO was shaded into the scene before the temporal pass and +its dither advanced per frame; both are backported for the TAA path in `f202d02`, not yet judged in game. + +### Phase 2: frame graph → **Milestone 1** + +1. `ResourceStateTracker` + `BarrierBatcher` driving the existing immediate path (derived + stages replace `ALL_COMMANDS`; no graph yet). Gate: sync clean; barrier count reported and + reduced. +2. `FrameGraph` streaming recorder + `PassRecorder`; the platform declares the M1 pass set from + `(stage, target)` and its post methods; lib gains `BeginRenderStage`/`EndRenderStage`. + Both paths coexist behind `OPTIMUM_VULKAN_FRAMEGRAPH`; a declared-reads violation splits. + Gate: pixel-identical readbacks vs the non-graph path at four settings combinations; + `ScopesOpened == PassCount`. +3. Write-mask motion windows (all three tiers), clear promotion, `FramePlan` load/store + solving; TAA resolve/sharpen/sky-motion/liquid-motion through the graph, contract unchanged. + Gate: motion-attachment bit-exactness; history accumulates over 8+ frames in a multi-frame + test with no readback inside the loop. +4. Transient aliasing implemented, default off. + +Tests: pure `FrameGraphBarrierTests` (RAW/WAR/WAW/layout table, swapchain ends `PRESENT_SRC`, +aliased first use `UNDEFINED`, no reader → no barrier), `FramePlanTests` (signature match, +load/store solve, alias intervals never overlap); GPU `FrameGraphFrameTests` (the real declared +frame for 5 frames, TAA accumulates, scopes == passes, zero `SYNC-` messages), +`MotionWindowTests` per tier, `FeedbackPassTests` (final composition write-0/sample-1; ReadSelf +copy), `ClearPromotionTests` (masked-out clear is a no-op), `AttachmentSemanticsTests` and +`WorldRenderPathTests` stay green; `Optimum.Tests`: `TriggerRenderStage` brackets the event +and is a Cecil target. + +**Milestone 1 definition of done** (all numbers, then eyes): +- `pacing-gate.sh` passes against the OpenGL baseline of the same scene. +- Blocking uploads 0 during load and a 10-minute session; blocking waits 1 per frame. +- Acquire happens after the render submit; acquire wait stage is `TRANSFER` or + `COLOR_ATTACHMENT_OUTPUT`. +- `ScopesOpened == PassCount`; no transition inside a scope. +- `sync,best` validation: zero `[error]` over the scripted session (menu → world → weather → + water → night → resize → shader reload → screenshot → exit). +- Per-attachment SSIM vs OpenGL, TAA off ≥ min(0.98, same-session GL-vs-GL SSIM − 0.01) on every + attachment, or an allowlist row (launches of one save are not bit-identical). +- TAA on: still-frame luma-diff median over 7 pairs within 0.3 of the OpenGL median + (reference VK 1.84 / GL 1.87, `docs/taa-acceptance.md:55`); `docs/taa-acceptance.md` rows + A11, A13, A14, A15, A17, A18 re-pass. +- Then the user judges it in game on both backends, renderer line confirmed. + +**Milestone 1 accepted (user, 2026-09-11, at `6568556`).** Phase 2 complete: barriers from usage, frame +graph (22.3 passes == 22.3 scopes per frame, 0 splits, 0 mask restarts, plan hits every frame), transient +allocator (implemented, not yet wired to the graph), clear promotion, SSAO alpha gap closed, TAA +anti-flicker resolve merged (distant-leaf rejection 1.05 % on both backends). Open and carried to Phase 4: +Vulkan costs ~25 % more frame time than OpenGL on the fixed scene (7.59 ms vs 6.08, stddev 0.37 vs 0.12) +and is GPU-bound (5.43 ms of the 7.67 ms frame in the frame-pacing wait), so the pacing gate fails its +stddev rule; per-pass timestamps come first. Also open: wire `TransientAllocator` into the graph, +`ClearDepth` ignores the depth write mask, `BuildMipMaps` LOD-bias parity. Testing policy tightened by the +user: no long sessions, no per-attachment SSIM matrices, one short run plus the cheap numbers. + +**Branching at Milestone 1 (user, 2026-09-11):** once Milestone 1 is accepted, `feat/vulkan-native` +merges back into `main` (with `fix/taa-antiflicker-disocclusion` merged into it first), and the next +work (DLSS) starts on a new branch from the updated `main`. No DLSS or later-phase work lands on +`feat/vulkan-native`. + +### Phase 3: native shaders + +Set convention, placement table, manifest, compiler tool, adapter layout (rewriter retargeted +to sets 0-3 in the same commit as set 0 lands, so there is one layout change, not two), native +GLSL in seven stages (includes + six fullscreen/post programs first; GUI/lines/ +texture2texture; chunk family incl. `NoSSBOs`; entity family incl. OIT variant; particles/ +decals/sky/clouds; SSAO/godrays/bloom/colorgrade/OIT compose/debug; the seven Optimum programs) (taa-resolve keeps the 2026-09-11 fix: 3x3 nearest-depth disocclusion with motion from the nearest-depth tap and luminance anti-flicker weighting, pinned by the GPU tests on fix/taa-antiflicker-disocclusion and by `scripts/dev/taa-rejection.py`; never a single-sample depth test), +scanner v2, the contract addendum-or-v2 decision, `ReloadShaders` no longer recompiling on a +settings change. + +Tests: `vk-shader-parity-tests.cs` (per program per variant: uniform-name set, sampler name set +and order, vertex-input locations, fragment-output count equal to the GLSL 330 source through +the existing `ShaderCorpus`; a vanilla shader change in a game update fails here instead of on +screen), `vk-motion-writer-shape-tests.cs`, manifest schema and consistency tests, +`AdapterLayoutMatchesNativeLayoutTests`, the native-vs-rewriter differential motion tests, +`Optimum.Launcher.Tests` fixtures (a mod overriding `chunkopaque.fsh` marks only that program; +a `shaderincludes` override marks all; Harmony + platform string → `openGlRequired`), a test +that `sources/shaders-vk/` contains no `.vsh/.fsh`. + +Exit: log line reports 48 native / 0 failed; parity and differential tests green; per-attachment +SSIM ≥ 0.99 or allowlisted; validation clean; in game the settings sweep (SSAO 0/1/2, shadows +0/1/2, bloom, god rays 0/1/2, FXAA, render scale 0.5/1.0/1.5, waving foliage) on both backends, +plus the full `docs/vulkan-acceptance.md` matrix; contract decision recorded. + +### Phase 3b: retire the GL-emulation layer (decision 7) + +Native shaders alone leave the device taking GL-shaped calls: `GlStateTracker` ("the emulated OpenGL state +machine") resolves viewport, scissor, depth, cull, stencil, blend, colour mask, topology and program into a +`PipelineKey` on every draw; textures, programs and framebuffers are GL-style integer ids; samplers bind by +texture unit; uniforms are located by byte offset; draw-buffer masks and the clip-depth remap follow GL. +Phase 3b removes that layer from the Vulkan path. + +- **Native render systems.** Each system the patched client drives - chunks (opaque, topsoil, liquid, + shadow), entities (the OIT variant included), particles, decals, sky and clouds, GUI and text, the post + chain and TAA - gets a Vulkan-side renderer that owns its pipelines (created from the manifest at load, + never resolved per draw), its descriptor sets on the set convention, and its per-draw data in push + constants and the draw set. The client hands it scene data - meshes, textures, transforms, uniforms by + meaning - not GL calls. +- **The platform contract changes shape.** `ClientPlatformAbstract`'s GL-shaped members (fixed-function + state toggles, texture units, `SetUniform` by location, draw buffers) remain the OpenGL path's. On Vulkan + the render systems reach their native renderers through transplanted seams, one per system, and the + GL-shaped overrides in `VulkanClientPlatform` are deleted as each system moves. +- **Resources by handle.** Meshes, textures and targets are typed handles owned by the renderer; the + GL-id tables, texture units and the location-offset uniform shadow go with the last GL-shaped caller. + +Exit: `GlStateTracker`, the GL enum tables and the placement-by-location uniform path have no Vulkan-path +callers; every vanilla render system draws through its native renderer; the Phase 3 exit sweep re-passes on +both backends. Open: the order systems move in (the post chain and TAA first, since Optimum owns them end +to end), and whether the runtime rewriter survives for mod shaders. + +### Phase 4: performance + +Moved to roadmap step 4 (2026-09-15), after the general refactor, except the SPIR-V cache and manifest, +which Phase 3 produces. + +Disk pipeline cache + used-key manifest + warm-up; push-constant placement from the measured +profile (`OPTIMUM_VULKAN_UNIFORM_PROFILE`) frozen into the manifest for the 48 programs; +animation SSBO ring; `Use()` include-block early-out (measured first); per-pass GPU timestamps +(`timestampValidBits` gated); transient aliasing default on after clean validation on all +targets; bindless set 2 only if `DescriptorCache.Misses` per frame in a loaded world justifies +it; `DirectToSwapchain` and transfer backend B measured, kept only where they win. + +Exit: on the fixed scene Vulkan mean FPS ≥ OpenGL and p99 ≤ OpenGL on this machine, numbers in +`docs/vulkan-acceptance.md` §6 (Arc 140V row filled when the handheld is available); pipeline +cache hit rate ≥ 95 % on second launch; per-pass ms table sums to within 10 % of GPU frame time; +`perf-capture.sh` × 4 (both backends × TAA on/off) plus a 30-minute session. + +### Phase 5: mod API and fork ports + +Contracts pass API and opt-in motion-writer API (data holders only); `VSEssentials` / +`VSSurvivalMod` / `VSCreativeMod` renderers checked against declared passes; a fixture +shader-pack mod on the rewriter; three real mods from the user's library. Exit: forks run with +no concrete-cast fallback; fixture renders; scanner v2 launcher tests green. + +Under decisions 7 and 8 this phase also publishes the mod documentation: how a mod declares passes, writes +motion, ships native shaders and draws through the native renderers, with a fixture mod that follows it +step by step. The forks port to the native render systems rather than to the GL-shaped seam. + +### Upscaling, latency, frame generation, HDR and ray tracing: not on this branch + +The vendor orchestrator decisions (2026-09-11 and 12), the latency seams, the NGX spike on native Linux, +DLSS Super Resolution, the upscaler and frame-generation seams (the former Phase 6), and the HDR and +ray-tracing roadmap items live on `feat/dlss-g` and in this document's version on `main`. They return as +their own pull requests on top of the native backend. + +### Roadmap item: a headless render harness that does not take the machine + +Added 2026-09-12 at the user's request: "A headless renderer you can run in the background and take frames +out so my PC is not blocked." Every visual verification so far has meant opening the real client on the +user's desktop, stealing focus and the GPU, which is why in-game checks are rationed and why a +temporal artefact cannot be judged at all without the user sitting in front of it. + +What it has to be: the real renderer and the real client path (a mock proves nothing, and a launch +on the wrong backend is not a verification), driven without a visible +window, writing frames to disk on demand, and runnable while the user works. The pieces already exist and +are the reason this is a roadmap item rather than a project: the GPU test suite creates real +`VulkanDevice`s and hidden GLFW windows today, `OPTIMUM_PARITY_DUMP` already writes every attachment at a +chosen in-world frame, `scripts/dev/parity-capture.sh` already drives a full client run unattended, and +`OptimumParityDump.WritePresentedFrame` already exists on the diagnostic branch. + +Shape to aim for: +- An offscreen mode for the client (hidden window or a surfaceless device where the swapchain is replaced + by an owned image), selected by an environment variable, with the frame loop otherwise untouched. +- Frame extraction: presented frames to disk at a chosen cadence or frame list, plus the existing + per-attachment dump, in a format the existing tools already read (`ssim.py`, `taa-rejection.py`, + `luma-diff.py`). +- A scripted camera and world state so a sequence is reproducible frame for frame: the fixed scene of + `docs/vulkan-acceptance.md` section 0 plus a recorded camera path, so two runs differ only by the change + under test. This is what finally makes temporal artefacts measurable without eyes - consecutive-frame + differences on a *deterministic* sequence, which today's launches cannot provide. +- Low priority on the GPU (or an explicit "run only while idle" switch) so a capture can sit in the + background while the user plays or works. +Acceptance: one command produces a 60-frame deterministic sequence on both backends, with the renderer +line confirmed, without a window appearing on the user's desktop; the shimmer class of bug (jitter, +disocclusion, AO noise) shows up as a number from that sequence. + +**Built 2026-09-12.** `OPTIMUM_HEADLESS` (hidden window - the surfaceless-device alternative was rejected +because there is no surfaceless GL path in this client, so it would not be symmetric across backends), +`OPTIMUM_HEADLESS_COMMANDS` (chat-command script: the section 0 scene and vanilla's own `SystemCinematicCamera` +via `.cam load` / `.cam play`), `OPTIMUM_HEADLESS_FIXED_DT` (pins `ClientMain.DeltaTimeLimiter`), +`OPTIMUM_HEADLESS_FRAMES` plus a frame list or a cadence (PPM per frame, through `ReadDefaultFramebuffer`), +`scripts/dev/headless-capture.sh` end to end. The 30 FPS background cap comes free from the window never +being focused. See `docs/vulkan-acceptance.md` section 3, "Headless render harness", for what it does not cover: +a display server is still required, reproducibility is repeatable rather than bit-exact, and no camera path +is checked in yet - which is why the acceptance sentence above is not yet a claim, only a capability. +Backported to `feat/vulkan-taa` in `af082c5`. + +### Roadmap item: GTAO (XeGTAO) replaces the vanilla SSAO + +Added 2026-09-12 at the user's request, after DLSS exposed the ambient occlusion as the last frame-wide +shimmer source. + +Vanilla's AO is hemisphere SSAO, 20 samples (24 at `SSAOLEVEL 2`), radius 0.9, with the sample kernel +rotated by a **screen-locked Bayer-128 dither** (`bayer128(texcoord * screenSize)` mapped onto a golden +spiral) and a bilateral blur, computed at half render resolution +(`.vanilla/**/assets/game/shaders/ssao.fsh`). A dither fixed to the pixel grid under a jittered camera +re-rolls each surface point's kernel every frame, which no temporal accumulator can average, and in the +DLSS path the result is composited after the upscale, where the upscaler never sees it. + +Target: **XeGTAO** (GameTechDev, MIT, Jimenez et al. 2016) - radiometrically correct horizon-slice +integral, a 5x5 depth-aware spatial denoiser, and controlled temporal noise designed to converge through a +temporal accumulator. Measured by Intel at 0.56 ms (1080p, RTX 2060) and 1.4 ms (4K, RTX 3070); bent +normals cost about 25 % more. It ships as HLSL compute for D3D12 Shader Model 6.3, so the work is a GLSL +port plus a compute path in the renderer - it belongs after Phase 3's native shaders, where compute and +the set convention already exist. + +Order of work, because the cheap parts are prerequisites and may settle the symptom on their own: +1. Composite AO inside the scene, before the upscaler evaluate, at render resolution (NVIDIA's placement + rule; also removes the magnification of a half-render-resolution buffer). +2. Make the dither temporally varying (rotate with the jitter phase) so any accumulator converges it. +3. Only then port XeGTAO, and judge it against the fixed SSAO rather than against today's. + +**Status on this branch (2026-09-15):** steps 1 and 2 are in for the TAA path (`f202d02`: the AO multiplied +into the scene before the resolve, the dither advanced per frame under `TAAMOTION`). Step 3 is roadmap step +2, after the native backend, as native compute. + +--- + +## Verification and evidence rules + +- A launch is a verification only with the `[Optimum] Vulkan renderer` / `[Optimum] OpenGL + renderer:` line in the log; both backends at every phase exit; game closed afterwards. +- Accepted evidence for temporal and pacing claims (recorded in `docs/vulkan-acceptance.md` §4): the `sync,best` validation log; a multi-frame GPU test with Present + between frames and no readback in the loop; the pacing-gate numbers; per-attachment numeric + diffs; a 60 fps `ffmpeg -f x11grab` capture with consecutive-frame region diffs for anything + called flicker; per-pass timestamps for anything called a stall; `OPTIMUM_VULKAN_POISON=1` + for anything that might read undefined memory. Screenshot pairs are never evidence. +- Every fix: GPU readback test in `Optimum.Render.Vulkan.Tests` (patterns + `VulkanDeviceIntegrationTests`, `AttachmentSemanticsTests`, multi-frame `TaaResolveTests`) and + a source-coverage test in `Optimum.Tests` (pattern `fsr-pipeline-coverage-tests.cs`). +- "OFF is vanilla" is a test, not a claim: the lib diff against `_ref/` is limited to the listed + regions and contains no renderer-specific code. + +## Risks (ranked) + +1. **Virtualization bypassed by `call`**: silent OpenGL-looking behaviour on Vulkan. Mitigated + only by the Phase 0 verifier; Phase 1A does not start without it. +2. **Injected virtual missing at runtime** (`MissingMethodException` deep in a frame): the + `InitializeGraphics` reflection self-check fails the install to the OpenGL fallback instead. +3. **The post-window fallback path is nearly untestable**: `OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE` + exercises it in the real client once per phase exit. +4. **Mass re-pointing of 14 test files in 1A.2 could paper over a regression**: pure-move commit, + bodies diffed textually, no behaviour change allowed in that commit. +5. **Write-mask semantics vs undefined attachment contents** (rule 9): keep + `AttachmentSemanticsTests` and `WorldRenderPathTests` green through Phase 2, read the sync log + before believing a picture, poison mode available. +6. **Driver tiers on Arc 140V** (color-write-enable, EDS3, descriptor indexing, timeline + semaphores): every tier forceable by env and tested; the baked-into-pipeline tier always + works; the Arc row of the vendor matrix is filled before Phase 4 exit. +7. **Streaming graph without foresight**: the plan cache; one conservative frame per settings + change or resize (which already resets TAA). +8. **Temporal contract drift** across 48 rewritten shaders: single-writer include, differential + tests, `temporal-contract-tests.cs`, explicit addendum-or-v2 decision in Phase 3. +9. **Manifest vs `collectUniformNames` disagreement**: the parity test diffs the name sets per + program per variant; a mismatch means the native shader is wrong. +10. **Full-native scope** (decision 7): every vanilla render system gets a native renderer, so the + programme is long. The game runs at every phase boundary and each system moves on its own, so the + programme can stop at any boundary and still ship. +11. **Mods without native support** (decision 8) have to reach OpenGL reliably: the launcher scan must + route them, Harmony patches on platform graphics members included, which scan v1 does not detect. + +## Documentation to update + +`VULKAN-BACKEND-PLAN.md` → v2 (§6 native shaders and sets, §9 Harmony and shader-pack rules, §10-§11 +replaced by this plan's phases and tests, §14 file list); `docs/vulkan-acceptance.md`; +`docs/parity-allowlist.md`; `docs/temporal-frame-contract.md` addendum or v2 (Phase 3); the mod +documentation for Vulkan-native support (Phase 5, decision 8); the build and diagnostics notes gain +`make check-shaders-vk`, the evidence rules and the new environment switches. + +## Critical files + +- `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs`, + `ClientPlatformWindows.cs` (`window_RenderFrame` 729, `Start` ~1086, `SetupOptimumFrameBuffers` + 1629-1916, post chain 3170-3599, `BlitPrimaryToDefault` 4008), `ClientMain.cs` + (`TriggerRenderStage`, 1402), `Vintagestory.Client/ClientProgram.cs` (214-255, 356-410, 451), + `Vintagestory.Client/ScreenManager.cs` (29, 121, `Render`), `ShaderProgramBase.cs`, `UBO.cs` +- `Optimum.Patcher/Program.cs`, `MemberInjector.cs`, `SelfConsistencyVerifier.cs` +- `VintagestoryApi/Client/optimum-render-device.cs`, `optimum-render-bootstrap.cs` +- `Optimum.Render.Vulkan/VulkanDevice.cs` (`Present` 695-733, `BlitToSwapchain` 744-799, + `PrepareDraw` 1747-1843, `AllocateIndirect` 2356-2399, `FlushFrame` 2503-2512), + `Core/FrameRing.cs`, `Core/Swapchain.cs`, `Core/TextureManager.cs`, `Core/RenderTargetManager.cs`, + `Core/VulkanAllocator.cs`, `Core/MeshManager.cs`, `Core/PipelineCache.cs`, + `Core/GlStateTracker.cs`, `Core/VulkanStats.cs`, `Shaders/ProgramInterfaceLayout.cs` +- `Optimum.Launcher/ShaderCompatibilityScanner.cs` +- `Optimum.Render.Vulkan.Tests/{VulkanDeviceIntegrationTests,AttachmentSemanticsTests, + TaaResolveTests,SwapchainTests}.cs`, `ShaderCorpus.cs`; `Optimum.Tests/ + {cecil-transplant-lambda-tests,temporal-contract-tests,fsr-pipeline-coverage-tests}.cs` + +## Not on the critical path (parallel follow-ups, own PRs) + +Shader patch system for the GL-path overrides (`patches/shaders/*.patch` against the vanilla +archive, `extract/check-shader-patches.sh`); splitting +`Optimum.Shaders` out of the renderer and a lavapipe CI job; `vkCmdDrawIndexedIndirectCount` +with GPU culling (the per-slot indirect ring is shaped for it). From 19d101e5b581e078592277e90f2bf2935cf5b86d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 19:03:32 +0200 Subject: [PATCH 131/226] fix(scripts): the review's portability blockers - shaderc placement, prime-run, numpy, Windows bootstrap - libshaderc_shared goes into the application root on every path (make deploy, the install copy, and the Windows, Linux and macOS packagers). Silk.NET.Shaderc probes the application directory and LD_LIBRARY_PATH, not Lib/, so the copy in Lib/ was never found and the client fell back to OpenGL without saying why. - run-client.sh no longer requires Arch's prime-run: it uses prime-run where it exists, otherwise sets the standard PRIME offload variables when the NVIDIA driver is loaded, and launches plainly on machines without it. - check-prereqs.sh reports numpy, which scripts/dev/ssim.py and taa-rejection.py (and their self-tests in Optimum.Tests) import. - bootstrap.sh strips the carriage return a Windows python3 leaves on the last field of the forks.json read loops; bootstrap.ps1 calls the built-in Expand-Archive by its module-qualified name, so a Pscx Expand-Archive earlier on PSModulePath cannot shadow it. The teardown-order blocker needs no change: VulkanClientPlatform.ShutdownGraphics disposes the device, whose Dispose waits for the device to go idle before destroying anything, and ClientProgram disposes the window only after that; framebuffers deleted before it are destroyed on the frame timeline. Verified: bash -n on every edited shell script, the PowerShell parser on both .ps1 files, check-prereqs.sh run on Windows. Not run: make deploy (no make on this host) and a Linux launch. --- Makefile | 7 +++++-- scripts/bootstrap.ps1 | 4 +++- scripts/bootstrap.sh | 4 ++++ scripts/check-prereqs.sh | 12 ++++++++++++ scripts/dev/run-client.sh | 13 ++++++++++++- scripts/package-linux.sh | 6 ++++-- scripts/package-macos.sh | 3 ++- scripts/package.ps1 | 6 ++++-- 8 files changed, 46 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index bf8f73ab..eb0a5f77 100644 --- a/Makefile +++ b/Makefile @@ -106,7 +106,10 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client @# stale copy here makes the probe throw and the client fall back to OpenGL. @cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(VANILLA_DIR)/ @cp $(MOD_OUT)/Silk.NET.*.dll $(VANILLA_DIR)/ - @if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(VANILLA_DIR)/Lib/; fi + @# shaderc goes into the application root: Silk.NET.Shaderc probes the application + @# directory and LD_LIBRARY_PATH, not Lib/, and a copy it cannot find makes the + @# renderer fall back to OpenGL silently. + @if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(VANILLA_DIR)/; fi @# Every file, not *.fsh plus *.vsh: the packagers copy the whole directory, @# and a stage that ships only on one of the two paths is the bug the @# completeness check below exists to catch. @@ -136,7 +139,7 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client cp $(MOD_OUT)/VSCreativeMod.dll $(INSTALL_DIR)/Mods/; \ cp $(MOD_OUT)/cairo-sharp.dll $(INSTALL_DIR)/Lib/; \ cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(INSTALL_DIR)/; cp $(MOD_OUT)/Silk.NET.*.dll $(INSTALL_DIR)/; \ - if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(INSTALL_DIR)/Lib/; fi; \ + if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(INSTALL_DIR)/; fi; \ for f in sources/shaders/*; do [ -f "$$f" ] || continue; cp -f "$$f" "$(INSTALL_DIR)/assets/game/shaders/$$(basename $$f)" || exit 1; done; \ if [ -d "sources/shaderincludes" ]; then mkdir -p $(INSTALL_DIR)/assets/game/shaderincludes; for f in sources/shaderincludes/*; do [ -f "$$f" ] || continue; cp -f "$$f" "$(INSTALL_DIR)/assets/game/shaderincludes/$$(basename $$f)" || exit 1; done; fi; \ for f in sources/shaders/* sources/shaderincludes/*; do [ -f "$$f" ] || continue; d="$(INSTALL_DIR)/assets/game/$$(echo $$f | cut -d/ -f2)/$$(basename $$f)"; cmp -s "$$f" "$$d" || { echo "Error: $$f did not reach $$d (missing or content differs)"; exit 1; }; done; \ diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 9ab87845..2b815d95 100644 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -601,7 +601,9 @@ try { } Move-Item -Force $innounpPartial $innounpZip - Expand-Archive -Path $innounpZip -DestinationPath $toolsDir -Force + # Module-qualified: another module's Expand-Archive earlier on PSModulePath + # (Pscx ships one) shadows the built-in cmdlet and has no -DestinationPath. + Microsoft.PowerShell.Archive\Expand-Archive -Path $innounpZip -DestinationPath $toolsDir -Force $found = Get-ChildItem -Path $toolsDir -Recurse -Filter 'innounp.exe' | Select-Object -First 1 if ($found -and $found.FullName -ne $innounp) { Copy-Item -Force $found.FullName $innounp diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index ae81288d..f29402c9 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -462,6 +462,9 @@ done forks_file="$repo_root/forks.json" if [[ -f "$forks_file" ]]; then while IFS=$'\t' read -r name url ref; do + # A Windows python3 writes CRLF into the pipe and read keeps the \r on the last + # field, so "git checkout \r" fails with an unknown pathspec. + ref="${ref%$'\r'}" base="$snapshot_dir/$name" if [[ ! -d "$base" || "$refresh" == "1" ]]; then @@ -499,6 +502,7 @@ fi ref_dir="$repo_root/ref/source" if [[ -f "$forks_file" ]]; then while IFS=$'\t' read -r name url ref; do + ref="${ref%$'\r'}" dest="$ref_dir/$name" if [[ ! -d "$dest" ]]; then echo "Cloning reference: $name" diff --git a/scripts/check-prereqs.sh b/scripts/check-prereqs.sh index 006b619c..20ba78b7 100644 --- a/scripts/check-prereqs.sh +++ b/scripts/check-prereqs.sh @@ -17,6 +17,7 @@ checks=( "git|1|bootstrap.sh, extract-patches.sh, package-macos.ps1|apt-get install git" "perl|1|bootstrap.sh, extract-patches.sh|apt-get install perl" "python3|1|bootstrap.sh|apt-get install python3" + "numpy|0|scripts/dev/ssim.py, taa-rejection.py and their Optimum.Tests self-tests|apt-get install python3-numpy (or: python3 -m pip install numpy)" "curl|1|bootstrap.sh, package-*.ps1|apt-get install curl" "tar|1|bootstrap.sh, package-*.ps1|apt-get install tar" "unzip|0|bootstrap.sh (zip archives; python3 fallback exists)|apt-get install unzip" @@ -35,6 +36,17 @@ printf '%-14s %-10s %s\n' "----" "------" "-------" for entry in "${checks[@]}"; do IFS='|' read -r name required used hint <<< "$entry" + # numpy is a python module, not a command: probe it through python3. + if [[ "$name" == "numpy" ]]; then + if python3 -c "import numpy" >/dev/null 2>&1; then + printf '%-14s %s%-8s %s\n' "$name" "$(green OK)" "" "$used" + else + printf '%-14s %s%-2s %s\n' "$name" "$(yellow optional)" "" "$used" + printf ' %s %s\n' "$(yellow '→ install only if needed:')" "$hint" + missing_optional=$((missing_optional + 1)) + fi + continue + fi if command -v "$name" >/dev/null 2>&1; then if [[ "$name" == "innoextract" ]]; then inno_version="$(innoextract --version 2>/dev/null | sed -n 's/^innoextract \([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1.\2/p' | head -n 1 || true)" diff --git a/scripts/dev/run-client.sh b/scripts/dev/run-client.sh index 0dabd2b8..56b502c1 100755 --- a/scripts/dev/run-client.sh +++ b/scripts/dev/run-client.sh @@ -27,6 +27,17 @@ PY fi fi cd "$CLIENT" || exit 1 -setsid prime-run dotnet Vintagestory.dll --dataPath "$DATA_PATH" -o "$WORLD" > "$LOG" 2>&1 < /dev/null & +# PRIME render offload onto the discrete NVIDIA GPU. prime-run is Arch's wrapper and +# is missing on Debian, Ubuntu, Mint, Fedora and openSUSE; the variables it sets are +# the standard ones, so set them directly when the NVIDIA driver is loaded. Without +# that driver they would point GLX at a vendor library that is not installed, so a +# machine without it launches plainly. +LAUNCH=(dotnet) +if command -v prime-run >/dev/null 2>&1; then + LAUNCH=(prime-run dotnet) +elif [[ -e /proc/driver/nvidia/version ]]; then + export __NV_PRIME_RENDER_OFFLOAD=1 __GLX_VENDOR_LIBRARY_NAME=nvidia __VK_LAYER_NV_optimus=NVIDIA_only +fi +setsid "${LAUNCH[@]}" Vintagestory.dll --dataPath "$DATA_PATH" -o "$WORLD" > "$LOG" 2>&1 < /dev/null & disown echo "launched; log: $LOG" diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 6cc15fb3..36171c80 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -296,10 +296,12 @@ for silk_dll in "$MOD_OUT"/Silk.NET.*.dll; do [[ -f "$silk_dll" ]] && cp -f "$silk_dll" "$STAGE_DIR/" done -# shaderc is a native library; the game loads natives out of Lib/. +# shaderc is a native library loaded by Silk.NET.Shaderc, which probes the +# application directory and LD_LIBRARY_PATH, not Lib/ - a copy it cannot find +# makes the renderer fall back to OpenGL silently. SHADERC_NATIVE="$MOD_OUT/runtimes/linux-x64/native/libshaderc_shared.so" if [[ -f "$SHADERC_NATIVE" ]]; then - cp -f "$SHADERC_NATIVE" "$STAGE_DIR/Lib/" + cp -f "$SHADERC_NATIVE" "$STAGE_DIR/" else echo "warning: no native shaderc at $SHADERC_NATIVE; the Vulkan renderer will not load" >&2 fi diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index a6eedfcf..3de12401 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -169,9 +169,10 @@ cp -f "$MOD_OUT/Optimum.Render.Vulkan.dll" "$APP_DIR/" for silk_dll in "$MOD_OUT"/Silk.NET.*.dll; do [[ -f "$silk_dll" ]] && cp -f "$silk_dll" "$APP_DIR/" done +# shaderc goes into the application directory, where Silk.NET.Shaderc probes; not Lib/. SHADERC_NATIVE="$MOD_OUT/runtimes/osx-x64/native/libshaderc_shared.dylib" if [[ -f "$SHADERC_NATIVE" ]]; then - cp -f "$SHADERC_NATIVE" "$APP_DIR/Lib/" + cp -f "$SHADERC_NATIVE" "$APP_DIR/" else echo "warning: no native shaderc at $SHADERC_NATIVE; the Vulkan renderer will not load" >&2 fi diff --git a/scripts/package.ps1 b/scripts/package.ps1 index fe1d2b9d..02a7c9de 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -275,10 +275,12 @@ try { Get-ChildItem -Path $apiOut -Filter 'Silk.NET.*.dll' | ForEach-Object { Copy-Item -Force $_.FullName $stageDir } - # shaderc is a native library; the game loads natives out of Lib\. + # shaderc is a native library loaded by Silk.NET.Shaderc, which probes the + # application directory, not Lib\ - a copy it cannot find makes the renderer + # fall back to OpenGL silently. $shadercNative = Join-Path $apiOut (Join-Path 'runtimes' (Join-Path 'win-x64' (Join-Path 'native' 'shaderc_shared.dll'))) if (Test-Path $shadercNative) { - Copy-Item -Force $shadercNative (Join-Path $stageDir 'Lib') + Copy-Item -Force $shadercNative $stageDir } else { Write-Warning "No native shaderc at $shadercNative; the Vulkan renderer will not load" } From a41efcee84e6c4179899530ad17cbf7d1a2f5c5b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 19:26:25 +0200 Subject: [PATCH 132/226] feat(vulkan): values every program shares live in one frame block, not in each program ShaderProgramBase.Use() writes the same DefaultShaderUniforms values - fog and light, the shadow cascades, the vertex warp, the sky colour, the colour map and the underwater effect - into every program that includes the file declaring them, up to 56 writes per program switch. Each program carried its own copy, and every draw copied its whole uniform shadow into the frame's ring whether anything had changed or not. - FrameGlobals: a fixed-layout shared block at set 0, binding 0, sized for the largest declaration the game produces (100 dynamic lights). A uniform reads it only when the program includes the file whose Use() block writes it and the declaration matches; the GUI's own lightPosition, a fragment-only flatFogDensity, frameSize (written by the blur passes) and every program built without include information keep a copy of their own, as on OpenGL. - The device keeps one shadow for it: a write that changes nothing is a comparison, a change bumps one version, and every draw in the frame binds the same ring snapshot through one descriptor set shared by all programs. - Sets are ordered by change frequency: 0 frame, 1 samplers, 2 storage, 3 the program's own uniform blocks. - A program's own block is copied into the ring only when it changed since that program's last snapshot this frame; the dirty flag that was set and never read is gone. Tests: FrameGlobalsTests pin the layout, the placement rule, the rewriter's emission, the declared defaults, and - against the game's files - that every member is declared by its owning include with the table's type and written by Use() inside that include's block. FrameGlobalsDeviceTests show on a device that two programs including the owner share one value while a program without it keeps its own. Verified: build 0 errors; Optimum.Tests 1171 passed, 6 host-environment failures unchanged; Optimum.Render.Vulkan.Tests 631 of 635 passed (8 new), the PacingStats path failure and 3 validation-layer skips unchanged. Not yet run in game. --- .../FrameGlobalsDeviceTests.cs | 130 +++++++++++ .../FrameGlobalsTests.cs | 209 ++++++++++++++++++ Optimum.Render.Vulkan.Tests/FrameRingTests.cs | 2 + .../ShaderTranslationUnitTests.cs | 2 +- Optimum.Render.Vulkan/Core/DescriptorCache.cs | 6 +- .../Core/ShaderProgramResources.cs | 125 ++++++++--- Optimum.Render.Vulkan/Shaders/FrameGlobals.cs | 198 +++++++++++++++++ .../Shaders/ProgramInterfaceLayout.cs | 62 +++++- .../Shaders/ShaderRewriter.cs | 47 +++- .../Shaders/ShaderTranslator.cs | 10 +- Optimum.Render.Vulkan/VulkanDevice.cs | 110 ++++++++- 11 files changed, 853 insertions(+), 48 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs create mode 100644 Optimum.Render.Vulkan/Shaders/FrameGlobals.cs diff --git a/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs b/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs new file mode 100644 index 00000000..adf84fc1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs @@ -0,0 +1,130 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The shared frame block on a real device: programs assembled from the same +/// include read one copy of a frame value, a program that does not include it keeps +/// its own, and a program's own uniforms stay its own. +/// +public class FrameGlobalsDeviceTests(ITestOutputHelper output) +{ + private const int Size = 4; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 texCoord; + void main() + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + private const string ZNearFragment = """ + #version 330 core + uniform float zNear; + uniform float tint; + in vec2 texCoord; + out vec4 outColor; + void main() { outColor = vec4(zNear, tint, 0.0, 1.0); } + """; + + [SkippableFact] + public void ProgramsThatIncludeTheOwnerShareOneCopyAndOthersKeepTheirOwn() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int first = Link(seam, "shared-a", includeFog: true); + int second = Link(seam, "shared-b", includeFog: true); + int isolated = Link(seam, "isolated", includeFog: false); + + int firstZNear = seam.GetUniformLocation(first, "zNear"); + int secondZNear = seam.GetUniformLocation(second, "zNear"); + int isolatedZNear = seam.GetUniformLocation(isolated, "zNear"); + Assert.True(ShaderProgramResources.IsFrameLocation(firstZNear)); + Assert.Equal(firstZNear, secondZNear); + Assert.False(ShaderProgramResources.IsFrameLocation(isolatedZNear)); + Assert.True(isolatedZNear >= 0); + Assert.False(ShaderProgramResources.IsFrameLocation(seam.GetUniformLocation(first, "tint"))); + + // Written once, through the first program. + seam.UseProgram(first); + seam.SetUniform(first, firstZNear, 0.25f); + Assert.True(FrameGlobals.TryGetMember("zNear", out UniformMember member)); + Assert.Equal(0.25f, BitConverter.ToSingle(seam.FrameGlobalsForTests, member.Offset)); + + seam.SetUniform(second, seam.GetUniformLocation(second, "tint"), 0.75f); + seam.SetUniform(isolated, seam.GetUniformLocation(isolated, "tint"), 0.75f); + + int secondTarget = Target(seam, out int secondColour); + int isolatedTarget = Target(seam, out int isolatedColour); + seam.SetViewport(0, 0, Size, Size); + seam.SetCullFace(false); + seam.SetDepthTest(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + Draw(seam, second, secondTarget); + Draw(seam, isolated, isolatedTarget); + + seam.BeginFrame(); + byte[] shared = seam.ReadBackLevel0ForTests(secondColour); + byte[] own = seam.ReadBackLevel0ForTests(isolatedColour); + seam.Present(); + + output.WriteLine($"second program: R={shared[0]} G={shared[1]}; isolated program: R={own[0]} G={own[1]}"); + // The second program never wrote zNear, yet reads the first's value. + Assert.InRange(shared[0], 63, 65); + Assert.InRange(shared[1], 190, 192); + // The isolated program reads its own zNear, never written: zero. + Assert.Equal(0, own[0]); + Assert.InRange(own[1], 190, 192); + + GpuTest.AssertClean(seam); + } + } + + private static int Link(VulkanDevice seam, string name, bool includeFog) + { + var vertex = new Shader(EnumShaderType.VertexShader, FullscreenVertex, name + ".vsh"); + var fragment = new Shader(EnumShaderType.FragmentShader, ZNearFragment, name + ".fsh"); + Assert.True(seam.CompileShader(vertex)); + Assert.True(seam.CompileShader(fragment)); + + var program = new ShaderProgram { PassName = name, VertexShader = vertex, FragmentShader = fragment }; + if (includeFog) program.includes.Add("fogandlight.fsh"); + + int id = seam.LinkProgram(program); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + return id; + } + + private static int Target(VulkanDevice seam, out int colour) + { + int target = seam.CreateFramebuffer(Size, Size); + colour = seam.CreateTexture2DRaw(Size, Size, 0x8058, IntPtr.Zero, 4); + seam.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + return target; + } + + private static void Draw(VulkanDevice seam, int program, int target) + { + seam.BeginFrame(); + seam.BindFramebuffer(target); + seam.SetDrawBuffers(target, 1); + seam.ClearColor(0, 0, 0, 0, 1); + seam.UseProgram(program); + seam.DrawFullscreenTriangle(); + seam.Present(); + } +} diff --git a/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs new file mode 100644 index 00000000..3ebcac5e --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The shared frame block: its fixed layout, the rule that decides which of a +/// program's uniforms read it, how the rewriter emits it, and - against the game's +/// own files - that the table names exactly what ShaderProgramBase.Use() +/// writes, with the types the includes declare. +/// +public class FrameGlobalsTests +{ + private static readonly HashSet AllOwners = new(StringComparer.Ordinal) + { + "fogandlight.fsh", "fogandlight.vsh", "shadowcoords.vsh", "vertexwarp.vsh", + "skycolor.fsh", "colormap.vsh", "underwatereffects.fsh", + }; + + private static ProgramInterfaceLayout LayoutOf(IReadOnlySet? includes, params (EnumShaderType Stage, string Source)[] stages) + { + var parsed = stages.Select(s => (s.Stage, GlslParser.Parse(s.Source))).ToList(); + return ProgramInterfaceLayout.Build(parsed, null, includes); + } + + [Fact] + public void MembersAreScalarAlignedInOrderAndNeverOverlap() + { + int end = 0; + foreach (UniformMember member in FrameGlobals.Members) + { + Assert.True(member.Offset >= end, member.Name + " overlaps the member before it"); + Assert.Equal(0, member.Offset % 4); + Assert.Equal(member.Type.Size * member.ElementCount, member.Size); + end = member.Offset + member.Size; + } + Assert.Equal(end, FrameGlobals.BlockSize); + // Small enough that a snapshot per change is nothing next to a frame. + Assert.True(FrameGlobals.BlockSize < 8192, "frame block is " + FrameGlobals.BlockSize + " bytes"); + } + + [Fact] + public void AMemberJoinsOnlyWhenTheProgramIncludesItsOwnerAndTheDeclarationFits() + { + GlslType vec3 = GetType("vec3"); + GlslType floatType = GetType("float"); + var fog = new HashSet(StringComparer.Ordinal) { "fogandlight.vsh" }; + + Assert.True(FrameGlobals.TryPlace("pointLights", vec3, 4, fog, out _)); + Assert.True(FrameGlobals.TryPlace("pointLights", vec3, FrameGlobals.MaxDynamicLights, fog, out _)); + // Longer than the shared capacity, or not an array where the member is one. + Assert.False(FrameGlobals.TryPlace("pointLights", vec3, FrameGlobals.MaxDynamicLights + 1, fog, out _)); + Assert.False(FrameGlobals.TryPlace("pointLights", vec3, 0, fog, out _)); + // A different type. + Assert.False(FrameGlobals.TryPlace("pointLights", floatType, 4, fog, out _)); + // The owner is not included: the GUI program's own lightPosition, say. + Assert.False(FrameGlobals.TryPlace("lightPosition", vec3, 0, fog, out _)); + Assert.False(FrameGlobals.TryPlace("pointLights", vec3, 4, null, out _)); + // frameSize is written outside Use() by the blur passes, so it is never shared. + Assert.False(FrameGlobals.TryGetMember("frameSize", out _)); + } + + [Fact] + public void AProgramThatIncludesTheOwnerReadsTheSharedBlockAndKeepsTheRestToItself() + { + const string vertex = """ + #version 330 core + uniform vec3 pointLights[4]; + uniform float viewDistance; + uniform float tint; + void main() {} + """; + const string fragment = """ + #version 330 core + uniform float viewDistance; + out vec4 outColor; + void main() { outColor = vec4(viewDistance); } + """; + var includes = new HashSet(StringComparer.Ordinal) { "fogandlight.vsh" }; + + ProgramInterfaceLayout layout = LayoutOf(includes, + (EnumShaderType.VertexShader, vertex), (EnumShaderType.FragmentShader, fragment)); + + Assert.True(layout.UsesFrameBlock); + Assert.Equal(4, layout.FrameMemberDeclaredLengths["pointLights"]); + Assert.Equal(0, layout.FrameMemberDeclaredLengths["viewDistance"]); + Assert.Contains("viewDistance", layout.FrameMembersByStage[EnumShaderType.FragmentShader]); + Assert.DoesNotContain("pointLights", layout.FrameMembersByStage[EnumShaderType.FragmentShader]); + // Only the program's own uniform is left in its block. + Assert.Equal(new[] { "tint" }, layout.Members.Select(m => m.Name)); + + string code = ShaderRewriter.Rewrite(GlslParser.Parse(vertex), layout, EnumShaderType.VertexShader, emitDepthRemap: true).Code; + FrameGlobals.TryGetMember("pointLights", out UniformMember lights); + FrameGlobals.TryGetMember("viewDistance", out UniformMember distance); + Assert.Contains("layout(scalar, set = 0, binding = 0) uniform OptimumFrameGlobals", code); + Assert.Contains($"layout(offset = {lights.Offset}) vec3 pointLights[4];", code); + Assert.Contains($"layout(offset = {distance.Offset}) float viewDistance;", code); + Assert.Contains("layout(scalar, set = 3, binding = 0) uniform OptimumUniforms", code); + Assert.DoesNotContain("uniform vec3 pointLights[4];", code); + } + + [Fact] + public void WithoutIncludesEveryUniformStaysTheProgramsOwn() + { + ProgramInterfaceLayout layout = LayoutOf(null, (EnumShaderType.VertexShader, """ + #version 330 core + uniform float zNear; + void main() {} + """)); + + Assert.False(layout.UsesFrameBlock); + Assert.Contains("zNear", layout.MembersByName.Keys); + } + + [Fact] + public void TheSharedShadowStartsWithTheDeclaredDefaults() + { + byte[] shadow = FrameGlobals.CreateShadow(); + Assert.Equal(FrameGlobals.BlockSize, shadow.Length); + Assert.Equal(0.3f, ReadFloat(shadow, "zNear")); + Assert.Equal(1500f, ReadFloat(shadow, "zFar")); + Assert.Equal(1f, ReadFloat(shadow, "windWaveIntensity")); + FrameGlobals.TryGetMember("perceptionEffectId", out UniformMember id); + Assert.Equal(1, BitConverter.ToInt32(shadow, id.Offset)); + } + + /// + /// Every member is declared by its owning include with the table's type, and + /// its array fits the shared capacity. A game update that changes one of these + /// declarations fails here rather than reading the wrong bytes on screen. + /// + [SkippableFact] + public void TheOwningIncludesDeclareEveryMemberWithTheSameType() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + Dictionary includes = ShaderCorpus.LoadIncludes(); + + foreach (UniformMember member in FrameGlobals.Members) + { + string owner = FrameGlobals.OwnerOf(member.Name)!; + Assert.True(includes.TryGetValue(owner, out string? source), owner + " is missing"); + // Declarations can sit in a file the owner includes (fogSpheres lives in + // fogspheres.ash); the registry records every nested include too. + source = ShaderCorpus.ExpandIncludes(source!, includes); + Match declaration = Regex.Match(source, + @"uniform\s+(\w+)\s+" + Regex.Escape(member.Name) + @"\b\s*(?:\[([^\]]*)\])?"); + Assert.True(declaration.Success, owner + " does not declare " + member.Name); + Assert.Equal(member.Type.Name, declaration.Groups[1].Value); + + string size = declaration.Groups[2].Value.Trim(); + if (member.ArrayLength == 0) + { + Assert.True(size.Length == 0, member.Name + " is declared as an array in " + owner); + continue; + } + int declared = size == "DYNLIGHTS" + ? FrameGlobals.MaxDynamicLights + : size.Split('*').Select(part => int.Parse(part.Trim(), System.Globalization.CultureInfo.InvariantCulture)) + .Aggregate(1, (a, b) => a * b); + Assert.True(declared <= member.ArrayLength, member.Name + " is declared longer than the shared capacity"); + } + } + + /// + /// Every member is written by Use() inside its owner's include block. A + /// member written anywhere else would be clobbered by other programs sharing it. + /// + [SkippableFact] + public void UseWritesEveryMemberInsideItsOwnersBlock() + { + string path = Path.Combine(ShaderCorpus.RepositoryRoot, "build", "VintagestoryLib", + "Vintagestory.Client.NoObf", "ShaderProgramBase.cs"); + Skip.IfNot(File.Exists(path), "No bootstrapped build tree."); + string source = File.ReadAllText(path); + int use = source.IndexOf("public void Use()", StringComparison.Ordinal); + Assert.True(use > 0); + string body = source[use..source.IndexOf("public void Stop()", use, StringComparison.Ordinal)]; + + foreach (UniformMember member in FrameGlobals.Members) + { + string owner = FrameGlobals.OwnerOf(member.Name)!; + Assert.Contains(owner, AllOwners); + int block = body.IndexOf("includes.Contains(\"" + owner + "\")", StringComparison.Ordinal); + Assert.True(block > 0, "Use() has no block for " + owner); + int next = body.IndexOf("includes.Contains(", block + 1, StringComparison.Ordinal); + int end = next > 0 ? next : body.Length; + Assert.True(body.IndexOf("\"" + member.Name + "\"", block, end - block, StringComparison.Ordinal) > 0, + "Use() does not write " + member.Name + " in the " + owner + " block"); + } + } + + private static float ReadFloat(byte[] shadow, string name) + { + Assert.True(FrameGlobals.TryGetMember(name, out UniformMember member)); + return BitConverter.ToSingle(shadow, member.Offset); + } + + private static GlslType GetType(string name) + { + Assert.True(GlslType.TryParse(name, out GlslType type)); + return type; + } +} diff --git a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs index 6dc15af8..dccb9fa1 100644 --- a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs @@ -228,6 +228,8 @@ public void SlotsAllocateFromDisjointRegionsOfOneSharedBuffer() [Fact] public void DescriptorBindingConstantsAgreeWithTheShaderRewriter() { + Assert.Equal(ProgramInterfaceLayout.FrameSet, ProgramInterfaceLayoutBindings.FrameSet); + Assert.Equal(FrameGlobals.Binding, ProgramInterfaceLayoutBindings.FrameBinding); Assert.Equal(ProgramInterfaceLayout.DefaultBlockSet, ProgramInterfaceLayoutBindings.DefaultBlockSet); Assert.Equal(ProgramInterfaceLayout.DefaultBlockBinding, ProgramInterfaceLayoutBindings.DefaultBlockBinding); Assert.Equal(ProgramInterfaceLayout.SamplerSet, ProgramInterfaceLayoutBindings.SamplerSet); diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs index cf74d798..fae745d5 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs @@ -310,7 +310,7 @@ void main() {} ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, source)); string code = RewriteVertex(source, layout); - Assert.Contains("layout(scalar, set = 0, binding = 0) uniform OptimumUniforms", code); + Assert.Contains("layout(scalar, set = 3, binding = 0) uniform OptimumUniforms", code); Assert.Contains("layout(offset = 0) float zNear;", code); Assert.Contains("layout(offset = 4) vec3 tint;", code); // The originals are gone, so the names resolve to the block members. diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs index 1236ef4c..131fe468 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorCache.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -463,8 +463,10 @@ public void Dispose() /// internal static class ProgramInterfaceLayoutBindings { - public const int DefaultBlockSet = 0; - public const int DefaultBlockBinding = 0; + public const int FrameSet = 0; + public const int FrameBinding = 0; public const int SamplerSet = 1; public const int StorageSet = 2; + public const int DefaultBlockSet = 3; + public const int DefaultBlockBinding = 0; } diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs index c03c40b8..351c8f6e 100644 --- a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -16,11 +16,16 @@ namespace Optimum.Render.Vulkan.Core; /// uniforms one at a time by name, at any point before a draw, and expects the /// values to persist for the life of the program. So writes land in this buffer, /// and a draw copies it into the frame's uniform ring only when something -/// changed. +/// changed since the snapshot it last took. +/// +/// Values every program shares - fog, light, shadow cascades, warp, sky - are not +/// in this buffer at all: they live in the device's frame block (set 0, +/// ), and their locations point there. /// internal sealed unsafe class ShaderProgramResources : IDisposable { private readonly VulkanContext _context; + private readonly bool _ownsFrameLayout; private bool _disposed; public int ProgramId { get; } @@ -28,15 +33,23 @@ internal sealed unsafe class ShaderProgramResources : IDisposable public Dictionary Modules { get; } = new(); - /// Set 0 uniforms, set 1 samplers, set 2 storage buffers. - public DescriptorSetLayout[] SetLayouts { get; } = new DescriptorSetLayout[3]; + /// + /// Set 0 the shared frame block, set 1 samplers, set 2 storage buffers, set 3 + /// the program's own uniform blocks. + /// + public DescriptorSetLayout[] SetLayouts { get; } = new DescriptorSetLayout[ProgramInterfaceLayout.SetCount]; public PipelineLayout PipelineLayout { get; private set; } /// CPU mirror of the generated uniform block. public byte[] UniformShadow { get; } - /// True when the shadow has changed since it was last uploaded. - public bool UniformsDirty { get; private set; } = true; + /// Bumped by every write that changes the shadow. + public uint UniformVersion { get; private set; } = 1; + + /// Which frame's ring holds the last snapshot of the shadow, taken at which version, where. + public uint SnapshotFrame { get; private set; } + public uint SnapshotVersion { get; private set; } + public uint SnapshotOffset { get; private set; } /// /// Which texture unit each sampler uniform points at. In GL this is just an @@ -44,8 +57,12 @@ internal sealed unsafe class ShaderProgramResources : IDisposable /// public Dictionary SamplerUnits { get; } = new(StringComparer.Ordinal); + /// + /// The device's shared frame set layout. Programs built outside a device - in + /// tests - pass none and get a layout of their own with the same shape. + /// public ShaderProgramResources( - VulkanContext context, int programId, TranslatedProgram translated) + VulkanContext context, int programId, TranslatedProgram translated, DescriptorSetLayout frameLayout = default) { _context = context; ProgramId = programId; @@ -64,6 +81,13 @@ public ShaderProgramResources( SamplerUnits[sampler.Name] = sampler.Binding; } + if (frameLayout.Handle == 0) + { + frameLayout = CreateFrameSetLayout(context); + _ownsFrameLayout = true; + } + SetLayouts[ProgramInterfaceLayout.FrameSet] = frameLayout; + CreateSetLayouts(); CreatePipelineLayout(); } @@ -89,16 +113,35 @@ private ShaderModule CreateModule(byte[] spirv) } /// - /// Builds one layout per set. Stage visibility is set to all graphics stages - /// rather than tracked per binding: the sets are tiny, the cost of a wider - /// visibility is nil, and a uniform shared between stages - which GL makes - /// routine - would otherwise need its visibility recomputed on every link. + /// Stage visibility is set to all graphics stages rather than tracked per + /// binding: the sets are tiny, the cost of a wider visibility is nil, and a + /// uniform shared between stages - which GL makes routine - would otherwise + /// need its visibility recomputed on every link. /// - private void CreateSetLayouts() + private const ShaderStageFlags AllGraphics = + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit | ShaderStageFlags.GeometryBit; + + /// + /// The shared frame block's set layout: one dynamic uniform buffer. Identical + /// for every program, so one descriptor set in the frame's uniform ring serves + /// all of them and only the dynamic offset moves when the block changes. + /// + public static DescriptorSetLayout CreateFrameSetLayout(VulkanContext context) { - const ShaderStageFlags allGraphics = - ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit | ShaderStageFlags.GeometryBit; + return CreateSetLayout(context, new List + { + new() + { + Binding = FrameGlobals.Binding, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = AllGraphics, + }, + }); + } + private void CreateSetLayouts() + { var uniformBindings = new List(); if (Interface.HasUniformBlock) { @@ -107,7 +150,7 @@ private void CreateSetLayouts() Binding = ProgramInterfaceLayout.DefaultBlockBinding, DescriptorType = DescriptorType.UniformBufferDynamic, DescriptorCount = 1, - StageFlags = allGraphics, + StageFlags = AllGraphics, }); } // A block the shader declares for itself is dynamic for the same reason @@ -121,7 +164,7 @@ private void CreateSetLayouts() Binding = (uint)block.Binding, DescriptorType = DescriptorType.UniformBufferDynamic, DescriptorCount = 1, - StageFlags = allGraphics, + StageFlags = AllGraphics, }); } @@ -133,7 +176,7 @@ private void CreateSetLayouts() Binding = (uint)sampler.Binding, DescriptorType = DescriptorType.CombinedImageSampler, DescriptorCount = 1, - StageFlags = allGraphics, + StageFlags = AllGraphics, }); } @@ -145,16 +188,16 @@ private void CreateSetLayouts() Binding = (uint)block.Binding, DescriptorType = DescriptorType.StorageBuffer, DescriptorCount = 1, - StageFlags = allGraphics, + StageFlags = AllGraphics, }); } - SetLayouts[ProgramInterfaceLayout.DefaultBlockSet] = CreateSetLayout(uniformBindings); - SetLayouts[ProgramInterfaceLayout.SamplerSet] = CreateSetLayout(samplerBindings); - SetLayouts[ProgramInterfaceLayout.StorageSet] = CreateSetLayout(storageBindings); + SetLayouts[ProgramInterfaceLayout.DefaultBlockSet] = CreateSetLayout(_context, uniformBindings); + SetLayouts[ProgramInterfaceLayout.SamplerSet] = CreateSetLayout(_context, samplerBindings); + SetLayouts[ProgramInterfaceLayout.StorageSet] = CreateSetLayout(_context, storageBindings); } - private DescriptorSetLayout CreateSetLayout(List bindings) + private static DescriptorSetLayout CreateSetLayout(VulkanContext context, List bindings) { // An empty set is still created rather than skipped, so set numbering // stays fixed: samplers are always set 1 whether or not the program has @@ -169,8 +212,8 @@ private DescriptorSetLayout CreateSetLayout(List bin PBindings = array.Length == 0 ? null : bindingsPtr, }; - if (_context.Api.CreateDescriptorSetLayout( - _context.Device, &createInfo, null, out DescriptorSetLayout layout) != Result.Success) + if (context.Api.CreateDescriptorSetLayout( + context.Device, &createInfo, null, out DescriptorSetLayout layout) != Result.Success) { throw new InvalidOperationException("vkCreateDescriptorSetLayout failed"); } @@ -207,9 +250,18 @@ private void CreatePipelineLayout() /// private const int FirstSamplerLocation = -2; + /// + /// Where frame block locations start: the member's offset in the shared block + /// plus this. Far above any per-program block, so the two ranges never meet. + /// + public const int FrameLocationBase = 1 << 28; + /// Whether a location handed out by names a sampler. public static bool IsSamplerLocation(int location) => location <= FirstSamplerLocation; + /// Whether a location handed out by is in the shared frame block. + public static bool IsFrameLocation(int location) => location >= FrameLocationBase; + private static int SamplerIndexOf(int location) => FirstSamplerLocation - location; /// @@ -220,10 +272,17 @@ private void CreatePipelineLayout() /// bindings - but the client looks every declared uniform up by name and /// treats a -1 as "the shader does not use this". Returning -1 for samplers /// would tell it that every texture uniform in the game is unused, so they - /// get locations of their own from a disjoint range. + /// get locations of their own from a disjoint range. Members of the shared + /// frame block get a third range. /// public int LocationOf(string name) { + if (Interface.FrameMemberDeclaredLengths.ContainsKey(name) && + FrameGlobals.TryGetMember(name, out UniformMember frameMember)) + { + return FrameLocationBase + frameMember.Offset; + } + if (Interface.MembersByName.TryGetValue(name, out UniformMember? member)) { return member.Offset; @@ -263,10 +322,18 @@ public void SetUniform(int offset, ReadOnlySpan data) if (data.SequenceEqual(destination)) return; data.CopyTo(destination); - UniformsDirty = true; + UniformVersion++; } - public void MarkUniformsClean() => UniformsDirty = false; + /// Whether the shadow's current contents already sit in 's ring. + public bool HasSnapshotFor(uint frame) => SnapshotFrame == frame && SnapshotVersion == UniformVersion; + + public void NoteSnapshot(uint frame, uint offset) + { + SnapshotFrame = frame; + SnapshotVersion = UniformVersion; + SnapshotOffset = offset; + } public void Dispose() { @@ -276,9 +343,11 @@ public void Dispose() Vk api = _context.Api; api.DestroyPipelineLayout(_context.Device, PipelineLayout, null); - foreach (DescriptorSetLayout layout in SetLayouts) + for (int set = 0; set < SetLayouts.Length; set++) { - if (layout.Handle != 0) api.DestroyDescriptorSetLayout(_context.Device, layout, null); + // The shared frame layout belongs to the device. + if (set == ProgramInterfaceLayout.FrameSet && !_ownsFrameLayout) continue; + if (SetLayouts[set].Handle != 0) api.DestroyDescriptorSetLayout(_context.Device, SetLayouts[set], null); } foreach (ShaderModule module in Modules.Values) { diff --git a/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs b/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs new file mode 100644 index 00000000..9ff49e5c --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// The per-frame values every program reads, as one block shared by all of them: +/// descriptor set 0, binding 0. +/// +/// ShaderProgramBase.Use() writes the same values from +/// DefaultShaderUniforms into every program that includes the file that +/// declares them - fog and light, the shadow cascades, the vertex warp, the sky +/// colour, the colour map and the underwater effect - up to 56 writes per program +/// switch. Stored per program, each of those programs carries its own copy and a +/// draw copies all of it into the frame's uniform ring. Stored here, the values +/// live once: a write that changes nothing is a comparison, a write that changes +/// something bumps one version, and every draw in the frame binds the same +/// snapshot until the next change. +/// +/// A name joins the shared block for a program only when that program's +/// Use() really writes it, which is when the program includes the file +/// listed as the name's owner. The GUI program sets lightPosition itself +/// without including fog and light; a program that includes only the fragment +/// half of fog and light declares flatFogDensity but never has it written. +/// Both keep a copy of their own, exactly as on OpenGL, where every program has +/// its own uniform storage. +/// +/// Offsets are fixed for the whole process, independent of which programs exist: +/// arrays are sized for the largest declaration the game can produce (100 dynamic +/// lights is the settings slider's maximum), and a shader that declares a shorter +/// array reads a prefix of the same member. Scalar block layout, as in the +/// per-program block, so the game's packed float arrays land as a memcpy. +/// +internal static class FrameGlobals +{ + public const int Set = 0; + public const int Binding = 0; + public const string BlockTypeName = "OptimumFrameGlobals"; + + /// The dynamic lights slider's maximum; DYNLIGHTS never exceeds it. + public const int MaxDynamicLights = 100; + + private readonly record struct Entry(string Name, string TypeName, int Capacity, string Owner, string? Initializer = null); + + // Owners and initialisers mirror ShaderProgramBase.Use() and the vanilla + // include declarations; FrameGlobalsTests pins both against the assets. + private static readonly Entry[] Entries = + { + // fogandlight.fsh + new("zNear", "float", 0, "fogandlight.fsh", "0.3"), + new("zFar", "float", 0, "fogandlight.fsh", "1500.0"), + new("lightPosition", "vec3", 0, "fogandlight.fsh"), + new("shadowIntensity", "float", 0, "fogandlight.fsh", "1"), + new("glitchStrength", "float", 0, "fogandlight.fsh", "0"), + new("psychedelicStrength", "float", 0, "fogandlight.fsh", "0"), + new("shadowMapWidthInv", "float", 0, "fogandlight.fsh"), + new("shadowMapHeightInv", "float", 0, "fogandlight.fsh"), + + // fogandlight.vsh (also the unconditional writer of the view distances) + new("viewDistance", "float", 0, "fogandlight.vsh"), + new("viewDistanceLod0", "float", 0, "fogandlight.vsh"), + new("fogSphereQuantity", "int", 0, "fogandlight.vsh"), + new("pointLightQuantity", "int", 0, "fogandlight.vsh"), + new("flatFogDensity", "float", 0, "fogandlight.vsh"), + new("flatFogStart", "float", 0, "fogandlight.vsh"), + new("glitchStrengthFL", "float", 0, "fogandlight.vsh"), + new("nightVisionStrength", "float", 0, "fogandlight.vsh"), + + // shadowcoords.vsh + new("shadowRangeNear", "float", 0, "shadowcoords.vsh"), + new("shadowRangeFar", "float", 0, "shadowcoords.vsh"), + + // vertexwarp.vsh + new("timeCounter", "float", 0, "vertexwarp.vsh"), + new("windWaveCounter", "float", 0, "vertexwarp.vsh"), + new("windWaveCounterHighFreq", "float", 0, "vertexwarp.vsh"), + new("windSpeed", "float", 0, "vertexwarp.vsh"), + new("waterWaveCounter", "float", 0, "vertexwarp.vsh"), + new("playerpos", "vec3", 0, "vertexwarp.vsh"), + new("globalWarpIntensity", "float", 0, "vertexwarp.vsh"), + new("glitchWaviness", "float", 0, "vertexwarp.vsh", "0"), + new("windWaveIntensity", "float", 0, "vertexwarp.vsh", "1"), + new("waterWaveIntensity", "float", 0, "vertexwarp.vsh", "1"), + new("perceptionEffectId", "int", 0, "vertexwarp.vsh", "1"), + new("perceptionEffectIntensity", "float", 0, "vertexwarp.vsh", "1"), + + // skycolor.fsh (sky and glow are samplers and stay per program) + new("fogWaveCounter", "float", 0, "skycolor.fsh"), + new("sunsetMod", "float", 0, "skycolor.fsh"), + new("ditherSeed", "int", 0, "skycolor.fsh"), + new("horizontalResolution", "int", 0, "skycolor.fsh"), + new("playerToSealevelOffset", "float", 0, "skycolor.fsh"), + + // colormap.vsh + new("seasonRel", "float", 0, "colormap.vsh"), + new("seaLevel", "float", 0, "colormap.vsh"), + new("atlasHeight", "float", 0, "colormap.vsh"), + new("seasonTemperature", "float", 0, "colormap.vsh"), + + // underwatereffects.fsh. frameSize is not here: bilateralblur and the blur + // passes declare a frameSize of their own and write it outside Use(). + new("cameraUnderwater", "float", 0, "underwatereffects.fsh"), + new("waterMurkColor", "vec4", 0, "underwatereffects.fsh"), + + // The large members last, so the scalars above share a few cache lines. + new("toShadowMapSpaceMatrixNear", "mat4", 0, "shadowcoords.vsh"), + new("toShadowMapSpaceMatrixFar", "mat4", 0, "shadowcoords.vsh"), + new("fogSpheres", "float", 3 * 8, "fogandlight.vsh"), + new("colorMapRects", "vec4", 40, "colormap.vsh"), + new("pointLights", "vec3", MaxDynamicLights, "fogandlight.vsh"), + new("pointLightColors", "vec3", MaxDynamicLights, "fogandlight.vsh"), + }; + + private static readonly Dictionary ByName = new(StringComparer.Ordinal); + private static readonly List MemberList = new(); + + /// Size of the shared block in bytes. + public static int BlockSize { get; } + + /// Every member, in layout order. + public static IReadOnlyList Members => MemberList; + + static FrameGlobals() + { + int offset = 0; + foreach (Entry entry in Entries) + { + if (!GlslType.TryParse(entry.TypeName, out GlslType type)) + { + throw new InvalidOperationException("frame global '" + entry.Name + "' has unknown type " + entry.TypeName); + } + + offset = Align(offset, type.Alignment); + var member = new UniformMember + { + Name = entry.Name, + Type = type, + ArrayLength = entry.Capacity, + Offset = offset, + Initializer = entry.Initializer, + }; + member.Size = type.Size * member.ElementCount; + offset += member.Size; + + MemberList.Add(member); + ByName.Add(entry.Name, (member, entry.Owner)); + } + BlockSize = offset; + } + + /// + /// Whether a program's declaration of reads the shared + /// block: the program includes the name's owner, and the declaration agrees + /// with the shared member - the same type, and an array no longer than the + /// member (or not an array where the member is not one). + /// + public static bool TryPlace( + string name, GlslType type, int arrayLength, IReadOnlySet? includes, out UniformMember member) + { + member = null!; + if (includes == null || !ByName.TryGetValue(name, out (UniformMember Member, string Owner) found)) return false; + if (!includes.Contains(found.Owner)) return false; + if (!found.Member.Type.Equals(type)) return false; + + bool fits = found.Member.ArrayLength == 0 + ? arrayLength == 0 + : arrayLength > 0 && arrayLength <= found.Member.ArrayLength; + if (!fits) return false; + + member = found.Member; + return true; + } + + /// The member called , whatever its owner. + public static bool TryGetMember(string name, out UniformMember member) + { + bool known = ByName.TryGetValue(name, out (UniformMember Member, string Owner) found); + member = known ? found.Member : null!; + return known; + } + + /// The include whose block in Use() writes . + public static string? OwnerOf(string name) => ByName.TryGetValue(name, out (UniformMember Member, string Owner) found) ? found.Owner : null; + + /// A shadow of the shared block, pre-filled with the declared defaults. + public static byte[] CreateShadow() + { + var buffer = new byte[BlockSize]; + foreach (UniformMember member in MemberList) + { + if (member.Initializer != null) ProgramInterfaceLayout.WriteInitializer(buffer, member); + } + return buffer; + } + + private static int Align(int value, int alignment) => + alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment; +} diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs index 8f25ec63..7f6e0cc5 100644 --- a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -65,10 +65,32 @@ internal sealed class ProgramInterfaceLayout { public const string BlockTypeName = "OptimumUniforms"; public const string BlockInstanceName = "_optimum"; - public const int DefaultBlockSet = 0; - public const int DefaultBlockBinding = 0; + + // Sets are ordered by how often their contents change: the frame block every + // program shares (FrameGlobals), then the program's textures, its storage + // buffers, and last its own uniform blocks, which change between draws. + public const int FrameSet = FrameGlobals.Set; public const int SamplerSet = 1; public const int StorageSet = 2; + public const int DefaultBlockSet = 3; + public const int DefaultBlockBinding = 0; + public const int SetCount = 4; + + /// + /// The shared frame members each stage declared (see ). + /// Emitted per stage for the same reason is. + /// + public Dictionary> FrameMembersByStage { get; } = new(); + + /// + /// The array length each shared frame member was declared with in this program: + /// a shader may read a prefix of the shared array (pointLights[DYNLIGHTS]). + /// 0 for a member that is not an array. + /// + public Dictionary FrameMemberDeclaredLengths { get; } = new(StringComparer.Ordinal); + + /// Whether the program reads anything from the shared frame block. + public bool UsesFrameBlock => FrameMemberDeclaredLengths.Count > 0; /// Members in declaration order, vertex stage first. public List Members { get; } = new(); @@ -152,7 +174,7 @@ public byte[] CreateShadowBuffer() return buffer; } - private static void WriteInitializer(byte[] buffer, UniformMember member) + internal static void WriteInitializer(byte[] buffer, UniformMember member) { // Only scalar literal defaults are honoured. Every initialiser in the // shipped shaders is one; a constructor expression would need an @@ -190,9 +212,14 @@ private static void WriteInitializer(byte[] buffer, UniformMember member) /// Locations from IShaderProgram's BindAttribLocation map, for mods /// that name attributes through the API instead of a layout qualifier. /// + /// + /// The program's include files; a uniform whose owning include is among them + /// reads the shared frame block (). + /// public static ProgramInterfaceLayout Build( IReadOnlyList<(EnumShaderType Stage, ParsedShader Parsed)> stages, - IReadOnlyDictionary? declaredAttributes = null) + IReadOnlyDictionary? declaredAttributes = null, + IReadOnlySet? includes = null) { var layout = new ProgramInterfaceLayout(); int offset = 0; @@ -206,7 +233,7 @@ public static ProgramInterfaceLayout Build( switch (declaration.Kind) { case GlslDeclarationKind.DefaultUniform: - AddDefaultUniform(layout, declaration, stage, ref offset); + AddDefaultUniform(layout, declaration, stage, includes, ref offset); break; case GlslDeclarationKind.OpaqueUniform: AddSampler(layout, declaration); @@ -230,7 +257,8 @@ public static ProgramInterfaceLayout Build( // ------------------------------------------------------------------ uniforms private static void AddDefaultUniform( - ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage, ref int offset) + ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage, + IReadOnlySet? includes, ref int offset) { if (!GlslType.TryParse(declaration.TypeName, out GlslType type)) { @@ -240,6 +268,28 @@ private static void AddDefaultUniform( return; } + // A value Use() writes into every program that includes its owner: it reads + // the shared frame block instead of taking room in this program's own. + if (declaration.UnresolvedArraySize == null && + FrameGlobals.TryPlace(declaration.Name, type, declaration.ArrayLength, includes, out _)) + { + if (!layout.FrameMembersByStage.TryGetValue(stage, out HashSet? frameMembers)) + { + frameMembers = new HashSet(StringComparer.Ordinal); + layout.FrameMembersByStage[stage] = frameMembers; + } + frameMembers.Add(declaration.Name); + + // Every stage is compiled with the same defines, so the lengths agree; + // the longest is kept should they not, since each is a prefix. + if (!layout.FrameMemberDeclaredLengths.TryGetValue(declaration.Name, out int known) || + declaration.ArrayLength > known) + { + layout.FrameMemberDeclaredLengths[declaration.Name] = declaration.ArrayLength; + } + return; + } + if (!layout.MembersByStage.TryGetValue(stage, out HashSet? stageMembers)) { stageMembers = new HashSet(StringComparer.Ordinal); diff --git a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs index ff86e98d..7841f9a5 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs @@ -67,9 +67,11 @@ public static RewrittenShader Rewrite( switch (declaration.Kind) { case GlslDeclarationKind.DefaultUniform: - // Its storage now lives in the generated block. Members keep - // their names there, so every use site still compiles. - if (layout.MembersByName.ContainsKey(declaration.Name)) + // Its storage now lives in the generated block or the shared + // frame block. Members keep their names in both, so every use + // site still compiles. + if (layout.MembersByName.ContainsKey(declaration.Name) || + layout.FrameMemberDeclaredLengths.ContainsKey(declaration.Name)) { edits.Add(new Edit(declaration.Start, declaration.Length, "")); } @@ -116,14 +118,16 @@ private static void AddHeaderEdits( ParsedShader parsed, ProgramInterfaceLayout layout, EnumShaderType stage, bool emitDepthRemap, List edits) { + string frameBlock = BuildFrameBlock(layout, stage); string block = BuildUniformBlock(layout, stage); var header = new StringBuilder(); header.Append("#version 450\n"); - if (block.Length > 0) + if (frameBlock.Length > 0 || block.Length > 0) { header.Append("#extension GL_EXT_scalar_block_layout : require\n"); } + header.Append(frameBlock); header.Append(block); // The geometry stage's EmitVertex() replacement lives in the header so @@ -150,6 +154,41 @@ private static void AddHeaderEdits( } } + /// + /// Emits the shared frame block () with the members + /// this stage reads, at the offsets every program agrees on, each with the array + /// length this program declared. Anonymous like the program's own block, so + /// every reference in the body resolves unchanged. + /// + private static string BuildFrameBlock(ProgramInterfaceLayout layout, EnumShaderType stage) + { + if (!layout.FrameMembersByStage.TryGetValue(stage, out HashSet? stageMembers)) return ""; + if (stageMembers.Count == 0) return ""; + + var builder = new StringBuilder(); + builder.Append(CultureInfo.InvariantCulture, $"\nlayout(scalar, set = {FrameGlobals.Set}"); + builder.Append(CultureInfo.InvariantCulture, $", binding = {FrameGlobals.Binding}) uniform "); + builder.Append(FrameGlobals.BlockTypeName); + builder.Append("\n{\n"); + + foreach (UniformMember member in FrameGlobals.Members) + { + if (!stageMembers.Contains(member.Name)) continue; + + builder.Append(CultureInfo.InvariantCulture, $" layout(offset = {member.Offset}) "); + builder.Append(member.Type.Name).Append(' ').Append(member.Name); + int length = layout.FrameMemberDeclaredLengths[member.Name]; + if (length > 0) + { + builder.Append('[').Append(length.ToString(CultureInfo.InvariantCulture)).Append(']'); + } + builder.Append(";\n"); + } + + builder.Append("};\n"); + return builder.ToString(); + } + /// /// Emits the block that replaces GL's default uniform block, carrying only /// the members this stage declared. diff --git a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs index 3a6c0eb6..30ef3b63 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs @@ -49,10 +49,16 @@ internal static class ShaderTranslator EnumShaderType.GeometryShader, }; + /// + /// The include files the program was assembled from, as ShaderRegistry records + /// them. They decide which uniforms read the shared frame block + /// (); without them every uniform stays the program's own. + /// public static TranslatedProgram Translate( IReadOnlyList stages, ShaderCompiler compiler, - IReadOnlyDictionary? declaredAttributes = null) + IReadOnlyDictionary? declaredAttributes = null, + IReadOnlySet? includes = null) { var program = new TranslatedProgram(); @@ -92,7 +98,7 @@ public static TranslatedProgram Translate( return program; } - program.Layout = ProgramInterfaceLayout.Build(parsed, declaredAttributes); + program.Layout = ProgramInterfaceLayout.Build(parsed, declaredAttributes, includes); foreach (string error in program.Layout.Errors) { program.Errors.Add(error); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index ec1a2f94..8791010a 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -98,6 +98,19 @@ public sealed unsafe class VulkanDevice : IDisposable /// private bool _lastUniformAllocationOk = true; + /// + /// The shared frame block (set 0, ): the one set + /// layout every program's pipeline layout names for it, the CPU shadow every + /// frame-global write lands in, and the ring snapshot draws bind until a write + /// changes something. Replaces up to 56 per-program copies of the same values. + /// + private DescriptorSetLayout _frameSetLayout; + private readonly byte[] _frameGlobals = FrameGlobals.CreateShadow(); + private uint _frameGlobalsVersion = 1; + private uint _frameGlobalsSnapshotFrame; + private uint _frameGlobalsSnapshotVersion; + private uint _frameGlobalsSnapshotOffset; + /// Sixteen bytes of float defaults followed by sixteen of int. private const ulong DefaultAttributeBufferSize = 32; @@ -395,6 +408,8 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _pipelines = new GraphicsPipelineCache(_context, _context.Capabilities.ColorWriteTier, _context.Capabilities.DynamicColorBlend); _descriptors = new DescriptorCache(_context); + // One layout for the shared frame block, named by every program's pipeline layout. + _frameSetLayout = ShaderProgramResources.CreateFrameSetLayout(_context); _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; for (int i = 0; i < _descriptorArenas.Length; i++) _descriptorArenas[i] = new DescriptorArena(_context); _indirectRing = new IndirectRing(_frames.FramesInFlight); @@ -1056,7 +1071,11 @@ public int LinkProgram(IShaderProgram program) return 0; } - TranslatedProgram translated = ShaderTranslator.Translate(stages, _shaderCompiler); + // The include files the registry assembled the program from decide which + // of its uniforms read the shared frame block. A program built any other + // way - a mod's, a test's - keeps every uniform to itself. + TranslatedProgram translated = ShaderTranslator.Translate(stages, _shaderCompiler, null, + (program as Vintagestory.Client.NoObf.ShaderProgramBase)?.includes); if (!translated.Success) { foreach (string error in translated.Errors) @@ -1078,7 +1097,7 @@ public int LinkProgram(IShaderProgram program) " type=" + member.Type + " count=" + member.ArrayLength); } } - _programs[programId] = new ShaderProgramResources(_context, programId, translated); + _programs[programId] = new ShaderProgramResources(_context, programId, translated, _frameSetLayout); _programNames[programId] = program.PassName ?? ""; return programId; } @@ -1119,12 +1138,39 @@ public int GetUniformLocation(int programId, string name) => private void Write(int programId, int location, ReadOnlySpan data) { + // A member of the shared frame block: one shadow for every program. + if (ShaderProgramResources.IsFrameLocation(location)) + { + WriteFrameGlobal(location - ShaderProgramResources.FrameLocationBase, data); + return; + } + if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) { program.SetUniform(location, data); } } + /// + /// Writes into the shared frame block. Use() rewrites the same values on every + /// program switch, so the common case is bytes that already match: a comparison + /// and nothing else. A real change bumps the version and the next draw takes a + /// new snapshot. + /// + private void WriteFrameGlobal(int offset, ReadOnlySpan data) + { + if (offset < 0 || offset + data.Length > _frameGlobals.Length) return; + + Span destination = _frameGlobals.AsSpan(offset, data.Length); + if (data.SequenceEqual(destination)) return; + + data.CopyTo(destination); + _frameGlobalsVersion++; + } + + /// A copy of the shared frame block's current bytes. For tests. + internal byte[] FrameGlobalsForTests => (byte[])_frameGlobals.Clone(); + public void SetUniform(int programId, int location, float value) => Write(programId, location, new ReadOnlySpan(&value, sizeof(float))); @@ -2326,11 +2372,53 @@ private void ReportUniformExhaustion(ShaderProgramResources program, string what MirrorValidationMessage(message); } + /// + /// Binds the shared frame block (set 0). Its bytes go into the ring once per + /// change of its contents: every draw that follows in the frame reads the same + /// snapshot, through the one descriptor set every program shares, and only the + /// dynamic offset moves when a value does. + /// + private void BindFrameGlobals(CommandBuffer commandBuffer, ShaderProgramResources program) + { + uint offset = 0; + if (_frameGlobalsSnapshotFrame == _frameCounter && _frameGlobalsSnapshotVersion == _frameGlobalsVersion) + { + offset = _frameGlobalsSnapshotOffset; + } + else if (_frames.Current.TryAllocateUniforms(_frameGlobals.Length, out RingAllocation allocation)) + { + fixed (byte* source = _frameGlobals) + { + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, _frameGlobals.Length, _frameGlobals.Length); + } + offset = allocation.Offset; + _frameGlobalsSnapshotFrame = _frameCounter; + _frameGlobalsSnapshotVersion = _frameGlobalsVersion; + _frameGlobalsSnapshotOffset = offset; + } + else + { + ReportUniformExhaustion(program, "the shared frame block"); + } + + var contents = new DescriptorSetContents( + 0, ProgramInterfaceLayout.FrameSet, Array.Empty(), + new[] { new BufferBindingValue(FrameGlobals.Binding, _frames.UniformBuffer, 0, (ulong)_frameGlobals.Length) }); + DescriptorSet frameSet = GetDescriptorSet(contents, _frameSetLayout); + _context.Api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, + ProgramInterfaceLayout.FrameSet, 1, &frameSet, 1, &offset); + } + private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) { Vk api = _context.Api; - // Set 0: the generated uniform block plus one entry for every block the + if (program.Interface.UsesFrameBlock) + { + BindFrameGlobals(commandBuffer, program); + } + + // Set 3: the generated uniform block plus one entry for every block the // shader declared for itself. Every one of them is a dynamic descriptor // pointing at this frame's uniform ring, so the set itself never changes // - the per-draw offset travels alongside it instead. @@ -2352,7 +2440,13 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources if (hasGeneratedBlock) { uint generatedOffset = 0; - if (_frames.Current.TryAllocateUniforms( + if (program.HasSnapshotFor(_frameCounter)) + { + // Nothing written since this program's last draw this frame + // took its snapshot: bind the same bytes again. + generatedOffset = program.SnapshotOffset; + } + else if (_frames.Current.TryAllocateUniforms( program.UniformShadow.Length, out RingAllocation allocation)) { fixed (byte* source = program.UniformShadow) @@ -2361,7 +2455,7 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program.UniformShadow.Length, program.UniformShadow.Length); } generatedOffset = allocation.Offset; - program.MarkUniformsClean(); + program.NoteSnapshot(_frameCounter, allocation.Offset); } else { @@ -3172,6 +3266,12 @@ public void Dispose() foreach (ShaderProgramResources program in _programs.Values) program.Dispose(); _programs.Clear(); + // After every pipeline layout that named it. + if (_context != null && _frameSetLayout.Handle != 0) + { + _context.Api.DestroyDescriptorSetLayout(_context.Device, _frameSetLayout, null); + _frameSetLayout = default; + } _uniformBuffers.Clear(); From 84f4893a9d3e5791d230404997447ca4b4869fbc Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 19:39:13 +0200 Subject: [PATCH 133/226] docs: commit references follow the history rewrite The identity rewrite gave every commit authored on this fork a new hash. The plan and the acceptance record cited seven of them; they now name the rewritten commits (the pre-rewrite hashes in older commit messages are left as they were). --- docs/vulkan-acceptance.md | 4 ++-- docs/vulkan-native-plan.md | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 5ce6b731..e65e6bb7 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -244,7 +244,7 @@ All numbers first, then eyes. Each row names the plan's definition of done verba - Pass: the user judges it in game on both backends. - Record: both renderer lines, the user's verdict and date. -### Milestone 1 exit results (2026-09-11, commit 6568556 deployed) +### Milestone 1 exit results (2026-09-11, commit f90cbac deployed) RTX 4070 Laptop, driver 615.71.09, X11. Section 0's fixed scene was applied to the save for the first time (creative, 12:00, clear sky, precipitation -1, wind still) and it removed the pacing noise: the two @@ -399,7 +399,7 @@ One entry per phase exit or milestone, appended, never edited after the fact. | date | phase / milestone | commit | rows passed | rows failed or deferred (with reason) | evidence paths | decision | |---|---|---|---|---|---|---| | 2026-09-11 | Phase 0 exit | 906b40f deployed (Phase 0 merged at cdd7412) | V0.1 and V0.2 recorded, V0.3 and V0.4 recorded, V0.5 pass (build 0 errors, Optimum.Tests 1056, GPU 386 with sync,best, check-patches 0 conflicts) | section 0 fixed scene not applied; Vulkan fails the pacing gate on p99, stddev and blocking uploads (the Milestone 1 target, not a Phase 0 gate) | `docs/gpu-verification-2026-09-11/phase0/` | Phase 0 accepted; Phase 1A and 1B start. M1.6 changed to a noise-floor rule. User observed no Vulkan jitter on these two runs (driver 615.71.09, sky-direction fix not deployed); correction 2026-09-11 evening: the distance jitter was back on Vulkan in every later run, and OpenGL never shows it. | -| 2026-09-11 | Milestone 1 (Phase 2 exit) | 6568556 deployed | M1.2, M1.3, M1.4, M1.7 pass; validation clean with `MANGOHUD=0` and in the 620-test GPU suite; SSAO alpha gap closed; fixed scene removed the OpenGL pacing bimodality | M1.1 fails `stddev_vs_baseline` (0.365 vs 0.151) and Vulkan costs ~25 % more frame time than OpenGL, GPU-bound (5.43 ms of 7.67 in the frame-pacing wait) - carried to Phase 4; M1.6 per-attachment parity dropped as a routine row; M1.8 TAA rows not re-run | `docs/gpu-verification-2026-09-11/m1/` | **Accepted by the user** after watching a 10-minute Vulkan session: `feat/vulkan-native` merges into `main`, DLSS and the latency seams start on new branches. Testing policy tightened (no long sessions, no SSIM matrices). | +| 2026-09-11 | Milestone 1 (Phase 2 exit) | f90cbac deployed | M1.2, M1.3, M1.4, M1.7 pass; validation clean with `MANGOHUD=0` and in the 620-test GPU suite; SSAO alpha gap closed; fixed scene removed the OpenGL pacing bimodality | M1.1 fails `stddev_vs_baseline` (0.365 vs 0.151) and Vulkan costs ~25 % more frame time than OpenGL, GPU-bound (5.43 ms of 7.67 in the frame-pacing wait) - carried to Phase 4; M1.6 per-attachment parity dropped as a routine row; M1.8 TAA rows not re-run | `docs/gpu-verification-2026-09-11/m1/` | **Accepted by the user** after watching a 10-minute Vulkan session: `feat/vulkan-native` merges into `main`, DLSS and the latency seams start on new branches. Testing policy tightened (no long sessions, no SSIM matrices). | | 2026-09-11 | Phase 1 exit (1A + 1B) | f373c4a deployed | both renderers start; forced-install-failure fallback renders on OpenGL; sync,best validation 0 errors; Vulkan blocking uploads 0 in all samples; Vulkan pacing better than Phase 0 on mean, p99 and stddev; build 0 errors, Optimum.Tests 1128, GPU 494 | Vulkan p99 fails 1.5 x mean; 10-minute session, window resize/alt-tab/minimise loop, sun glare and fork bridge on screen carried to Milestone 1; OpenGL pacing found bimodal between launches (A/B/A), not a regression | `docs/gpu-verification-2026-09-11/phase1/` | Phase 1 accepted; Phase 2 (frame graph) starts; M1.1 now interleaves runs | ## 6. Vendor matrix diff --git a/docs/vulkan-native-plan.md b/docs/vulkan-native-plan.md index 04334174..5ac32fab 100644 --- a/docs/vulkan-native-plan.md +++ b/docs/vulkan-native-plan.md @@ -6,7 +6,7 @@ ## Roadmap (user, 2026-09-15) This branch, `feat/vulkan-taa`, carries the Vulkan backend and TAA as their own pull request. It starts at -`c236676` (Milestone 1 on `main`, before the latency and DLSS work); upscaling, latency and frame +`9ad0c70` (Milestone 1 on `main`, before the latency and DLSS work); upscaling, latency and frame generation stay on `feat/dlss-g` for later pull requests. The work runs in this order: 1. **Fully Vulkan-native, no OpenGL mimicry** (decisions 7 and 8): native shaders with offline SPIR-V @@ -25,10 +25,10 @@ application root, `run-client.sh` without a hard `prime-run`, `numpy` in the pre swapchain resize tests, the runtime donor drift, and a device-idle wait before the window is released. **Where the branch is (2026-09-15).** Two backports from the DLSS line are in, neither judged in game yet: -- `f202d02`: the jittered AO is shaded into the scene before the TAA resolve, and the SSAO dither advances - per frame. On the DLSS line this pair (`e582ed0`, `8c33fa3`) removed the whole-frame jitter that the +- `41373cf`: the jittered AO is shaded into the scene before the TAA resolve, and the SSAO dither advances + per frame. On the DLSS line this pair (`2acede1`, `52b9d6c`) removed the whole-frame jitter that the resolve's 3x3 nearest-depth test and anti-flicker weighting had only damped. -- `af082c5`: the headless render harness (its roadmap item below). +- `766aada`: the headless render harness (its roadmap item below). ## Context @@ -111,8 +111,8 @@ reconciled below. Every file:line fact quoted was re-checked in the tree. ## Step 0: branching -Historical: `feat/vulkan-native` started from `main` at `94e2cc0` after TAA merged, with the sky-direction -fix on its own branch, and merged back into `main` at Milestone 1 (`c236676`). `feat/vulkan-taa` starts +Historical: `feat/vulkan-native` started from `main` at `48174c1` after TAA merged, with the sky-direction +fix on its own branch, and merged back into `main` at Milestone 1 (`9ad0c70`). `feat/vulkan-taa` starts there. Never `git stash`. WIP commits use the `wip:` prefix. --- @@ -559,7 +559,7 @@ found, unfixed: Vulkan `BuildMipMaps` keeps the atlas texture LOD bias where Ope (affects shadow, liquid and transparent terrain passes; shadow maps measured identical, so not visible). **Correction (2026-09-15):** this damped the whole-frame jitter the user saw rather than removing it. On the DLSS line the jitter went away once the jittered AO was shaded into the scene before the temporal pass and -its dither advanced per frame; both are backported for the TAA path in `f202d02`, not yet judged in game. +its dither advanced per frame; both are backported for the TAA path in `41373cf`, not yet judged in game. ### Phase 2: frame graph → **Milestone 1** @@ -601,7 +601,7 @@ and is a Cecil target. A11, A13, A14, A15, A17, A18 re-pass. - Then the user judges it in game on both backends, renderer line confirmed. -**Milestone 1 accepted (user, 2026-09-11, at `6568556`).** Phase 2 complete: barriers from usage, frame +**Milestone 1 accepted (user, 2026-09-11, at `f90cbac`).** Phase 2 complete: barriers from usage, frame graph (22.3 passes == 22.3 scopes per frame, 0 splits, 0 mask restarts, plan hits every frame), transient allocator (implemented, not yet wired to the graph), clear promotion, SSAO alpha gap closed, TAA anti-flicker resolve merged (distant-leaf rejection 1.05 % on both backends). Open and carried to Phase 4: @@ -741,7 +741,7 @@ via `.cam load` / `.cam play`), `OPTIMUM_HEADLESS_FIXED_DT` (pins `ClientMain.De being focused. See `docs/vulkan-acceptance.md` section 3, "Headless render harness", for what it does not cover: a display server is still required, reproducibility is repeatable rather than bit-exact, and no camera path is checked in yet - which is why the acceptance sentence above is not yet a claim, only a capability. -Backported to `feat/vulkan-taa` in `af082c5`. +Backported to `feat/vulkan-taa` in `766aada`. ### Roadmap item: GTAO (XeGTAO) replaces the vanilla SSAO @@ -768,7 +768,7 @@ Order of work, because the cheap parts are prerequisites and may settle the symp 2. Make the dither temporally varying (rotate with the jitter phase) so any accumulator converges it. 3. Only then port XeGTAO, and judge it against the fixed SSAO rather than against today's. -**Status on this branch (2026-09-15):** steps 1 and 2 are in for the TAA path (`f202d02`: the AO multiplied +**Status on this branch (2026-09-15):** steps 1 and 2 are in for the TAA path (`41373cf`: the AO multiplied into the scene before the resolve, the dither advanced per frame under `TAAMOTION`). Step 3 is roadmap step 2, after the native backend, as native compute. From 9b5276861ecd0177e66c04b13a0120c282df50a6 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 19:57:39 +0200 Subject: [PATCH 134/226] docs(research): caching, descriptor model, XeGTAO and validation notes the roadmap designs follow --- .gitignore | 2 + docs/research/vulkan-caching.md | 197 +++++++++++++++++++++++ docs/research/vulkan-descriptor-model.md | 132 +++++++++++++++ docs/research/vulkan-validation.md | 143 ++++++++++++++++ docs/research/xegtao-integration.md | 159 ++++++++++++++++++ 5 files changed, 633 insertions(+) create mode 100644 docs/research/vulkan-caching.md create mode 100644 docs/research/vulkan-descriptor-model.md create mode 100644 docs/research/vulkan-validation.md create mode 100644 docs/research/xegtao-integration.md diff --git a/.gitignore b/.gitignore index 682cf829..64f4a7e7 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,8 @@ docs/* # ...and the Vulkan-native plan: the design, the decisions behind it and the roadmap, for anyone # picking the work up. !docs/vulkan-native-plan.md +# ...and the research notes the plan and its designs cite. +!docs/research/ build-linux.sh build-macos.sh build-windows.ps1 diff --git a/docs/research/vulkan-caching.md b/docs/research/vulkan-caching.md new file mode 100644 index 00000000..7604488c --- /dev/null +++ b/docs/research/vulkan-caching.md @@ -0,0 +1,197 @@ +# SPIR-V and pipeline cache persistence + +Research notes for persisting compiled SPIR-V and the VkPipelineCache across launches. Collected 2026-09-15. The design at the end is what the cache implementation follows. + +## 1. VkPipelineCache persistence + +**What the spec guarantees** + +- The blob starts with `VkPipelineCacheHeaderVersionOne`: headerSize (32), headerVersion, vendorID, deviceID and a 16-byte pipelineCacheUUID. All fields are little-endian. Apps are expected to compare these against `VkPhysicalDeviceProperties`. [refpage](https://docs.vulkan.org/refpages/latest/refpages/source/VkPipelineCacheHeaderVersionOne.html) +- If the initial data is incompatible, "the pipeline cache will be initially empty". It is also valid usage that `pInitialData` came from `vkGetPipelineCacheData`. [VkPipelineCacheCreateInfo](https://docs.vulkan.org/refpages/latest/refpages/source/VkPipelineCacheCreateInfo.html) +- `vkGetPipelineCacheData` returns `VK_INCOMPLETE` if the buffer is too small, so always use the size-query-then-fetch pair. [refpage](https://docs.vulkan.org/refpages/latest/refpages/source/vkGetPipelineCacheData.html) +- Using a cache during pipeline creation is internally synchronized. `vkMergePipelineCaches` "should … prune duplicate entries". The destination cache needs external synchronization unless `INTERNALLY_SYNCHRONIZED_MERGE` (maintenance8) is used. [merge refpage](https://docs.vulkan.org/refpages/latest/refpages/source/vkMergePipelineCaches.html), [yosoygames](https://www.yosoygames.com.ar/wp/2024/09/why-does-vkmergepipelinecaches-exist/) + +**What drivers actually do** ([Kapoulkine / Roblox](https://zeux.io/2019/07/17/serializing-pipeline-cache/)) + +- Some drivers don't check the UUID properly and crash after a driver update. +- Some don't bump the UUID when compatibility breaks, including between 32-bit and 64-bit builds. +- One driver fails when given `initialDataSize == 0` with a non-null pointer. +- Seen on disk: partial writes, zero-filled chunks and zero-size files. + +**Other crash reports** + +- AMD 22.2.1+ on Windows: parallel `vkCreateGraphicsPipelines` calls corrupted the cache data, and Adrenalin crashed on `vkGetPipelineCacheData` for an empty cache. Both are known only from forum-thread titles; the AMD community site now redirects. [thread](https://community.amd.com/t5/opengl-vulkan/parallel-vkcreategraphicspipelines-calls-lead-to-corrupted/m-p/571884) +- Flutter/Impeller crashed on Snapdragon 845 with a corrupt cache file. The proposed fixes were validation, a hash check and atomic writes. [flutter#172624](https://github.com/flutter/flutter/issues/172624) + +**Recommended wrapper header** ([zeux](https://zeux.io/2019/07/17/serializing-pipeline-cache/)) + +- Fields: magic, dataSize, dataHash, vendorID, deviceID, driverVersion, driverABI (pointer size) and UUID. +- Validate every field and the hash before handing the data to the driver. +- If validation fails or `vkCreatePipelineCache` returns an error, retry with no initial data. +- Write to a temp file, then rename. Save at steady state or on exit. +- Godot (4.1+) does the same. It writes `user://vulkan/pipelines.cache` from worker threads, triggered by growth in MB rather than on a timer, and waits for pending saves at shutdown. [godot#76348](https://github.com/godotengine/godot/pull/76348) + +**Multi-GPU** + +- Godot's cache wasn't prefixed by GPU, so switching GPUs invalidated it. [godot#81150](https://github.com/godotengine/godot/issues/81150) +- Key the file name by vendor, device and UUID so each GPU keeps its own cache. + +**Driver implicit caches** + +- RADV checks the app's cache first, then "fall[s] back to the on-disk cache" (since Mesa 17.3). [Phoronix](https://www.phoronix.com/news/RADV-Vulkan-Disk-Cache) +- Mesa's cache is `$XDG_CACHE_HOME/mesa_shader_cache`, 1 GB by default. [Mesa envvars](https://docs.mesa3d.org/envvars.html) +- NVIDIA on Linux uses `~/.cache/nvidia/GLCache` for both OpenGL and Vulkan. [NVIDIA README](https://download.nvidia.com/XFree86/Linux-x86_64/570.86.16/README/openglenvvariables.html) The 460 driver raised the default size from 128 MB to 1 GB. [dxvk#4014](https://github.com/doitsujin/dxvk/issues/4014) +- NVIDIA on Windows uses `%LOCALAPPDATA%\NVIDIA\DXCache`, with a size setting since 496.13. Forum sources only; unverified. +- Driver caches are shared, size-capped, and "usually deleted when the driver is updated". [Godot docs](https://docs.godotengine.org/en/stable/tutorials/performance/pipeline_compilations.html) +- An app cache is therefore still worth having: cheap, deterministic and under our control. On Mesa and NVIDIA it presumably adds less on top of the driver cache. The Khronos sample measured 24 ms with a cache vs 50 ms without. [Vulkan-Samples](https://docs.vulkan.org/samples/latest/samples/performance/pipeline_cache/README.html) + +**Size limits** + +- The spec defines no limit. Godot's TPS demo produced a 6.3 MB cache. [godot#76348](https://github.com/godotengine/godot/pull/76348) + +## 2. Pipeline creation cache control (core in 1.3) + +- **`FAIL_ON_PIPELINE_COMPILE_REQUIRED`:** creation returns `VK_PIPELINE_COMPILE_REQUIRED` instead of compiling. +- **`EARLY_RETURN_ON_FAILURE`:** stops a batched creation call at the first failure. +- **`EXTERNALLY_SYNCHRONIZED` (on the cache):** the driver can skip its internal locking. +- The extension exists so that "task-based game engines" can find expensive hazards before running into them. [refpage](https://docs.vulkan.org/refpages/latest/refpages/source/VK_EXT_pipeline_creation_cache_control.html) +- **How engines use it:** on the render thread, try creation with FAIL_ON. On a miss, queue the real compile on a worker and skip the draw or use a fallback meanwhile. +- Skipping the draw is Unreal's default for PSOs that aren't ready. [UE PSO precaching](https://dev.epicgames.com/documentation/en-us/unreal-engine/pso-precaching-for-unreal-engine) +- Per-thread caches with `EXTERNALLY_SYNCHRONIZED`, merged later, remove lock contention. [yosoygames](https://www.yosoygames.com.ar/wp/2024/09/why-does-vkmergepipelinecaches-exist/) +- NVIDIA has supported it since 442.75. [NVIDIA](https://developer.nvidia.com/vulkan-driver) + +## 3. VK_KHR_pipeline_binary (released Aug 2024) + +**What it is** ([Khronos blog](https://www.khronos.org/blog/bringing-explicit-pipeline-caching-control-to-vulkan), [proposal](https://github.com/KhronosGroup/Vulkan-Docs/blob/main/proposals/VK_KHR_pipeline_binary.adoc)) + +- Each pipeline gets its own binary blobs instead of one opaque cache blob. +- The app stores three things: + - a global key, which acts as the validity check across driver updates; + - a map from pipeline key (`vkGetPipelineKeyKHR`) to binary keys; + - a map from binary key to data. +- To capture, create the pipeline with `CAPTURE_DATA`. To reload, pass `VkPipelineBinaryInfoKHR`. +- The create info must match exactly. +- `VK_PIPELINE_BINARY_MISSING_KHR` is the internal-cache equivalent of a compile-required result. +- `pipelineBinaryPrefersInternalCache` means the app should not capture; the blog cites platforms like Steam here. +- The blog says ordinary apps can keep using VkPipelineCache for simplicity. + +**Driver support** + +| Driver | Status | Source | +|---|---|---| +| NVIDIA | 553.00 (Windows) / 550.40.70 (Linux) | [NVIDIA](https://developer.nvidia.com/vulkan-driver) | +| RADV | Merged days after release | [Phoronix](https://www.phoronix.com/news/Intel-ANV-Pipeline-Binary) | +| ANV and NVK | Mesa 26.0 | same | +| AMD Windows | Reportedly 24.9.1; unverified, the release-notes page timed out | [AMD RN](https://www.amd.com/en/resources/support-articles/release-notes/RN-RAD-WIN-24-9-1.html) | +| Intel Windows | Unknown | — | + +Mesa older than 26.0 lacks it on Intel and NVK, so a fallback is mandatory. + +## 4. Shader module identifier and graphics pipeline library + +**VK_EXT_shader_module_identifier** + +- Lets an app skip generating SPIR-V when the driver cache is warm: pass an identifier instead of the module, and the attempt has to use FAIL_ON_COMPILE_REQUIRED. [proposal](https://github.com/KhronosGroup/Vulkan-Docs/blob/main/proposals/VK_EXT_shader_module_identifier.adoc) +- Check `shaderModuleIdentifierAlgorithmUUID` before trusting stored identifiers. +- Built for translation layers (>95% disk savings for D3D12-on-Vulkan). +- The flow is speculative, so it only helps if SPIR-V generation itself is the bottleneck. +- NVIDIA has supported it since 516.63. [NVIDIA](https://developer.nvidia.com/vulkan-driver) + +**VK_EXT_graphics_pipeline_library (GPL)** + +- Compiles stages at shader load time, then fast-links at draw time, with an optimized relink in the background. [Khronos](https://www.khronos.org/blog/reducing-draw-time-hitching-with-vk-ext-graphics-pipeline-library) +- Desktop vendors report fast linking. +- DXVK 2.7 removed its state cache as "largely unused since … GPL in DXVK 2.0". [DXVK 2.7](https://github.com/doitsujin/dxvk/releases/tag/v2.7) +- NVIDIA has supported GPL since 473.33. [NVIDIA](https://developer.nvidia.com/vulkan-driver) +- AMD Windows exposed it around 24.2.1, but DXVK reported problems. [dxvk#3859](https://github.com/doitsujin/dxvk/issues/3859) +- Khronos advises new engines to reconsider large permutation counts. +- It suits a renderer that can't predict full pipeline state, but it is a large change. + +## 5. SPIR-V caching + +**Godot** ([shader_rd.cpp](https://github.com/godotengine/godot/blob/master/servers/rendering/renderer_rd/shader_rd.cpp)) + +- The key is a SHA-256 over engine version and commit hash, stage sources and the debug-info flag, plus SHA-1 over defines, uniforms and code sections. +- Path: `name/groupSHA/sha1..cache`. +- Files start with a `GDSC` magic and a file-format version (4). +- A related issue asks for the cache folder to be versioned. [godot#63056](https://github.com/godotengine/godot/issues/63056) + +**shaderc version** + +- The C API exposes only `shaderc_get_spv_version`. [shaderc.h](https://github.com/google/shaderc/blob/main/libshaderc/include/shaderc/shaderc.h) +- glslang has `glslang::GetVersion()` in C++, which shaderc's C API does not expose. [glslang CHANGES](https://github.com/KhronosGroup/glslang/blob/main/CHANGES.md) +- Consequence (not documented practice): hash the loaded libshaderc binary, or its build tag, into the key. + +**Options that change output** (same header): target env, SPIR-V version, optimization level, debug info, macros, auto-bind and include callbacks. All belong in the key. + +**Caching preprocessed or reflection data** + +- Not covered by the sources above. +- Consequence: cache reflection alongside the SPIR-V to avoid re-running reflection. Caching the preprocessed text only helps if the GLSL 330 translation itself is expensive. + +## 6. Pre-warming + +- **Unreal** has two approaches: bundled PSO caches record what gets drawn during play and ship that list; runtime precaching compiles PSOs asynchronously on load. [UE PSO caches](https://dev.epicgames.com/documentation/en-us/unreal-engine/optimizing-rendering-with-pso-caches-in-unreal-engine), [precaching](https://dev.epicgames.com/documentation/en-us/unreal-engine/pso-precaching-for-unreal-engine) +- **Godot 4.4** precompiles at load time and renders with an ubershader while the specialized pipeline compiles in the background. [Godot docs](https://docs.godotengine.org/en/stable/tutorials/performance/pipeline_compilations.html) +- **Steam/Fossilize** replays recorded pipeline state to warm VkPipelineCaches in the background. [Phoronix](https://www.phoronix.com/news/Steam-Vulkan-Shader-Pre-Cache) + +## 7. Pitfalls for this renderer + +- **Variants and mods:** content-hash the final translated source plus defines rather than using file names. A mod editing a shader then misses the cache instead of loading stale SPIR-V. +- **Settings combinations:** pipeline-key logs grow without bound; evict by LRU or last-seen time. +- **Two game instances:** both can load read-only. Each writes to a unique temp file and renames. Last writer wins, which loses entries but never corrupts. Optionally merge the on-disk cache into ours before saving. +- **Windows antivirus:** scanners briefly lock new files, which makes replace-by-rename fail. Chrome's fix was to retry `ReplaceFile`. [BleepingComputer](https://www.bleepingcomputer.com/news/security/google-chrome-fixes-antivirus-file-locking-bug-on-windows-10/) `ReplaceFileW` requires both files on the same volume. [MS docs](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) +- **Mesa:** the app cache and the driver's disk cache stack. Keep the app cache anyway, because users can disable or clear the Mesa cache. [Mesa envvars](https://docs.mesa3d.org/envvars.html) + +## Design for this renderer + +**Implement now** + +1. **SPIR-V disk cache.** + - **Key:** SHA-256 over: + - a cache format version; + - the translator version; + - the libshaderc binary hash; + - target env and SPIR-V version, optimization level, debug flag; + - stage; + - the sorted define list; + - the final translated source, after includes are resolved. + - **File:** `/spirv//.spv`, with a small header: magic, format version, length, hash of the payload. + - **On load:** check magic, length, hash and that the word count is a multiple of 4. On any failure, recompile and overwrite. + - Store reflection data next to the SPIR-V. + - Sources: [Godot](https://github.com/godotengine/godot/blob/master/servers/rendering/renderer_rd/shader_rd.cpp), [shaderc.h](https://github.com/google/shaderc/blob/main/libshaderc/include/shaderc/shaderc.h). + +2. **Robust VkPipelineCache.** + - **File:** `/pipeline/--.bin`. + - **Header:** magic, format version, dataSize, 64-bit hash, vendorID, deviceID, driverVersion, pointer size, UUID. + - **Validate** the wrapper and the embedded Vulkan header. Never pass size 0 with a non-null pointer. + - **Fallback:** if validation fails or `vkCreatePipelineCache` errors, create an empty cache. + - **Save** on shutdown, plus opportunistically from a worker when the cache has grown by N MB. + - **Write safely:** unique temp file, then `File.Replace`/`File.Move(overwrite)`, retrying with backoff on IOException. + - Sources: [zeux](https://zeux.io/2019/07/17/serializing-pipeline-cache/), [spec](https://docs.vulkan.org/refpages/latest/refpages/source/VkPipelineCacheCreateInfo.html), [godot#76348](https://github.com/godotengine/godot/pull/76348), [Chrome](https://www.bleepingcomputer.com/news/security/google-chrome-fixes-antivirus-file-locking-bug-on-windows-10/). + +3. **Cache root.** `%LOCALAPPDATA%\\ShaderCache` on Windows, `$XDG_CACHE_HOME/` on Linux. Per-user, outside the game install and mods folders. + +4. **Async compile path.** + - On the render thread, create with `FAIL_ON_PIPELINE_COMPILE_REQUIRED`. + - On `VK_PIPELINE_COMPILE_REQUIRED`, enqueue a worker compile and skip that draw (or use a fallback) until the pipeline is ready. + - Guard any per-worker caches that use `EXTERNALLY_SYNCHRONIZED`, and merge them into the main cache under a lock before saving. + - Workaround for the AMD parallel-corruption reports: serialize `vkGetPipelineCacheData` and merges. + - Sources: [cache_control](https://docs.vulkan.org/refpages/latest/refpages/source/VK_EXT_pipeline_creation_cache_control.html), [UE](https://dev.epicgames.com/documentation/en-us/unreal-engine/pso-precaching-for-unreal-engine), [yosoygames](https://www.yosoygames.com.ar/wp/2024/09/why-does-vkmergepipelinecaches-exist/). + +**Next** + +5. **Pipeline-key log for pre-warming.** Serialize used pipeline keys (program hash, vertex layout, formats, blend, polygon mode, topology), tagged with the settings/define hash. At startup, precompile matching entries on background threads. Cap the log with LRU. Sources: [UE bundled PSO](https://dev.epicgames.com/documentation/en-us/unreal-engine/optimizing-rendering-with-pso-caches-in-unreal-engine), [Godot](https://docs.godotengine.org/en/stable/tutorials/performance/pipeline_compilations.html). + +**Defer** + +6. **VK_KHR_pipeline_binary.** Optional backend when present, with VkPipelineCache as the fallback. Honor `pipelineBinaryPrefersInternalCache`. Coverage is still uneven: Mesa ANV and NVK only from 26.0, Intel Windows unknown. [Khronos](https://www.khronos.org/blog/bringing-explicit-pipeline-caching-control-to-vulkan), [Phoronix](https://www.phoronix.com/news/Intel-ANV-Pipeline-Binary) +7. **GPL (fast-link, then optimize in the background).** Only if stutter remains after items 1–5; it needs a pipeline architecture refactor. [Khronos GPL](https://www.khronos.org/blog/reducing-draw-time-hitching-with-vk-ext-graphics-pipeline-library) +8. **Skip VK_EXT_shader_module_identifier.** Once SPIR-V is cached, its benefit is marginal. [proposal](https://github.com/KhronosGroup/Vulkan-Docs/blob/main/proposals/VK_EXT_shader_module_identifier.adoc) + +**Uncertain / version-dependent** + +- AMD Windows and Intel Windows `pipeline_binary` support. +- NVIDIA Windows DXCache defaults (forum sources only). +- Whether current drivers still have the header-validation bugs described in 2019. +- Hashing the libshaderc binary into the key is a consequence of the C API's limits, not documented practice. diff --git a/docs/research/vulkan-descriptor-model.md b/docs/research/vulkan-descriptor-model.md new file mode 100644 index 00000000..62e086e2 --- /dev/null +++ b/docs/research/vulkan-descriptor-model.md @@ -0,0 +1,132 @@ +# Native descriptor and draw model + +Research notes for moving the Vulkan renderer from GL emulation (per-program layouts, texture units, uniform-by-location) to a native model. Collected 2026-09-15. vulkan.gpuinfo.org refused automated access (HTTP 403), so coverage statements come from driver release notes and Mesa's feature list, not gpuinfo statistics. The architecture at the end is what the roadmap's "Fully Vulkan-native" step follows. + +## 1. Descriptor set organization + +- **Sets by change frequency.** Arseny Kapoulkine's layout is set 0 = per frame/view (globals plus global textures), set 1 = per material, set 2 = per draw with a dynamic UBO ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). NVIDIA adds: use as few sets as possible and avoid gaps between binding numbers ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/)). +- **Layout compatibility.** Pipeline layouts are "compatible for set N" only if sets 0..N use identically defined layouts and the push constant ranges are identical. Binding a pipeline whose layout differs at set K invalidates sets K and above, and the spec advises putting the least frequently changing sets first ([spec](https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html); the fetched page was truncated, so re-check the exact wording). Dynamic offsets are given in set order, then binding order, and must be multiples of `minUniformBufferOffsetAlignment` ([vkCmdBindDescriptorSets](https://vkdoc.net/man/vkCmdBindDescriptorSets)). +- **Allocation and update cost.** + - Batch `vkAllocateDescriptorSets`, because each call has overhead on some drivers. Size pools in classes (for example, shadow-pass sets vs material sets) ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + - Caching sets in a hashmap keyed by content cut frame time 38% (44 ms to 27 ms) in the Khronos sample. Avoid `FREE_DESCRIPTOR_SET_BIT`, which can force a slower allocator ([Vulkan-Samples](https://docs.vulkan.org/samples/latest/samples/performance/descriptor_management/README.html)). + - Granite hashes the set contents to find a cached `VkDescriptorSet`, recycles sets unused for 8 frames, and keeps one pool per layout ([Themaister](https://themaister.net/blog/2019/04/20/a-tour-of-granites-vulkan-backend-part-3/)). +- **Counterpoint from Zink.** Its maintainer measured that "the most performant option was always going to be the stupidest one": new sets every draw, written with update templates from aggressive bucket allocation. That beat content caching by 30-50% FPS in Minecraft ([supergoodcode](https://www.supergoodcode.com/sad-trumpet-noises/), [supergoodcode](https://www.supergoodcode.com/description/)). + - Takeaway: use update templates. Caching helps mainly when set contents really repeat. + +## 2. Bindless, descriptor_buffer and descriptor_heap + +- **Descriptor indexing (core in 1.2).** + - Provides update-after-bind, partially bound sets, runtime-sized arrays, and `nonuniformEXT` indexing. The spec minimum is 500k update-after-bind samplers. + - Costs: non-uniform indexing can cost GPU time, and GPU-assisted validation gets expensive ([Khronos sample](https://docs.vulkan.org/samples/latest/samples/extensions/descriptor_indexing/README.html)). + - Benefit: removes per-draw binds and enables GPU-driven batching ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + - DXVK 3.x already requires it, so it is safe to assume on NVIDIA, AMD and Intel ([DXVK wiki](https://github.com/doitsujin/dxvk/wiki/Driver-support)). +- **VK_EXT_descriptor_buffer.** + - Pros: descriptors live in a buffer and are updated with memcpy; no pools. + - Cons: no `*_DYNAMIC` descriptor types, should not be mixed with classic sets, weak tooling. Khronos calls it "a ridiculously powerful feature" that is also "an equally ridiculous foot-gun" ([Khronos](https://www.khronos.org/blog/vk-ext-descriptor-buffer)). + - DXVK 2.7 disabled it on NVIDIA Pascal and older and on AMD RDNA2 and older with AMD's own drivers, and noted a GPU-bound performance cost ([DXVK releases](https://github.com/doitsujin/dxvk/releases)). + - Zink uses it by default where available ([supergoodcode](https://www.supergoodcode.com/buffered/)). +- **VK_EXT_descriptor_heap (January 2026)** is its successor: one resource heap and one sampler heap, with no sets or pipeline layouts ([Khronos blog](https://www.khronos.org/blog/vulkan-introduces-roadmap-2026-and-new-descriptor-heap-extension), [Guide](https://docs.vulkan.org/guide/latest/descriptor_heap.html)). + - Ships in: NVIDIA 610+ ([NVIDIA](https://developer.nvidia.com/blog/streamlining-resource-binding-with-end-to-end-support-for-vulkan-descriptor-heaps/)), AMD Windows 25.30.17.02 ([AMD](https://www.amd.com/en/resources/support-articles/release-notes/RN-RAD-WIN-25-30-17-02-EXPANDED-VLK-SUPPORT.html)), RADV experimental in Mesa 26.1, ANV on by default from Mesa 26.2 ([Phoronix](https://www.phoronix.com/news/Intel-ANV-Descriptor-Heap-Merge)). + - No Intel Windows support found: it is absent from the extension listing for Intel Windows driver 32.0.101.8992 ([Geeks3D](https://www.geeks3d.com/20260815/intel-arc-graphics-driver-32-0-101-89xx/)). + - DXVK now prefers it and has deprecated its descriptor_buffer path ([DXVK releases](https://github.com/doitsujin/dxvk/releases)). + - Still an EXT; it may be revised on the way to KHR. +- **Conclusion for 2025-2026:** plain descriptor-indexing bindless as the baseline; descriptor_heap only as a later optional path. + +## 3. Per-draw data + +- **Push constants.** The guaranteed minimum is 128 bytes in 1.3 and 256 bytes in 1.4 ([1.4 proposal](https://docs.vulkan.org/features/latest/features/proposals/VK_VERSION_1_4.html)). + - NVIDIA recommends push constants for per-draw constants ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/)). Kapoulkine warns some (mostly mobile) architectures effectively offer about 12 bytes, and prefers dynamic UBOs for transforms ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + - Push constant ranges are part of layout compatibility ([Guide](https://docs.vulkan.org/guide/latest/push_constants.html)). +- **Dynamic offsets vs a big SSBO.** + - Dynamic UBOs beat rewriting descriptors. Use SSBOs for arrays beyond UBO limits ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + - One large per-frame buffer with offsets reduces the number of sets ([Vulkan-Samples](https://docs.vulkan.org/samples/latest/samples/performance/descriptor_management/README.html)). + - Inside drivers, dynamic offsets reach shaders "via some push-like mechanism", and Intel's UBO handling has 3-4 internal paths ([gfxstrand](https://gfxstrand.net/faith/blog/2022/08/descriptors-are-hard/)). + - The bindless pattern is a per-draw buffer holding material and transform indices ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). +- **Memory.** Keep buffers persistently mapped. Integrated GPUs expose memory that is both host-visible and device-local; ReBAR gives discrete GPUs the same; otherwise fall back to a staging copy ([VMA](https://gpuopen-librariesandsdks.github.io/VulkanMemoryAllocator/html/usage_patterns.html)). + +## 4. Pipeline state + +- **Dynamic state coverage.** + - Core in 1.3 via extended_dynamic_state 1 and 2: cull mode, front face, topology, depth/stencil tests, rasterizer discard, depth bias enable, primitive restart. + - Only via EDS3 (never core): polygon mode, blend enable/equation, write mask. + - Vertex input needs VK_EXT_vertex_input_dynamic_state ([Guide map](https://docs.vulkan.org/guide/latest/dynamic_state_map.html)). + - 1.4 made none of EDS3, shader_object or GPL mandatory ([1.4](https://docs.vulkan.org/features/latest/features/proposals/VK_VERSION_1_4.html)). +- **Graphics pipeline library (GPL).** Splits a pipeline into four parts, fast-links them at draw time, and compiles an optimized pipeline in the background. Needs `INDEPENDENT_SETS` layouts. NVIDIA, AMD and Intel all support fast linking ([Khronos](https://www.khronos.org/blog/reducing-draw-time-hitching-with-vk-ext-graphics-pipeline-library)). + - ANGLE does exactly this: libraries pre-created at program link time, linking at draw time, rate-limited monolithic builds in the background ([ANGLE](https://chromium.googlesource.com/angle/angle/+/HEAD/src/libANGLE/renderer/vulkan/doc/PipelineCreation.md)). +- **VK_EXT_shader_object.** All state is dynamic. Conformance requires draws within 150% of static-pipeline CPU cost and 120% of maximally dynamic pipelines ([Khronos](https://www.khronos.org/blog/you-can-use-vulkan-without-pipelines-today)). + - Ships in NVIDIA, RADV (Mesa 24.1), ANV (Mesa 25.3) ([Phoronix](https://www.phoronix.com/news/Intel-ANV-VK_EXT_shader_object), [Mesa](https://docs.mesa3d.org/features.txt)). + - Not listed in that Intel Windows driver ([Geeks3D](https://www.geeks3d.com/20260815/intel-arc-graphics-driver-32-0-101-89xx/)); AMD Windows support unconfirmed. Not portable. +- **General advice:** a pipeline cache serialized to disk, pre-warmed from recorded state sets ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). Create pipelines off the render thread and minimize `vkCmdBindPipeline`, which has both CPU and GPU cost ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/)). + +## 5. Submitting many small draws + +- **Command buffers.** Use L×T+N command pools (L = buffered frames, T = threads) and allocate/record on the thread that fills the buffer. Don't record tiny command buffers ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/)). Aim for fewer than 10 submits per frame and skip parallel recording for passes under about 100 draws ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). +- **Multi-draw indirect** plus compute culling removes per-model binds, and a buffer device address passed in push constants avoids set changes ([Vulkan-Samples MDI](https://docs.vulkan.org/samples/latest/samples/performance/multi_draw_indirect/README.html)). +- **Sorting:** by pipeline, then material, so binds are minimized ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/)). + +## 6. How the translation layers do it + +- **ANGLE** caches pipelines in four levels: the driver's VkPipelineCache, a hashmap from GL state to pipeline (xxHash over a packed description), a transition table between neighbouring states driven by dirty bits (avoiding hashing and memcmp), and the currently bound handle ([ANGLE](https://chromium.googlesource.com/angle/angle/+/HEAD/src/libANGLE/renderer/vulkan/doc/FastOpenGLStateTransitions.md)). +- **Zink** went from content caching, to lazy per-draw sets written with templates, to descriptor_buffer. It merged six sets into two buffers (normal and bindless) and saved over 10% VRAM ([supergoodcode](https://www.supergoodcode.com/buffered/)). +- **DXVK** went from descriptor_buffer (2.7) to descriptor_heap (3.x), with GPL and EDS3 as optional features against stutter ([DXVK wiki](https://github.com/doitsujin/dxvk/wiki/Driver-support)). +- **Lesson:** all three work around unknown, arbitrary state. A native renderer can close that state set ahead of time instead ([Khronos GPL](https://www.khronos.org/blog/reducing-draw-time-hitching-with-vk-ext-graphics-pipeline-library)). + +## 7. Assessment of the current design (a41efce, shared frame block) + +- **Set 0 frame block with a dynamic offset: reasonable.** It only stays bound across program switches if every program uses the identical set-0 layout *and* identical push constant ranges ([spec](https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html)). + - Re-snapshotting on each change costs one dynamic offset per bind, which drivers handle as push-like data, so it's cheap ([gfxstrand](https://gfxstrand.net/faith/blog/2022/08/descriptors-are-hard/)). +- **Per-program layouts in sets 1-3 are the weak spot.** + - Layouts that differ per program invalidate sets 1-3 on every program switch. + - Building 16-unit sampler sets per draw is the hottest path Zink and Khronos identify. + - The per-program uniform block goes through a dynamic offset. That works, but per-draw scalars would be cheaper in push constants. +- **Fixes, in order:** + 1. One global pipeline layout. + 2. Bindless textures: sampled-image array plus a small set of shared samplers. + 3. Per-draw indices and scalars in push constants (at most 128 bytes on 1.3). + 4. Per-object data in an SSBO indexed by `gl_DrawID` or `firstInstance`. +- **Pitfalls.** + - Intel's hardware binding table has 240 entries, so bind a few large arrays rather than many sets ([gfxstrand](https://gfxstrand.net/faith/blog/2022/08/descriptors-are-hard/)). + - Some Intel integrated GPUs have tight descriptor limits; query them ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + - descriptor_buffer regressed on older AMD and NVIDIA ([DXVK](https://github.com/doitsujin/dxvk/releases)). + - AMD's Windows driver still reports Vulkan 1.3.x, so don't assume 1.4 limits ([Geeks3D](https://www.geeks3d.com/20260603/amd-radeon-adrenalin-26-6-x-graphics-driver/)). + - Use `nonuniformEXT` where the index varies within a draw ([Khronos sample](https://docs.vulkan.org/samples/latest/samples/extensions/descriptor_indexing/README.html)). + +## Native architecture for this renderer + +**Now** + +1. **One global pipeline layout for every program.** + - Set 0: frame UBO with a dynamic offset, plus global textures such as shadow maps. + - Set 1: bindless `sampler2D[]`/`texture2D[]` array with partially-bound and update-after-bind flags. + - Set 2: storage buffers (chunk faces, per-object data). + - One push constant range of 128 bytes or less. + - Why: layout compatibility ([spec](https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html)); bindless ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + - Portable on 1.3: DXVK requires descriptor indexing on all three vendors ([DXVK wiki](https://github.com/doitsujin/dxvk/wiki/Driver-support)). + - Check `maxPerStageDescriptorUpdateAfterBindSampledImages` at startup. +2. **Per-draw data in push constants:** model matrix or offset, tint, texture/material indices ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/)). + - Larger per-program uniforms go in one per-frame SSBO or UBO ring addressed by an index or offset ([Vulkan-Samples](https://docs.vulkan.org/samples/latest/samples/performance/descriptor_management/README.html)). +3. **Until bindless lands:** write sets with `vkUpdateDescriptorSetWithTemplate` from bucketed per-frame pools that are reset, not freed ([supergoodcode](https://www.supergoodcode.com/sad-trumpet-noises/)). +4. **Buffers:** VMA-style sub-allocation, persistently mapped, preferring host-visible device-local memory ([VMA](https://gpuopen-librariesandsdks.github.io/VulkanMemoryAllocator/html/usage_patterns.html)). +5. **Pipelines:** a pipeline cache serialized to disk, plus pre-warming of recorded pipeline keys at load ([zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/); details in [vulkan-caching.md](vulkan-caching.md)). Keep blend and polygon mode in the key when EDS3 is missing. + +**Next** + +6. **Use EDS3 and vertex-input dynamic state when present** to shrink the key to program + formats ([Guide](https://docs.vulkan.org/guide/latest/dynamic_state_map.html)). Add GPL fast-linking with background optimized builds, ANGLE-style ([ANGLE](https://chromium.googlesource.com/angle/angle/+/HEAD/src/libANGLE/renderer/vulkan/doc/PipelineCreation.md)). +7. **Sort draws** by pipeline and then material, and cache the bound pipeline and state behind dirty bits ([ANGLE](https://chromium.googlesource.com/angle/angle/+/HEAD/src/libANGLE/renderer/vulkan/doc/FastOpenGLStateTransitions.md)). + - Chunks: MDI with per-draw SSBO entries indexed by draw ID. + - Entities and particles: instancing. +8. **Parallel recording** with per-thread pools, only for large passes ([NVIDIA](https://developer.nvidia.com/blog/vulkan-dos-donts/), [zeux](https://zeux.io/2020/02/27/writing-an-efficient-vulkan-renderer/)). + +**Later** + +9. **GPU-driven culling for chunks** with `vkCmdDrawIndexedIndirectCount` ([Vulkan-Samples](https://docs.vulkan.org/samples/latest/samples/performance/multi_draw_indirect/README.html)). +10. **Optional descriptor_heap backend** once Intel Windows ships it and the extension settles toward KHR ([Khronos](https://www.khronos.org/blog/vulkan-introduces-roadmap-2026-and-new-descriptor-heap-extension)). Skip descriptor_buffer; DXVK is deprecating it. +11. **Shader objects** only as an optional path (NVIDIA and Mesa), never a requirement ([Phoronix](https://www.phoronix.com/news/Intel-ANV-VK_EXT_shader_object)). + +**Uncertain / version-dependent** + +- gpuinfo coverage percentages could not be obtained. +- AMD Windows shader_object support is unconfirmed. +- Intel Windows support for descriptor_heap and shader_object rests on one Geeks3D extension listing. +- Release dates on DXVK's release page were unreadable, so none are given. +- The 256-byte push constant minimum applies only to Vulkan 1.4 devices. diff --git a/docs/research/vulkan-validation.md b/docs/research/vulkan-validation.md new file mode 100644 index 00000000..9ac05837 --- /dev/null +++ b/docs/research/vulkan-validation.md @@ -0,0 +1,143 @@ +# Vulkan validation and best practices + +Research notes for validating the Vulkan 1.3 renderer (Silk.NET, C#) against current Khronos and vendor guidance. Collected 2026-09-15. The design derived from it is the milestone plan at the end, which the roadmap's "Best-practice validation" step follows. + +## 1. What the Khronos validation layer can do + +**Validation areas and their settings.** These are layer settings in `VkLayer_khronos_validation.json` (https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/layers/VkLayer_khronos_validation.json.in): + +- **Core checks (on by default):** `validate_core`, `check_image_layout`, `check_command_buffer`, `check_object_in_use`, `check_query`, `check_shaders` (runs spirv-val, with caching), `stateless_param`, `object_lifetime`, `thread_safety`, `unique_handles`. +- **Synchronization (off by default):** `validate_sync`. Related settings: + - `syncval_full_validation` checks accesses across all command buffers. + - `syncval_shader_accesses_heuristic` is documented as "may produce false-positives". + - `syncval_message_extra_properties` adds key/value fields you can filter on. +- **Best practices (off by default):** `validate_best_practices`, plus `validate_best_practices_arm`, `_amd`, `_img` and `_nvidia`. +- **Legacy API warnings:** `legacy_detection` with `legacy_detection_mode`. +- **GPU-AV:** `gpuav_enable` with sub-checks for descriptor indexing, buffer-device-address out-of-bounds, indirect draw/dispatch buffers, vertex attribute fetch out-of-bounds, a shader sanitizer (for example divide-by-zero), plus `gpuav_safe_mode`. +- **Debug printf:** `printf_enable`, `printf_to_stdout`, `printf_buffer_size` (default 1024). + +**What each check catches and its limits.** + +- **Sync validation** reports five hazard types (read-after-write, write-after-read, write-after-write, and two racing variants). It does not track exact shader descriptor use, memory aliasing, indirect buffers or host memory access (https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/docs/syncval_usage.md). +- **GPU-AV** needs Vulkan 1.1+, one free descriptor set slot, and the features `fragmentStoresAndAtomics`, `vertexPipelineStoresAndAtomics` and `timelineSemaphore`. + - The GPU-AV documentation strongly advises against running it together with CPU core validation because of the slowdown. + - It has a fast "regression mode" and a slower, crash-avoiding "debug mode" (https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/docs/gpu_validation.md). + - SDK 1.4.341 added "Scoped GPU-AV" and GPU-AV coverage for descriptor heap and descriptor buffer (https://www.lunarg.com/lunarg-releases-vulkan-sdk-1-4-341-0/). +- **Debug printf** uses one descriptor set and some device memory. Its messages arrive at INFO severity with message ID `0x4fe1fef9`. Presets exist: `VK_LAYER_PRINTF_ONLY_PRESET=1` and `VK_LAYER_PRINTF_ENABLE=1` (https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/docs/debug_printf.md). +- **CPU-side bindless checks** can't know which descriptors a shader actually uses, so they are expensive and prone to false positives. LunarG points to GPU-AV for this (Vulkanised 2023, slide 29: https://vulkan.org/user/pages/09.events/vulkanised-2023/vulkanised_2023_using_vulkan_validation_effectively.pdf). + +**Vendor best-practice checks.** The main best-practices document does not list them. The check lists live in the layer source and in vendor posts: + +- **Arm:** more than 4x MSAA, using `vkCmdResolveImage` instead of resolving in the render pass, index-buffer ordering that thrashes the vertex cache. Arm says these warnings can be noisy on other GPUs (https://developer.arm.com/community/arm-community-blogs/b/mobile-graphics-and-gaming-blog/posts/arm-best-practice-warnings-in-vulkan-sdk). +- **AMD:** flags that should or shouldn't be used, and clearing with "fast" colors (https://gpuopen.com/learn/vulkan-best-practice-layer/). +- **NVIDIA:** includes checks such as `CreateDevice-PageableDeviceLocalMemory` and `AllocateMemory-SetPriority` (see issue #8276 in section 2). +- The older enable string `VALIDATION_CHECK_ENABLE_VENDOR_SPECIFIC_AMD` (or `_ARM`) set through `VK_LAYER_ENABLES` still appears in these posts. + +**How to configure.** + +- **VK_EXT_validation_features is deprecated** in favour of VK_EXT_layer_settings (https://docs.vulkan.org/refpages/latest/refpages/source/VK_EXT_validation_features.html). The renderer's current `"sync,best"` path should move over. +- **Precedence:** environment variables, then `vk_layer_settings.txt`, then VK_EXT_layer_settings (available since 1.3.272). +- **Settings file lookup:** the working directory, then `VK_LAYER_SETTINGS_PATH` (a directory or a file). +- **Environment variable names, strongest first:** `VK_KHRONOS_VALIDATION_`, `VK_VALIDATION_`, `VK_`. The `VK_LAYER_` form is deprecated. +- **Enabling layers from outside the app:** `VK_LOADER_LAYERS_ENABLE=*validation` (loader 1.3.234+). +- Source for the four items above: https://github.com/KhronosGroup/Vulkan-Utility-Libraries/blob/main/docs/layer_configuration.md +- **Open question:** the settings JSON lists environment names like `VK_LAYER_KHRONOS_VALIDATION_VALIDATE_BEST_PRACTICES`. Test which spelling the pinned SDK version actually honours. + +**Cost.** + +- LunarG: "Don't enable all areas at once (it will be slow), pick one of Core / Shader-Based / Synchronization / Best Practices", then fix what each reports and re-run the Standard preset (Vulkanised 2023, slide 17). +- GPU-AV reads data back from the GPU and instruments shaders (gpu_validation.md above). +- No authoritative slowdown multipliers were found; measure on our own workload. A "2–5x overhead" figure is sometimes attributed to the Vulkanised 2023 slides, but it does not appear in them. + +## 2. Running validation in CI and in-game + +**Defaults** (https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/layers/vk_layer_settings.txt): + +- `report_flags = error,warn`. Add `perf` and `info` when running best practices, because best-practice messages come out as Warning or Performance severity (Vulkanised 2023, slide 19). +- `enable_message_limit = true` with `duplicate_message_limit = 10`. In CI, count messages in our own callback rather than relying only on what the layer prints. +- `message_id_filter` is empty and takes a comma-separated list of VUID names or hex IDs. + +**Filtering.** + +- Use the layer's built-in filter rather than dropping messages in the callback; LunarG says built-in filtering is faster. +- It is still fine to use the callback to "trigger failures in your unit test framework" (slide 23). +- For sync messages, filter on the structured extra-properties fields, not on message text (syncval_usage.md). + +**Known false positives.** Suppress each one by exact ID, with a link to the upstream issue and a layer version to re-check against: + +- NVIDIA `BindMemory-NoPriority` fires even when priority is set through `VkMemoryPriorityAllocateInfoEXT` (https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/8276). +- `vkWaitForFences` with a zero timeout (https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/1788). +- Small-allocation warnings from UI code such as ImGui (https://github.com/ocornut/imgui/issues/4238). +- A fence reset after `vkDeviceWaitIdle` when the fence was a present fence (https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/8376). +- Several sync-validation issues around sync2 access flags and timeline semaphores (https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/7456, https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/7457). + +**Engine practice.** Godot runs with `--gpu-validation`, and `--gpu-abort` quits on the first error (https://docs.godotengine.org/en/latest/engine_details/development/debugging/vulkan/vulkan_validation_layers.html). No documented CI validation setups were found for DXVK, Vulkan-Samples or Filament. + +## 3. Other review tools + +- **vkconfig:** GUI presets for validation, synchronization and best practices, with a message-mute list (Vulkanised 2023, slides 7 and 16). +- **GFXReconstruct:** capture a known-good session, then replay it after code or driver changes to catch regressions (https://www.lunarg.com/mastering-gfxreconstuct-part-1/). +- **RenderDoc:** can replay a capture with API validation turned on (https://renderdoc.org/docs/window/capture_attach.html). +- **Nsight Aftermath:** GPU crash dumps and checkpoints through `VK_NV_device_diagnostics_config` (https://docs.nvidia.com/nsight-aftermath/SDK/index.html). Nsight's injection has been reported to cause validation errors of its own, so don't validate while Nsight is attached (https://forums.developer.nvidia.com/t/new-versions-of-nsight-add-flags-behind-the-scene-that-cause-vulkan-validation-errors/216730). +- **AMD:** the Radeon Developer Tool Suite includes RGP (GPU profiler), RMV (memory visualizer, uses debug names) and RGA (https://gpuopen.com/news/introducing-radeon-developer-tool-suite/). Radeon GPU Detective uses debug-utils labels for crash triage (https://gpuopen.com/learn/rgd-1-1-vulkan-support/). +- **Arm Performance Studio / Frame Advisor:** documented for Vulkan 1.0–1.2, so it is of limited use for a desktop 1.3 renderer (https://learn.arm.com/learning-paths/mobile-graphics-and-gaming/ams/). +- **Intel GPA:** 2025.1 is the final release and the tool is being discontinued; don't plan around it (https://www.intel.com/content/www/us/en/developer/articles/release-notes/gpa/2024-4.html). +- **Vulkan Guide chapters to audit against:** Synchronization, synchronization2, Swapchain Semaphore Reuse, Memory Allocation, descriptor indexing, WSI, Pipeline Cache, Robustness, Formats, Threading, debug utils, Deprecated (https://docs.vulkan.org/guide/latest/index.html). + +## 4. Audit checklist + +- **Memory:** sub-allocate; use dedicated allocations for large render targets; set memory priority where supported (https://gpuopen-librariesandsdks.github.io/VulkanMemoryAllocator/html/usage_patterns.html); enable pageable device-local memory on NVIDIA (https://docs.vulkan.org/refpages/latest/refpages/source/VK_EXT_pageable_device_local_memory.html). VK_EXT_memory_budget: no source fetched yet, unverified. +- **Command buffers:** reset whole pools rather than individual buffers, never allocate and free per frame (measured at 28.8% of frame time), use `ONE_TIME_SUBMIT`, one pool per thread (https://docs.vulkan.org/samples/latest/samples/performance/command_buffer_usage/README.html). +- **Swapchain:** + - Keep one present semaphore per swapchain image, indexed by the acquired image index. Validation has flagged unsafe reuse since SDK 1.4.313 (https://docs.vulkan.org/guide/latest/swapchain_semaphore_reuse.html). + - `swapchain_maintenance1` is now KHR and adds present fences, per-present mode changes and releasing acquired images (https://docs.vulkan.org/refpages/latest/refpages/source/VK_EXT_swapchain_maintenance1.html). + - `FIFO_LATEST_READY` is a present mode worth considering (https://docs.vulkan.org/refpages/latest/refpages/source/VK_EXT_present_mode_fifo_latest_ready.html). + - Also review recreation with `oldSwapchain` (no source fetched yet). +- **Pipeline cache:** check the header's vendorID, deviceID and pipelineCacheUUID before loading; persist the cache robustly (https://docs.vulkan.org/guide/latest/pipeline_cache.html). Details in [vulkan-caching.md](vulkan-caching.md). +- **Device loss:** VK_EXT_device_fault, and the newer VK_KHR_device_fault, which can be queried at any time and also reports non-fatal faults (https://docs.vulkan.org/refpages/latest/refpages/source/VK_KHR_device_fault.html). +- **Debug naming:** name objects and label command buffers, which improves validation messages and tool output (Vulkanised 2023, slides 20–25). +- **Descriptors, image layouts and usage flags, format feature checks, queue usage:** run core validation plus GPU-AV (for bindless), and review against the Formats and Threading chapters. + +## 5. Linux, Mesa and CI + +- **Lavapipe:** Vulkan 1.3 conformant and exposes all Vulkan 1.4 core extensions (not yet submitted for 1.4 conformance). It runs on Windows and Linux and is aimed at CI runners without a GPU (https://vulkan.org/user/pages/09.events/vulkanised-2025/T5-Lucas-Fryzek-Igalia.pdf). Windows builds are available (https://github.com/jakoch/rasterizers). +- **Pinning the driver:** set `VK_DRIVER_FILES` (loader 1.3.207+; `VK_ICD_FILENAMES` is deprecated) or `VK_LOADER_DRIVERS_SELECT` (1.3.234+) (https://github.com/KhronosGroup/Vulkan-Loader/blob/main/docs/LoaderInterfaceArchitecture.md). +- **Mesa environment variables** (https://docs.mesa3d.org/envvars.html): + - `MESA_VK_ABORT_ON_DEVICE_LOSS` + - `MESA_VK_WSI_HEADLESS_SWAPCHAIN`, which suits the headless harness + - `MESA_VK_DEVICE_SELECT` + - `RADV_DEBUG=hang` (writes hang dumps), `RADV_DEBUG=syncshaders` + - `ANV_DEBUG` +- **Expect lavapipe-specific failures** in the layer itself (https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/7731). Treat lavapipe as a correctness gate, not a stand-in for vendor best-practice or performance checks. + +## Milestone plan for this renderer + +1. **Move to VK_EXT_layer_settings (P0).** Replace `"sync,best"` with explicit settings, and log the layer version and the settings actually applied. + - *Exit:* tests set `validate_sync`, `validate_best_practices` and the vendor settings through VK_EXT_layer_settings, and skip with a clear reason when a setting is missing. + - Sources: validation_features deprecation refpage; layer_configuration.md. +2. **Split the validation runs by area, per LunarG's advice (P0).** + - (a) Standard core validation plus thread safety. + - (b) Sync only, with `syncval_full_validation` and `syncval_message_extra_properties`. + - (c) Best practices plus all four vendor settings, with `report_flags=error,warn,perf,info`. + - (d) GPU-AV with core off, nightly. + - *Exit:* each run fails on any message at warning severity or above that isn't in the suppression list. + - Sources: Vulkanised 2023 slides 17 and 19; gpu_validation.md. +3. **Suppression policy (P0).** Keep a versioned `message_id_filter` list; each entry records the VUID, the upstream issue link, the layer version and a re-check date. The Arm and IMG checks are advisory on desktop GPUs. + - *Exit:* at most a handful of documented entries. + - Sources: issues #8276 and #1788; the Arm blog. +4. **Headless harness runs (P1).** Drive the real client for N frames, including a resize (swapchain recreation) and a scene load, on NVIDIA and AMD (Windows) plus RADV and ANV (Linux). + - *Exit:* zero errors or warnings across the full session. Intel on Windows is lower priority, and its tool support is ending. +5. **Lavapipe CI lane (P1).** Pin the driver with `VK_DRIVER_FILES` and use `MESA_VK_WSI_HEADLESS_SWAPCHAIN` and `MESA_VK_ABORT_ON_DEVICE_LOSS`. + - *Exit:* core and sync validation are clean on every PR. + - Caveat: lavapipe's feature set differs from GPUs, so gate its tests on capabilities. + - Sources: Mesa envvars page; loader docs. +6. **Review against Khronos guidance (P1).** Work through the section 4 checklist against the listed Guide chapters and the command-buffer sample. The swapchain semaphore-per-image fix is mandatory. + - *Exit:* a signed-off checklist with evidence for each item. +7. **Robustness and tooling (P2).** + - Debug names on all objects, and command buffer labels. + - VK_EXT/KHR device_fault reporting on `VK_ERROR_DEVICE_LOST`. + - Optional Aftermath integration. + - A GFXReconstruct capture of a reference scene, replayed on driver updates. + - *Exit:* a deliberately triggered device loss produces a fault report. + +**Version notes.** GPU-AV scope and the semaphore-reuse VUID depend on the SDK version (1.4.313 and 1.4.341). Pin the SDK or layer version in CI. diff --git a/docs/research/xegtao-integration.md b/docs/research/xegtao-integration.md new file mode 100644 index 00000000..63706c7c --- /dev/null +++ b/docs/research/xegtao-integration.md @@ -0,0 +1,159 @@ +# XeGTAO on the Vulkan path + +Research notes for integrating XeGTAO (MIT, GameTechDev) into the Vulkan renderer's TAA path. Collected 2026-09-15. The integration plan at the end is what the roadmap's "XeGTAO" step follows. + +**Summary.** XeGTAO ports cleanly to GLSL compute. There are two real porting problems: + +- **Prefilter pass:** it depends on HLSL `groupshared` memory and has to be restructured. +- **Coordinate frame:** a GL-style projection with depth remapped to [0,1] needs a small constants and normals mapping, derived below. + +With TAA: NoiseIndex = frame % 64 and a single denoise pass. On GL 3.3, keep vanilla SSAO. + +Source files are cited by their GitHub URLs: [XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h), [XeGTAO.hlsli](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.hlsli), [vaGTAO.hlsl](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/vaGTAO.hlsl) and [README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md). + +## 1. XeGTAO specifics + +**Inputs** + +- **Depth:** raw depth, turned into view-space depth by `z = DepthUnpackConsts.x / (DepthUnpackConsts.y - d)`, with far values clamped to 65504 for fp16 ([hlsli](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.hlsli)). +- **View space:** positive Z forward. Screen UV origin is top-left; view +Y is up (`NDCToViewMul = (2·tanX, −2·tanY)`, `NDCToViewAdd = (−tanX, tanY)`) ([XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h)). +- **Normals:** optional but recommended, in view space. A separate normals-from-depth pass exists ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). Shading normals keep more detail than geometry normals ([issue #3](https://github.com/GameTechDev/XeGTAO/issues/3)). +- `GTAOUpdateConstants` reads D3D-style matrix entries, and its handedness fix carries the comment "I think it is [correct]" ([XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h)). Don't reuse it for a GL matrix. + +**Passes** + +| Pass | What it does | Threads / dispatch | +|---|---|---| +| Prefilter | Writes view-space depth into 5 mips (weighted-average filter) | 8×8 threads, each handling a 2×2 block; dispatch `(W+15)/16` ([vaGTAO.hlsl](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/vaGTAO.hlsl)) | +| Main | GTAO integral; writes the AO term and packed edges | 8×8, dispatch `(W+7)/8` | +| Denoise | Edge-aware 3×3 blur, two horizontal pixels per thread | Dispatch X ≈ `((W+1)/2+7)/8` (read from the code); the last pass multiplies back by 1.5 | + +- **Quality levels** (slices × steps per side): Low 1×2, Medium 2×2, High 3×3, Ultra 9×3 ([vaGTAO.hlsl](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/vaGTAO.hlsl)). +- **Default constants:** RadiusMultiplier 1.457, FalloffRange 0.615, SampleDistributionPower 2, ThinOccluderCompensation 0, FinalValuePower 2.2, DepthMIPSamplingOffset 3.30, DenoiseBlurBeta 1.2 (1e4 disables denoise) ([XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h)). The README gives 3.15 for the sampling offset, so docs and code disagree ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). +- **Noise:** + - A Hilbert index (64×64 tile) drives an R2 sequence, plus `288·(NoiseIndex%64)` per frame ([vaGTAO.hlsl](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/vaGTAO.hlsl)). + - NoiseIndex is `frame%64` when denoising with TAA, otherwise 0 ([XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h)). + - Computing the Hilbert index in the shader costs about 7%; a lookup texture avoids that ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). +- **Formats:** + - Working depth: R16F ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). + - AO: `R8_UINT`, stored as visibility / 1.5. + - Edges: `unorm` R8. + - Bent normals: packed RGBA8, bent normal in xyz and visibility in w, adding about 25% cost ([hlsli](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.hlsli), [README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). +- **Errata:** + - The repository is discontinued and was archived in April 2024 ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). + - Open issue #7: a biased hash in `vaNoise`, which only affects the non-default hash noise path ([#7](https://github.com/GameTechDev/XeGTAO/issues/7)). + - The README says "5×5 denoise" but the code is a 3×3 kernel; the question was never answered ([#6](https://github.com/GameTechDev/XeGTAO/issues/6)). + - Code comments admit several weak spots: `RotFromToMatrix` is "not tested… especially 16-bit floats", there is a fudge for over-darkening on slopes, fp16 plus 32-bit depth is an `#error`, and the depth bias is 0.99920 for fp16 vs 0.99999 for fp32 ([hlsli](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.hlsli)). + +## 2. Existing ports + +- **Bevy** ([PR #7402](https://github.com/bevyengine/bevy/pull/7402), [source](https://github.com/bevyengine/bevy/tree/main/crates/bevy_pbr/src/ssao)): + - Uses R16F if the adapter supports it for storage, otherwise R32F. + - Hilbert lookup texture (64×64, u16), and the same `288·(frame%64)` noise when TAA jitter is active. + - One 3×3 denoise pass of one pixel per thread (XeGTAO does two pixels per thread and two passes). + - Its `textureGather` component indices match XeGTAO's. + - It **stores raw NDC depth in the mips** and reconstructs positions through `view_from_clip`, which handles reversed-Z generically. It samples depth with a *linear* sampler, which XeGTAO warns causes interpolation artefacts. + - The main pass has since become visibility-bitmask AO (VBAO). + - AO only affects indirect diffuse ([docs.rs](https://docs.rs/bevy/latest/bevy/pbr/struct.ScreenSpaceAmbientOcclusion.html)). + - Pitfalls from review: packing workarounds and r32float fallbacks ([PR #7402](https://github.com/bevyengine/bevy/pull/7402)). +- **DiligentFX:** based on XeGTAO, uses pixel shaders for depth convolution so it runs on WebGL, supports half resolution with depth-aware upsampling, and uses a ReBLUR-style denoiser because XeGTAO's was not enough for large radii ([DiligentFX](https://github.com/DiligentGraphics/DiligentFX/tree/master/PostProcess/ScreenSpaceAmbientOcclusion)). +- **ReShade BaBa_XeGTAO:** fragment-shader-only port with no depth mips, an à-trous denoiser, its own temporal history and bilateral upsampling ([source](https://github.com/BarbatosBachiko/Reshade-Shaders/blob/main/Shaders/BaBa_XeGTAO.fx)). +- **Unreal (VisionGTAO):** its README says to keep denoise on with TAA/TSR/DLSS and to apply AO before fog and translucency ([VisionGTAO](https://github.com/JustinDarlington/VisionGTAO)). +- **Unity:** the aaaa-rp SRP includes XeGTAO with bent normals ([aaaa-rp](https://github.com/Delt06/aaaa-rp)). +- **Godot 4** uses ASSAO, not GTAO ([godot#101961](https://github.com/godotengine/godot/pull/101961)). +- **three.js** GTAO is a WebGL fragment shader with no XeGTAO lineage ([GTAOShader](https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/jsm/shaders/GTAOShader.js)). + +## 3. HLSL to GLSL compute + +**Straight mappings** + +- `[numthreads]` → `layout(local_size_x=8, local_size_y=8)`. +- `SV_DispatchThreadID` / `SV_GroupThreadID` → `gl_GlobalInvocationID` / `gl_LocalInvocationID`. +- `frac`/`lerp`/`saturate` → `fract`/`mix`/`clamp`; `asfloat`/`asint` (in FastSqrt) → `intBitsToFloat`/`floatBitsToInt`. +- `textureGather` returns texels in the order (i0j1, i1j1, i1j0, i0j0), the same as `GatherRed` ([GLSL built-ins](https://docs.vulkan.org/glsl/latest/chapters/builtinfunctions.html)). + +**Porting traps** + +- **Matrix order:** HLSL `mtx[r][c]` is row-major and GLSL is column-major, so `RotFromToMatrix` needs a transpose. This only matters for bent normals. +- **`groupshared`:** GLSL has no workgroup-shared memory. SPIR-V allows a Workgroup storage class in Vulkan compute ([SPIR-V environment](https://docs.vulkan.org/spec/latest/appendices/spirvenv.html)), but compiling the HLSL through DXC was historically blocked by `GroupMemoryBarrier` being unimplemented ([DXC #795](https://github.com/Microsoft/DirectXShaderCompiler/issues/795)). Fix: split the prefilter into separate dispatches (see the plan below). +- **Storage formats:** + - Declare every storage image with a format qualifier matching the Vulkan format, and check `STORAGE_IMAGE_BIT` first ([Vulkan Guide](https://docs.vulkan.org/guide/latest/storage_image_and_texel_buffers.html)). + - The mandatory storage list includes RGBA8_UNORM, RGBA16F and R32F but **not R8_UNORM** ([Vixen #612](https://github.com/Rikarin/Vixen/issues/612)). + - The gpuweb capability table marks R8_UNORM and R16F storage on Vulkan as conditional, and R32F, R32UI and RGBA8 as universal ([gpuweb wiki](https://github.com/gpuweb/gpuweb/wiki/Texture-format-capabilities)). + - `shaderStorageImageExtendedFormats` only guarantees support; enabling it does nothing ([VkPhysicalDeviceFeatures](https://docs.vulkan.org/refpages/latest/refpages/source/VkPhysicalDeviceFeatures.html)). + - Per-format coverage from vulkan.gpuinfo.org could not be obtained (HTTP 403), so query at runtime. +- **Precision:** `min16float` → `mediump` becomes RelaxedPrecision in SPIR-V, which drivers may ignore ([zeux notes](https://gist.github.com/zeux/c83001968e06fe0b789fa4bd513860c6)). Mesa 22.3 fixed RelaxedPrecision bugs ([Mesa notes](https://docs.mesa3d.org/relnotes/22.3.0.html)), and XeGTAO itself saw fp16 slowdowns on some GPUs ([README FAQ](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). Start with `float`. +- **Constants block:** all members are paired `vec2`s and scalars, 96 bytes. The std140 layout matches the HLSL cbuffer and fits in push constants (Vulkan guarantees at least 128 bytes). Calculated from the struct, not taken from a source. + +## 4. TAA coupling + +- **Denoise pass count:** since v1.21, one pass "is enough when TAA [is] enabled" ([XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h)). +- **Noise and history:** XeGTAO relies on TAA plus temporal noise, and temporal variance has to stay low enough that TAA doesn't treat the noise as detail ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). That matters for our resolve's 3×3 neighbourhood clamp, since noisy AO widens the clamp box. Unverified; measure it. +- **Where AO is applied:** + - XeGTAO's sample dims probe diffuse and specular light, plus micro-shadowing on direct light ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). + - Bevy applies it to indirect diffuse only. + - Applying before the resolve, as the renderer now does with SSAO (41373cf), lets TAA integrate the noise. +- **Performance and resolution:** + - High preset: 2.39 ms at 1080p on i7-1195G7 integrated graphics; 0.56 ms on an RTX 2060. + - Medium costs about 2/3 of High, and Low about 2/3 of Medium. + - The paper runs at half resolution. XeGTAO defaults to full resolution and suggests half resolution with a bilateral upsample if that's still too slow ([README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md)). + - No Arc 140V numbers exist; measure. + +## 5. Vulkan compute integration + +- **Same command buffer is fine:** dispatch, then a barrier from GENERAL/`SHADER_WRITE` to `READ_ONLY_OPTIMAL`/`SHADER_READ` before the fragment pass ([sync examples](https://docs.vulkan.org/guide/latest/synchronization_examples.html)). +- **Storage image rules:** layout must be GENERAL, the image needs `STORAGE` usage, and views need identity swizzle ([VkWriteDescriptorSet](https://docs.vulkan.org/refpages/latest/refpages/source/VkWriteDescriptorSet.html)). +- **Mip views:** one view per mip for writes, as XeGTAO does ([vaGTAO.hlsl](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/vaGTAO.hlsl)); Bevy's mip0–4 bindings follow the same pattern. +- **Frame graph:** model GTAO as one node that reads Depth and Normal and writes AO, with transient internal images and no readbacks. Profile with timestamp queries. + +## 6. GL 3.3 fallback + +- Core GL 3.3 has no compute and no `textureGather` (that arrived with [ARB_texture_gather](https://registry.khronos.org/OpenGL/extensions/ARB/ARB_texture_gather.txt)/GL 4.0). +- Fragment-shader ports exist (ReShade, DiligentFX for WebGL), but they drop or rework the mips and the denoiser. +- Godot's GL renderer went with a trivial SSAO (S4AO, about 0.6 ms on a GTX 1650 Ti) ([Godot PR #109447](https://github.com/godotengine/godot/pull/109447)). +- **Decision:** keep vanilla SSAO on GL. + +--- + +## Integration plan for this renderer + +1. **Constants from a GL projection with remapped depth.** Derived here, not taken from a source; verify with step 7. + - Take `A = P[2][2]` and `B = P[2][3]` (math row/column notation; in a column-major array these are `m[10]` and `m[14]`). With `d = (z_ndc+1)/2`: + - `DepthUnpackConsts = (−B/2, (1−A)/2) = (n·f/(f−n), f/(f−n))` + - For an infinite far plane this becomes `(n, 1)`. + - This is the same form as D3D, so the shader needs no changes. + - `tanX = 1/P[0][0]` and `tanY = 1/P[1][1]`. Keep XeGTAO's `NDCToViewMul`/`NDCToViewAdd` unchanged. + - Normals, mapped from GL view space (z pointing backwards): + - **G-buffer stored upright** (row 0 = top of screen): pass `(n.x, n.y, −n.z)`. This is a mirror; visibility only uses dot products and lengths, so it is unaffected. Bent normals are uncertain here. + - **G-buffer stored bottom-up:** pass `(n.x, −n.y, −n.z)`, which is a proper rotation. + - TAA jitter: ignore it at first; the error is sub-pixel. +2. **Formats** + - **Working depth:** R32F with `XE_GTAO_FP32_DEPTHS` and the half-precision path off (the code requires this combination). + - R32F storage is universally supported. + - fp16 steps are 0.5 m between 512 and 1024 m, which is coarse against a 0.5 m radius at voxel view distances. + - R16F is an option where supported. + - **AO and edges:** R8_UNORM where `STORAGE_IMAGE_BIT` is set, otherwise RGBA8_UNORM (AO in r, edges in g). + - **Hilbert lookup:** a sampled R16_UINT texture. + - **Bent normals:** skip for now. +3. **Passes** + - **Prefilter:** + - Pass A writes mip0 and mip1: one gather per thread, dispatch `(W+15)/16`. Guard writes that fall outside the image. + - Passes B–D build mips 2–4, each from the previous mip with `texelFetch`. + - **Main:** quality level set by shaderc macros, dispatch `(W+7)/8`. + - **Denoise:** 1 pass with TAA (final pass multiplies by 1.5). Offer 2–3 passes when TAA is off, ping-ponging between images with pre-built descriptor sets. + - **Sampler:** point, clamp, NEAREST mipmap mode. +4. **TAA:** `NoiseIndex = frame % 64` when TAA and denoise are on, otherwise 0. + - Composite before the resolve, and don't apply AO to the glow attachment. + - Longer term, move AO onto the ambient terms only. +5. **Presets and tuning** + - Integrated GPUs: Medium at full resolution. Discrete GPUs: High. Ultra only for screenshots. + - Add half resolution with bilateral upsampling later. + - EffectRadius (in blocks): start around 0.5–1.0 and tune; this is a starting guess. +6. **GL backend:** keep vanilla SSAO. +7. **Verification** + - **Frame check:** compare linearised depth and reconstructed XY against the view-space position attachment. Target under 0.1% error away from sky and far pixels. + - **Normals check:** debug-view the normals and edges. + - **Reference:** a numpy CPU version of the main pass on depth and normals captured by the headless harness, compared with PSNR and [ꟻLIP](https://github.com/NVlabs/flip). + - **Convergence:** average 64 NoiseIndex frames with a static camera to get a converged image, and diff the TAA output against it. + - **Temporal stability:** per-pixel temporal standard deviation with a static camera, and frame-to-frame reprojected differences in motion. + - **Hardware:** cross-check NVIDIA, AMD, Intel and Mesa (lavapipe as a deterministic CPU reference), with per-pass GPU timestamps on the Arc 140V against vanilla SSAO. From 618a0b15b24867d6f5279130f37a3697ecd61e19 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:07:56 +0200 Subject: [PATCH 135/226] feat(vulkan): compiled SPIR-V and the driver pipeline cache persist between launches, validated before use --- .../PipelineCacheTests.cs | 75 ++++++ .../ShaderCacheTests.cs | 247 ++++++++++++++++++ Optimum.Render.Vulkan/Core/CacheFileWriter.cs | 77 ++++++ Optimum.Render.Vulkan/Core/PipelineCache.cs | 57 +++- .../Core/PipelineCacheFile.cs | 113 ++++++++ Optimum.Render.Vulkan/Core/VulkanContext.cs | 9 + .../Platform/VulkanClientPlatform.cs | 10 +- .../Shaders/ShaderBinaryCache.cs | 126 +++++++++ .../Shaders/ShaderCompiler.cs | 74 ++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 54 +++- 10 files changed, 825 insertions(+), 17 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs create mode 100644 Optimum.Render.Vulkan/Core/CacheFileWriter.cs create mode 100644 Optimum.Render.Vulkan/Core/PipelineCacheFile.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ShaderBinaryCache.cs diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs index 91381eb7..576aa7a7 100644 --- a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -246,4 +246,79 @@ public void FullscreenPassesShareOnePipelinePerProgram() } } + /// + /// The launch-to-launch round trip on a real driver: the blob the driver hands out + /// carries its own header for this device, survives the file wrapper, and seeds a + /// new cache that then builds the same pipeline without complaint. + /// + [SkippableFact] + public void ASavedPipelineCacheSeedsTheNextCacheOnTheSameDevice() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context, messages), "No usable Vulkan device."); + + string root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "optimum-pipeline-cache-" + Guid.NewGuid().ToString("N")); + using (context) + { + try + { + using var compiler = new ShaderCompiler(); + TranslatedProgram translated = TranslateVanilla("blit", compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + using var program = new ShaderProgramResources(context!, programId: 7, translated); + + var tracker = new GlStateTracker(); + tracker.SetProgram(7); + var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); + int targetId = tracker.InternTargetFormats(targets); + GraphicsPipelineCache.PipelineRequest Request() => new() + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = targets, + Blend = new[] { tracker.BlendFor(0) }, + PolygonMode = tracker.PolygonMode, + Topology = tracker.Topology, + }; + + PipelineCacheIdentity identity = PipelineCacheIdentity.Of(context!.Capabilities); + string path = PipelineCacheFile.PathFor(root, identity); + byte[] blob; + using (var first = new GraphicsPipelineCache(context!)) + { + Assert.False(first.SeedAccepted); + first.Get(tracker.BuildKey(0, targetId, 1), Request()); + blob = first.SerializeDriverCache(); + } + + Assert.True(PipelineCacheFile.HasMatchingVulkanHeader(blob, identity), + "the driver's blob does not name the device its properties report"); + Assert.True(PipelineCacheFile.Save(path, blob, identity)); + byte[]? loaded = PipelineCacheFile.Load(path, identity); + Assert.Equal(blob, loaded); + + using (var second = new GraphicsPipelineCache(context!, loaded)) + { + Assert.True(second.SeedAccepted); + Assert.NotEqual(0ul, second.Get(tracker.BuildKey(0, targetId, 1), Request()).Handle); + } + + _output.WriteLine($"pipeline cache blob {blob.Length} bytes at {path}"); + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + finally + { + try + { + System.IO.Directory.Delete(root, recursive: true); + } + catch (System.IO.DirectoryNotFoundException) + { + } + } + } + } + } diff --git a/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs b/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs new file mode 100644 index 00000000..dcc25c81 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs @@ -0,0 +1,247 @@ +using System; +using System.IO; +using System.Linq; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The on-disk caches: compiled SPIR-V and the driver's pipeline cache. +/// +/// Both files are read back into the driver on the next launch, so the property that +/// matters is that anything not written whole, by this format, for this compiler or +/// this GPU and driver, reads as a miss - never as data. The failure shapes pinned +/// here are the ones seen in the wild (docs/research/vulkan-caching.md §1). +/// +public sealed class ShaderCacheTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "optimum-cache-tests-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + } + + private const string VertexSource = """ + #version 450 + void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } + """; + + /// A minimal SPIR-V header: magic, version, generator, bound, schema. + private static byte[] FakeSpirv(uint bound = 7) + { + var words = new uint[] { 0x07230203, 0x00010500, 0, bound, 0, 0x00020011, 0x00000001 }; + return words.SelectMany(BitConverter.GetBytes).ToArray(); + } + + // ------------------------------------------------------------ SPIR-V cache + + [Fact] + public void AStoredModuleReadsBackIdentically() + { + var cache = new ShaderBinaryCache(_root); + string key = ShaderBinaryCache.KeyFor(VertexSource, EnumShaderType.VertexShader, "compiler-a"); + byte[] spirv = FakeSpirv(); + + cache.Put(key, spirv); + + Assert.Equal(spirv, cache.TryGet(key)); + Assert.Equal(1, cache.Hits); + } + + [Fact] + public void TheKeyChangesWithCompilerStageAndSource() + { + string baseline = ShaderBinaryCache.KeyFor(VertexSource, EnumShaderType.VertexShader, "compiler-a"); + + Assert.NotEqual(baseline, ShaderBinaryCache.KeyFor(VertexSource, EnumShaderType.VertexShader, "compiler-b")); + Assert.NotEqual(baseline, ShaderBinaryCache.KeyFor(VertexSource, EnumShaderType.FragmentShader, "compiler-a")); + Assert.NotEqual(baseline, ShaderBinaryCache.KeyFor(VertexSource + " ", EnumShaderType.VertexShader, "compiler-a")); + Assert.Equal(baseline, ShaderBinaryCache.KeyFor(VertexSource, EnumShaderType.VertexShader, "compiler-a")); + } + + [Fact] + public void DamagedModuleFilesAreMisses() + { + byte[] file = ShaderBinaryCache.Wrap(FakeSpirv()); + Assert.NotNull(ShaderBinaryCache.Unwrap(file)); + + // Truncated by an interrupted write. + Assert.Null(ShaderBinaryCache.Unwrap(file[..^4])); + // Empty. + Assert.Null(ShaderBinaryCache.Unwrap(Array.Empty())); + // A zero-filled block inside the payload. + byte[] zeroed = (byte[])file.Clone(); + Array.Clear(zeroed, ShaderBinaryCache.HeaderSize + 8, 8); + Assert.Null(ShaderBinaryCache.Unwrap(zeroed)); + // A file from another format version. + byte[] otherVersion = (byte[])file.Clone(); + BitConverter.TryWriteBytes(otherVersion.AsSpan(4), ShaderBinaryCache.FormatVersion + 1); + Assert.Null(ShaderBinaryCache.Unwrap(otherVersion)); + // Raw SPIR-V dropped in without the header. + Assert.Null(ShaderBinaryCache.Unwrap(FakeSpirv())); + } + + [Fact] + public void ADamagedFileOnDiskIsAMissAndIsReplacedByTheNextPut() + { + var cache = new ShaderBinaryCache(_root); + string key = ShaderBinaryCache.KeyFor(VertexSource, EnumShaderType.VertexShader, "compiler-a"); + cache.Put(key, FakeSpirv()); + string path = Directory.GetFiles(_root, "*.spv", SearchOption.AllDirectories).Single(); + File.WriteAllBytes(path, new byte[File.ReadAllBytes(path).Length]); + + Assert.Null(cache.TryGet(key)); + + cache.Put(key, FakeSpirv(bound: 9)); + Assert.Equal(FakeSpirv(bound: 9), cache.TryGet(key)); + Assert.Empty(Directory.GetFiles(_root, "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public void SomethingThatIsNotSpirvIsNeverStored() + { + var cache = new ShaderBinaryCache(_root); + cache.Put("aa00", new byte[] { 1, 2, 3, 4 }); + + Assert.False(Directory.Exists(_root)); + } + + /// The compiler's cache hook: the second compile of a source is read, not compiled, and is the same module. + [Fact] + public void TheCompilerServesARepeatedSourceFromTheCache() + { + using var compiler = new ShaderCompiler { BinaryCache = new ShaderBinaryCache(_root) }; + + ShaderCompileResult first = compiler.Compile(VertexSource, "cached.vsh", EnumShaderType.VertexShader); + ShaderCompileResult second = compiler.Compile(VertexSource, "renamed.vsh", EnumShaderType.VertexShader); + + Assert.True(first.Success, first.Error); + Assert.True(second.Success, second.Error); + Assert.Equal(first.Spirv, second.Spirv); + Assert.Equal(1, compiler.BinaryCache.Misses); + Assert.Equal(1, compiler.BinaryCache.Hits); + } + + [Fact] + public void TheCompilerIdentityNamesTheOptionsAndTheShadercBuild() + { + using var compiler = new ShaderCompiler(); + + Assert.StartsWith(ShaderCompiler.OptionsIdentity + ";", compiler.Identity); + Assert.Matches("(shaderc-sha256:[0-9a-f]{64}|silk-shaderc-.+)$", compiler.Identity); + } + + [Fact] + public void AFailedCompileIsNotCached() + { + using var compiler = new ShaderCompiler { BinaryCache = new ShaderBinaryCache(_root) }; + + Assert.False(compiler.Compile("#version 450\nvoid main() { oops }", "broken.vsh", EnumShaderType.VertexShader).Success); + Assert.False(Directory.Exists(_root) && Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories).Any()); + } + + // ------------------------------------------------------------ pipeline cache file + + private static PipelineCacheIdentity Identity(uint vendor = 0x10de, uint device = 0x2803, uint driver = 0x8c4a4000, + byte uuidSeed = 1) => + new(vendor, device, driver, Enumerable.Range(uuidSeed, 16).Select(i => (byte)i).ToArray()); + + /// A blob whose VkPipelineCacheHeaderVersionOne names , plus driver data. + private static byte[] DriverBlob(PipelineCacheIdentity identity, int extra = 64) + { + var blob = new byte[32 + extra]; + BitConverter.TryWriteBytes(blob.AsSpan(0), 32u); + BitConverter.TryWriteBytes(blob.AsSpan(4), 1u); + BitConverter.TryWriteBytes(blob.AsSpan(8), identity.VendorId); + BitConverter.TryWriteBytes(blob.AsSpan(12), identity.DeviceId); + identity.Uuid.CopyTo(blob, 16); + for (int i = 32; i < blob.Length; i++) blob[i] = (byte)(i * 7); + return blob; + } + + [Fact] + public void APipelineCacheReadsBackForTheDeviceThatWroteIt() + { + PipelineCacheIdentity identity = Identity(); + string path = PipelineCacheFile.PathFor(_root, identity); + byte[] blob = DriverBlob(identity); + + Assert.True(PipelineCacheFile.Save(path, blob, identity)); + + Assert.Equal(blob, PipelineCacheFile.Load(path, identity)); + } + + [Fact] + public void APipelineCacheFromAnotherGpuOrDriverIsNotLoaded() + { + PipelineCacheIdentity identity = Identity(); + byte[] file = PipelineCacheFile.Wrap(DriverBlob(identity), identity); + + Assert.Null(PipelineCacheFile.Unwrap(file, Identity(vendor: 0x1002))); + Assert.Null(PipelineCacheFile.Unwrap(file, Identity(device: 0x2804))); + // A driver update that kept its UUID: the case drivers get wrong. + Assert.Null(PipelineCacheFile.Unwrap(file, Identity(driver: 0x8c4b0000))); + Assert.Null(PipelineCacheFile.Unwrap(file, Identity(uuidSeed: 2))); + } + + [Fact] + public void DamagedPipelineCacheFilesAreNotLoaded() + { + PipelineCacheIdentity identity = Identity(); + byte[] file = PipelineCacheFile.Wrap(DriverBlob(identity), identity); + Assert.NotNull(PipelineCacheFile.Unwrap(file, identity)); + + Assert.Null(PipelineCacheFile.Unwrap(file[..^1], identity)); + Assert.Null(PipelineCacheFile.Unwrap(file[..PipelineCacheFile.HeaderSize], identity)); + Assert.Null(PipelineCacheFile.Unwrap(Array.Empty(), identity)); + Assert.Null(PipelineCacheFile.Unwrap(new byte[file.Length], identity)); + byte[] flipped = (byte[])file.Clone(); + flipped[^1] ^= 0xff; + Assert.Null(PipelineCacheFile.Unwrap(flipped, identity)); + } + + [Fact] + public void ABlobWhoseOwnVulkanHeaderNamesAnotherDeviceIsNeitherSavedNorLoaded() + { + PipelineCacheIdentity identity = Identity(); + byte[] foreign = DriverBlob(Identity(device: 0x1234)); + string path = PipelineCacheFile.PathFor(_root, identity); + + Assert.False(PipelineCacheFile.Save(path, foreign, identity)); + Assert.Null(PipelineCacheFile.Unwrap(PipelineCacheFile.Wrap(foreign, identity), identity)); + Assert.False(PipelineCacheFile.Save(path, Array.Empty(), identity)); + } + + [Fact] + public void EachGpuHasItsOwnPipelineCacheFile() + { + Assert.NotEqual( + PipelineCacheFile.PathFor(_root, Identity()), + PipelineCacheFile.PathFor(_root, Identity(device: 0x2804))); + } + + // ------------------------------------------------------------ location + + [Theory] + [InlineData(null, null, null)] + [InlineData("C:/cache", null, "C:/cache")] + [InlineData("C:/cache", "", "C:/cache")] + [InlineData("C:/cache", "D:/elsewhere", "D:/elsewhere")] + [InlineData(null, "D:/elsewhere", "D:/elsewhere")] + [InlineData("C:/cache", "0", null)] + [InlineData("C:/cache", "off", null)] + public void TheEnvironmentOverridesOrDisablesTheCacheDirectory(string? configured, string? environment, string? expected) + { + Assert.Equal(expected, VulkanDevice.ResolveShaderCacheRoot(configured, environment)); + } +} diff --git a/Optimum.Render.Vulkan/Core/CacheFileWriter.cs b/Optimum.Render.Vulkan/Core/CacheFileWriter.cs new file mode 100644 index 00000000..337d0256 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/CacheFileWriter.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Threading; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Replaces a cache file without a reader ever seeing half of it. +/// +/// The bytes go to a temporary file unique to this process and call, which is then +/// moved over the destination. Two game instances saving at once lose one of the +/// two writes, never corrupt the file. On Windows a virus scanner briefly holds +/// newly written files open, which makes the move fail; the move is retried with +/// a short backoff before the write is given up (docs/research/vulkan-caching.md §7). +/// +internal static class CacheFileWriter +{ + private const int MoveAttempts = 5; + + /// Writes to ; false when it could not. + public static bool WriteAtomically(string path, ReadOnlySpan bytes) + { + string temporary = path + "." + Environment.ProcessId + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + } + + for (int attempt = 1; ; attempt++) + { + try + { + File.Move(temporary, path, overwrite: true); + return true; + } + catch (Exception error) when (IsTransient(error) && attempt < MoveAttempts) + { + Thread.Sleep(10 << attempt); + } + } + } + catch (Exception error) when (IsTransient(error)) + { + TryDelete(temporary); + return false; + } + } + + /// The whole file, or null when it is missing or unreadable. + public static byte[]? TryReadAll(string path) + { + try + { + return File.Exists(path) ? File.ReadAllBytes(path) : null; + } + catch (Exception error) when (IsTransient(error)) + { + return null; + } + } + + private static bool IsTransient(Exception error) => error is IOException or UnauthorizedAccessException; + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception error) when (IsTransient(error)) + { + } + } +} diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index 43eca1ce..5c779979 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -87,8 +87,28 @@ public GraphicsPipelineCache(VulkanContext context, ColorWriteTier tier, bool dy } _dynamicStates = dynamicStates.ToArray(); + // A rejected blob is not an error: the driver starts cold instead. Some + // drivers return an error rather than an empty cache for data they do not + // accept, so that case retries without it (docs/research/vulkan-caching.md §1). + if (initialData is { Length: > 0 } && TryCreateDriverCache(context, initialData, out _driverCache)) + { + SeedAccepted = true; + } + else + { + TryCreateDriverCache(context, null, out _driverCache); + } + } + + /// The driver created its cache from the initial data rather than empty. + public bool SeedAccepted { get; } + + private static bool TryCreateDriverCache( + VulkanContext context, byte[]? initialData, out Silk.NET.Vulkan.PipelineCache cache) + { fixed (byte* data = initialData) { + // Never a non-null pointer with a zero size: one driver fails on exactly that. var createInfo = new PipelineCacheCreateInfo { SType = StructureType.PipelineCacheCreateInfo, @@ -96,12 +116,12 @@ public GraphicsPipelineCache(VulkanContext context, ColorWriteTier tier, bool dy PInitialData = initialData is { Length: > 0 } ? data : null, }; - // A rejected blob is not an error: the driver simply starts cold. - if (context.Api.CreatePipelineCache( - context.Device, &createInfo, null, out Silk.NET.Vulkan.PipelineCache cache) == Result.Success) + if (context.Api.CreatePipelineCache(context.Device, &createInfo, null, out cache) == Result.Success) { - _driverCache = cache; + return true; } + cache = default; + return false; } } @@ -320,22 +340,33 @@ private Pipeline Create(PipelineRequest request) /// /// The driver's cache blob, to be written next to the SPIR-V cache so the - /// next run starts warm. + /// next run starts warm (). /// public byte[] SerializeDriverCache() { if (_driverCache.Handle == 0) return Array.Empty(); - nuint size = 0; - _context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, null); - if (size == 0) return Array.Empty(); - - var data = new byte[(int)size]; - fixed (byte* dataPtr = data) + // The cache can grow between the size query and the fetch while another + // thread creates a pipeline; VK_INCOMPLETE then means "ask again". + for (int attempt = 0; attempt < 4; attempt++) { - _context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, dataPtr); + nuint size = 0; + if (_context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, null) != Result.Success || + size == 0) + { + return Array.Empty(); + } + + var data = new byte[(int)size]; + Result result; + fixed (byte* dataPtr = data) + { + result = _context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, dataPtr); + } + if (result == Result.Success) return size == (nuint)data.Length ? data : data[..(int)size]; + if (result != Result.Incomplete) break; } - return data; + return Array.Empty(); } public void Dispose() diff --git a/Optimum.Render.Vulkan/Core/PipelineCacheFile.cs b/Optimum.Render.Vulkan/Core/PipelineCacheFile.cs new file mode 100644 index 00000000..fd7763e4 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineCacheFile.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Security.Cryptography; + +namespace Optimum.Render.Vulkan.Core; + +/// The device and driver a pipeline cache blob was produced by. +internal readonly record struct PipelineCacheIdentity(uint VendorId, uint DeviceId, uint DriverVersion, byte[] Uuid) +{ + public static PipelineCacheIdentity Of(VulkanCapabilities capabilities) => new( + capabilities.VendorId, capabilities.DeviceId, capabilities.DriverVersion, capabilities.PipelineCacheUuid); + + /// One file per GPU, so switching between two GPUs keeps both caches warm. + public string FileName => $"{VendorId:x4}-{DeviceId:x4}-{Convert.ToHexStringLower(Uuid)}.bin"; +} + +/// +/// The driver's pipeline cache blob on disk, wrapped so that it is only ever handed +/// back to the driver that wrote it, whole. +/// +/// The spec says a driver must start empty when the blob's own header does not match +/// it, but drivers have been seen to skip that check and crash after a driver update, +/// to keep the UUID across incompatible builds, and to fail on a zero-length blob; +/// files have been seen truncated, zero-filled and empty. So the wrapper records +/// the vendor, device, driver version, pointer size and UUID alongside a SHA-256 of +/// the blob. Every field and the blob's own Vulkan header are checked before loading, +/// and anything that fails is treated as no cache at all. +/// Design and sources: docs/research/vulkan-caching.md §1 and "Design for this renderer" item 2. +/// +internal static class PipelineCacheFile +{ + /// "OPLC", little-endian. + internal const uint FileMagic = 0x434C504F; + + internal const uint FormatVersion = 1; + + /// Magic, version, data size, SHA-256, vendor, device, driver version, pointer size, UUID. + internal const int HeaderSize = 4 + 4 + 8 + 32 + 4 + 4 + 4 + 4 + 16; + + /// VkPipelineCacheHeaderVersionOne: size, version, vendor, device, UUID. + private const int VulkanHeaderSize = 32; + + private const uint VulkanHeaderVersionOne = 1; + + public static string PathFor(string cacheRoot, PipelineCacheIdentity identity) => + Path.Combine(cacheRoot, "pipeline", identity.FileName); + + /// The blob stored for , or null when there is none it can use. + public static byte[]? Load(string path, PipelineCacheIdentity identity) + { + byte[]? file = CacheFileWriter.TryReadAll(path); + return file == null ? null : Unwrap(file, identity); + } + + /// Stores a blob; false when it was empty, not a pipeline cache, or could not be written. + public static bool Save(string path, byte[] data, PipelineCacheIdentity identity) + { + if (!HasMatchingVulkanHeader(data, identity)) return false; + return CacheFileWriter.WriteAtomically(path, Wrap(data, identity)); + } + + internal static byte[] Wrap(byte[] data, PipelineCacheIdentity identity) + { + var file = new byte[HeaderSize + data.Length]; + Span header = file.AsSpan(0, HeaderSize); + BitConverter.TryWriteBytes(header[0..], FileMagic); + BitConverter.TryWriteBytes(header[4..], FormatVersion); + BitConverter.TryWriteBytes(header[8..], (ulong)data.Length); + SHA256.HashData(data, header.Slice(16, 32)); + BitConverter.TryWriteBytes(header[48..], identity.VendorId); + BitConverter.TryWriteBytes(header[52..], identity.DeviceId); + BitConverter.TryWriteBytes(header[56..], identity.DriverVersion); + BitConverter.TryWriteBytes(header[60..], (uint)IntPtr.Size); + identity.Uuid.AsSpan(0, 16).CopyTo(header[64..]); + data.CopyTo(file, HeaderSize); + return file; + } + + /// The blob inside a file written for exactly , or null. + internal static byte[]? Unwrap(byte[] file, PipelineCacheIdentity identity) + { + if (file.Length <= HeaderSize) return null; + ReadOnlySpan header = file.AsSpan(0, HeaderSize); + if (BitConverter.ToUInt32(header[0..]) != FileMagic) return null; + if (BitConverter.ToUInt32(header[4..]) != FormatVersion) return null; + if (BitConverter.ToUInt64(header[8..]) != (ulong)(file.Length - HeaderSize)) return null; + if (BitConverter.ToUInt32(header[48..]) != identity.VendorId) return null; + if (BitConverter.ToUInt32(header[52..]) != identity.DeviceId) return null; + if (BitConverter.ToUInt32(header[56..]) != identity.DriverVersion) return null; + if (BitConverter.ToUInt32(header[60..]) != (uint)IntPtr.Size) return null; + if (!header.Slice(64, 16).SequenceEqual(identity.Uuid)) return null; + + ReadOnlySpan data = file.AsSpan(HeaderSize); + Span hash = stackalloc byte[32]; + SHA256.HashData(data, hash); + if (!hash.SequenceEqual(header.Slice(16, 32))) return null; + + byte[] blob = data.ToArray(); + return HasMatchingVulkanHeader(blob, identity) ? blob : null; + } + + /// The blob's own VkPipelineCacheHeaderVersionOne names this device. + internal static bool HasMatchingVulkanHeader(byte[] data, PipelineCacheIdentity identity) + { + if (data.Length < VulkanHeaderSize || identity.Uuid is not { Length: 16 }) return false; + ReadOnlySpan header = data; + return BitConverter.ToUInt32(header[0..]) >= VulkanHeaderSize + && BitConverter.ToUInt32(header[4..]) == VulkanHeaderVersionOne + && BitConverter.ToUInt32(header[8..]) == identity.VendorId + && BitConverter.ToUInt32(header[12..]) == identity.DeviceId + && header.Slice(16, 16).SequenceEqual(identity.Uuid); + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index c6ea0fa9..7afc7c5d 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -54,6 +54,11 @@ internal sealed class VulkanCapabilities { public string DeviceName = ""; public string DriverName = ""; + public uint VendorId; + public uint DeviceId; + public uint DriverVersion; + /// VkPhysicalDeviceProperties::pipelineCacheUUID; 16 bytes. + public byte[] PipelineCacheUuid = new byte[16]; public uint ApiVersion; public PhysicalDeviceType DeviceType; public uint MaxImageDimension2D; @@ -987,6 +992,10 @@ private VulkanCapabilities ReadCapabilities() { DeviceName = SilkMarshal.PtrToString((nint)properties.DeviceName) ?? "unknown", DriverName = SilkMarshal.PtrToString((nint)driverProperties.DriverName) ?? "unknown", + VendorId = properties.VendorID, + DeviceId = properties.DeviceID, + DriverVersion = properties.DriverVersion, + PipelineCacheUuid = new ReadOnlySpan(properties.PipelineCacheUuid, 16).ToArray(), ApiVersion = properties.ApiVersion, DeviceType = properties.DeviceType, MaxImageDimension2D = properties.Limits.MaxImageDimension2D, diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index e3e61682..f3d8ae2a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -154,8 +154,14 @@ public partial class VulkanClientPlatform : ClientPlatformWindows "OptimumFinishDeviceFrameBufferSetup", }; - /// Test seam: the device to bring up (tests add validation capture). - internal Func DeviceFactory = () => new VulkanDevice(); + /// + /// Test seam: the device to bring up (tests add validation capture). The real one keeps + /// compiled shaders and the pipeline cache in the game's per-user cache folder. + /// + internal Func DeviceFactory = () => new VulkanDevice + { + ShaderCacheDirectory = System.IO.Path.Combine(GamePaths.Cache, "optimum-vulkan"), + }; /// Test seam: where the crash marker goes; null means . internal string? CrashMarkerDataPath; diff --git a/Optimum.Render.Vulkan/Shaders/ShaderBinaryCache.cs b/Optimum.Render.Vulkan/Shaders/ShaderBinaryCache.cs new file mode 100644 index 00000000..aa9598c7 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderBinaryCache.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// Compiled SPIR-V on disk, addressed by everything that decides it. +/// +/// Without it every program is compiled from GLSL at every launch - the whole +/// vanilla set, every settings variant the session reaches, every mod shader. The +/// rewritten source of a stage, which already has its defines resolved and its +/// includes expanded, fully determines its SPIR-V for a given compiler build and set +/// of options. The key is a hash of exactly that: this format's version, the +/// compiler identity (), the stage and the +/// source. A mod that edits a shader changes the source and so misses, rather than +/// loading stale SPIR-V under a file name. Nothing about the program's layout is +/// stored: the layout is rebuilt from the source before the cache is consulted, and +/// the cache only replaces the one step that is expensive. +/// +/// Each file carries a small header - magic, format version, payload length and a +/// SHA-256 of the payload - checked before anything is handed to the driver. +/// Anything that fails the check is a miss, never an error: a truncated file from an +/// interrupted write, a zero-filled block, a file another tool dropped in the +/// directory. The miss recompiles and overwrites it. Design and sources: +/// docs/research/vulkan-caching.md §5 and "Design for this renderer" item 1. +/// +internal sealed class ShaderBinaryCache +{ + private const uint SpirvMagic = 0x07230203; + + /// "OSPV", little-endian. + internal const uint FileMagic = 0x5650534F; + + /// + /// Bumped whenever the file layout changes, or whenever something that decides the + /// SPIR-V for a given source changes without appearing in the key. + /// + internal const uint FormatVersion = 1; + + /// Magic, format version, payload length, SHA-256 of the payload. + internal const int HeaderSize = 4 + 4 + 4 + 32; + + private long _hits; + private long _misses; + private long _writeFailures; + + public string Directory { get; } + + public long Hits => Interlocked.Read(ref _hits); + public long Misses => Interlocked.Read(ref _misses); + public long WriteFailures => Interlocked.Read(ref _writeFailures); + + public ShaderBinaryCache(string directory) + { + Directory = directory; + } + + /// The key for one stage's source under one compiler identity: lowercase hex SHA-256. + public static string KeyFor(string code, EnumShaderType stage, string compilerIdentity) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + hash.AppendData(Encoding.UTF8.GetBytes( + "optimum-spirv-" + FormatVersion + "\n" + compilerIdentity + "\n" + (int)stage + "\n")); + hash.AppendData(Encoding.UTF8.GetBytes(code)); + return Convert.ToHexStringLower(hash.GetHashAndReset()); + } + + /// The cached module for , or null. + public byte[]? TryGet(string key) + { + byte[]? file = CacheFileWriter.TryReadAll(PathFor(key)); + byte[]? spirv = file == null ? null : Unwrap(file); + Interlocked.Increment(ref spirv == null ? ref _misses : ref _hits); + return spirv; + } + + /// Stores a module. A failure to write only costs the next launch a compile. + public void Put(string key, byte[] spirv) + { + if (!IsSpirv(spirv)) return; + if (!CacheFileWriter.WriteAtomically(PathFor(key), Wrap(spirv))) + { + Interlocked.Increment(ref _writeFailures); + } + } + + internal static byte[] Wrap(byte[] spirv) + { + var file = new byte[HeaderSize + spirv.Length]; + BitConverter.TryWriteBytes(file.AsSpan(0), FileMagic); + BitConverter.TryWriteBytes(file.AsSpan(4), FormatVersion); + BitConverter.TryWriteBytes(file.AsSpan(8), (uint)spirv.Length); + SHA256.HashData(spirv, file.AsSpan(12, 32)); + spirv.CopyTo(file, HeaderSize); + return file; + } + + /// The payload of a well-formed file, or null for anything else. + internal static byte[]? Unwrap(byte[] file) + { + if (file.Length < HeaderSize) return null; + if (BitConverter.ToUInt32(file, 0) != FileMagic) return null; + if (BitConverter.ToUInt32(file, 4) != FormatVersion) return null; + if (BitConverter.ToUInt32(file, 8) != (uint)(file.Length - HeaderSize)) return null; + + ReadOnlySpan payload = file.AsSpan(HeaderSize); + Span hash = stackalloc byte[32]; + SHA256.HashData(payload, hash); + if (!hash.SequenceEqual(file.AsSpan(12, 32))) return null; + + byte[] spirv = payload.ToArray(); + return IsSpirv(spirv) ? spirv : null; + } + + /// A module header and a whole number of words; anything else is not SPIR-V. + internal static bool IsSpirv(byte[] data) => + data.Length >= 20 && data.Length % 4 == 0 && BitConverter.ToUInt32(data, 0) == SpirvMagic; + + // Two hex digits of fan-out keep a directory of a few thousand modules browsable. + private string PathFor(string key) => Path.Combine(Directory, key[..2], key + ".spv"); +} diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs index d17f5d44..af726acb 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -82,8 +82,82 @@ public ShaderCompileResult Preprocess(string code, string prefixCode, string fil } } + /// + /// Where compiled modules are kept between launches; null compiles every time. + /// The filename only names errors (no debug info is emitted), so it is not part of the key. + /// + public ShaderBinaryCache? BinaryCache { get; set; } + + /// + /// The options sets, spelled out for the cache key. + /// Change both together. + /// + internal const string OptionsIdentity = "glsl;vulkan1.3;spirv1.5;performance;no-debug-info;no-include-resolver"; + + private static string? _nativeIdentity; + + /// + /// Everything besides the source that decides the SPIR-V: the options and the + /// shaderc build. The C API exposes no compiler version, so the build is the + /// loaded library's own hash (docs/research/vulkan-caching.md §5). + /// + public string Identity => OptionsIdentity + ";" + (_nativeIdentity ??= NativeLibraryIdentity()); + + internal static string NativeLibraryIdentity() + { + string name = OperatingSystem.IsWindows() ? "shaderc_shared.dll" + : OperatingSystem.IsMacOS() ? "libshaderc_shared.dylib" + : "libshaderc_shared.so"; + string runtime = RuntimeInformation.RuntimeIdentifier; + + foreach (string? directory in new[] + { + AppContext.BaseDirectory, + System.IO.Path.GetDirectoryName(typeof(ShaderCompiler).Assembly.Location), + }) + { + if (string.IsNullOrEmpty(directory)) continue; + foreach (string candidate in new[] + { + System.IO.Path.Combine(directory, name), + System.IO.Path.Combine(directory, "runtimes", runtime, "native", name), + }) + { + try + { + if (!System.IO.File.Exists(candidate)) continue; + using System.IO.FileStream stream = System.IO.File.OpenRead(candidate); + return "shaderc-sha256:" + + Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(stream)); + } + catch (Exception error) when (error is System.IO.IOException or UnauthorizedAccessException) + { + } + } + } + + // Not found where the packagers put it: fall back to the binding's version, + // which pins the native package it ships with. + return "silk-shaderc-" + typeof(Shaderc).Assembly.GetName().Version; + } + /// Compiles already-rewritten Vulkan GLSL to SPIR-V. public ShaderCompileResult Compile(string code, string filename, EnumShaderType stage) + { + string? key = null; + if (BinaryCache != null) + { + key = ShaderBinaryCache.KeyFor(code, stage, Identity); + byte[]? cached = BinaryCache.TryGet(key); + if (cached != null) return new ShaderCompileResult { Success = true, Spirv = cached }; + } + + ShaderCompileResult compiled = CompileUncached(code, filename, stage); + if (key != null && compiled.Success) BinaryCache!.Put(key, compiled.Spirv); + return compiled; + } + + private ShaderCompileResult CompileUncached(string code, string filename, EnumShaderType stage) { var result = new ShaderCompileResult(); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 8791010a..35e59dbd 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -46,6 +46,24 @@ public sealed unsafe class VulkanDevice : IDisposable private FrameRing _frames = null!; private ShaderCompiler _shaderCompiler = null!; + /// + /// Where compiled SPIR-V and the driver's pipeline cache are kept between launches, + /// set before . Null keeps nothing, which is what tests get; + /// the platform points it at the game's per-user cache folder. OPTIMUM_VULKAN_SHADER_CACHE + /// overrides it: a path to use instead, or 0 to keep nothing. + /// + public string? ShaderCacheDirectory { get; set; } + + private string? _pipelineCachePath; + private PipelineCacheIdentity _pipelineCacheIdentity; + + internal static string? ResolveShaderCacheRoot(string? configured, string? environment) + { + if (string.IsNullOrWhiteSpace(environment)) return string.IsNullOrWhiteSpace(configured) ? null : configured; + string value = environment.Trim(); + return value is "0" or "off" or "false" ? null : value; + } + private readonly Dictionary _programs = new(); /// Pass names by program id, so a device-loss report can name the shader. @@ -405,8 +423,17 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // Colour write tier (C4): draw buffers and motion windows are write masks. _state.ColorWriteTier = _context.Capabilities.ColorWriteTier; _state.DynamicBlend = _context.Capabilities.DynamicColorBlend; + string? cacheRoot = ResolveShaderCacheRoot(ShaderCacheDirectory, + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_SHADER_CACHE")); + byte[]? pipelineSeed = null; + if (cacheRoot != null) + { + _pipelineCacheIdentity = PipelineCacheIdentity.Of(_context.Capabilities); + _pipelineCachePath = PipelineCacheFile.PathFor(cacheRoot, _pipelineCacheIdentity); + pipelineSeed = PipelineCacheFile.Load(_pipelineCachePath, _pipelineCacheIdentity); + } _pipelines = new GraphicsPipelineCache(_context, _context.Capabilities.ColorWriteTier, - _context.Capabilities.DynamicColorBlend); + _context.Capabilities.DynamicColorBlend, pipelineSeed); _descriptors = new DescriptorCache(_context); // One layout for the shared frame block, named by every program's pipeline layout. _frameSetLayout = ShaderProgramResources.CreateFrameSetLayout(_context); @@ -421,7 +448,14 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _targets.ScopeClosing = _queryRing.OnScopeClosing; _targets.ScopeClosed = _queryRing.OnScopeClosed; _targets.ScopeOpened = _queryRing.OnScopeOpened; - _shaderCompiler = new ShaderCompiler(); + _shaderCompiler = new ShaderCompiler + { + BinaryCache = cacheRoot == null ? null : new ShaderBinaryCache(System.IO.Path.Combine(cacheRoot, "spirv")), + }; + MirrorValidationMessage(cacheRoot == null + ? "--- shader cache off" + : "--- shader cache " + cacheRoot + "; pipeline cache " + + (pipelineSeed == null ? "cold" : _pipelines.SeedAccepted ? "warm (" + pipelineSeed.Length + " bytes)" : "rejected by the driver")); CreateDefaultAttributeBuffer(); CreatePlaceholderTexture(); CreatePlaceholderUniformBuffer(); @@ -3254,6 +3288,21 @@ public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr d // ------------------------------------------------------------------- teardown + /// + /// Writes the driver's pipeline cache for the next launch. On shutdown only, after + /// the device is idle; a failed write costs the next launch its warm start, nothing more. + /// + private void SavePipelineCache() + { + if (_pipelineCachePath == null || _pipelines == null) return; + byte[] blob = _pipelines.SerializeDriverCache(); + if (blob.Length == 0) return; + if (!PipelineCacheFile.Save(_pipelineCachePath, blob, _pipelineCacheIdentity)) + { + MirrorValidationMessage("--- pipeline cache not saved to " + _pipelineCachePath); + } + } + public void Dispose() { if (_disposed) return; @@ -3288,6 +3337,7 @@ public void Dispose() _shaderCompiler?.Dispose(); _frames?.Dispose(); _descriptors?.Dispose(); + SavePipelineCache(); _pipelines?.Dispose(); _targets?.Dispose(); _meshes?.Dispose(); From 5bc337b4cec965ce3838c9d17958c3b191d145be Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:07:57 +0200 Subject: [PATCH 136/226] docs(plan): one pipeline layout with bindless textures from the start (decision 9), and the caches that landed --- docs/vulkan-native-plan.md | 51 +++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/docs/vulkan-native-plan.md b/docs/vulkan-native-plan.md index 5ac32fab..0298aaa7 100644 --- a/docs/vulkan-native-plan.md +++ b/docs/vulkan-native-plan.md @@ -86,6 +86,12 @@ reconciled below. Every file:line fact quoted was re-checked in the tree. "mods rendering through the game API land inside declared passes and work": Optimum publishes how to add Vulkan-native support to a mod - declaring passes, writing motion, shipping native shaders, drawing through the native renderers (Phase 5). +9. **One pipeline layout, bindless textures from the start (user, 2026-09-15).** Supersedes the set + convention's per-pass and per-material sets and moves bindless out of Phase 4. Per-program layouts + invalidate bound sets on every program switch, and building a sampler set per draw is the hot path the + Khronos descriptor-management sample and Zink's measurements point at; descriptor indexing is portable + (DXVK requires it on every vendor). Every program shares set 0 frame, set 1 bindless textures and shared + samplers, set 2 storage, and one push-constant range (`docs/research/vulkan-descriptor-model.md`). ## Constraints @@ -343,16 +349,20 @@ by glslc `-I`. The GLSL 330 assets in `sources/shaders/` keep shipping and keep uniform-name set and texture declaration order, which is the client's oracle. The native path supplies only placements and bindings; no `ShaderRegistry` change is needed. -**Set convention** (frequency-ordered; mirrored in `Shaders/SetConvention.cs`, a test asserts -`bindings.glsl` and the C# agree): +**Set convention** (decision 9; one pipeline layout shared by every program; mirrored in +`Shaders/SetConvention.cs`, a test asserts `bindings.glsl` and the C# agree; design sources in +`docs/research/vulkan-descriptor-model.md`, implementation details in `docs/research/vulkan-bindless.md`): | Set | Update | Contents | |---|---|---| -| 0 frame | once per frame | `FrameGlobals` UBO (every uniform `ShaderProgramBase.Use()` auto-binds plus the `OptimumTemporal` record) and the fixed frame textures `shadowMapFar/Near`, `sky`, `glow`, `liquidDepth` | -| 1 pass | once per pass | `PassParams` UBO (dynamic offset) and pass inputs (scene, glow, depth, motion, history×3, gbuffer, bloom, godrays) | -| 2 material | bound once | sampled textures and samplers as a plain array; bindless (partially bound, update-after-bind) is a Phase 4 option decided by the measured descriptor miss rate | -| 3 draw | dynamic offset per draw | `DrawData` UBO (model/prev-model matrices, per-draw warp/tint overrides, flags), `FaceData` SSBO, `Animation`/`AnimationPrev` SSBOs | -| push (≤128 B) | per draw | the few scalars written between draws of one program (material index, origin, z-offset, flags), chosen per program from a measured write-frequency profile | +| 0 frame | once per frame | `FrameGlobals` UBO with a dynamic offset (every uniform `ShaderProgramBase.Use()` auto-binds plus the `OptimumTemporal` record) and the fixed frame textures `shadowMapFar/Near`, `sky`, `glow`, `liquidDepth` | +| 1 textures | when a texture is created or retired | the bindless texture arrays (partially bound, update-after-bind) and the few shared samplers; every texture the game creates gets a slot, and shaders index it | +| 2 storage | when a buffer is created or retired | `FaceData`, per-object and `Animation`/`AnimationPrev` SSBOs, indexed by draw | +| push (≤128 B) | per draw | texture slot indices, per-draw scalars (origin, z-offset, tint, flags) and the offset of the draw's record; larger per-program values sit in a per-frame buffer addressed from here | + +No layout differs between programs, so a program switch never invalidates a bound set, and no draw builds +a descriptor set. Startup checks the descriptor-indexing features and limits; a device without them stays +on OpenGL. **Uniform placement.** `GetUniformLocation(program, name)` returns an index into the program's placement table `(home: Push | Frame | Pass | Draw | SamplerUnit, offset, size)`, `-1` when the @@ -387,11 +397,12 @@ rewriter; one log line `[Optimum] shaders: N native, M rewritten, K failed`. `OPTIMUM_VK_SHADER_SOURCE=` compiles the tree at runtime through shaderc for the dev loop; a test asserts runtime and offline SPIR-V are byte-identical for a sample program. -**Mod-shader adapter.** The rewriter targets the same four sets: loose uniforms → set 3 per-draw -block, samplers → set 2 plain array, SSBOs → set 3, and any loose uniform whose name matches a -`FrameGlobals` member → set 0 (so a mod shader including `fogandlight.fsh` keeps working -unchanged). Confined to `ProgramInterfaceLayout.Build` plus a frame-global name map; a test -compares the descriptor-set layouts of a native and an adapter program. +**Mod-shader adapter.** The rewriter targets the same shared layout (decision 9): loose uniforms → +the program's record in the per-frame uniform buffer, addressed from push constants; samplers → +indices into the set 1 bindless arrays, carried in push constants; SSBOs → set 2; and any loose +uniform whose name matches a `FrameGlobals` member → set 0 (so a mod shader including +`fogandlight.fsh` keeps working unchanged). Confined to `ProgramInterfaceLayout.Build` plus a +frame-global name map; a test asserts a native and an adapter program share one pipeline layout. **Temporal contract.** One writer: `include/motion.glsl` with `optimumWriteMotion(mv, reactive, writerDepth)` and `optimumWriteReactiveOnly(reactive)` @@ -671,12 +682,22 @@ to end), and whether the runtime rewriter survives for mod shaders. Moved to roadmap step 4 (2026-09-15), after the general refactor, except the SPIR-V cache and manifest, which Phase 3 produces. -Disk pipeline cache + used-key manifest + warm-up; push-constant placement from the measured +Landed early, per `docs/research/vulkan-caching.md`: the disk SPIR-V cache (`ShaderBinaryCache`: key over +format version, compiler options, the shaderc binary's hash, stage and rewritten source; header with a +SHA-256 of the payload) and the persisted driver pipeline cache (`PipelineCacheFile`: one file per GPU, +wrapper checked against vendor, device, driver version, pointer size and UUID, blob header checked too, +empty cache on any mismatch or driver rejection, saved at shutdown). Both write atomically with retries +and live in `GamePaths.Cache/optimum-vulkan`; `OPTIMUM_VULKAN_SHADER_CACHE=` moves them and +`OPTIMUM_VULKAN_SHADER_CACHE=0` turns them off. The device-up validation log line says whether the +pipeline cache started cold, warm or rejected. Still open from the research: compile-required +(`FAIL_ON_PIPELINE_COMPILE_REQUIRED`) with background builds, growth-triggered saves, and +`VK_KHR_pipeline_binary` as an optional backend. + +Used-key manifest + warm-up; push-constant placement from the measured profile (`OPTIMUM_VULKAN_UNIFORM_PROFILE`) frozen into the manifest for the 48 programs; animation SSBO ring; `Use()` include-block early-out (measured first); per-pass GPU timestamps (`timestampValidBits` gated); transient aliasing default on after clean validation on all -targets; bindless set 2 only if `DescriptorCache.Misses` per frame in a loaded world justifies -it; `DirectToSwapchain` and transfer backend B measured, kept only where they win. +targets; bindless textures are Phase 3 now (decision 9); `DirectToSwapchain` and transfer backend B measured, kept only where they win. Exit: on the fixed scene Vulkan mean FPS ≥ OpenGL and p99 ≤ OpenGL on this machine, numbers in `docs/vulkan-acceptance.md` §6 (Arc 140V row filled when the handheld is available); pipeline From 31684e37a195c6bd647ff295bcb96911f61a0f88 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:13:17 +0200 Subject: [PATCH 137/226] feat(vulkan): extra validation checks go through VK_EXT_layer_settings, with the vendor best-practice sets and GPU-AV modes --- .../ValidationFeaturesTests.cs | 73 +++++++- Optimum.Render.Vulkan/Core/VulkanContext.cs | 165 ++++++++++++++++-- Optimum.Render.Vulkan/VulkanDevice.cs | 11 +- .../vulkan-backend-integration-tests.cs | 2 + docs/vulkan-acceptance.md | 9 +- 5 files changed, 239 insertions(+), 21 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs b/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs index a4fd4381..16430b8b 100644 --- a/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs +++ b/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs @@ -8,10 +8,12 @@ namespace Optimum.Render.Vulkan.Tests; /// -/// The extra validation features (sync validation, best practices) ride on -/// VK_EXT_validation_features, chained into vkCreateInstance's pNext. A chained -/// struct whose instance extension was never enabled is ignored by a conformant -/// loader, so the two decisions have to be made together - that was the bug. +/// The extra validation checks (sync validation, best practices with the vendor +/// sets, GPU-assisted) are requested through VK_EXT_layer_settings, with the +/// deprecated VK_EXT_validation_features as the fallback for older layers, both +/// chained into vkCreateInstance's pNext. A chained struct whose instance extension +/// was never enabled is ignored by a conformant loader, so the two decisions have +/// to be made together - that was the bug. /// public class ValidationFeaturesTests { @@ -19,6 +21,59 @@ public class ValidationFeaturesTests public ValidationFeaturesTests(ITestOutputHelper output) => _output = output; + private static Dictionary SettingsFor(string features) + { + var byName = new Dictionary(); + foreach (VulkanContext.ValidationLayerSetting setting in VulkanContext.ValidationLayerSettings(features)) + { + Assert.True(byName.TryAdd(setting.Name, setting), setting.Name + " is requested twice"); + } + return byName; + } + + /// + /// The layer's own setting names (docs/research/vulkan-validation.md §1): best practices report as + /// warnings and performance messages, so "best" also widens report_flags; the desktop vendor + /// sets come with it, the mobile ones only on request. + /// + [Fact] + public void TheFeatureNamesMapOntoTheLayersSettings() + { + Dictionary settings = SettingsFor(" sync , BEST "); + Assert.True(settings["validate_sync"].Enabled); + Assert.True(settings.ContainsKey("syncval_message_extra_properties")); + Assert.True(settings["validate_best_practices"].Enabled); + Assert.True(settings.ContainsKey("validate_best_practices_nvidia")); + Assert.True(settings.ContainsKey("validate_best_practices_amd")); + Assert.False(settings.ContainsKey("validate_best_practices_arm")); + Assert.Equal("error,warn,perf", settings["report_flags"].Text); + + Dictionary mobile = SettingsFor("best,mobile"); + Assert.True(mobile.ContainsKey("validate_best_practices_arm")); + Assert.True(mobile.ContainsKey("validate_best_practices_img")); + } + + /// GPU-AV is advised against alongside CPU core validation, so "gpu-only" turns core off. + [Fact] + public void GpuOnlyTurnsCoreValidationOff() + { + Assert.True(SettingsFor("gpu")["gpuav_enable"].Enabled); + Assert.False(SettingsFor("gpu").ContainsKey("validate_core")); + + Dictionary gpuOnly = SettingsFor("gpu-only"); + Assert.True(gpuOnly["gpuav_enable"].Enabled); + Assert.False(gpuOnly["validate_core"].Enabled); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("nonsense,,")] + public void AnEmptyOrUnknownFeatureListSetsNothing(string? setting) + { + Assert.Empty(VulkanContext.ValidationLayerSettings(setting)); + } + [Fact] public void TheFeatureListParsesTheDocumentedNames() { @@ -64,6 +119,16 @@ public void AnInstanceComesUpWithTheFeaturesRequested() { Skip.IfNot(context!.ValidationEnabled, "Validation layer not installed."); Assert.NotEqual(default, context.Instance); + _output.WriteLine("layer " + context.ValidationLayerVersion + ": " + context.ValidationSettingsApplied); + + // A current layer takes the settings, not the deprecated struct. + using var api = Vk.GetApi(); + if (VulkanContext.LayerAdvertisesExtension(api, "VK_LAYER_KHRONOS_validation", VulkanContext.LayerSettingsExtensionName)) + { + Assert.StartsWith("layer settings validate_sync", context.ValidationSettingsApplied); + Assert.Contains("report_flags=error,warn,perf", context.ValidationSettingsApplied); + } + Assert.NotEmpty(context.ValidationLayerVersion); } } diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 7afc7c5d..d6f8e01f 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -17,7 +17,7 @@ internal sealed class VulkanContextOptions /// Turns on the validation layers and the debug messenger. public bool EnableValidation; - /// Comma list of extra layer features: sync, best, gpu. + /// Comma list of extra layer checks: sync, best, mobile, gpu, gpu-only (). public string ValidationFeatures = ""; /// Pins a physical device by index; -1 picks automatically. @@ -259,34 +259,90 @@ private bool CreateInstance(VulkanContextOptions options, out string? failureRea var extensions = new List(options.RequiredInstanceExtensions); bool validation = options.EnableValidation && HasValidationLayer(); - // Extra layer features (sync validation, best practices, GPU assisted) - // ride on VK_EXT_validation_features. Parse them before the extension - // list is marshalled, because the extension has to be enabled under - // exactly the same condition as the pNext chain below - a chained - // struct whose extension was never enabled is ignored at best. - // Only when the layer actually advertises it: the extension is deprecated - // in favour of VK_EXT_layer_settings, and naming one the layer does not - // have fails vkCreateInstance outright - which would turn a diagnostic - // environment variable into a silent fall back to OpenGL (rule 1). + // Extra layer checks (sync validation, best practices with the vendor sets, + // GPU-assisted validation) go through VK_EXT_layer_settings, which replaces + // the deprecated VK_EXT_validation_features (docs/research/vulkan-validation.md + // §1); a layer too old for it still gets the deprecated struct. Both are + // decided before the extension list is marshalled, because an extension has + // to be enabled under exactly the condition its struct is chained - a chained + // struct whose extension was never enabled is ignored at best. And only when + // the layer advertises it: naming an extension the layer does not have fails + // vkCreateInstance outright, which would turn a diagnostic environment + // variable into a silent fall back to OpenGL (rule 1). + List settings = ValidationLayerSettings(options.ValidationFeatures); List enables = ParseValidationFeatures(options.ValidationFeatures); - bool chainValidationFeatures = validation && enables.Count > 0 + bool chainLayerSettings = validation && settings.Count > 0 + && LayerAdvertisesExtension(Api, ValidationLayer, LayerSettingsExtensionName); + bool chainValidationFeatures = validation && !chainLayerSettings && enables.Count > 0 && LayerAdvertisesExtension(Api, ValidationLayer, ValidationFeaturesExtensionName); if (validation) { extensions.Add(ExtDebugUtils.ExtensionName); } + if (chainLayerSettings) + { + extensions.Add(LayerSettingsExtensionName); + } if (chainValidationFeatures) { extensions.Add(ValidationFeaturesExtensionName); } + ValidationSettingsApplied = + chainLayerSettings ? "layer settings " + DescribeValidationLayerSettings(settings) + : chainValidationFeatures ? "deprecated validation features " + string.Join(",", enables) + : validation && settings.Count > 0 + ? "extra checks NOT APPLIED (the layer has neither VK_EXT_layer_settings nor VK_EXT_validation_features)" + : ""; byte* applicationName = (byte*)SilkMarshal.StringToPtr("Optimum"); byte* engineName = (byte*)SilkMarshal.StringToPtr("Optimum.Render.Vulkan"); nint extensionsPtr = SilkMarshal.StringArrayToPtr(extensions); nint layersPtr = validation ? SilkMarshal.StringArrayToPtr(new[] { ValidationLayer }) : 0; + // Every string a layer setting points at, freed with the rest below. + var settingStrings = new List(); try { + int settingSlots = Math.Max(settings.Count, 1); + LayerSettingEXT* settingsPtr = stackalloc LayerSettingEXT[settingSlots]; + Bool32* boolValues = stackalloc Bool32[settingSlots]; + nint* textValues = stackalloc nint[settingSlots]; + if (chainLayerSettings) + { + nint layerName = SilkMarshal.StringToPtr(ValidationLayer); + settingStrings.Add(layerName); + for (int i = 0; i < settings.Count; i++) + { + nint settingName = SilkMarshal.StringToPtr(settings[i].Name); + settingStrings.Add(settingName); + settingsPtr[i] = new LayerSettingEXT + { + PLayerName = (byte*)layerName, + PSettingName = (byte*)settingName, + ValueCount = 1, + }; + if (settings[i].Text == null) + { + boolValues[i] = settings[i].Enabled; + settingsPtr[i].Type = LayerSettingTypeEXT.Bool32Ext; + settingsPtr[i].PValues = &boolValues[i]; + } + else + { + textValues[i] = SilkMarshal.StringToPtr(settings[i].Text); + settingStrings.Add(textValues[i]); + settingsPtr[i].Type = LayerSettingTypeEXT.StringExt; + settingsPtr[i].PValues = &textValues[i]; + } + } + } + var layerSettings = new LayerSettingsCreateInfoEXT + { + SType = StructureType.LayerSettingsCreateInfoExt, + SettingCount = (uint)settings.Count, + PSettings = settingsPtr, + }; + var applicationInfo = new ApplicationInfo { SType = StructureType.ApplicationInfo, @@ -309,7 +365,9 @@ private bool CreateInstance(VulkanContextOptions options, out string? failureRea var createInfo = new InstanceCreateInfo { SType = StructureType.InstanceCreateInfo, - PNext = chainValidationFeatures ? &validationFeatures : null, + PNext = chainLayerSettings ? &layerSettings + : chainValidationFeatures ? (void*)&validationFeatures + : null, PApplicationInfo = &applicationInfo, EnabledExtensionCount = (uint)extensions.Count, PpEnabledExtensionNames = (byte**)extensionsPtr, @@ -331,6 +389,7 @@ private bool CreateInstance(VulkanContextOptions options, out string? failureRea SilkMarshal.Free((nint)engineName); SilkMarshal.Free(extensionsPtr); if (layersPtr != 0) SilkMarshal.Free(layersPtr); + foreach (nint text in settingStrings) SilkMarshal.Free(text); } if (validation) @@ -344,6 +403,84 @@ private bool CreateInstance(VulkanContextOptions options, out string? failureRea /// Name of VK_EXT_validation_features; Silk.NET has no wrapper class for it. internal const string ValidationFeaturesExtensionName = "VK_EXT_validation_features"; + /// Name of VK_EXT_layer_settings, the layer's own replacement for it. + internal const string LayerSettingsExtensionName = "VK_EXT_layer_settings"; + + /// The validation layer's spec and implementation version, once it has been found. + public string ValidationLayerVersion { get; private set; } = ""; + + /// + /// Which extra checks reached the layer and how, for the device-up log line; empty when + /// none were asked for. + /// + public string ValidationSettingsApplied { get; private set; } = ""; + + /// One VK_EXT_layer_settings entry for the Khronos layer: a boolean, or a string when is set. + internal readonly record struct ValidationLayerSetting(string Name, bool Enabled = true, string? Text = null); + + /// + /// Maps the comma list from OPTIMUM_VULKAN_VALIDATION_FEATURES onto the layer's settings + /// (VkLayer_khronos_validation.json; docs/research/vulkan-validation.md §1 and §2): + /// "sync" synchronization validation with structured message properties to filter on; + /// "best" best practices with the NVIDIA and AMD sets, whose messages are warnings and + /// performance reports; "mobile" the Arm and IMG sets, advisory on desktop GPUs; + /// "gpu" GPU-assisted validation; "gpu-only" the same with CPU core validation off. + /// + internal static List ValidationLayerSettings(string? features) + { + var settings = new List(); + foreach (string feature in (features ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + switch (feature.ToLowerInvariant()) + { + case "sync": + AddSetting(settings, new ValidationLayerSetting("validate_sync")); + AddSetting(settings, new ValidationLayerSetting("syncval_message_extra_properties")); + break; + case "best": + AddSetting(settings, new ValidationLayerSetting("validate_best_practices")); + AddSetting(settings, new ValidationLayerSetting("validate_best_practices_nvidia")); + AddSetting(settings, new ValidationLayerSetting("validate_best_practices_amd")); + AddSetting(settings, new ValidationLayerSetting("report_flags", Text: "error,warn,perf")); + break; + case "mobile": + AddSetting(settings, new ValidationLayerSetting("validate_best_practices")); + AddSetting(settings, new ValidationLayerSetting("validate_best_practices_arm")); + AddSetting(settings, new ValidationLayerSetting("validate_best_practices_img")); + break; + case "gpu": + AddSetting(settings, new ValidationLayerSetting("gpuav_enable")); + break; + case "gpu-only": + AddSetting(settings, new ValidationLayerSetting("gpuav_enable")); + AddSetting(settings, new ValidationLayerSetting("validate_core", Enabled: false)); + break; + } + } + return settings; + } + + private static void AddSetting(List settings, ValidationLayerSetting setting) + { + foreach (ValidationLayerSetting existing in settings) + { + if (existing.Name == setting.Name) return; + } + settings.Add(setting); + } + + internal static string DescribeValidationLayerSettings(List settings) + { + var parts = new List(settings.Count); + foreach (ValidationLayerSetting setting in settings) + { + parts.Add(setting.Text != null ? setting.Name + "=" + setting.Text + : setting.Enabled ? setting.Name + : setting.Name + "=false"); + } + return string.Join(" ", parts); + } + /// /// Whether advertises /// as an instance extension. A layer's extensions are invisible to the @@ -395,7 +532,9 @@ internal static List ParseValidationFeatures(string? { case "sync": enables.Add(ValidationFeatureEnableEXT.SynchronizationValidationExt); break; case "best": enables.Add(ValidationFeatureEnableEXT.BestPracticesExt); break; + case "mobile": enables.Add(ValidationFeatureEnableEXT.BestPracticesExt); break; case "gpu": enables.Add(ValidationFeatureEnableEXT.GpuAssistedExt); break; + case "gpu-only": enables.Add(ValidationFeatureEnableEXT.GpuAssistedExt); break; } } return enables; @@ -421,6 +560,8 @@ private bool HasValidationLayer() { if (SilkMarshal.PtrToString((nint)layersPtr[i].LayerName) == ValidationLayer) { + ValidationLayerVersion = VersionString(layersPtr[i].SpecVersion) + + " (implementation " + layersPtr[i].ImplementationVersion + ")"; return true; } } diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 35e59dbd..35655d4f 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -228,9 +228,11 @@ private sealed class StagedStage /// /// OPTIMUM_VULKAN_VALIDATION_FEATURES: comma list of "sync" (synchronization - /// validation), "best" (best practices, vendor checks included) and "gpu" - /// (GPU-assisted). Requested through VK_EXT_validation_features so it does - /// not depend on the layer's environment variable names, which changed. + /// validation), "best" (best practices with the NVIDIA and AMD sets), "mobile" + /// (the Arm and IMG sets), "gpu" (GPU-assisted) and "gpu-only" (GPU-assisted, + /// core off). Requested through VK_EXT_layer_settings (the deprecated + /// VK_EXT_validation_features on older layers) so it does not depend on the + /// layer's environment variable names, which changed. /// private static readonly string ValidationFeatureSetting = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_VALIDATION_FEATURES") ?? ""; @@ -388,7 +390,8 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa }; VulkanResult.DescribeDeviceLoss = DescribeDeviceLoss; MirrorValidationMessage("--- device up on " + _context.Capabilities.DeviceName + - "; validation layers " + (_context.ValidationEnabled ? "ENABLED" : "NOT AVAILABLE") + + "; validation layers " + (_context.ValidationEnabled ? "ENABLED " + _context.ValidationLayerVersion : "NOT AVAILABLE") + + (_context.ValidationSettingsApplied.Length == 0 ? "" : "; " + _context.ValidationSettingsApplied) + "; GPU checkpoints " + (_context.CheckpointsAvailable ? "ENABLED" : "NOT AVAILABLE") + "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE") + "; poison " + (_context.PoisonFreshResources ? "ON" : "off") + diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index ae085d34..d4e42499 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -653,6 +653,8 @@ public void ValidationMessagesAlwaysReachAFileAndExtraFeaturesCanBeRequested() Assert.Contains("ValidationFeatureEnableEXT.SynchronizationValidationExt", context); Assert.Contains("ValidationFeatureEnableEXT.BestPracticesExt", context); Assert.Contains("StructureType.ValidationFeaturesExt", context); + Assert.Contains("StructureType.LayerSettingsCreateInfoExt", context); + Assert.Contains("\"validate_best_practices_nvidia\"", context); } [Fact] diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index e65e6bb7..434a5fde 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -317,7 +317,14 @@ pacing seen in Phase 0 and Phase 1 was the moving world, not the build. Evidence ### Validation log - `OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` (log: - `$TMPDIR/optimum-vulkan-validation.log`, or set the first variable to a path). Read it before + `$TMPDIR/optimum-vulkan-validation.log`, or set the first variable to a path). The feature list is a + comma list: `sync` (synchronization validation with structured message properties), `best` (best + practices with the NVIDIA and AMD sets, reporting performance warnings too), `mobile` (the Arm and IMG + sets, advisory on desktop GPUs), `gpu` (GPU-assisted validation) and `gpu-only` (GPU-assisted with CPU + core validation off, as the layer's documentation advises). They reach the layer through + `VK_EXT_layer_settings`, or the deprecated `VK_EXT_validation_features` on a layer without it; the + device-up line of the log names the layer version and the settings actually applied. Run one area per + session (`docs/research/vulkan-validation.md` §1). Read it before instrumenting anything: a bug that flickers between frames is invisible to screenshots and to per-frame probes (`CLAUDE.md` rule 9). From a9d0218a7a6c7a8118b33468ec67b36affdb7fd5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:17:47 +0200 Subject: [PATCH 138/226] docs: handover note - where the Vulkan branch work stands and what comes next --- .gitignore | 2 + docs/vulkan-branch-progress.md | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/vulkan-branch-progress.md diff --git a/.gitignore b/.gitignore index 64f4a7e7..7a794512 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,8 @@ docs/* # ...and the Vulkan-native plan: the design, the decisions behind it and the roadmap, for anyone # picking the work up. !docs/vulkan-native-plan.md +# ...and the handover note saying where the branch work stands. +!docs/vulkan-branch-progress.md # ...and the research notes the plan and its designs cite. !docs/research/ build-linux.sh diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md new file mode 100644 index 00000000..12f54105 --- /dev/null +++ b/docs/vulkan-branch-progress.md @@ -0,0 +1,82 @@ +# feat/vulkan-taa: where the work stands + +Handover note so the work can continue on another machine. Plan of record: `docs/vulkan-native-plan.md`. +Research the designs follow: `docs/research/`. Last updated 2026-09-15. + +## Goal and roadmap (in order) + +1. Fully Vulkan-native (no GL mimicry; render systems record pipelines and descriptors directly; mods get + documentation for native support, no compatibility layer). +2. XeGTAO (native compute, composed before the TAA resolve). +3. General refactor. +4. Optimisation, streamlining, simplification. +5. Validation against Vulkan best practice. +6. Cleanup of history and documentation for the upstream review (last). + +Review blockers land alongside. + +## Working rules + +- Research current best practice online before each major piece; every research result goes into + `docs/research/.md` (committed), and the design cites it. +- Commit each verified step and push regularly. +- Commits as NightHammer1000 . Set `git config user.name/user.email` on a new machine + before committing. +- No history rewrites or force-pushes without explicit OK and a merge-base check against upstream. + Upstream PR communication is handled by the owner. +- No tooling or assistant attribution in code, docs or commits. + +## Done on this branch + +| Commit | What | +|---|---| +| 41373cf | AO composed into the scene before the TAA resolve; SSAO dither advances per frame (temporal stability fix) | +| 766aada | Headless render harness backport | +| f83bda2 | Plan of record with the full-native roadmap | +| 19d101e | Review blockers: shaderc placement, prime-run, numpy probe, Windows bootstrap | +| a41efce | Shared frame block (set 0 `FrameGlobals`) | +| 9b52768 | Research notes: caching, descriptor model, XeGTAO, validation | +| 618a0b1 | Persisted SPIR-V cache and driver pipeline cache (`GamePaths.Cache/optimum-vulkan`, `OPTIMUM_VULKAN_SHADER_CACHE`) | +| 5bc337b | Plan decision 9: one pipeline layout, bindless textures from the start | +| 31684e3 | Validation extra checks via `VK_EXT_layer_settings` (`OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best,mobile,gpu,gpu-only`) | + +Test baselines (Windows machine): Vulkan GPU suite 652/656 before the validation layer was +installed (1 host failure `PacingStatsTests` - WSL path translation; 3 skips for the missing layer). +Optimum.Tests: 5 host-environment failures (pacing gate x2, numpy self-tests x2, `_ref/` not +materialised). + +## In progress when the machine changed + +- **Bindless implementation research** (for decision 9) was running and did not finish. Redo it and write + `docs/research/vulkan-bindless.md`. Questions: separate `texture2D[]` + `sampler[]` vs combined arrays + and handling of shadow/array/cube/integer samplers; layout and pool flags (partially bound, + update-after-bind, variable count) and required features/limits per vendor incl. Intel iGPU; slot + lifetime with frames in flight (texture ids already recycle through a free list with deferred + deletion, and transient aliasing rebinds ids per frame, so slots must follow `Rebind`/`RestoreBindings`); + when `nonuniformEXT` is needed; driver quirks 2024-2026. +- **Full GPU suite with the validation layer active** was running (Vulkan SDK 1.4.357.0 had just been + installed with `winget install KhronosGroup.VulkanSDK`; previously every validation test skipped). At + interruption 110 tests had run with only the known `PacingStatsTests` failure. Rerun it and triage any + validation messages that now surface. The notebook needs the SDK (or distro validation layers) too. + +## Next steps + +1. Bindless research note (above), then Phase 3 groundwork on decision 9: one global pipeline layout + (set 0 frame UBO + frame textures, set 1 bindless textures + shared samplers, set 2 storage, push + constants <= 128 B), descriptor-indexing feature and limit check at startup (device without it stays + on OpenGL), `bindings.glsl` + `SetConvention.cs` with an agreement test. +2. Rewriter retargeted to that layout (samplers -> bindless indices in push constants, loose uniforms -> + per-frame record addressed from push constants); then native GLSL 450 per program family, offline + compiler tool and manifest (plan Phase 3), then native render systems and removal of the GL emulation + (Phase 3b). +3. Caching follow-ups (`docs/research/vulkan-caching.md`): compile-required path with background builds, + growth-triggered saves, pipeline-key log for pre-warming, optional `VK_KHR_pipeline_binary`. + Real-client warm-start check with the headless harness still open (no game data on the Windows + machine). +4. XeGTAO per `docs/research/xegtao-integration.md` (compute pass kind in the frame graph first). +5. Validation milestones 2-7 per `docs/research/vulkan-validation.md` (per-area runs, suppression list, + headless sessions on NVIDIA/AMD/RADV/ANV, lavapipe lane, Khronos checklist incl. one present semaphore + per swapchain image, debug names and device fault reports). +6. Refactor (split `VulkanDevice.cs`), optimisation (plan Phase 4), final cleanup. + +Untracked `shaderincludes/` at the repo root is a bootstrap artefact; leave it alone. From 11195c54cd3150bda9ca61916ff331afc050eb82 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:20:15 +0200 Subject: [PATCH 139/226] docs: handover note records the present-after-write hazard the validation layer surfaced --- docs/vulkan-branch-progress.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 12f54105..0836fd54 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -54,10 +54,18 @@ materialised). lifetime with frames in flight (texture ids already recycle through a free list with deferred deletion, and transient aliasing rebinds ids per frame, so slots must follow `Rebind`/`RestoreBindings`); when `nonuniformEXT` is needed; driver quirks 2024-2026. -- **Full GPU suite with the validation layer active** was running (Vulkan SDK 1.4.357.0 had just been - installed with `winget install KhronosGroup.VulkanSDK`; previously every validation test skipped). At - interruption 110 tests had run with only the known `PacingStatsTests` failure. Rerun it and triage any - validation messages that now surface. The notebook needs the SDK (or distro validation layers) too. +- **Full GPU suite with the validation layer active** (Vulkan SDK 1.4.357.0, installed with + `winget install KhronosGroup.VulkanSDK`; previously every validation test skipped) finished: 661 tests, + 6 failures. One is the known host failure `PacingStatsTests`. The other five are new and all the same + sync-validation hazard, which the missing layer had hidden: `SYNC-HAZARD-PRESENT-AFTER-WRITE` ("no + sufficient synchronization is present to ensure that a swapchain present operation does not conflict + with a prior layout transition") in `PresentDecouplingTests.RecordingTimeDoesNotGrowWithTheInjectedAcquireDelay`, + `SwapchainRecreationTests.AHiddenWindowResizeLoopRecreatesWithoutWaitingAndStaysClean` and + `SwapchainTests.TogglingVsyncRebuildsTheChainCleanly` / `ADeviceComesUpAgainstARealWindowAndPresentsFrames` + / `ResizingRebuildsTheChainAndKeepsPresenting`. First fix on the list: the present path's semaphore wait + must cover the command buffer that transitions the swapchain image to PRESENT_SRC, with one render-finished + semaphore per swapchain image indexed by the acquired image (`docs/research/vulkan-validation.md` §4, + Swapchain Semaphore Reuse). The notebook needs the SDK (or distro validation layers) too. ## Next steps From 8036680329e343f3fbab0dca8b86d75e307f57d3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:24:13 +0200 Subject: [PATCH 140/226] docs: full handoff for the Vulkan branch - context, rules, repository essentials, status and ordered to-do --- docs/vulkan-branch-progress.md | 273 ++++++++++++++++++++++++--------- 1 file changed, 198 insertions(+), 75 deletions(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 0836fd54..b67260eb 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -1,90 +1,213 @@ -# feat/vulkan-taa: where the work stands +# feat/vulkan-taa: handoff -Handover note so the work can continue on another machine. Plan of record: `docs/vulkan-native-plan.md`. -Research the designs follow: `docs/research/`. Last updated 2026-09-15. +Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-15 at 11195c5. -## Goal and roadmap (in order) +- Plan of record: `docs/vulkan-native-plan.md` (decisions 1-9, phases, risks). +- Research the designs follow: `docs/research/` (caching, descriptor model, XeGTAO, validation). +- Acceptance procedures: `docs/vulkan-acceptance.md`, `docs/taa-acceptance.md`, `docs/temporal-frame-contract.md`. +- Older planning documents still in the tree: `VULKAN-BACKEND-PLAN.md`, `TAA-PLAN.md` (history; the plan of + record supersedes them where they disagree). -1. Fully Vulkan-native (no GL mimicry; render systems record pipelines and descriptors directly; mods get - documentation for native support, no compatibility layer). -2. XeGTAO (native compute, composed before the TAA resolve). -3. General refactor. -4. Optimisation, streamlining, simplification. -5. Validation against Vulkan best practice. -6. Cleanup of history and documentation for the upstream review (last). +--- -Review blockers land alongside. +## 1. Context -## Working rules +**Upstream.** StratumServer/Optimum PR #69 (owner: NightHammer1000) was split: the maintainer wants the +Vulkan backend landed first, so this branch carries Vulkan + TAA only, without the DLSS/frame-generation +work (branches `feat/dlss`, `feat/dlss-g` keep that). The owner handles all upstream communication and the +PR itself; do not push to upstream or touch the PR. -- Research current best practice online before each major piece; every research result goes into - `docs/research/.md` (committed), and the design cites it. -- Commit each verified step and push regularly. -- Commits as NightHammer1000 . Set `git config user.name/user.email` on a new machine - before committing. -- No history rewrites or force-pushes without explicit OK and a merge-base check against upstream. - Upstream PR communication is handled by the owner. -- No tooling or assistant attribution in code, docs or commits. +**Base.** Branched from the last TAA-only commit before the DLSS work (9ad0c70 after the identity rewrite). -## Done on this branch +**History rewrite that already happened.** All 14 fork branches had their author identity rewritten to +NightHammer1000 on 2026-09-15. A first attempt also re-created upstream's signed +commits, which broke the common history with StratumServer:main and closed PR #69 irrecoverably; the redo +excluded upstream history. Binding from now on: no rewrite or force-push without the owner's explicit OK, +restricted to the commits that need it (`--not `), and with `git merge-base` against upstream +compared before and after for every branch. + +## 2. Working rules + +- **Research first.** Before each major piece, research current best practice online (Khronos spec, + guide and samples, vendor guidance, shipped engines such as DXVK, Godot, Unreal, Bevy), write the result + to `docs/research/.md` with citations, commit it, and state the design with its sources before + writing code. +- **Commit each verified step, push regularly.** Verified means the relevant tests ran. +- **Identity.** `git config --global user.name NightHammer1000` and + `git config --global user.email nightstorm@kpc.bz` on every machine before committing. The notebook is the + machine that previously committed with a wrong identity; check it first. +- **No tooling or assistant attribution** in code, comments, docs, tests, scripts or commit messages, and + no co-author trailers. +- **Genuine decisions go to the owner** (forks between plan and research, system installs, anything + outward-facing). +- **Keep the to-do list** in section 5 current and commit it as items change state. + +## 3. Repository essentials + +- **Game code is decompiled and patched.** `scripts/bootstrap.sh` (or `scripts/bootstrap.ps1 -Refresh` on + Windows) downloads the client, decompiles it into `build/VintagestoryLib`, clones the forks and applies + `patches/*.patch`. `scripts/extract-patches.sh` regenerates the patches from the working tree, + `scripts/check-patches.sh` verifies them. +- **Fork sources live at the repo root** (`VintagestoryApi/`, ...); extraction copies them into `sources/` + and overwrites anything edited there. Always edit the root fork tree, never `sources/`. +- **Cecil transplant patcher** (`Optimum.Patcher/Program.cs`): every member injected into the client + (fields, methods, shader program entries) has to be listed in its members/targets lists, and transplanted + code must avoid cached lambdas and LINQ predicates (`Optimum.Tests/cecil-transplant-lambda-tests.cs`). + Client members the Vulkan platform reads also go into `VulkanClientPlatform.ExpectedWindowsMembers`, and + new shader files into the packaging scripts. +- **Build and deploy:** `make build`, `make deploy` (Cecil-patched DLLs into the vanilla client), `make run`. + `make check` reports missing tools. +- **Tests:** + - `dotnet test Optimum.Render.Vulkan.Tests -c Release` - the GPU suite (real device; the validation layer + is used when installed). + - `dotnet test Optimum.Tests -c Release` - source, patch, shader and script coverage. + - `make test` - Optimum.Tests plus the launcher tests. +- **Headless render harness:** `scripts/dev/headless-capture.sh --renderer vulkan|opengl --world + --out [--commands ] [--count ]` (real client, hidden window, frames written by the client; + compare with `scripts/dev/ssim.py`). Needs a logged-in game install and a save. +- **Diagnostics environment variables** (Vulkan): + - `OPTIMUM_VULKAN_VALIDATION=1|`, `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best,mobile,gpu,gpu-only` + - `OPTIMUM_VULKAN_STATS=`, `OPTIMUM_RENDER_TRACE` + - `OPTIMUM_VULKAN_SHADER_CACHE=|0` + - `OPTIMUM_VULKAN_FRAMEGRAPH=0`, `OPTIMUM_VULKAN_ALIAS=1`, `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` + - `OPTIMUM_VULKAN_NO_MEMORY_BUDGET=1`, `OPTIMUM_VULKAN_NO_REBAR=1`, `OPTIMUM_VULKAN_POISON`, `OPTIMUM_VULKAN_CHECKPOINTS` + +### Renderer layout (Optimum.Render.Vulkan) + +- `VulkanDevice.cs` (about 3400 lines, to be split in the refactor): the device behind the patched + platform; program link, uniform writes, descriptor binding, draw, present, teardown. +- `Core/VulkanContext.cs`: instance (validation via `VK_EXT_layer_settings`), device selection, feature + negotiation, capabilities (incl. vendor, device, driver version, pipeline-cache UUID). +- `Core/PipelineCache.cs` (`GraphicsPipelineCache`), `Core/PipelineCacheFile.cs`, `Core/CacheFileWriter.cs`. +- `Core/DescriptorCache.cs`, `Core/DescriptorArena.cs`, `Core/ShaderProgramResources.cs` (today: set 0 frame, + 1 samplers, 2 storage, 3 program blocks, one layout per program - what decision 9 replaces). +- `Core/TextureManager.cs` (texture ids with a free list and deferred deletion; `Rebind`/`RestoreBindings` + for transient aliasing), `Core/MeshManager.cs`, `Core/RenderTargetManager.cs`, `Core/FrameRing.cs` + (timeline semaphores, uniform ring with dynamic offsets), `Core/GlStateTracker.cs` (GL emulation, to be + removed in Phase 3b). +- `Graph/`: streaming frame graph, transient allocator, feedback copy pool. +- `Shaders/`: runtime translation GLSL 330 -> 450 (`GlslParser` -> `ProgramInterfaceLayout` -> + `ShaderRewriter` -> `ShaderCompiler`/shaderc), `FrameGlobals.cs` (shared frame block, include-owner rule), + `ShaderBinaryCache.cs`. +- `Platform/VulkanClientPlatform.cs`: the substituted client platform. + +## 4. Knowledge that is not obvious from the code + +- **Temporal stability (the whole-frame jitter).** Symptom: with TAA the entire image appeared to shift a + few pixels per frame in random directions, worst toward the horizon. A 3x3 nearest-depth test only masked + it. The real fix (41373cf, backported from `feat/dlss`): AO computed from the jittered G-buffer is + composed into the scene before the TAA resolve (`ApplyOptimumSceneSsao`, `scene-ssao` shaders, + `optimumSsaoInScene` in `final.fsh`), and the SSAO dither advances per frame when TAA is on. Any new AO + (XeGTAO) must follow the same placement. +- **Jitter convention:** `P[8] -= 2*jx/W`, content moves by +JitterPx; resolve in `taa-resolve.fsh`. +- **Shared frame block** (a41efce): uniforms written by `ShaderProgramBase.Use()` live once in set 0 + (`FrameGlobals`), placed only for programs that include the owning include file; frame locations start at + `1 << 28`. +- **Caches** (618a0b1): SPIR-V key = format version + compiler options + SHA-256 of the loaded shaderc + binary + stage + rewritten source; one pipeline cache file per GPU, checked against vendor, device, driver + version, pointer size, UUID and the blob's own header; empty cache on any mismatch or driver rejection; + saved at device dispose; stored under `GamePaths.Cache/optimum-vulkan`. +- **Headless capture pixel order:** the client-side default framebuffer is BGRA + (`OptimumDefaultFramebufferIsBgra`); `Leaf.ReadDefaultFramebuffer` swaps unless the device colour format is BGRA. +- **Windows bootstrap pitfalls** (fixed in 19d101e, keep in mind): CRLF in fork refs, a PowerShell module + shadowing `Expand-Archive`, innounp overwrite prompts, `Tee-Object` masking exit codes. + +## 5. Status and to-do + +### Done | Commit | What | |---|---| -| 41373cf | AO composed into the scene before the TAA resolve; SSAO dither advances per frame (temporal stability fix) | +| 41373cf | AO into the scene before the TAA resolve; per-frame SSAO dither (temporal stability) | | 766aada | Headless render harness backport | | f83bda2 | Plan of record with the full-native roadmap | -| 19d101e | Review blockers: shaderc placement, prime-run, numpy probe, Windows bootstrap | -| a41efce | Shared frame block (set 0 `FrameGlobals`) | +| 19d101e | Review blockers: shaderc in the app root, prime-run guard, numpy probe, Windows bootstrap | +| a41efce | Shared frame block (set 0 `FrameGlobals`), sets reordered | +| 84f4893 | Docs follow the history rewrite | | 9b52768 | Research notes: caching, descriptor model, XeGTAO, validation | -| 618a0b1 | Persisted SPIR-V cache and driver pipeline cache (`GamePaths.Cache/optimum-vulkan`, `OPTIMUM_VULKAN_SHADER_CACHE`) | -| 5bc337b | Plan decision 9: one pipeline layout, bindless textures from the start | -| 31684e3 | Validation extra checks via `VK_EXT_layer_settings` (`OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best,mobile,gpu,gpu-only`) | - -Test baselines (Windows machine): Vulkan GPU suite 652/656 before the validation layer was -installed (1 host failure `PacingStatsTests` - WSL path translation; 3 skips for the missing layer). -Optimum.Tests: 5 host-environment failures (pacing gate x2, numpy self-tests x2, `_ref/` not -materialised). - -## In progress when the machine changed - -- **Bindless implementation research** (for decision 9) was running and did not finish. Redo it and write - `docs/research/vulkan-bindless.md`. Questions: separate `texture2D[]` + `sampler[]` vs combined arrays - and handling of shadow/array/cube/integer samplers; layout and pool flags (partially bound, - update-after-bind, variable count) and required features/limits per vendor incl. Intel iGPU; slot - lifetime with frames in flight (texture ids already recycle through a free list with deferred - deletion, and transient aliasing rebinds ids per frame, so slots must follow `Rebind`/`RestoreBindings`); - when `nonuniformEXT` is needed; driver quirks 2024-2026. -- **Full GPU suite with the validation layer active** (Vulkan SDK 1.4.357.0, installed with - `winget install KhronosGroup.VulkanSDK`; previously every validation test skipped) finished: 661 tests, - 6 failures. One is the known host failure `PacingStatsTests`. The other five are new and all the same - sync-validation hazard, which the missing layer had hidden: `SYNC-HAZARD-PRESENT-AFTER-WRITE` ("no - sufficient synchronization is present to ensure that a swapchain present operation does not conflict - with a prior layout transition") in `PresentDecouplingTests.RecordingTimeDoesNotGrowWithTheInjectedAcquireDelay`, - `SwapchainRecreationTests.AHiddenWindowResizeLoopRecreatesWithoutWaitingAndStaysClean` and - `SwapchainTests.TogglingVsyncRebuildsTheChainCleanly` / `ADeviceComesUpAgainstARealWindowAndPresentsFrames` - / `ResizingRebuildsTheChainAndKeepsPresenting`. First fix on the list: the present path's semaphore wait - must cover the command buffer that transitions the swapchain image to PRESENT_SRC, with one render-finished - semaphore per swapchain image indexed by the acquired image (`docs/research/vulkan-validation.md` §4, - Swapchain Semaphore Reuse). The notebook needs the SDK (or distro validation layers) too. - -## Next steps - -1. Bindless research note (above), then Phase 3 groundwork on decision 9: one global pipeline layout - (set 0 frame UBO + frame textures, set 1 bindless textures + shared samplers, set 2 storage, push - constants <= 128 B), descriptor-indexing feature and limit check at startup (device without it stays - on OpenGL), `bindings.glsl` + `SetConvention.cs` with an agreement test. -2. Rewriter retargeted to that layout (samplers -> bindless indices in push constants, loose uniforms -> - per-frame record addressed from push constants); then native GLSL 450 per program family, offline - compiler tool and manifest (plan Phase 3), then native render systems and removal of the GL emulation - (Phase 3b). -3. Caching follow-ups (`docs/research/vulkan-caching.md`): compile-required path with background builds, - growth-triggered saves, pipeline-key log for pre-warming, optional `VK_KHR_pipeline_binary`. - Real-client warm-start check with the headless harness still open (no game data on the Windows - machine). -4. XeGTAO per `docs/research/xegtao-integration.md` (compute pass kind in the frame graph first). -5. Validation milestones 2-7 per `docs/research/vulkan-validation.md` (per-area runs, suppression list, - headless sessions on NVIDIA/AMD/RADV/ANV, lavapipe lane, Khronos checklist incl. one present semaphore - per swapchain image, debug names and device fault reports). -6. Refactor (split `VulkanDevice.cs`), optimisation (plan Phase 4), final cleanup. +| 618a0b1 | Persisted SPIR-V cache and driver pipeline cache | +| 5bc337b | Plan decision 9 (one pipeline layout, bindless now); set-convention table and mod adapter rewritten | +| 31684e3 | Validation extra checks via `VK_EXT_layer_settings`, layer version and applied settings logged | +| a9d0218, 11195c5 | Handover note (this file) | + +Upstream review blockers: shaderc placement, prime-run, numpy, Windows bootstrap - fixed. Swapchain resize +tests passed on Windows without the layer (the reviewer saw failures on Linux/MX150; recheck there). Donor +drift `TaaRuntimeDonorCoverageTests` 25/25 at this base (recheck whenever patches change). `vkDeviceWaitIdle` +before window release was already correct. + +### Test state (Windows machine, 2026-09-15) + +- GPU suite with the Vulkan SDK 1.4.357.0 validation layer: 661 tests, 6 failures. + - `PacingStatsTests.PacingGateReadsTheLinesThisBackendWrites`: host issue (WSL path translation), not a + renderer defect. + - Five new, all `SYNC-HAZARD-PRESENT-AFTER-WRITE` ("no sufficient synchronization is present to ensure + that a swapchain present operation does not conflict with a prior layout transition"): + `PresentDecouplingTests.RecordingTimeDoesNotGrowWithTheInjectedAcquireDelay`, + `SwapchainRecreationTests.AHiddenWindowResizeLoopRecreatesWithoutWaitingAndStaysClean`, + `SwapchainTests.TogglingVsyncRebuildsTheChainCleanly`, + `SwapchainTests.ADeviceComesUpAgainstARealWindowAndPresentsFrames`, + `SwapchainTests.ResizingRebuildsTheChainAndKeepsPresenting`. + They were hidden before because no layer was installed. +- Optimum.Tests: 5 host-environment failures (pacing gate x2, numpy self-tests x2, `_ref/` not materialised). + +### Next, in order + +1. **Fix the present-after-write hazard.** The present has to wait on a semaphore signalled by the submit + that transitions the swapchain image to PRESENT_SRC, with one render-finished semaphore per swapchain + image indexed by the acquired image (`docs/research/vulkan-validation.md` §4; Vulkan Guide "Swapchain + Semaphore Reuse"). Exit: the five tests pass with the layer; the rest of the suite is unchanged. +2. **Bindless implementation research** (was running, did not finish): write `docs/research/vulkan-bindless.md`. + Questions: separate `texture2D[]` + `sampler[]` vs combined arrays; shadow, array, cube and integer + samplers; layout and pool flags (partially bound, update-after-bind, variable count); required features + and limits per vendor incl. Intel iGPU; slot lifetime with frames in flight (ids recycle with deferred + deletion, transient aliasing rebinds ids per frame); when `nonuniformEXT` is required; driver quirks + 2024-2026; a concrete recommendation. +3. **Phase 3 groundwork on decision 9:** one global pipeline layout (set 0 frame UBO + frame textures, set 1 + bindless textures + shared samplers, set 2 storage, push constants <= 128 B); descriptor-indexing feature + and limit check at startup (without it the session stays on OpenGL); `bindings.glsl` + + `Shaders/SetConvention.cs` with an agreement test; uniform placement table (push | frame | per-frame + record | storage | texture slot). +4. **Rewriter retargeted** to the shared layout (samplers -> bindless indices, loose uniforms -> per-frame + record addressed from push constants), then native GLSL 450 per program family (includes; post programs; + GUI/lines; chunks; entities; particles/decals/sky/clouds; SSAO/godrays/bloom/colorgrade/OIT; Optimum + programs), offline compiler tool + `shaders.manifest.json` + MSBuild target + packaging, runtime manifest + load with the "N native, M rewritten, K failed" log line, specialization constants for quality defines, + parity tests against the GLSL 330 sources. +5. **Phase 3b:** native render systems (post chain and TAA first, then chunks, entities, + particles/decals/sky, GUI/text); remove `GlStateTracker`, GL id tables, texture units and + uniform-by-location from the Vulkan path; decide the runtime rewriter's fate for mod shaders. +6. **Phase 5:** mod pass and motion-writer API, fork renderers on native systems, scanner v2, mod + documentation for Vulkan-native support plus a fixture mod. +7. **Caching follow-ups** (`docs/research/vulkan-caching.md`): `FAIL_ON_PIPELINE_COMPILE_REQUIRED` with + background compiles, growth-triggered saves, pipeline-key log for pre-warming, optional + `VK_KHR_pipeline_binary`; real-client warm-start check with the headless harness (needs game data). +8. **XeGTAO** (`docs/research/xegtao-integration.md`): compute pass kind in the frame graph, GLSL compute + port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, + composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. +9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. +10. **Optimisation** (plan Phase 4): per-pass GPU timestamps, push-constant placement from the measured + profile, transient aliasing on by default, DirectToSwapchain / transfer backend measured. Exit: Vulkan + mean FPS >= OpenGL and p99 <= OpenGL on the fixed scene. +11. **Validation milestones 2-7** (`docs/research/vulkan-validation.md`): per-area runs (core / sync / + best + vendors / GPU-AV nightly), versioned `message_id_filter` suppression list, headless sessions + clean on NVIDIA/AMD/RADV/ANV, lavapipe CI lane, Khronos checklist review, debug names and device fault + reports. +12. **Cleanup for review (last).** Inventory so far - tracked files with tooling/workflow references: + `TAA-PLAN.md`, `VULKAN-BACKEND-PLAN.md` (read it fully), `docs/vulkan-acceptance.md` (rule references), + `docs/taa-acceptance.md`, `scripts/dev/worktree-bootstrap.sh`, `scripts/tests/bootstrap-git-repository.sh`, + `scripts/dev/parity-capture.sh`, `scripts/dev/luma-diff.py`, `Optimum.Render.Vulkan/Core/RenderTargetManager.cs`, + `Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs`, `patches/.../ClientProgram.cs.patch`. About 27 + commit subjects since 553afdb carry worktree/"merge wave" artefacts; changing them needs a history + rewrite (owner's OK and merge-base check) or a squash for the upstream PR. No co-author trailers exist. + Optional: fix the host-environment test failures (numpy self-tests, pacing gate path translation on Windows). + +## 6. Setting up the notebook + +1. `git fetch origin && git checkout feat/vulkan-taa && git pull` +2. Set the git identity (section 2) and verify with `git config user.email`. +3. Bootstrap if the tree is not materialised (`make bootstrap`, or `scripts/bootstrap.ps1 -Refresh` on Windows). +4. Install the Vulkan validation layers (`winget install KhronosGroup.VulkanSDK` on Windows; the distro's + `vulkan-validation-layers` package on Linux), otherwise the validation tests skip. +5. Run both test suites and compare with section 5, then start with item 1. Untracked `shaderincludes/` at the repo root is a bootstrap artefact; leave it alone. From ff70cba5584439289f1acfbb4a240ae863c74685 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:31:09 +0200 Subject: [PATCH 141/226] docs(research): bindless texture descriptors - array per sampled type, limits per vendor, slot lifetime, nonuniform rules --- docs/research/vulkan-bindless.md | 401 +++++++++++++++++++++++++++++++ docs/vulkan-branch-progress.md | 15 +- 2 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 docs/research/vulkan-bindless.md diff --git a/docs/research/vulkan-bindless.md b/docs/research/vulkan-bindless.md new file mode 100644 index 00000000..226133c5 --- /dev/null +++ b/docs/research/vulkan-bindless.md @@ -0,0 +1,401 @@ +# Bindless texture descriptors on desktop Vulkan 1.3 + +Research notes for implementing decision 9 of `docs/vulkan-native-plan.md` (one pipeline layout, bindless +textures). Collected 2026-09-15. Every claim carries a URL. **[Inference]** marks reasoning not taken from a +source; **[Uncertain]** marks something that could not be verified from a primary source. Device limits come +from the Vulkan Hardware Database (gpuinfo, default "recent (1y)" filter) and the Mesa `main` tree at commit +`8860343e1ecc` (2026-09-15). Companion note: [vulkan-descriptor-model.md](vulkan-descriptor-model.md). + +--- + +## 1. Combined `sampler2D[]` vs separate `texture2D[]` + `sampler[]`; view types, shadow and integer textures + +**Guidance and engine practice** + +- **Khronos Vulkan-Samples** shows both forms, combined `uniform sampler2D Combined[]` and separate + `uniform texture2D Tex[]; uniform sampler Samp[]`. Separate arrays help when several shaders pair the same + textures with different samplers. Texture indices travel in push constants; descriptor memory is treated + as a ring buffer. https://github.com/KhronosGroup/Vulkan-Samples/blob/main/samples/extensions/descriptor_indexing/README.adoc +- **NVIDIA**: "Prefer using combined image and sampler descriptors" and "Do not exceed 1M active descriptors + and 2K samplers in total for the whole application". Push constants are "the fastest way to transfer + per-draw varying constants". https://developer.nvidia.com/blog/advanced-api-performance-descriptors +- **Granite** (2026, descriptor-heap path) keeps resources and samplers apart: about 1M resource descriptors + and 4096 samplers. Sampler slots come from an index allocator because the sampler heap is too small to + allocate linearly; combined pairs go through `useCombinedImageSamplerIndex`. + https://themaister.net/blog/2026/03/29/walking-backwards-into-the-future-a-look-at-descriptor-heap-in-granite/ +- **VK_EXT_descriptor_heap** uses separate sampler and resource heaps; its proposal notes that "multiple + vendors have dedicated image and sampler heaps". + https://github.com/KhronosGroup/Vulkan-Docs/blob/main/proposals/VK_EXT_descriptor_heap.adoc +- **Bevy** uses global binding arrays, "one for each type of resource". Resources are de-duplicated and + reference counted, and materials are allocated into slabs. The limit is 2048 resources per binding on + non-Metal. Fallback resources were dropped once wgpu gained partially-bound arrays. + https://github.com/bevyengine/bevy/pull/17898 + - Bevy falls back to non-bindless per material when "Intel Iris Xe … [has] low limits on the numbers of + samplers per shader". https://github.com/bevyengine/bevy/pull/17155 +- **nvpro GLSL generator** uses one array per GLSL type (`sampler2D = 0, texture2D = 1, usamplerBuffer = 2`). + https://developer.nvidia.com/blog/improved-glsl-syntax-vulkans-descriptorset-indexing/ +- **Parizet's bindless write-up** declares `sampler2D[]`, `usampler2D[]`, `sampler3D[]` and `usampler3D[]` at + the same set/binding (aliasing), with a free-list per set. + https://www.vincentparizet.com/blog/posts/vulkan_bindless_descriptors/ + - **[Uncertain]** Not confirmed from the spec that aliasing different sampled types on one binding is + portable and validation-clean. +- **Wicked Engine** passes descriptor indices through push constants (search-result excerpt; the article + returned 404). https://wickedengine.net/2021/04/bindless-descriptors/ +- **Other engines** + - vkguide.dev: the GPU-driven chapter covers unbounded texture arrays indexed from buffers; no dedicated + bindless chapter found. https://vkguide.dev/docs/gpudriven/gpu_driven_engines/ + - wgpu exposes this as `TEXTURE_BINDING_ARRAY`, `PARTIALLY_BOUND_BINDING_ARRAY` and + `SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`. https://docs.rs/wgpu/latest/wgpu/struct.Features.html + - DXVK 3.0 uses VK_EXT_descriptor_heap by default and deprecates its 2.7 descriptor-buffer path. + https://github.com/doitsujin/dxvk/releases/tag/v3.0 + - vkd3d-proton needs "at least 1000000 UpdateAfterBind descriptors for all types except UniformBuffer". + https://github.com/HansKristian-Work/vkd3d-proton/blob/master/README.md + - **[Uncertain]** Godot, Filament and The Forge: no primary source found on their bindless texture schemes. + +**Hard rules that force one array per type** (spec, Texel Input Validation, +https://docs.vulkan.org/spec/latest/chapters/textures.html) + +- An `OpImage*Dref*` instruction with a sampler whose `compareEnable = VK_FALSE` gives a poison texel value, + and so does a non-Dref instruction with `compareEnable = VK_TRUE`. +- If the image view type does not match the SPIR-V `Arrayed` flag (array vs non-array, cube vs cube array), + texel values are undefined. +- If the signedness of the sample operation does not match the image format, the result is undefined. +- The validation layers do not check the Dref/compareEnable match; there is no VUID. + https://github.com/KhronosGroup/Vulkan-ValidationLayers/issues/3641 +- **[Inference]** Consequences for this renderer: + - Each GLSL sampler type needs its own binding: `sampler2D`, `sampler2DArray`, `sampler3D`, `samplerCube`, + the shadow variants, and `usampler*`/`isampler*`. + - A shadow slot must be written with a comparison sampler. + - A depth image that is also read without comparison needs a second slot in the plain 2D array with a + non-compare sampler. + - Integer textures should use nearest-filter samplers. **[Uncertain]** The exact VUID for linear filtering + of integer formats was not pulled. + +## 2. Flags, features, limits, sizing + +**Layout, pool and allocation** + +- A layout with `VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT` must be allocated from a pool + created with `VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT`. Such layouts "have alternate limits". + https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html +- **Per-binding flags** (same page): + - `UPDATE_AFTER_BIND_BIT`: updates made between bind and submit are used and "do not invalidate the + command buffer". + - `PARTIALLY_BOUND_BIT`: descriptors that are not dynamically used "need not contain valid descriptors". + "If a descriptor is not dynamically used, any resource referenced by the descriptor is not considered to + be referenced during command execution." + - `VARIABLE_DESCRIPTOR_COUNT_BIT`: only on the highest-numbered binding in the layout. +- `UNIFORM_BUFFER_DYNAMIC`, `STORAGE_BUFFER_DYNAMIC` and `INPUT_ATTACHMENT` cannot be update-after-bind; the + spec summary says this also applies at the pipeline-layout level. + https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html + - **[Uncertain]** The exact pipeline-layout VUID text was not obtained. Query + `maxDescriptorSetUpdateAfterBindUniformBuffersDynamic`; ANV reports `MAX_DYNAMIC_BUFFERS / 2` + (anv_physical_device.c). +- Each descriptor type needs its own update-after-bind feature: `descriptorBindingSampledImageUpdateAfterBind` + for SAMPLED_IMAGE and COMBINED_IMAGE_SAMPLER, `descriptorBindingStorageBufferUpdateAfterBind` for SSBOs, and + so on. https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html +- Immutable samplers cannot be changed; on COMBINED_IMAGE_SAMPLER bindings with immutable samplers the + sampler part of an update is ignored (same page). + +**Features** + +- Roadmap 2022 (inherited by 2024 and 2026) requires `descriptorIndexing`, all `*ArrayNonUniformIndexing` + features for sampled images, storage buffers and storage images, `descriptorBindingSampledImageUpdateAfterBind`, + `descriptorBindingStorageImageUpdateAfterBind`, `descriptorBindingStorageBufferUpdateAfterBind`, + `descriptorBindingUpdateUnusedWhilePending`, `descriptorBindingPartiallyBound`, + `descriptorBindingVariableDescriptorCount` and `runtimeDescriptorArray`. + https://docs.vulkan.org/spec/latest/appendices/roadmap.html +- **[Uncertain]** Whether plain Vulkan 1.3 core (without the roadmap profile) makes these mandatory. Query + them anyway. +- A dynamic index that is uniform per draw needs the 1.0 feature `shaderSampledImageArrayDynamicIndexing`; + per-invocation (non-uniform) indices need `shaderSampledImageArrayNonUniformIndexing`. + https://chunkstories.xyz/blog/a-note-on-descriptor-indexing/ , + https://docs.vulkan.org/guide/latest/extensions/VK_EXT_descriptor_indexing.html +- Target devices: the Arc 140V on Windows (driver 101.8724) and Lunar Lake on Mesa 26.1.7 both report + `runtimeDescriptorArray`, `descriptorBindingPartiallyBound`, `descriptorBindingVariableDescriptorCount` and + `descriptorBindingSampledImageUpdateAfterBind`. https://vulkan.gpuinfo.org/displayreport.php?id=48570 , + https://vulkan.gpuinfo.org/displayreport.php?id=51163 + - The Windows report also lists `shaderSampledImageArrayNonUniformIndexing`. + - Both list VK_EXT_descriptor_buffer. **[Uncertain]** Neither showed VK_EXT_descriptor_heap in the + extracted text. + +**Limits** + +- Definitions: https://docs.vulkan.org/refpages/latest/refpages/source/VkPhysicalDeviceDescriptorIndexingProperties.html. + The per-stage limit counts COMBINED_IMAGE_SAMPLER, SAMPLED_IMAGE and UNIFORM_TEXEL_BUFFER across all sets in + the pipeline layout (search-result excerpt of + https://docs.vulkan.org/refpages/latest/refpages/source/VkPipelineLayoutCreateInfo.html). +- The Vulkan-Samples README says the "min-spec here is 500k". + https://github.com/KhronosGroup/Vulkan-Samples/blob/main/samples/extensions/descriptor_indexing/README.adoc + +`maxPerStageDescriptorUpdateAfterBindSampledImages` by vendor (vendor attribution from the 100 most recent +reports per value on gpuinfo's `listreports.php?property=…&value=…`; distributions from +https://vulkan.gpuinfo.org/displaycoreproperty.php?core=1.2&name=maxperstagedescriptorupdateafterbindsampledimages&platform=windows +and `platform=linux`): + +| Platform / driver | Value | +|---|---| +| NVIDIA Windows and Linux | 1,048,576 | +| AMD Windows | 4,294,967,295 | +| Intel Windows (Arc A/B, Arc 140V, "Intel Graphics") | 33,554,432 (140V report 48570; A770 report 37286) | +| RADV | 8,388,606 | +| ANV Gfx12.5+ (DG2, MTL, ARL, BMG, LNL) | 33,554,432 images; 67,108,864 samplers | +| ANV pre-12.5 (Skylake–Tiger/Alder Lake Iris Xe) | 201,326,592 images; 402,653,184 samplers | +| llvmpipe | 1,000,000 / 1,015,808 | +| SwiftShader | 500,000 | + +**Where the Mesa values come from** + +- **ANV** + - Gfx ≥ 12.5 uses "extended bindless" direct descriptors: `intel_has_extended_bindless` is `verx10 >= 125` + (src/intel/dev/intel_device_info.h). Older generations use indirect descriptors (anv_physical_device.c). + - The limit is heap size divided by descriptor size. The bindless surface-state pool is 2 GiB or 4 GiB and + the indirect descriptor pool 3 GiB (src/intel/vulkan/anv_va.c). + - A source comment says ≤ Gfx12.0 is practically about 500K live image views. + - https://gitlab.freedesktop.org/mesa/mesa/-/blob/main/src/intel/vulkan/anv_physical_device.c +- **RADV** sizes descriptor sets to stay addressable in 2 GiB, counting 32 bytes per sampler and 64 per + sampled image. https://gitlab.freedesktop.org/mesa/mesa/-/blob/main/src/amd/vulkan/radv_physical_device.c +- **Pre-Skylake Intel**: the 240-entry binding table is a hardware limit there. + https://gfxstrand.net/faith/blog/2022/08/descriptors-are-hard/ + +**Other limits that matter** + +- `maxSamplerAllocationCount`: 4000 on NVIDIA and Intel Windows (including the 140V); 1,048,576 on AMD + Windows; 65,536 on ANV and RADV. + https://vulkan.gpuinfo.org/displaydevicelimit.php?name=maxSamplerAllocationCount&platform=windows +- `maxPushConstantsSize`: 256 on about 95% of devices; 128 on older AMD Windows (RX 560/570) and SwiftShader. + https://vulkan.gpuinfo.org/displaydevicelimit.php?name=maxPushConstantsSize&platform=windows ; 128 is the spec + minimum. https://github.com/KhronosGroup/Vulkan-Guide/blob/main/chapters/push_constants.adoc +- The non-update-after-bind `maxPerStageDescriptorSamplers` is 16 or 64 on older Intel Windows iGPUs: HD + 520/530/630 report 16; UHD 620/730 and Iris Xe report 64. + https://vulkan.gpuinfo.org/displaydevicelimit.php?name=maxPerStageDescriptorSamplers&platform=windows + - **[Uncertain]** Their update-after-bind sampler limits were not checked. + +**Sizing** + +- **[Inference]** Every desktop target allows millions of descriptors, so the practical cap is memory and CPU + write cost. At RADV's roughly 96 bytes per combined descriptor, 65,536 slots is about 6 MB. +- A variable count is fixed at allocation, so growing the array means allocating a new set. + +## 3. Index lifetime, deferred free, placeholders, validation + +- **Destroying resources.** `vkDestroyImageView` requires "All submitted commands that refer to imageView must + have completed execution" (VUID-vkDestroyImageView-imageView-01026). + https://docs.vulkan.org/refpages/latest/refpages/source/vkDestroyImageView.html + - For PARTIALLY_BOUND bindings only dynamically accessed descriptors count as referenced; destroying + unaccessed ones is fine (clarified in the 1.3.210 spec update). + https://github.com/KhronosGroup/Vulkan-Docs/issues/1794 +- **Updating while in flight.** Update-after-bind covers updates between bind and submit; + `UPDATE_UNUSED_WHILE_PENDING` covers updating descriptors that pending command buffers do not use. + https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html + - **[Inference]** Rewriting a slot that a submitted, unfinished command buffer may still sample is not + covered. Slot reuse must wait until those frames finish. +- **Existing practice** + - Batch texture writes at end of frame, and "be sure to not change a used resource in command buffers that + are running". https://jorenjoestar.github.io/post/vulkan_bindless_texture/ + - Free-list per set. https://www.vincentparizet.com/blog/posts/vulkan_bindless_descriptors/ + - Slab allocation with de-duplication and reference counts. https://github.com/bevyengine/bevy/pull/17898 +- **Placeholders vs partially bound.** Bevy dropped fallback resources once partially-bound arrays were + available. https://github.com/bevyengine/bevy/pull/17898 + - **[Inference]** Still write a 1×1 placeholder into freed and unallocated slots: an accidental read then + shows a visible colour instead of undefined behaviour, for one write per free. +- **Validation** + - CPU validation cannot know runtime indices; with PARTIALLY_BOUND or UPDATE_AFTER_BIND the checks move to + GPU-AV. https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/docs/gpu_av_descriptor_indexing.md + - GPU-AV instruments shaders for out-of-bounds indices, uninitialized descriptors and destroyed descriptors, + then post-processes accessed descriptors on the CPU after submit (same page). + - Settings keys: `gpuav_enable`, `gpuav_descriptor_checks`, `gpuav_post_process_descriptor_indexing`, + `gpuav_shader_instrumentation`, `gpuav_select_instrumented_shaders`. + https://github.com/KhronosGroup/Vulkan-ValidationLayers/blob/main/layers/VkLayer_khronos_validation.json.in + - GPU-AV adds runtime overhead. + https://github.com/KhronosGroup/Vulkan-Samples/blob/main/samples/extensions/descriptor_indexing/README.adoc + - A 2019 LunarG deck said GPU-AV waited for queue idle after each submit; **[Uncertain]** whether that still + holds. https://www.lunarg.com/wp-content/uploads/2019/09/GPU-Assisted-Validation-v5_Feb_20.pdf + +## 4. When `nonuniformEXT` is required + +- **Rule.** All invocations in an invocation group using the same dynamic index need no annotation; different + indices need `nonuniformEXT` and the `NonUniform` decoration. + https://docs.vulkan.org/guide/latest/extensions/VK_EXT_descriptor_indexing.html +- **Spec VUIDs.** VUID-RuntimeSpirv-None-10148 and -subgroupSize-10149 require `NonUniform` on the resource + operand (the pointer or sampled image) when the resource is not uniform within the invocation group. + VUID-...-SampledImageArrayNonUniformIndexing-10135 requires dynamically uniform indexing without that + capability. https://docs.vulkan.org/refpages/latest/refpages/source/RuntimeSpirv.html +- **Practical reading.** + - Treat the invocation group as the whole draw call or dispatch. Only `gl_DrawID` is explicitly dynamically + uniform; `gl_InstanceIndex` indexing needs `NonUniform`. + https://anki3d.org/resource-uniformity-bindless-access-in-vulkan/ + - **[Inference]** A push-constant or per-draw UBO index is constant across the draw, so no `nonuniformEXT` is + needed. An index from a vertex attribute, instance data or per-pixel data does need it. +- **GLSL and glslang.** + - GL_EXT_nonuniform_qualifier: "Constructors and builtin functions … will not generate nonuniform results." + https://github.com/KhronosGroup/GLSL/blob/main/extensions/ext/GL_EXT_nonuniform_qualifier.txt + - glslang had a bug placing the decoration on the wrong values, addressed via PR #1762. + https://github.com/KhronosGroup/glslang/issues/1760 + - `nonuniformEXT` on a texture index inside if/else or a ternary produces invalid SPIR-V (issue opened + 2024-03-29; **[Uncertain]** fix status). https://github.com/KhronosGroup/glslang/issues/3561 + - **[Inference]** Combined arrays avoid the `sampler2D(tex[i], s)` constructor path. Where nonuniform is + used, check with spirv-dis that the sampled-image operand carries `NonUniform`. + +## 5. Performance pitfalls + +- **Non-uniform indexing is not native on most drivers.** `shaderSampledImageArrayNonUniformIndexingNative = + VK_FALSE` means a non-uniformly indexed instruction "may execute multiple times". + https://docs.vulkan.org/refpages/latest/refpages/source/VkPhysicalDeviceDescriptorIndexingProperties.html + - It is false on ANV and RADV (source), on Intel Windows (140V and A770 reports), and on about 25% of Windows + and 63% of Linux reports. + https://vulkan.gpuinfo.org/displaycoreproperty.php?core=1.2&name=shadersampledimagearraynonuniformindexingnative&platform=windows + - AMD's compiler adds extra instructions when `NonUniform` is present. + https://anki3d.org/resource-uniformity-bindless-access-in-vulkan/ + - **[Inference]** Keep indices per-draw uniform on hot paths. +- **Implicit LOD with divergent indices.** `quadDivergentImplicitLod` is false on ANV and RADV (source) and true + on the 140V Windows driver. + - **[Inference]** If the index varies inside a 2×2 quad, use explicit-LOD or gradient sampling. +- **Update-after-bind cost.** + - Update-after-bind stops the driver consuming descriptors at record time and allows updates from multiple + threads. https://developer.arm.com/community/arm-community-blogs/b/mobile-graphics-and-gaming-blog/posts/vulkan-descriptor-indexing + - UBOs are deliberately not required to support update-after-bind because of implementation cost. + https://gfxstrand.net/faith/blog/2022/08/descriptors-are-hard/ + - DXVK 3.0 reports AMD RDNA1/2 Windows drivers can only use its "slow legacy binding model" with severe + performance problems. https://github.com/doitsujin/dxvk/releases/tag/v3.0 +- **Push constant vs UBO index.** + - NVIDIA calls push constants the fastest per-draw path and advises few sets and tightly packed bindings. + https://developer.nvidia.com/blog/advanced-api-performance-descriptors , + https://developer.nvidia.com/blog/vulkan-dos-donts/ + - **[Inference]** GPU-side cost is the same either way because both are uniform; the difference is CPU + overhead. +- **Samplers.** Keep the unique sampler count small: 4000 allocation limit on NVIDIA and Intel Windows + (gpuinfo above), and NVIDIA's "2K samplers" advice. + +## 6. Known driver bugs and quirks + +- **Intel Windows.** Bevy crashed on Arc (Core Ultra 7 155H, driver 101.6458) with "Binding count declared + with exactly 2048 items, but 6 items were provided". https://github.com/bevyengine/bevy/issues/18098 + - A wgpu-level check that fires when partially-bound support is not detected. **[Uncertain]** Whether the + root cause is the driver. +- **AMD Windows** + - `binding_array` sampling corruption on Radeon 6800 XT with driver 23.11.1, fixed on the naga SPIR-V + back-end side (PR #4766). https://github.com/gfx-rs/wgpu/issues/4762 + - Historical report: non-uniform sampler-array indexing plus `discard` caused device lost. **[Uncertain]** + Date and fix unknown; the thread now redirects. + https://community.amd.com/t5/opengl-vulkan/likely-driver-bug-in-vulkan-on-windows-when-using-ext-descriptor/m-p/243766 +- **Mesa and others** + - vkd3d-proton recommends RADV with Mesa ≥ 22.0 and NVIDIA ≥ 535 "to fix various bugs", and has not tested + Intel. https://github.com/HansKristian-Work/vkd3d-proton/blob/master/README.md + - An AMD driver crash from 2019 has since been fixed. https://chunkstories.xyz/blog/a-note-on-descriptor-indexing/ +- **Nothing found** for 2024–2026 on large-array bugs specific to ANV, RADV or Intel Windows beyond the above. + **[Uncertain]** That could be a search gap. +- **Future direction.** VK_EXT_descriptor_heap is meant "to completely replace" descriptor sets. + https://www.khronos.org/blog/vulkan-introduces-roadmap-2026-and-new-descriptor-heap-extension + - NVIDIA 610+ drivers support it. + https://developer.nvidia.com/blog/streamlining-resource-binding-with-end-to-end-support-for-vulkan-descriptor-heaps/ + - DXVK requires NVIDIA 595.84+ for its heap path. https://github.com/doitsujin/dxvk/releases/tag/v3.0 + +--- + +## Implementation for this renderer + +The sourced basis is cited above; everything in this section is **[Inference]** built on it. + +**Set layout** + +- **Set 0** (normal pool, no update-after-bind): frame UBO as `UNIFORM_BUFFER_DYNAMIC` plus frame textures. + Dynamic buffers cannot be update-after-bind. +- **Set 1** (layout flag `UPDATE_AFTER_BIND_POOL`, pool flag `UPDATE_AFTER_BIND`): combined-image-sampler + arrays, one binding per GLSL sampled type, every binding `PARTIALLY_BOUND | UPDATE_AFTER_BIND`. Starting + sizes, all clamped against the device limits: + +| Binding | Type | Size | +|---|---|---| +| 0 | `sampler2D` | 16384 | +| 1 | `sampler2DArray` | 1024 | +| 2 | `samplerCube` | 256 | +| 3 | `sampler3D` | 256 | +| 4 | `usampler2D` | 1024 | +| 5 | `isampler2D` | 256 | +| 6 | `sampler2DShadow` | 128 | +| 7 | `sampler2DArrayShadow` | 64 | +| 8 (optional) | `samplerCubeShadow` | 64 | + +- The highest-numbered binding may also carry `VARIABLE_DESCRIPTOR_COUNT`; optional, since fixed sizes keep + the design simple and memory stays in the single-digit MB range. +- **Set 2**: storage buffers, update-after-bind (`descriptorBindingStorageBufferUpdateAfterBind`) if they are + rewritten while bound; otherwise a normal set. + +**Why combined arrays.** GL-style `sampler2D` ties sampler state to the texture, and compare mode is a texture +parameter in GL. Combined arrays keep that model, need one index per sampler uniform, and follow NVIDIA's +preference. Sampler objects stay de-duplicated in a sampler cache, well under 4000. Reserve a shared +`sampler[]` only for post-process or compute passes if needed; revisit separate arrays when moving to +descriptor heap. + +**Limits check at startup** + +- Sum all set-1 counts plus set-0 textures; require the sum ≤ `maxPerStageDescriptorUpdateAfterBindSampledImages` + and ≤ `maxDescriptorSetUpdateAfterBindSampledImages`. +- Same checks against the `*Samplers` limits, since combined descriptors count against both. +- `maxDescriptorSetUpdateAfterBindUniformBuffersDynamic` ≥ 1, because set 0 shares a pipeline layout with an + update-after-bind set. +- `maxPushConstantsSize` ≥ 128. + +**Features to enable** + +- `runtimeDescriptorArray`, `descriptorBindingPartiallyBound`, `descriptorBindingSampledImageUpdateAfterBind`, + and `descriptorBindingStorageBufferUpdateAfterBind` if set 2 uses it. +- `descriptorBindingVariableDescriptorCount` if used. +- `shaderSampledImageArrayDynamicIndexing` (1.0 feature). +- `shaderSampledImageArrayNonUniformIndexing` only if non-uniform paths exist. + +**Fallback tiers** + +1. No update-after-bind: fixed-size arrays in a normal pool; write only sets that no recording or executable + command buffer has bound (for example one set copy per frame in flight, each updated before recording). +2. No runtime arrays or partial binding: sized arrays filled entirely with placeholders. +3. None of these features: the current per-program layout path (decision 9 says such a device stays on + OpenGL instead; decide when implementing). + +All listed target devices report the required features. + +**Slot allocator and deferred free** + +- One allocator per binding, each a LIFO free-list of `uint` slots. Slot 0 is reserved for a placeholder: + 1×1 magenta for colour arrays, 1×1 depth with a compare sampler for shadow arrays, a 1×1 integer texture for + `usampler`/`isampler`. +- **Allocate:** pop a slot and queue the write. Flush all queued writes with one `vkUpdateDescriptorSets` per + frame on the render thread, before recording any draw that uses the index. Update-after-bind makes this + legal while set 1 is bound. +- **Free:** push `(binding, slot, view/image, retireSerial)` to a pending queue, where `retireSerial` is the last + frame or submission that could have used the slot. +- **Each frame start**, for entries whose serial the GPU has finished (the timeline semaphore already tracks + this): + 1. Write the placeholder into the slot. + 2. Destroy the view and image. + 3. Return the slot to the free-list. +- Never rewrite a live slot in place. A texture re-upload that changes the image gets a new slot, and the old + one retires. In this renderer, transient aliasing (`TextureManager.Rebind`/`RestoreBindings`) swaps the image + behind a texture id per frame, so a rebind must resolve to the physical texture's slot rather than + rewriting the client texture's slot. + +**Shaders** + +- Rewrite `uniform sampler2D name;` to a field `uint name_idx;` in a push-constant block (or a per-draw record + when a program exceeds its push-constant budget). +- Rewrite `texture(name, uv)` to `texture(uTex2D[pc.name_idx], uv)`, and the equivalent for other types. +- Shared declarations: `#extension GL_EXT_nonuniform_qualifier : require` and + `layout(set=1,binding=0) uniform sampler2D uTex2D[];` etc. +- No `nonuniformEXT` for these per-draw indices. Only where an index comes from per-vertex, per-instance or + per-pixel data, and there prefer explicit-LOD or gradient sampling. +- GLSL 330 constant-indexed sampler arrays (`uniform sampler2D a[4]`) become one index per element. + **[Uncertain]** GLSL 3.30 was assumed to allow only constant indices on sampler arrays; not re-sourced. + +**Validation plan** + +- CPU validation layer always on in debug runs. +- `spirv-val` on every rewritten shader; assert with `spirv-dis` that no unexpected `NonUniform` appears and that + any intended one sits on the sampled-image operand. +- Dedicated GPU-AV runs with `gpuav_enable`, `gpuav_descriptor_checks` and + `gpuav_post_process_descriptor_indexing`, using `gpuav_select_instrumented_shaders` to limit cost. +- Engine-side debug asserts, because the layers do not check these: + - Dref usage matches `compareEnable`. + - Image view type matches the destination binding. + - Integer formats go only to `usampler`/`isampler` bindings with nearest samplers. +- Smoke tests on NVIDIA, AMD Windows, Intel Windows (Arc 140V), ANV and RADV. diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index b67260eb..9f77f64b 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -3,7 +3,7 @@ Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-15 at 11195c5. - Plan of record: `docs/vulkan-native-plan.md` (decisions 1-9, phases, risks). -- Research the designs follow: `docs/research/` (caching, descriptor model, XeGTAO, validation). +- Research the designs follow: `docs/research/` (caching, descriptor model, bindless, XeGTAO, validation). - Acceptance procedures: `docs/vulkan-acceptance.md`, `docs/taa-acceptance.md`, `docs/temporal-frame-contract.md`. - Older planning documents still in the tree: `VULKAN-BACKEND-PLAN.md`, `TAA-PLAN.md` (history; the plan of record supersedes them where they disagree). @@ -156,12 +156,13 @@ before window release was already correct. that transitions the swapchain image to PRESENT_SRC, with one render-finished semaphore per swapchain image indexed by the acquired image (`docs/research/vulkan-validation.md` §4; Vulkan Guide "Swapchain Semaphore Reuse"). Exit: the five tests pass with the layer; the rest of the suite is unchanged. -2. **Bindless implementation research** (was running, did not finish): write `docs/research/vulkan-bindless.md`. - Questions: separate `texture2D[]` + `sampler[]` vs combined arrays; shadow, array, cube and integer - samplers; layout and pool flags (partially bound, update-after-bind, variable count); required features - and limits per vendor incl. Intel iGPU; slot lifetime with frames in flight (ids recycle with deferred - deletion, transient aliasing rebinds ids per frame); when `nonuniformEXT` is required; driver quirks - 2024-2026; a concrete recommendation. +2. **Bindless implementation research: done** (`docs/research/vulkan-bindless.md`). Design outcome: combined-image-sampler + arrays in set 1, one binding per GLSL sampled type (2D, 2DArray, Cube, 3D, usampler2D, isampler2D, the shadow + variants), `PARTIALLY_BOUND | UPDATE_AFTER_BIND`, slot 0 a placeholder per type; per-draw indices in push + constants (no `nonuniformEXT`); slot writes batched once per frame; frees deferred until the timeline says + the frames that could sample them finished; set 0 stays a normal set (dynamic UBOs cannot be + update-after-bind); limit checks on the update-after-bind sampled-image/sampler counts, dynamic UBOs and + push-constant size; transient aliasing must resolve to the physical texture's slot. 3. **Phase 3 groundwork on decision 9:** one global pipeline layout (set 0 frame UBO + frame textures, set 1 bindless textures + shared samplers, set 2 storage, push constants <= 128 B); descriptor-indexing feature and limit check at startup (without it the session stays on OpenGL); `bindings.glsl` + From 1a2212865f0eeca535263e2a97123405996f468f Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 20:34:20 +0200 Subject: [PATCH 142/226] docs(research): how the ANV bindless limits derive from its descriptor pools --- docs/research/vulkan-bindless.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/research/vulkan-bindless.md b/docs/research/vulkan-bindless.md index 226133c5..4c8555cf 100644 --- a/docs/research/vulkan-bindless.md +++ b/docs/research/vulkan-bindless.md @@ -158,6 +158,22 @@ and `platform=linux`): - **Pre-Skylake Intel**: the 240-entry binding table is a hardware limit there. https://gfxstrand.net/faith/blog/2022/08/descriptors-are-hard/ +**How the ANV limits derive** + +- **Pre-Gfx12.5 image limit (confirmed from source):** `struct anv_address_range_descriptor` is a `uint64_t + address` plus two `uint32_t` fields, 16 bytes. The indirect descriptor pool is 3 GiB (`anv_va.c`), and 3 GiB / + 16 B = 201,326,592, exactly what gpuinfo shows for Skylake through Tiger/Alder Lake on Linux. + https://gitlab.freedesktop.org/mesa/mesa/-/blob/main/src/intel/vulkan/anv_private.h , + https://gitlab.freedesktop.org/mesa/mesa/-/blob/main/src/intel/vulkan/anv_va.c +- **Pre-Gfx12.5 sampler limit [Inference]:** `struct anv_sampled_image_descriptor` starts with a `uint32_t image` + field holding a 20-bit SURFACE_STATE index; if the struct is 8 bytes, 3 GiB / 8 B = 402,653,184, matching the + reported sampler limit. The rest of the struct was not read. +- **Gfx12.5+ (DG2, MTL, ARL, BMG, Lunar Lake 140V) [Inference]:** 33,554,432 images equals a 2 GiB bindless + surface-state pool divided by a 64-byte surface state; 67,108,864 samplers would mean a 32-byte sampler state. + The `ANV_SURFACE_STATE_SIZE` / `ANV_SAMPLER_STATE_SIZE` defines were not found. +- None of this changes the recommendation: every Intel target reports tens of millions of update-after-bind + sampled-image descriptors, so array size is bounded by memory and write cost, not by the driver. + **Other limits that matter** - `maxSamplerAllocationCount`: 4000 on NVIDIA and Intel Windows (including the 140V); 1,048,576 on AMD From 99b836d7c380b9926e8c2532a95d2237645e8670 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 21:07:18 +0200 Subject: [PATCH 143/226] fix(tests): compare the SSAO override with the vanilla shader from the client archive The comparison read assets/game/shaders/ssao.fsh from the deployed client directory, which make deploy overwrites with the override itself. A deployed checkout therefore compared the override against a copy that already carried the TAAMOTION blocks and failed for a reason unrelated to the shader. VanillaShaderArchive reads the pristine file from the client archive instead. Verified: Optimum.Tests 1177 passed, 34 skipped, 0 failed. --- .../ssao-temporal-dither-coverage-tests.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs b/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs index 29df609f..8e7e6e78 100644 --- a/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs +++ b/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs @@ -65,13 +65,15 @@ public void TheFrameVaryingTermIsGatedOnTheTemporalPipeline() [Fact] public void WithoutATemporalConsumerTheOverrideIsTheVanillaShader() { - string vanillaPath = Path.Combine( - Root(), ".vanilla", "win-x64", "vintagestory", "assets", "game", "shaders", "ssao.fsh"); - // The vanilla shaders are proprietary and never committed: a checkout that - // has not bootstrapped has nothing to compare against. - if (!File.Exists(vanillaPath)) return; + // Read the pristine shader from the client archive, never from the deployed + // client directory: `make deploy` copies our own overrides in there, so a + // deployed checkout compared the override against itself - and against a + // copy that already carried the TAAMOTION blocks, which fails for the wrong + // reason. The vanilla shaders are proprietary and never committed, so a + // checkout without the archive has nothing to compare against. + string? vanilla = VanillaShaderArchive.TryRead("assets/game/shaders/ssao.fsh"); + if (vanilla == null) return; - string vanilla = File.ReadAllText(vanillaPath); string preprocessed = StripTaaMotionBlocks(Read("sources/shaders/ssao.fsh")); Assert.Equal(vanilla.Replace("\r\n", "\n"), preprocessed.Replace("\r\n", "\n")); } From b8bdef2349301025ef6f74a1f92026d0e767ea35 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 21:07:56 +0200 Subject: [PATCH 144/226] docs: Linux test state for the Vulkan branch, and why item 1 does not reproduce here Same validation layer version as the Windows run. With every implicit overlay layer switched off, and validation shown to be live by the suite's own positive control, the GPU suite passes 661 of 661 with no synchronization messages; the five present-after-write tests pass, with and without the overlays. Records the environment every run needs on this machine and what the Windows recheck has to rule out. --- docs/vulkan-branch-progress.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 9f77f64b..ce7c19dd 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -150,6 +150,33 @@ before window release was already correct. They were hidden before because no layer was installed. - Optimum.Tests: 5 host-environment failures (pacing gate x2, numpy self-tests x2, `_ref/` not materialised). +### Test state (Linux notebook, 2026-09-15, at 99b836d) + +RTX 4070 Laptop, driver 615.71.09, X11 (XWayland), Vulkan SDK layers 1.4.357.0 - the same layer version as the +Windows run above. + +- **Implicit layers switched off for every run**, confirmed with `VK_LOADER_DEBUG=layer`: MangoHud is enabled + globally on this machine and `VK_LAYER_LS_frame_generation` has no enable variable, so both would otherwise hook + the test host and draw or present on the swapchain. Only `VK_LAYER_MESA_device_select` stays (it orders + devices). Environment: + `MANGOHUD=0 DISABLE_MANGOHUD=1 DISABLE_LSFG=1 DISABLE_VK_LAYER_VALVE_steam_overlay_1=1 DISABLE_VK_LAYER_VALVE_steam_fossilize_1=1 DISABLE_GAMESCOPE_WSI=1 DISABLE_VULKAN_RENDERDOC_CAPTURE_1_45=1 DISABLE_LAYER_MESA_ANTI_LAG=1`. +- **Validation is live in the suite**, not assumed: the loader inserts `VK_LAYER_KHRONOS_validation` into the test + process, `ValidationFeaturesTests` asserts the layer settings were applied, and + `SyncValidationControlTests.AnUnsynchronisedWriteAfterWriteIsReportedUnderASyncId` provokes a hazard and passes + only because sync validation reports it. +- GPU suite: **661 passed, 0 failed, 0 skipped, no `SYNC-` messages.** The five present-after-write tests listed + above pass here, and they also pass with MangoHud and the frame-generation layer switched back on. +- Optimum.Tests: 1177 passed, 34 skipped, 0 failed. The one failure before `99b836d` was + `SsaoTemporalDitherCoverageTests.WithoutATemporalConsumerTheOverrideIsTheVanillaShader` reading the deployed, + override-carrying copy of `ssao.fsh`; it now reads the client archive. + +**Item 1 status:** the present-after-write hazard does not reproduce on this machine with the identical layer +version, so it is specific to the Windows run: its driver, its present path, or an implicit layer installed there +(overlays such as the NVIDIA, Steam or RTSS ones hook the same way). Recheck on the Windows machine with every +implicit layer disabled before changing the present path; the present semaphores already follow the Vulkan Guide's +per-image pattern (`SwapchainSlot.PresentSemaphoreFor(imageIndex)`, signalled by the submission that moves the image +to `PRESENT_SRC`). + ### Next, in order 1. **Fix the present-after-write hazard.** The present has to wait on a semaphore signalled by the submit From 2cb4f8170a7224233b212864af6653fb74a502e5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 21:31:27 +0200 Subject: [PATCH 145/226] docs: the present-after-write hazard is Pascal-specific - reproduction matrix, layer mechanism, what the GTX 1060 run must capture --- docs/vulkan-branch-progress.md | 41 ++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index ce7c19dd..50ca98cb 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -137,6 +137,8 @@ before window release was already correct. ### Test state (Windows machine, 2026-09-15) +GeForce GTX 1060 (Pascal) on Windows. Pascal stays on NVIDIA's 580 driver branch; 590 and later dropped it. + - GPU suite with the Vulkan SDK 1.4.357.0 validation layer: 661 tests, 6 failures. - `PacingStatsTests.PacingGateReadsTheLinesThisBackendWrites`: host issue (WSL path translation), not a renderer defect. @@ -170,19 +172,44 @@ Windows run above. `SsaoTemporalDitherCoverageTests.WithoutATemporalConsumerTheOverrideIsTheVanillaShader` reading the deployed, override-carrying copy of `ssao.fsh`; it now reads the client archive. -**Item 1 status:** the present-after-write hazard does not reproduce on this machine with the identical layer -version, so it is specific to the Windows run: its driver, its present path, or an implicit layer installed there -(overlays such as the NVIDIA, Steam or RTSS ones hook the same way). Recheck on the Windows machine with every -implicit layer disabled before changing the present path; the present semaphores already follow the Vulkan Guide's -per-image pattern (`SwapchainSlot.PresentSemaphoreFor(imageIndex)`, signalled by the submission that moves the image -to `PRESENT_SRC`). +**Item 1 status: does not reproduce here; the failing hardware is Pascal on the 580 driver branch.** + +- Reproduction matrix on this notebook, the five tests only, implicit layers off, layers 1.4.357.0: RTX 4070 on + Wayland and on X11 (GLFW platform chosen by unsetting `DISPLAY` or `WAYLAND_DISPLAY`; setting them to an empty + string makes GLFW fail and every test skips), and the Intel UHD iGPU (Mesa ANV, selected with + `VK_LOADER_DRIVERS_SELECT=*intel*`; `MESA_VK_DEVICE_SELECT` alone still hands the tests the NVIDIA device) on + Wayland and on X11. **5/5 passed in all four, zero `SYNC-` messages.** The present path has not changed since + the Windows run (no commits under `Present/`, `FrameRing.cs` or `VulkanDevice.cs` after `11195c5`). +- The two machines that failed share an architecture: the Windows GTX 1060 above, and the upstream reviewer's + MX150 on Linux (driver 580.173.02), whose resize-loop tests also failed (reported as "swapchain fence signaling + races" in the PR 69 review). Both are Pascal, and Pascal is frozen on the 580 branch, so the driver's + acquire/present behaviour (image index order, SUBOPTIMAL/OUT_OF_DATE results, image counts, present modes) is + the variable this notebook cannot vary. +- What the layer needs to report it (`layers/sync/sync_submit.cpp`, `QueueBatchContext::ResolvePresentSemaphoreWait` + and `DoQueuePresentValidate`): a present on the same queue imports the batch that signalled its wait semaphore + through a barrier, and everything else from the queue's last batch without one. `SYNC-HAZARD-PRESENT-AFTER-WRITE` + therefore means the image's last layout transition reached the present by the unbarriered route: the wait + resolved against a different batch than the one that transitioned the image, or the layer found no signal for the + semaphore. Our signal stage is not the cause: `VkSubmitInfo` signal semaphores are converted to `ALL_COMMANDS` + (`layers/utils/convert_utils.cpp`), which covers layout transitions since KhronosGroup/Vulkan-ValidationLayers#7479. + Two layer facts to check against the full message: `PreCallRecordDestroySemaphore` erases pending timeline signals + but not pending binary ones, and an acquire records its semaphore's signal with `emplace`, which ignores an entry + already present for the same handle. +- **Needed from the Windows machine before any code change:** the complete text of one failure. The assertion prints + the first full layer message per hazard id (`ValidationAssert.NoSyncHazards`, "first message"), which names the + prior access: command buffer, submit index, batch tag and command. Run the five tests alone + (`dotnet test Optimum.Render.Vulkan.Tests --filter --logger "console;verbosity=detailed"`) with the + Windows implicit layers disabled, and record the driver version and `vulkaninfo --summary` (present modes, + image counts) next to it. ### Next, in order 1. **Fix the present-after-write hazard.** The present has to wait on a semaphore signalled by the submit that transitions the swapchain image to PRESENT_SRC, with one render-finished semaphore per swapchain image indexed by the acquired image (`docs/research/vulkan-validation.md` §4; Vulkan Guide "Swapchain - Semaphore Reuse"). Exit: the five tests pass with the layer; the rest of the suite is unchanged. + Semaphore Reuse"). That pattern is already in place; the hazard is Pascal/580-specific and waits on the full + message from the GTX 1060 (see "Item 1 status"). Exit: the five tests pass with the layer on Pascal; the rest + of the suite is unchanged. 2. **Bindless implementation research: done** (`docs/research/vulkan-bindless.md`). Design outcome: combined-image-sampler arrays in set 1, one binding per GLSL sampled type (2D, 2DArray, Cube, 3D, usampler2D, isampler2D, the shadow variants), `PARTIALLY_BOUND | UPDATE_AFTER_BIND`, slot 0 a placeholder per type; per-draw indices in push From 5764ba6136e51a9396b3f5f8f1d891f560040463 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 21:47:48 +0200 Subject: [PATCH 146/226] feat(vulkan): descriptor-indexing device floor and the decision-9 set convention A device without partially bound, update-after-bind sampled-image arrays, dynamic sampled-image indexing or the matching limits is rejected with the named reason and the session stays on OpenGL; the features are enabled and reported on the device-up line. bindings.glsl is the set and binding source of truth for native shaders and SetConvention.cs mirrors it. Verified: GPU suite 673/673 with sync,best validation and no SYNC- message on the RTX 4070; SetConventionTests and BindlessCapabilityTests 19/19, including a decision-9 layout created and allocated without a validation message. --- .../BindlessCapabilityTests.cs | 234 ++++++++++++++++++ .../SetConventionTests.cs | 188 ++++++++++++++ .../Core/DescriptorIndexingFloor.cs | 83 +++++++ Optimum.Render.Vulkan/Core/VulkanContext.cs | 45 ++++ .../Shaders/SetConvention.cs | 89 +++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 6 +- docs/vulkan-branch-progress.md | 11 + sources/shaders-vk/include/bindings.glsl | 80 ++++++ 8 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SetConventionTests.cs create mode 100644 Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs create mode 100644 Optimum.Render.Vulkan/Shaders/SetConvention.cs create mode 100644 sources/shaders-vk/include/bindings.glsl diff --git a/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs b/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs new file mode 100644 index 00000000..283dbe7a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs @@ -0,0 +1,234 @@ +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Plan decision 9's device floor: one pipeline layout with a bindless set of +/// partially bound, update-after-bind combined-image-sampler arrays. The floor is +/// judged without a device; the selected device is then shown to enable it, with a +/// layout of the decided shape passing validation. +/// +public class BindlessCapabilityTests +{ + private readonly ITestOutputHelper _output; + + public BindlessCapabilityTests(ITestOutputHelper output) => _output = output; + + private static DescriptorIndexingSupport AtFloor() => new( + RuntimeDescriptorArray: true, + DescriptorBindingPartiallyBound: true, + DescriptorBindingSampledImageUpdateAfterBind: true, + ShaderSampledImageArrayDynamicIndexing: true, + MaxPerStageDescriptorUpdateAfterBindSampledImages: DescriptorIndexingFloor.RequiredSampledImages, + MaxPerStageDescriptorUpdateAfterBindSamplers: DescriptorIndexingFloor.RequiredSampledImages, + MaxDescriptorSetUpdateAfterBindSampledImages: DescriptorIndexingFloor.RequiredSampledImages, + MaxDescriptorSetUpdateAfterBindSamplers: DescriptorIndexingFloor.RequiredSampledImages, + MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic: 1, + MaxPushConstantsSize: DescriptorIndexingFloor.RequiredPushConstantBytes); + + [Fact] + public void TheSampledImageRequirementIsTheResearchedSetOneTablePlusTheFrameTextures() + { + Assert.Equal(19456u, DescriptorIndexingFloor.BindlessSampledImages); + Assert.Equal(19472u, DescriptorIndexingFloor.RequiredSampledImages); + Assert.Equal(128u, DescriptorIndexingFloor.RequiredPushConstantBytes); + } + + [Fact] + public void ADeviceExactlyAtTheFloorMeetsIt() + { + Assert.Empty(DescriptorIndexingFloor.Missing(AtFloor())); + } + + [Fact] + public void EachMissingFeatureIsNamedOnItsOwn() + { + Assert.Equal(new[] { "runtimeDescriptorArray" }, + DescriptorIndexingFloor.Missing(AtFloor() with { RuntimeDescriptorArray = false })); + Assert.Equal(new[] { "descriptorBindingPartiallyBound" }, + DescriptorIndexingFloor.Missing(AtFloor() with { DescriptorBindingPartiallyBound = false })); + Assert.Equal(new[] { "descriptorBindingSampledImageUpdateAfterBind" }, + DescriptorIndexingFloor.Missing(AtFloor() with { DescriptorBindingSampledImageUpdateAfterBind = false })); + Assert.Equal(new[] { "shaderSampledImageArrayDynamicIndexing" }, + DescriptorIndexingFloor.Missing(AtFloor() with { ShaderSampledImageArrayDynamicIndexing = false })); + } + + [Fact] + public void EachLimitBelowTheFloorIsNamedWithTheReportedAndRequiredValue() + { + uint below = DescriptorIndexingFloor.RequiredSampledImages - 1; + string required = DescriptorIndexingFloor.RequiredSampledImages.ToString(); + + AssertSingle(AtFloor() with { MaxPerStageDescriptorUpdateAfterBindSampledImages = below }, + "maxPerStageDescriptorUpdateAfterBindSampledImages", below, required); + AssertSingle(AtFloor() with { MaxPerStageDescriptorUpdateAfterBindSamplers = below }, + "maxPerStageDescriptorUpdateAfterBindSamplers", below, required); + AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindSampledImages = below }, + "maxDescriptorSetUpdateAfterBindSampledImages", below, required); + AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindSamplers = below }, + "maxDescriptorSetUpdateAfterBindSamplers", below, required); + AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic = 0 }, + "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", 0, "1"); + AssertSingle(AtFloor() with { MaxPushConstantsSize = 64 }, "maxPushConstantsSize", 64, "128"); + } + + /// + /// The per-stage update-after-bind sampled-image limits recorded for every target + /// family in docs/research/vulkan-bindless.md (NVIDIA, AMD Windows, Intel Windows, + /// RADV, ANV 12.5+, ANV pre-12.5, llvmpipe, SwiftShader): the floor excludes none. + /// + [Theory] + [InlineData(1_048_576u)] + [InlineData(4_294_967_295u)] + [InlineData(33_554_432u)] + [InlineData(8_388_606u)] + [InlineData(201_326_592u)] + [InlineData(1_000_000u)] + [InlineData(500_000u)] + public void NoResearchedTargetFallsBelowTheSampledImageFloor(uint reported) + { + Assert.Empty(DescriptorIndexingFloor.Missing(AtFloor() with + { + MaxPerStageDescriptorUpdateAfterBindSampledImages = reported, + MaxPerStageDescriptorUpdateAfterBindSamplers = reported, + MaxDescriptorSetUpdateAfterBindSampledImages = reported, + MaxDescriptorSetUpdateAfterBindSamplers = reported, + })); + } + + /// + /// The selected device meets the floor, and a pipeline layout of decision 9's + /// shape - set 0 with a dynamic frame UBO and frame textures, set 1 the bindless + /// array flagged PARTIALLY_BOUND | UPDATE_AFTER_BIND from an update-after-bind pool, + /// a 128-byte push-constant range - is created and allocated with no validation + /// message. The layer reports the binding flags and the pool flag as errors unless + /// the device enabled the matching features, so this is also the proof they are on. + /// + [SkippableFact] + public unsafe void TheSelectedDeviceEnablesTheBindlessSetAndADecisionNineLayoutValidates() + { + var messages = new List(); + Skip.IfNot(GpuTest.TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + Vk api = context!.Api; + Device device = context.Device; + DescriptorIndexingSupport support = context.Capabilities.DescriptorIndexing; + _output.WriteLine($"device: {context.Capabilities.DeviceName}"); + _output.WriteLine($"support: {support}"); + Assert.Empty(DescriptorIndexingFloor.Missing(support)); + + const ShaderStageFlags stages = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit; + + DescriptorBindingFlags bindlessFlags = DescriptorBindingFlags.PartiallyBoundBit | DescriptorBindingFlags.UpdateAfterBindBit; + var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo + { + SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo, + BindingCount = 1, + PBindingFlags = &bindlessFlags, + }; + var textures = new DescriptorSetLayoutBinding + { + Binding = 0, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = DescriptorIndexingFloor.BindlessSampledImages, + StageFlags = stages, + }; + var bindlessInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + PNext = &bindingFlags, + Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit, + BindingCount = 1, + PBindings = &textures, + }; + DescriptorSetLayout bindless; + Assert.Equal(Result.Success, api.CreateDescriptorSetLayout(device, &bindlessInfo, null, &bindless)); + + DescriptorSetLayoutBinding* frameBindings = stackalloc DescriptorSetLayoutBinding[2]; + frameBindings[0] = new DescriptorSetLayoutBinding + { + Binding = 0, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = stages, + }; + frameBindings[1] = new DescriptorSetLayoutBinding + { + Binding = 1, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = DescriptorIndexingFloor.FrameTextures, + StageFlags = stages, + }; + var frameInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 2, + PBindings = frameBindings, + }; + DescriptorSetLayout frame; + Assert.Equal(Result.Success, api.CreateDescriptorSetLayout(device, &frameInfo, null, &frame)); + + DescriptorSetLayout* setLayouts = stackalloc DescriptorSetLayout[2]; + setLayouts[0] = frame; + setLayouts[1] = bindless; + var pushConstants = new PushConstantRange + { + StageFlags = stages, + Offset = 0, + Size = DescriptorIndexingFloor.RequiredPushConstantBytes, + }; + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 2, + PSetLayouts = setLayouts, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstants, + }; + PipelineLayout pipelineLayout; + Assert.Equal(Result.Success, api.CreatePipelineLayout(device, &layoutInfo, null, &pipelineLayout)); + + var poolSize = new DescriptorPoolSize(DescriptorType.CombinedImageSampler, DescriptorIndexingFloor.BindlessSampledImages); + var poolInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + Flags = DescriptorPoolCreateFlags.UpdateAfterBindBit, + MaxSets = 1, + PoolSizeCount = 1, + PPoolSizes = &poolSize, + }; + DescriptorPool pool; + Assert.Equal(Result.Success, api.CreateDescriptorPool(device, &poolInfo, null, &pool)); + + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = pool, + DescriptorSetCount = 1, + PSetLayouts = &bindless, + }; + DescriptorSet set; + Assert.Equal(Result.Success, api.AllocateDescriptorSets(device, &allocateInfo, &set)); + Assert.NotEqual(0ul, set.Handle); + + api.DestroyDescriptorPool(device, pool, null); + api.DestroyPipelineLayout(device, pipelineLayout, null); + api.DestroyDescriptorSetLayout(device, frame, null); + api.DestroyDescriptorSetLayout(device, bindless, null); + } + + foreach (string message in ValidationAssert.Snapshot(messages)) _output.WriteLine("[validation] " + message); + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + + private static void AssertSingle(DescriptorIndexingSupport support, string name, uint reported, string required) + { + Assert.Equal(new[] { $"{name} {reported} < {required}" }, DescriptorIndexingFloor.Missing(support)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SetConventionTests.cs b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs new file mode 100644 index 00000000..93ac9338 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Plan decision 9's set convention exists twice: sources/shaders-vk/include/bindings.glsl +/// for native shaders and for the renderer. A drift +/// between them writes a texture or buffer into a binding the shader never reads, +/// which renders black or placeholder magenta rather than failing, so they are +/// compared define by define and declaration by declaration. +/// +public class SetConventionTests +{ + private static readonly Regex Define = new(@"^\s*#define\s+(OPTIMUM_[A-Z0-9_]+)\s+(\d+)\s*$", RegexOptions.Multiline); + + private static readonly Regex Declaration = new( + @"^\s*layout\(set = (\w+), binding = (\w+)\) uniform (\w+) (\w+)(\[\])?;\s*$", RegexOptions.Multiline); + + [Fact] + public void EveryDefineInTheIncludeHasTheValueTheRendererUses() + { + var expected = new SortedDictionary(StringComparer.Ordinal) + { + ["OPTIMUM_SET_FRAME"] = SetConvention.FrameSet, + ["OPTIMUM_SET_TEXTURES"] = SetConvention.TextureSet, + ["OPTIMUM_SET_STORAGE"] = SetConvention.StorageSet, + ["OPTIMUM_PUSH_CONSTANT_BYTES"] = (int)SetConvention.PushConstantBytes, + ["OPTIMUM_BINDING_FRAME_GLOBALS"] = SetConvention.FrameGlobalsBinding, + }; + foreach (SetConvention.Binding binding in SetConvention.FrameTextures) expected[binding.Define] = binding.Value; + foreach (SetConvention.Binding binding in SetConvention.StorageBuffers) expected[binding.Define] = binding.Value; + foreach (SetConvention.Binding binding in SetConvention.TextureArrays) + { + expected[binding.Define] = binding.Value; + expected[CapacityDefine(binding)] = (int)binding.Capacity; + } + + Assert.Equal(expected, Defines()); + } + + [Fact] + public void EverySamplerDeclarationSitsAtTheSetBindingAndTypeTheRendererWrites() + { + Dictionary defines = Defines(); + var declared = new List(); + foreach (Match match in Declaration.Matches(ReadInclude())) + { + declared.Add(Describe(defines[match.Groups[1].Value], defines[match.Groups[2].Value], + match.Groups[3].Value, match.Groups[4].Value, match.Groups[5].Success)); + } + + var expected = new List(); + foreach (SetConvention.Binding binding in SetConvention.FrameTextures) + { + expected.Add(Describe(SetConvention.FrameSet, binding.Value, binding.GlslType, binding.Name, runtimeArray: false)); + } + foreach (SetConvention.Binding binding in SetConvention.TextureArrays) + { + expected.Add(Describe(SetConvention.TextureSet, binding.Value, binding.GlslType, binding.Name, runtimeArray: true)); + } + + Assert.Equal(expected, declared); + } + + [Fact] + public void TheConventionAgreesWithTheFrameBlockAndTheDeviceFloor() + { + Assert.Equal(FrameGlobals.Set, SetConvention.FrameSet); + Assert.Equal(FrameGlobals.Binding, SetConvention.FrameGlobalsBinding); + + Assert.Equal(SumOfCapacities(SetConvention.TextureArrays), SetConvention.TextureArrayCapacityTotal); + Assert.Equal(SetConvention.TextureArrayCapacityTotal, DescriptorIndexingFloor.BindlessSampledImages); + Assert.True(SetConvention.FrameTextures.Length <= DescriptorIndexingFloor.FrameTextures, + "the device floor's frame-texture headroom must cover set 0's textures"); + Assert.Equal(SetConvention.PushConstantBytes, DescriptorIndexingFloor.RequiredPushConstantBytes); + } + + [Fact] + public void BindingsAreUniqueWithinEachSet() + { + AssertUnique(SetConvention.FrameGlobalsBinding, SetConvention.FrameTextures); + AssertUnique(null, SetConvention.TextureArrays); + AssertUnique(null, SetConvention.StorageBuffers); + } + + /// + /// The include is real GLSL: a fragment shader that includes it and samples a + /// frame texture and a bindless array element compiles to SPIR-V. + /// + [SkippableFact] + public void TheIncludeCompilesIntoAFragmentShaderThatSamplesBothSets() + { + string source = "#version 450\n" + ReadInclude() + """ + + layout(location = 0) out vec4 outColor; + void main() + { + float lit = texture(shadowMapFar, vec3(0.5, 0.5, 0.5)); + outColor = texture(optimumTextures2D[0], vec2(0.5)) * lit + texture(sky, vec2(0.5)); + } + """; + + ShaderCompiler compiler; + try + { + compiler = new ShaderCompiler(); + } + catch (Exception error) when (error is DllNotFoundException or InvalidOperationException) + { + Skip.If(true, "shaderc unavailable: " + error.Message); + return; + } + + using (compiler) + { + ShaderCompileResult result = compiler.Compile(source, "bindings-probe.frag", EnumShaderType.FragmentShader); + Assert.True(result.Success, result.Error); + Assert.NotEmpty(result.Spirv); + } + } + + /// + /// Native sources use .vert/.frag and includes; a .vsh or .fsh there would be + /// picked up by the packagers' sources/shaders globs as a vanilla override. + /// + [Fact] + public void TheNativeShaderTreeHoldsNoGameShaderExtensions() + { + string tree = Path.Combine(Root(), "sources", "shaders-vk"); + Assert.True(Directory.Exists(tree), tree + " missing"); + foreach (string file in Directory.EnumerateFiles(tree, "*", SearchOption.AllDirectories)) + { + string extension = Path.GetExtension(file); + Assert.False(extension is ".vsh" or ".fsh", file + " uses a game shader extension"); + } + } + + private static string CapacityDefine(SetConvention.Binding binding) => + binding.Define.Replace("OPTIMUM_BINDING_", "OPTIMUM_CAPACITY_", StringComparison.Ordinal); + + private static string Describe(int set, int binding, string type, string name, bool runtimeArray) => + $"set {set} binding {binding}: {type} {name}{(runtimeArray ? "[]" : "")}"; + + private static uint SumOfCapacities(SetConvention.Binding[] bindings) + { + uint sum = 0; + foreach (SetConvention.Binding binding in bindings) sum += binding.Capacity; + return sum; + } + + private static void AssertUnique(int? reserved, SetConvention.Binding[] bindings) + { + var seen = new HashSet(); + if (reserved != null) seen.Add(reserved.Value); + foreach (SetConvention.Binding binding in bindings) + { + Assert.True(seen.Add(binding.Value), $"{binding.Define} reuses binding {binding.Value}"); + } + } + + private static Dictionary Defines() + { + var defines = new Dictionary(StringComparer.Ordinal); + foreach (Match match in Define.Matches(ReadInclude())) + { + Assert.True(defines.TryAdd(match.Groups[1].Value, int.Parse(match.Groups[2].Value)), + match.Groups[1].Value + " is defined twice"); + } + return defines; + } + + private static string ReadInclude() => + File.ReadAllText(Path.Combine(Root(), SetConvention.IncludePath)).Replace("\r\n", "\n"); + + private static string Root() + { + string root = Directory.GetCurrentDirectory(); + while (!Directory.Exists(Path.Combine(root, "Optimum.Patcher"))) root = Directory.GetParent(root)!.FullName; + return root; + } +} diff --git a/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs b/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs new file mode 100644 index 00000000..418b941b --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// What a device reports for the bindless texture model of plan decision 9, read +/// from VkPhysicalDeviceVulkan12Features/Properties and the 1.0 features and limits. +/// +internal readonly record struct DescriptorIndexingSupport( + bool RuntimeDescriptorArray, + bool DescriptorBindingPartiallyBound, + bool DescriptorBindingSampledImageUpdateAfterBind, + bool ShaderSampledImageArrayDynamicIndexing, + uint MaxPerStageDescriptorUpdateAfterBindSampledImages, + uint MaxPerStageDescriptorUpdateAfterBindSamplers, + uint MaxDescriptorSetUpdateAfterBindSampledImages, + uint MaxDescriptorSetUpdateAfterBindSamplers, + uint MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic, + uint MaxPushConstantsSize); + +/// +/// The device floor for decision 9: one pipeline layout whose set 1 holds +/// combined-image-sampler arrays with PARTIALLY_BOUND | UPDATE_AFTER_BIND, indexed +/// per draw from push constants (docs/research/vulkan-bindless.md, "Limits check at +/// startup" and "Features to enable"). A device below it is not used for Vulkan at +/// all - the session stays on OpenGL - rather than running a second, per-program +/// layout path. +/// +internal static class DescriptorIndexingFloor +{ + /// + /// Sum of the set-1 array capacities (, the + /// starting sizes in docs/research/vulkan-bindless.md "Implementation for this + /// renderer"). + /// + public const uint BindlessSampledImages = Shaders.SetConvention.TextureArrayCapacityTotal; + + /// Set 0's fixed frame textures, with headroom (the plan names five). + public const uint FrameTextures = 16; + + /// + /// Combined image samplers count against both the sampled-image and the sampler + /// limits, and the per-stage update-after-bind limits count every set in the + /// layout, set 0 included. + /// + public const uint RequiredSampledImages = BindlessSampledImages + FrameTextures; + + /// The spec minimum, and the budget decision 9 gives per-draw indices and scalars. + public const uint RequiredPushConstantBytes = 128; + + /// + /// Every requirement the device misses, as the feature or limit name the spec uses + /// (limits with the reported and the required value). Empty when the floor is met. + /// + public static List Missing(in DescriptorIndexingSupport support) + { + var missing = new List(); + if (!support.RuntimeDescriptorArray) missing.Add("runtimeDescriptorArray"); + if (!support.DescriptorBindingPartiallyBound) missing.Add("descriptorBindingPartiallyBound"); + if (!support.DescriptorBindingSampledImageUpdateAfterBind) missing.Add("descriptorBindingSampledImageUpdateAfterBind"); + // A per-draw index from a push constant is dynamic but uniform over the draw. + if (!support.ShaderSampledImageArrayDynamicIndexing) missing.Add("shaderSampledImageArrayDynamicIndexing"); + + AtLeast(missing, "maxPerStageDescriptorUpdateAfterBindSampledImages", + support.MaxPerStageDescriptorUpdateAfterBindSampledImages, RequiredSampledImages); + AtLeast(missing, "maxPerStageDescriptorUpdateAfterBindSamplers", + support.MaxPerStageDescriptorUpdateAfterBindSamplers, RequiredSampledImages); + AtLeast(missing, "maxDescriptorSetUpdateAfterBindSampledImages", + support.MaxDescriptorSetUpdateAfterBindSampledImages, RequiredSampledImages); + AtLeast(missing, "maxDescriptorSetUpdateAfterBindSamplers", + support.MaxDescriptorSetUpdateAfterBindSamplers, RequiredSampledImages); + // Set 0's frame UBO is dynamic and shares the layout with the update-after-bind set. + AtLeast(missing, "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", + support.MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic, 1); + AtLeast(missing, "maxPushConstantsSize", support.MaxPushConstantsSize, RequiredPushConstantBytes); + return missing; + } + + private static void AtLeast(List missing, string name, uint reported, uint required) + { + if (reported < required) missing.Add($"{name} {reported} < {required}"); + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index d6f8e01f..7558a005 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -73,6 +73,8 @@ internal sealed class VulkanCapabilities public ulong MinUniformBufferOffsetAlignment; public ulong MaxUniformBufferRange; public uint MaxColorAttachments = 8; + /// The bindless features and limits of plan decision 9; every selected device meets . + public DescriptorIndexingSupport DescriptorIndexing; /// VK_EXT_color_write_enable enabled (only when the selected tier uses it). public bool ColorWriteEnable; @@ -745,6 +747,9 @@ private bool IsUsable(PhysicalDevice device, out string? reason) if (!features.Features.IndependentBlend) missing.Add("independentBlend"); // Chunk rendering issues one indirect multidraw per pool. if (!features.Features.MultiDrawIndirect) missing.Add("multiDrawIndirect"); + // Decision 9: one pipeline layout with bindless textures. A device without + // it stays on OpenGL rather than running a second, per-program layout path. + missing.AddRange(DescriptorIndexingFloor.Missing(ReadDescriptorIndexingSupport(device))); if (missing.Count > 0) { @@ -756,6 +761,38 @@ private bool IsUsable(PhysicalDevice device, out string? reason) return true; } + /// The features and limits judges, as the device reports them. + private DescriptorIndexingSupport ReadDescriptorIndexingSupport(PhysicalDevice device) + { + var vulkan12Features = new PhysicalDeviceVulkan12Features { SType = StructureType.PhysicalDeviceVulkan12Features }; + var features = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan12Features, + }; + Api.GetPhysicalDeviceFeatures2(device, &features); + + var vulkan12Properties = new PhysicalDeviceVulkan12Properties { SType = StructureType.PhysicalDeviceVulkan12Properties }; + var properties = new PhysicalDeviceProperties2 + { + SType = StructureType.PhysicalDeviceProperties2, + PNext = &vulkan12Properties, + }; + Api.GetPhysicalDeviceProperties2(device, &properties); + + return new DescriptorIndexingSupport( + RuntimeDescriptorArray: vulkan12Features.RuntimeDescriptorArray, + DescriptorBindingPartiallyBound: vulkan12Features.DescriptorBindingPartiallyBound, + DescriptorBindingSampledImageUpdateAfterBind: vulkan12Features.DescriptorBindingSampledImageUpdateAfterBind, + ShaderSampledImageArrayDynamicIndexing: features.Features.ShaderSampledImageArrayDynamicIndexing, + MaxPerStageDescriptorUpdateAfterBindSampledImages: vulkan12Properties.MaxPerStageDescriptorUpdateAfterBindSampledImages, + MaxPerStageDescriptorUpdateAfterBindSamplers: vulkan12Properties.MaxPerStageDescriptorUpdateAfterBindSamplers, + MaxDescriptorSetUpdateAfterBindSampledImages: vulkan12Properties.MaxDescriptorSetUpdateAfterBindSampledImages, + MaxDescriptorSetUpdateAfterBindSamplers: vulkan12Properties.MaxDescriptorSetUpdateAfterBindSamplers, + MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic: vulkan12Properties.MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic, + MaxPushConstantsSize: properties.Properties.Limits.MaxPushConstantsSize); + } + private bool TryFindGraphicsQueue(PhysicalDevice device, out uint family) { family = 0; @@ -840,6 +877,8 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso { IndependentBlend = true, MultiDrawIndirect = true, + // Decision 9: per-draw bindless indices come from push constants. + ShaderSampledImageArrayDynamicIndexing = true, // Optional. Wireframe debug and thick lines degrade rather than fail. FillModeNonSolid = available.FillModeNonSolid, WideLines = available.WideLines, @@ -914,6 +953,11 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso PNext = &vulkan13, ScalarBlockLayout = true, TimelineSemaphore = true, + // Decision 9's bindless set: partially bound combined-image-sampler arrays + // written while bound. IsUsable already proved the device has them. + RuntimeDescriptorArray = true, + DescriptorBindingPartiallyBound = true, + DescriptorBindingSampledImageUpdateAfterBind = true, }; var features2 = new PhysicalDeviceFeatures2 { @@ -1150,6 +1194,7 @@ private VulkanCapabilities ReadCapabilities() MinUniformBufferOffsetAlignment = properties.Limits.MinUniformBufferOffsetAlignment, MaxUniformBufferRange = properties.Limits.MaxUniformBufferRange, MaxColorAttachments = properties.Limits.MaxColorAttachments, + DescriptorIndexing = ReadDescriptorIndexingSupport(PhysicalDevice), }; } diff --git a/Optimum.Render.Vulkan/Shaders/SetConvention.cs b/Optimum.Render.Vulkan/Shaders/SetConvention.cs new file mode 100644 index 00000000..03a8ea8d --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/SetConvention.cs @@ -0,0 +1,89 @@ +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// The descriptor set convention of plan decision 9: one pipeline layout shared by +/// every program. Mirrors sources/shaders-vk/include/bindings.glsl, the +/// source of truth for native shaders; SetConventionTests keeps the two in +/// agreement, define by define and declaration by declaration. +/// +/// | Set | Update | Contents | +/// | 0 frame | once per frame | FrameGlobals UBO (dynamic offset) and the fixed frame textures | +/// | 1 textures | when a texture is created or retired | bindless combined-image-sampler arrays, one per GLSL sampled type, PARTIALLY_BOUND and UPDATE_AFTER_BIND | +/// | 2 storage | when a buffer is created or retired | FaceData and the animation buffers | +/// | push | per draw | texture slot indices and per-draw scalars, at most | +/// +/// Array sizes are docs/research/vulkan-bindless.md's starting sizes; the device +/// floor () is their sum. +/// +internal static class SetConvention +{ + public const string IncludePath = "sources/shaders-vk/include/bindings.glsl"; + + public const int FrameSet = 0; + public const int TextureSet = 1; + public const int StorageSet = 2; + public const int SetCount = 3; + + /// The spec minimum; everything a draw needs that is larger lives in a per-frame record addressed from here. + public const uint PushConstantBytes = 128; + + public const int FrameGlobalsBinding = 0; + + public const uint Texture2DCapacity = 16384; + public const uint Texture2DArrayCapacity = 1024; + public const uint TextureCubeCapacity = 256; + public const uint Texture3DCapacity = 256; + public const uint UnsignedTexture2DCapacity = 1024; + public const uint SignedTexture2DCapacity = 256; + public const uint Shadow2DCapacity = 128; + public const uint Shadow2DArrayCapacity = 64; + public const uint ShadowCubeCapacity = 64; + + public const uint TextureArrayCapacityTotal = + Texture2DCapacity + Texture2DArrayCapacity + TextureCubeCapacity + Texture3DCapacity + + UnsignedTexture2DCapacity + SignedTexture2DCapacity + Shadow2DCapacity + Shadow2DArrayCapacity + + ShadowCubeCapacity; + + /// A binding declared once in bindings.glsl: its define, value and, for samplers, the declaration. + public readonly record struct Binding(string Define, int Value, string GlslType, string Name, uint Capacity); + + /// + /// Set 0's fixed frame textures, under the names the game's shaders already use + /// (fogandlight.fsh declares the two shadow maps as sampler2DShadow). + /// Binding 0 is the FrameGlobals block. + /// + public static readonly Binding[] FrameTextures = + { + new("OPTIMUM_BINDING_SHADOW_MAP_FAR", 1, "sampler2DShadow", "shadowMapFar", 1), + new("OPTIMUM_BINDING_SHADOW_MAP_NEAR", 2, "sampler2DShadow", "shadowMapNear", 1), + new("OPTIMUM_BINDING_SKY", 3, "sampler2D", "sky", 1), + new("OPTIMUM_BINDING_GLOW", 4, "sampler2D", "glow", 1), + new("OPTIMUM_BINDING_LIQUID_DEPTH", 5, "sampler2D", "liquidDepth", 1), + }; + + /// Set 1: one runtime-sized array per GLSL sampled type. + public static readonly Binding[] TextureArrays = + { + new("OPTIMUM_BINDING_TEXTURES_2D", 0, "sampler2D", "optimumTextures2D", Texture2DCapacity), + new("OPTIMUM_BINDING_TEXTURES_2D_ARRAY", 1, "sampler2DArray", "optimumTextures2DArray", Texture2DArrayCapacity), + new("OPTIMUM_BINDING_TEXTURES_CUBE", 2, "samplerCube", "optimumTexturesCube", TextureCubeCapacity), + new("OPTIMUM_BINDING_TEXTURES_3D", 3, "sampler3D", "optimumTextures3D", Texture3DCapacity), + new("OPTIMUM_BINDING_TEXTURES_2D_UINT", 4, "usampler2D", "optimumTextures2DUint", UnsignedTexture2DCapacity), + new("OPTIMUM_BINDING_TEXTURES_2D_INT", 5, "isampler2D", "optimumTextures2DInt", SignedTexture2DCapacity), + new("OPTIMUM_BINDING_TEXTURES_2D_SHADOW", 6, "sampler2DShadow", "optimumTextures2DShadow", Shadow2DCapacity), + new("OPTIMUM_BINDING_TEXTURES_2D_ARRAY_SHADOW", 7, "sampler2DArrayShadow", "optimumTextures2DArrayShadow", Shadow2DArrayCapacity), + new("OPTIMUM_BINDING_TEXTURES_CUBE_SHADOW", 8, "samplerCubeShadow", "optimumTexturesCubeShadow", ShadowCubeCapacity), + }; + + /// + /// Set 2's storage buffers. Only FaceData exists in the game today (the chunk + /// shaders' faceDataBuf); the animation pair is the plan's move of bone + /// matrices off the 64 KiB UBO limit. + /// + public static readonly Binding[] StorageBuffers = + { + new("OPTIMUM_BINDING_FACE_DATA", 0, "buffer", "faceDataBuf", 1), + new("OPTIMUM_BINDING_ANIMATION", 1, "buffer", "animationBuf", 1), + new("OPTIMUM_BINDING_ANIMATION_PREV", 2, "buffer", "animationPrevBuf", 1), + }; +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 35655d4f..205df880 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -396,7 +396,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; device fault reporting " + (_context.DeviceFaultAvailable ? "ENABLED" : "NOT AVAILABLE") + "; poison " + (_context.PoisonFreshResources ? "ON" : "off") + "; color write tier " + DeviceCaps.Token(_context.Capabilities.ColorWriteTier) + - (_context.Capabilities.DynamicColorBlend ? " (dynamic blend)" : "")); + (_context.Capabilities.DynamicColorBlend ? " (dynamic blend)" : "") + + "; bindless sampled images per stage " + + _context.Capabilities.DescriptorIndexing.MaxPerStageDescriptorUpdateAfterBindSampledImages + + " (needs " + DescriptorIndexingFloor.RequiredSampledImages + ")" + + "; push constants " + _context.Capabilities.DescriptorIndexing.MaxPushConstantsSize + " B"); // A ReBAR miss is logged, not an error: the validation mirror and the // trace, never GetError. The stats sample reads this allocator's heaps. _context.Allocator.Log = MirrorValidationMessage; diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 50ca98cb..aaa5ba25 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -222,6 +222,17 @@ Windows run above. and limit check at startup (without it the session stays on OpenGL); `bindings.glsl` + `Shaders/SetConvention.cs` with an agreement test; uniform placement table (push | frame | per-frame record | storage | texture slot). + **Status (2026-09-15):** steps 1 and 2 of the decision-9 sequence are in. Capability negotiation: + `Core/DescriptorIndexingFloor.cs` judges runtimeDescriptorArray, partially bound, sampled-image update-after-bind, + dynamic sampled-image indexing and the update-after-bind sampled-image/sampler, dynamic-UBO and push-constant limits + against the set-1 table; `IsUsable` rejects a device below it (OpenGL fallback with the named reason), `CreateDevice` + enables the features, `VulkanCapabilities.DescriptorIndexing` carries them and the device-up line logs them. Set + convention: `sources/shaders-vk/include/bindings.glsl` (source of truth) and `Shaders/SetConvention.cs`, pinned by + `SetConventionTests` (defines, declarations, uniqueness, floor agreement, the include compiles). Tests: + `BindlessCapabilityTests` (floor logic, researched vendor limits, a decision-9 layout created and allocated with no + validation message on the selected device). GPU suite 673/673, no `SYNC-`; both local devices (RTX 4070, UHD ADL-S) + meet the floor. Open for the layout step: best practices' AMD check `KeepLayoutSmall` warns on that layout's + 128-byte push-constant range; size the real push block from the uniform placement map, not the maximum. 4. **Rewriter retargeted** to the shared layout (samplers -> bindless indices, loose uniforms -> per-frame record addressed from push constants), then native GLSL 450 per program family (includes; post programs; GUI/lines; chunks; entities; particles/decals/sky/clouds; SSAO/godrays/bloom/colorgrade/OIT; Optimum diff --git a/sources/shaders-vk/include/bindings.glsl b/sources/shaders-vk/include/bindings.glsl new file mode 100644 index 00000000..0dd81569 --- /dev/null +++ b/sources/shaders-vk/include/bindings.glsl @@ -0,0 +1,80 @@ +// Descriptor set convention for Optimum's Vulkan-native shaders (plan decision 9): +// one pipeline layout shared by every program. +// +// This file is the source of truth for set and binding numbers. The renderer's +// Shaders/SetConvention.cs mirrors it, and SetConventionTests fails when the two +// disagree on any define or declaration. +// +// set 0 frame once per frame FrameGlobals UBO (dynamic offset) + frame textures +// set 1 textures on create / retire bindless combined-image-sampler arrays, +// PARTIALLY_BOUND | UPDATE_AFTER_BIND +// set 2 storage on create / retire FaceData and the animation buffers +// push per draw texture slot indices and per-draw scalars +// +// Indices into the set-1 arrays come from push constants and are uniform over a +// draw, so they need no nonuniformEXT; an index taken from per-vertex, per-instance +// or per-pixel data does (docs/research/vulkan-bindless.md, section 4). + +#ifndef OPTIMUM_BINDINGS_GLSL +#define OPTIMUM_BINDINGS_GLSL + +#extension GL_EXT_nonuniform_qualifier : require + +#define OPTIMUM_SET_FRAME 0 +#define OPTIMUM_SET_TEXTURES 1 +#define OPTIMUM_SET_STORAGE 2 + +#define OPTIMUM_PUSH_CONSTANT_BYTES 128 + +// Set 0. The FrameGlobals block itself is generated from Shaders/FrameGlobals.cs. +#define OPTIMUM_BINDING_FRAME_GLOBALS 0 +#define OPTIMUM_BINDING_SHADOW_MAP_FAR 1 +#define OPTIMUM_BINDING_SHADOW_MAP_NEAR 2 +#define OPTIMUM_BINDING_SKY 3 +#define OPTIMUM_BINDING_GLOW 4 +#define OPTIMUM_BINDING_LIQUID_DEPTH 5 + +layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_SHADOW_MAP_FAR) uniform sampler2DShadow shadowMapFar; +layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_SHADOW_MAP_NEAR) uniform sampler2DShadow shadowMapNear; +layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_SKY) uniform sampler2D sky; +layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_GLOW) uniform sampler2D glow; +layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_LIQUID_DEPTH) uniform sampler2D liquidDepth; + +// Set 1. Capacities are the starting sizes from docs/research/vulkan-bindless.md; +// slot 0 of every array holds a placeholder. +#define OPTIMUM_BINDING_TEXTURES_2D 0 +#define OPTIMUM_BINDING_TEXTURES_2D_ARRAY 1 +#define OPTIMUM_BINDING_TEXTURES_CUBE 2 +#define OPTIMUM_BINDING_TEXTURES_3D 3 +#define OPTIMUM_BINDING_TEXTURES_2D_UINT 4 +#define OPTIMUM_BINDING_TEXTURES_2D_INT 5 +#define OPTIMUM_BINDING_TEXTURES_2D_SHADOW 6 +#define OPTIMUM_BINDING_TEXTURES_2D_ARRAY_SHADOW 7 +#define OPTIMUM_BINDING_TEXTURES_CUBE_SHADOW 8 + +#define OPTIMUM_CAPACITY_TEXTURES_2D 16384 +#define OPTIMUM_CAPACITY_TEXTURES_2D_ARRAY 1024 +#define OPTIMUM_CAPACITY_TEXTURES_CUBE 256 +#define OPTIMUM_CAPACITY_TEXTURES_3D 256 +#define OPTIMUM_CAPACITY_TEXTURES_2D_UINT 1024 +#define OPTIMUM_CAPACITY_TEXTURES_2D_INT 256 +#define OPTIMUM_CAPACITY_TEXTURES_2D_SHADOW 128 +#define OPTIMUM_CAPACITY_TEXTURES_2D_ARRAY_SHADOW 64 +#define OPTIMUM_CAPACITY_TEXTURES_CUBE_SHADOW 64 + +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_2D) uniform sampler2D optimumTextures2D[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_2D_ARRAY) uniform sampler2DArray optimumTextures2DArray[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_CUBE) uniform samplerCube optimumTexturesCube[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_3D) uniform sampler3D optimumTextures3D[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_2D_UINT) uniform usampler2D optimumTextures2DUint[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_2D_INT) uniform isampler2D optimumTextures2DInt[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_2D_SHADOW) uniform sampler2DShadow optimumTextures2DShadow[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_2D_ARRAY_SHADOW) uniform sampler2DArrayShadow optimumTextures2DArrayShadow[]; +layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_CUBE_SHADOW) uniform samplerCubeShadow optimumTexturesCubeShadow[]; + +// Set 2. The blocks' contents are declared by the programs that read them. +#define OPTIMUM_BINDING_FACE_DATA 0 +#define OPTIMUM_BINDING_ANIMATION 1 +#define OPTIMUM_BINDING_ANIMATION_PREV 2 + +#endif From 2cc41a23346fd2531a91196a5343aa3ff6e11003 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:08:28 +0200 Subject: [PATCH 147/226] docs(research): ambient occlusion candidates - XeGTAO archived, visibility bitmasks, MXAO, Alchemy/SAO, openmw-ssao, Unity GTAO Sources, licences and what each would contribute, collected as the starting point for the deep research that decides the combined ambient occlusion design. --- docs/research/xegtao-integration.md | 93 +++++++++++++++++++++++++++++ docs/vulkan-branch-progress.md | 2 +- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/docs/research/xegtao-integration.md b/docs/research/xegtao-integration.md index 63706c7c..f6006cf9 100644 --- a/docs/research/xegtao-integration.md +++ b/docs/research/xegtao-integration.md @@ -9,6 +9,99 @@ Research notes for integrating XeGTAO (MIT, GameTechDev) into the Vulkan rendere With TAA: NoiseIndex = frame % 64 and a single denoise pass. On GL 3.3, keep vanilla SSAO. +## 0. Status and algorithm choice (2026-09-15) + +- **XeGTAO is archived.** The repository was archived on 2024-04-22; its last commits are "Archiving Notice" and a + README update. It stays MIT and usable, but receives no fixes. https://github.com/GameTechDev/XeGTAO +- **The algorithm is not superseded as a base, but it has a maintained successor:** GTAO with visibility bitmasks + (Therrien, Levesque, Gilet 2023). Each slice's two horizon angles become a bitfield of N sectors, and every depth + sample is treated as a slab of constant thickness, so light passes behind thin surfaces instead of the whole + horizon being occluded. https://arxiv.org/abs/2301.11376 +- **Shipped and maintained:** Bevy replaced GTAO with it in 0.15 (new `constant_object_thickness` field). + https://bevy.org/learn/migration-guides/0-14-to-0-15/ + - Source: `crates/bevy_pbr/src/ssao/{preprocess_depth,ssao,spatial_denoise}.wesl`, MIT OR Apache-2.0. Its header + names XeGTAO v1.30, Therrien's code post and SSRT3 as bases. + https://github.com/bevyengine/bevy/tree/main/crates/bevy_pbr/src/ssao + - Open follow-ups: bevyengine/bevy#19713 (2025) lists acos-free slice evaluation and thickness heuristics as the + known improvements over a single fixed thickness. https://github.com/bevyengine/bevy/issues/19713 + - The Skyrim and Fallout 4 community shaders' Screen Space GI uses the same sector bitmask in `gi.cs.hlsl`. GPL-3.0, + so reference only (local copies under `~/Projekte/ReScaleFrame/references`). +- **Licences of the other references:** Therrien's code post states no licence + (https://cdrinmatane.github.io/posts/ssaovb-code/), so the sector update is implemented from the paper. The + ground-truth VBAO variant on Shadertoy (linked from bevy#19713) states none either: reference only. +- **Why it matters for this game [Inference]:** the scene is dominated by thin, alpha-tested geometry (leaves, grass, + fences, plants). Horizon-based GTAO treats every such surface as infinitely thick and darkens everything behind + it. The thickness term is aimed at exactly this case. +- **[Uncertain]** A commercial Unreal plugin reports about 3 ms for UE5's GTAO against 0.6 ms for its visibility-bitmask + version at 1080p (Epic forum listing); vendor-reported, not reproduced here. + +**Other candidates checked (2026-09-15)** + +- **MXAO (iMMERSE, Pascal Gilcher).** + - The modes (`MXAO_AO_TYPE`) are GTAO, solid angle, visibility bitmask, and visibility bitmask with solid angle. + https://guides.martysmods.com/shaders/immerse/mxao/ + - The author says it adds a better horizon falloff than baseline GTAO and a cosine term that the plain bitmask + lacks. https://github.com/martymcmodding/iMMERSE + - **Code unusable:** the repository licence and the shader header read "Copyright (c) Pascal Gilcher. All rights + reserved ... Unauthorized copying of this file, via any medium is strictly prohibited ... Proprietary and + confidential". https://github.com/martymcmodding/iMMERSE/blob/main/Shaders/MartysMods_MXAO.fx + - What carries over are published ideas only. Cosine-weighted visibility bitmasks are documented by the + ground-truth VBAO follow-up referenced in bevyengine/bevy#19713 (reference only, licence unstated). + - MXAO also shows the useful product shape: the slice integration is a switch, not a fork. +- **Alchemy AO (McGuire, Osman, Bukowski, Hennessy, HPG 2011) and Scalable Ambient Obscurance (McGuire 2012).** + https://casual-effects.com/research/McGuire2011AlchemyAO/VV11AlchemyAO.pdf , + https://research.nvidia.com/sites/default/files/pubs/2012-06_Scalable-Ambient-Obscurance/McGuire12SAO.pdf + - Point-sample obscurance with an aesthetic falloff and intensity/contrast parameters, not a radiometric AO + estimate. + - SAO's lasting contribution is the depth mip chain for wide radii at constant cost. XeGTAO and Bevy already use + it (Bevy's `preprocess_depth.wesl` cites SAO section 2.2). + - **[Inference]** Horizon-based slice integration extracts more per depth sample than independent point samples, + so it gives less noise at the low sample counts a TAA-accumulated pass runs at. Alchemy/SAO would need more + samples or more blur for the same stability. +- **openmw-ssao (zesterer, last push 2024-11-25).** https://github.com/zesterer/openmw-ssao + - **No licence file:** reference only. + - Point-sample SSAO (`shaders/ssao.omwfx`) with its own temporal reprojection: + - AO history in a private buffer; + - a world-position "marker" stored beside it to reject stale history; + - the per-pixel sample count reduced where history is trusted; + - change-based rejection against ghosts; + - a depth-weighted blur. + - **Not adopted.** On this renderer TAA is the accumulator and AO is composed before the resolve (section 4 of the + handoff knowledge). A second, AO-private history would stack a second ghosting source on top of TAA's. + - Its depth-relative occlusion falloff against halos is the standard range check the GTAO pipeline already has. +- **Unity Ground Truth Ambient Occlusion (MaxwellGengYF, last push 2019-03-12).** + https://github.com/MaxwellGengYF/Unity-Ground-Truth-Ambient-Occlusion + - **No licence file:** reference only. + - A legacy-pipeline Unity port of Jimenez 2016 with its own temporal filter and GTSO specular occlusion. + - Superseded as a reference by XeGTAO v1.30 and Bevy. + - Specular occlusion needs a PBR specular term this game's shading does not have. + +- **"Low-sample GTAO + spatial denoise".** This is not an alternative but the structure every candidate above shares: + - few slices and steps per pixel; + - noise varied per frame; + - an edge-aware 3x3 spatial denoise; + - TAA accumulating over frames. + + XeGTAO (one denoise pass with TAA) and Bevy (one 3x3 bilateral pass) both work this way. The choice between them + is only the per-slice integration. + +**Decision.** Implement GTAO with visibility bitmasks. Keep XeGTAO's surrounding pipeline, which Bevy keeps too: +- the prefiltered depth mip chain; +- Hilbert-LUT noise with an R2 sequence advanced per frame while TAA is active; +- an edge-aware spatial denoise. + +The main pass is the low-sample slice loop with a switchable integration (a specialization constant or macro, as +MXAO switches its AO type): visibility bitmask with cosine weighting (default) or horizon GTAO, so both can be +measured on this game's foliage with the headless harness. The bitmask variant uses: +- 32-bit mask per slice; +- `SLICE_COUNT` and `SAMPLES_PER_SLICE_SIDE` as quality macros; +- thickness in blocks. + +XeGTAO's analytic visibility integral and bent normals are not used. The MIT notices of XeGTAO and Bevy stay in the +headers of the ported files. Owner decision: it is the default ambient occlusion on Vulkan whenever TAA is active; +vanilla SSAO otherwise and on OpenGL. The integration plan below still applies, except for step 3's main pass and +step 2's bent normals. + Source files are cited by their GitHub URLs: [XeGTAO.h](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.h), [XeGTAO.hlsli](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/XeGTAO.hlsli), [vaGTAO.hlsl](https://github.com/GameTechDev/XeGTAO/blob/master/Source/Rendering/Shaders/vaGTAO.hlsl) and [README](https://github.com/GameTechDev/XeGTAO/blob/master/README.md). ## 1. XeGTAO specifics diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index aaa5ba25..4a8ee17a 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -247,7 +247,7 @@ Windows run above. 7. **Caching follow-ups** (`docs/research/vulkan-caching.md`): `FAIL_ON_PIPELINE_COMPILE_REQUIRED` with background compiles, growth-triggered saves, pipeline-key log for pre-warming, optional `VK_KHR_pipeline_binary`; real-client warm-start check with the headless harness (needs game data). -8. **XeGTAO** (`docs/research/xegtao-integration.md`): compute pass kind in the frame graph, GLSL compute +8. **GTAO with visibility bitmasks** (XeGTAO-derived; XeGTAO itself is archived since 2024-04-22, see `docs/research/xegtao-integration.md` section 0; default AO on Vulkan while TAA is active): compute pass kind in the frame graph, GLSL compute port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. 9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. From 3a18273f53115c2d92d84b1df1a956961a7cc1a6 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:14:21 +0200 Subject: [PATCH 148/226] docs(vulkan): native shader interface contract; program record binding and a two-dynamic-UBO floor The contract every native program family follows: file layout, name/sampler/input/ output parity with the GLSL 330 oracle, push block and program record placement, variant axes versus specialization constants, the offline manifest and reflection, the single motion writer (b survives a rejected vector, as the frozen contract states) and the runtime seam. Set 2 gains the program record at binding 3, and the device floor requires two update-after-bind-compatible dynamic uniform buffers. Verified: SetConventionTests, BindlessCapabilityTests, device selection and the rewriter binding agreement 20/20 on the RTX 4070; both local devices stay usable. --- .gitignore | 2 + .../BindlessCapabilityTests.cs | 8 +- .../SetConventionTests.cs | 3 +- .../Core/DescriptorIndexingFloor.cs | 11 +- .../Shaders/SetConvention.cs | 8 + docs/vulkan-native-shaders.md | 199 ++++++++++++++++++ sources/shaders-vk/include/bindings.glsl | 3 + 7 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 docs/vulkan-native-shaders.md diff --git a/.gitignore b/.gitignore index 7a794512..47745590 100644 --- a/.gitignore +++ b/.gitignore @@ -86,6 +86,8 @@ docs/* !docs/vulkan-branch-progress.md # ...and the research notes the plan and its designs cite. !docs/research/ +# ...and the native shader interface contract every program family is written against. +!docs/vulkan-native-shaders.md build-linux.sh build-macos.sh build-windows.ps1 diff --git a/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs b/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs index 283dbe7a..9486e911 100644 --- a/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs +++ b/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs @@ -27,7 +27,7 @@ public class BindlessCapabilityTests MaxPerStageDescriptorUpdateAfterBindSamplers: DescriptorIndexingFloor.RequiredSampledImages, MaxDescriptorSetUpdateAfterBindSampledImages: DescriptorIndexingFloor.RequiredSampledImages, MaxDescriptorSetUpdateAfterBindSamplers: DescriptorIndexingFloor.RequiredSampledImages, - MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic: 1, + MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic: DescriptorIndexingFloor.RequiredDynamicUniformBuffers, MaxPushConstantsSize: DescriptorIndexingFloor.RequiredPushConstantBytes); [Fact] @@ -71,8 +71,10 @@ public void EachLimitBelowTheFloorIsNamedWithTheReportedAndRequiredValue() "maxDescriptorSetUpdateAfterBindSampledImages", below, required); AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindSamplers = below }, "maxDescriptorSetUpdateAfterBindSamplers", below, required); - AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic = 0 }, - "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", 0, "1"); + uint dynamicBelow = DescriptorIndexingFloor.RequiredDynamicUniformBuffers - 1; + AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic = dynamicBelow }, + "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", dynamicBelow, + DescriptorIndexingFloor.RequiredDynamicUniformBuffers.ToString()); AssertSingle(AtFloor() with { MaxPushConstantsSize = 64 }, "maxPushConstantsSize", 64, "128"); } diff --git a/Optimum.Render.Vulkan.Tests/SetConventionTests.cs b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs index 93ac9338..d2dd8148 100644 --- a/Optimum.Render.Vulkan.Tests/SetConventionTests.cs +++ b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs @@ -33,6 +33,7 @@ public void EveryDefineInTheIncludeHasTheValueTheRendererUses() ["OPTIMUM_SET_STORAGE"] = SetConvention.StorageSet, ["OPTIMUM_PUSH_CONSTANT_BYTES"] = (int)SetConvention.PushConstantBytes, ["OPTIMUM_BINDING_FRAME_GLOBALS"] = SetConvention.FrameGlobalsBinding, + ["OPTIMUM_BINDING_PROGRAM_RECORD"] = SetConvention.ProgramRecordBinding, }; foreach (SetConvention.Binding binding in SetConvention.FrameTextures) expected[binding.Define] = binding.Value; foreach (SetConvention.Binding binding in SetConvention.StorageBuffers) expected[binding.Define] = binding.Value; @@ -87,7 +88,7 @@ public void BindingsAreUniqueWithinEachSet() { AssertUnique(SetConvention.FrameGlobalsBinding, SetConvention.FrameTextures); AssertUnique(null, SetConvention.TextureArrays); - AssertUnique(null, SetConvention.StorageBuffers); + AssertUnique(SetConvention.ProgramRecordBinding, SetConvention.StorageBuffers); } /// diff --git a/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs b/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs index 418b941b..5ac031fc 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs @@ -45,6 +45,12 @@ internal static class DescriptorIndexingFloor /// public const uint RequiredSampledImages = BindlessSampledImages + FrameTextures; + /// + /// Set 0's frame block and set 2's program record are both dynamic uniform buffers in + /// the layout that also holds the update-after-bind set. + /// + public const uint RequiredDynamicUniformBuffers = 2; + /// The spec minimum, and the budget decision 9 gives per-draw indices and scalars. public const uint RequiredPushConstantBytes = 128; @@ -69,9 +75,10 @@ public static List Missing(in DescriptorIndexingSupport support) support.MaxDescriptorSetUpdateAfterBindSampledImages, RequiredSampledImages); AtLeast(missing, "maxDescriptorSetUpdateAfterBindSamplers", support.MaxDescriptorSetUpdateAfterBindSamplers, RequiredSampledImages); - // Set 0's frame UBO is dynamic and shares the layout with the update-after-bind set. + // Set 0's frame UBO and set 2's program record are dynamic and share the layout + // with the update-after-bind set. AtLeast(missing, "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", - support.MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic, 1); + support.MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic, RequiredDynamicUniformBuffers); AtLeast(missing, "maxPushConstantsSize", support.MaxPushConstantsSize, RequiredPushConstantBytes); return missing; } diff --git a/Optimum.Render.Vulkan/Shaders/SetConvention.cs b/Optimum.Render.Vulkan/Shaders/SetConvention.cs index 03a8ea8d..0f31b89d 100644 --- a/Optimum.Render.Vulkan/Shaders/SetConvention.cs +++ b/Optimum.Render.Vulkan/Shaders/SetConvention.cs @@ -80,6 +80,14 @@ internal static class SetConvention /// shaders' faceDataBuf); the animation pair is the plan's move of bone /// matrices off the 64 KiB UBO limit. /// + /// + /// Set 2's program record: every non-frame uniform that is not in the push block + /// (docs/vulkan-native-shaders.md section 4), a dynamic uniform buffer whose offset + /// moves when the record changed. Kept out of because it + /// is a uniform buffer, not a storage buffer. + /// + public const int ProgramRecordBinding = 3; + public static readonly Binding[] StorageBuffers = { new("OPTIMUM_BINDING_FACE_DATA", 0, "buffer", "faceDataBuf", 1), diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md new file mode 100644 index 00000000..a28b88ff --- /dev/null +++ b/docs/vulkan-native-shaders.md @@ -0,0 +1,199 @@ +# Vulkan-native shaders: the interface contract + +Every native program family is written against this document. It turns plan section C ("Shaders", +`docs/vulkan-native-plan.md`) and decision 9 into rules precise enough that seven families can be rewritten in +parallel and still link against one pipeline layout, one manifest and one runtime. + +Inputs: +- the plan's set convention, uniform placement and define matrix; +- `docs/research/vulkan-bindless.md` and `docs/research/vulkan-descriptor-model.md`; +- the committed `sources/shaders-vk/include/bindings.glsl` and `Shaders/SetConvention.cs`; +- four read-only maps of the tree taken on 2026-09-15: the program corpus, uniform write frequency, native + integration seams, and the motion writers. Their findings are restated below where they decide something. + +## 1. Files + +- **Program sources:** `sources/shaders-vk/.vert` and `.frag`, named after the program's `PassName` + (`chunkopaque`, `taa-resolve`, ...). Never `.vsh`/`.fsh` (`SetConventionTests` enforces it), so no packager + glob over `sources/shaders/` can pick them up. +- **Per-program interface:** `sources/shaders-vk/.interface.glsl` declares the program's push block + and record (section 4). Both stages include it, so the two declarations cannot drift. +- **Shared includes:** `sources/shaders-vk/include/*.glsl`, one per game include, same base name + (`fogandlight.frag.glsl`, `fogandlight.vert.glsl`, `vertexwarp.glsl`, `shadowcoords.glsl`, `colormap.vert.glsl`, + `colormap.frag.glsl`, `dither.glsl`, `skycolor.glsl`, `underwatereffects.glsl`, `noise2d.glsl`, `noise3d.glsl`, + `oit.glsl`, `fxaa.glsl`, `colorutil.glsl`, `normalshading.glsl`, `fogspheres.glsl`, `vertexflagbits.glsl`). + `default.fsh` and `printvalues.fsh` are included by nothing and are not ported. +- **Generated and fixed includes:** + - `include/bindings.glsl`: sets and bindings, the source of truth (committed). + - `include/frame.glsl`: the FrameGlobals block, generated from `Shaders/FrameGlobals.cs`. A test regenerates + it and fails when the committed file differs. + - `include/specialization.glsl`: constant ids (section 5), mirrored in C# with an agreement test. + - `include/motion.glsl`: the only motion writer (section 7). +- **Language:** GLSL 450 with `GL_EXT_scalar_block_layout`, `GL_EXT_nonuniform_qualifier` and + `GL_GOOGLE_include_directive`, compiled by the same shaderc library the runtime uses + (`--target-env=vulkan1.3 -O`). + +## 2. What must stay identical to the GLSL 330 program + +The client's oracle for a program's uniforms is `ShaderProgram.collectUniformNames` +(`build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgram.cs:57-68`). It regexes the GLSL 330 source +**text**, before preprocessing, and assigns texture units in sampler declaration order. `HasUniform` only ever +consults that set. So for every program and every variant: + +1. **Names:** frame members the program declares, plus push members, plus record members, plus sampler names, + must equal the GLSL 330 name set exactly, with the same GLSL type per name (`extraGlow` is `int` in some + programs and `float` in others; each keeps its own). +2. **Samplers:** the sampler list keeps the GLSL 330 declaration order. It is the key the device uses to turn a + bound texture unit into a bindless slot. +3. **Vertex inputs:** same locations and types. +4. **Fragment outputs:** same locations and names per variant. +5. **Behaviour:** the same pixels within the tolerance the family's differential test states. The TAA resolve's + rule-11 invariants (3x3 nearest-depth disocclusion with motion from the nearest-depth tap, luminance + anti-flicker 0.3x..1.2x) are reproduced verbatim. + +A static parity test per program per variant (names, sampler order, inputs, outputs, types) runs through +`ShaderCorpus` on the GLSL 330 side and the manifest on the native side. It needs no GPU and is part of every +family stage. + +## 3. Descriptor use + +- **Set 0 (frame):** `frame.glsl` declares the FrameGlobals UBO at `OPTIMUM_BINDING_FRAME_GLOBALS` (scalar + layout, dynamic offset). The fixed frame textures (`shadowMapFar`, `shadowMapNear`, `sky`, `glow`, + `liquidDepth`) come from `bindings.glsl` under their game names. +- **A frame member is used under its own name** only when the program includes the member's owner file, the + same rule `FrameGlobals.TryPlace` applies today. Otherwise the member is an ordinary record member. +- **Set 1 (textures):** every other sampler is an index into the bindless array of its GLSL type + (`optimumTextures2D`, `optimumTextures2DArray`, `optimumTexturesCube`, `optimumTextures2DShadow`, ...). The + index is a `uint` push member carrying the sampler's own name (section 4). Indices are uniform over a draw, so + no `nonuniformEXT`. +- **Set 2 (storage):** + - `OPTIMUM_BINDING_FACE_DATA` holds the chunk `FaceData` array. + - `OPTIMUM_BINDING_ANIMATION` and `OPTIMUM_BINDING_ANIMATION_PREV` hold the bone matrices, read as storage + buffers. + - `OPTIMUM_BINDING_PROGRAM_RECORD` (binding 3) is the program record: a dynamic uniform buffer. +- **Push constants:** one block per program, at most `OPTIMUM_PUSH_CONSTANT_BYTES` (128). + +## 4. Placement: push block and program record + +```glsl +// .interface.glsl +layout(push_constant, scalar) uniform OptimumDraw { + uint terrainTex; // sampler slot, same name as the GLSL 330 sampler + vec3 origin; // DRAW-frequency uniforms that fit + mat4 modelViewMatrix; +} draw; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram { + float alphaTest; // everything else the program declares + vec4 rgbaFogIn; +} program; +``` + +- **Push block:** + - Every non-frame sampler's slot index first (4 B each). + - Then the uniforms the frequency map classifies as DRAW (they change between draws without a `Use()`), in + declaration order, while the block stays within 128 B. +- **Record:** every remaining non-frame uniform (PROGRAM-frequency ones, and DRAW ones that did not fit). + - The device snapshots it into the frame's uniform ring when it changed. + - It binds the new dynamic offset at the next draw. +- **Classification for the families (from the frequency map):** + - **Chunk family** (origin, modelViewMatrix or mvpMatrix, forcedTransparency, slot): 76-84 B, all in push. + - **Decals:** about 80 B, in push. + - **Entities** (about 280 B of DRAW data): push holds `entityId`, `addRenderFlags`, `taaHistoryValid`, + `taaReactive`, the two slots and the small flags. The record holds `modelMatrix`, `prevModelMatrix`, + `viewMatrix`, `rgbaLightIn`, `renderColor` and the remaining scalars. + - **GUI** (160-240 B): push holds slots, `rgbaIn`, `extraGlow`, `applyColor`, `noTexture`, + `overlayOpacity`; `modelViewMatrix`, `modelMatrix` and `projectionMatrix` go to the record. + - **Particles:** no DRAW uniforms; push holds only slots. + - **Fullscreen and post programs:** one draw per `Use()`, so push holds only slots and everything else is + record. + - **Held items (`standard`):** at most two draws a frame; slots and flags in push, the rest in the record. +- **The placement is authored in the source,** not computed at runtime. The manifest records it by reflection + (section 6), and Phase 4's measured uniform profile may move members later. +- **Dynamic UBO limit:** set 0 and the record are two dynamic uniform buffers in a layout that also holds an + update-after-bind set. The device floor therefore requires `maxDescriptorSetUpdateAfterBindUniformBuffersDynamic` + of at least 2 (`Core/DescriptorIndexingFloor.cs`). + +## 5. Defines: variant axes and specialization constants + +The prefix is `ShaderRegistry.registerDefaultShaderCodePrefixes`, `ShaderRegistry.cs:462-540`. + +- **A define stays a variant axis** (`#if`, compiled offline per value) when it changes a declaration: an input, + output, uniform, buffer or anything the other stage sees. The axes are: + - `TAAMOTION` together with `GBUFFER` (= `SSAOLEVEL > 0`). They fix the output set and the motion location + (2 or 4), exactly as `TAAMOTIONLOCATION` does today. + - `USEOIT` (entityanimated only). + - `USESSBO` (0 only for `Chunkshadowmap_NoSSBOs`). + - `GREEDYMESH` (the programs with two attribute layouts). + - `ALLOWDEPTHOFFSET`, `GLOWSUB`, `VEC3SCALE` (per-registration defines). +- **Every other define is a specialization constant** declared in `specialization.glsl`, used as + `if (OPTIMUM_BLOOM != 0)`: `FXAA`, `SSAOLEVEL` (its value), `NORMALVIEW`, `BLOOM`, `GODRAYS`, `FOAMEFFECT`, + `SHINYEFFECT`, `SHADOWQUALITY`, `WAVINGSTUFF`, `MINBRIGHT` (float), `GREEDYMESH_GRAD`. + - `DYNLIGHTS` becomes the loop bound `pointLightQuantity` over arrays fixed at `FrameGlobals.MaxDynamicLights`. + - `MAXANIMATEDELEMENTS` is fixed. +- **Consequences:** + - Declarations a spec-constant branch uses are declared unconditionally, so the name set of section 2 is + unaffected (the oracle already sees names inside inactive `#if`s). + - A settings change becomes a pipeline-key change, not a recompile. +- **Variant key** in the manifest: the sorted `NAME=value` list of the axis symbols the program branches on. + +## 6. Offline compile, manifest, reflection + +- **Tool:** `tools/shader-compiler` (C#, references the renderer for `ShaderCompiler`, `SetConvention` and + reflection), with three modes: + - `--build`: compiles every program and variant into `shaders-vk/*.spv` plus `shaders.manifest.json`. + - `--verify`: recompiles and compares hashes; this is the `make check-shaders-vk` gate. + - `--single`: one program. +- **Build wiring:** an MSBuild target runs it with a content-hash cache. Deploy puts the output beside the + renderer DLL, never under `assets/`. +- **Reflection** is a small SPIR-V reader in the renderer (`Shaders/SpirvReflection.cs`), not a new package. + - It reads `OpEntryPoint` interfaces, `OpName`/`OpMemberName`, and `OpDecorate`/`OpMemberDecorate` (`Location`, + `DescriptorSet`, `Binding`, `Offset`, `SpecId`) plus the type graph for sizes. + - Set and binding numbers are fixed by `bindings.glsl`, so reflection only confirms them. No SPIR-V + reflection dependency exists in the tree (integration map, section 5), and this avoids adding one to + packaging. +- **Manifest per program and variant:** + - the variant key; + - stage files with SHA-256; + - push members (name, type, offset, size) and record members; + - frame members; + - samplers (name, GLSL type, array kind, push offset, GLSL 330 order); + - storage bindings, vertex inputs, fragment outputs and `writtenOutputs`; + - specialization constants (id, name, type, default). + + Schema version and toolchain identity sit at the top. + +## 7. Motion: `include/motion.glsl` + +```glsl +vec2 optimumMotionVector(vec4 prevClip, vec2 renderSize, vec2 jitterPx); // prev - current, current unjittered +vec4 optimumWriteMotion(vec4 prevClip, vec2 renderSize, vec2 jitterPx, float reactive, float writerDepth); +vec4 optimumWriteReactiveOnly(float reactive); // rg = 0, a = 0 +``` + +- **Behind the previous camera** (`prevClip.w <= 1e-6`), `optimumWriteMotion` returns `vec4(0, 0, reactive, 0)`. + The frozen contract requires this: "a writer that bails out of its vector must still deliver b and zero only + rg and a" (`docs/temporal-frame-contract.md` section 3.2). + - Today `chunkliquidmotion`, `particlescube` and `taa-skymotion` do so. + - `entityanimated`, `standard` and `instanced` drop `reactive` on that branch. That contradicts the contract + and is fixed in the GLSL 330 writers first, with GPU tests, so native-vs-330 differential tests compare + like with like. + - `chunkopaque`, `chunktopsoil` and `decals` pass a literal 0, so nothing observable changes for them. +- **One exception:** `particlescube` keeps its writer depth on that branch (`a = gl_FragCoord.z`) and its + reactive of 1. It calls `optimumMotionVector` directly and states why. +- **What stays in each program:** the previous-position reconstruction (warp replay, skinning, instance + transforms, liquid waves, z-offset replay). The include starts where a previous clip position exists. +- **Enforcement:** a source test fails any assignment to `outMotion` outside the include's return values. + +## 8. Runtime + +- **Seam:** `VulkanDevice.LinkProgram`, before `ShaderTranslator.Translate`. +- **Lookup:** the manifest is looked up by (`PassName`, variant key built from the program's prefix defines). + - On a hit the program links from SPIR-V, and its placement table answers `GetUniformLocation`: frame, push + or record offset, or sampler index, keeping today's three location ranges as the outward shape. + - On a miss it falls back per program to the rewriter. +- **Log line:** one line reports `[Optimum] shaders: N native, M rewritten, K failed`. +- **Environment overrides:** + - `OPTIMUM_VK_NATIVE_SHADERS=0` forces the rewriter for A/B runs. + - `OPTIMUM_VK_SHADER_SOURCE=` compiles the source tree at runtime for the development loop. +- **Mod shaders:** they stay on the rewriter, retargeted to the same shared layout (handoff item 4, first half). diff --git a/sources/shaders-vk/include/bindings.glsl b/sources/shaders-vk/include/bindings.glsl index 0dd81569..dc459051 100644 --- a/sources/shaders-vk/include/bindings.glsl +++ b/sources/shaders-vk/include/bindings.glsl @@ -76,5 +76,8 @@ layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_CUBE_SHADO #define OPTIMUM_BINDING_FACE_DATA 0 #define OPTIMUM_BINDING_ANIMATION 1 #define OPTIMUM_BINDING_ANIMATION_PREV 2 +// The program record (docs/vulkan-native-shaders.md section 4): a dynamic uniform +// buffer with every non-frame uniform that is not in the push block. +#define OPTIMUM_BINDING_PROGRAM_RECORD 3 #endif From ed169967490f100452aa9ef8f6415dbc844d6ec5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:18:53 +0200 Subject: [PATCH 149/226] fix(scripts): keep shader trees out of the working-tree source sync bootstrap.sh and worktree-bootstrap.sh copied sources/shaders-vk (and bootstrap.sh also sources/shaderincludes) to the repository root, where a stale copy could be edited instead of the real tree that deploy, the packagers and the shader compiler read. Both now skip lang, shaders, shaderincludes and shaders-vk. Verified: BootstrapSourceSyncCoverageTests 2/2; bash -n on both scripts. --- .../bootstrap-source-sync-coverage-tests.cs | 41 +++++++++++++++++++ scripts/bootstrap.sh | 9 ++-- scripts/dev/worktree-bootstrap.sh | 3 +- 3 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 Optimum.Tests/bootstrap-source-sync-coverage-tests.cs diff --git a/Optimum.Tests/bootstrap-source-sync-coverage-tests.cs b/Optimum.Tests/bootstrap-source-sync-coverage-tests.cs new file mode 100644 index 00000000..648c590d --- /dev/null +++ b/Optimum.Tests/bootstrap-source-sync-coverage-tests.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// bootstrap.sh and worktree-bootstrap.sh copy Optimum-only files from sources/ into the +/// working tree, where the fork projects are built. Asset overlays and the native shader +/// tree are not project source: deploy, the packagers and the shader compiler read them +/// from sources/ directly. A root copy of one of them is stale the moment the real file +/// is edited, and an edit made to the copy is silently lost. +/// +public class BootstrapSourceSyncCoverageTests +{ + private static readonly string[] SourceOnlyTrees = { "lang", "shaders", "shaderincludes", "shaders-vk" }; + + [Theory] + [InlineData("scripts/bootstrap.sh")] + [InlineData("scripts/dev/worktree-bootstrap.sh")] + public void TheSourceSyncSkipsEveryTreeThatIsReadFromSourcesDirectly(string script) + { + string text = File.ReadAllText(Path.Combine(Root(), script)); + Match skip = Regex.Match(text, @"case ""\$top(?:_proj)?"" in\s+([a-z|\-]+)\)\s+continue"); + Assert.True(skip.Success, script + " no longer has the sources/ skip list"); + + string[] skipped = skip.Groups[1].Value.Split('|'); + foreach (string tree in SourceOnlyTrees) + { + Assert.Contains(tree, skipped); + } + } + + private static string Root() + { + string root = Directory.GetCurrentDirectory(); + while (!Directory.Exists(Path.Combine(root, "Optimum.Patcher"))) root = Directory.GetParent(root)!.FullName; + return root; + } +} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index f29402c9..cfc96a62 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -1547,11 +1547,12 @@ if [[ -d "$sources_dir" ]]; then rel="${src#$sources_dir/}" # For vanilla (decompiled) projects, the working tree is under build/. top_proj="$(echo "$rel" | cut -d/ -f1)" - # lang/ and shaders/ are deploy-time asset overlays, not project source. - # deploy and the package scripts read them from sources/ directly; copying - # them here dumped stray lang/ and shaders/ dirs at the repo root. + # lang/, shaders/, shaderincludes/ and shaders-vk/ are asset or native shader + # trees, not project source: deploy, the package scripts and the shader + # compiler read them from sources/ directly; copying them here dumped stray + # directories at the repo root that could be edited in place of the real ones. case "$top_proj" in - lang|shaders) continue ;; + lang|shaders|shaderincludes|shaders-vk) continue ;; esac if echo "$vanilla_patch_projects" | grep -qw "$top_proj"; then target="$repo_root/build/$rel" diff --git a/scripts/dev/worktree-bootstrap.sh b/scripts/dev/worktree-bootstrap.sh index 470333c9..4d7d275c 100755 --- a/scripts/dev/worktree-bootstrap.sh +++ b/scripts/dev/worktree-bootstrap.sh @@ -92,7 +92,8 @@ done < <(find "$wt/patches" -type f -name '*.patch' -not -path '*/runtime/*' -pr while IFS= read -r -d '' src; do rel="${src#$wt/sources/}" top="$(cut -d/ -f1 <<<"$rel")" - case "$top" in lang|shaders|shaderincludes) continue ;; esac + # Deploy-time and native shader trees are read from sources/ directly; a root copy is stale clutter. + case "$top" in lang|shaders|shaderincludes|shaders-vk) continue ;; esac if is_vanilla_project "$top"; then target="$wt/build/$rel"; else target="$wt/$rel"; fi mkdir -p "$(dirname "$target")" cp -f "$src" "$target" From 2fcb32060ccbee903665aafbf04bb258c33e94ad Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:22:18 +0200 Subject: [PATCH 150/226] wip(bindless): decision 9 set 1 texture table and the shared pipeline layout, device-internal BindlessTextureTable (Core/BindlessTextureTable.cs): one set layout from SetConvention.TextureArrays (every binding PARTIALLY_BOUND | UPDATE_AFTER_BIND, UPDATE_AFTER_BIND_POOL, capacities clamped to the descriptor-indexing limits), its own update-after-bind pool, exactly one set. Per-kind placeholders in slot 0 (magenta colour, 2D array, cube, 3D, uint, sint, far-plane depth for the three shadow kinds); every slot is filled with its placeholder at creation. BindlessSlots.cs holds the device-free part: kinds, the TextureSuitsSampler rules per kind, the effective sampler state, a LIFO allocator per binding that never hands out slot 0, and the slot book keyed on (VulkanTexture.Id, kind, effective state, layout). A live slot is never rewritten; a deleted texture's slots retire against FrameRecorded and get the placeholder back, then return to the free list, at the first frame start whose FrameCompleted passed that value. TextureManager.Delete raises Deleted, which the device routes to the table. Writes are queued and applied with one vkUpdateDescriptorSets per flush. SharedPipelineLayout (Core/SharedPipelineLayout.cs): set 0 FrameGlobals dynamic UBO plus the frame textures, set 1 the table's layout, set 2 the storage buffers, one 128-byte push constant range for vertex and fragment. Created at bring-up, destroyed after the idle wait. No existing draw uses either yet. Poison fix (TextureManager.Poison): the poison clear now ends with a transition to the shader-readable layout. The tracker models the clear and a following copy as the same Transfer write, so the first upload after a poison clear got no barrier and synchronization validation reported WRITE_AFTER_WRITE (write_barriers = 0, seq_no 2). It showed on the first texture of a poisoned device, and moving placeholder creation order moved it with that texture, so the defect was the renderer's, not the table's. This was fixed at the cause, with no allowlist entry. Deviations, with reasons: - Flush runs at frame start and also before every frame submission (Present and partial submits). A slot first resolved while recording must be written before the command buffer naming it is submitted; a frame-start-only flush leaves such a draw sampling an unwritten descriptor for a frame. Update-after-bind permits writes between bind and submit (docs/research/vulkan-bindless.md, section 2). - A changed sampler state or layout gets a new slot, and the old one retires once a texture holds more than 4 live keys (least recently used first), not immediately. A texture that alternates between two states per draw, such as a unit sampler override, would otherwise allocate and retire a slot every draw and exhaust the arrays within a frame. - The compare-mode check at slot creation normalises the state rather than rejecting it: shadow kinds always compare, others never, and integer kinds sample nearest. A mismatch gives poison texels the layers never report (research section 1, Texel Input Validation); the declaration is what the shader samples through. A texture that cannot sit behind the kind (view type, depth, signedness) still resolves to slot 0. - Stats are counters on VulkanStats (flushes, writes, placeholder resolutions) plus live slots and pending retirements on the table, not a new stats.* line. The stats sample's line count and every token are pinned by PacingStatsTests and docs/taa-acceptance.md, which "the existing suite stays unchanged" rules out editing. - TextureManager.CreateVolume: the sampler3D placeholder needs a 3D image, and the texture manager only created 2D ones. Verified (implicit Vulkan layers disabled; VK_LOADER_DEBUG shows only VK_LAYER_MESA_device_select; sync,best validation through GpuTest): - BindlessTextureTableTests: 51 cases, run together with PoisonModeTests, all pass. Unit cases: LIFO reuse with slot 0 never handed out, a retired slot not reused before its Frame value completes, state and layout keys, the variant cap, full arrays, the kind table (23 shapes), the effective state, capacity clamping. GPU cases, read back only at the end with no validation error or SYNC- hazard: two textures sampled from one set in one frame (poison off and on); a deleted texture whose GL id goes to a new texture over 6 presented frames, where the new slot samples green, the old slot samples the placeholder once freed, and the next texture reuses the old slot; a shadow slot compares 0.5 against references 0.25 and 0.75, and the placeholder passes; wrong-kind requests resolve to slot 0 and sample magenta. - dotnet test Optimum.Render.Vulkan.Tests: 715 passed, 0 failed, 0 skipped. - dotnet test Optimum.Tests -c Release: 1177 passed, 0 failed, 34 skipped. The first run failed only Smoke_VintagestoryLibDll_Exists, because the Release VintagestoryLib was not yet built in this worktree; it passed after building it. --- .../BindlessTextureTableTests.cs | 691 ++++++++++++++++++ Optimum.Render.Vulkan/Core/BindlessSlots.cs | 354 +++++++++ .../Core/BindlessTextureTable.cs | 425 +++++++++++ .../Core/SharedPipelineLayout.cs | 134 ++++ Optimum.Render.Vulkan/Core/TextureManager.cs | 101 +++ Optimum.Render.Vulkan/Core/VulkanStats.cs | 21 + Optimum.Render.Vulkan/VulkanDevice.cs | 73 ++ 7 files changed, 1799 insertions(+) create mode 100644 Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs create mode 100644 Optimum.Render.Vulkan/Core/BindlessSlots.cs create mode 100644 Optimum.Render.Vulkan/Core/BindlessTextureTable.cs create mode 100644 Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs diff --git a/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs new file mode 100644 index 00000000..3672b7b1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs @@ -0,0 +1,691 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Plan decision 9's set 1: the bindless texture table and the shared pipeline +/// layout. The slot bookkeeping is judged without a device (LIFO reuse, slot 0 +/// reserved, retirement held until its Frame value completes, keys and kinds); the +/// device tests sample slots from a shader that includes bindings.glsl, across +/// presented frames, and read back only at the end. +/// +public class BindlessTextureTableTests +{ + private readonly ITestOutputHelper _output; + + public BindlessTextureTableTests(ITestOutputHelper output) => _output = output; + + private sealed class FakeClock : ITimelineClock + { + public ulong FrameRecorded { get; set; } + public ulong TransferRecorded { get; set; } + public ulong FrameCompleted { get; set; } + public ulong TransferCompleted { get; set; } + } + + private static uint[] Capacities(uint each) + { + var capacities = new uint[BindlessKinds.Count]; + Array.Fill(capacities, each); + return capacities; + } + + private static BindlessSlotKey Key(ulong texture, SamplerState? state = null, + ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal, TextureKind kind = TextureKind.Texture2D) => + new(texture, kind, state ?? SamplerState.Default, layout); + + // ------------------------------------------------------------ allocator + + [Fact] + public void SlotZeroIsNeverHandedOutAndFreedSlotsComeBackLastInFirstOut() + { + var allocator = new BindlessSlotAllocator(8); + var handed = new List(); + for (int i = 0; i < 7; i++) + { + Assert.True(allocator.TryAllocate(out uint slot)); + handed.Add(slot); + } + Assert.Equal(new uint[] { 1, 2, 3, 4, 5, 6, 7 }, handed); + Assert.False(allocator.TryAllocate(out uint none)); + Assert.Equal(0u, none); + + allocator.Free(3); + allocator.Free(6); + Assert.True(allocator.TryAllocate(out uint first)); + Assert.True(allocator.TryAllocate(out uint second)); + Assert.Equal(6u, first); + Assert.Equal(3u, second); + Assert.Equal(7, allocator.Live); + + Assert.Throws(() => allocator.Free(0)); + } + + // ----------------------------------------------------------------- book + + [Fact] + public void ARetiredSlotIsNotReusedBeforeItsFrameValueCompletes() + { + var clock = new FakeClock { FrameRecorded = 5, FrameCompleted = 3 }; + var book = new BindlessSlotBook(clock, Capacities(4)); + + uint retired = book.Acquire(Key(100), out bool created); + Assert.True(created); + Assert.Equal(1u, retired); + Assert.Equal(1, book.Release(100)); + Assert.Equal(1, book.PendingRetirements); + + var freed = new List<(TextureKind, uint)>(); + clock.FrameCompleted = 4; + Assert.Equal(0, book.Collect(freed)); + Assert.Empty(freed); + + // Frame 5 may still sample slot 1: a new texture gets another slot. + uint other = book.Acquire(Key(101), out _); + Assert.NotEqual(retired, other); + Assert.NotEqual(0u, other); + + clock.FrameCompleted = 5; + Assert.Equal(1, book.Collect(freed)); + Assert.Equal(new[] { (TextureKind.Texture2D, retired) }, freed); + Assert.Equal(0, book.PendingRetirements); + + Assert.Equal(retired, book.Acquire(Key(102), out bool reused)); + Assert.True(reused); + } + + [Fact] + public void AChangedSamplerStateOrLayoutGetsANewSlotAndAnUnchangedKeyKeepsItsSlot() + { + var clock = new FakeClock(); + var book = new BindlessSlotBook(clock, Capacities(16)); + + uint plain = book.Acquire(Key(7), out bool created); + Assert.True(created); + Assert.Equal(plain, book.Acquire(Key(7), out bool again)); + Assert.False(again); + + uint linear = book.Acquire(Key(7, SamplerState.Default with { MagFilter = Filter.Linear }), out bool linearCreated); + uint depthReadOnly = book.Acquire(Key(7, layout: ImageLayout.DepthReadOnlyOptimal), out bool layoutCreated); + Assert.True(linearCreated); + Assert.True(layoutCreated); + Assert.Equal(3, new HashSet { plain, linear, depthReadOnly }.Count); + + // The same state in another kind's array is another slot of that array. + uint arrayed = book.Acquire(Key(7, kind: TextureKind.Texture2DArray), out _); + Assert.Equal(1, book.LiveSlots(TextureKind.Texture2DArray)); + Assert.Equal(1u, arrayed); + Assert.Equal(0, book.PendingRetirements); + } + + [Fact] + public void PastTheVariantCapTheLeastRecentlyUsedKeyOfATextureRetires() + { + var clock = new FakeClock { FrameRecorded = 1 }; + var book = new BindlessSlotBook(clock, Capacities(32)); + + var states = new SamplerState[BindlessSlotBook.MaxVariantsPerTexture + 1]; + for (int i = 0; i < states.Length; i++) states[i] = SamplerState.Default with { LodBias = i }; + + uint oldest = book.Acquire(Key(9, states[0]), out _); + for (int i = 1; i < BindlessSlotBook.MaxVariantsPerTexture; i++) book.Acquire(Key(9, states[i]), out _); + // Touch the oldest: it becomes the most recently used, states[1] the least. + Assert.Equal(oldest, book.Acquire(Key(9, states[0]), out _)); + Assert.Equal(0, book.PendingRetirements); + + book.Acquire(Key(9, states[^1]), out _); + Assert.Equal(1, book.PendingRetirements); + Assert.Equal(oldest, book.Acquire(Key(9, states[0]), out bool kept)); + Assert.False(kept); + book.Acquire(Key(9, states[1]), out bool recreated); + Assert.True(recreated); + } + + [Fact] + public void AFullArrayResolvesToThePlaceholderAndCountsIt() + { + var book = new BindlessSlotBook(new FakeClock(), Capacities(2)); + Assert.Equal(1u, book.Acquire(Key(1), out _)); + Assert.Equal(0u, book.Acquire(Key(2), out bool created)); + Assert.False(created); + Assert.Equal(1, book.Exhausted); + } + + // ---------------------------------------------------------------- kinds + + [Fact] + public void KindsFollowTheConventionTable() + { + Assert.Equal(SetConvention.TextureArrays.Length, BindlessKinds.Count); + Assert.Equal(BindlessKinds.Count, Enum.GetValues().Length); + for (int i = 0; i < SetConvention.TextureArrays.Length; i++) + { + SetConvention.Binding binding = SetConvention.TextureArrays[i]; + Assert.True(BindlessKinds.TryFromGlslType(binding.GlslType, out TextureKind kind)); + Assert.Equal((TextureKind)i, kind); + Assert.Equal((uint)binding.Value, BindlessKinds.BindingOf(kind)); + Assert.Equal(binding.GlslType.Contains("Shadow", StringComparison.Ordinal), BindlessKinds.IsShadow(kind)); + Assert.Equal(binding.GlslType[0] is 'u' or 'i', BindlessKinds.IsInteger(kind)); + } + Assert.False(BindlessKinds.TryFromGlslType("sampler1D", out _)); + Assert.False(BindlessKinds.TryFromGlslType("sampler2DMS", out _)); + } + + [Theory] + // Colour 2D. + [InlineData(Format.R8G8B8A8Unorm, 1u, false, false, (int)TextureKind.Texture2D, true)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, false, (int)TextureKind.Texture2DArray, false)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, false, (int)TextureKind.TextureCube, false)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, false, (int)TextureKind.Texture3D, false)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, false, (int)TextureKind.Shadow2D, false)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, false, (int)TextureKind.UnsignedTexture2D, false)] + // Arrays, cubes, volumes. + [InlineData(Format.R16G16B16A16Sfloat, 3u, false, false, (int)TextureKind.Texture2DArray, true)] + [InlineData(Format.R16G16B16A16Sfloat, 3u, false, false, (int)TextureKind.Texture2D, false)] + [InlineData(Format.R8G8B8A8Unorm, 6u, true, false, (int)TextureKind.TextureCube, true)] + [InlineData(Format.R8G8B8A8Unorm, 6u, true, false, (int)TextureKind.Texture2DArray, false)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, true, (int)TextureKind.Texture3D, true)] + [InlineData(Format.R8G8B8A8Unorm, 1u, false, true, (int)TextureKind.Texture2D, false)] + // Depth: through sampler2D as on GL, and behind every shadow kind of its shape. + [InlineData(Format.D32Sfloat, 1u, false, false, (int)TextureKind.Texture2D, true)] + [InlineData(Format.D32Sfloat, 1u, false, false, (int)TextureKind.Shadow2D, true)] + [InlineData(Format.D32Sfloat, 1u, false, false, (int)TextureKind.Shadow2DArray, false)] + [InlineData(Format.D24UnormS8Uint, 2u, false, false, (int)TextureKind.Shadow2DArray, true)] + [InlineData(Format.D32Sfloat, 6u, true, false, (int)TextureKind.ShadowCube, true)] + [InlineData(Format.D32Sfloat, 1u, false, false, (int)TextureKind.SignedTexture2D, false)] + // Integer formats only behind the matching signedness. + [InlineData(Format.R32Uint, 1u, false, false, (int)TextureKind.UnsignedTexture2D, true)] + [InlineData(Format.R32Uint, 1u, false, false, (int)TextureKind.SignedTexture2D, false)] + [InlineData(Format.R32Uint, 1u, false, false, (int)TextureKind.Texture2D, false)] + [InlineData(Format.R16Sint, 1u, false, false, (int)TextureKind.SignedTexture2D, true)] + [InlineData(Format.R16Sint, 1u, false, false, (int)TextureKind.UnsignedTexture2D, false)] + public void KindDerivationFollowsFormatAndViewShape(Format format, uint layers, bool cube, bool volume, + int kind, bool suits) + { + Assert.Equal(suits, BindlessKinds.Suits(new TextureShape(format, layers, cube, volume), (TextureKind)kind)); + } + + [Fact] + public void TheSlotStateComparesExactlyWhenTheDeclarationDoesAndSamplesIntegersNearest() + { + SamplerState linearCompare = SamplerState.Default with + { + MagFilter = Filter.Linear, MinFilter = Filter.Linear, MipmapMode = SamplerMipmapMode.Linear, + CompareEnable = true, MaxAnisotropy = 8f, + }; + Assert.False(BindlessKinds.EffectiveState(linearCompare, TextureKind.Texture2D).CompareEnable); + Assert.True(BindlessKinds.EffectiveState(SamplerState.Default, TextureKind.Shadow2D).CompareEnable); + Assert.Equal(Filter.Linear, BindlessKinds.EffectiveState(linearCompare, TextureKind.Shadow2D).MagFilter); + + SamplerState integer = BindlessKinds.EffectiveState(linearCompare, TextureKind.UnsignedTexture2D); + Assert.Equal(Filter.Nearest, integer.MagFilter); + Assert.Equal(Filter.Nearest, integer.MinFilter); + Assert.Equal(SamplerMipmapMode.Nearest, integer.MipmapMode); + Assert.Equal(1f, integer.MaxAnisotropy); + Assert.False(integer.CompareEnable); + } + + [Fact] + public void CapacitiesAreTheConventionSizesAtTheFloorAndScaleDownBelowIt() + { + var atFloor = new DescriptorIndexingSupport(true, true, true, true, + DescriptorIndexingFloor.RequiredSampledImages, DescriptorIndexingFloor.RequiredSampledImages, + DescriptorIndexingFloor.RequiredSampledImages, DescriptorIndexingFloor.RequiredSampledImages, 1, 128); + uint[] full = BindlessKinds.ClampCapacities(atFloor, DescriptorIndexingFloor.FrameTextures); + for (int i = 0; i < full.Length; i++) Assert.Equal(SetConvention.TextureArrays[i].Capacity, full[i]); + + uint[] halved = BindlessKinds.ClampCapacities( + atFloor with { MaxDescriptorSetUpdateAfterBindSamplers = DescriptorIndexingFloor.RequiredSampledImages / 2 }, + DescriptorIndexingFloor.FrameTextures); + ulong total = 0; + foreach (uint capacity in halved) + { + Assert.True(capacity >= 2); + total += capacity; + } + Assert.True(total <= DescriptorIndexingFloor.RequiredSampledImages / 2 - DescriptorIndexingFloor.FrameTextures); + Assert.True(halved[(int)TextureKind.Texture2D] > halved[(int)TextureKind.ShadowCube]); + } + + // ---------------------------------------------------------------- device + + private const int Size = 4; + private const int GlRgba8 = 0x8058; + private const int GlDepth32F = 0x8CAC; + + private static readonly byte[] Red = { 255, 0, 0, 255 }; + private static readonly byte[] Green = { 0, 255, 0, 255 }; + private static readonly byte[] Blue = { 0, 0, 255, 255 }; + private static readonly byte[] Magenta = { 255, 0, 255, 255 }; + + private const string VertexSource = """ + #version 450 + void main() + { + vec2 corner = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); + } + """; + + private const string FragmentBody = """ + + layout(push_constant) uniform Push + { + uint slot; + uint kind; + float reference; + } pc; + + layout(location = 0) out vec4 outColor; + + void main() + { + if (pc.kind == 6u) + { + float lit = texture(optimumTextures2DShadow[pc.slot], vec3(0.5, 0.5, pc.reference)); + outColor = vec4(lit, 0.0, 0.0, 1.0); + } + else + { + outColor = texture(optimumTextures2D[pc.slot], vec2(0.5)); + } + } + """; + + /// A headless device with a pipeline on the shared layout that samples set 1 by push-constant slot. + private sealed unsafe class Harness : IDisposable + { + public VulkanDevice Device = null!; + public Pipeline Pipeline; + private ShaderModule _vertex; + private ShaderModule _fragment; + + public BindlessTextureTable Table => Device.BindlessForTests; + + public void Dispose() + { + VulkanContext context = Device.ContextForTests; + if (context != null) + { + // The last frame may still name the pipeline. + VulkanStats.WaitDeviceIdle(context.Api, context.Device); + if (Pipeline.Handle != 0) context.Api.DestroyPipeline(context.Device, Pipeline, null); + if (_vertex.Handle != 0) context.Api.DestroyShaderModule(context.Device, _vertex, null); + if (_fragment.Handle != 0) context.Api.DestroyShaderModule(context.Device, _fragment, null); + } + Device.Dispose(); + } + + public void CreatePipeline(ShaderCompiler compiler) + { + string include = File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, SetConvention.IncludePath)) + .Replace("\r\n", "\n"); + ShaderCompileResult vertex = compiler.Compile(VertexSource, "bindless-probe.vert", EnumShaderType.VertexShader); + Assert.True(vertex.Success, vertex.Error); + ShaderCompileResult fragment = compiler.Compile("#version 450\n" + include + FragmentBody, + "bindless-probe.frag", EnumShaderType.FragmentShader); + Assert.True(fragment.Success, fragment.Error); + + VulkanContext context = Device.ContextForTests; + _vertex = Module(context, vertex.Spirv); + _fragment = Module(context, fragment.Spirv); + + byte* entry = (byte*)SilkMarshal.StringToPtr("main"); + try + { + PipelineShaderStageCreateInfo* stages = stackalloc PipelineShaderStageCreateInfo[2]; + stages[0] = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.VertexBit, Module = _vertex, PName = entry, + }; + stages[1] = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.FragmentBit, Module = _fragment, PName = entry, + }; + var vertexInput = new PipelineVertexInputStateCreateInfo { SType = StructureType.PipelineVertexInputStateCreateInfo }; + var inputAssembly = new PipelineInputAssemblyStateCreateInfo + { + SType = StructureType.PipelineInputAssemblyStateCreateInfo, + Topology = PrimitiveTopology.TriangleList, + }; + var viewport = new PipelineViewportStateCreateInfo + { + SType = StructureType.PipelineViewportStateCreateInfo, ViewportCount = 1, ScissorCount = 1, + }; + var rasterizer = new PipelineRasterizationStateCreateInfo + { + SType = StructureType.PipelineRasterizationStateCreateInfo, + PolygonMode = PolygonMode.Fill, CullMode = CullModeFlags.None, + FrontFace = FrontFace.CounterClockwise, LineWidth = 1f, + }; + var multisample = new PipelineMultisampleStateCreateInfo + { + SType = StructureType.PipelineMultisampleStateCreateInfo, + RasterizationSamples = SampleCountFlags.Count1Bit, + }; + var attachment = new PipelineColorBlendAttachmentState + { + ColorWriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit | + ColorComponentFlags.BBit | ColorComponentFlags.ABit, + }; + var blend = new PipelineColorBlendStateCreateInfo + { + SType = StructureType.PipelineColorBlendStateCreateInfo, AttachmentCount = 1, PAttachments = &attachment, + }; + DynamicState* dynamics = stackalloc DynamicState[] { DynamicState.Viewport, DynamicState.Scissor }; + var dynamic = new PipelineDynamicStateCreateInfo + { + SType = StructureType.PipelineDynamicStateCreateInfo, DynamicStateCount = 2, PDynamicStates = dynamics, + }; + Format colour = Format.R8G8B8A8Unorm; + var rendering = new PipelineRenderingCreateInfo + { + SType = StructureType.PipelineRenderingCreateInfo, ColorAttachmentCount = 1, PColorAttachmentFormats = &colour, + }; + var info = new GraphicsPipelineCreateInfo + { + SType = StructureType.GraphicsPipelineCreateInfo, + PNext = &rendering, + StageCount = 2, + PStages = stages, + PVertexInputState = &vertexInput, + PInputAssemblyState = &inputAssembly, + PViewportState = &viewport, + PRasterizationState = &rasterizer, + PMultisampleState = &multisample, + PColorBlendState = &blend, + PDynamicState = &dynamic, + Layout = Device.SharedLayoutForTests.Layout, + }; + Pipeline pipeline; + Assert.Equal(Result.Success, + context.Api.CreateGraphicsPipelines(context.Device, default, 1, &info, null, &pipeline)); + Pipeline = pipeline; + } + finally + { + SilkMarshal.Free((nint)entry); + } + } + + private static ShaderModule Module(VulkanContext context, byte[] spirv) + { + fixed (byte* code = spirv) + { + var info = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + ShaderModule module; + Assert.Equal(Result.Success, context.Api.CreateShaderModule(context.Device, &info, null, &module)); + return module; + } + } + + public int Texture(byte[] texel) + { + fixed (byte* pixels = texel) return Device.CreateTexture2DRaw(1, 1, GlRgba8, (IntPtr)pixels, 4); + } + + public int DepthTexture(float depth) => Device.CreateTexture2DRaw(1, 1, GlDepth32F, (IntPtr)(&depth), 4); + + /// A colour texture with a framebuffer around it; returns both. + public (int Texture, int Framebuffer) Target() + { + int texture = Device.CreateTexture2DRaw(Size, Size, GlRgba8, IntPtr.Zero, 4); + int framebuffer = Device.CreateFramebuffer(Size, Size); + Device.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + Device.SetDrawBuffers(framebuffer, 1); + return (texture, framebuffer); + } + + /// Clears the target and draws the probe sampling of 's array. + public void Draw((int Texture, int Framebuffer) target, uint slot, TextureKind kind = TextureKind.Texture2D, + float reference = 0f, params int[] sampled) + { + Device.BindFramebuffer(target.Framebuffer); + Device.ClearColor(0, 0f, 0f, 0f, 1f); + var push = new byte[12]; + BitConverter.TryWriteBytes(push.AsSpan(0, 4), slot); + BitConverter.TryWriteBytes(push.AsSpan(4, 4), (uint)kind); + BitConverter.TryWriteBytes(push.AsSpan(8, 4), reference); + Device.DrawBindlessForTests(Pipeline, Size, Size, push, sampled); + } + + public byte[] Pixel((int Texture, int Framebuffer) target) => Device.ReadBackLevel0ForTests(target.Texture)[..4]; + } + + private bool TryCreateHarness(bool poison, out Harness? harness, out ShaderCompiler? compiler) + { + harness = null; + compiler = null; + try + { + compiler = new ShaderCompiler(); + } + catch (Exception error) when (error is DllNotFoundException or InvalidOperationException) + { + _output.WriteLine("shaderc unavailable: " + error.Message); + return false; + } + + VulkanDevice device = GpuTest.NewDevice(); + Action? configure = device.ConfigureContextOptions; + device.ConfigureContextOptions = options => + { + configure?.Invoke(options); + options.Poison = poison; + }; + if (!device.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + _output.WriteLine("Vulkan unavailable: " + failureReason); + device.Dispose(); + compiler.Dispose(); + compiler = null; + return false; + } + + harness = new Harness { Device = device }; + harness.CreatePipeline(compiler); + return true; + } + + /// + /// Two textures resolve to two slots of the same set, and one frame samples + /// both through the same pipeline and bound set: each target shows its own + /// texture. The table comes up at the convention's sizes with one layout per set. + /// + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void TwoTexturesInOneSetSampleTheirOwnColoursInOneFrame(bool poison) + { + Skip.IfNot(TryCreateHarness(poison, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); + using (compiler) + using (harness) + { + VulkanDevice device = harness!.Device; + BindlessTextureTable table = harness.Table; + Assert.Equal(poison, device.ContextForTests.PoisonFreshResources); + foreach (TextureKind kind in Enum.GetValues()) + { + Assert.Equal(BindlessKinds.ConventionCapacity(kind), table.CapacityOf(kind)); + } + SharedPipelineLayout shared = device.SharedLayoutForTests; + Assert.NotEqual(0ul, shared.Layout.Handle); + Assert.Equal(table.Layout.Handle, shared.TextureSetLayout.Handle); + + int red = harness.Texture(Red); + int green = harness.Texture(Green); + var first = harness.Target(); + var second = harness.Target(); + + device.BeginFrame(); + uint redSlot = table.Resolve(red, TextureKind.Texture2D, SamplerState.Default); + uint greenSlot = table.Resolve(green, TextureKind.Texture2D, SamplerState.Default); + Assert.NotEqual(0u, redSlot); + Assert.NotEqual(0u, greenSlot); + Assert.NotEqual(redSlot, greenSlot); + Assert.Equal(2, table.PendingWrites); + harness.Draw(first, redSlot, sampled: red); + harness.Draw(second, greenSlot, sampled: green); + device.Present(); + Assert.Equal(0, table.PendingWrites); + + Assert.Equal(Red, harness.Pixel(first)); + Assert.Equal(Green, harness.Pixel(second)); + Assert.Equal(2, table.LiveSlots(TextureKind.Texture2D)); + GpuTest.AssertClean(device); + } + } + + /// + /// A texture is deleted and its GL id goes to a new texture. The new texture gets + /// a new slot; the old slot keeps serving nothing new until the Frame value of the + /// deletion completes, then holds the placeholder, over several presented frames + /// with no readback among them; and the next texture reuses it. + /// + [SkippableFact] + public void ADeletedTexturesSlotHoldsThePlaceholderUntilReusedWhileItsGlIdServesTheNewTexture() + { + Skip.IfNot(TryCreateHarness(false, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); + using (compiler) + using (harness) + { + VulkanDevice device = harness!.Device; + BindlessTextureTable table = harness.Table; + var retiredTarget = harness.Target(); + var liveTarget = harness.Target(); + + int red = harness.Texture(Red); + device.BeginFrame(); + uint redSlot = table.Resolve(red, TextureKind.Texture2D, SamplerState.Default); + harness.Draw(retiredTarget, redSlot, sampled: red); + device.Present(); + + device.DeleteTexture(red); + Assert.Equal(1, table.PendingRetirements); + + int green = harness.Texture(Green); + Assert.Equal(red, green); + uint greenSlot = table.Resolve(green, TextureKind.Texture2D, SamplerState.Default); + Assert.NotEqual(0u, greenSlot); + Assert.NotEqual(redSlot, greenSlot); + + int placeholderFrames = 0; + for (int frame = 0; frame < 6; frame++) + { + device.BeginFrame(); + harness.Draw(liveTarget, greenSlot, sampled: green); + // A retired slot is only sampled once the table has freed it: before + // that its descriptor names an image the timeline is about to destroy. + if (table.PendingRetirements == 0) + { + harness.Draw(retiredTarget, redSlot); + placeholderFrames++; + } + device.Present(); + } + Assert.True(placeholderFrames >= 3, "the retired slot was freed after " + (6 - placeholderFrames) + " frames"); + + Assert.Equal(Magenta, harness.Pixel(retiredTarget)); + Assert.Equal(Green, harness.Pixel(liveTarget)); + + int blue = harness.Texture(Blue); + uint blueSlot = table.Resolve(blue, TextureKind.Texture2D, SamplerState.Default); + Assert.Equal(redSlot, blueSlot); + device.BeginFrame(); + harness.Draw(retiredTarget, blueSlot, sampled: blue); + device.Present(); + Assert.Equal(Blue, harness.Pixel(retiredTarget)); + + GpuTest.AssertClean(device); + } + } + + /// + /// A depth texture behind the shadow array compares with the slot's sampler + /// (its own compare mode off: the declaration decides), and the shadow + /// placeholder at slot 0 is the far plane, which every reference passes. + /// + [SkippableFact] + public void AShadowSlotComparesAgainstTheStoredDepth() + { + Skip.IfNot(TryCreateHarness(false, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); + using (compiler) + using (harness) + { + VulkanDevice device = harness!.Device; + BindlessTextureTable table = harness.Table; + int depth = harness.DepthTexture(0.5f); + var nearer = harness.Target(); + var farther = harness.Target(); + var placeholder = harness.Target(); + + device.BeginFrame(); + uint slot = table.Resolve(depth, TextureKind.Shadow2D, SamplerState.Default); + Assert.NotEqual(0u, slot); + Assert.Equal(slot, table.Resolve(depth, TextureKind.Shadow2D, SamplerState.Default with { CompareEnable = true })); + harness.Draw(nearer, slot, TextureKind.Shadow2D, 0.25f, depth); + harness.Draw(farther, slot, TextureKind.Shadow2D, 0.75f, depth); + harness.Draw(placeholder, 0, TextureKind.Shadow2D, 0.9f); + device.Present(); + + Assert.Equal(new byte[] { 255, 0, 0, 255 }, harness.Pixel(nearer)); + Assert.Equal(new byte[] { 0, 0, 0, 255 }, harness.Pixel(farther)); + Assert.Equal(new byte[] { 255, 0, 0, 255 }, harness.Pixel(placeholder)); + GpuTest.AssertClean(device); + } + } + + /// + /// A texture asked for as a kind it cannot sit behind, or no texture at all, + /// resolves to slot 0 and allocates nothing; slot 0 samples the placeholder. + /// + [SkippableFact] + public void AWrongKindRequestResolvesToThePlaceholderSlot() + { + Skip.IfNot(TryCreateHarness(false, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); + using (compiler) + using (harness) + { + VulkanDevice device = harness!.Device; + BindlessTextureTable table = harness.Table; + int colour = harness.Texture(Red); + int depth = harness.DepthTexture(0.5f); + var target = harness.Target(); + + long before = table.PlaceholderResolutions; + Assert.Equal(0u, table.Resolve(colour, TextureKind.Shadow2D, SamplerState.Default)); + Assert.Equal(0u, table.Resolve(colour, TextureKind.Texture2DArray, SamplerState.Default)); + Assert.Equal(0u, table.Resolve(colour, TextureKind.TextureCube, SamplerState.Default)); + Assert.Equal(0u, table.Resolve(colour, TextureKind.Texture3D, SamplerState.Default)); + Assert.Equal(0u, table.Resolve(colour, TextureKind.UnsignedTexture2D, SamplerState.Default)); + Assert.Equal(0u, table.Resolve(depth, TextureKind.SignedTexture2D, SamplerState.Default)); + Assert.Equal(0u, table.Resolve(0, TextureKind.Texture2D, SamplerState.Default)); + Assert.Equal(before + 7, table.PlaceholderResolutions); + foreach (TextureKind kind in Enum.GetValues()) Assert.Equal(0, table.LiveSlots(kind)); + Assert.Equal(0, table.PendingWrites); + + device.BeginFrame(); + harness.Draw(target, 0); + device.Present(); + + Assert.Equal(Magenta, harness.Pixel(target)); + GpuTest.AssertClean(device); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/BindlessSlots.cs b/Optimum.Render.Vulkan/Core/BindlessSlots.cs new file mode 100644 index 00000000..1c487ffc --- /dev/null +++ b/Optimum.Render.Vulkan/Core/BindlessSlots.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The GLSL sampled type a bindless slot serves: one set-1 array per kind, in the +/// order of (the enum value is the index +/// into that table, not necessarily the binding number; see ). +/// +internal enum TextureKind +{ + Texture2D = 0, + Texture2DArray = 1, + TextureCube = 2, + Texture3D = 3, + UnsignedTexture2D = 4, + SignedTexture2D = 5, + Shadow2D = 6, + Shadow2DArray = 7, + ShadowCube = 8, +} + +/// What of a texture decides which kinds it may sit behind. +internal readonly record struct TextureShape(Format Format, uint Layers, bool Cube, bool Volume) +{ + public static TextureShape Of(VulkanTexture texture) => + new(texture.Format, texture.Layers, texture.Cube, texture.Volume); +} + +/// +/// The rules that keep a descriptor legal for the array it is written into. +/// The validation layers check none of them for a partially bound array: a view +/// type that does not match the declaration, a Dref sample through a sampler with +/// compareEnable off (or the reverse), or an integer format behind a float sampler +/// all give undefined or poison texels with no message (docs/research/vulkan-bindless.md, +/// section 1, "Hard rules"). They are enforced here, when a slot is created. +/// +internal static class BindlessKinds +{ + public const int Count = 9; + + private static readonly Dictionary ByGlslType = BuildGlslTypes(); + + private static Dictionary BuildGlslTypes() + { + var types = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < SetConvention.TextureArrays.Length; i++) + { + types.Add(SetConvention.TextureArrays[i].GlslType, (TextureKind)i); + } + return types; + } + + /// The kind whose array a GLSL sampler of reads; false for types set 1 has no array for. + public static bool TryFromGlslType(string glslType, out TextureKind kind) => ByGlslType.TryGetValue(glslType, out kind); + + /// The set-1 binding number of the kind's array. + public static uint BindingOf(TextureKind kind) => (uint)SetConvention.TextureArrays[(int)kind].Value; + + /// The convention's starting size of the kind's array. + public static uint ConventionCapacity(TextureKind kind) => SetConvention.TextureArrays[(int)kind].Capacity; + + public static bool IsShadow(TextureKind kind) => + kind is TextureKind.Shadow2D or TextureKind.Shadow2DArray or TextureKind.ShadowCube; + + public static bool IsInteger(TextureKind kind) => + kind is TextureKind.UnsignedTexture2D or TextureKind.SignedTexture2D; + + /// + /// Whether a texture of can legally sit behind + /// . The dimensionality rules are the ones + /// TextureSuitsSampler applies to per-program sets (a GL texture target + /// cannot change): 2D kinds need one layer, array kinds more than one, cube + /// kinds a cube, 3D a volume. Shadow kinds need a depth format, integer kinds + /// the matching signedness, and float kinds a non-integer format (depth reads + /// through sampler2D as it does on GL). + /// + public static bool Suits(TextureShape shape, TextureKind kind) + { + bool plain = !shape.Cube && !shape.Volume; + bool dimensions = kind switch + { + TextureKind.Texture2D or TextureKind.UnsignedTexture2D or TextureKind.SignedTexture2D + or TextureKind.Shadow2D => plain && shape.Layers == 1, + TextureKind.Texture2DArray or TextureKind.Shadow2DArray => plain && shape.Layers > 1, + TextureKind.TextureCube or TextureKind.ShadowCube => shape.Cube, + TextureKind.Texture3D => shape.Volume, + _ => false, + }; + if (!dimensions) return false; + + string name = shape.Format.ToString(); + bool unsigned = name.Contains("Uint", StringComparison.Ordinal); + bool signed = name.Contains("Sint", StringComparison.Ordinal); + return kind switch + { + _ when IsShadow(kind) => TextureManager.IsDepthFormat(shape.Format), + TextureKind.UnsignedTexture2D => unsigned, + TextureKind.SignedTexture2D => signed, + _ => !unsigned && !signed, + }; + } + + /// + /// The sampler state a slot of is written with. GL keeps + /// compare mode on the texture and leaves a mismatch with the sampler declaration + /// undefined; Vulkan turns it into a poison texel the layers never report. The + /// declaration is what the shader samples through, so it decides: shadow kinds + /// compare, the others do not. Integer formats cannot be filtered linearly or + /// anisotropically, so integer kinds sample nearest. + /// + public static SamplerState EffectiveState(SamplerState state, TextureKind kind) + { + state = state with { CompareEnable = IsShadow(kind) }; + if (IsInteger(kind)) + { + state = state with + { + MagFilter = Filter.Nearest, + MinFilter = Filter.Nearest, + MipmapMode = SamplerMipmapMode.Nearest, + MaxAnisotropy = 1f, + }; + } + return state; + } + + /// + /// Per-kind array sizes: the convention's starting sizes when the device's + /// update-after-bind limits hold them all plus + /// (set 0's textures, which count against the same per-stage limits); otherwise + /// each size scaled down in proportion, never below two (the placeholder and one + /// slot). Devices meeting keep the starting sizes. + /// + public static uint[] ClampCapacities(in DescriptorIndexingSupport support, uint reservedSampledImages) + { + ulong limit = Math.Min(Math.Min(support.MaxPerStageDescriptorUpdateAfterBindSampledImages, + support.MaxPerStageDescriptorUpdateAfterBindSamplers), + Math.Min(support.MaxDescriptorSetUpdateAfterBindSampledImages, + support.MaxDescriptorSetUpdateAfterBindSamplers)); + ulong budget = limit > reservedSampledImages ? limit - reservedSampledImages : 0; + + var capacities = new uint[Count]; + ulong total = 0; + for (int i = 0; i < Count; i++) + { + capacities[i] = ConventionCapacity((TextureKind)i); + total += capacities[i]; + } + if (total <= budget) return capacities; + + for (int i = 0; i < Count; i++) + { + capacities[i] = (uint)Math.Max(2UL, capacities[i] * budget / total); + } + return capacities; + } +} + +/// +/// The slots of one set-1 array: a LIFO free list over 1..Capacity-1. Slot 0 +/// holds the array's placeholder and is never handed out, so 0 always means +/// "nothing to sample" to a shader. +/// +internal sealed class BindlessSlotAllocator +{ + private readonly Stack _free = new(); + private uint _next = 1; + + public BindlessSlotAllocator(uint capacity) + { + if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity)); + Capacity = capacity; + } + + public uint Capacity { get; } + + /// Slots handed out and not freed. + public int Live { get; private set; } + + /// The most recently freed slot, else the lowest never used; false when the array is full. + public bool TryAllocate(out uint slot) + { + if (_free.Count > 0) + { + slot = _free.Pop(); + } + else if (_next < Capacity) + { + slot = _next++; + } + else + { + slot = 0; + return false; + } + Live++; + return true; + } + + public void Free(uint slot) + { + if (slot == 0 || slot >= _next) throw new ArgumentOutOfRangeException(nameof(slot), slot, "not an allocated slot"); + _free.Push(slot); + Live--; + } +} + +/// +/// What a slot holds: a combined image sampler of one physical texture +/// (, never reused, so a GL id handed to a new texture +/// or aliased to a transient image cannot reach an old slot), under one effective +/// sampler state and image layout, in one kind's array. +/// +internal readonly record struct BindlessSlotKey(ulong TextureId, TextureKind Kind, SamplerState State, ImageLayout Layout); + +/// +/// The bookkeeping of the bindless table, without a device: which key owns which +/// slot, and when a retired slot may be handed out again. +/// +/// A live slot is never rewritten in place: a draw recorded against it may still +/// be executing. A texture's slot retires when the texture is deleted, or when the +/// texture has more than keys (a changed +/// glTexParameter or a unit-level sampler override each make a new key; the least +/// recently used one retires, so a texture alternating between two states does not +/// churn slots every draw). A retired slot keeps its descriptor until the Frame +/// timeline value recorded at retirement has completed; then the caller writes the +/// placeholder into it and it returns to the free list. +/// +internal sealed class BindlessSlotBook +{ + public const int MaxVariantsPerTexture = 4; + + private readonly record struct Retired(TextureKind Kind, uint Slot, ulong Frame); + + private readonly ITimelineClock _clock; + private readonly BindlessSlotAllocator[] _allocators; + private readonly Dictionary _slots = new(); + // Each texture's keys, least recently used first. + private readonly Dictionary> _byTexture = new(); + private readonly List _retired = new(); + + public BindlessSlotBook(ITimelineClock clock, IReadOnlyList capacities) + { + if (capacities.Count != BindlessKinds.Count) throw new ArgumentException("one capacity per kind", nameof(capacities)); + _clock = clock; + _allocators = new BindlessSlotAllocator[BindlessKinds.Count]; + for (int i = 0; i < _allocators.Length; i++) _allocators[i] = new BindlessSlotAllocator(capacities[i]); + } + + public uint CapacityOf(TextureKind kind) => _allocators[(int)kind].Capacity; + + public int LiveSlots(TextureKind kind) => _allocators[(int)kind].Live; + + /// Retired slots whose Frame value has not completed yet. + public int PendingRetirements => _retired.Count; + + /// Acquisitions that found the kind's array full and got the placeholder. + public long Exhausted { get; private set; } + + /// + /// The slot holding , allocating one when there is none + /// (: the caller writes the descriptor). 0 when the + /// array is full. + /// + public uint Acquire(in BindlessSlotKey key, out bool created) + { + if (_slots.TryGetValue(key, out uint existing)) + { + List keys = _byTexture[key.TextureId]; + int index = keys.IndexOf(key); + if (index != keys.Count - 1) + { + keys.RemoveAt(index); + keys.Add(key); + } + created = false; + return existing; + } + + created = false; + if (!_allocators[(int)key.Kind].TryAllocate(out uint slot)) + { + Exhausted++; + return 0; + } + + _slots.Add(key, slot); + if (!_byTexture.TryGetValue(key.TextureId, out List? variants)) + { + variants = new List(2); + _byTexture.Add(key.TextureId, variants); + } + variants.Add(key); + if (variants.Count > MaxVariantsPerTexture) + { + BindlessSlotKey oldest = variants[0]; + variants.RemoveAt(0); + Retire(oldest); + } + + created = true; + return slot; + } + + /// Retires every slot of a deleted texture. Returns how many. + public int Release(ulong textureId) + { + if (!_byTexture.Remove(textureId, out List? keys)) return 0; + foreach (BindlessSlotKey key in keys) Retire(key); + return keys.Count; + } + + private void Retire(in BindlessSlotKey key) + { + uint slot = _slots[key]; + _slots.Remove(key); + // Every command that could still name the slot carries this value or an older one. + _retired.Add(new Retired(key.Kind, slot, _clock.FrameRecorded)); + } + + /// + /// Frees every retired slot whose Frame value has completed, appending it to + /// so the caller writes the placeholder back before the + /// slot can be allocated and written again. Returns how many. + /// + public int Collect(List<(TextureKind Kind, uint Slot)> freed) + { + if (_retired.Count == 0) return 0; + + ulong completed = _clock.FrameCompleted; + int kept = 0; + int count = 0; + for (int i = 0; i < _retired.Count; i++) + { + Retired entry = _retired[i]; + if (entry.Frame <= completed) + { + _allocators[(int)entry.Kind].Free(entry.Slot); + freed.Add((entry.Kind, entry.Slot)); + count++; + } + else + { + _retired[kept++] = entry; + } + } + _retired.RemoveRange(kept, _retired.Count - kept); + return count; + } +} diff --git a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs new file mode 100644 index 00000000..ba004a36 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs @@ -0,0 +1,425 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Set 1 of plan decision 9: one descriptor set of combined-image-sampler arrays, +/// one per GLSL sampled type (), every binding +/// PARTIALLY_BOUND | UPDATE_AFTER_BIND, allocated once from its own +/// update-after-bind pool. Shaders index the arrays with slot numbers from push +/// constants. +/// +/// A slot holds one physical texture under one effective sampler state and +/// layout (); decides +/// which. Slot 0 of every array is that kind's placeholder, and every other slot +/// holds the placeholder until it is allocated and again once it is freed, so a +/// stale or out-of-date index samples magenta (or a far-plane depth, or an +/// integer texel) instead of undefined memory. +/// +/// Writes are queued and applied by in one +/// vkUpdateDescriptorSets: at frame start (, which first +/// writes placeholders back into slots whose retirement completed) and before +/// every submission of the frame, so a slot first resolved while recording is +/// written before the command buffer naming it is submitted. Update-after-bind +/// makes both legal while the set is bound (docs/research/vulkan-bindless.md, +/// sections 2 and 3). Render thread, except . +/// +internal sealed unsafe class BindlessTextureTable : IDisposable +{ + /// Placeholder colour for float and depth-less colour arrays: loud, as poison mode is. + private static readonly byte[] Magenta = { 255, 0, 255, 255 }; + + private readonly record struct PendingWrite(TextureKind Kind, uint Slot, ImageView View, Sampler Sampler, ImageLayout Layout); + + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly BindlessSlotBook _book; + private readonly object _lock = new(); + private readonly int[] _placeholders = new int[BindlessKinds.Count]; + private readonly List _pending = new(); + // (kind, slot) -> index in _pending: a later write to the same slot replaces the earlier one. + private readonly Dictionary<(TextureKind, uint), int> _pendingIndex = new(); + private readonly List<(TextureKind Kind, uint Slot)> _freed = new(); + private DescriptorPool _pool; + private DescriptorSetLayout _layout; + private bool _disposed; + + public DescriptorSetLayout Layout => _layout; + + /// The one set. Bound at . + public DescriptorSet Set { get; } + + /// Lookups that resolved to a placeholder slot. + public long PlaceholderResolutions { get; private set; } + + /// Slot writes applied since creation (the initial placeholder fill excluded). + public long WritesFlushed { get; private set; } + + /// Writes applied by the most recent that had any. + public int LastFlushWrites { get; private set; } + + public BindlessTextureTable(VulkanContext context, TextureManager textures, ITimelineClock clock) + { + _context = context; + _textures = textures; + uint[] capacities = BindlessKinds.ClampCapacities(context.Capabilities.DescriptorIndexing, + DescriptorIndexingFloor.FrameTextures); + _book = new BindlessSlotBook(clock, capacities); + + try + { + _layout = CreateLayout(capacities); + _pool = CreatePool(capacities); + Set = AllocateSet(); + CreatePlaceholders(); + FillWithPlaceholders(capacities); + } + catch + { + DestroyObjects(); + throw; + } + } + + public uint CapacityOf(TextureKind kind) => _book.CapacityOf(kind); + + public int LiveSlots(TextureKind kind) + { + lock (_lock) return _book.LiveSlots(kind); + } + + public int PendingRetirements + { + get { lock (_lock) return _book.PendingRetirements; } + } + + public int PendingWrites + { + get { lock (_lock) return _pending.Count; } + } + + /// The texture id of a kind's placeholder. Tests and diagnostics. + public int PlaceholderTextureId(TextureKind kind) => _placeholders[(int)kind]; + + /// for what a texture id resolves to now, aliasing included. + public uint Resolve(int textureId, TextureKind kind, SamplerState state, + ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal) => + Resolve(_textures.Get(textureId), kind, state, layout); + + /// + /// The slot a shader samples through as + /// with in . + /// A new slot's write is queued; the caller records draws with the index and the + /// write lands before their submission. 0 (the placeholder) for no texture, a + /// texture that cannot sit behind the kind, or a full array. + /// + public uint Resolve(VulkanTexture? texture, TextureKind kind, SamplerState state, + ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal) + { + if (texture == null || !BindlessKinds.Suits(TextureShape.Of(texture), kind)) + { + NotePlaceholder(); + return 0; + } + + SamplerState effective = BindlessKinds.EffectiveState(state, kind); + lock (_lock) + { + uint slot = _book.Acquire(new BindlessSlotKey(texture.Id, kind, effective, layout), out bool created); + if (slot == 0) + { + NotePlaceholder(); + return 0; + } + if (created) Queue(new PendingWrite(kind, slot, texture.View, _textures.Samplers.Get(effective), layout)); + return slot; + } + } + + private void NotePlaceholder() + { + lock (_lock) PlaceholderResolutions++; + VulkanStats.NoteBindlessPlaceholderResolution(); + } + + /// + /// Retires every slot of a deleted physical texture against the Frame value + /// recorded now. Any thread (). + /// + public void Release(ulong textureId) + { + lock (_lock) _book.Release(textureId); + } + + /// + /// Frame start, after the ring's wait and collection: slots whose retirement + /// completed get their placeholder back and return to the free lists, then + /// every queued write is applied. + /// + public int BeginFrame() + { + lock (_lock) + { + _freed.Clear(); + _book.Collect(_freed); + foreach ((TextureKind kind, uint slot) in _freed) QueuePlaceholder(kind, slot); + return Flush(); + } + } + + /// Applies every queued write in one vkUpdateDescriptorSets. Returns how many. + public int Flush() + { + lock (_lock) + { + int count = _pending.Count; + if (count == 0) return 0; + + var images = new DescriptorImageInfo[count]; + var writes = new WriteDescriptorSet[count]; + fixed (DescriptorImageInfo* imagesPtr = images) + fixed (WriteDescriptorSet* writesPtr = writes) + { + for (int i = 0; i < count; i++) + { + PendingWrite write = _pending[i]; + imagesPtr[i] = new DescriptorImageInfo(write.Sampler, write.View, write.Layout); + writesPtr[i] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = Set, + DstBinding = BindlessKinds.BindingOf(write.Kind), + DstArrayElement = write.Slot, + DescriptorCount = 1, + DescriptorType = DescriptorType.CombinedImageSampler, + PImageInfo = imagesPtr + i, + }; + } + _context.Api.UpdateDescriptorSets(_context.Device, (uint)count, writesPtr, 0, null); + } + + _pending.Clear(); + _pendingIndex.Clear(); + WritesFlushed += count; + LastFlushWrites = count; + VulkanStats.NoteBindlessFlush(count); + return count; + } + } + + private void Queue(in PendingWrite write) + { + if (_pendingIndex.TryGetValue((write.Kind, write.Slot), out int index)) + { + _pending[index] = write; + return; + } + _pendingIndex.Add((write.Kind, write.Slot), _pending.Count); + _pending.Add(write); + } + + private void QueuePlaceholder(TextureKind kind, uint slot) + { + (ImageView view, Sampler sampler) = PlaceholderDescriptor(kind); + Queue(new PendingWrite(kind, slot, view, sampler, ImageLayout.ShaderReadOnlyOptimal)); + } + + private (ImageView View, Sampler Sampler) PlaceholderDescriptor(TextureKind kind) + { + VulkanTexture placeholder = _textures.Get(_placeholders[(int)kind]) + ?? throw new InvalidOperationException("bindless placeholder for " + kind + " is gone"); + return (placeholder.View, _textures.Samplers.Get(BindlessKinds.EffectiveState(SamplerState.Default, kind))); + } + + // ---------------------------------------------------------------- creation + + private DescriptorSetLayout CreateLayout(uint[] capacities) + { + var bindings = new DescriptorSetLayoutBinding[BindlessKinds.Count]; + var flags = new DescriptorBindingFlags[BindlessKinds.Count]; + for (int i = 0; i < bindings.Length; i++) + { + bindings[i] = new DescriptorSetLayoutBinding + { + Binding = BindlessKinds.BindingOf((TextureKind)i), + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = capacities[i], + StageFlags = SharedPipelineLayout.Stages, + }; + flags[i] = DescriptorBindingFlags.PartiallyBoundBit | DescriptorBindingFlags.UpdateAfterBindBit; + } + + fixed (DescriptorSetLayoutBinding* bindingsPtr = bindings) + fixed (DescriptorBindingFlags* flagsPtr = flags) + { + var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo + { + SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo, + BindingCount = (uint)flags.Length, + PBindingFlags = flagsPtr, + }; + var info = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + PNext = &bindingFlags, + Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit, + BindingCount = (uint)bindings.Length, + PBindings = bindingsPtr, + }; + DescriptorSetLayout layout; + VulkanResult.Check(_context.Api.CreateDescriptorSetLayout(_context.Device, &info, null, &layout), + "vkCreateDescriptorSetLayout for the bindless texture set"); + return layout; + } + } + + private DescriptorPool CreatePool(uint[] capacities) + { + uint total = 0; + foreach (uint capacity in capacities) total += capacity; + var size = new DescriptorPoolSize(DescriptorType.CombinedImageSampler, total); + var info = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + Flags = DescriptorPoolCreateFlags.UpdateAfterBindBit, + MaxSets = 1, + PoolSizeCount = 1, + PPoolSizes = &size, + }; + DescriptorPool pool; + VulkanResult.Check(_context.Api.CreateDescriptorPool(_context.Device, &info, null, &pool), + "vkCreateDescriptorPool for the bindless texture set"); + return pool; + } + + private DescriptorSet AllocateSet() + { + DescriptorSetLayout layout = _layout; + var info = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _pool, + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + DescriptorSet set; + VulkanResult.Check(_context.Api.AllocateDescriptorSets(_context.Device, &info, &set), + "vkAllocateDescriptorSets for the bindless texture set"); + return set; + } + + /// + /// One-texel textures of each kind's view type and format class. Their pixels + /// ride the upload batch, which runs before the first frame that can sample them. + /// + private void CreatePlaceholders() + { + fixed (byte* magenta = Magenta) + { + _placeholders[(int)TextureKind.Texture2D] = Colour(_textures.Create(1, 1, Format.R8G8B8A8Unorm), 1, magenta); + // A single-layer texture gets a 2D view; an array view needs two layers. + _placeholders[(int)TextureKind.Texture2DArray] = + Colour(_textures.Create(1, 1, Format.R8G8B8A8Unorm, layers: 2), 2, magenta); + _placeholders[(int)TextureKind.TextureCube] = + Colour(_textures.Create(1, 1, Format.R8G8B8A8Unorm, layers: 6, cube: true), 6, magenta); + _placeholders[(int)TextureKind.Texture3D] = Colour(_textures.CreateVolume(1, 1, 1, Format.R8G8B8A8Unorm), 1, magenta); + _placeholders[(int)TextureKind.UnsignedTexture2D] = Colour(_textures.Create(1, 1, Format.R8G8B8A8Uint), 1, magenta); + } + + byte[] signed = { 127, 0, 127, 127 }; + fixed (byte* texel = signed) + { + _placeholders[(int)TextureKind.SignedTexture2D] = Colour(_textures.Create(1, 1, Format.R8G8B8A8Sint), 1, texel); + } + + // Depth at the far plane: every comparison against it passes, so nothing is + // shadowed, as with a missing shadow map on GL. + _placeholders[(int)TextureKind.Shadow2D] = Depth(_textures.Create(1, 1, Format.D32Sfloat), 1); + _placeholders[(int)TextureKind.Shadow2DArray] = Depth(_textures.Create(1, 1, Format.D32Sfloat, layers: 2), 2); + _placeholders[(int)TextureKind.ShadowCube] = Depth(_textures.Create(1, 1, Format.D32Sfloat, layers: 6, cube: true), 6); + + for (int i = 0; i < BindlessKinds.Count; i++) + { + VulkanTexture placeholder = _textures.Get(_placeholders[i])!; + if (!BindlessKinds.Suits(TextureShape.Of(placeholder), (TextureKind)i)) + { + throw new InvalidOperationException("bindless placeholder does not suit " + (TextureKind)i); + } + } + } + + private int Colour(int id, uint layers, byte* texel) + { + for (uint layer = 0; layer < layers; layer++) _textures.Upload(id, 0, 0, 0, 1, 1, (IntPtr)texel, 4, layer); + return id; + } + + private int Depth(int id, uint layers) + { + float far = 1f; + for (uint layer = 0; layer < layers; layer++) _textures.Upload(id, 0, 0, 0, 1, 1, (IntPtr)(&far), 4, layer); + VulkanTexture texture = _textures.Get(id)!; + texture.State = texture.State with { CompareEnable = true }; + return id; + } + + /// Writes each binding's placeholder into every element, one descriptor write per binding. + private void FillWithPlaceholders(uint[] capacities) + { + var infos = new DescriptorImageInfo[BindlessKinds.Count][]; + var handles = new System.Runtime.InteropServices.GCHandle[BindlessKinds.Count]; + var writes = new WriteDescriptorSet[BindlessKinds.Count]; + try + { + for (int i = 0; i < BindlessKinds.Count; i++) + { + (ImageView view, Sampler sampler) = PlaceholderDescriptor((TextureKind)i); + infos[i] = new DescriptorImageInfo[capacities[i]]; + Array.Fill(infos[i], new DescriptorImageInfo(sampler, view, ImageLayout.ShaderReadOnlyOptimal)); + handles[i] = System.Runtime.InteropServices.GCHandle.Alloc(infos[i], + System.Runtime.InteropServices.GCHandleType.Pinned); + writes[i] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = Set, + DstBinding = BindlessKinds.BindingOf((TextureKind)i), + DstArrayElement = 0, + DescriptorCount = capacities[i], + DescriptorType = DescriptorType.CombinedImageSampler, + PImageInfo = (DescriptorImageInfo*)handles[i].AddrOfPinnedObject(), + }; + } + fixed (WriteDescriptorSet* writesPtr = writes) + { + _context.Api.UpdateDescriptorSets(_context.Device, (uint)writes.Length, writesPtr, 0, null); + } + } + finally + { + foreach (System.Runtime.InteropServices.GCHandle handle in handles) + { + if (handle.IsAllocated) handle.Free(); + } + } + } + + private void DestroyObjects() + { + Vk api = _context.Api; + // Destroying the pool frees the set. The placeholders belong to the texture manager. + if (_pool.Handle != 0) api.DestroyDescriptorPool(_context.Device, _pool, null); + if (_layout.Handle != 0) api.DestroyDescriptorSetLayout(_context.Device, _layout, null); + _pool = default; + _layout = default; + } + + /// Teardown, after the device-idle wait and after every pipeline layout naming . + public void Dispose() + { + if (_disposed) return; + _disposed = true; + DestroyObjects(); + } +} diff --git a/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs new file mode 100644 index 00000000..9e72bbba --- /dev/null +++ b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs @@ -0,0 +1,134 @@ +using System; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Plan decision 9's one pipeline layout, shared by every program once shaders +/// target it (): +/// +/// | set 0 | FrameGlobals dynamic UBO and the fixed frame textures; a normal set (dynamic buffers cannot be update-after-bind) | +/// | set 1 | the bindless texture table's layout (), not owned here | +/// | set 2 | the storage buffers, a normal set | +/// | push | for vertex and fragment | +/// +/// Created once at device bring-up; destroyed at teardown after the device-idle +/// wait, before the table's set layout it names. +/// +internal sealed unsafe class SharedPipelineLayout : IDisposable +{ + public const ShaderStageFlags Stages = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit; + + private readonly VulkanContext _context; + private bool _disposed; + + public DescriptorSetLayout FrameSetLayout { get; } + public DescriptorSetLayout TextureSetLayout { get; } + public DescriptorSetLayout StorageSetLayout { get; } + public PipelineLayout Layout { get; } + + public SharedPipelineLayout(VulkanContext context, DescriptorSetLayout textureSetLayout) + { + _context = context; + TextureSetLayout = textureSetLayout; + Vk api = context.Api; + + var frameBindings = new DescriptorSetLayoutBinding[1 + SetConvention.FrameTextures.Length]; + frameBindings[0] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.FrameGlobalsBinding, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = Stages, + }; + for (int i = 0; i < SetConvention.FrameTextures.Length; i++) + { + frameBindings[1 + i] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.FrameTextures[i].Value, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = SetConvention.FrameTextures[i].Capacity, + StageFlags = Stages, + }; + } + + var storageBindings = new DescriptorSetLayoutBinding[SetConvention.StorageBuffers.Length]; + for (int i = 0; i < storageBindings.Length; i++) + { + storageBindings[i] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.StorageBuffers[i].Value, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = SetConvention.StorageBuffers[i].Capacity, + StageFlags = Stages, + }; + } + + FrameSetLayout = CreateSetLayout(frameBindings, "set 0 (frame)"); + try + { + StorageSetLayout = CreateSetLayout(storageBindings, "set 2 (storage)"); + } + catch + { + api.DestroyDescriptorSetLayout(context.Device, FrameSetLayout, null); + throw; + } + + DescriptorSetLayout* setLayouts = stackalloc DescriptorSetLayout[SetConvention.SetCount]; + setLayouts[SetConvention.FrameSet] = FrameSetLayout; + setLayouts[SetConvention.TextureSet] = TextureSetLayout; + setLayouts[SetConvention.StorageSet] = StorageSetLayout; + var pushConstants = new PushConstantRange + { + StageFlags = Stages, + Offset = 0, + Size = SetConvention.PushConstantBytes, + }; + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = SetConvention.SetCount, + PSetLayouts = setLayouts, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstants, + }; + PipelineLayout layout; + Result result = api.CreatePipelineLayout(context.Device, &layoutInfo, null, &layout); + if (result != Result.Success) + { + api.DestroyDescriptorSetLayout(context.Device, StorageSetLayout, null); + api.DestroyDescriptorSetLayout(context.Device, FrameSetLayout, null); + VulkanResult.Check(result, "vkCreatePipelineLayout for the shared layout"); + } + Layout = layout; + } + + private DescriptorSetLayout CreateSetLayout(DescriptorSetLayoutBinding[] bindings, string what) + { + fixed (DescriptorSetLayoutBinding* bindingsPtr = bindings) + { + var info = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = (uint)bindings.Length, + PBindings = bindingsPtr, + }; + DescriptorSetLayout layout; + VulkanResult.Check(_context.Api.CreateDescriptorSetLayout(_context.Device, &info, null, &layout), + "vkCreateDescriptorSetLayout for the shared layout's " + what); + return layout; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Vk api = _context.Api; + api.DestroyPipelineLayout(_context.Device, Layout, null); + api.DestroyDescriptorSetLayout(_context.Device, StorageSetLayout, null); + api.DestroyDescriptorSetLayout(_context.Device, FrameSetLayout, null); + } +} diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index 77b2738a..ff7d92d1 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -85,6 +85,9 @@ internal sealed unsafe class VulkanTexture : IDisposable /// Whether the view is a cube rather than a six-layer array. public bool Cube { get; init; } + + /// Whether the image is 3D (); GL-created textures never are. + public bool Volume { get; init; } public ImageAspectFlags Aspect { get; init; } /// Mutable, as glTexParameter is. @@ -429,6 +432,84 @@ public int Create( return Register(texture); } + /// + /// Creates a single-level 3D colour texture, for the bindless table's + /// sampler3D placeholder: the game creates no 3D textures, but the + /// array's slot 0 still needs a 3D view. Uploads address it as layer 0 of a + /// one-texel-deep image. + /// + public int CreateVolume(uint width, uint height, uint depth, Format format) + { + width = Math.Max(1, width); + height = Math.Max(1, height); + depth = Math.Max(1, depth); + + var imageInfo = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type3D, + Format = format, + Extent = new Extent3D(width, height, depth), + MipLevels = 1, + ArrayLayers = 1, + Samples = SampleCountFlags.Count1Bit, + Tiling = ImageTiling.Optimal, + Usage = ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit, + SharingMode = SharingMode.Exclusive, + InitialLayout = ImageLayout.Undefined, + }; + + Vk api = _context.Api; + if (api.CreateImage(_context.Device, &imageInfo, null, out Image image) != Result.Success) + { + throw new InvalidOperationException("vkCreateImage failed for a 3D texture"); + } + + MemoryRequirements requirements = VulkanAllocator.ImageRequirements(_context, image, out bool dedicated); + MemoryAllocation allocation = _context.Allocator.Allocate( + requirements, MemoryPropertyFlags.DeviceLocalBit, linear: false, + $"a {width}x{height}x{depth} {format} image", MemoryPoolClass.DeviceImages, dedicated, default, image); + if (api.BindImageMemory(_context.Device, image, allocation.Memory, allocation.Offset) != Result.Success) + { + api.DestroyImage(_context.Device, image, null); + _context.Allocator.Free(allocation); + throw new InvalidOperationException("vkBindImageMemory failed for a 3D texture"); + } + + var viewInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = ImageViewType.Type3D, + Format = format, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + if (api.CreateImageView(_context.Device, &viewInfo, null, out ImageView view) != Result.Success) + { + api.DestroyImage(_context.Device, image, null); + _context.Allocator.Free(allocation); + throw new InvalidOperationException("vkCreateImageView failed for a 3D texture"); + } + + var texture = new VulkanTexture(_context) + { + Image = image, + Allocation = allocation, + View = view, + Format = format, + Width = width, + Height = height, + MipLevels = 1, + Layers = 1, + Volume = true, + Aspect = ImageAspectFlags.ColorBit, + }; + + if (_context.PoisonFreshResources) Poison(texture); + + return Register(texture); + } + /// /// Poison mode: fills every level and layer of a new image with /// 's value for its format, so a read of content @@ -457,6 +538,15 @@ private void Poison(VulkanTexture texture) _context.Api.CmdClearColorImage(commandBuffer, texture.Image, ImageLayout.TransferDstOptimal, &color, 1, &range); } + + // Out of TRANSFER_DST, into the layout an upload leaves a texture in. + // The tracker models the clear and a following copy as the same + // Transfer write, so without this the first upload recorded right + // after the clear gets no barrier; synchronization2 tells CLEAR from + // COPY, and the layer reports WRITE_AFTER_WRITE (write_barriers = 0, + // seq_no 2, 2026-09-15, the first texture of a poisoned device). The + // poison stays: the layout change keeps the contents. + TransitionTexture(commandBuffer, texture, ResourceUsage.SampleFragment); } finally { @@ -644,6 +734,13 @@ public void SetBorderColor(int textureId, float r, float g, float b, float a) }; } + /// + /// Called from with the physical texture that id named, on + /// whichever thread deleted it, under the upload lock. The bindless table + /// retires the texture's slots here. + /// + public Action? Deleted { get; set; } + public void Delete(int textureId, FrameRing? ring = null) { // Under the upload lock; see Upload. Retiring inside it keys the entry on @@ -659,6 +756,10 @@ public void Delete(int textureId, FrameRing? ring = null) _textures[textureId] = null; _freeIds.Push(textureId); + // Before the texture is retired, under the same timeline values: its + // bindless slots then outlive every frame that could sample them. + Deleted?.Invoke(texture); + // Handing it to the ring means it outlives any frame still referencing it. if (ring != null) ring.DeferDeletion(texture); else texture.Dispose(); diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 3ddd207c..2cd075cd 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -290,6 +290,27 @@ public static void NoteTransientFrame(ulong transientBytes, ulong aliasedBytes, public static long RebarFallbacks => Interlocked.Read(ref _rebarFallbacks); + private static long _bindlessWrites; + private static long _bindlessFlushes; + private static long _bindlessPlaceholderResolutions; + + /// + /// One vkUpdateDescriptorSets on the bindless set carrying + /// slot writes (new slots and placeholders written back into freed ones). + /// + public static void NoteBindlessFlush(int writes) + { + Interlocked.Increment(ref _bindlessFlushes); + Interlocked.Add(ref _bindlessWrites, writes); + } + + /// A bindless lookup that resolved to a placeholder slot: no texture, a texture of the wrong kind, or a full array. + public static void NoteBindlessPlaceholderResolution() => Interlocked.Increment(ref _bindlessPlaceholderResolutions); + + public static long BindlessWrites => Interlocked.Read(ref _bindlessWrites); + public static long BindlessFlushes => Interlocked.Read(ref _bindlessFlushes); + public static long BindlessPlaceholderResolutions => Interlocked.Read(ref _bindlessPlaceholderResolutions); + /// A multi-draw that did not fit its frame slot's indirect buffer and took an overflow buffer. public static void NoteIndirectOverflow() => Interlocked.Increment(ref _indirectOverflows); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 205df880..ba84bc96 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -123,6 +123,14 @@ public sealed unsafe class VulkanDevice : IDisposable /// changes something. Replaces up to 56 per-program copies of the same values. /// private DescriptorSetLayout _frameSetLayout; + + /// + /// Decision 9's set 1 and the one shared pipeline layout. Created at bring-up and + /// kept current (slots retire with their textures, writes flush before every + /// submission); no draw uses them until shaders target the shared layout. + /// + private BindlessTextureTable? _bindless; + private SharedPipelineLayout? _sharedLayout; private readonly byte[] _frameGlobals = FrameGlobals.CreateShadow(); private uint _frameGlobalsVersion = 1; private uint _frameGlobalsSnapshotFrame; @@ -444,6 +452,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _descriptors = new DescriptorCache(_context); // One layout for the shared frame block, named by every program's pipeline layout. _frameSetLayout = ShaderProgramResources.CreateFrameSetLayout(_context); + // Decision 9: the bindless table retires a texture's slots on the timeline + // values of its deletion, and the shared layout names the table's set layout. + _bindless = new BindlessTextureTable(_context, _textures, _frames.Timeline); + _textures.Deleted = texture => _bindless.Release(texture.Id); + _sharedLayout = new SharedPipelineLayout(_context, _bindless.Layout); _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; for (int i = 0; i < _descriptorArenas.Length; i++) _descriptorArenas[i] = new DescriptorArena(_context); _indirectRing = new IndirectRing(_frames.FramesInFlight); @@ -777,6 +790,9 @@ public void BeginFrame() _transients.BeginFrame(); FrameSlot slot = _frames.BeginFrame(); + // After the ring's wait and collection, before anything is recorded: freed + // bindless slots get their placeholder back and queued writes land. + _bindless?.BeginFrame(); _readSelfCopies.Collect(); _frameActive = true; _frameCounter++; @@ -827,6 +843,56 @@ public void BeginFrame() /// The frame ring's upload manager. Tests only. internal UploadManager UploadsForTests => _uploads; + /// Decision 9's set 1. Tests only. + internal BindlessTextureTable BindlessForTests => _bindless!; + + /// Decision 9's shared pipeline layout. Tests only. + internal SharedPipelineLayout SharedLayoutForTests => _sharedLayout!; + + /// + /// Records one fullscreen triangle into the bound target with a pipeline built on + /// the shared layout: the bindless set bound at set 1, + /// pushed for vertex and fragment. Every placeholder and every texture in + /// is made shader-readable first, as a draw + /// does for what it samples. Tests only, until shaders target the shared layout. + /// + internal void DrawBindlessForTests(Pipeline pipeline, int width, int height, byte[] pushConstants, + params int[] sampledTextureIds) + { + if (!_frameActive || _targets.Bound == null || _bindless == null || _sharedLayout == null) return; + CommandBuffer commandBuffer = Commands; + Vk api = _context.Api; + + var sampled = new List(sampledTextureIds); + for (int kind = 0; kind < BindlessKinds.Count; kind++) sampled.Add(_bindless.PlaceholderTextureId((TextureKind)kind)); + foreach (int id in sampled) + { + VulkanTexture? texture = _textures.Get(id); + if (texture == null || texture.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; + _targets.EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, texture, Graph.ResourceUsage.SampleFragment); + } + _barriers.Flush(commandBuffer); + _targets.EnsureRendering(commandBuffer); + + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + var viewport = new Viewport(0, 0, width, height, 0, 1); + api.CmdSetViewport(commandBuffer, 0, 1, &viewport); + var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D((uint)width, (uint)height)); + api.CmdSetScissor(commandBuffer, 0, 1, &scissor); + DescriptorSet set = _bindless.Set; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, _sharedLayout.Layout, + (uint)Shaders.SetConvention.TextureSet, 1, &set, 0, null); + fixed (byte* push = pushConstants) + { + api.CmdPushConstants(commandBuffer, _sharedLayout.Layout, SharedPipelineLayout.Stages, 0, + (uint)pushConstants.Length, push); + } + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + // The raw bind and states above are not what the cache believes the buffer holds. + _dynamicState.Invalidate(); + } + /// Static meshes on device-local memory through staging (Phase 1B step 5's default). Tests only. internal bool DeviceLocalStaticMeshesForTests { @@ -936,6 +1002,8 @@ public void Present() _targets.EndRendering(_frames.Current.CommandBuffer); long presentEntry = System.Diagnostics.Stopwatch.GetTimestamp(); + // A slot first resolved while recording is written before its draws are submitted. + _bindless?.Flush(); ulong renderValue = _frames.EndFrame(); _frameActive = false; long frameSubmitted = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -3093,6 +3161,7 @@ public void EndOcclusionQuery(int queryId) private ulong SubmitPartial() { _targets.EndRendering(Commands); + _bindless?.Flush(); ulong submitted = _frames.SubmitPartial(); Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); return submitted; @@ -3328,6 +3397,10 @@ public void Dispose() _context.Api.DestroyDescriptorSetLayout(_context.Device, _frameSetLayout, null); _frameSetLayout = default; } + // The shared pipeline layout before the table's set layout it names; the + // table's placeholders are textures and go with the texture manager. + _sharedLayout?.Dispose(); + _bindless?.Dispose(); _uniformBuffers.Clear(); From 8cafb03f15fd5c9fbe74d15267f16699bb645544 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:23:48 +0200 Subject: [PATCH 151/226] wip(pipeline-cache): background compiles, growth-triggered saves, pipeline-key log prewarm Render-thread graphics pipelines are created with FAIL_ON_PIPELINE_COMPILE_REQUIRED (pipelineCreationCacheControl enabled when the device supports it). On VK_PIPELINE_COMPILE_REQUIRED the key goes to a bounded background worker that compiles against its own VkPipelineCache and merges into the main cache under a lock (creations, merges and vkGetPipelineCacheData are serialised); the draw is skipped and counted, a key already compiling is not queued twice, and finished pipelines are published at frame start. OPTIMUM_VULKAN_SYNC_PIPELINES=1 or VulkanDevice.SynchronousPipelines=true keeps blocking creation; GpuTest devices and the capture scripts default to it. The driver cache is saved from a worker once it has grown 8 MiB (sampled at most every 10 s) through the identity wrapper and the unique-temp-file replace with backoff; the shutdown save stays. Every pipeline used is recorded in a versioned, SHA-256-checked, LRU-capped key log (pipeline/.keys) by content: program SPIR-V hash, settings hash, vertex layout, formats, blend, polygon mode, topology. When a matching program links, its entries are prewarmed on the worker and the first lookup adopts them. stats.pipelines reports compiled_sync, compiled_async, prewarmed, warm, draws_skipped, pending, cache_bytes and saves. VK_KHR_pipeline_binary stays deferred. Verified: dotnet test Optimum.Render.Vulkan.Tests 692 passed, 0 failed, 0 skipped (sync,best validation, implicit layers disabled, only VK_LAYER_MESA_device_select inserted); dotnet test Optimum.Tests -c Release 1177 passed, 0 failed, 34 skipped of 1211. New tests: key log round trip, version mismatch, LRU cap, corrupt file; growth trigger; replace retry and give-up; GPU: async not-ready then ready and renders the unique colour, same key compiles once, prewarm from the log serves the first use without a compile or skipped draw, worker save from growth. The game was not launched. --- Optimum.Render.Vulkan.Tests/GpuTest.cs | 5 +- .../PacingStatsTests.cs | 7 +- .../PipelineCacheTests.cs | 361 ++++++++++ .../ShaderCacheTests.cs | 192 ++++++ Optimum.Render.Vulkan/Core/CacheFileWriter.cs | 17 +- Optimum.Render.Vulkan/Core/PipelineCache.cs | 614 +++++++++++++++++- .../Core/PipelineCachePersistence.cs | 170 +++++ Optimum.Render.Vulkan/Core/PipelineKeyLog.cs | 395 +++++++++++ .../Core/ShaderProgramResources.cs | 27 + Optimum.Render.Vulkan/Core/VulkanContext.cs | 18 + Optimum.Render.Vulkan/Core/VulkanStats.cs | 71 +- Optimum.Render.Vulkan/VulkanDevice.cs | 102 ++- docs/taa-acceptance.md | 15 +- docs/vulkan-branch-progress.md | 10 +- scripts/dev/headless-capture.sh | 4 + scripts/dev/parity-capture.sh | 4 + 16 files changed, 1964 insertions(+), 48 deletions(-) create mode 100644 Optimum.Render.Vulkan/Core/PipelineCachePersistence.cs create mode 100644 Optimum.Render.Vulkan/Core/PipelineKeyLog.cs diff --git a/Optimum.Render.Vulkan.Tests/GpuTest.cs b/Optimum.Render.Vulkan.Tests/GpuTest.cs index bd37648e..232f1498 100644 --- a/Optimum.Render.Vulkan.Tests/GpuTest.cs +++ b/Optimum.Render.Vulkan.Tests/GpuTest.cs @@ -63,7 +63,10 @@ public static bool TryCreateContext(ITestOutputHelper output, List? mess public static VulkanDevice NewDevice() { var messages = new List(); - var device = new VulkanDevice { DebugMode = true }; + // Blocking pipeline creation: these tests read pixels back after one frame, and a + // background compile would skip that frame's draw. The async path has its own tests + // (PipelineCacheTests), which set SynchronousPipelines = false before Initialize. + var device = new VulkanDevice { DebugMode = true, SynchronousPipelines = true }; device.ConfigureContextOptions = options => { options.EnableValidation = true; diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index af6b9a88..ef87f746 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -152,7 +152,7 @@ public void NewStatsLinesCarryStableKeyValueTokens() } [Fact] - public void SampleIsTheOriginalLineFollowedByFourTokenLines() + public void SampleIsTheOriginalLineFollowedByTheTokenLines() { // The first call may only arm the interval clock. VulkanStats.SampleIfDue(TimeSpan.Zero); @@ -160,7 +160,9 @@ public void SampleIsTheOriginalLineFollowedByFourTokenLines() Assert.NotNull(sample); string[] lines = sample!.Split('\n'); - Assert.Equal(6, lines.Length); + Assert.Equal(7, lines.Length); + // Caching follow-ups: pipelines compiled blocking/async/prewarmed, skipped draws, cache bytes, saves. + Assert.StartsWith("stats.pipelines compiled_sync=", lines[6]); // Phase 2 step 4: transient and aliased MiB, the Transient pool's heap peak, ReadSelf copies. Assert.StartsWith("stats.transients transient_mib=", lines[5]); // Phase 1B step 5: pool classes, ReBAR use and misses, used/budget per heap. @@ -185,6 +187,7 @@ public void AcceptanceDocumentNamesEveryStatsToken() VulkanStats.FormatCountersLine(default), VulkanAllocator.FormatMemoryLine(default), VulkanStats.FormatTransientsLine(default), + VulkanStats.FormatPipelinesLine(default), }) { foreach (Match token in Regex.Matches(line, @"([a-z0-9_]+)=")) diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs index 576aa7a7..4aec3d3a 100644 --- a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; using System.Linq; using Optimum.Render.Vulkan.Core; using Optimum.Render.Vulkan.Shaders; @@ -321,4 +323,363 @@ public void ASavedPipelineCacheSeedsTheNextCacheOnTheSameDevice() } } + // ------------------------------------------------------ growth-triggered save + + [Fact] + public void TheGrowthTriggerSamplesAtMostOncePerIntervalAndFiresOnThreshold() + { + const long mib = 1024 * 1024; + long second = Stopwatch.Frequency; + var trigger = new PipelineCacheGrowthTrigger(8 * mib, TimeSpan.FromSeconds(10), baselineBytes: 2 * mib); + + // The first call arms the clock; then one sample per interval, never two. + Assert.False(trigger.SampleDue(100 * second)); + Assert.False(trigger.SampleDue(105 * second)); + Assert.True(trigger.SampleDue(110 * second)); + Assert.False(trigger.SampleDue(119 * second)); + Assert.True(trigger.SampleDue(121 * second)); + + // Growth is measured from what is on disk: the seed first, then the last save. + Assert.False(trigger.GrewEnough(9 * mib)); + Assert.True(trigger.GrewEnough(10 * mib)); + trigger.NoteSaved(10 * mib); + Assert.Equal(10 * mib, trigger.BaselineBytes); + Assert.False(trigger.GrewEnough(17 * mib)); + Assert.True(trigger.GrewEnough(18 * mib)); + } + + [Fact] + public void SynchronousPipelinesComeFromTheSettingOrTheEnvironment() + { + Assert.False(VulkanDevice.ResolveSynchronousPipelines(null, null)); + Assert.False(VulkanDevice.ResolveSynchronousPipelines(null, "0")); + Assert.True(VulkanDevice.ResolveSynchronousPipelines(null, "1")); + Assert.True(VulkanDevice.ResolveSynchronousPipelines(null, " true ")); + Assert.True(VulkanDevice.ResolveSynchronousPipelines(true, "0")); + Assert.False(VulkanDevice.ResolveSynchronousPipelines(false, "1")); + } + + /// + /// The opportunistic save on a real driver: once the cache has grown past the threshold, + /// a due sample writes the file from a worker, and the file seeds a new cache. + /// + [SkippableFact] + public void AGrownPipelineCacheIsSavedFromAWorkerBeforeShutdown() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context, messages), "No usable Vulkan device."); + + string root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "optimum-pipeline-growth-" + Guid.NewGuid().ToString("N")); + using (context) + { + try + { + using var compiler = new ShaderCompiler(); + TranslatedProgram translated = TranslateVanilla("blit", compiler); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + using var program = new ShaderProgramResources(context!, programId: 9, translated); + + var tracker = new GlStateTracker(); + tracker.SetProgram(9); + var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); + int targetId = tracker.InternTargetFormats(targets); + + PipelineCacheIdentity identity = PipelineCacheIdentity.Of(context!.Capabilities); + PipelineCachePersistence persistence = PipelineCachePersistence.Open(root, identity, out byte[]? seed, + thresholdBytes: 1, interval: TimeSpan.FromSeconds(1)); + Assert.Null(seed); + + using var cache = new GraphicsPipelineCache(context!) { KeyLog = persistence.KeyLog }; + cache.Get(tracker.BuildKey(0, targetId, 1), new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = VertexLayoutDescription.Empty, + Targets = targets, + Blend = new[] { tracker.BlendFor(0) }, + PolygonMode = tracker.PolygonMode, + Topology = tracker.Topology, + }); + Assert.True(persistence.KeyLog.HasUnsavedChanges); + + long second = Stopwatch.Frequency; + Assert.False(persistence.Tick(cache, 10 * second), "the first tick only arms the clock"); + Assert.False(persistence.Tick(cache, 10 * second + second / 2), "sampled inside the interval"); + Assert.True(persistence.Tick(cache, 11 * second)); + persistence.WaitForPendingSave(); + + Assert.Equal(1, persistence.Saves); + Assert.False(persistence.KeyLog.HasUnsavedChanges); + byte[]? saved = PipelineCacheFile.Load(persistence.CachePath, identity); + Assert.NotNull(saved); + Assert.Equal(1, PipelineKeyLog.Load(persistence.KeyLogPath).Count); + using (var seeded = new GraphicsPipelineCache(context!, saved)) + { + Assert.True(seeded.SeedAccepted); + } + + // The shutdown save still writes both files. + persistence.SaveAtShutdown(cache); + Assert.Equal(2, persistence.Saves); + + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + finally + { + try + { + System.IO.Directory.Delete(root, recursive: true); + } + catch (System.IO.DirectoryNotFoundException) + { + } + } + } + } + + // ---------------------------------------------------------- background compiles + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + /// + /// A colour no earlier run used: its constants are in the SPIR-V, so neither our cache nor + /// the driver's implicit one can hold the pipeline and the first creation must compile. + /// + private static byte[] UniqueColour() + { + var bytes = new byte[3]; + Random.Shared.NextBytes(bytes); + for (int i = 0; i < 3; i++) bytes[i] = (byte)(1 + bytes[i] % 254); + return new byte[] { bytes[0], bytes[1], bytes[2], 255 }; + } + + private static string SolidFragment(byte[] colour) => string.Format(CultureInfo.InvariantCulture, """ + #version 330 core + out vec4 outColor; + void main(void) + {{ + outColor = vec4({0:F1} / 255.0, {1:F1} / 255.0, {2:F1} / 255.0, 1.0); + }} + """, colour[0], colour[1], colour[2]); + + /// A device from GpuTest with the given pipeline mode and cache root, or null (the reason is logged). + private VulkanDevice? OpenDevice(bool synchronousPipelines, string? cacheRoot = null) + { + VulkanDevice device = GpuTest.NewDevice(); + device.SynchronousPipelines = synchronousPipelines; + device.ShaderCacheDirectory = cacheRoot; + if (device.Initialize(IntPtr.Zero, 0, 0, out string reason)) return device; + _output.WriteLine("Vulkan unavailable: " + reason); + device.Dispose(); + return null; + } + + private static int ColourTarget(VulkanDevice device, int size) + { + int texture = device.CreateTexture2D(size, size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = device.CreateFramebuffer(size, size); + device.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + device.SetDrawBuffers(framebuffer, 0b1); + return framebuffer; + } + + private static void BeginDraw(VulkanDevice device, int framebuffer, int size, int program) + { + device.BeginFrame(); + device.BindFramebuffer(framebuffer); + device.ClearColor(0, 0f, 0f, 0f, 1f); + device.SetViewport(0, 0, size, size); + device.SetDepthTest(false); + device.SetCullFace(false); + device.SetBlend(false, EnumBlendMode.Standard); + device.UseProgram(program); + } + + /// The centre pixel of the frame just presented. + private static unsafe byte[] ReadCentre(VulkanDevice device, int framebuffer, int size) + { + var pixels = new byte[size * size * 4]; + device.BindFramebuffer(framebuffer); + fixed (byte* destination = pixels) + { + device.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + int centre = (size / 2 * size + size / 2) * 4; + return new[] { pixels[centre], pixels[centre + 1], pixels[centre + 2], pixels[centre + 3] }; + } + + /// + /// The background path end to end: a pipeline the driver has never seen is not ready on the + /// frame that asks for it (the draw is skipped, the target keeps its clear), the worker + /// compiles it, the next frame start publishes it, and that frame's draw renders with it. + /// + [SkippableFact] + public void AnAsyncPipelineIsNotReadyUntilTheWorkerFinishesAndThenRenders() + { + using VulkanDevice? device = OpenDevice(synchronousPipelines: false); + Skip.If(device == null, "No usable Vulkan device."); + GraphicsPipelineCache pipelines = device!.PipelinesForTests; + Skip.IfNot(pipelines.AsyncCompiles, "The device lacks pipelineCreationCacheControl."); + const int size = 8; + + byte[] colour = UniqueColour(); + int program = VulkanDeviceIntegrationTests.LinkProgram(device, FullscreenVertex, SolidFragment(colour)); + int framebuffer = ColourTarget(device, size); + + BeginDraw(device, framebuffer, size, program); + device.DrawFullscreenTriangle(); + Assert.Equal(1, pipelines.DrawsSkipped); + Assert.Equal(1, pipelines.QueuedCompiles); + Assert.Equal(0, pipelines.CompiledSync); + Assert.Equal(0, pipelines.Count); + device.Present(); + Assert.Equal(new byte[] { 0, 0, 0, 255 }, ReadCentre(device, framebuffer, size)); + + Assert.True(pipelines.WaitForBackgroundCompiles(TimeSpan.FromSeconds(60)), "the worker did not finish"); + Assert.Equal(1, pipelines.CompiledAsync); + // Finished but not yet visible: publishing waits for the frame start. + Assert.Equal(0, pipelines.Count); + + BeginDraw(device, framebuffer, size, program); + Assert.Equal(1, pipelines.Count); + device.DrawFullscreenTriangle(); + device.Present(); + byte[] pixel = ReadCentre(device, framebuffer, size); + _output.WriteLine($"expected {string.Join(",", colour)}, centre {string.Join(",", pixel)}"); + + Assert.Equal(colour, pixel); + Assert.Equal(1, pipelines.DrawsSkipped); + Assert.Equal(0, pipelines.CompiledSync); + Assert.Equal(1, pipelines.Hits); + GpuTest.AssertClean(device); + } + + /// + /// A key asked for again while its compile is queued or running is not queued again, and + /// a second key for the same pipeline waits on the same compile. + /// + [SkippableFact] + public void TheSameKeyRequestedWhileCompilingCompilesOnce() + { + using VulkanDevice? device = OpenDevice(synchronousPipelines: false); + Skip.If(device == null, "No usable Vulkan device."); + GraphicsPipelineCache pipelines = device!.PipelinesForTests; + Skip.IfNot(pipelines.AsyncCompiles, "The device lacks pipelineCreationCacheControl."); + const int size = 8; + + byte[] colour = UniqueColour(); + int program = VulkanDeviceIntegrationTests.LinkProgram(device, FullscreenVertex, SolidFragment(colour)); + int framebuffer = ColourTarget(device, size); + + BeginDraw(device, framebuffer, size, program); + device.DrawFullscreenTriangle(); + device.DrawFullscreenTriangle(); + Assert.Equal(2, pipelines.DrawsSkipped); + device.Present(); + + // Asked for again on the next frame, whether or not the worker is done by then. + BeginDraw(device, framebuffer, size, program); + device.DrawFullscreenTriangle(); + device.Present(); + + Assert.True(pipelines.WaitForBackgroundCompiles(TimeSpan.FromSeconds(60)), "the worker did not finish"); + Assert.Equal(1, pipelines.QueuedCompiles); + Assert.Equal(1, pipelines.CompiledAsync); + + BeginDraw(device, framebuffer, size, program); + device.DrawFullscreenTriangle(); + device.DrawFullscreenTriangle(); + device.Present(); + + Assert.Equal(colour, ReadCentre(device, framebuffer, size)); + Assert.Equal(1, pipelines.Count); + Assert.Equal(1, pipelines.QueuedCompiles); + Assert.Equal(1, pipelines.CompiledAsync); + Assert.Equal(0, pipelines.CompiledSync); + Assert.Equal(0, pipelines.PendingCompiles); + GpuTest.AssertClean(device); + } + + /// + /// The launch-to-launch prewarm: a session records the pipelines it used in the key log; + /// the next session builds them on the worker as soon as their program links, and the + /// first draw that asks is served without a compile or a skipped frame. + /// + [SkippableFact] + public void APrewarmFromTheKeyLogServesTheFirstUseWithoutACompile() + { + string root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "optimum-pipeline-prewarm-" + Guid.NewGuid().ToString("N")); + const int size = 8; + byte[] colour = UniqueColour(); + string fragment = SolidFragment(colour); + + try + { + PipelineCacheIdentity identity; + using (VulkanDevice? first = OpenDevice(synchronousPipelines: true, cacheRoot: root)) + { + Skip.If(first == null, "No usable Vulkan device."); + Skip.IfNot(first!.ContextForTests.Capabilities.PipelineCreationCacheControl, + "The device lacks pipelineCreationCacheControl."); + identity = PipelineCacheIdentity.Of(first.ContextForTests.Capabilities); + + int program = VulkanDeviceIntegrationTests.LinkProgram(first, FullscreenVertex, fragment); + int framebuffer = ColourTarget(first, size); + BeginDraw(first, framebuffer, size, program); + first.DrawFullscreenTriangle(); + first.Present(); + Assert.Equal(colour, ReadCentre(first, framebuffer, size)); + Assert.Equal(1, first.PipelinesForTests.CompiledSync); + GpuTest.AssertClean(first); + } + + Assert.True(System.IO.File.Exists(PipelineKeyLog.PathFor(root, identity)), "no key log was written at shutdown"); + Assert.Equal(1, PipelineKeyLog.Load(PipelineKeyLog.PathFor(root, identity)).Count); + + using VulkanDevice? second = OpenDevice(synchronousPipelines: false, cacheRoot: root); + Skip.If(second == null, "No usable Vulkan device."); + GraphicsPipelineCache pipelines = second!.PipelinesForTests; + Assert.True(pipelines.AsyncCompiles); + + int relinked = VulkanDeviceIntegrationTests.LinkProgram(second, FullscreenVertex, fragment); + Assert.Equal(1, pipelines.PendingCompiles); + Assert.True(pipelines.WaitForBackgroundCompiles(TimeSpan.FromSeconds(60)), "the prewarm did not finish"); + Assert.Equal(1, pipelines.Prewarmed); + + int target = ColourTarget(second, size); + BeginDraw(second, target, size, relinked); + Assert.Equal(1, pipelines.PrewarmedWaiting); + second.DrawFullscreenTriangle(); + second.Present(); + + Assert.Equal(colour, ReadCentre(second, target, size)); + Assert.Equal(1, pipelines.PrewarmHits); + Assert.Equal(0, pipelines.DrawsSkipped); + Assert.Equal(0, pipelines.CompiledSync); + Assert.Equal(0, pipelines.CompiledAsync); + Assert.Equal(0, pipelines.QueuedCompiles); + _output.WriteLine($"prewarmed {pipelines.Prewarmed}, of them from the seeded driver cache {pipelines.Warm}"); + GpuTest.AssertClean(second); + } + finally + { + try + { + System.IO.Directory.Delete(root, recursive: true); + } + catch (System.IO.DirectoryNotFoundException) + { + } + } + } } diff --git a/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs b/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs index dcc25c81..da94082a 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Optimum.Render.Vulkan.Core; using Optimum.Render.Vulkan.Shaders; using Vintagestory.API.Client; using Xunit; +using VkFormat = Silk.NET.Vulkan.Format; +using VkPolygonMode = Silk.NET.Vulkan.PolygonMode; +using VkPrimitiveTopology = Silk.NET.Vulkan.PrimitiveTopology; namespace Optimum.Render.Vulkan.Tests; @@ -244,4 +248,192 @@ public void TheEnvironmentOverridesOrDisablesTheCacheDirectory(string? configure { Assert.Equal(expected, VulkanDevice.ResolveShaderCacheRoot(configured, environment)); } + + // ------------------------------------------------------- pipeline-key log + + private static PipelineKeyLogEntry KeyEntry(ulong program, ulong settings = 1, + VkFormat color = VkFormat.R8G8B8A8Unorm, bool blend = false) => new() + { + SettingsHash = settings, + ProgramHash = new UInt128(program, ~program), + Bindings = new[] { new VertexBinding(0, 24, false), new VertexBinding(15, 16, false) }, + Attributes = new[] + { + new VertexAttribute(0, 0, VkFormat.R32G32B32Sfloat, 0), + new VertexAttribute(1, 0, VkFormat.R32G32B32Sfloat, 12), + new VertexAttribute(2, 15, VkFormat.R32G32B32A32Sfloat, 0), + }, + ColorFormats = new[] { color, VkFormat.R16G16B16A16Sfloat }, + DepthFormat = VkFormat.D32Sfloat, + Blend = new[] { AttachmentBlend.Default with { Enabled = blend }, AttachmentBlend.Default }, + PolygonMode = VkPolygonMode.Fill, + Topology = VkPrimitiveTopology.TriangleList, + }; + + [Fact] + public void AKeyLogRoundTripsEveryEntryWithItsLastSeenTime() + { + var log = new PipelineKeyLog(); + PipelineKeyLogEntry first = KeyEntry(program: 7); + PipelineKeyLogEntry blended = KeyEntry(program: 7, blend: true); + PipelineKeyLogEntry otherSettings = KeyEntry(program: 7, settings: 2); + log.Record(first, 1000); + log.Record(blended, 2000); + log.Record(otherSettings, 3000); + // The same content again is the same entry, seen later. + log.Record(KeyEntry(program: 7), 4000); + + Assert.Equal(3, log.Count); + Assert.NotEqual(first.ContentId, blended.ContentId); + Assert.NotEqual(first.ContentId, otherSettings.ContentId); + + string path = Path.Combine(_root, "pipeline", "gpu.keys"); + Assert.True(log.HasUnsavedChanges); + Assert.True(log.Save(path)); + Assert.False(log.HasUnsavedChanges); + + PipelineKeyLog loaded = PipelineKeyLog.Load(path); + Assert.Equal(3, loaded.Count); + Assert.False(loaded.HasUnsavedChanges); + + List matching = loaded.Matching(1, new UInt128(7, ~7ul)); + Assert.Equal(2, matching.Count); + PipelineKeyLogEntry roundTripped = Assert.Single(matching, e => e.ContentId == first.ContentId); + Assert.Equal(4000, roundTripped.LastSeenUnixMs); + Assert.Equal(first.Bindings, roundTripped.Bindings); + Assert.Equal(first.Attributes, roundTripped.Attributes); + Assert.Equal(first.ColorFormats, roundTripped.ColorFormats); + Assert.Equal(first.DepthFormat, roundTripped.DepthFormat); + Assert.Equal(first.Blend, roundTripped.Blend); + Assert.Equal(first.PolygonMode, roundTripped.PolygonMode); + Assert.Equal(first.Topology, roundTripped.Topology); + Assert.Equal(3000, Assert.Single(loaded.Matching(2, new UInt128(7, ~7ul))).LastSeenUnixMs); + Assert.Empty(loaded.Matching(1, new UInt128(8, ~8ul))); + } + + [Fact] + public void AKeyLogOfAnotherFormatVersionIsDiscarded() + { + var log = new PipelineKeyLog(); + log.Record(KeyEntry(program: 1), 10); + byte[] file = log.Serialize(); + Assert.Equal(1, PipelineKeyLog.Parse(file).Count); + + // A whole, correctly hashed file that only differs in its version: the version check rejects it. + byte[] future = (byte[])file.Clone(); + BitConverter.TryWriteBytes(future.AsSpan(4), PipelineKeyLog.FormatVersion + 1); + System.Security.Cryptography.SHA256.HashData(future.AsSpan(0, future.Length - 32), future.AsSpan(future.Length - 32)); + Assert.Equal(0, PipelineKeyLog.Parse(future).Count); + } + + [Fact] + public void AKeyLogKeepsOnlyTheMostRecentlyUsedEntriesUpToItsCap() + { + var log = new PipelineKeyLog(capacity: 4); + for (ulong program = 1; program <= 4; program++) log.Record(KeyEntry(program), (long)program); + // Entry 1 is used again, so entry 2 is now the least recently seen. + log.Record(KeyEntry(1), 10); + log.Record(KeyEntry(5), 5); + + PipelineKeyLog loaded = PipelineKeyLog.Parse(log.Serialize(), capacity: 4); + Assert.Equal(4, loaded.Count); + Assert.Empty(loaded.Matching(1, new UInt128(2, ~2ul))); + foreach (ulong kept in new ulong[] { 1, 3, 4, 5 }) + { + Assert.Single(loaded.Matching(1, new UInt128(kept, ~kept))); + } + + // A file written with a larger cap is trimmed to the reader's. + Assert.Equal(2, PipelineKeyLog.Parse(log.Serialize(), capacity: 2).Count); + Assert.Single(PipelineKeyLog.Parse(log.Serialize(), capacity: 2).Matching(1, new UInt128(1, ~1ul))); + } + + [Fact] + public void ACorruptKeyLogIsIgnored() + { + var log = new PipelineKeyLog(); + log.Record(KeyEntry(program: 1), 10); + log.Record(KeyEntry(program: 2), 20); + byte[] file = log.Serialize(); + Assert.Equal(2, PipelineKeyLog.Parse(file).Count); + + byte[] flipped = (byte[])file.Clone(); + flipped[40] ^= 0x20; + byte[] garbage = new byte[file.Length]; + new Random(3).NextBytes(garbage); + + Assert.Equal(0, PipelineKeyLog.Parse(null).Count); + Assert.Equal(0, PipelineKeyLog.Parse(Array.Empty()).Count); + Assert.Equal(0, PipelineKeyLog.Parse(file[..^1]).Count); + Assert.Equal(0, PipelineKeyLog.Parse(file[..(file.Length / 2)]).Count); + Assert.Equal(0, PipelineKeyLog.Parse(new byte[file.Length]).Count); + Assert.Equal(0, PipelineKeyLog.Parse(flipped).Count); + Assert.Equal(0, PipelineKeyLog.Parse(garbage).Count); + Assert.Equal(0, PipelineKeyLog.Load(Path.Combine(_root, "missing.keys")).Count); + + // A damaged file on disk loads as empty and is replaced whole by the next save. + string path = Path.Combine(_root, "pipeline", "damaged.keys"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, flipped); + PipelineKeyLog reloaded = PipelineKeyLog.Load(path); + Assert.Equal(0, reloaded.Count); + reloaded.Record(KeyEntry(program: 3), 30); + Assert.True(reloaded.Save(path)); + Assert.Equal(1, PipelineKeyLog.Load(path).Count); + } + + [Fact] + public void TheKeyLogSitsBesideItsGpusPipelineCache() + { + PipelineCacheIdentity identity = Identity(); + string keys = PipelineKeyLog.PathFor(_root, identity); + Assert.Equal(Path.GetDirectoryName(PipelineCacheFile.PathFor(_root, identity)), Path.GetDirectoryName(keys)); + Assert.EndsWith(".keys", keys); + Assert.NotEqual(keys, PipelineKeyLog.PathFor(_root, Identity(device: 0x2804))); + Assert.NotEqual(PipelineKeyLog.SettingsHashFor(ColorWriteTier.PipelineKey, false), + PipelineKeyLog.SettingsHashFor(ColorWriteTier.DynamicMask, true)); + } + + // -------------------------------------------------------------- safe write + + [Fact] + public void AReplaceBlockedByAScannerIsRetriedWithBackoff() + { + string path = Path.Combine(_root, "retry", "cache.bin"); + int calls = 0; + var sleeps = new List(); + + bool written = CacheFileWriter.WriteAtomically(path, new byte[] { 1, 2, 3 }, (from, to) => + { + if (++calls < 3) throw new IOException("the file is held open by another process"); + File.Move(from, to, overwrite: true); + }, sleeps.Add); + + Assert.True(written); + Assert.Equal(3, calls); + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(path)); + Assert.Equal(2, sleeps.Count); + Assert.True(sleeps[1] > sleeps[0], "the backoff does not grow"); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, "*.tmp")); + } + + [Fact] + public void AReplaceThatKeepsFailingGivesUpAndKeepsTheOldFile() + { + string path = Path.Combine(_root, "retry", "cache.bin"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, new byte[] { 9, 9 }); + int calls = 0; + + bool written = CacheFileWriter.WriteAtomically(path, new byte[] { 1, 2, 3 }, (_, _) => + { + calls++; + throw new UnauthorizedAccessException("access denied"); + }, _ => { }); + + Assert.False(written); + Assert.Equal(CacheFileWriter.MoveAttempts, calls); + Assert.Equal(new byte[] { 9, 9 }, File.ReadAllBytes(path)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, "*.tmp")); + } } diff --git a/Optimum.Render.Vulkan/Core/CacheFileWriter.cs b/Optimum.Render.Vulkan/Core/CacheFileWriter.cs index 337d0256..1759b5e8 100644 --- a/Optimum.Render.Vulkan/Core/CacheFileWriter.cs +++ b/Optimum.Render.Vulkan/Core/CacheFileWriter.cs @@ -15,10 +15,19 @@ namespace Optimum.Render.Vulkan.Core; /// internal static class CacheFileWriter { - private const int MoveAttempts = 5; + internal const int MoveAttempts = 5; /// Writes to ; false when it could not. - public static bool WriteAtomically(string path, ReadOnlySpan bytes) + public static bool WriteAtomically(string path, ReadOnlySpan bytes) => + WriteAtomically(path, bytes, static (from, to) => File.Move(from, to, overwrite: true), Thread.Sleep); + + /// + /// The same, with the replace step and the backoff sleep supplied: tests stand in for a + /// scanner holding the new file open. moves its first argument + /// over its second; takes milliseconds. + /// + internal static bool WriteAtomically(string path, ReadOnlySpan bytes, Action replace, + Action sleep) { string temporary = path + "." + Environment.ProcessId + "." + Guid.NewGuid().ToString("N") + ".tmp"; try @@ -33,12 +42,12 @@ public static bool WriteAtomically(string path, ReadOnlySpan bytes) { try { - File.Move(temporary, path, overwrite: true); + replace(temporary, path); return true; } catch (Exception error) when (IsTransient(error) && attempt < MoveAttempts) { - Thread.Sleep(10 << attempt); + sleep(10 << attempt); } } } diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index 5c779979..a79bb7e1 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading; using Silk.NET.Core.Native; using Silk.NET.Vulkan; using Vintagestory.API.Client; @@ -25,6 +27,17 @@ internal sealed unsafe class GraphicsPipelineCache : IDisposable private readonly VulkanContext _context; private readonly Dictionary _pipelines = new(); private readonly Silk.NET.Vulkan.PipelineCache _driverCache; + + /// + /// Serialises every host access to : creations against it, + /// merges into it and reads of its data. Merges require it (the destination is externally + /// synchronised), and serialising the reads and merges is the workaround for the AMD + /// reports of parallel creation corrupting cache data (docs/research/vulkan-caching.md §1, + /// "Design for this renderer" item 4). The background compiles themselves run against a + /// cache of their own, outside this lock. + /// + private readonly object _driverCacheLock = new(); + private readonly DynamicState[] _dynamicStates; private bool _disposed; @@ -37,6 +50,54 @@ internal sealed unsafe class GraphicsPipelineCache : IDisposable /// How many lookups had to compile. public long Misses { get; private set; } + private long _compiledSync; + private long _compiledAsync; + private long _prewarmedCount; + private long _warm; + private long _prewarmHits; + private long _drawsSkipped; + private long _queuedCompiles; + + /// Pipelines compiled on the calling thread. + public long CompiledSync => Interlocked.Read(ref _compiledSync); + + /// Pipelines a lookup asked for that the background worker compiled. + public long CompiledAsync => Interlocked.Read(ref _compiledAsync); + + /// Pipelines the worker compiled from the key log before any lookup asked. + public long Prewarmed => Interlocked.Read(ref _prewarmedCount); + + /// FAIL_ON_PIPELINE_COMPILE_REQUIRED creations the driver cache satisfied without a compile. + public long Warm => Interlocked.Read(ref _warm); + + /// First lookups of a key served by a prewarmed pipeline. + public long PrewarmHits => Interlocked.Read(ref _prewarmHits); + + /// Lookups that reported "not ready" (the draw was skipped). + public long DrawsSkipped => Interlocked.Read(ref _drawsSkipped); + + /// Jobs handed to the background worker for a lookup (prewarm jobs not included). + public long QueuedCompiles => Interlocked.Read(ref _queuedCompiles); + + private bool _asyncCompiles; + + /// + /// Whether may hand compiles to the background worker. Only takes + /// effect on a device with pipelineCreationCacheControl; off (the default) keeps every + /// lookup blocking, as always is. + /// + public bool AsyncCompiles + { + get => _asyncCompiles; + set => _asyncCompiles = value && _context.Capabilities.PipelineCreationCacheControl && !_workersStopped; + } + + /// Where every pipeline this cache hands out is recorded, for the next launch's prewarm. Null records nothing. + public PipelineKeyLog? KeyLog { get; set; } + + /// The settings hash key-log entries of this cache carry (). + public ulong SettingsHash { get; } + /// The colour write tier every pipeline of this cache is built for. public ColorWriteTier ColorWriteTier { get; } @@ -73,6 +134,7 @@ public GraphicsPipelineCache(VulkanContext context, ColorWriteTier tier, bool dy { _context = context; ColorWriteTier = tier; + SettingsHash = PipelineKeyLog.SettingsHashFor(tier, dynamicBlend); var dynamicStates = new List(CoreDynamicStates); if (tier == ColorWriteTier.DynamicEnable) dynamicStates.Add(DynamicState.ColorWriteEnableExt); @@ -136,6 +198,7 @@ internal sealed class PipelineRequest public required PrimitiveTopology Topology { get; init; } } + /// The pipeline for , compiled on this thread if it has to be. public Pipeline Get(PipelineKey key, PipelineRequest request) { if (_pipelines.TryGetValue(key, out Pipeline existing)) @@ -145,12 +208,516 @@ public Pipeline Get(PipelineKey key, PipelineRequest request) } Misses++; - Pipeline pipeline = Create(request); - _pipelines[key] = pipeline; + PipelineKeyLogEntry entry = PipelineKeyLogEntry.From(SettingsHash, request); + if (!TryAdoptPrewarmed((request.Program.ProgramId, entry.ContentId), out Pipeline pipeline)) + { + pipeline = CreateBlocking(request); + } + Store(key, pipeline, entry); return pipeline; } - private Pipeline Create(PipelineRequest request) + /// + /// The pipeline for if it can be had without compiling on this + /// thread; otherwise false, with the compile queued on the background worker, and the + /// caller skips its draw (Unreal's default for a PSO that is not ready, + /// docs/research/vulkan-caching.md §2). A key already compiling is not queued again. + /// Finished compiles become visible at . + /// + /// With off this never returns false. + /// + public bool TryGet(PipelineKey key, PipelineRequest request, out Pipeline pipeline) + { + if (_pipelines.TryGetValue(key, out pipeline)) + { + Hits++; + return true; + } + + if (_pendingByKey.ContainsKey(key)) + { + NoteSkipped(); + return false; + } + + Misses++; + PipelineKeyLogEntry entry = PipelineKeyLogEntry.From(SettingsHash, request); + var id = (request.Program.ProgramId, entry.ContentId); + + if (TryAdoptPrewarmed(id, out pipeline)) + { + Store(key, pipeline, entry); + return true; + } + + // The same pipeline under another key, or a prewarm of it, is already on its way. + if (_pendingJobs.TryGetValue(id, out CompileJob? pending)) + { + pending.DemandKeys.Add(key); + _pendingByKey[key] = pending; + Promote(pending); + NoteSkipped(); + return false; + } + + if (!AsyncCompiles || _failedJobs.Contains(id)) + { + pipeline = CreateBlocking(request); + Store(key, pipeline, entry); + return true; + } + + Result result; + lock (_driverCacheLock) + { + result = CreatePipeline(request, _driverCache, PipelineCreateFlags.CreateFailOnPipelineCompileRequiredBit, + out pipeline); + } + if (result == Result.Success) + { + Interlocked.Increment(ref _warm); + VulkanStats.NotePipelineWarm(); + Store(key, pipeline, entry); + return true; + } + if (result != Result.PipelineCompileRequired) + { + throw new InvalidOperationException("vkCreateGraphicsPipelines failed: " + result); + } + + var job = new CompileJob(id, request, entry, prewarm: false); + if (!TryEnqueue(job)) + { + // The worker is saturated; a draw is never left waiting on a queue it cannot join. + pipeline = CreateBlocking(request); + Store(key, pipeline, entry); + return true; + } + + Interlocked.Increment(ref _queuedCompiles); + job.DemandKeys.Add(key); + _pendingJobs[id] = job; + _pendingByKey[key] = job; + VulkanStats.NotePipelinesPending(_pendingJobs.Count); + NoteSkipped(); + return false; + } + + private void NoteSkipped() + { + Interlocked.Increment(ref _drawsSkipped); + VulkanStats.NotePipelineDrawSkipped(); + } + + private Pipeline CreateBlocking(PipelineRequest request) + { + Result result; + Pipeline pipeline; + lock (_driverCacheLock) + { + result = CreatePipeline(request, _driverCache, 0, out pipeline); + } + if (result != Result.Success) + { + throw new InvalidOperationException("vkCreateGraphicsPipelines failed: " + result); + } + Interlocked.Increment(ref _compiledSync); + VulkanStats.NotePipelineCompiledSync(); + return pipeline; + } + + private void Store(PipelineKey key, Pipeline pipeline, PipelineKeyLogEntry entry) + { + _pipelines[key] = pipeline; + KeyLog?.Record(entry, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + } + + private bool TryAdoptPrewarmed((int ProgramId, UInt128 ContentId) id, out Pipeline pipeline) + { + if (!_prewarmed.Remove(id, out pipeline)) return false; + Interlocked.Increment(ref _prewarmHits); + return true; + } + + // ------------------------------------------------------------ background compiles + + /// A compile for the worker. Fields other than the result are touched by the render thread only. + private sealed class CompileJob + { + public CompileJob((int ProgramId, UInt128 ContentId) id, PipelineRequest request, PipelineKeyLogEntry entry, + bool prewarm) + { + Id = id; + Request = request; + Entry = entry; + Prewarm = prewarm; + } + + public (int ProgramId, UInt128 ContentId) Id { get; } + public PipelineRequest Request { get; } + public PipelineKeyLogEntry Entry { get; } + + /// Built from the key log; cleared when a lookup promotes it. Under the queue lock. + public bool Prewarm; + + /// Waiting in a queue rather than running or done. Under the queue lock. + public bool Queued; + + /// Its program was deleted; the result is destroyed rather than published. + public volatile bool Cancelled; + + /// The keys whose lookups wait for this pipeline. + public readonly List DemandKeys = new(); + + public Pipeline Pipeline; + public Result Status; + } + + /// Lookups first; each queue bounded so a burst cannot grow memory without limit. + internal const int DemandQueueCapacity = 256; + + internal const int PrewarmQueueCapacity = 4096; + + private readonly object _queueLock = new(); + private readonly LinkedList _demandQueue = new(); + private readonly LinkedList _prewarmQueue = new(); + private readonly List _inFlight = new(); + private readonly ConcurrentQueue _completed = new(); + private Thread[]? _workers; + private bool _stopping; + private bool _workersStopped; + + /// Render thread: every queued or running job by pipeline identity, and by the keys waiting on it. + private readonly Dictionary<(int ProgramId, UInt128 ContentId), CompileJob> _pendingJobs = new(); + private readonly Dictionary _pendingByKey = new(); + + /// Render thread: prewarmed pipelines no lookup has claimed yet. + private readonly Dictionary<(int ProgramId, UInt128 ContentId), Pipeline> _prewarmed = new(); + + /// Render thread: jobs whose compile failed; their next lookup compiles blocking and reports the error. + private readonly HashSet<(int ProgramId, UInt128 ContentId)> _failedJobs = new(); + + /// Compiles queued or running, prewarm included. + public int PendingCompiles => _pendingJobs.Count; + + /// Prewarmed pipelines published and not yet claimed by a lookup. + public int PrewarmedWaiting => _prewarmed.Count; + + private bool TryEnqueue(CompileJob job) + { + lock (_queueLock) + { + if (_stopping) return false; + LinkedList queue = job.Prewarm ? _prewarmQueue : _demandQueue; + int capacity = job.Prewarm ? PrewarmQueueCapacity : DemandQueueCapacity; + if (queue.Count >= capacity) return false; + queue.AddLast(job); + job.Queued = true; + _workers ??= StartWorkers(); + Monitor.Pulse(_queueLock); + return true; + } + } + + /// A lookup waits on a prewarm job still in its queue: it moves to the lookup queue. + private void Promote(CompileJob job) + { + lock (_queueLock) + { + if (!job.Prewarm) return; + job.Prewarm = false; + if (!job.Queued) return; + _prewarmQueue.Remove(job); + _demandQueue.AddLast(job); + } + } + + private Thread[] StartWorkers() + { + // One or two: compiles hold the main cache's lock only for the short warm attempt + // and the merge, so a second worker helps a cold start; more would compete with the + // game's own threads. + int count = Math.Clamp(Environment.ProcessorCount / 4, 1, 2); + var workers = new Thread[count]; + for (int i = 0; i < count; i++) + { + workers[i] = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "optimum-pipeline-compile-" + i, + }; + workers[i].Start(); + } + return workers; + } + + private void WorkerLoop() + { + while (true) + { + CompileJob job; + lock (_queueLock) + { + while (!_stopping && _demandQueue.Count == 0 && _prewarmQueue.Count == 0) Monitor.Wait(_queueLock); + if (_stopping) return; + LinkedList queue = _demandQueue.Count > 0 ? _demandQueue : _prewarmQueue; + job = queue.First!.Value; + queue.RemoveFirst(); + job.Queued = false; + _inFlight.Add(job); + } + + try + { + Compile(job); + } + finally + { + _completed.Enqueue(job); + lock (_queueLock) + { + _inFlight.Remove(job); + Monitor.PulseAll(_queueLock); + } + } + } + } + + private void Compile(CompileJob job) + { + Vk api = _context.Api; + + // The main cache may already hold it (a warm start): no compile, no merge. + lock (_driverCacheLock) + { + job.Status = CreatePipeline(job.Request, _driverCache, PipelineCreateFlags.CreateFailOnPipelineCompileRequiredBit, + out job.Pipeline); + } + if (job.Status == Result.Success) + { + Interlocked.Increment(ref _warm); + VulkanStats.NotePipelineWarm(); + NoteBuilt(job); + return; + } + if (job.Status != Result.PipelineCompileRequired) return; + + // The compile itself runs against a cache of this job's own, so the render thread's + // warm attempts never wait on it; the result is merged into the main cache after. + if (!TryCreateDriverCache(_context, null, out Silk.NET.Vulkan.PipelineCache local)) + { + job.Status = Result.ErrorInitializationFailed; + return; + } + try + { + job.Status = CreatePipeline(job.Request, local, 0, out job.Pipeline); + if (job.Status != Result.Success) return; + + lock (_driverCacheLock) + { + api.MergePipelineCaches(_context.Device, _driverCache, 1, &local); + } + + bool prewarm; + lock (_queueLock) prewarm = job.Prewarm; + if (!prewarm) + { + Interlocked.Increment(ref _compiledAsync); + VulkanStats.NotePipelineCompiledAsync(); + } + NoteBuilt(job); + } + finally + { + api.DestroyPipelineCache(_context.Device, local, null); + } + } + + /// Counts a prewarm job's pipeline once it exists, compiled or taken from the driver cache. + private void NoteBuilt(CompileJob job) + { + bool prewarm; + lock (_queueLock) prewarm = job.Prewarm; + if (!prewarm) return; + Interlocked.Increment(ref _prewarmedCount); + VulkanStats.NotePipelinePrewarmed(); + } + + /// + /// Render thread, at a safe point (frame start): makes the worker's finished pipelines + /// visible to lookups. Returns how many were published. + /// + public int PublishCompleted() + { + int published = 0; + while (_completed.TryDequeue(out CompileJob? job)) + { + _pendingJobs.Remove(job.Id); + foreach (PipelineKey key in job.DemandKeys) _pendingByKey.Remove(key); + + if (job.Status != Result.Success || job.Pipeline.Handle == 0) + { + // A lookup waiting on it retries blocking and surfaces the error there. + if (!job.Cancelled) _failedJobs.Add(job.Id); + continue; + } + + if (job.Cancelled || _disposed) + { + _context.Api.DestroyPipeline(_context.Device, job.Pipeline, null); + continue; + } + + bool used = false; + foreach (PipelineKey key in job.DemandKeys) + { + if (_pipelines.ContainsKey(key)) continue; + Store(key, job.Pipeline, job.Entry); + used = true; + } + if (!used && (job.DemandKeys.Count > 0 || !_prewarmed.TryAdd(job.Id, job.Pipeline))) + { + _context.Api.DestroyPipeline(_context.Device, job.Pipeline, null); + continue; + } + published++; + } + VulkanStats.NotePipelinesPending(_pendingJobs.Count); + return published; + } + + /// + /// Render thread, when links: queues a background build of + /// every key-log entry recorded for a program with the same SPIR-V under this cache's + /// settings. Needs . Returns how many were queued. + /// + public int PrewarmFor(ShaderProgramResources program) + { + if (KeyLog == null || !AsyncCompiles) return 0; + + int queued = 0; + foreach (PipelineKeyLogEntry entry in KeyLog.Matching(SettingsHash, program.SourceHash)) + { + var id = (program.ProgramId, entry.ContentId); + if (_pendingJobs.ContainsKey(id) || _prewarmed.ContainsKey(id)) continue; + + var job = new CompileJob(id, entry.ToRequest(program), entry, prewarm: true); + if (!TryEnqueue(job)) break; + _pendingJobs[id] = job; + queued++; + } + VulkanStats.NotePipelinesPending(_pendingJobs.Count); + return queued; + } + + /// + /// Render thread, before is destroyed: drops its queued + /// compiles and waits for any the worker is running, so no compile ever reads a + /// destroyed shader module or layout. + /// + public void CancelProgram(ShaderProgramResources program) + { + lock (_queueLock) + { + foreach (LinkedList queue in new[] { _demandQueue, _prewarmQueue }) + { + for (LinkedListNode? node = queue.First; node != null;) + { + LinkedListNode? next = node.Next; + if (ReferenceEquals(node.Value.Request.Program, program)) + { + node.Value.Cancelled = true; + node.Value.Queued = false; + queue.Remove(node); + Forget(node.Value); + } + node = next; + } + } + + while (true) + { + bool running = false; + foreach (CompileJob job in _inFlight) + { + if (!ReferenceEquals(job.Request.Program, program)) continue; + job.Cancelled = true; + running = true; + } + if (!running) break; + Monitor.Wait(_queueLock); + } + } + + var stale = new List<(int ProgramId, UInt128 ContentId)>(); + foreach ((int ProgramId, UInt128 ContentId) id in _prewarmed.Keys) + { + if (id.ProgramId == program.ProgramId) stale.Add(id); + } + foreach ((int ProgramId, UInt128 ContentId) id in stale) + { + _prewarmed.Remove(id, out Pipeline pipeline); + _context.Api.DestroyPipeline(_context.Device, pipeline, null); + } + VulkanStats.NotePipelinesPending(_pendingJobs.Count); + } + + private void Forget(CompileJob job) + { + _pendingJobs.Remove(job.Id); + foreach (PipelineKey key in job.DemandKeys) _pendingByKey.Remove(key); + } + + /// Waits until no compile is queued or running. Tests only; false on timeout. + internal bool WaitForBackgroundCompiles(TimeSpan timeout) + { + long deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + lock (_queueLock) + { + while (_demandQueue.Count > 0 || _prewarmQueue.Count > 0 || _inFlight.Count > 0) + { + long remaining = deadline - Environment.TickCount64; + if (remaining <= 0) return false; + Monitor.Wait(_queueLock, (int)Math.Min(remaining, int.MaxValue)); + } + } + return true; + } + + /// + /// Stops the workers after the job each is running, dropping queued ones. Lookups + /// compile blocking from then on. Before any program is destroyed at shutdown. + /// + public void StopBackgroundCompiles() + { + Thread[]? workers; + lock (_queueLock) + { + _stopping = true; + _workersStopped = true; + _asyncCompiles = false; + foreach (CompileJob job in _demandQueue) job.Cancelled = true; + foreach (CompileJob job in _prewarmQueue) job.Cancelled = true; + _demandQueue.Clear(); + _prewarmQueue.Clear(); + workers = _workers; + Monitor.PulseAll(_queueLock); + } + if (workers != null) + { + foreach (Thread worker in workers) worker.Join(); + } + while (_completed.TryDequeue(out CompileJob? job)) + { + if (job.Pipeline.Handle != 0) _context.Api.DestroyPipeline(_context.Device, job.Pipeline, null); + } + _pendingJobs.Clear(); + _pendingByKey.Clear(); + } + + private Result CreatePipeline(PipelineRequest request, Silk.NET.Vulkan.PipelineCache cache, + PipelineCreateFlags flags, out Pipeline pipeline) { Vk api = _context.Api; byte* entryPoint = (byte*)SilkMarshal.StringToPtr("main"); @@ -308,6 +875,9 @@ private Pipeline Create(PipelineRequest request) var createInfo = new GraphicsPipelineCreateInfo { SType = StructureType.GraphicsPipelineCreateInfo, + // FAIL_ON_PIPELINE_COMPILE_REQUIRED for a warm attempt: the driver + // returns VK_PIPELINE_COMPILE_REQUIRED instead of compiling. + Flags = flags, PNext = &renderingInfo, StageCount = (uint)stages.Count, PStages = stagesPtr, @@ -323,13 +893,9 @@ private Pipeline Create(PipelineRequest request) }; Result result = api.CreateGraphicsPipelines( - _context.Device, _driverCache, 1, &createInfo, null, out Pipeline pipeline); - - if (result != Result.Success) - { - throw new InvalidOperationException("vkCreateGraphicsPipelines failed: " + result); - } - return pipeline; + _context.Device, cache, 1, &createInfo, null, out pipeline); + if (result != Result.Success) pipeline = default; + return result; } } finally @@ -345,7 +911,24 @@ private Pipeline Create(PipelineRequest request) public byte[] SerializeDriverCache() { if (_driverCache.Handle == 0) return Array.Empty(); + lock (_driverCacheLock) return SerializeDriverCacheLocked(); + } + + /// The serialised driver cache size in bytes, without copying it. Safe from any thread. + public long DriverCacheSize() + { + if (_driverCache.Handle == 0) return 0; + lock (_driverCacheLock) + { + nuint size = 0; + return _context.Api.GetPipelineCacheData(_context.Device, _driverCache, ref size, null) == Result.Success + ? (long)size + : 0; + } + } + private byte[] SerializeDriverCacheLocked() + { // The cache can grow between the size query and the fetch while another // thread creates a pipeline; VK_INCOMPLETE then means "ask again". for (int attempt = 0; attempt < 4; attempt++) @@ -374,12 +957,21 @@ public void Dispose() if (_disposed) return; _disposed = true; + StopBackgroundCompiles(); + Vk api = _context.Api; + // One pipeline can serve several keys (equal content under different interned ids). + var destroyed = new HashSet(); foreach (Pipeline pipeline in _pipelines.Values) { - api.DestroyPipeline(_context.Device, pipeline, null); + if (destroyed.Add(pipeline.Handle)) api.DestroyPipeline(_context.Device, pipeline, null); } _pipelines.Clear(); + foreach (Pipeline pipeline in _prewarmed.Values) + { + if (destroyed.Add(pipeline.Handle)) api.DestroyPipeline(_context.Device, pipeline, null); + } + _prewarmed.Clear(); if (_driverCache.Handle != 0) { diff --git a/Optimum.Render.Vulkan/Core/PipelineCachePersistence.cs b/Optimum.Render.Vulkan/Core/PipelineCachePersistence.cs new file mode 100644 index 00000000..c1945bb9 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineCachePersistence.cs @@ -0,0 +1,170 @@ +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Decides when the driver's pipeline cache has grown enough to be worth writing out +/// before shutdown. +/// +/// A session that crashes, or is killed, loses everything a shutdown-only save would +/// have written. Godot saves from a worker when the cache has grown by some megabytes +/// rather than on a timer (docs/research/vulkan-caching.md §1, godot#76348); this is that +/// rule, with the size sampled at most once per interval because the size query goes +/// through the driver. +/// +internal sealed class PipelineCacheGrowthTrigger +{ + public const long DefaultThresholdBytes = 8L * 1024 * 1024; + + public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(10); + + private readonly long _thresholdBytes; + private readonly long _intervalTicks; + private long _lastSampleTimestamp; + private long _baselineBytes; + + /// What is on disk already: the seed the cache was created from, or 0. + public PipelineCacheGrowthTrigger(long thresholdBytes, TimeSpan interval, long baselineBytes) + { + _thresholdBytes = Math.Max(1, thresholdBytes); + _intervalTicks = (long)(interval.TotalSeconds * Stopwatch.Frequency); + _baselineBytes = baselineBytes; + } + + public long BaselineBytes => Interlocked.Read(ref _baselineBytes); + + /// + /// Whether a size sample is due at (Stopwatch ticks). The + /// first call only arms the clock; afterwards at most one sample per interval. + /// + public bool SampleDue(long timestamp) + { + if (_lastSampleTimestamp == 0) + { + _lastSampleTimestamp = timestamp; + return false; + } + if (timestamp - _lastSampleTimestamp < _intervalTicks) return false; + _lastSampleTimestamp = timestamp; + return true; + } + + /// The cache is at least the threshold larger than what was last written. + public bool GrewEnough(long currentBytes) => currentBytes - BaselineBytes >= _thresholdBytes; + + public void NoteSaved(long bytes) => Interlocked.Exchange(ref _baselineBytes, bytes); +} + +/// +/// The pipeline cache and pipeline-key log files of one GPU, and the saves that write them. +/// +/// The render thread only calls , a timestamp comparison; the size query, +/// the serialisation and the file writes run on a thread-pool task, one at a time. The +/// shutdown save stays: waits for a running save and writes +/// both files once more. +/// +internal sealed class PipelineCachePersistence +{ + private readonly PipelineCacheIdentity _identity; + private readonly PipelineCacheGrowthTrigger _trigger; + private Task? _pending; + private long _saves; + + public string CachePath { get; } + public string KeyLogPath { get; } + public PipelineKeyLog KeyLog { get; } + + /// Where a failed write is reported (the validation mirror in the device). + public Action? Log { get; set; } + + /// Driver cache files written, opportunistic and shutdown saves together. + public long Saves => Interlocked.Read(ref _saves); + + private PipelineCachePersistence(string cachePath, string keyLogPath, PipelineCacheIdentity identity, + PipelineKeyLog keyLog, PipelineCacheGrowthTrigger trigger) + { + CachePath = cachePath; + KeyLogPath = keyLogPath; + _identity = identity; + KeyLog = keyLog; + _trigger = trigger; + } + + /// Loads both files for ; is the usable driver blob or null. + public static PipelineCachePersistence Open(string cacheRoot, PipelineCacheIdentity identity, out byte[]? seed, + long thresholdBytes = PipelineCacheGrowthTrigger.DefaultThresholdBytes, TimeSpan? interval = null) + { + string cachePath = PipelineCacheFile.PathFor(cacheRoot, identity); + string keyLogPath = PipelineKeyLog.PathFor(cacheRoot, identity); + seed = PipelineCacheFile.Load(cachePath, identity); + return new PipelineCachePersistence(cachePath, keyLogPath, identity, PipelineKeyLog.Load(keyLogPath), + new PipelineCacheGrowthTrigger(thresholdBytes, interval ?? PipelineCacheGrowthTrigger.DefaultInterval, + seed?.Length ?? 0)); + } + + /// + /// Render thread, once per frame: starts a background save pass when a sample is due + /// and no pass is running. True when one started. + /// + public bool Tick(GraphicsPipelineCache cache, long timestamp) + { + if (_pending is { IsCompleted: false }) return false; + if (!_trigger.SampleDue(timestamp)) return false; + _pending = Task.Run(() => BackgroundPass(cache)); + return true; + } + + /// Waits for a running background pass. Before the cache is disposed. + public void WaitForPendingSave() + { + Task? pending = _pending; + if (pending == null) return; + try + { + pending.Wait(); + } + catch (AggregateException error) + { + Log?.Invoke("--- pipeline cache background save failed: " + error.InnerException?.Message); + } + } + + public void SaveAtShutdown(GraphicsPipelineCache cache) + { + WaitForPendingSave(); + SaveDriverCache(cache); + if (KeyLog.HasUnsavedChanges && !KeyLog.Save(KeyLogPath)) + { + Log?.Invoke("--- pipeline key log not saved to " + KeyLogPath); + } + } + + private void BackgroundPass(GraphicsPipelineCache cache) + { + long size = cache.DriverCacheSize(); + VulkanStats.NotePipelineCacheBytes(size); + if (_trigger.GrewEnough(size)) SaveDriverCache(cache); + if (KeyLog.HasUnsavedChanges && !KeyLog.Save(KeyLogPath)) + { + Log?.Invoke("--- pipeline key log not saved to " + KeyLogPath); + } + } + + private void SaveDriverCache(GraphicsPipelineCache cache) + { + byte[] blob = cache.SerializeDriverCache(); + if (blob.Length == 0) return; + VulkanStats.NotePipelineCacheBytes(blob.Length); + if (!PipelineCacheFile.Save(CachePath, blob, _identity)) + { + Log?.Invoke("--- pipeline cache not saved to " + CachePath); + return; + } + _trigger.NoteSaved(blob.Length); + Interlocked.Increment(ref _saves); + VulkanStats.NotePipelineCacheSave(); + } +} diff --git a/Optimum.Render.Vulkan/Core/PipelineKeyLog.cs b/Optimum.Render.Vulkan/Core/PipelineKeyLog.cs new file mode 100644 index 00000000..4071e961 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineKeyLog.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// One pipeline the renderer built, described by what decides it rather than by the +/// session's interned ids. +/// +/// A names its program, vertex layout, target formats and +/// blend set by ids handed out in first-use order, which mean nothing in the next +/// launch. This entry carries the content those ids stood for: the program's SPIR-V +/// hash (, which already has the +/// program's defines resolved), the vertex layout, the attachment formats, the blend +/// state per attachment, polygon mode and topology, plus the settings hash of the +/// device-wide state that shapes every pipeline (). +/// Design: docs/research/vulkan-caching.md "Design for this renderer" item 5. +/// +internal sealed class PipelineKeyLogEntry +{ + /// More than any attachment or attribute count Vulkan allows; a larger count is a damaged file. + private const int MaxArrayLength = 64; + + public required ulong SettingsHash { get; init; } + public required UInt128 ProgramHash { get; init; } + public required VertexBinding[] Bindings { get; init; } + public required VertexAttribute[] Attributes { get; init; } + public required Format[] ColorFormats { get; init; } + public required Format DepthFormat { get; init; } + + /// Exactly one per colour format: the pipeline builder pads a short blend array with the default. + public required AttachmentBlend[] Blend { get; init; } + + public required PolygonMode PolygonMode { get; init; } + public required PrimitiveTopology Topology { get; init; } + + /// Unix milliseconds of the last session that used this pipeline; the LRU order. + public long LastSeenUnixMs { get; set; } + + private UInt128? _contentId; + + /// A hash of everything above except : equal ids build equal pipelines. + public UInt128 ContentId => _contentId ??= ComputeContentId(); + + public static PipelineKeyLogEntry From(ulong settingsHash, GraphicsPipelineCache.PipelineRequest request) + { + Format[] colors = request.Targets.ColorFormats; + var blend = new AttachmentBlend[colors.Length]; + for (int i = 0; i < blend.Length; i++) + { + blend[i] = i < request.Blend.Length ? request.Blend[i] : AttachmentBlend.Default; + } + + return new PipelineKeyLogEntry + { + SettingsHash = settingsHash, + ProgramHash = request.Program.SourceHash, + Bindings = (VertexBinding[])request.VertexLayout.Bindings.Clone(), + Attributes = (VertexAttribute[])request.VertexLayout.Attributes.Clone(), + ColorFormats = (Format[])colors.Clone(), + DepthFormat = request.Targets.DepthFormat, + Blend = blend, + PolygonMode = request.PolygonMode, + Topology = request.Topology, + }; + } + + /// The request that rebuilds this pipeline for , whose hash must match. + public GraphicsPipelineCache.PipelineRequest ToRequest(ShaderProgramResources program) => new() + { + Program = program, + VertexLayout = new VertexLayoutDescription(Bindings, Attributes), + Targets = new RenderTargetFormats(ColorFormats, DepthFormat), + Blend = Blend, + PolygonMode = PolygonMode, + Topology = Topology, + }; + + private UInt128 ComputeContentId() + { + using var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + WriteContent(writer); + } + Span hash = stackalloc byte[32]; + SHA256.HashData(stream.GetBuffer().AsSpan(0, (int)stream.Length), hash); + return new UInt128(BitConverter.ToUInt64(hash[..8]), BitConverter.ToUInt64(hash.Slice(8, 8))); + } + + internal void WriteContent(BinaryWriter writer) + { + writer.Write(SettingsHash); + writer.Write((ulong)(ProgramHash >> 64)); + writer.Write((ulong)ProgramHash); + + writer.Write(Bindings.Length); + foreach (VertexBinding binding in Bindings) + { + writer.Write(binding.Binding); + writer.Write(binding.Stride); + writer.Write(binding.PerInstance); + } + + writer.Write(Attributes.Length); + foreach (VertexAttribute attribute in Attributes) + { + writer.Write(attribute.Location); + writer.Write(attribute.Binding); + writer.Write((int)attribute.Format); + writer.Write(attribute.Offset); + } + + writer.Write(ColorFormats.Length); + foreach (Format format in ColorFormats) writer.Write((int)format); + writer.Write((int)DepthFormat); + + writer.Write(Blend.Length); + foreach (AttachmentBlend blend in Blend) + { + writer.Write(blend.Enabled); + writer.Write((int)blend.SrcColor); + writer.Write((int)blend.DstColor); + writer.Write((int)blend.ColorOp); + writer.Write((int)blend.SrcAlpha); + writer.Write((int)blend.DstAlpha); + writer.Write((int)blend.AlphaOp); + writer.Write((uint)blend.WriteMask); + } + + writer.Write((int)PolygonMode); + writer.Write((int)Topology); + } + + /// One entry's content, or null when a count is out of range. Truncation throws EndOfStreamException. + internal static PipelineKeyLogEntry? ReadContent(BinaryReader reader) + { + ulong settingsHash = reader.ReadUInt64(); + ulong programHigh = reader.ReadUInt64(); + ulong programLow = reader.ReadUInt64(); + + int bindingCount = reader.ReadInt32(); + if ((uint)bindingCount > MaxArrayLength) return null; + var bindings = new VertexBinding[bindingCount]; + for (int i = 0; i < bindingCount; i++) + { + bindings[i] = new VertexBinding(reader.ReadUInt32(), reader.ReadUInt32(), reader.ReadBoolean()); + } + + int attributeCount = reader.ReadInt32(); + if ((uint)attributeCount > MaxArrayLength) return null; + var attributes = new VertexAttribute[attributeCount]; + for (int i = 0; i < attributeCount; i++) + { + attributes[i] = new VertexAttribute(reader.ReadUInt32(), reader.ReadUInt32(), (Format)reader.ReadInt32(), + reader.ReadUInt32()); + } + + int colorCount = reader.ReadInt32(); + if ((uint)colorCount > MaxArrayLength) return null; + var colors = new Format[colorCount]; + for (int i = 0; i < colorCount; i++) colors[i] = (Format)reader.ReadInt32(); + var depth = (Format)reader.ReadInt32(); + + int blendCount = reader.ReadInt32(); + if (blendCount != colorCount) return null; + var blend = new AttachmentBlend[blendCount]; + for (int i = 0; i < blendCount; i++) + { + blend[i] = new AttachmentBlend + { + Enabled = reader.ReadBoolean(), + SrcColor = (BlendFactor)reader.ReadInt32(), + DstColor = (BlendFactor)reader.ReadInt32(), + ColorOp = (BlendOp)reader.ReadInt32(), + SrcAlpha = (BlendFactor)reader.ReadInt32(), + DstAlpha = (BlendFactor)reader.ReadInt32(), + AlphaOp = (BlendOp)reader.ReadInt32(), + WriteMask = (ColorComponentFlags)reader.ReadUInt32(), + }; + } + + return new PipelineKeyLogEntry + { + SettingsHash = settingsHash, + ProgramHash = new UInt128(programHigh, programLow), + Bindings = bindings, + Attributes = attributes, + ColorFormats = colors, + DepthFormat = depth, + Blend = blend, + PolygonMode = (PolygonMode)reader.ReadInt32(), + Topology = (PrimitiveTopology)reader.ReadInt32(), + }; + } +} + +/// +/// Every pipeline the renderer has used, kept next to the driver's pipeline cache so the +/// next launch can build them on a background worker before the first draw asks +/// (docs/research/vulkan-caching.md §6 and "Design for this renderer" item 5). +/// +/// Settings combinations make such a log grow without bound (§7), so it is capped by +/// entry count and evicts the entries whose last use is oldest. The file is versioned +/// and carries a SHA-256 of its contents; a file of another version, a truncated or +/// damaged one, reads as an empty log - it only ever costs a prewarm, never a crash. +/// +internal sealed class PipelineKeyLog +{ + /// "OPKL", little-endian. + internal const uint FileMagic = 0x4C4B504F; + + /// Bumped whenever the entry layout or the meaning of a hash in it changes. + internal const uint FormatVersion = 1; + + public const int DefaultCapacity = 4096; + + private const int HeaderSize = 4 + 4 + 4; + private const int TrailerSize = 32; + + private readonly object _lock = new(); + private readonly Dictionary _entries = new(); + private long _changes; + private long _savedChanges; + + public PipelineKeyLog(int capacity = DefaultCapacity) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + Capacity = capacity; + } + + public int Capacity { get; } + + public int Count + { + get { lock (_lock) return _entries.Count; } + } + + /// Something was recorded since the log was loaded or last saved. + public bool HasUnsavedChanges + { + get { lock (_lock) return _changes != _savedChanges; } + } + + /// + /// The device-wide state every pipeline of a cache is built with beyond its request: + /// the colour write tier (which states are dynamic) and dynamic blend. Program defines + /// are not here - they are part of the SPIR-V the program hash covers. + /// + public static ulong SettingsHashFor(ColorWriteTier tier, bool dynamicBlend) + { + Span hash = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes( + "optimum-pipeline-settings-" + FormatVersion + "|" + (int)tier + "|" + (dynamicBlend ? 1 : 0)), hash); + return BitConverter.ToUInt64(hash[..8]); + } + + /// Beside the driver cache file of the same GPU. + public static string PathFor(string cacheRoot, PipelineCacheIdentity identity) => + Path.Combine(cacheRoot, "pipeline", Path.ChangeExtension(identity.FileName, ".keys")); + + /// Notes a pipeline used now; an entry already present only moves its last-seen time. + public void Record(PipelineKeyLogEntry entry, long nowUnixMs) + { + lock (_lock) + { + if (_entries.TryGetValue(entry.ContentId, out PipelineKeyLogEntry? existing)) + { + if (existing.LastSeenUnixMs >= nowUnixMs) return; + existing.LastSeenUnixMs = nowUnixMs; + } + else + { + entry.LastSeenUnixMs = nowUnixMs; + _entries[entry.ContentId] = entry; + // Amortised: evict only once the log is a quarter over its cap. + if (_entries.Count > Capacity + Capacity / 4) TrimLocked(); + } + _changes++; + } + } + + /// The entries built for this settings hash and program. + public List Matching(ulong settingsHash, UInt128 programHash) + { + lock (_lock) + { + return _entries.Values.Where(e => e.SettingsHash == settingsHash && e.ProgramHash == programHash).ToList(); + } + } + + /// The file bytes: at most entries, most recently used first. + public byte[] Serialize() + { + lock (_lock) return SerializeLocked(); + } + + private byte[] SerializeLocked() + { + TrimLocked(); + using var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + writer.Write(FileMagic); + writer.Write(FormatVersion); + writer.Write(_entries.Count); + foreach (PipelineKeyLogEntry entry in _entries.Values.OrderByDescending(e => e.LastSeenUnixMs)) + { + entry.WriteContent(writer); + writer.Write(entry.LastSeenUnixMs); + } + } + + long length = stream.Length; + var file = new byte[length + TrailerSize]; + stream.GetBuffer().AsSpan(0, (int)length).CopyTo(file); + SHA256.HashData(file.AsSpan(0, (int)length), file.AsSpan((int)length, TrailerSize)); + return file; + } + + /// A log from file bytes; anything not written whole by this version is an empty log. + public static PipelineKeyLog Parse(byte[]? file, int capacity = DefaultCapacity) + { + var log = new PipelineKeyLog(capacity); + if (file == null || file.Length < HeaderSize + TrailerSize) return log; + if (BitConverter.ToUInt32(file, 0) != FileMagic) return log; + if (BitConverter.ToUInt32(file, 4) != FormatVersion) return log; + + int payload = file.Length - TrailerSize; + Span hash = stackalloc byte[32]; + SHA256.HashData(file.AsSpan(0, payload), hash); + if (!hash.SequenceEqual(file.AsSpan(payload, TrailerSize))) return log; + + var parsed = new Dictionary(); + try + { + using var stream = new MemoryStream(file, 0, payload, writable: false); + using var reader = new BinaryReader(stream, Encoding.UTF8); + reader.ReadUInt32(); + reader.ReadUInt32(); + int count = reader.ReadInt32(); + if (count < 0) return log; + for (int i = 0; i < count; i++) + { + PipelineKeyLogEntry? entry = PipelineKeyLogEntry.ReadContent(reader); + if (entry == null) return log; + entry.LastSeenUnixMs = reader.ReadInt64(); + parsed[entry.ContentId] = entry; + } + if (stream.Position != payload) return log; + } + catch (EndOfStreamException) + { + return log; + } + + foreach (KeyValuePair entry in parsed) log._entries.Add(entry.Key, entry.Value); + log.TrimLocked(); + return log; + } + + public static PipelineKeyLog Load(string path, int capacity = DefaultCapacity) => + Parse(CacheFileWriter.TryReadAll(path), capacity); + + /// Writes the log; false when it could not. Safe from any thread. + public bool Save(string path) + { + long changes; + byte[] bytes; + lock (_lock) + { + changes = _changes; + bytes = SerializeLocked(); + } + if (!CacheFileWriter.WriteAtomically(path, bytes)) return false; + lock (_lock) _savedChanges = Math.Max(_savedChanges, changes); + return true; + } + + /// Drops the least recently used entries beyond . + private void TrimLocked() + { + int excess = _entries.Count - Capacity; + if (excess <= 0) return; + foreach (PipelineKeyLogEntry stale in _entries.Values.OrderBy(e => e.LastSeenUnixMs).Take(excess).ToList()) + { + _entries.Remove(stale.ContentId); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs index 351c8f6e..b9b7eb9e 100644 --- a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -73,6 +73,7 @@ public ShaderProgramResources( { Modules[stage.Key] = CreateModule(stage.Value); } + SourceHash = HashSpirv(translated.Spirv); // Sampler uniforms default to the unit matching their binding, which is // the order the game's own texture-location bookkeeping assigns. @@ -92,6 +93,32 @@ public ShaderProgramResources( CreatePipelineLayout(); } + /// + /// A hash of every stage's SPIR-V, in stage order. The SPIR-V was compiled from the + /// rewritten source with its defines resolved, so two programs with this hash build the + /// same pipelines for the same state - what the pipeline-key log matches on across launches. + /// + public UInt128 SourceHash { get; } + + private static UInt128 HashSpirv(Dictionary spirv) + { + using var hash = System.Security.Cryptography.IncrementalHash.CreateHash( + System.Security.Cryptography.HashAlgorithmName.SHA256); + var stages = new List(spirv.Keys); + stages.Sort(); + Span header = stackalloc byte[8]; + foreach (EnumShaderType stage in stages) + { + BitConverter.TryWriteBytes(header, (int)stage); + BitConverter.TryWriteBytes(header[4..], spirv[stage].Length); + hash.AppendData(header); + hash.AppendData(spirv[stage]); + } + Span digest = stackalloc byte[32]; + hash.GetHashAndReset(digest); + return new UInt128(BitConverter.ToUInt64(digest[..8]), BitConverter.ToUInt64(digest.Slice(8, 8))); + } + private ShaderModule CreateModule(byte[] spirv) { fixed (byte* code = spirv) diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 7558a005..10983b20 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -84,6 +84,11 @@ internal sealed class VulkanCapabilities public bool DynamicColorBlend; /// The tier draws use; see . public ColorWriteTier ColorWriteTier = ColorWriteTier.PipelineKey; + /// + /// pipelineCreationCacheControl (core in 1.3, optional to support) enabled: pipelines can be + /// created with FAIL_ON_PIPELINE_COMPILE_REQUIRED, which the background compile path needs. + /// + public bool PipelineCreationCacheControl; } /// @@ -940,12 +945,24 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso _ => wantDeviceFault ? &faultFeatures : null, }; + // Optional: FAIL_ON_PIPELINE_COMPILE_REQUIRED for the background compile path + // (docs/research/vulkan-caching.md §2). Without it every pipeline compiles blocking. + var vulkan13Query = new PhysicalDeviceVulkan13Features { SType = StructureType.PhysicalDeviceVulkan13Features }; + var vulkan13QueryRoot = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan13Query, + }; + Api.GetPhysicalDeviceFeatures2(PhysicalDevice, &vulkan13QueryRoot); + bool pipelineCacheControl = vulkan13Query.PipelineCreationCacheControl; + var vulkan13 = new PhysicalDeviceVulkan13Features { SType = StructureType.PhysicalDeviceVulkan13Features, PNext = optionalFeatures, DynamicRendering = true, Synchronization2 = true, + PipelineCreationCacheControl = pipelineCacheControl, }; var vulkan12 = new PhysicalDeviceVulkan12Features { @@ -1012,6 +1029,7 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso LoadDiagnosticExtensions(wantCheckpoints, wantDeviceFault); Capabilities = ReadCapabilities(); Capabilities.ColorWriteTier = colorWriteTier; + Capabilities.PipelineCreationCacheControl = pipelineCacheControl; Capabilities.ColorWriteEnable = colorWriteTier == ColorWriteTier.DynamicEnable; Capabilities.DynamicColorWriteMask = colorWriteTier == ColorWriteTier.DynamicMask; Capabilities.DynamicColorBlend = colorWriteTier == ColorWriteTier.DynamicMask && canBlend; diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 3ddd207c..1c0439f9 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -423,7 +423,8 @@ public static Result WaitDeviceIdle(Vk api, Device device) Leases: Interlocked.Exchange(ref _transientLeases, 0), AliasedLeases: Interlocked.Exchange(ref _aliasedLeases, 0), ReadSelfCopies: Interlocked.Exchange(ref _readSelfCopies, 0), - ReadSelfPool: Interlocked.Read(ref _readSelfPool))); + ReadSelfPool: Interlocked.Read(ref _readSelfPool))) + "\n" + + FormatPipelinesLine(TakePipelineSample()); } private static double Mib(ulong bytes) => bytes / (1024.0 * 1024.0); @@ -494,8 +495,76 @@ public static string FormatCountersLine(CounterSample counters) => counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits); private static long _lastSample; + + private static long _pipelinesSync; + private static long _pipelinesAsync; + private static long _pipelinesPrewarmed; + private static long _pipelinesWarm; + private static long _pipelineDrawsSkipped; + private static long _pipelinesPending; + private static long _pipelineCacheBytes; + private static long _pipelineCacheSaves; + + /// A pipeline compiled on the calling (render) thread, blocking the draw. + public static void NotePipelineCompiledSync() => Interlocked.Increment(ref _pipelinesSync); + + /// A pipeline a draw asked for, compiled by the background worker. + public static void NotePipelineCompiledAsync() => Interlocked.Increment(ref _pipelinesAsync); + + /// A pipeline built from the pipeline-key log before any draw asked for it. + public static void NotePipelinePrewarmed() => Interlocked.Increment(ref _pipelinesPrewarmed); + + /// A FAIL_ON_PIPELINE_COMPILE_REQUIRED creation the driver cache satisfied without compiling. + public static void NotePipelineWarm() => Interlocked.Increment(ref _pipelinesWarm); + + /// A draw skipped because its pipeline was still compiling in the background. + public static void NotePipelineDrawSkipped() => Interlocked.Increment(ref _pipelineDrawsSkipped); + + public static void NotePipelinesPending(int pending) => Interlocked.Exchange(ref _pipelinesPending, pending); + + /// Serialised driver cache size at the last sample or save. + public static void NotePipelineCacheBytes(long bytes) => Interlocked.Exchange(ref _pipelineCacheBytes, bytes); + + /// A driver pipeline cache file written (opportunistic or shutdown). + public static void NotePipelineCacheSave() => Interlocked.Increment(ref _pipelineCacheSaves); + + public static long PipelineDrawsSkipped => Interlocked.Read(ref _pipelineDrawsSkipped); + + private static PipelineSample TakePipelineSample() => new( + CompiledSync: Interlocked.Exchange(ref _pipelinesSync, 0), + CompiledAsync: Interlocked.Exchange(ref _pipelinesAsync, 0), + Prewarmed: Interlocked.Exchange(ref _pipelinesPrewarmed, 0), + Warm: Interlocked.Exchange(ref _pipelinesWarm, 0), + DrawsSkipped: Interlocked.Exchange(ref _pipelineDrawsSkipped, 0), + Pending: Interlocked.Read(ref _pipelinesPending), + CacheBytes: Interlocked.Read(ref _pipelineCacheBytes), + Saves: Interlocked.Exchange(ref _pipelineCacheSaves, 0)); + + /// + /// stats.pipelines: pipelines compiled blocking, by the background worker, and + /// prewarmed from the key log over the interval; creations the driver cache satisfied + /// without a compile; draws skipped waiting for a pipeline; compiles still pending; the + /// serialised driver cache size at the last sample; cache files written over the interval. + /// + public static string FormatPipelinesLine(PipelineSample sample) => + string.Format(CultureInfo.InvariantCulture, + "stats.pipelines compiled_sync={0} compiled_async={1} prewarmed={2} warm={3} draws_skipped={4} " + + "pending={5} cache_bytes={6} saves={7}", + sample.CompiledSync, sample.CompiledAsync, sample.Prewarmed, sample.Warm, sample.DrawsSkipped, + sample.Pending, sample.CacheBytes, sample.Saves); } +/// The values on the stats.pipelines line. +internal readonly record struct PipelineSample( + long CompiledSync, + long CompiledAsync, + long Prewarmed, + long Warm, + long DrawsSkipped, + long Pending, + long CacheBytes, + long Saves); + /// The per-interval counters on the stats.counters line. internal readonly record struct CounterSample( long BlockingUploads, diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 205df880..2d10d44c 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -54,8 +54,22 @@ public sealed unsafe class VulkanDevice : IDisposable /// public string? ShaderCacheDirectory { get; set; } - private string? _pipelineCachePath; - private PipelineCacheIdentity _pipelineCacheIdentity; + /// The pipeline cache and key-log files and their saves; null when there is no cache root. + private PipelineCachePersistence? _pipelinePersistence; + + /// + /// Forces blocking pipeline creation (every draw lands in the frame that issues it) when + /// true, allows background compiles with skipped draws when false; null reads + /// OPTIMUM_VULKAN_SYNC_PIPELINES. Set before . GPU tests that read + /// pixels back after one frame get true from GpuTest. + /// + public bool? SynchronousPipelines { get; set; } + + internal static bool ResolveSynchronousPipelines(bool? configured, string? environment) => + configured ?? environment?.Trim() is "1" or "on" or "true"; + + /// The pipeline cache. Tests only. + internal GraphicsPipelineCache PipelinesForTests => _pipelines; internal static string? ResolveShaderCacheRoot(string? configured, string? environment) { @@ -435,12 +449,18 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa byte[]? pipelineSeed = null; if (cacheRoot != null) { - _pipelineCacheIdentity = PipelineCacheIdentity.Of(_context.Capabilities); - _pipelineCachePath = PipelineCacheFile.PathFor(cacheRoot, _pipelineCacheIdentity); - pipelineSeed = PipelineCacheFile.Load(_pipelineCachePath, _pipelineCacheIdentity); + _pipelinePersistence = PipelineCachePersistence.Open(cacheRoot, + PipelineCacheIdentity.Of(_context.Capabilities), out pipelineSeed); + _pipelinePersistence.Log = MirrorValidationMessage; } _pipelines = new GraphicsPipelineCache(_context, _context.Capabilities.ColorWriteTier, _context.Capabilities.DynamicColorBlend, pipelineSeed); + // Background compiles (docs/research/vulkan-caching.md, design item 4): a draw whose + // pipeline is not in the driver cache is skipped while a worker compiles it. + bool synchronousPipelines = ResolveSynchronousPipelines(SynchronousPipelines, + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_SYNC_PIPELINES")); + _pipelines.AsyncCompiles = !synchronousPipelines; + _pipelines.KeyLog = _pipelinePersistence?.KeyLog; _descriptors = new DescriptorCache(_context); // One layout for the shared frame block, named by every program's pipeline layout. _frameSetLayout = ShaderProgramResources.CreateFrameSetLayout(_context); @@ -462,7 +482,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa MirrorValidationMessage(cacheRoot == null ? "--- shader cache off" : "--- shader cache " + cacheRoot + "; pipeline cache " + - (pipelineSeed == null ? "cold" : _pipelines.SeedAccepted ? "warm (" + pipelineSeed.Length + " bytes)" : "rejected by the driver")); + (pipelineSeed == null ? "cold" : _pipelines.SeedAccepted ? "warm (" + pipelineSeed.Length + " bytes)" : "rejected by the driver") + + "; pipeline key log " + (_pipelinePersistence?.KeyLog.Count ?? 0) + " entries"); + MirrorValidationMessage("--- pipelines " + (_pipelines.AsyncCompiles + ? "compile in the background" + : synchronousPipelines ? "compile blocking (OPTIMUM_VULKAN_SYNC_PIPELINES)" : "compile blocking (no pipelineCreationCacheControl)")); CreateDefaultAttributeBuffer(); CreatePlaceholderTexture(); CreatePlaceholderUniformBuffer(); @@ -767,6 +791,11 @@ public void BeginFrame() } _lastFrameStart = frameStart; + // The safe point for pipelines the background worker finished, and for the + // opportunistic pipeline cache save (a timestamp check; the save runs on a worker). + _pipelines.PublishCompleted(); + _pipelinePersistence?.Tick(_pipelines, frameStart); + // The frame that ended: its ReadSelf copies wait on the Frame value it // recorded (taken before the ring reserves the next), and its transient // leases and bindings end. @@ -1138,8 +1167,15 @@ public int LinkProgram(IShaderProgram program) " type=" + member.Type + " count=" + member.ArrayLength); } } - _programs[programId] = new ShaderProgramResources(_context, programId, translated, _frameSetLayout); + var resources = new ShaderProgramResources(_context, programId, translated, _frameSetLayout); + _programs[programId] = resources; _programNames[programId] = program.PassName ?? ""; + // Pipelines an earlier launch used with this exact program start compiling now. + int prewarming = _pipelines.PrewarmFor(resources); + if (prewarming > 0 && RenderTrace.Enabled) + { + RenderTrace.Write("program " + programId + " prewarming " + prewarming + " pipelines"); + } return programId; } @@ -1167,6 +1203,8 @@ public void DeleteProgram(int programId) { if (!_programs.Remove(programId, out ShaderProgramResources? program)) return; _programNames.Remove(programId); + // No background compile may still be reading its modules or layout. + _pipelines.CancelProgram(program); _frames.DeferDeletion(program); } @@ -2169,17 +2207,27 @@ private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer comma ", merged " + vertexLayout.Attributes.Length); } - Pipeline pipeline = _pipelines.Get( - _state.BuildKey(layoutId, formatsId, attachmentCount, drawBuffers), - new GraphicsPipelineCache.PipelineRequest + if (!_pipelines.TryGet( + _state.BuildKey(layoutId, formatsId, attachmentCount, drawBuffers), + new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = vertexLayout, + Targets = formats, + Blend = blend, + PolygonMode = _state.PolygonMode, + Topology = _state.Topology, + }, + out Pipeline pipeline)) + { + // Compiling on the background worker: this draw is skipped (counted as + // draws_skipped on stats.pipelines) and the pipeline is published at a frame start. + if (RenderTrace.Enabled) { - Program = program, - VertexLayout = vertexLayout, - Targets = formats, - Blend = blend, - PolygonMode = _state.PolygonMode, - Topology = _state.Topology, - }); + RenderTrace.Write("draw skipped: pipeline for program " + _state.CurrentProgram + " still compiling"); + } + return false; + } Vk api = _context.Api; api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); @@ -3296,18 +3344,15 @@ public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr d // ------------------------------------------------------------------- teardown /// - /// Writes the driver's pipeline cache for the next launch. On shutdown only, after - /// the device is idle; a failed write costs the next launch its warm start, nothing more. + /// Writes the driver's pipeline cache and the pipeline-key log for the next launch, after + /// the device is idle; opportunistic saves during the session come from + /// . A failed write costs the next launch its + /// warm start, nothing more. /// private void SavePipelineCache() { - if (_pipelineCachePath == null || _pipelines == null) return; - byte[] blob = _pipelines.SerializeDriverCache(); - if (blob.Length == 0) return; - if (!PipelineCacheFile.Save(_pipelineCachePath, blob, _pipelineCacheIdentity)) - { - MirrorValidationMessage("--- pipeline cache not saved to " + _pipelineCachePath); - } + if (_pipelinePersistence == null || _pipelines == null) return; + _pipelinePersistence.SaveAtShutdown(_pipelines); } public void Dispose() @@ -3320,6 +3365,11 @@ public void Dispose() VulkanStats.WaitDeviceIdle(_context.Api, _context.Device); } + // Background compiles read program modules and layouts, and a background save + // reads the driver cache: both end before anything they use is destroyed. + _pipelines?.StopBackgroundCompiles(); + _pipelinePersistence?.WaitForPendingSave(); + foreach (ShaderProgramResources program in _programs.Values) program.Dispose(); _programs.Clear(); // After every pipeline layout that named it. diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 5133f559..00c67203 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -265,8 +265,8 @@ before it lack the field and still parse, but cannot be compared against a basel [Optimum] fps window= frames= mean= min= max= p99= stddev= ``` -`OPTIMUM_VULKAN_STATS`, Vulkan only, one sample per second of five lines. The first line is -unchanged from earlier builds; the other four carry stable `key=value` tokens: +`OPTIMUM_VULKAN_STATS`, Vulkan only, one sample per second of seven lines. The first line is +unchanged from earlier builds; the other six carry stable `key=value` tokens: ``` stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows @@ -275,6 +275,7 @@ stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_s stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= +stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draws_skipped= pending= cache_bytes= saves= ``` - The first line's "blocking uploads" counts every synchronous setup submission (uploads and @@ -319,6 +320,16 @@ stats.transients transient_mib= aliased_mib= heap_peak_mib= lease `leases` and `aliased_leases` (over the interval), `readself_copies` (draws that sampled a colour attachment they write and took a pooled copy) and `readself_pool` (copies the pool holds; a released copy is reused after the Frame timeline passed the frame that released it). +- `stats.pipelines` (caching follow-ups, `GraphicsPipelineCache`): per interval, `compiled_sync` + (pipelines compiled blocking on the render thread: every one with `OPTIMUM_VULKAN_SYNC_PIPELINES=1` or + without pipelineCreationCacheControl, otherwise only when the background queue is full), + `compiled_async` (compiled by the background worker for a draw that was skipped meanwhile), + `prewarmed` (built from the pipeline-key log after their program linked, before any draw asked), + `warm` (FAIL_ON_PIPELINE_COMPILE_REQUIRED creations the driver cache satisfied without compiling), + `draws_skipped` (draws skipped because their pipeline was still compiling) and `saves` (driver cache + files written: growth-triggered saves from a worker, every 8 MiB of growth sampled at most every + 10 s, plus the shutdown save); snapshots: `pending` (compiles queued or running) and `cache_bytes` + (serialised driver cache size at the last sample or save). - `stats.memory`, a snapshot at sample time (Phase 1B step 5): `blocks` (live device allocations the allocator holds), `dedicated` (of them, one-resource blocks), `rebar_used` and `rebar_cap` (ReBAR class bytes and its cap, min(192 MiB, heap budget x 0.25)), `rebar_misses` diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index aaa5ba25..86b7d48a 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -68,7 +68,8 @@ compared before and after for every branch. - **Diagnostics environment variables** (Vulkan): - `OPTIMUM_VULKAN_VALIDATION=1|`, `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best,mobile,gpu,gpu-only` - `OPTIMUM_VULKAN_STATS=`, `OPTIMUM_RENDER_TRACE` - - `OPTIMUM_VULKAN_SHADER_CACHE=|0` + - `OPTIMUM_VULKAN_SHADER_CACHE=|0`, `OPTIMUM_VULKAN_SYNC_PIPELINES=1` (blocking pipeline creation; the + capture scripts default to it) - `OPTIMUM_VULKAN_FRAMEGRAPH=0`, `OPTIMUM_VULKAN_ALIAS=1`, `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` - `OPTIMUM_VULKAN_NO_MEMORY_BUDGET=1`, `OPTIMUM_VULKAN_NO_REBAR=1`, `OPTIMUM_VULKAN_POISON`, `OPTIMUM_VULKAN_CHECKPOINTS` @@ -247,6 +248,13 @@ Windows run above. 7. **Caching follow-ups** (`docs/research/vulkan-caching.md`): `FAIL_ON_PIPELINE_COMPILE_REQUIRED` with background compiles, growth-triggered saves, pipeline-key log for pre-warming, optional `VK_KHR_pipeline_binary`; real-client warm-start check with the headless harness (needs game data). + - Landed on `wip/pipeline-cache-follow-ups` (design items 2, 4, 5): render-thread creation with + FAIL_ON, skipped draws while a bounded worker compiles against its own cache and merges under a + lock, publication at frame start; growth-triggered saves from a worker (8 MiB, sampled every 10 s) + next to the shutdown save; the versioned, LRU-capped `pipeline/.keys` log with prewarm when a + matching program links; `stats.pipelines`. GPU tests default to blocking creation (`GpuTest`). + - Still open: `VK_KHR_pipeline_binary` (deferred, research item 6) and the real-client warm-start + check with the headless harness. 8. **XeGTAO** (`docs/research/xegtao-integration.md`): compute pass kind in the frame graph, GLSL compute port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. diff --git a/scripts/dev/headless-capture.sh b/scripts/dev/headless-capture.sh index 8bb2ab4c..660fb45a 100755 --- a/scripts/dev/headless-capture.sh +++ b/scripts/dev/headless-capture.sh @@ -235,6 +235,10 @@ if [[ "$FIXED_DT" != "0" ]]; then export OPTIMUM_HEADLESS_FIXED_DT="$FIXED_DT"; # mid-frame, and every run ends in a crash report that nobody can tell from a real # one. kill-client.sh stays as the fallback in cleanup(). export OPTIMUM_HEADLESS_EXIT_WHEN_DONE=1 +# Captures compare exact frames: every draw must land in the frame that issues it, so +# pipelines compile blocking unless the caller asks otherwise (background compiles skip +# the draw until the worker is done). +export OPTIMUM_VULKAN_SYNC_PIPELINES="${OPTIMUM_VULKAN_SYNC_PIPELINES:-1}" if (( PARITY_DUMP == 1 )); then export OPTIMUM_PARITY_DUMP="$OUT_DIR" export OPTIMUM_PARITY_FRAME="$PARITY_FRAME" diff --git a/scripts/dev/parity-capture.sh b/scripts/dev/parity-capture.sh index 0dd07874..cc286b72 100755 --- a/scripts/dev/parity-capture.sh +++ b/scripts/dev/parity-capture.sh @@ -128,6 +128,10 @@ echo "config: Renderer=$RENDERER_ARG (was $SAVED_RENDERER)" # rewrite the config a second time. export OPTIMUM_PARITY_DUMP="$OUT_DIR" export OPTIMUM_PARITY_FRAME="$FRAME" +# Captures compare exact frames: every draw must land in the frame that issues it, so +# pipelines compile blocking unless the caller asks otherwise (background compiles skip +# the draw until the worker is done). +export OPTIMUM_VULKAN_SYNC_PIPELINES="${OPTIMUM_VULKAN_SYNC_PIPELINES:-1}" # LAUNCHED is set first: if the launch itself fails half-way, cleanup still closes # whatever started (kill-client.sh is a no-op when nothing runs). LAUNCHED=1 From 61bb90ca311cb6315feb9fbe2f8a7500d9b7d9c6 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:30:33 +0200 Subject: [PATCH 152/226] wip(native-shaders): entity, standard and instanced motion writers keep reactive behind the previous camera The local taaMotionVector helpers in entityanimated.fsh, standard.fsh and instanced.fsh returned vec4(0.0) when the previous clip w was <= 1e-6, dropping taaReactive / taaInstanceReactive along with the vector. docs/temporal-frame-contract.md section 3.2 requires a writer that bails out of its vector to still deliver b and zero only rg and a; the helpers now return vec4(0.0, 0.0, reactive, 0.0) on that branch, inside the TAAMOTION blocks. Verified: new GPU readback test APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue in TaaEntityMotionWriterTests, TaaStandardMotionWriterTests and TaaInstancedMotionWriterTests (previous clip w = -1, reactive 0.6: rg = 0, a = 0, b = 0.6) failed 3/3 with reactive = 0 before the shader change. Full Optimum.Render.Vulkan.Tests: 682 passed, 0 failed, 0 skipped (sync,best validation, implicit layers disabled). Optimum.Tests -c Release: 1176 passed, 34 skipped, 1 failed (Smoke_VintagestoryLibDll_Exists: no Cecil-patched VintagestoryLib.dll in this worktree, unrelated). Coverage tests pinning the helper text updated; docs/vulkan-native-shaders.md section 7 now records the fix as done. --- .../TaaEntityMotionWriterTests.cs | 77 +++++++++++++++++-- .../TaaInstancedMotionWriterTests.cs | 62 +++++++++++++-- .../TaaStandardMotionWriterTests.cs | 77 +++++++++++++++++-- .../taa-entity-motion-coverage-tests.cs | 2 +- .../taa-instanced-motion-coverage-tests.cs | 2 +- .../taa-standard-motion-coverage-tests.cs | 2 +- docs/vulkan-native-shaders.md | 10 ++- sources/shaders/entityanimated.fsh | 2 +- sources/shaders/instanced.fsh | 2 +- sources/shaders/standard.fsh | 2 +- 10 files changed, 207 insertions(+), 31 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs index 570d5201..241f86eb 100644 --- a/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs @@ -64,6 +64,21 @@ private static float[] Translation(float x, float y, float z) => new[] x, y, z, 1f, }; + /// + /// A previous camera the surface was behind: the previous view puts the quad at + /// view z = +1 and the previous projection's w row is -z, so the previous clip w + /// is -1 and the writer takes its behind-the-camera branch. + /// + private static readonly float[] BehindView = Translation(0f, 0f, 1f); + + private static readonly float[] BehindProjection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, -1, + 0, 0, 0, 0, + }; + // ------------------------------------------------------------------ tests /// @@ -245,19 +260,61 @@ public void APreviousWarpStateThatDiffersFromThisFrameProducesItsOwnMotion() } } + /// + /// A previous position behind the previous camera is not a motion vector, but + /// the contract (docs/temporal-frame-contract.md section 3.2) still wants the + /// reactive value: a writer that bails out of its vector delivers b and zeroes + /// only rg and a. The entity helper used to return vec4(0.0) there and drop the + /// taaReactive value with the vector. + /// + [SkippableFact] + public void APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float reactive = 0.6f; + Decoded centre = RenderEntityMotion(device!, + previousBone: Identity, + previousModelMatrix: Identity, + historyValid: 1, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f, + previousView: BehindView, + previousProjection: BehindProjection, + reactive: reactive); + + _output.WriteLine($"behind: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + // No vector, and the zero alpha that routes the pixel to the camera + // fallback rather than pretending the writer owns it. + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.WriterDepth, 0f, 0.01f); + // ... but the reactive value is still delivered. + Assert.InRange(centre.Reactive, reactive - 0.01f, reactive + 0.01f); + } + } + // ---------------------------------------------------------------- harness private readonly struct Decoded { - public Decoded(float motionX, float motionY, float writerDepth) + public Decoded(float motionX, float motionY, float reactive, float writerDepth) { MotionX = motionX; MotionY = motionY; + Reactive = reactive; WriterDepth = writerDepth; } public float MotionX { get; } public float MotionY { get; } + public float Reactive { get; } public float WriterDepth { get; } } @@ -274,7 +331,10 @@ private unsafe Decoded RenderEntityMotion( int historyValid, float cameraDeltaX, float cameraDeltaY, - float previousGlobalWarp) + float previousGlobalWarp, + float[]? previousView = null, + float[]? previousProjection = null, + float? reactive = null) { VulkanDevice seam = device; @@ -355,11 +415,11 @@ private unsafe Decoded RenderEntityMotion( SetSceneUniforms(seam, program); SetWarpUniforms(seam, program, previousGlobalWarp); - SetMatrix(seam, program, "prevProjectionMatrix", Identity); - SetMatrix(seam, program, "prevViewMatrix", Identity); + SetMatrix(seam, program, "prevProjectionMatrix", previousProjection ?? Identity); + SetMatrix(seam, program, "prevViewMatrix", previousView ?? Identity); SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); SetInt(seam, program, "taaHistoryValid", historyValid); - SetFloat(seam, program, "taaReactive", historyValid != 0 ? 0f : 1f); + SetFloat(seam, program, "taaReactive", reactive ?? (historyValid != 0 ? 0f : 1f)); SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); SetFloat2(seam, program, "taaRenderSize", Size, Size); SetFloat2(seam, program, "taaJitterPx", 0f, 0f); @@ -382,7 +442,8 @@ private unsafe Decoded RenderEntityMotion( return new Decoded( (decoded[offset] / 255f * 2f - 1f) * DecodeScale, (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, - decoded[offset + 2] / 255f); + decoded[offset + 2] / 255f, + decoded[offset + 3] / 255f); } private static unsafe void WriteBone(VulkanDevice seam, int ubo, float[] matrix) @@ -415,8 +476,8 @@ void main(void) outColor = vec4( clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), - clamp(m.a, 0.0, 1.0), - 1.0); + clamp(m.b, 0.0, 1.0), + clamp(m.a, 0.0, 1.0)); } "; int decode = LinkFromCorpus(seam, new List diff --git a/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs index 8a45804b..b36f54e0 100644 --- a/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs @@ -62,6 +62,21 @@ private static float[] Translation(float x, float y, float z) => new[] x, y, z, 1f, }; + /// + /// A previous camera the block was behind: the previous view puts the quad at + /// view z = +1 and the previous projection's w row is -z, so the previous clip w + /// is -1 and the writer takes its behind-the-camera branch. + /// + private static readonly float[] BehindView = Translation(0f, 0f, 1f); + + private static readonly float[] BehindProjection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, -1, + 0, 0, 0, 0, + }; + // ------------------------------------------------------------------ tests /// @@ -206,20 +221,56 @@ public void WithoutUsableHistoryTheVectorIsCameraMotionOnlyAndReactive() } } + /// + /// A previous position behind the previous camera is not a motion vector, but + /// the contract (docs/temporal-frame-contract.md section 3.2) still wants the + /// reactive value: a writer that bails out of its vector delivers b and zeroes + /// only rg and a. The instanced helper used to return vec4(0.0) there and drop + /// the per-instance reactive value with the vector. + /// + [SkippableFact] + public void APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float reactive = 0.6f; + var instance = new Instance(Identity, Identity, historyValid: true, reactive: reactive); + Decoded centre = RenderInstancedMotion(device!, new[] { instance }, 0f, 0f, + previousView: BehindView, previousProjection: BehindProjection)[Size / 2]; + + _output.WriteLine($"behind: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + // No vector, and the zero alpha that routes the pixel to the camera + // fallback rather than pretending the writer owns it. + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.WriterDepth, 0f, 0.01f); + // ... but the reactive value is still delivered. + Assert.InRange(centre.Reactive, reactive - 0.01f, reactive + 0.01f); + } + } + // ---------------------------------------------------------------- harness private readonly struct Instance { - public Instance(float[] transform, float[] previousTransform, bool historyValid) + public Instance(float[] transform, float[] previousTransform, bool historyValid, float? reactive = null) { Transform = transform; PreviousTransform = previousTransform; HistoryValid = historyValid; + Reactive = reactive ?? (historyValid ? 0f : 1f); } public float[] Transform { get; } public float[] PreviousTransform { get; } public bool HistoryValid { get; } + /// The metadata's reactive channel; by default what the C# side stamps for the history state. + public float Reactive { get; } } private readonly struct Decoded @@ -248,7 +299,8 @@ public Decoded(float motionX, float motionY, float reactive, float writerDepth) /// both camera matrices are the identity and no jitter is applied. /// private unsafe Decoded[] RenderInstancedMotion( - VulkanDevice device, Instance[] instances, float cameraDeltaX, float cameraDeltaY) + VulkanDevice device, Instance[] instances, float cameraDeltaX, float cameraDeltaY, + float[]? previousView = null, float[]? previousProjection = null) { VulkanDevice seam = device; @@ -309,8 +361,8 @@ private unsafe Decoded[] RenderInstancedMotion( SetMatrix(seam, program, "toShadowMapSpaceMatrixNear", Identity); SetSceneUniforms(seam, program); - SetMatrix(seam, program, "prevProjectionMatrix", Identity); - SetMatrix(seam, program, "prevModelViewMatrix", Identity); + SetMatrix(seam, program, "prevProjectionMatrix", previousProjection ?? Identity); + SetMatrix(seam, program, "prevModelViewMatrix", previousView ?? Identity); SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); SetFloat2(seam, program, "taaRenderSize", Size, Size); SetFloat2(seam, program, "taaJitterPx", 0f, 0f); @@ -459,7 +511,7 @@ private static MeshData BuildInstancedQuad(Instance[] instances) Array.Copy(instances[i].Transform, 0, part.Values, j + OptimumInstanceMotion.TransformOffset, 16); Array.Copy(instances[i].PreviousTransform, 0, part.Values, j + OptimumInstanceMotion.PrevTransformOffset, 16); part.Values[j + OptimumInstanceMotion.MetaOffset] = instances[i].HistoryValid ? 1f : 0f; - part.Values[j + OptimumInstanceMotion.MetaOffset + 1] = instances[i].HistoryValid ? 0f : 1f; + part.Values[j + OptimumInstanceMotion.MetaOffset + 1] = instances[i].Reactive; } part.Count = instances.Length * OptimumInstanceMotion.InstanceFloats; mesh.CustomFloats = part; diff --git a/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs index afacbd41..d5938709 100644 --- a/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs @@ -63,6 +63,21 @@ private static float[] Translation(float x, float y, float z) => new[] x, y, z, 1f, }; + /// + /// A previous camera the surface was behind: the previous view puts the quad at + /// view z = +1 and the previous projection's w row is -z, so the previous clip w + /// is -1 and the writer takes its behind-the-camera branch. + /// + private static readonly float[] BehindView = Translation(0f, 0f, 1f); + + private static readonly float[] BehindProjection = + { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, -1, + 0, 0, 0, 0, + }; + // ------------------------------------------------------------------ tests /// @@ -228,19 +243,61 @@ public void ThePreviousWarpStateIsReplayedThroughTheCallersOwnBranch() } } + /// + /// A previous position behind the previous camera is not a motion vector, but + /// the contract (docs/temporal-frame-contract.md section 3.2) still wants the + /// reactive value: a writer that bails out of its vector delivers b and zeroes + /// only rg and a. The standard helper used to return vec4(0.0) there and drop the + /// taaReactive value with the vector. + /// + [SkippableFact] + public void APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + using (device) + { + const float reactive = 0.6f; + Decoded centre = RenderStandardMotion(device!, + previousModelMatrix: Identity, + historyValid: 1, + dontWarpVertices: WarpFull, + cameraDeltaX: 0f, + cameraDeltaY: 0f, + previousGlobalWarp: 0f, + previousView: BehindView, + previousProjection: BehindProjection, + reactive: reactive); + + _output.WriteLine($"behind: mv = ({centre.MotionX}, {centre.MotionY}), " + + $"reactive = {centre.Reactive}, writerDepth = {centre.WriterDepth}"); + + // No vector, and the zero alpha that routes the pixel to the camera + // fallback rather than pretending the writer owns it. + Assert.InRange(centre.MotionX, -0.3f, 0.3f); + Assert.InRange(centre.MotionY, -0.3f, 0.3f); + Assert.InRange(centre.WriterDepth, 0f, 0.01f); + // ... but the reactive value is still delivered. + Assert.InRange(centre.Reactive, reactive - 0.01f, reactive + 0.01f); + } + } + // ---------------------------------------------------------------- harness private readonly struct Decoded { - public Decoded(float motionX, float motionY, float writerDepth) + public Decoded(float motionX, float motionY, float reactive, float writerDepth) { MotionX = motionX; MotionY = motionY; + Reactive = reactive; WriterDepth = writerDepth; } public float MotionX { get; } public float MotionY { get; } + public float Reactive { get; } public float WriterDepth { get; } } @@ -258,7 +315,10 @@ private unsafe Decoded RenderStandardMotion( int dontWarpVertices, float cameraDeltaX, float cameraDeltaY, - float previousGlobalWarp) + float previousGlobalWarp, + float[]? previousView = null, + float[]? previousProjection = null, + float? reactive = null) { VulkanDevice seam = device; @@ -323,11 +383,11 @@ private unsafe Decoded RenderStandardMotion( SetSceneUniforms(seam, program, dontWarpVertices); SetWarpUniforms(seam, program, previousGlobalWarp); - SetMatrix(seam, program, "prevProjectionMatrix", Identity); - SetMatrix(seam, program, "prevViewMatrix", Identity); + SetMatrix(seam, program, "prevProjectionMatrix", previousProjection ?? Identity); + SetMatrix(seam, program, "prevViewMatrix", previousView ?? Identity); SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); SetInt(seam, program, "taaHistoryValid", historyValid); - SetFloat(seam, program, "taaReactive", historyValid != 0 ? 0f : 1f); + SetFloat(seam, program, "taaReactive", reactive ?? (historyValid != 0 ? 0f : 1f)); SetFloat3(seam, program, "cameraPosDelta", cameraDeltaX, cameraDeltaY, 0f); SetFloat2(seam, program, "taaRenderSize", Size, Size); SetFloat2(seam, program, "taaJitterPx", 0f, 0f); @@ -350,7 +410,8 @@ private unsafe Decoded RenderStandardMotion( return new Decoded( (decoded[offset] / 255f * 2f - 1f) * DecodeScale, (decoded[offset + 1] / 255f * 2f - 1f) * DecodeScale, - decoded[offset + 2] / 255f); + decoded[offset + 2] / 255f, + decoded[offset + 3] / 255f); } /// @@ -373,8 +434,8 @@ void main(void) outColor = vec4( clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), - clamp(m.a, 0.0, 1.0), - 1.0); + clamp(m.b, 0.0, 1.0), + clamp(m.a, 0.0, 1.0)); } "; int decode = LinkFromCorpus(seam, new List diff --git a/Optimum.Tests/taa-entity-motion-coverage-tests.cs b/Optimum.Tests/taa-entity-motion-coverage-tests.cs index 64f3a7c2..b9ef07f5 100644 --- a/Optimum.Tests/taa-entity-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-entity-motion-coverage-tests.cs @@ -81,7 +81,7 @@ public void TheEntityFragmentShaderWritesTheMotionAttachmentWithItsOwnDepth() Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); Assert.Contains("return vec4(prevPixel - currentPixel, reactive, writerDepth);", fragment); - Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0);", fragment); // The first-person hand and item programs write gl_FragDepth, so the // writer depth has to carry the same offset or the resolve's depth-match diff --git a/Optimum.Tests/taa-instanced-motion-coverage-tests.cs b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs index 438d32c7..31b8a2b0 100644 --- a/Optimum.Tests/taa-instanced-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs @@ -69,7 +69,7 @@ public void TheInstancedFragmentShaderWritesTheMotionAttachment() Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); Assert.Contains("return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z);", fragment); - Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0);", fragment); Assert.Contains("outMotion = taaMotionVector(taaInstanceReactive);", fragment); Assert.True(DeclaresUniform(fragment, "taaRenderSize")); diff --git a/Optimum.Tests/taa-standard-motion-coverage-tests.cs b/Optimum.Tests/taa-standard-motion-coverage-tests.cs index df429389..759bbcaf 100644 --- a/Optimum.Tests/taa-standard-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-standard-motion-coverage-tests.cs @@ -75,7 +75,7 @@ public void TheStandardFragmentShaderWritesTheMotionAttachmentWithItsOwnDepth() Assert.Contains("vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize;", fragment); Assert.Contains("vec2 currentPixel = gl_FragCoord.xy - taaJitterPx;", fragment); Assert.Contains("return vec4(prevPixel - currentPixel, reactive, writerDepth);", fragment); - Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0);", fragment); + Assert.Contains("if (taaPrevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0);", fragment); // The first-person item program writes gl_FragDepth, so the writer depth // has to carry the same offset or the resolve's depth-match test rejects diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index a28b88ff..b111930c 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -174,10 +174,12 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg - **Behind the previous camera** (`prevClip.w <= 1e-6`), `optimumWriteMotion` returns `vec4(0, 0, reactive, 0)`. The frozen contract requires this: "a writer that bails out of its vector must still deliver b and zero only rg and a" (`docs/temporal-frame-contract.md` section 3.2). - - Today `chunkliquidmotion`, `particlescube` and `taa-skymotion` do so. - - `entityanimated`, `standard` and `instanced` drop `reactive` on that branch. That contradicts the contract - and is fixed in the GLSL 330 writers first, with GPU tests, so native-vs-330 differential tests compare - like with like. + - The GLSL 330 writers `chunkliquidmotion`, `particlescube`, `taa-skymotion`, `entityanimated`, `standard` and + `instanced` do so. + - `entityanimated`, `standard` and `instanced` used to drop `reactive` on that branch (their local helper + returned `vec4(0.0)`). That contradicted the contract and was fixed in the GLSL 330 writers first, pinned by + `APreviousPositionBehindThePreviousCameraStillCarriesTheReactiveValue` in each writer's GPU test file, so + native-vs-330 differential tests compare like with like. - `chunkopaque`, `chunktopsoil` and `decals` pass a literal 0, so nothing observable changes for them. - **One exception:** `particlescube` keeps its writer depth on that branch (`a = gl_FragCoord.z`) and its reactive of 1. It calls `optimumMotionVector` directly and states why. diff --git a/sources/shaders/entityanimated.fsh b/sources/shaders/entityanimated.fsh index c2229778..e073bda2 100644 --- a/sources/shaders/entityanimated.fsh +++ b/sources/shaders/entityanimated.fsh @@ -54,7 +54,7 @@ layout(location = TAAMOTIONLOCATION) out vec4 outMotion; vec4 taaMotionVector(float reactive, float writerDepth) { - if (taaPrevClip.w <= 1e-6) return vec4(0.0); + if (taaPrevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0); vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; return vec4(prevPixel - currentPixel, reactive, writerDepth); diff --git a/sources/shaders/instanced.fsh b/sources/shaders/instanced.fsh index bf6c3efa..132761b9 100644 --- a/sources/shaders/instanced.fsh +++ b/sources/shaders/instanced.fsh @@ -39,7 +39,7 @@ layout(location = TAAMOTIONLOCATION) out vec4 outMotion; vec4 taaMotionVector(float reactive) { - if (taaPrevClip.w <= 1e-6) return vec4(0.0); + if (taaPrevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0); vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; return vec4(prevPixel - currentPixel, reactive, gl_FragCoord.z); diff --git a/sources/shaders/standard.fsh b/sources/shaders/standard.fsh index a12f2e94..ab949f69 100644 --- a/sources/shaders/standard.fsh +++ b/sources/shaders/standard.fsh @@ -53,7 +53,7 @@ layout(location = TAAMOTIONLOCATION) out vec4 outMotion; vec4 taaMotionVector(float reactive, float writerDepth) { - if (taaPrevClip.w <= 1e-6) return vec4(0.0); + if (taaPrevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0); vec2 prevPixel = (taaPrevClip.xy / taaPrevClip.w * 0.5 + 0.5) * taaRenderSize; vec2 currentPixel = gl_FragCoord.xy - taaJitterPx; return vec4(prevPixel - currentPixel, reactive, writerDepth); From 284acc550f1eb1fce9ee7413d05b1d06a8c90be3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:37:31 +0200 Subject: [PATCH 153/226] docs(research): ambient occlusion - every named source researched and combined into one design GTAO with a cosine-weighted 32-sector visibility bitmask (Therrien 2023, sector boundaries from the GTAO slice CDF) on XeGTAO's prefiltered depth, sample distribution, Hilbert-R2 noise advanced per frame and one-pass edge-aware denoise; a thin-geometry class channel for foliage; no AO history beyond the TAA resolve; physically correct output with an albedo hook for multi-bounce once PBR materials exist. Includes the comparison across XeGTAO, GTAO 2016, visibility bitmasks, Bevy, MXAO, Alchemy AO, SAO, openmw-ssao, the Unity GTAO port and local engine references, the verdicts on the forwarded proposals, the measurement plan and the decisions on its questions. --- docs/research/ambient-occlusion.md | 728 ++++++++++++++++++++++++++++ docs/research/xegtao-integration.md | 3 + docs/vulkan-branch-progress.md | 2 +- 3 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 docs/research/ambient-occlusion.md diff --git a/docs/research/ambient-occlusion.md b/docs/research/ambient-occlusion.md new file mode 100644 index 00000000..6b770321 --- /dev/null +++ b/docs/research/ambient-occlusion.md @@ -0,0 +1,728 @@ +# Ambient occlusion for the Vulkan path: sources, comparison, combined design + +Deep research for the AO step of the Vulkan branch (`feat/vulkan-taa`), collected 2026-09-15. Extends +`docs/research/xegtao-integration.md` (read in full; referred to as "the note"). Every source below was read +from the actual paper or code (downloaded locally for the reading, not committed); the three Shadertoys linked +from bevy#19713 could not be read (Cloudflare challenge on every fetch route), so anything about their internals +is marked [Uncertain] and taken from the issue text, the Bluesky thread and the authors' descriptions. + +Conventions: [Inference] = my conclusion, not a source claim. [Uncertain] = could not verify against a primary +source. Repository facts are cited as `file:line` at the current branch. + +**Owner decisions taken as given** (`docs/research/xegtao-integration.md` section 0 and `docs/vulkan-branch-progress.md` +section 4): the chosen AO is the default on Vulkan whenever TAA is active; vanilla SSAO otherwise and always on +OpenGL; AO is composed into the scene before the TAA resolve and never onto the glow attachment; jitter is +`P[8] -= 2*jx/W` (`docs/temporal-frame-contract.md` section 2); the TAA resolve invariants stay (3x3 nearest-depth disocclusion with motion from the nearest-depth tap, luminance anti-flicker 0.3x..1.2x; pinned by `TaaResolveTests` and `Optimum.Tests/taa-antiflicker-coverage-tests.cs`). + +**Decisions taken on this document's questions (2026-09-15; section E records them):** +physically correct AO with no vanilla floor and no contrast boost; the hand-view draws are patched to write a class +value into `gNormal.w`; plants, grass and cross-quad blocks are flagged in the same channel; no multi-bounce in the +first version but an explicit albedo hook; the handheld default (render-res 2x2 vs half-res 3x3) is decided by the +section D numbers; the AO working term, edges and mip 0 become opt-in parity-dump and headless outputs. + +--- + +## 0. What this renderer gives the AO pass (constraints, verified) + +- **G-buffer.** Primary colour 2 = `gNormal`, colour 3 = `gPosition`, both `RGBA16F`, sampler LINEAR, wrap + CLAMP_TO_BORDER with a white border, present only when `SSAOQuality > 0` + (`build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs:2331-2350`; Vulkan mirror + `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs:76-90`). Depth is `D32_SFLOAT`, 0 = near, + not reversed (`docs/temporal-frame-contract.md` section 3.1). +- **What is in them.** `gnormal = modelViewMatrix * vec4(normal, 0)` and `gnormal.w = isLeaves ? 1 : 0` + ("Cheap hax to make SSAO on leaves less bad looking"), `isLeaves = (renderFlags & WindModeBitMask) > 0` + (`.vanilla/.../shaders/chunkopaque.vsh:72,102-103`); `outGPosition = vec4(camPos.xyz, fogAmount*2 + glowLevel + murkiness)` + (`chunkopaque.fsh:96-97`). So: **view-space normal in GL convention (z toward the viewer), leaf flag in w; + view-space position in xyz, an attenuation term in w.** Entities write `outGNormal = vec4(gnormal.xyz, 0)` + (`entityanimated.fsh:113`), sky writes zeros (`nightsky.fsh:43-44`), cube particles put alpha in w + (`particlescube.fsh:47`). +- **Foliage is alpha-tested** (`if (aTest < alphaTest || rgba.a < 0.005) discard;`, `chunkopaque.fsh:81`), so + leaf and grass holes are real holes in the depth buffer. [Inference] This is what makes a thickness-aware + integration pay off here: light legitimately passes through the holes, and only the solid texels occlude. +- **Vanilla SSAO** (`.vanilla/.../shaders/ssao.fsh`, override `sources/shaders/ssao.fsh`): half resolution + (`frameBuffers[13]`, `num3 = 0.5f`, `ClientPlatformWindows.cs:2426-2432`), 20 or 24 hemisphere point samples of + radius 0.9 from a 64-entry kernel, a screen-locked Bayer-128 dither rotated by golden-angle (the override advances + it by `frac(temporalFrameIndex * (PHI-1))` when `TAAMOTION == 1`, `sources/shaders/ssao.fsh:104-115`), range check + `depthDiff in (0, 0.2)` (leaves: `[0.02, 0.2)` plus a normal-difference test), a per-pixel normal push + `fragPos += normal * clamp(-z/150 - 0.05, 0, 10)` for distant flicker, `distanceFade = clamp(1.2 - z/250, 0, 1)`, + an AO floor `max(occ, 0.5 or 0.7)` and a `1 - (1-occ)*1.4` boost off leaves; then an 11-tap depth-weighted + separable blur, 1 or 3 iterations (`bilateralblur.fsh`, `ClientPlatformWindows.cs:3482-3496`). +- **Composition today.** `ApplyOptimumSceneSsao` multiplies the blurred half-res AO into Primary colour 0 with + `EnumBlendMode.Multiply`, `1 - AO` in alpha, before the resolve (`ClientPlatformWindows.cs:3613-3634`, + `sources/shaders/scene-ssao.fsh`); `final.fsh` skips its own application when `optimumSsaoInScene == 1` + (`sources/shaders/final.fsh:105-116`). `SSAOLEVEL > 1` takes the min of two vertically adjacent texels + (`scene-ssao.fsh:10-12`), a half-res upsample fudge. +- **TAA.** Halton(2,3), `phaseCount = max(1, ceil(8 * upscale^2))` (8 at native, 32 at render scale 0.5), + `FrameIndex` is the clock (`docs/temporal-frame-contract.md` section 2; `OptimumTemporalFrame.cs:372-373`). The + resolve clips history to the 3x3 YCoCg mean +/- 1.25 sigma (`sources/shaders/taa-resolve.fsh:134-135`), blends with + `alpha = mix(0.12, 0.03, w*w)` on the luminance-difference weight, `max(alpha, reactive)` + (`taa-resolve.fsh:288-290`), and rejects on a 3x3 nearest-depth disocclusion test (contract section 4). +- **Compute.** The Vulkan device records no compute dispatch today (no `CmdDispatch` / compute pipeline in + `Optimum.Render.Vulkan`; only the graph's stage masks already name `ComputeShaderBit`, + `Optimum.Render.Vulkan/Graph/ResourceUsage.cs:80,168`). A compute pass kind is the first deliverable of the AO + step (progress doc section 5 item 8) or the first version is fragment-shader based (section C.13). +- **Correction to the note.** The note says "GLSL has no workgroup-shared memory" (section 3, porting traps). + GLSL compute shaders do have `shared` variables and `barrier()` (GLSL 4.60 spec, "Shared Variables"; + Vulkan GLSL compiles them to the SPIR-V Workgroup storage class). Bevy's `preprocess_depth.wesl` uses exactly that + (`var previous_mip_depth: array, 8>`). The prefilter can stay one dispatch. [Inference] + The note's split into four dispatches is still a valid fallback for a fragment-only first version. + +--- + +## A. The sources, one by one + +### A.1 XeGTAO (Intel, MIT, archived 2024-04-22) + +Files read: `XeGTAO.hlsli`, `XeGTAO.h`, `vaGTAO.hlsl`, README (https://github.com/GameTechDev/XeGTAO). + +**Algorithm.** Three compute passes (README "Algorithm overview"): +1. *Prefilter* (`XeGTAO_PrefilterDepths16x16`): 8x8 threads, 2x2 texels each, mips 0-4 of view-space depth with + `XeGTAO_DepthMIPFilter`: `weight_i = saturate((maxDepth - depth_i) * falloffMul + falloffAdd)`, radius scaled by + `depthRangeScaleFactor = 0.75` ("found empirically :)"), then the weighted mean (`XeGTAO.hlsli:579-604`). +2. *Main* (`XeGTAO_MainPass`, `XeGTAO.hlsli:~240-575`): per slice `phi = (slice + noiseSlice)/sliceCount * PI`, + per step `stepNoise = frac(noiseSample + (slice + step*stepsPerSlice) * 0.618...)` (R1 sequence), + `s = ((step + stepNoise)/stepsPerSlice)^SampleDistributionPower + minS` with `minS = 1.3 / screenspaceRadius` + ("avoid sampling center pixel"), `mipLevel = clamp(log2(len(sampleOffset)) - DepthMIPSamplingOffset, 0, 5)`, + sample offset snapped to pixel centres, two depth fetches per step (both sides of the slice), horizon cosines + initialised to `cos(n +/- PI/2)` instead of -1 ("lowHorizonCos"), sample weight + `saturate(sampleDist * falloffMul + falloffAdd)` with `falloffFrom = R*(1-0.615)`, then + `shc = lerp(lowHorizonCos, shc, weight)` and `horizonCos = max(horizonCos, shc)`; the arc integral is the + GTAO paper's closed form `iarc = (cosNorm + 2*h*sin(n) - cos(2h - n)) / 4` per side, times + `projectedNormalVecLength` after `lerp(projectedNormalVecLength, 1, 0.05)` ("I can't figure out the slight + overdarkening on high slopes, so I'm adding this fudge", `XeGTAO.hlsli:531-532`); `visibility /= sliceCount`, + `pow(visibility, FinalValuePower)`, `max(0.03, ...)`; a small-screen-radius fade + `visibility += saturate((10 - screenspaceRadius)/100)*0.5`; depth bias `viewspaceZ *= 0.99999` (fp32) or + `0.99920` (fp16). Edges: `saturate(1.25 - |dz|/(z*0.011))` after a slope adjustment, packed 2 bits each + (`XeGTAO_CalculateEdges`, `XeGTAO_PackEdges`). +3. *Denoise* (`XeGTAO_Denoise`, `XeGTAO.hlsli:~700-820`): 3x3, two horizontal pixels per thread, centre weight + `DenoiseBlurBeta` (1.2) on the final pass and `beta/5` otherwise, cardinal weights = the 2-bit edges made symmetric + (`edgesC *= (edgesL.y, edgesR.x, edgesT.w, edgesB.z)`, "Works real nice with TAA"), diagonals `0.85*0.5 *` + products of adjacent edges, and a **leak**: when 3-4 edges are set, `edginess = saturate(4 - 2.5 - sum)/1.5 * 0.5` + is added to all edges ("reduces both spatial and temporal aliasing"). The README says "5x5 depth-aware" but the + kernel is 3x3 (issue #6, unanswered). Working AO is stored as `visibility / 1.5` (`XE_GTAO_OCCLUSION_TERM_SCALE`, + "raw, pre-denoised occlusion term can overshoot 1 but will later average out to 1", `XeGTAO.h:114`). + +**Thin occluders.** The paper's thickness heuristic (section A.2 eq. 9) is present in two `#if 0` branches; the +active `#else` is "a version where thicknessHeuristic is completely disabled" (`XeGTAO.hlsli:496-506`); +`ThinOccluderCompensation` defaults to 0 and only scales `sampleDelta.z` in the falloff distance ("biases the +near-field bounding falloff along the view vector", README). README: "Rather than implementing the conservative +thickness heuristic from the original paper, this version increases slices while undersampling horizon searches"; +auto-tune found only a small gain, so it is off. **XeGTAO has no visibility bitmask and no multi-bounce**: zero +hits for `bitmask|countbits|multibounce|albedo` in `XeGTAO.hlsli` and `vaGTAO.hlsl`; the only `Albedo` in +`XeGTAO.h:89` belongs to the reference ray-traced AO tool. + +**Noise.** `index = HilbertLUT[pix % 64] + 288*(NoiseIndex % 64)`; `noise = frac(0.5 + index * R2)` with +`R2 = (0.75487766624669276005, 0.5698402909980532659114)` (`vaGTAO.hlsl:77-85`, "why 288? tried out a few and that's +the best so far"). `NoiseIndex = frameCounter % 64` when denoise is on, else 0 (`XeGTAO.h:196`). README history: +a 2-channel 64x64 tileable blue noise "worked well for spatial-only noise" but "adding temporal offsets/rotations +caused overlaps which would often show as temporal artifacts"; a 3D noise "worked well with TAA but was fairly big +in size and did not work well when using spatial-only filtering"; hence Hilbert+R2. On TAA: "we must keep temporal +variance low enough to avoid having TAA mischaracterizing this noise as features, which limits the amount of +temporal supersampling that we can leverage." + +**Presets** (`vaGTAO.hlsl:101-129`): Low 1 slice x 2 steps, Medium 2x2, High 3x3, Ultra 9x3 (steps are per side, +two fetches each). **Cost** (README): High 0.56 ms at 1080p on RTX 2060, 2.39 ms at 1080p on i7-1195G7 integrated +graphics, 1.4 ms at 4K on RTX 3070; Medium ~2/3 of High, Low ~2/3 of Medium; bent normals +25%; Hilbert LUT saves ~7%. + +**Defaults** (`XeGTAO.h:107-113`): Radius 0.5, RadiusMultiplier 1.457, FalloffRange 0.615, SampleDistributionPower +2.0, ThinOccluderCompensation 0, FinalValuePower 2.2, DepthMIPSamplingOffset 3.30 (README says 3.15). + +**Strengths for this game.** The whole scaffold is production-hardened, MIT, and every port (Bevy, Skyrim SSGI, Luma) +keeps it: mips, noise, edges, denoise, minS, pixel snapping, fp32/fp16 bias. **Weaknesses.** Horizon integration treats +every leaf and fence as an infinitely thick wall (README "Known limitations": "depth buffer represents viewspace +heightmap, not actual geometry, causing artifacts with thin features"); archived; the D3D-only constants helper. +**Licence:** MIT, code usable with notice. + +### A.2 GTAO, Jimenez, Wu, Pesce, Jarabo 2016 (Activision technical report) + +Read from `PracticalRealtimeStrategiesTRfinal.pdf` (https://www.activision.com/cdn/research/PracticalRealtimeStrategiesTRfinal.pdf). + +- **Integral.** `A(x) = 1/pi * int_0^pi int_{theta1}^{theta2} cos(theta - gamma)+ |sin theta| dtheta dphi` (eq. 5), + horizons around the **view vector** (following Timonen), `gamma` = angle between the projected normal and the + view vector; the inner integral is analytic: + `a = 1/4 (-cos(2 theta1 - gamma) + cos(gamma) + 2 theta1 sin(gamma)) + 1/4 (-cos(2 theta2 - gamma) + cos(gamma) + 2 theta2 sin(gamma))` (eq. 7), + multiplied by `||n_x||` (the projected-normal length) per slice (eq. 8). Horizon search eq. 6: + `theta1 = arccos(max_s +)`. "2 cos and 1 sin, plus three acos" per slice; "memory bounded". +- **Attenuation.** "we do not consider any attenuation function ... In order to minimize artifacts we employ a + conservative attenuation strategy ... linear blending from 1 to 0 from a given, large enough distance, to the + maximum search radius" (section 4.1). This is XeGTAO's `FalloffRange`. +- **Thickness heuristic (eq. 9).** "thin features tend to cast too much occlusion ... assumption that the thickness of + an object is similar to their screen space size": during the search, `theta = max(theta_s, theta)` if + `cos(theta_s) >= cos(theta_{s-1})`, else `blend(theta_{s-1}, theta_s)` with an exponential moving average. "does + not bias the occlusion results for simple corners (e.g. walls)". Figure 4 shows leaves/branches. +- **Sampling and filtering.** Half resolution, "one direction per pixel", 4x4 spatial neighbourhood reconstructed with + a bilateral filter (uniform weights per the Bevy header quoting the paper), "6 different rotations" reprojected + with an exponential accumulation buffer: 4x4x6 = 96 effective directions. **0.5 ms on PS4 at 1080p.** +- **Multi-bounce (section 5, eq. 10).** `G(A, rho) = a A^3 - b A^2 + c A` with + `a = 2.0404 rho - 0.3324`, `b = 4.7951 rho - 0.6417`, `c = 2.7552 rho + 0.6903`, fitted on seven albedos against + three-bounce references. This is where the "multi-bounce approximation" comes from; XeGTAO does not carry it, + Unity's port and UE4 do. +- **GTSO** (section 6): specular occlusion from the bent-normal cone and the GGX lobe; needs roughness. Not applicable + (no PBR specular in this game). + +**Licence:** paper only; the formulas are published mathematics. + +### A.3 Screen Space Indirect Lighting with Visibility Bitmask, Therrien, Levesque, Gilet 2023 + +Read from arXiv 2301.11376 (Vis Comput 2022) and the code post https://cdrinmatane.github.io/posts/ssaovb-code/. + +- **Method.** "replaces the two horizon angles by a bit field representing the binary state (occluded / un-occluded) + of N sectors uniformly distributed around the hemisphere slice." Per sample: front angle `theta_f` at the sample, + back angle `theta_b` at `sample - V * t` (constant thickness `t` along the view vector), both converted "from cosine + space to angular space", shifted to the normal-centred hemisphere, and the bits between them set (`UpdateSectors`: + `start = minHorizon * N`, `count = ceil((max - min) * N)`); AO = `1 - countbits(mask)/N`. Paper: "we used the + round criterion which requires the sector to be half covered" (the post shows ceil/round/floor as choices; Bevy uses + ceil, iMMERSE MXAO ceil). +- **Falloff.** "the UpdateHorizon() function from GTAO is not needed anymore, because we don't need to apply any + falloff! The constant thickness and the bitmask is enough" (post). Paper: fixed world-space thickness + "can cause an over-attenuation of occlusion for objects far away from the camera, so we give the option to + increase t linearly over the distance"; "Finding an efficient heuristic to estimate an accurate thickness for each + sample ... remains a difficult problem we leave for future work". Figure 6: fixed thickness "causes light leaks at + depth discontinuities" where GTAO with falloff does not. +- **Cosine weighting.** "Note that we do not take the cosine weight into account in this case" (post). +- **Sectors.** 32 ("just crossed the threshold where the artifacts became almost invisible", fits a uint); 128 sectors + cost 5-10% more. +- **Cost** (Table 1, RTX 2080, 1080p, one jittered slice per pixel, 32 sectors): radius 0.8 / 8 samples 0.49 ms GTAO + vs 0.51 ms bitmask; radius 1 / 16 samples 0.95 vs 0.97; "about 15 GPU instructions per sample"; denoise a constant + 0.3 ms. Unreal marketplace VBAO (ARK.KRA) reports 0.45-1.60 ms at 1080p on RTX 2060 for its four quality tiers and + "relies entirely on the engine built in TAA" for denoising (vendor claim, forum listing). +- **GI.** The same bitmask gates radiance fetches from the lit buffer per unoccluded sector; "we also sample the HDR + light buffer and the screen space normal buffer for every sample taken". + +**Strengths.** Exactly the failure mode of this game's geometry: Figure 11 (light behind bars) is a fence. Cheap. +**Weaknesses.** No cosine term, constant thickness is a global guess, leaks at discontinuities. +**Licence:** paper; the post states none, the Unity HDRP code it patches is Unity's. Ideas only; implement from the +paper's Algorithm 1 (which is what Bevy did, under MIT). + +### A.4 Bevy `bevy_pbr/src/ssao` (MIT OR Apache-2.0) + +Files read: `ssao.wesl`, `preprocess_depth.wesl`, `spatial_denoise.wesl`, `mod.rs` (main branch, 2026-09-15). + +- Header: "Visibility Bitmask Ambient Occlusion (VBAO) ... heavily based on XeGTAO v1.30 ... and + https://cdrinmatane.github.io/posts/ssaovb-code/ ... SSRT3". +- **Noise:** Hilbert LUT (64x64 `R16Uint`) + `288u * (frame_count % 64u)` under `TEMPORAL_JITTER`, R2 as XeGTAO. +- **Main pass:** XeGTAO's slice/step loop with `s *= s`, mip `clamp(log2(len(sample*viewport)) - 3.3, 0, 5)`, + **no minS and no pixel snapping** (dropped from XeGTAO), depth read through a **linear** sampler on the mip chain + (XeGTAO warns this interpolates between texels), positions through `view_from_clip` (raw NDC depth in the mips, + so reversed-Z is generic). `processSample`: `delta_back = delta - view_vec * thickness`, + `front_back = fast_acos(dot(normalize(.), view_vec))`, `saturate(fma(dir, -angles/PI, n))`, then `insertBits` + from `u32(min*N)` for `ceil((max-min)*N)` bits with `N = 2 * SAMPLES_PER_SLICE_SIDE`... note: Bevy passes + `samples_per_slice_side * 2.0` as the sector count, i.e. **the sector count equals the sample count per slice** + (4 to 18), not 32 [read from `ssao.wesl`, `processSample(..., samples_per_slice_side * 2.0, &bitmask)`]. Visibility + `1 - occluded/(slices * 2 * samples_per_side)`, clamp to `[0.03, 1]`. No cosine weighting, no falloff, no final + power. [Inference] With only 4-18 sectors the mask is coarse; the paper found 32 to be the banding threshold. +- **Thickness:** `constant_object_thickness` default 0.25 ("how far behind an object a ray of light needs to be in + order to pass behind it"). +- **Presets** (`mod.rs`): Low 1x2 (4 samples), Medium 2x2 (8), High 3x3 (18), Ultra 9x3 (54), Custom. +- **Edges/denoise:** XeGTAO edges with `bias 0.25`, `scale = z * 0.011`, packed `pack4x8unorm`; one 3x3 pass, one + pixel per thread, centre 1.2, diagonals 0.425, no leak term. Header: paper uses 4x4 uniform bilateral offset by + +/- 1 pixel every other frame; XeGTAO 3x3 twice; Bevy 3x3 once. +- **Prefilter:** 5 mips with workgroup memory, credits SAO section 2.2, XeGTAO's weighted average. +- **Formats:** R16Float if storage-supported else R32Float for depth mips and both AO textures. +- Docs: "strongly recommended that you use SSAO in conjunction with TAA". +- **Open improvements** (bevy#19713, Elabajaba, 2025-06-18): acos-free evaluation (shadertoy 4cdfzf), occluder + thickness heuristics (shadertoy 3clGWB + Bottosson), and GT-VBAO (Mirko Salm, shadertoy XXGSDd/Xc3yzs) which lists + four VBAO shortcomings: slice-local sample distribution ("pole concentration near the view vector and cosine falloff + toward horizons", fixed by CDF remapping of horizon angles), point-sample treatment (quantised arc lengths), + perspective distortion of slice directions, and cosine-weighted hemisphere support (three options; option 3 "direct + CDF importance sampling using invertible approximation with single random number"). Salm's own description: GT-VBAO + "matches the results of a brute force ray-marcher" sharing the same depth-sample and thickness assumptions, and + "supports both uniform hemisphere weighting and cosine weighted" (X post 1833211198009184650, via search snippet). + [Uncertain] internals; the Shadertoy pages were not readable from here. +- **Bottosson's thickness thread** (Bluesky, read via the public API): constant thickness "is impossible to tweak so + that it works for varying sizes of occluders" and "occlusion is binary, so you get quite sharp artifacts when a gap + opens up behind an object"; his heuristic: "estimate if subsequent samples along a horizon are a part of the same + surface or not, and using that estimate the width of the surface. I then simply use the width as the thickness + estimate. On top of that I randomly scale the thickness to reduce artifacts." Code: shadertoy wcBGRz, 3clGWB, wcfGWB + (licence unstated). + +**Licence:** MIT OR Apache-2.0; code usable with notices. Its authors' own header names its bases, so a port from Bevy +inherits a clean chain. + +### A.5 MXAO: iMMERSE (proprietary) and qUINT (all rights reserved) + +Read: `MartysMods_MXAO.fx` header and structure (study only), `qUINT_mxao.fx` header, the martysmods guide. + +- **Licence.** iMMERSE: "Copyright (c) Pascal Gilcher. All rights reserved. Unauthorized copying of this file, via any + medium is strictly prohibited. Proprietary and confidential" (file header). qUINT: "Copyright (c) Pascal Gilcher / + Marty McFly. All rights reserved." (`qUINT_mxao.fx` header); the repository has no LICENSE file (raw fetch 404). + **Both: ideas only, nothing reproduced.** +- **What it does** (from the file's structure and UI text, and the guide): four `MXAO_AO_TYPE`s, "0: GTAO (high + contrast, fast), 1: Solid Angle (smoother, fastest), 2: Visibility Bitmask (DX11+ only, highest quality, slower), + 3: Visibility Bitmask w/ Solid Angle"; seven sample presets as slice/step pairs; a shading rate (full, half, quarter + = checkerboard skip by `FRAMECOUNT`); deinterleaved 2x2 / 4x4 / 5x5 tiles; a 4096x64 "temporal blue noise" seed + texture indexed by `FRAMECOUNT % 64`; `s = ((i + jitter)/n)^2` step distribution; thickness + `T = log(1 + r) * 0.3333` ("arbitrary thickness that looks good relative to sample radius"); horizon initialised at + `cos(normal_angle -/+ pi/2)` with the comment "much better falloff than original GTAO :)"; in bitmask mode a + half-occlusion `ceil(saturate(h.y - h.x) * 32)` and a comment "this almost perfectly approximates inverse transform + sampling for cosine lobe"; a guided-filter-style spatial filter (`(mv.w - mv.x*mv.z) / (mv.y - mv.x^2)`), and its own + temporal blend/accumulate passes. **iMMERSE MXAO has no indirect lighting**; the older qUINT MXAO had + `MXAO_ENABLE_IL` ("Will cause a major fps hit"). +- **Independently published counterparts of every idea worth keeping:** + - horizon initialised at the hemisphere edge `cos(n +/- pi/2)`: XeGTAO `lowHorizonCos` (MIT); + - slice weight = projected-normal length: GTAO eq. 8; + - "solid angle" AO = the uniform-weighted formulation: GTAO paper Appendix A ("uniform ... instead of + cosine-weighted") and Unity's `IntegrateArc_UniformWeight = 1 - cos(h)`; + - cosine importance mapping of sectors: derivable from first principles (section C.4) and the same trick appears in + the Skyrim SSGI (GPL, `smoothstep(0,1,(angle+n)/pi+0.5)` "using smoothstep for cos"); + - deinterleaving: ASSAO (Intel MIT, in Godot) and CACAO (MIT); + - 64-frame blue-noise seed texture: EA FastNoise (BSD-3); + - checkerboard shading rate: XeGTAO FAQ ("half by half or checkerboard"). +- **Strength** is the product shape: integration as a switch, not a fork. Adopted as a shape. + +### A.6 Alchemy AO, McGuire, Osman, Bukowski, Hennessy, HPG 2011 + +Read from `VV11AlchemyAO.pdf`. + +- Estimator (eq. 10): `A = max(0, 1 - (2 sigma / s) * sum_i max(0, v_i . n + z_C beta) / (v_i . v_i + eps))^k`, + `r = 0.5 m, sigma = 1, k = 1, beta = 1e-4 m`; falloff `g(t) = u t max(u,t)^-2` chosen because it "resembles the + shifted hyperbola ... artistically desirable in StarCraft II" and cancels terms; samples on a screen disk, per-pixel + XOR-hash rotation; treats the depth buffer as a thin shell (Loos and Sloan) rather than an infinite volume, which is + why it shows fewer halos than volumetric obscurance. 12 spp at 720p in 3 ms on GeForce 580; two 13-tap 1D + cross-bilateral passes; 4.5 ms on Xbox 360. Self-occlusion needs the bias `beta`. +- **For this game.** [Inference] Independent point samples extract one bit of information per fetch; the slice + methods extract a horizon or a sector range per fetch, so at 8-18 fetches Alchemy is noisier for the same cost. + Its lasting ideas are the thin-shell assumption and the `beta` bias, both subsumed by GTAO/VBAO. +- **Licence:** paper; the G3D reference code is BSD (search result, casual-effects G3D; the exact BSD variant is + [Uncertain]). + +### A.7 Scalable Ambient Obscurance, McGuire, Mara, Luebke, HPG 2012 + +Read from `McGuire12SAO.pdf`. + +- Same estimator as Alchemy (eq. 1), new structure: a camera-space z mip chain (rotated-grid subsampling was the best + filter in their Table 1; hardware mip averaging was not), mip per sample `m_i = floor(log2(h'_i / q'))` (eq. 9) with + `q'` the screen-radius increment, spiral samples `alpha_i = (i+0.5)/s`, `theta_i = 2 pi alpha_i tau + phi` + (`tau = 7` for `s = 9`), face normals reconstructed from depth derivatives within 0.2 degrees, a 2x2 reconstruction + then two wide 1D bilateral passes, `z_f = -inf` for precision. **Cost** (Table 3, GTX 680): 1080p total 1.59 ms with a + 192 px guard band (z mips 0.24, sparse AO 0.78, blur 0.41); 2.26 ms on GTX 580 vs 16.1 ms for the unhierarchical + version (7.1x). Section 2.2 is what XeGTAO/Bevy cite for their mip chain; XeGTAO replaced rotated-grid subsampling by + a depth-weighted average. +- **Licence:** paper; G3D code BSD [Uncertain variant]. Contributes the mip idea, already in XeGTAO. + +### A.8 "Low-sample GTAO + spatial denoise" as a technique + +Every production implementation reviewed runs few samples and leans on a spatial pass plus a temporal accumulator: +GTAO paper 1 direction/pixel + 4x4 bilateral + 6-frame reprojection (A.2); XeGTAO 2x2 or 3x3 + one 3x3 pass with TAA +(A.1, `XeGTAO.h` v1.21 note "1-pass new ... enough when TAA enabled"); Bevy 2x2 + one 3x3 pass with TAA (A.4); +Therrien's benchmarks "one hemisphere slice per pixel jittered over multiple frames" + a 0.3 ms denoise (A.3); Unreal +`r.GTAO.NumAngles=2` at half resolution with spatial and temporal filters (artiliada.github.io "State of GTAO in +Unreal"; the spatial filter was broken in 4.26/4.27); the UE marketplace VBAO with the engine TAA only (A.3). Unity's +port uses an 8-tap separable cross-bilateral plus an AABB-clamped private history (A.10). openmw-ssao a 12-tap Poisson +depth-weighted blur plus a private history (A.9). Godot/ASSAO 3-12 taps deinterleaved plus a 4-tap edge blur x N +(A.11). The choice inside this family is only: which per-slice integration, how many denoise taps, and whether the +accumulator is the engine TAA or a private history. Here the accumulator is fixed by decision (TAA). + +### A.9 openmw-ssao (zesterer, no licence: ideas only) + +Read: `shaders/ssao.omwfx` in full. + +- Point-sample SSAO: up to `cfg_samples` (default 30, max 400) samples on a spiral (`t += 3.88` per sample, radius + `0.01 + fract(t*22.8)` scaled by `cfg_radius / (10 + depth^0.75 * 0.2)`), per-pixel hash rotation plus + `fract(simulationTime * t)` per frame when temporal filtering is on; weight per sample from a depth-difference + ignore term (`cfg_depth_compensation`, "Lower values produce 'occlusion halos'") and a depth/normal factor mix. +- **Temporal reprojection:** history in `ao_next` (rgb = ao, marker.xy, a = 1/depth); the **marker** is a hash of the + world position (`dot(sin(wpos*0.09), 1), dot(sin(wpos*0.13), 1)`) stored beside the AO and compared after + reprojection (`blend = temporal^0.1 / (1 + max(0, |last.yz - marker|) * inv_depth * 1e5)`); the sample count is + reduced where the history is trusted (`samples = cfg_samples * max(1 - blend*0.9, 0)`); change-based rejection + `reject_changed` when the new AO is far above the old. +- **Sky/water/fog/hands:** AO = 1 for `depth > far*0.99`, for pixels on the other side of the water plane than the + camera; the final combine mixes AO out by fog coverage; hands: `if (!cfg_enable_hands && depth < 40) ssao = + smoothstep(1, ssao, min(depth/60, 1))`. +- **Blur:** 12-tap Poisson, `weight = exp2(-|d - d_s|/d * 50)`. +- **For this game.** Every temporal trick exists because it has no engine TAA to lean on. Sky, water-plane, fog and + hands handling are the useful ideas (all trivial and re-derivable; section C.9). + +### A.10 Unity Ground Truth Ambient Occlusion (MaxwellGengYF, no licence: ideas only) + +Read: `GTAO_Common.cginc`, `GTAO_Pass.cginc`. + +- GTAO with the cosine arc (`IntegrateArc_CosWeight`), a **thickness blend** in the horizon loop: + `h = (H > h) ? lerp(H, h, falloff) : lerp(H, h, thickness)` with `falloff = saturate(d^2 * 2/r^2)` (the paper's + eq. 9 as a lerp), bent normal from the mean horizon, noise = interleaved gradient noise plus a per-pixel + `0.25 * ((y - x) & 3)` step offset and per-frame `_AO_TemporalOffsets/_AO_TemporalDirections`; an 8-radius separable + cross-bilateral (`exp2(-r^2 * falloff - dz^2)`); a **temporal filter** that clamps the reprojected history to a + neighbourhood AABB and blends with `weight = saturate(response * (1 - 8*|velocity|))`, response up to 0.98; the + paper's multi-bounce with rounded coefficients (`A = 2*albedo - 0.33, B = -4.8*albedo + 0.64, C = 2.75*albedo + 0.69`, + `max(AO, ((AO*A + B)*AO + C)*AO)`), and GTSO reflection occlusion. +- **For this game.** The private temporal filter is the pattern rejected in C.7; the multi-bounce form is the paper's. + +### A.11 Engines available locally (`~/Projekte/ReScaleFrame/references`) + +| Engine | Licence (file) | AO | What was read | +|---|---|---|---| +| Unreal (`UnrealEngine`, `UnrealEngine-4.18.3`) | UE EULA (`LICENSE.md`) | The local checkouts are **4.18.3** (`Engine/Build/Build.version`): `PostProcessAmbientOcclusion.usf` is the old SSAO, **no GTAO source locally** (0 `GTAO` hits). GTAO exists since 4.24: `r.AmbientOcclusion.Method=1`, `r.GTAO.NumAngles=2`, `r.GTAO.Downsample=1`, `r.GTAO.SpatialFilter`, `r.GTAO.TemporalFilter`, `r.GTAO.ThicknessBlend`, `r.GTAO.FalloffEnd` (artiliada.github.io/2024/12/27/GTAO.html). | reference only; EULA, no code reuse | +| Godot (`godot`) | MIT | `servers/rendering/renderer_rd/shaders/effects/ssao*.glsl`: Intel ASSAO (2016 MIT header, "2020-12-05: clayjohn: convert to Vulkan and Godot"). Deinterleaved 4 passes, 3/5/12 taps x2 per preset, depth mips with `SSAO_DEPTH_MIPS_GLOBAL_OFFSET -4.3`, 2-bit packed edges, **haloing reduction** weight `clamp(-dz * neg_inv_radius + 2, 0, 1)`, **normal-based edges** `clamp(dot(n, n_neighbour) + 0.5, 0, 1)` at quality >= 2, detail AO from the 4 neighbours, adaptive importance map at level 3, blur "smart" (4-tap edge-weighted, centre 0.5) and "wide" (+/-2 px). SSIL (PR #51206): "ASSAO-like ... does not require Temporal Super Sampling", 0.3-1.0 ms on the author's hardware. | usable ideas and code (MIT) | +| Donut (`Donut`, `Streamline_Sample/donut`) | MIT (NVIDIA) | `ssao_compute_cs.hlsl`: deinterleaved 4x4, 16 spiral samples, 4x4 blue-noise rotation, `saturate(NdotV - bias) * saturate(1 - d^2/r^2)`, groupshared 24x24 blur; optional SH "directional occlusion". | usable (MIT); a point-sample baseline only | +| Skyrim CS "Screen Space GI" (`gi.cs.hlsl`, 405 lines) | GPL-3.0 (`COPYING`) | XeGTAO-derived **bitmask** (32 sectors, `countbits * 0.03125`), `smoothstep(0,1,(angle+n)/pi+0.5)` in place of the cosine mapping, AO radius gating `s < AORadius`, constant `Thickness`, GI back-thickness 300 units, STBN noise "from https://github.com/electronicarts/fastnoise 128x128x64" indexed by `FrameIndex % 64`, a **normal flip** `if (dot(viewVec, pixCenterPos) > 0) viewspaceNormal = -viewspaceNormal` ("flip foliage normal"), half/quarter-res mip floors, `DepthFade`, `AOPower`, and a private SVGF-style temporal denoiser (5-tap clamp on the history) with a radiance-disocclusion pass. | ideas only | +| Fallout 4 CS `ScreenSpaceGI/XeGTAO/*` | GPL-3.0 | the same family | ideas only | +| FidelityFX CACAO | MIT (`docs/license.md`; gpuopen.com "Open source, MIT license") | No CACAO source in the local SDK copy (only sample images); `Luma-Framework/.../ffx_cacao.h` shows 5 quality levels and `adaptiveQualityLimit`. gpuopen manual: ASSAO adaptation, 2x2 deinterleave into four quarter-res passes, importance map at HIGHEST, 4 blur passes default (2 at LOWEST), bilateral upsample. | usable (MIT); ASSAO family, superseded by GTAO for quality | +| Luma-Framework | custom MIT | ships XeGTAO ports per game (`Luma_*_XeGTAO.hlsl`) | a second MIT port to cross-check GL-style projections against [not read in depth] | + +--- + +## B. Comparison + +Scale: ++ best, + good, o neutral, - weak, -- worst, for **this** game (voxel terrain, alpha-tested foliage, fences, +1-block steps, long distances, TAA accumulator, Arc 140V budget). Cost columns are the sources' own numbers at 1080p; +"foliage" = thin geometry behaviour (halos, over-darkening behind leaves/fences). + +| Source | Accuracy vs ground truth | Thin geometry | Noise at 8-18 fetches | Convergence through TAA | Cost at 1080p (source) | Edge/halo handling | Licence | +|---|---|---|---|---|---|---|---| +| XeGTAO horizon GTAO | + (paper matches MC reference; the 0.05 slope fudge and `FinalValuePower` are auto-tuned deviations) | -- (infinitely thick occluders; heuristic off by default) | + (analytic arc per slice) | ++ (designed for it: R2 64-frame index, one denoise pass) | 0.56 ms RTX 2060 High, 2.39 ms i7-1195G7 High | + (2-bit slope-aware depth edges, leak, symmetric) | MIT | +| GTAO paper 2016 | + | - (eq. 9 EMA heuristic helps leaves/branches, "conservative") | o (1 dir/pixel, needs 4x4 + 6 frames) | ++ (that is its design) | 0.5 ms PS4 half-res | o (bilateral 4x4) | paper | +| VBAO (Therrien) | + (Fig. 10 closer to RT reference than GTAO at wide radius; no cosine term) | ++ (light passes behind bars, Fig. 11; leaks at discontinuities, Fig. 6) | + (same fetches as GTAO; sectors quantise) | ++ (paper's benchmarks are 1 jittered slice) | GTAO + 0.01-0.02 ms, RTX 2080 | o (needs the GTAO edges; no falloff) | paper (code post unlicensed) | +| Bevy VBAO | o (sector count = sample count, 4-18; no cosine; linear depth sampler) | + (constant 0.25) | o | ++ | not published; XeGTAO-class | + (XeGTAO edges, 3x3 once) | MIT/Apache-2 | +| GT-VBAO (Salm) [Uncertain] | ++ (claims to match a brute-force ray marcher, uniform and cosine) | ++ (with Bottosson's width heuristic) | ? | ? | ? | ? | unlicensed Shadertoy | +| MXAO (iMMERSE) | + (bitmask + cosine lobe mode) | ++ (bitmask modes) | + | private history | not published | o | proprietary | +| Alchemy 2011 | - (aesthetic falloff, not radiometric) | o (thin shell) | -- (1 bit per fetch; 12 spp + 2x13 taps) | o | 3 ms GTX 580, 12 spp 720p | - (bias `beta`, halos at silhouettes) | paper (BSD code) | +| SAO 2012 | - (same estimator) | o | -- | o | 1.59 ms GTX 680 (9 spp + wide blur) | o | paper (BSD code) | +| openmw-ssao | - (heuristic point AO) | o | -- (30 spp when untrusted) | private history + marker | not published | + (depth compensation, fog, water, hands) | none | +| Unity GTAO | + | - (thickness lerp = paper eq. 9) | + | private AABB-clamped history | not published | o (8-tap separable) | none | +| Godot/ASSAO, CACAO | o (obscurance with `shadow_power`) | - (haloing-reduction weight only) | + (deinterleaved 6-24 taps, adaptive) | o (no temporal design; SSIL "does not require TSS") | Godot SSIL 0.3-1.0 ms; CACAO n/a | ++ (depth + normal edges, detail AO) | MIT | +| Skyrim SSGI (bitmask) | + | ++ | + | private SVGF-style history | not published | + (XeGTAO edges) | GPL | +| Vanilla VS SSAO | -- (kernel AO with a 0.5-0.7 floor and x1.4 boost) | - (leaves hack: skip near samples, normal test) | - (20-24 spp at half res) | - (screen-locked Bayer; the override rotates it) | not measured here | - (11-tap separable depth blur, min-of-2-rows) | game | + +[Inference] Reading the table: the only integration that addresses the dominant geometry (thin alpha-tested foliage, +fences) is the visibility bitmask; the only production scaffold with TAA-first design and a permissive licence is +XeGTAO (and its Bevy port); everything point-sample based is dominated on noise per fetch. What the bitmask lacks +(cosine weighting, a thickness model better than one constant, halo control at discontinuities) is exactly what +GT-VBAO, Bottosson and the GTAO paper's falloff supply as ideas. + +--- + +## C. The combined design + +Pipeline: `prefilter (depth -> 5 mips) -> main (bitmask AO + edges) -> denoise (3x3) -> compose (multiply into +Primary colour 0 before the resolve)`. Components, their source, and why they beat the alternatives. + +### C.1 Depth prefilter and mip selection - XeGTAO (MIT), constants from the note + +- Input: Primary `D32_SFLOAT`, linearised with the note's GL derivation (`DepthUnpackConsts = (-B/2, (1-A)/2)`, + `tan = 1/P[0][0], 1/P[1][1]`), **not** `gPosition`: at 200-500 blocks an fp16 position has 0.125-0.5 block steps, + the D32 depth linearised in fp32 does not (note, step 2; XeGTAO fp16 caveats in the README). +- Working depth R32F, 5 mips, XeGTAO's `DepthMIPFilter` (weighted mean, `depthRangeScaleFactor 0.75`), one dispatch + with `shared` scratch (section 0 correction; Bevy does the same in WGSL). Point sampler, NEAREST mip (XeGTAO's warning + about linear samplers; Bevy's linear sampler is the one thing not to copy). +- Mip per sample `clamp(log2(len_px) - 3.30, 0, 4)` (XeGTAO/Bevy). SAO's rotated-grid subsampling is not needed: the + weighted average is what XeGTAO tuned for temporal stability ("temporal stability is the first affected", + `XeGTAO.h:112` comment). +- Why not Godot/CACAO deinterleaving: [Inference] it optimises cache behaviour for point-sample kernels; XeGTAO's README + states the deinterleaved approach is "unsuitable due to GTAO's linear sampling pattern constraints". + +### C.2 Slice and sample distribution, counts per preset - XeGTAO (MIT) + +- Per slice `phi = (slice + noise.x)/N * pi`; per step R1 `stepNoise`, `s = t^2 + minS` with `minS = 1.3/screenRadius` + and pixel-centre snapping (XeGTAO; Bevy dropped both and its issue tracker lists the resulting artefacts under + "point sample treatment" [Inference on causality]). Two fetches per step (both slice sides). +- Presets (slices x steps per side; fetches): Low 1x2 (4), Medium 2x2 (8), High 3x3 (18), Ultra 9x3 (54) - the + XeGTAO/Bevy table, kept identical so the published cost ratios (High : Medium : Low = 1 : 2/3 : 4/9) apply. +- Effect radius in blocks: start 0.75 with `RadiusMultiplier 1.457` folded in (XeGTAO's auto-tuned screen-space bias + compensation); the paper's VBAO benchmarks used radius 0.8-1 (A.3). Tune in D. + +### C.3 Per-slice integration - visibility bitmask (Therrien 2023 Algorithm 1, re-implemented; Bevy MIT as the reference port), switchable to XeGTAO's analytic horizon + +- 32 sectors in one `uint` (paper: the banding threshold, and 128 costs 5-10% more). Not Bevy's "sectors = sample + count". +- Front/back angles from the sample and `sample - V * t` (paper); bits set with the **round** criterion (paper's + choice; Bevy/MXAO use ceil, which over-occludes by up to one sector per sample [Inference]). +- Visibility = `1 - popcount/32` per slice (paper), averaged over slices, weighted by the projected-normal length + `||n_x||` per slice (GTAO eq. 8; XeGTAO `projectedNormalVecLength`) so grazing slices count less - MXAO's + "slice weight" is this same term. +- **Switch** (specialization constant): `INTEGRATION = BITMASK_COS | BITMASK_UNIFORM | HORIZON_GTAO`. The third is + XeGTAO's `iarc` code path unchanged, so the foliage comparison in D is apples to apples on the same fetches. + +### C.4 Cosine weighting of the bitmask - derived here; the idea is GT-VBAO's (Salm) and the GTAO paper's eq. 5 + +The paper's bitmask counts sectors uniformly in angle ("we do not take the cosine weight into account"); ground-truth +AO is cosine-weighted (GTAO eq. 5). Two published routes: weight each unoccluded sector by its cosine arc integral +(eq. 7 evaluated on the sector bounds; 32 evaluations per slice, or a 2D LUT over `(gamma, sector)`), or **distribute +the sector boundaries by the cosine CDF** so uniform bit counting is already cosine-weighted (GT-VBAO option 3 +"direct CDF importance sampling", bevy#19713; MXAO's comment "approximates inverse transform sampling for cosine +lobe"; the Skyrim SSGI's `smoothstep` is a cheap approximation of it). First-principles form, no source code involved: +on the normal-centred slice `phi in [-pi/2, pi/2]`, `p(phi) = cos(phi)/2`, `CDF(phi) = (sin(phi) + 1)/2`, so + + sectorIndex(phi) = (1 + sin(phi)) / 2 * 32, phi = theta - n (theta from the view vector, n the projected-normal angle) + sin(phi) = sin(theta) cos(n) - cos(theta) sin(n), cos(theta) = dot(h, V), sin(theta) = +/- sqrt(1 - cos^2) + +which needs **no acos per sample** (the "acos-free slice evaluation" of bevy#19713 is presumably this or equivalent +[Uncertain]; `sin(n), cos(n)` come once per slice from `cosNorm` and the sign). What this ignores: the `|sin theta|` +Jacobian in eq. 5 (Salm's "pole concentration near the view vector"). [Inference] For sectors indexed around the +normal this is a second-order bias at moderate `n`; it is one of the things D quantifies against the analytic horizon +path, which has the exact weighting. The exact per-sector eq. 7 weighting stays as a third `INTEGRATION` value only if +the CDF mapping fails that comparison. + +### C.5 Thickness model - Therrien (constant, distance-scaled) + Bottosson (randomised) + a game-specific class channel + +- Base: constant thickness `t` in blocks, **increased linearly with view distance** (paper's own option against + far over-attenuation; VS terrain is viewed at 100-500 blocks, so this is not optional here). +- **Randomised thickness** per sample: `t * (0.5 + noise)` (Bottosson: "randomly scale the thickness to reduce + artifacts"; the bitmask is binary, so a single `t` makes a hard pop when a gap opens - with TAA as accumulator a + dithered `t` converges to a soft transition [Inference]). Draw the scale from the same R2 pair (a third R2 dimension + or `frac(noise.y * 7)`), so it advances per frame and TAA averages it. +- **Class channel (decided):** `gNormal.w` becomes a surface class. Today it is `1` for wind-mode (leaf) blocks and + `0` otherwise (`chunkopaque.vsh:103`); the chunk-shader patch extends the thin class to plants, grass and cross-quad + blocks, and the hand-view draws write their own class (C.9). Proposed encoding, chosen so vanilla SSAO's + `leavesHack = w > 0` keeps its meaning when it runs: `0` solid, `1` thin foliage, `-1` hand view. Vanilla SSAO + never sees the extended flag on grass: the chunk shader writes the extended class only under a define stamped while + the new AO is active (the vanilla `w` stays byte-identical on OpenGL and with TAA off) [implementation note, not a + source]. Particles write their alpha into `w` (`particlescube.fsh:47`), which reads as "thin" - correct for them. + For a sample landing on a thin texel use `t_thin` (about 0.05 block) instead of `t`; for solid terrain keep `t` + (default 0.5 block: a fence post is 0.125-0.25, a full block 1). Cost: one extra fetch of `gNormal.w` at the sample + (RGBA16F, mip 0 only), or pack the class into the sign of mip 0 of the working depth so it rides along for free + [Inference; the prefilter must preserve it and reconstruction must `abs()` it]. Not from any source; D.3 measures + the thin-foliage scenes with and without the channel. +- Bottosson's same-surface width estimate is the principled version ("estimate if subsequent samples along a horizon + are a part of the same surface ... use the width as the thickness estimate"). [Uncertain] its code was unreadable; + it needs consecutive-sample bookkeeping per slice side, so it is a switchable `THICKNESS = CONST | DIST | RANDOM | + WIDTH` variant, implemented from the description if the class channel does not close the gap. +- **Not adopted:** XeGTAO's `ThinOccluderCompensation` (off by default, small measured gain, and it is a falloff bias, + not a thickness); the GTAO paper's eq. 9 EMA (the bitmask supersedes it for the same fetches, Fig. 10/11); + MXAO's `log(1 + r)/3` (a proprietary constant with no derivation). + +### C.6 Falloff - none inside the bitmask (Therrien), plus XeGTAO's radius gate and fades + +- No per-sample distance falloff (paper: "we don't need to apply any falloff"). Samples with `s > 1` are outside the + radius by construction of the step distribution; the Skyrim SSGI's `s < AORadius` gate is the same thing. +- Keep XeGTAO's small-screen-radius fade (`visibility += saturate((10 - screenRadius)/100)*0.5`), and a far fade as + vanilla's `distanceFade = clamp(1.2 - z/250, 0, 1)` (game-specific numbers, `ssao.fsh:87`) so distant terrain is not + darkened by sub-pixel geometry; the SSGI has the same `DepthFade`. +- Leaks at depth discontinuities (paper Fig. 6) are handled by the denoiser's edges, not by a falloff: the XeGTAO + edge/leak logic already isolates silhouettes. + +### C.7 Noise sequence and its advance - XeGTAO/Bevy (MIT): Hilbert LUT + R2, `NoiseIndex = FrameIndex` + +- Default: 64x64 `R16_UINT` Hilbert LUT, `index += 288 * (NoiseIndex % 64)`, R2 (XeGTAO, Bevy). `NoiseIndex = + OptimumTemporal.Frame.FrameIndex` while a temporal consumer owns the frame (the same clock the SSAO override already + uses, `ClientPlatformWindows.cs:3466-3477`), 0 otherwise. +- **Commensurability check** (not in any source, [Inference]): the jitter phase count is 8 at native and 32 at render + scale 0.5 (`OptimumTemporalMath.JitterPhaseCount`); both divide 64, so each jitter phase meets only 8 (or 2) distinct + noise tiles. XeGTAO's own users run 8-phase Halton with 64 without complaint, but 32 phases with 2 patterns each is + new territory. Keep the cycle length a parameter (`NOISE_CYCLE = 64 | 61`) and measure the periodic residual in D; + 61 is coprime with 8 and 32. +- Switchable alternative: a 128x128x64 spatiotemporal blue-noise texture generated with EA FastNoise (BSD-3-Clause; + the Skyrim SSGI uses exactly that shape, indexed `pix % 128, frame % 64`). NVIDIA's STBN SDK is **not** usable + (its `License.txt` is a "Non-Commercial Use License"). XeGTAO's README recorded why plain 2D blue noise + temporal + offsets failed; STBN is the designed answer to that, so it is worth one measurement, not the default. +- Rejected: Owen-scrambled Sobol per pixel - no reviewed AO ships it; per-pixel independent scrambling is white in + space, which the 3x3 denoise cannot average, while Hilbert+R2 is low-discrepancy across neighbours by construction. +- TAA off: `NoiseIndex = 0`, two denoise passes (XeGTAO), but by owner decision vanilla SSAO runs instead; the code + path stays for measurement only. + +### C.8 Spatial denoise - XeGTAO (MIT) 3x3 edge-aware, one pass; ASSAO/Godot normal edges as an option + +- One 3x3 pass with TAA (XeGTAO v1.21 note; Bevy), centre weight 1.2, 2-bit slope-aware depth edges, edge symmetry, + the **leak** term (XeGTAO only; Bevy dropped it) - the leak is what keeps single-pixel-wide fence rails and grass + blades from becoming isolated noisy pixels ("reduces both spatial and temporal aliasing"). +- Optional multiplicative **normal-based edge factor** `clamp(dot(n_c, n_neighbour) + 0.5, 0, 1)` (ASSAO/Godot + `SSAO_NORMAL_BASED_EDGES_DOT_THRESHOLD`, MIT). [Inference] At a 1-block step the depth edge already fires; at a convex + block corner depth is continuous and only the normal flips, and AO on both faces is similar, so blurring across is + mostly harmless. Measured, default off unless D shows bleeding along block edges. +- Not separable, not 5x5: XeGTAO's four `Gather`s cover the 3x3 for both AO and edges; a separable 5+5 is two passes + and more bandwidth on an iGPU, and the GTAO paper's 4x4 is tied to its half-res + 6-frame design. +- Two passes when TAA is off (XeGTAO `DenoisePasses`), three ("soft") only for screenshots. + +### C.9 Sky, far depth, hands, water/fog, foliage - vanilla + openmw ideas, re-derived + +- **Sky:** depth == 1 (`>= 0.999999` as the resolve tests) writes AO 1 and skips the loop (vanilla `fragPos.x == 0` + early-out; openmw `depth > far*0.99`). +- **Far:** C.6 fade; positions from D32 (C.1). +- **Hands (decided):** the hand view has its own projection (`docs/temporal-frame-contract.md` section 7.6, "Two + views"); a sample offset computed with the world projection lands on the wrong texel for hand pixels, and vanilla + ignores this (it projects with the world matrix too). The hand-view draws (the programs listed under "First-person + hands, echo chamber" in contract section 6, which already reproject through `GetPrevProjection(Hand)`) are patched + to write the hand class into `gNormal.w` (C.5); the AO pass writes visibility 1 on hand-class pixels and, as a + sample, treats them as solid with the world reconstruction (a hand in front of a wall still occludes the wall + approximately [Inference]). The lib/shader patch follows `patch-workflow` (Cecil member lists, extract, check). + openmw's near-depth fade (`depth < 40` smoothstep) is the fallback only if the patch proves impossible. +- **Water/fog/OIT:** keep vanilla's modulation `AO_final = 1 - (1 - AO) * (1 - attenuate)` with + `attenuate = gPosition.w + 0.75 * (1 - revealage)` (`ssao.fsh:76-82`, `:152`), applied in the **compose** pass, not + in the AO pass, so the AO texture stays a pure visibility term for measurement; openmw's fog-coverage mix is the same + idea. +- **Foliage:** alpha-test holes are real depth holes (section 0); the thin class drives `t_thin` (C.5); the SSGI's + **normal flip** `if dot(V, p) > 0: n = -n` handles double-sided leaves whose stored normal faces away (re-derived; one + compare). + +### C.10 Normals and projection - G-buffer `gNormal`, the note's GL mapping + +- `n = normalize(gNormal.xyz)`, mapped `(n.x, n.y, -n.z)` into XeGTAO's +Z-forward frame (note step 1; a mirror, and + the bitmask only uses dot products and one cross product per slice whose sign is consistent under the same mirror + applied to positions [Inference: verify with the note's step 7 frame check]). Flip toward the viewer (C.9). +- Class = `gNormal.w` (C.5): `> 0` thin (leaves, plants, grass, cross-quads, translucent particles), `< 0` hand view, + `0` solid; entities write 0 (section 0). Sky writes zeros: unaffected because sky is skipped first. +- Constants block: the note's 96-byte layout in push constants. + +### C.11 Output, tone and composition - XeGTAO formats; the AO value is the radiometric visibility, untouched (decided) + +- AO: `R8_UNORM` where storage-supported else `RGBA8_UNORM` (note); edges `R8_UNORM`/`RGBA8` second channel. Full + render resolution or half resolution + bilateral upsample (XeGTAO FAQ; the GTAO paper's production path) per preset + (C.12). +- **Tone (decided: physically correct).** The composed value is the cosine-weighted visibility from C.3/C.4, scaled + back from the `1.5` UNORM packing after the denoise. **No** vanilla floor (`max(occ, 0.5|0.7)`) and **no** `1.4x` + boost. **No** `FinalValuePower`: XeGTAO's README states it "has no basis in physical light transfer, we found that + auto-tune can use it to achieve better ground truth match" - a screen-space bias compensation tuned on Intel's + training set, not a derivation, so it is off (`1.0`) and exists only as a measurement knob to reproduce XeGTAO's + reference numbers. XeGTAO's `max(0.03, v)` clamp is kept (a pixel that is visible cannot have zero visibility; it also + guards the packing). `RadiusMultiplier 1.457` is kept: it is XeGTAO's compensation of the screen-space radius bias + *toward* the ray-traced reference ("allows us to use different value as compared to ground truth radius to counter + inherent screen space biases", `XeGTAO.h:107`), and D.2 re-tunes it against this game's reference. The horizon + path's `0.05` slope fudge stays only inside `HORIZON_GTAO` for parity with XeGTAO's numbers. +- **Multi-bounce (decided: not in the first version).** The GTAO paper's `G(A, rho)` (eq. 10) needs the surface + albedo; the scene colour at this point is lit LDR radiance (`RGBA8`, no exposure path, contract section 7.5), so + feeding it to the fit would be wrong. The compose pass keeps an explicit **albedo input hook** (`TONE = LINEAR | + MULTIBOUNCE`, an optional albedo texture binding, `MULTIBOUNCE` refused when the binding is absent) so the generated + PBR material stage can switch the fit on with a real albedo; the coefficients of eq. 10 are recorded in A.2. +- **Composition:** the existing `scene-ssao` multiply before the resolve, sampling the AO with nearest filtering at + full resolution or through the bilateral upsample at half; drop the `SSAOLEVEL > 1` min-of-two-rows (it compensated + the half-res upsample fudge); the water/fog/OIT attenuation of C.9 is applied here; never on glow (owner rule). + +### C.12 Quality presets and expected cost + +Arc 140V: 8 Xe2 cores at up to 1.95 GHz, ~4.2 TFLOPS FP32 peak (videocardz / cputronic figures; chipsandcheese +confirms 8 cores, 1.95 GHz, 8 MB L2, LPDDR5X). XeGTAO's iGPU figure is for the i7-1195G7 (96 EU Iris Xe, 2.39 ms High +at 1080p). [Inference] Xe2 has roughly twice that throughput and twice the L2, and this pass is bandwidth-bound +(GTAO paper, Therrien), so: + +| Preset | Slices x steps (fetches) | Denoise | Resolution | Expected 1080p render-res cost | Target device | +|---|---|---|---|---|---| +| Handheld candidate A | 2x2 (8) | 1 pass | full render res | ~0.8-1.2 ms on Arc 140V [Inference]; bitmask +3-5% over horizon (Therrien's 15 instr/sample) | Arc 140V | +| Handheld candidate B | 3x3 (18) | 1 pass + bilateral upsample | half render res | ~0.4-0.6 ms [Inference: 18/8 of A's fetches on a quarter of the pixels, plus the upsample] | Arc 140V | +| Discrete | 3x3 (18) | 1 pass | full | ~0.6 ms RTX 2060-class (XeGTAO High), ~0.3 ms RTX 4070 [Inference] | dGPU | +| Screenshot | 9x3 (54) | 2 passes | full | ~3x Discrete | screenshots only | + +**Handheld default (decided): chosen by the section D numbers between candidates A and B**, with no preference in +advance. The trade is more fetches per pixel at half resolution against fewer at full: candidate B has better +per-pixel convergence and a bilateral upsample that blurs across sub-pixel foliage; candidate A keeps single-pixel +grass and rails at their own resolution but with 8 fetches. D.2-D.5 decide (FLIP against the converged reference on the +foliage scenes, temporal standard deviation, cost). With an upscaler the render resolution is already 0.5-0.67 of the +display, which favours A [Inference]; it is still measured, not assumed. + +### C.13 Variants kept switchable for measurement (specialization constants or macros) + +`INTEGRATION = BITMASK_COS | BITMASK_UNIFORM | HORIZON_GTAO`; `SECTORS = 32` (fixed); `THICKNESS = CONST | DIST | +RANDOM | WIDTH`; `CLASS_CHANNEL = 0|1` (thin class read at the sample); `NOISE = HILBERT_R2 | STBN_FASTNOISE`, +`NOISE_CYCLE = 64 | 61`; `DENOISE_PASSES = 1|2|3`, `NORMAL_EDGES = 0|1`; `RESOLUTION = FULL | HALF_UPSAMPLE`; +`TONE = LINEAR | MULTIBOUNCE` (albedo hook, C.11) with `FINAL_POWER` as a measurement-only uniform (default 1.0). +Everything else is a uniform. Debug outputs (decided): the AO working term (pre-denoise, unscaled), the packed edges +and working-depth mip 0 are opt-in attachments of `OPTIMUM_PARITY_DUMP` and of the headless frame writer, so D reads +them from disk instead of instrumenting the shader. First version if the compute pass kind is not ready: the same +shaders as fragment passes (prefilter as four blits, main and denoise as fullscreen triangles; DiligentFX and the +ReShade port did this, note section 2) - the maths is identical, so nothing measured has to be redone. + +### C.14 The owner-forwarded proposal, verified claim by claim + +| # | Proposal | Verdict | Verified facts and reasons | +|---|---|---|---| +| 1a | XeGTAO's horizon search maths and its thickness heuristic | **Adopted (scaffold), rejected (heuristic)** | XeGTAO's horizon scaffold (slices, R1 steps, `s^2`, `minS`, mips, pixel snapping, edges, denoise, noise) is adopted (C.1, C.2, C.7, C.8), and its analytic horizon integral stays as the `HORIZON_GTAO` switch (C.3). Its thickness heuristic is **disabled in its own code** (`#else` branch "thicknessHeuristic is completely disabled", `XeGTAO.hlsli:496-506`; `ThinOccluderCompensation = 0`; README: auto-tune found only a small gain) and is a falloff bias along the view vector, not a thickness. The bitmask's explicit thickness replaces it (C.5). | +| 1b | A "lightweight multi-bounce ambient occlusion approximation" so crevices are not pitch black | **Rejected for the first version; albedo hook kept** | The fit is **not in XeGTAO** (0 hits for `multibounce|albedo` in `XeGTAO.hlsli`/`vaGTAO.hlsl`; the only `Albedo` in `XeGTAO.h:89` belongs to its RTAO reference tool). It is GTAO 2016 section 5 eq. 10, `G(A, rho)` with `a = 2.0404 rho - 0.3324, b = 4.7951 rho - 0.6417, c = 2.7552 rho + 0.6903`, and it needs the **albedo**; the scene colour here is lit LDR radiance, so applying the fit to it would be wrong (decided). The compose pass keeps `TONE = MULTIBOUNCE` behind an albedo binding for the PBR material stage (C.11). Crevices not going black is a property of the physically correct value itself (a visible pixel has `v >= 0.03`; no vanilla floor is needed to fake bounce). | +| 2a | MXAO's screen-space indirect lighting (colour-buffer bounce during the horizon search) | **Rejected** | MXAO is proprietary (header quoted in A.5). iMMERSE MXAO has **no IL**; only the old qUINT MXAO had `MXAO_ENABLE_IL` ("Will cause a major fps hit"). SSIL here would treat an LDR, already-lit `RGBA8` scene colour as radiance with no albedo split (contract section 7.5: "no HDR exposure path"), doubles the fetches (Therrien: the HDR light buffer and the normal buffer "for every sample taken"), and every shipped bitmask GI (Skyrim SSGI) carries a private temporal denoiser, which 3C rules out. Revisit after the ambient-term split and the PBR material stage. | +| 2b | MXAO's "normal-oriented sample distribution/weighting so flat voxel walls do not self-shadow" | **Adapted through published sources** | What MXAO has is the projected-normal slice weight (GTAO eq. 8, `||n_x||`) and the horizon initialised at the hemisphere edge `cos(n +/- pi/2)` (XeGTAO `lowHorizonCos`, MIT; MXAO's own comment calls it "much better falloff than original GTAO"); both are adopted from those sources (C.3). The bitmask itself is sector-indexed around the projected normal, so it is normal-oriented by construction. Flat-wall self-occlusion is handled by XeGTAO's depth bias (`viewspaceZ *= 0.99999`) and `minS` (never sample the centre pixel), plus the normal flip for back-facing foliage (C.9). Nothing is taken from MXAO's code. | +| 3A | Per-frame rotated directions from a 1D array of blue-noise textures or an Owen-scrambled Sobol sequence, cycling 8 or 16 frames | **Adapted** | Per-frame advance: yes, but with Hilbert+R2 and a 64-frame index (XeGTAO/Bevy), because XeGTAO's README records that tileable 2D blue noise with temporal offsets "caused overlaps which would often show as temporal artifacts". An 8- or 16-frame cycle equals or divides the 8-phase Halton jitter, so every phase would meet the same one or two patterns forever; 64 (or 61, coprime with 8 and 32, C.7) is kept. Spatiotemporal blue noise generated with EA FastNoise (BSD-3) is the measured alternative; NVIDIA's STBN SDK is under a non-commercial licence and excluded. Owen-scrambled Sobol rejected: no reviewed AO ships it, and per-pixel scrambling is white in space, which the 3x3 denoise cannot average (C.7). | +| 3B | Never feed raw AO into TAA; a separable cross-bilateral whose weight drops to zero past a depth threshold scaled by voxel size or past a few degrees of normal deviation | **Adapted** | Agreed on never composing raw AO: one XeGTAO 3x3 edge-aware pass runs before composition (C.8; XeGTAO v1.21 note and Bevy both find one pass sufficient with TAA). The separable form is rejected: two passes and more traffic on an iGPU, and XeGTAO's 3x3 with symmetric 2-bit edges and the leak term already stays sharp at 90-degree block edges (slope-adjusted depth test). Normal-angle rejection is adopted as the ASSAO/Godot optional factor `clamp(dot(n_c, n_n) + 0.5, 0, 1)` (MIT), default off pending D. A fixed voxel-size depth threshold is wrong for this geometry: XeGTAO's threshold is depth-relative (`0.011 * z`), which keeps a 1-block step an edge at 5 blocks and at 200 blocks alike. | +| 3C | Reproject the previous frame's AO with the velocity vectors and clamp the history with a tight 3x3 variance/neighbourhood box | **Rejected** | It is the second-history pattern the branch already ruled out (progress doc section 4; the note's openmw analysis): TAA is the accumulator and AO is composed before the resolve. The resolve already does the proposed clamp (3x3 YCoCg variance clip, `taa-resolve.fsh:134-135`) and a nearest-depth disocclusion test on the composed image, so voxel disocclusions are handled there. A private history would add a second reprojection, a second disocclusion test and a second ghosting source, and would then be accumulated again by DLSS/XeSS when those replace TAA (double temporal lag). Every source with a private history (Unity, openmw, Skyrim SSGI, MXAO) has one because it has no engine TAA to lean on; XeGTAO, Bevy and the UE VBAO product do not. If TAA-only convergence fails the numbers in D, the fix is a second denoise pass or half resolution, never a history. | + +--- + +## D. Measurement plan + +All runs with the implicit Vulkan layers off (`docs/vulkan-branch-progress.md`, Linux test state), renderer confirmed from the log, through +`scripts/dev/headless-capture.sh` (frames to disk, static camera or `.cam play`, `OPTIMUM_HEADLESS_FIXED_DT`). +**Inputs to every item (decided):** the AO working term, the packed edges and working-depth mip 0 are opt-in +outputs of `OPTIMUM_PARITY_DUMP` and of the headless frame writer (C.13), next to the existing attachments, so the +numbers below come from files, not from shader instrumentation. + +1. **Frame and normals check** (note step 7): reconstructed view-space XY/Z from D32 against `gPosition` for pixels off + sky and closer than 100 blocks, target < 0.1% relative error; debug views of normals, the class channel and edges + (the hand-class pixels must be exactly the hand-view draws; the thin class must cover leaves, plants, grass and + cross-quads and nothing else). +2. **Converged reference.** A numpy re-implementation of the bitmask and horizon integrals on captured depth + normals + at 64 slices x 32 steps, no noise (the same height-field and thickness assumptions, so it is the algorithm's own + ground truth; Salm's framing). Report PSNR and FLIP of (a) one noisy frame, (b) after denoise, (c) the TAA output + after 128 static frames, against it - per `INTEGRATION`, `THICKNESS`, `TONE` variant, on three scenes: dense forest + (leaves at 5-50 blocks), a fence with terrain behind it, a village with 1-block steps and long flat ground. + Optional true ground truth: a voxel ray-cast AO from the save's block data (the world is voxels; this is the one + game where real ground truth is cheap [Inference], but it is a separate tool). +3. **Thin-foliage behaviour.** Mean AO on the terrain behind the fence and under the canopy, per variant, versus (2). + Over-darkening = ratio below 1 against the reference; halos = AO < 0.95 on ground pixels the reference leaves at 1. + Run with `CLASS_CHANNEL = 0` and `1` on the same captures (decided): the channel stays only if the thin-foliage + scenes move measurably toward the reference. +4. **Temporal stability.** Static camera, wind stilled, 128 frames: per-pixel temporal standard deviation of the + resolved luminance over the centre crop and in labelled regions (leaves-far, grass, fence, flat ground, steps), + vanilla SSAO+TAA as the baseline. Acceptance: no region worse than vanilla; `scripts/dev/taa-rejection.py` leaf-far + rejection stays <= 1.5% (the 2026-09-11 resolve regression gate, `scripts/dev/taa-rejection.py`). Camera motion via `.cam play`: consecutive-frame diffs of the resolved + image; a 60 fps `ffmpeg -f x11grab` capture for flicker (rule 10). Also log the resolve's mean `clipKeep` and mean + `alpha` with AO on vs off (the XeGTAO README warning about TAA reading noise as detail, in numbers). +5. **Cost.** Per-pass GPU timestamps (prefilter, main, denoise, upsample, compose) at 1080p and 1440p render + resolution on the Arc 140V and the RTX 4070, all presets, `INTEGRATION` and `RESOLUTION` variants; vanilla SSAO's + three passes on the same frame as the baseline. Budget: the handheld preset <= 1.0 ms at 1080p on the 140V. + **Handheld decision (decided to be made here):** candidates A (2x2 full render res) and B (3x3 half res + upsample) + from C.12 are compared on (2) FLIP, (3), (4) and this item; the one that is within budget and closer to the reference + on the foliage scenes becomes the handheld default. A tie on quality goes to the cheaper one. +6. **Noise/jitter commensurability.** At render scale 0.5 (32 phases) with `NOISE_CYCLE 64` vs `61`: the temporal + power spectrum of a flat-ground pixel over 256 frames; a peak at period 32/64 is the failure. +7. **Cross-vendor:** NVIDIA, Intel ANV (the notebook's UHD), lavapipe as the deterministic CPU reference (note step 7); + validation with `sync,best` clean. + +**Results that change the design:** (2)/(3) horizon GTAO closer to the reference on the foliage scenes than the +bitmask -> default flips to `HORIZON_GTAO` and the bitmask stays a variant; `BITMASK_COS` not better than +`BITMASK_UNIFORM` -> drop the CDF mapping; the class channel without measurable gain in (3) -> the chunk-shader +extension is reverted and the channel keeps only the hand class; (4) worse than vanilla in any region -> second denoise +pass, then half resolution, never a history; (5) decides the handheld default between A and B; (6) shows a period -> +cycle 61; `NORMAL_EDGES` no gain in (3)/(4) -> removed; `RadiusMultiplier` re-tuned if (2) shows a systematic radius +bias against this game's reference. + +--- + +## E. Questions and their decisions + +### E.1 Decided (2026-09-15) + +1. **Look (owner):** physically correct. No vanilla floor, no 1.4x contrast; the AO value is the radiometric + visibility of the research, with XeGTAO's `FinalValuePower` off because XeGTAO's own README says it has no physical + basis (C.11). Reason: the roadmap goes to generated PBR materials and then ray/path tracing, so the AO term has to + be the thing those replace, not a look. The effect radius stays a tuning parameter measured against the converged + reference (D.2), not against vanilla's strength. +2. **First-person hands:** the hand-view draws are patched to write the hand class into `gNormal.w` (lib/shader patch + in scope, C.5/C.9). openmw's near-depth fade is the fallback only if the patch proves impossible. +3. **Class channel:** plants, grass and cross-quad blocks are flagged thin as well, through the small chunk-shader + change (C.5). D.3 measures the thin-foliage scenes with and without it. +4. **Multi-bounce:** not in the first version; the scene colour is lit radiance, not albedo, so the Jimenez fit cannot + take it. An explicit albedo input hook stays in the compose pass for the PBR material stage (C.11). +5. **Handheld default:** decided by the section D measurements between render-resolution 2x2 and half-resolution 3x3, + no preference in advance (C.12, D.5). +6. **Parity dump and headless outputs:** the AO working term, the edges and mip 0 become opt-in outputs (C.13, D). + +### E.2 Decided after review (2026-09-15) + +1. **Compute first.** The compute pass kind is built first. It is needed anyway for the prefiltered depth chain, the + denoiser, and the later ray-tracing and denoising roadmap; a fragment version would be thrown away. +2. **No shipped noise texture in the first version.** The `STBN_FASTNOISE` variant is a measurement variant whose + texture is generated locally from the BSD-3 FastNoise code and never packaged. It ships, with the BSD-3 notice, + only if section D measures it better than the Hilbert-R2 default. + +--- + +## Sources (primary, as read) + +- XeGTAO: https://github.com/GameTechDev/XeGTAO (`Source/Rendering/Shaders/XeGTAO.hlsli`, `XeGTAO.h`, `vaGTAO.hlsl`, `README.md`); issues #3, #6, #7. +- Jimenez et al. 2016: https://www.activision.com/cdn/research/PracticalRealtimeStrategiesTRfinal.pdf +- Therrien et al. 2023: https://arxiv.org/abs/2301.11376 ; code post https://cdrinmatane.github.io/posts/ssaovb-code/ ; + Unreal VBAO product thread https://forums.unrealengine.com/t/ark-kra-vbao-visibility-bitmask-ambient-occlusion/2705204 +- Bevy: https://github.com/bevyengine/bevy/tree/main/crates/bevy_pbr/src/ssao ; https://github.com/bevyengine/bevy/issues/19713 ; + Bottosson thread at://did:plc:4x5tm73cr75gbyr7t6rzcph3/app.bsky.feed.post/3liejizfkmk2k (public API); Salm X post 1833211198009184650 (search snippet only). +- MXAO: https://github.com/martymcmodding/iMMERSE/blob/main/Shaders/MartysMods_MXAO.fx ; https://github.com/martymcmodding/qUINT ; + https://guides.martysmods.com/shaders/immerse/mxao/ ; https://github.com/martymcmodding/iMMERSE/issues/8 +- Alchemy AO: https://casual-effects.com/research/McGuire2011AlchemyAO/VV11AlchemyAO.pdf +- SAO: https://research.nvidia.com/sites/default/files/pubs/2012-06_Scalable-Ambient-Obscurance/McGuire12SAO.pdf +- openmw-ssao: https://github.com/zesterer/openmw-ssao (`shaders/ssao.omwfx`) +- Unity GTAO: https://github.com/MaxwellGengYF/Unity-Ground-Truth-Ambient-Occlusion (`Shaders/GTAO_Common.cginc`, `GTAO_Pass.cginc`) +- Godot: `~/Projekte/ReScaleFrame/references/godot/servers/rendering/renderer_rd/shaders/effects/ssao*.glsl`; SSIL https://github.com/godotengine/godot/pull/51206 +- Donut: `~/Projekte/ReScaleFrame/references/Donut/shaders/passes/ssao_*.hlsl` +- Skyrim CS SSGI: `~/Projekte/ReScaleFrame/references/skyrim-community-shaders/features/Screen Space GI/Shaders/ScreenSpaceGI/gi.cs.hlsl` (GPL) +- CACAO: https://gpuopen.com/manuals/fidelityfx_sdk/fidelityfx_sdk-page_techniques_combined-adaptive-compute-ambient-occlusion/ ; https://gpuopen.com/fidelityfx-cacao/ +- Unreal GTAO state: https://artiliada.github.io/2024/12/27/GTAO.html +- Noise: https://github.com/electronicarts/fastnoise (BSD-3) ; https://github.com/NVIDIAGameWorks/SpatiotemporalBlueNoiseSDK (`License.txt`: non-commercial) +- Arc 140V: https://chipsandcheese.com/p/lunar-lakes-igpu-debut-of-intels ; https://cputronic.com/gpu/intel-arc-140v +- Repository: `docs/research/xegtao-integration.md`, `docs/vulkan-branch-progress.md` sections 4-5, `docs/temporal-frame-contract.md`, + `sources/shaders/ssao.fsh`, `scene-ssao.fsh`, `final.fsh`, `taa-resolve.fsh`, `.vanilla/.../shaders/{ssao,chunkopaque,bilateralblur}.{vsh,fsh}`, + `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs`, `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs`. diff --git a/docs/research/xegtao-integration.md b/docs/research/xegtao-integration.md index f6006cf9..6a1cbc83 100644 --- a/docs/research/xegtao-integration.md +++ b/docs/research/xegtao-integration.md @@ -11,6 +11,9 @@ With TAA: NoiseIndex = frame % 64 and a single denoise pass. On GL 3.3, keep van ## 0. Status and algorithm choice (2026-09-15) +**Superseded as the design by `docs/research/ambient-occlusion.md`**, which researches every source below in depth +and combines them (section C); this section stays as the candidate record. + - **XeGTAO is archived.** The repository was archived on 2024-04-22; its last commits are "Archiving Notice" and a README update. It stays MIT and usable, but receives no fixes. https://github.com/GameTechDev/XeGTAO - **The algorithm is not superseded as a base, but it has a maintained successor:** GTAO with visibility bitmasks diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 4a8ee17a..f6e11cea 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -247,7 +247,7 @@ Windows run above. 7. **Caching follow-ups** (`docs/research/vulkan-caching.md`): `FAIL_ON_PIPELINE_COMPILE_REQUIRED` with background compiles, growth-triggered saves, pipeline-key log for pre-warming, optional `VK_KHR_pipeline_binary`; real-client warm-start check with the headless harness (needs game data). -8. **GTAO with visibility bitmasks** (XeGTAO-derived; XeGTAO itself is archived since 2024-04-22, see `docs/research/xegtao-integration.md` section 0; default AO on Vulkan while TAA is active): compute pass kind in the frame graph, GLSL compute +8. **GTAO with visibility bitmasks** (XeGTAO-derived; XeGTAO itself is archived since 2024-04-22, see `docs/research/xegtao-integration.md` section 0; the combined design is `docs/research/ambient-occlusion.md` section C; physically correct, default AO on Vulkan while TAA is active): compute pass kind in the frame graph, GLSL compute port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. 9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. From b4d2a6beb5f451ba7c62af2088ed8d23f6f826b9 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:40:57 +0200 Subject: [PATCH 154/226] wip(bindless): review - program record in the shared layout's set 2; no slot for an already released texture Merged feat/vulkan-taa first: the reviewed commit sat two commits behind it (3a18273, ed16996), which added SetConvention.ProgramRecordBinding and a two-dynamic-UBO floor. 1. SharedPipelineLayout set 2 lacked the program record, a dynamic uniform buffer at binding 3 (docs/vulkan-native-shaders.md section 3); a native shader reading it could not build a pipeline on the shared layout. Pinned by BindlessTextureTableTests.TheSharedLayoutDeclaresTheProgramRecordInSetTwo (failed before: 0 record bindings). 2. BindlessTextureTable.Resolve could allocate a slot for a texture whose Delete had already run Release on another thread (texture read outside the table lock). Nothing retired that slot, and its descriptor named a view the frame ring destroys. VulkanTexture.Released is now set in Delete under the upload lock before the hook, and Resolve checks it under the lock Release takes. Pinned by BindlessTextureTableTests.AResolveOfATextureAlreadyReleasedAllocatesNothing (failed before: 1 live slot). Verified: dotnet test Optimum.Render.Vulkan.Tests 717 passed, 0 failed, 0 skipped (implicit layers off, only VK_LAYER_MESA_device_select inserted; sync,best through GpuTest). dotnet test Optimum.Tests -c Release 1179 passed, 0 failed, 34 skipped (after building the Release VintagestoryLib in this worktree). --- .../BindlessTextureTableTests.cs | 71 ++++++++++++++++ .../Core/BindlessTextureTable.cs | 7 ++ .../Core/SharedPipelineLayout.cs | 85 ++++++++++++------- Optimum.Render.Vulkan/Core/TextureManager.cs | 17 ++++ Optimum.Render.Vulkan/VulkanDevice.cs | 3 + 5 files changed, 153 insertions(+), 30 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs index 3672b7b1..4b0ce284 100644 --- a/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs +++ b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs @@ -688,4 +688,75 @@ public void AWrongKindRequestResolvesToThePlaceholderSlot() GpuTest.AssertClean(device); } } + + /// + /// The shared layout declares every binding of the convention: set 2 carries the + /// program record (a dynamic uniform buffer at ) + /// beside the storage buffers, and the layout's dynamic uniform buffers are the + /// ones the device floor requires. A native shader reading the record would + /// otherwise not build a pipeline on the shared layout. + /// + [Fact] + public void TheSharedLayoutDeclaresTheProgramRecordInSetTwo() + { + DescriptorSetLayoutBinding[] storage = SharedPipelineLayout.StorageBindings(); + int records = 0; + foreach (DescriptorSetLayoutBinding binding in storage) + { + if (binding.Binding != (uint)SetConvention.ProgramRecordBinding) continue; + records++; + Assert.Equal(DescriptorType.UniformBufferDynamic, binding.DescriptorType); + Assert.Equal(1u, binding.DescriptorCount); + Assert.Equal(SharedPipelineLayout.Stages, binding.StageFlags); + } + Assert.Equal(1, records); + foreach (SetConvention.Binding buffer in SetConvention.StorageBuffers) + { + Assert.Contains(storage, b => b.Binding == (uint)buffer.Value && b.DescriptorType == DescriptorType.StorageBuffer); + } + + uint dynamicUniforms = 0; + foreach (DescriptorSetLayoutBinding binding in SharedPipelineLayout.FrameBindings()) + { + if (binding.DescriptorType == DescriptorType.UniformBufferDynamic) dynamicUniforms += binding.DescriptorCount; + } + foreach (DescriptorSetLayoutBinding binding in storage) + { + if (binding.DescriptorType == DescriptorType.UniformBufferDynamic) dynamicUniforms += binding.DescriptorCount; + } + Assert.Equal(DescriptorIndexingFloor.RequiredDynamicUniformBuffers, dynamicUniforms); + } + + /// + /// A lookup that took the texture before another thread deleted it and reaches + /// the table after the deletion released its slots must not allocate: nothing + /// would ever retire that slot, and its descriptor would name a view the frame + /// ring destroys. It resolves to the placeholder instead. + /// + [SkippableFact] + public void AResolveOfATextureAlreadyReleasedAllocatesNothing() + { + Skip.IfNot(TryCreateHarness(false, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); + using (compiler) + using (harness) + { + VulkanDevice device = harness!.Device; + BindlessTextureTable table = harness.Table; + int red = harness.Texture(Red); + VulkanTexture held = device.TexturesForTests.Get(red)!; + + device.DeleteTexture(red); + Assert.Equal(0, table.PendingRetirements); + + long before = table.PlaceholderResolutions; + Assert.Equal(0u, table.Resolve(held, TextureKind.Texture2D, SamplerState.Default)); + Assert.Equal(before + 1, table.PlaceholderResolutions); + Assert.Equal(0, table.LiveSlots(TextureKind.Texture2D)); + Assert.Equal(0, table.PendingWrites); + + device.BeginFrame(); + device.Present(); + GpuTest.AssertClean(device); + } + } } diff --git a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs index ba004a36..d52d6911 100644 --- a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs +++ b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs @@ -127,6 +127,13 @@ public uint Resolve(VulkanTexture? texture, TextureKind kind, SamplerState state SamplerState effective = BindlessKinds.EffectiveState(state, kind); lock (_lock) { + // Read under the lock Release takes: a delete that set the flag after this + // read blocks in Release until the slot below exists, and then retires it. + if (texture.Released) + { + NotePlaceholder(); + return 0; + } uint slot = _book.Acquire(new BindlessSlotKey(texture.Id, kind, effective, layout), out bool created); if (slot == 0) { diff --git a/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs index 9e72bbba..987ded64 100644 --- a/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs +++ b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs @@ -34,36 +34,8 @@ public SharedPipelineLayout(VulkanContext context, DescriptorSetLayout textureSe TextureSetLayout = textureSetLayout; Vk api = context.Api; - var frameBindings = new DescriptorSetLayoutBinding[1 + SetConvention.FrameTextures.Length]; - frameBindings[0] = new DescriptorSetLayoutBinding - { - Binding = (uint)SetConvention.FrameGlobalsBinding, - DescriptorType = DescriptorType.UniformBufferDynamic, - DescriptorCount = 1, - StageFlags = Stages, - }; - for (int i = 0; i < SetConvention.FrameTextures.Length; i++) - { - frameBindings[1 + i] = new DescriptorSetLayoutBinding - { - Binding = (uint)SetConvention.FrameTextures[i].Value, - DescriptorType = DescriptorType.CombinedImageSampler, - DescriptorCount = SetConvention.FrameTextures[i].Capacity, - StageFlags = Stages, - }; - } - - var storageBindings = new DescriptorSetLayoutBinding[SetConvention.StorageBuffers.Length]; - for (int i = 0; i < storageBindings.Length; i++) - { - storageBindings[i] = new DescriptorSetLayoutBinding - { - Binding = (uint)SetConvention.StorageBuffers[i].Value, - DescriptorType = DescriptorType.StorageBuffer, - DescriptorCount = SetConvention.StorageBuffers[i].Capacity, - StageFlags = Stages, - }; - } + DescriptorSetLayoutBinding[] frameBindings = FrameBindings(); + DescriptorSetLayoutBinding[] storageBindings = StorageBindings(); FrameSetLayout = CreateSetLayout(frameBindings, "set 0 (frame)"); try @@ -105,6 +77,59 @@ public SharedPipelineLayout(VulkanContext context, DescriptorSetLayout textureSe Layout = layout; } + /// Set 0: the FrameGlobals dynamic UBO and the fixed frame textures. + internal static DescriptorSetLayoutBinding[] FrameBindings() + { + var bindings = new DescriptorSetLayoutBinding[1 + SetConvention.FrameTextures.Length]; + bindings[0] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.FrameGlobalsBinding, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = Stages, + }; + for (int i = 0; i < SetConvention.FrameTextures.Length; i++) + { + bindings[1 + i] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.FrameTextures[i].Value, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = SetConvention.FrameTextures[i].Capacity, + StageFlags = Stages, + }; + } + return bindings; + } + + /// + /// Set 2: the storage buffers and the program record, a dynamic uniform buffer + /// (docs/vulkan-native-shaders.md section 3). Set 2 is a normal set, so the + /// dynamic buffer is legal beside the update-after-bind set 1; the device floor + /// counts it (). + /// + internal static DescriptorSetLayoutBinding[] StorageBindings() + { + var bindings = new DescriptorSetLayoutBinding[SetConvention.StorageBuffers.Length + 1]; + for (int i = 0; i < SetConvention.StorageBuffers.Length; i++) + { + bindings[i] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.StorageBuffers[i].Value, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = SetConvention.StorageBuffers[i].Capacity, + StageFlags = Stages, + }; + } + bindings[^1] = new DescriptorSetLayoutBinding + { + Binding = (uint)SetConvention.ProgramRecordBinding, + DescriptorType = DescriptorType.UniformBufferDynamic, + DescriptorCount = 1, + StageFlags = Stages, + }; + return bindings; + } + private DescriptorSetLayout CreateSetLayout(DescriptorSetLayoutBinding[] bindings, string what) { fixed (DescriptorSetLayoutBinding* bindingsPtr = bindings) diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index ff7d92d1..5ea77217 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -68,6 +68,18 @@ internal sealed unsafe class VulkanTexture : IDisposable /// Never reused, unlike ; see . public ulong Id { get; } = ResourceIds.Next(); + private volatile bool _released; + + /// + /// Set by under the upload lock, before the + /// texture's bindless slots are released; a released texture gets no new slot. + /// + public bool Released + { + get => _released; + internal set => _released = value; + } + public Format Format { get; init; } /// @@ -756,6 +768,11 @@ public void Delete(int textureId, FrameRing? ring = null) _textures[textureId] = null; _freeIds.Push(textureId); + // Marked before the hook: a lookup that took the texture before this + // delete and reaches the bindless table after Release must not allocate + // a slot nothing would ever retire. + texture.Released = true; + // Before the texture is retired, under the same timeline values: its // bindless slots then outlive every frame that could sample them. Deleted?.Invoke(texture); diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index ba84bc96..ebe258be 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -905,6 +905,9 @@ internal bool DeviceLocalStaticMeshesForTests /// The context (and its allocator). Tests only. internal VulkanContext ContextForTests => _context; + /// The texture manager. Tests only. + internal TextureManager TexturesForTests => _textures; + /// Where per-second backend counters go, when asked for. private static readonly string? StatsLogPath = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_STATS"); From 2e959d04c6082b23d11bcca4dd8179169e27fd1e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:42:50 +0200 Subject: [PATCH 155/226] wip(pipeline-cache): review - queued prewarm no longer skips warm draws, deleted programs' finished compiles destroyed, captures force blocking pipelines Review of the pipeline cache follow-ups (merge 34a9e46) found three defects, each pinned by a test that failed before the fix: - TryGet skipped every draw whose pipeline had a prewarm job still queued, even when the driver cache could serve it at once, so on a warm start or unchanged-source reload the prewarm cost frames the plain path would not. A queued prewarm now gets the FAIL_ON attempt on the lookup; on success the job is dropped (or cancelled if a worker took it). Test: PipelineCacheTests.AQueuedPrewarmDoesNotSkipADrawTheDriverCacheCanServe - CancelProgram missed jobs finished but not yet published; the next frame start parked their pipeline in _prewarmed for a deleted program (leaked until device disposal). All tracked jobs of the program are cancelled now; Forget only removes entries that still belong to the job. Test: PipelineCacheTests.AFinishedCompileOfADeletedProgramIsDestroyedNotPublished - OPTIMUM_PARITY_DUMP and OPTIMUM_HEADLESS_FRAMES set without the capture scripts left background compiles on, so a dumped frame (default frame 0) lost draws. Both now force blocking creation. Test: PipelineCacheTests.AFrameCaptureForcesBlockingPipelinesWithoutTheScripts Verified: Optimum.Render.Vulkan.Tests 695/695 with implicit layers disabled (only MESA_device_select inserted), no SYNC- message outside the deliberate SyncValidationControlTests control; Optimum.Tests -c Release 1179 passed, 34 skipped, 0 failed. --- .../PipelineCacheTests.cs | 144 ++++++++++++++++++ Optimum.Render.Vulkan/Core/PipelineCache.cs | 100 +++++++++++- Optimum.Render.Vulkan/VulkanDevice.cs | 20 ++- 3 files changed, 257 insertions(+), 7 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs index 4aec3d3a..c2f47496 100644 --- a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -359,6 +359,23 @@ public void SynchronousPipelinesComeFromTheSettingOrTheEnvironment() Assert.False(VulkanDevice.ResolveSynchronousPipelines(false, "1")); } + /// + /// A frame written to disk must hold every draw, and OPTIMUM_PARITY_DUMP and + /// OPTIMUM_HEADLESS_FRAMES are documented as standalone switches: set without the capture + /// scripts (which also export OPTIMUM_VULKAN_SYNC_PIPELINES), they still force blocking + /// creation. Explicit settings keep the last word. + /// + [Fact] + public void AFrameCaptureForcesBlockingPipelinesWithoutTheScripts() + { + Assert.True(VulkanDevice.ResolveSynchronousPipelines(null, null, parityDump: "/tmp/dump", headlessFrames: null)); + Assert.True(VulkanDevice.ResolveSynchronousPipelines(null, null, parityDump: null, headlessFrames: "/tmp/frames")); + Assert.True(VulkanDevice.ResolveSynchronousPipelines(null, "0", parityDump: "/tmp/dump", headlessFrames: null)); + // The capture code ignores a relative or blank directory, so the pipelines do too. + Assert.False(VulkanDevice.ResolveSynchronousPipelines(null, null, parityDump: "relative", headlessFrames: " ")); + Assert.False(VulkanDevice.ResolveSynchronousPipelines(false, null, parityDump: "/tmp/dump", headlessFrames: "/tmp/frames")); + } + /// /// The opportunistic save on a real driver: once the cache has grown past the threshold, /// a due sample writes the file from a worker, and the file seeds a new cache. @@ -682,4 +699,131 @@ public void APrewarmFromTheKeyLogServesTheFirstUseWithoutACompile() } } } + + /// + /// One blocking session under that draws + /// once, so the root then holds a key log entry and a driver cache that contain that + /// pipeline. False when there is no device with pipelineCreationCacheControl. + /// + private bool RecordSession(string root, string fragment, byte[] colour, int size) + { + using VulkanDevice? first = OpenDevice(synchronousPipelines: true, cacheRoot: root); + if (first == null || !first.ContextForTests.Capabilities.PipelineCreationCacheControl) return false; + int program = VulkanDeviceIntegrationTests.LinkProgram(first, FullscreenVertex, fragment); + int framebuffer = ColourTarget(first, size); + BeginDraw(first, framebuffer, size, program); + first.DrawFullscreenTriangle(); + first.Present(); + Assert.Equal(colour, ReadCentre(first, framebuffer, size)); + GpuTest.AssertClean(first); + return true; + } + + private static void DeleteRoot(string root) + { + try + { + System.IO.Directory.Delete(root, recursive: true); + } + catch (System.IO.DirectoryNotFoundException) + { + } + } + + /// + /// A prewarm job still waiting in its queue must not cost a draw the driver cache can serve + /// on the spot: on a warm start (or a shader reload with unchanged sources) every program + /// queues its prewarm when it links, and the workers reach the last of them seconds later. + /// The lookup tries the driver cache itself, takes the pipeline, and the queued job is + /// dropped rather than built a second time. + /// + [SkippableFact] + public void AQueuedPrewarmDoesNotSkipADrawTheDriverCacheCanServe() + { + string root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "optimum-pipeline-queued-prewarm-" + Guid.NewGuid().ToString("N")); + const int size = 8; + byte[] colour = UniqueColour(); + string fragment = SolidFragment(colour); + try + { + Skip.IfNot(RecordSession(root, fragment, colour, size), "No device with pipelineCreationCacheControl."); + + using VulkanDevice? second = OpenDevice(synchronousPipelines: false, cacheRoot: root); + Skip.If(second == null, "No usable Vulkan device."); + GraphicsPipelineCache pipelines = second!.PipelinesForTests; + Skip.IfNot(pipelines.SeedAccepted, "The driver rejected its own saved cache."); + pipelines.HoldBackgroundCompilesForTests = true; + + int program = VulkanDeviceIntegrationTests.LinkProgram(second, FullscreenVertex, fragment); + Assert.Equal(1, pipelines.PendingCompiles); + + int target = ColourTarget(second, size); + BeginDraw(second, target, size, program); + second.DrawFullscreenTriangle(); + second.Present(); + + Assert.Equal(colour, ReadCentre(second, target, size)); + Assert.Equal(0, pipelines.DrawsSkipped); + Assert.Equal(0, pipelines.CompiledSync); + Assert.Equal(1, pipelines.Warm); + + pipelines.HoldBackgroundCompilesForTests = false; + Assert.True(pipelines.WaitForBackgroundCompiles(TimeSpan.FromSeconds(60)), "the workers did not drain"); + BeginDraw(second, target, size, program); + second.DrawFullscreenTriangle(); + second.Present(); + + Assert.Equal(0, pipelines.PendingCompiles); + Assert.Equal(0, pipelines.PrewarmedWaiting); + Assert.Equal(0, pipelines.Prewarmed); + Assert.Equal(1, pipelines.Count); + GpuTest.AssertClean(second); + } + finally + { + DeleteRoot(root); + } + } + + /// + /// A compile that finished but was not yet published when its program was deleted (the + /// window between the worker's completion and the next frame start) belongs to the + /// deleted program like a queued or running one: it is destroyed at publication, never + /// parked as a prewarmed pipeline no lookup can claim. + /// + [SkippableFact] + public void AFinishedCompileOfADeletedProgramIsDestroyedNotPublished() + { + string root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "optimum-pipeline-deleted-program-" + Guid.NewGuid().ToString("N")); + const int size = 8; + byte[] colour = UniqueColour(); + string fragment = SolidFragment(colour); + try + { + Skip.IfNot(RecordSession(root, fragment, colour, size), "No device with pipelineCreationCacheControl."); + + using VulkanDevice? second = OpenDevice(synchronousPipelines: false, cacheRoot: root); + Skip.If(second == null, "No usable Vulkan device."); + GraphicsPipelineCache pipelines = second!.PipelinesForTests; + + int program = VulkanDeviceIntegrationTests.LinkProgram(second, FullscreenVertex, fragment); + Assert.Equal(1, pipelines.PendingCompiles); + Assert.True(pipelines.WaitForBackgroundCompiles(TimeSpan.FromSeconds(60)), "the prewarm did not finish"); + + second.DeleteProgram(program); + int target = ColourTarget(second, size); + second.BeginFrame(); + second.BindFramebuffer(target); + second.ClearColor(0, 0f, 0f, 0f, 1f); + second.Present(); + + Assert.Equal(0, pipelines.PendingCompiles); + Assert.Equal(0, pipelines.PrewarmedWaiting); + GpuTest.AssertClean(second); + } + finally + { + DeleteRoot(root); + } + } } diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index a79bb7e1..0f8b32ba 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -253,6 +253,13 @@ public bool TryGet(PipelineKey key, PipelineRequest request, out Pipeline pipeli // The same pipeline under another key, or a prewarm of it, is already on its way. if (_pendingJobs.TryGetValue(id, out CompileJob? pending)) { + // A prewarm no worker has reached yet never had a warm attempt: on a warm start + // the driver cache serves it here, and waiting for the queue would skip the draw. + if (TryTakeWarmFromQueuedPrewarm(pending, request, out pipeline)) + { + Store(key, pipeline, entry); + return true; + } pending.DemandKeys.Add(key); _pendingByKey[key] = pending; Promote(pending); @@ -303,6 +310,51 @@ public bool TryGet(PipelineKey key, PipelineRequest request, out Pipeline pipeli return false; } + /// + /// The FAIL_ON_PIPELINE_COMPILE_REQUIRED attempt for a lookup whose pipeline is only + /// queued as a prewarm. On success the prewarm is dropped: removed from its queue, or, + /// when a worker took it meanwhile, cancelled so its result is destroyed at publication. + /// A prewarm already promoted by an earlier lookup had its attempt then and gets none. + /// + private bool TryTakeWarmFromQueuedPrewarm(CompileJob job, PipelineRequest request, out Pipeline pipeline) + { + pipeline = default; + lock (_queueLock) + { + if (!job.Prewarm || !job.Queued) return false; + } + + Result result; + lock (_driverCacheLock) + { + result = CreatePipeline(request, _driverCache, PipelineCreateFlags.CreateFailOnPipelineCompileRequiredBit, + out pipeline); + } + if (result == Result.PipelineCompileRequired) return false; + if (result != Result.Success) + { + throw new InvalidOperationException("vkCreateGraphicsPipelines failed: " + result); + } + + Interlocked.Increment(ref _warm); + VulkanStats.NotePipelineWarm(); + lock (_queueLock) + { + if (job.Queued) + { + job.Queued = false; + if (!_prewarmQueue.Remove(job)) _demandQueue.Remove(job); + } + else + { + job.Cancelled = true; + } + Forget(job); + } + VulkanStats.NotePipelinesPending(_pendingJobs.Count); + return true; + } + private void NoteSkipped() { Interlocked.Increment(ref _drawsSkipped); @@ -386,6 +438,23 @@ public CompileJob((int ProgramId, UInt128 ContentId) id, PipelineRequest request private Thread[]? _workers; private bool _stopping; private bool _workersStopped; + private bool _holdForTests; + + /// + /// Tests only: while true the workers take no new job, so a test can observe a lookup + /// against a job that is still queued. Setting it false wakes them. + /// + internal bool HoldBackgroundCompilesForTests + { + set + { + lock (_queueLock) + { + _holdForTests = value; + Monitor.PulseAll(_queueLock); + } + } + } /// Render thread: every queued or running job by pipeline identity, and by the keys waiting on it. private readonly Dictionary<(int ProgramId, UInt128 ContentId), CompileJob> _pendingJobs = new(); @@ -458,7 +527,10 @@ private void WorkerLoop() CompileJob job; lock (_queueLock) { - while (!_stopping && _demandQueue.Count == 0 && _prewarmQueue.Count == 0) Monitor.Wait(_queueLock); + while (!_stopping && (_holdForTests || (_demandQueue.Count == 0 && _prewarmQueue.Count == 0))) + { + Monitor.Wait(_queueLock); + } if (_stopping) return; LinkedList queue = _demandQueue.Count > 0 ? _demandQueue : _prewarmQueue; job = queue.First!.Value; @@ -553,8 +625,9 @@ public int PublishCompleted() int published = 0; while (_completed.TryDequeue(out CompileJob? job)) { - _pendingJobs.Remove(job.Id); - foreach (PipelineKey key in job.DemandKeys) _pendingByKey.Remove(key); + // A job dropped early (Forget) may have been replaced under its id or keys by a + // newer one; only this job's own entries go. + Forget(job); if (job.Status != Result.Success || job.Pipeline.Handle == 0) { @@ -618,6 +691,14 @@ public int PrewarmFor(ShaderProgramResources program) /// public void CancelProgram(ShaderProgramResources program) { + // Every job of the program the render thread still tracks, including one the worker + // finished that waits in the completed queue for the next frame start: publishing + // that one would park a pipeline of a deleted program where no lookup can claim it. + foreach (CompileJob job in _pendingJobs.Values) + { + if (ReferenceEquals(job.Request.Program, program)) job.Cancelled = true; + } + lock (_queueLock) { foreach (LinkedList queue in new[] { _demandQueue, _prewarmQueue }) @@ -665,8 +746,17 @@ public void CancelProgram(ShaderProgramResources program) private void Forget(CompileJob job) { - _pendingJobs.Remove(job.Id); - foreach (PipelineKey key in job.DemandKeys) _pendingByKey.Remove(key); + if (_pendingJobs.TryGetValue(job.Id, out CompileJob? current) && ReferenceEquals(current, job)) + { + _pendingJobs.Remove(job.Id); + } + foreach (PipelineKey key in job.DemandKeys) + { + if (_pendingByKey.TryGetValue(key, out CompileJob? waiting) && ReferenceEquals(waiting, job)) + { + _pendingByKey.Remove(key); + } + } } /// Waits until no compile is queued or running. Tests only; false on timeout. diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 2d10d44c..3b9f3f77 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -68,6 +68,20 @@ public sealed unsafe class VulkanDevice : IDisposable internal static bool ResolveSynchronousPipelines(bool? configured, string? environment) => configured ?? environment?.Trim() is "1" or "on" or "true"; + /// + /// The same, where a frame capture also forces blocking creation: OPTIMUM_PARITY_DUMP and + /// OPTIMUM_HEADLESS_FRAMES write exact frames to disk, and a background compile would leave + /// draws out of them. Each counts when it names an absolute directory, which is when the + /// capture code acts on it. An explicit still wins. + /// + internal static bool ResolveSynchronousPipelines(bool? configured, string? environment, string? parityDump, + string? headlessFrames) => + configured ?? (ResolveSynchronousPipelines(null, environment) || NamesCaptureDirectory(parityDump) || + NamesCaptureDirectory(headlessFrames)); + + private static bool NamesCaptureDirectory(string? value) => + !string.IsNullOrWhiteSpace(value) && System.IO.Path.IsPathRooted(value); + /// The pipeline cache. Tests only. internal GraphicsPipelineCache PipelinesForTests => _pipelines; @@ -458,7 +472,9 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // Background compiles (docs/research/vulkan-caching.md, design item 4): a draw whose // pipeline is not in the driver cache is skipped while a worker compiles it. bool synchronousPipelines = ResolveSynchronousPipelines(SynchronousPipelines, - Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_SYNC_PIPELINES")); + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_SYNC_PIPELINES"), + Environment.GetEnvironmentVariable("OPTIMUM_PARITY_DUMP"), + Environment.GetEnvironmentVariable("OPTIMUM_HEADLESS_FRAMES")); _pipelines.AsyncCompiles = !synchronousPipelines; _pipelines.KeyLog = _pipelinePersistence?.KeyLog; _descriptors = new DescriptorCache(_context); @@ -486,7 +502,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; pipeline key log " + (_pipelinePersistence?.KeyLog.Count ?? 0) + " entries"); MirrorValidationMessage("--- pipelines " + (_pipelines.AsyncCompiles ? "compile in the background" - : synchronousPipelines ? "compile blocking (OPTIMUM_VULKAN_SYNC_PIPELINES)" : "compile blocking (no pipelineCreationCacheControl)")); + : synchronousPipelines ? "compile blocking (OPTIMUM_VULKAN_SYNC_PIPELINES, a frame capture or the device setting)" : "compile blocking (no pipelineCreationCacheControl)")); CreateDefaultAttributeBuffer(); CreatePlaceholderTexture(); CreatePlaceholderUniformBuffer(); From 72761310b61b8a1f343a06a5db47dc66183c9e49 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:49:07 +0200 Subject: [PATCH 156/226] wip(native-shaders): shared native includes, frame.glsl generator, specialization constants, motion.glsl - frame.glsl generated by FrameGlobals.GenerateInclude: the FrameGlobals UBO at set 0 binding 0, scalar layout with explicit offsets, instance optimumFrame, and per-owner name groups activated by OPTIMUM_FRAME_OWNER_ macros. - specialization.glsl plus Shaders/SpecializationConvention.cs: ids 0-11, including OPTIMUM_DYNLIGHTS, which still selects fogandlight's no-point-light path. - All 17 game includes ported to GLSL 450 with machine-read port headers; 5 transformed (#if on quality defines turned into constant branches), 12 verbatim. Also varyings.glsl (include varying locations 16-25) and motion.glsl. - ShaderCompiler.CompileNative resolves quoted includes through shaderc callbacks. - Contract updated: sections 1, 3, 4 (anonymous push/record blocks verified), 5 and 7. Verified: Optimum.Render.Vulkan.Tests 761/761 passed, 0 skipped, with sync,best validation and implicit layers disabled. This covers the 95 tests in FrameGlobals, SpecializationConvention, NativeShaderInclude, TaaMotionInclude and SetConvention: probes compile every include with owner names active and inactive; SPIR-V frame offsets and spec ids match the C# tables; the motion.glsl GPU readback covers the valid, behind-camera and reactive-only cases. Optimum.Tests (Release): 1177 passed, 34 skipped, 0 failed. --- .../FrameGlobalsTests.cs | 63 + .../NativeShaderIncludeTests.cs | 330 ++++++ .../NativeShaderTree.cs | 139 +++ .../SpecializationConventionTests.cs | 130 +++ Optimum.Render.Vulkan.Tests/SpirvReader.cs | 171 +++ .../TaaMotionIncludeTests.cs | 334 ++++++ Optimum.Render.Vulkan/Shaders/FrameGlobals.cs | 98 ++ .../Shaders/ShaderCompiler.Includes.cs | 134 +++ .../Shaders/ShaderCompiler.cs | 2 +- .../Shaders/SpecializationConvention.cs | 46 + docs/vulkan-native-shaders.md | 100 +- sources/shaders-vk/include/colormap.frag.glsl | 66 ++ sources/shaders-vk/include/colormap.vert.glsl | 101 ++ sources/shaders-vk/include/colorutil.glsl | 116 ++ sources/shaders-vk/include/dither.glsl | 39 + .../shaders-vk/include/fogandlight.frag.glsl | 360 ++++++ .../shaders-vk/include/fogandlight.vert.glsl | 200 ++++ sources/shaders-vk/include/fogspheres.glsl | 138 +++ sources/shaders-vk/include/frame.glsl | 157 +++ sources/shaders-vk/include/fxaa.glsl | 1010 +++++++++++++++++ sources/shaders-vk/include/motion.glsl | 48 + sources/shaders-vk/include/noise2d.glsl | 99 ++ sources/shaders-vk/include/noise3d.glsl | 212 ++++ sources/shaders-vk/include/normalshading.glsl | 30 + sources/shaders-vk/include/oit.glsl | 73 ++ sources/shaders-vk/include/shadowcoords.glsl | 68 ++ sources/shaders-vk/include/skycolor.glsl | 185 +++ .../shaders-vk/include/specialization.glsl | 33 + .../shaders-vk/include/underwatereffects.glsl | 57 + sources/shaders-vk/include/varyings.glsl | 36 + .../shaders-vk/include/vertexflagbits.glsl | 141 +++ sources/shaders-vk/include/vertexwarp.glsl | 303 +++++ 32 files changed, 5011 insertions(+), 8 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeShaderIncludeTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/NativeShaderTree.cs create mode 100644 Optimum.Render.Vulkan.Tests/SpecializationConventionTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SpirvReader.cs create mode 100644 Optimum.Render.Vulkan.Tests/TaaMotionIncludeTests.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ShaderCompiler.Includes.cs create mode 100644 Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs create mode 100644 sources/shaders-vk/include/colormap.frag.glsl create mode 100644 sources/shaders-vk/include/colormap.vert.glsl create mode 100644 sources/shaders-vk/include/colorutil.glsl create mode 100644 sources/shaders-vk/include/dither.glsl create mode 100644 sources/shaders-vk/include/fogandlight.frag.glsl create mode 100644 sources/shaders-vk/include/fogandlight.vert.glsl create mode 100644 sources/shaders-vk/include/fogspheres.glsl create mode 100644 sources/shaders-vk/include/frame.glsl create mode 100644 sources/shaders-vk/include/fxaa.glsl create mode 100644 sources/shaders-vk/include/motion.glsl create mode 100644 sources/shaders-vk/include/noise2d.glsl create mode 100644 sources/shaders-vk/include/noise3d.glsl create mode 100644 sources/shaders-vk/include/normalshading.glsl create mode 100644 sources/shaders-vk/include/oit.glsl create mode 100644 sources/shaders-vk/include/shadowcoords.glsl create mode 100644 sources/shaders-vk/include/skycolor.glsl create mode 100644 sources/shaders-vk/include/specialization.glsl create mode 100644 sources/shaders-vk/include/underwatereffects.glsl create mode 100644 sources/shaders-vk/include/varyings.glsl create mode 100644 sources/shaders-vk/include/vertexflagbits.glsl create mode 100644 sources/shaders-vk/include/vertexwarp.glsl diff --git a/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs index 3ebcac5e..7560cf6f 100644 --- a/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs @@ -195,6 +195,69 @@ public void UseWritesEveryMemberInsideItsOwnersBlock() } } + /// + /// sources/shaders-vk/include/frame.glsl is generated from the table. Set + /// OPTIMUM_REGENERATE_NATIVE_INCLUDES=1 to rewrite it after changing the table; without it + /// any difference fails, so a table change cannot ship with a stale native block. + /// + [Fact] + public void TheCommittedNativeIncludeIsWhatTheTableGenerates() + { + string path = Path.Combine(ShaderCorpus.RepositoryRoot, FrameGlobals.IncludePath); + string generated = FrameGlobals.GenerateInclude(); + if (Environment.GetEnvironmentVariable("OPTIMUM_REGENERATE_NATIVE_INCLUDES") == "1") + { + File.WriteAllText(path, generated); + } + + Assert.True(File.Exists(path), path + " is missing; run with OPTIMUM_REGENERATE_NATIVE_INCLUDES=1"); + Assert.Equal(generated, File.ReadAllText(path).Replace("\r\n", "\n")); + } + + /// + /// The compiled block puts every member at the offset the renderer writes: the SPIR-V + /// Offset decorations of the block at set 0, binding 0 are the table's offsets, member + /// by member, and the arrays stride by their element size (scalar layout, no std140 padding). + /// + [SkippableFact] + public void TheCompiledNativeBlockHasTheTablesOffsets() + { + Skip.IfNot(NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason), reason); + const string probe = """ + #version 450 + #include "frame.glsl" + layout(location = 0) out vec4 outColor; + void main() + { + outColor = vec4(optimumFrame.zNear, optimumFrame.pointLights[99].x, 0.0, 1.0); + } + """; + + using (compiler) + { + ShaderCompileResult result = NativeShaderTree.Compile(compiler!, probe, EnumShaderType.FragmentShader, "frame-offsets-probe"); + Assert.True(result.Success, result.Error); + + SpirvReader spirv = SpirvReader.Parse(result.Spirv); + uint? block = spirv.BlockAt((uint)FrameGlobals.Set, (uint)FrameGlobals.Binding); + Assert.True(block.HasValue, "no block at set 0, binding 0"); + Assert.Equal(FrameGlobals.Members.Count, spirv.MemberCount(block!.Value)); + + for (int i = 0; i < FrameGlobals.Members.Count; i++) + { + UniformMember member = FrameGlobals.Members[i]; + Assert.True(member.Offset == spirv.MemberOffset(block.Value, i), + $"{member.Name}: table offset {member.Offset}, SPIR-V offset {spirv.MemberOffset(block.Value, i)}"); + string? name = spirv.MemberName(block.Value, i); + if (name != null) Assert.Equal(member.Name, name); + if (member.ArrayLength > 0) + { + Assert.Equal((uint?)member.Type.Size, spirv.ArrayStride(block.Value, i)); + } + } + } + } + private static float ReadFloat(byte[] shadow, string name) { Assert.True(FrameGlobals.TryGetMember(name, out UniformMember member)); diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderIncludeTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderIncludeTests.cs new file mode 100644 index 00000000..4dba7023 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderIncludeTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The shared native includes (sources/shaders-vk/include, docs/vulkan-native-shaders.md +/// sections 1, 3 and 4): each compiles inside a probe program the way a family program will +/// include it, each port accounts for every uniform its game file declared, a verbatim port keeps +/// the game file's code token for token, a transformed one keeps every function signature, and +/// the push block and record forms of section 4 compile with their members as global names. +/// +public class NativeShaderIncludeTests +{ + public static IEnumerable ProbeCases() + { + foreach (string include in NativeShaderTree.IncludeNames()) + { + foreach (EnumShaderType stage in NativeShaderTree.StagesOf(include)) + { + yield return new object[] { include, stage, true }; + yield return new object[] { include, stage, false }; + } + } + } + + /// + /// A probe includes bindings.glsl, frame.glsl and specialization.glsl, declares a record with + /// every name the include (and what it includes) leaves to the program, then includes it. + /// + /// With owner names active the probe defines every frame owner macro, as a program whose + /// stages include every owner would, so every frame member comes from the block. With them + /// inactive only the includes themselves activate owners, and every other frame-ownable name + /// is a record member, as in a program that includes the fragment half of fog and light alone. + /// + [SkippableTheory] + [MemberData(nameof(ProbeCases))] + public void TheIncludeCompilesInsideAProbeProgram(string include, EnumShaderType stage, bool ownerNamesActive) + { + Skip.IfNot(NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason), reason); + + string probe = BuildProbe(include, stage, ownerNamesActive); + using (compiler) + { + ShaderCompileResult result = NativeShaderTree.Compile(compiler!, probe, stage, + "probe-" + include.Replace('.', '-') + (ownerNamesActive ? "-owners" : "-alone")); + Assert.True(result.Success, include + " (" + stage + ", owner names " + + (ownerNamesActive ? "active" : "inactive") + "):\n" + result.Error + "\n--- probe ---\n" + probe); + Assert.NotEmpty(result.Spirv); + } + } + + internal static string BuildProbe(string include, EnumShaderType stage, bool ownerNamesActive) + { + List closure = NativeShaderTree.Closure(include); + var ports = closure.Select(NativeShaderTree.PortOf).Where(port => port != null).Select(port => port!).ToList(); + var ownersInClosure = ports.Select(port => port.FrameOwner).Where(owner => owner != null).ToHashSet(StringComparer.Ordinal); + + var probe = new StringBuilder(); + probe.Append("#version 450\n#extension GL_EXT_scalar_block_layout : require\n"); + if (ownerNamesActive) + { + foreach (string owner in FrameGlobals.Owners) probe.Append("#define ").Append(FrameGlobals.OwnerMacro(owner)).Append('\n'); + } + // Variant axes a program always stamps; the probe takes the branch that declares the most. + probe.Append("#define USEOIT 1\n"); + probe.Append("#include \"bindings.glsl\"\n#include \"frame.glsl\"\n#include \"specialization.glsl\"\n"); + + var record = new List(); + var symbols = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (NativeShaderTree.Port port in ports) + { + foreach (NativeShaderTree.Declaration uniform in port.ProgramUniforms) + { + if (!seen.Add(uniform.Name)) continue; + string? owner = FrameGlobals.OwnerOf(uniform.Name); + bool fromFrame = owner != null && (ownerNamesActive || ownersInClosure.Contains(owner)); + if (!fromFrame) record.Add(" " + uniform.Text + ";\n"); + } + foreach (NativeShaderTree.Declaration symbol in port.ProgramSymbols) + { + if (seen.Add(symbol.Name)) symbols.Add(symbol.Text + ";\n"); + } + } + if (record.Count > 0) + { + probe.Append("layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram\n{\n"); + foreach (string member in record) probe.Append(member); + probe.Append("};\n"); + } + foreach (string symbol in symbols) probe.Append(symbol); + + probe.Append("#include \"").Append(include).Append("\"\n"); + probe.Append(stage == EnumShaderType.VertexShader + ? "void main()\n{\n gl_Position = vec4(0.0);\n}\n" + : "void main()\n{\n}\n"); + return probe.ToString(); + } + + /// + /// Every uniform the game file declares is accounted for by the port: a frame member the file + /// owns, a frame texture from bindings.glsl, or a program uniform in the header - with the + /// game file's type. A game update that adds a uniform fails here. + /// + [SkippableFact] + public void EveryPortAccountsForEveryUniformItsGameFileDeclares() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + Dictionary includes = ShaderCorpus.LoadIncludes(); + var uniform = new Regex(@"^\s*uniform\s+(\w+)\s+(\w+)", RegexOptions.Multiline); + + var ported = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string include in NativeShaderTree.IncludeNames()) + { + NativeShaderTree.Port? port = NativeShaderTree.PortOf(include); + if (port == null) continue; + Assert.True(ported.Add(port.GameFile), port.GameFile + " is ported twice"); + Assert.True(includes.TryGetValue(port.GameFile, out string? source), port.GameFile + " is not a game include"); + + var declared = uniform.Matches(source!).Select(m => m.Groups[1].Value + " " + m.Groups[2].Value) + .OrderBy(s => s, StringComparer.Ordinal).ToList(); + + var accounted = new List(); + foreach (UniformMember member in FrameGlobals.Members) + { + if (FrameGlobals.OwnerOf(member.Name) == port.GameFile && declared.Contains(member.Type.Name + " " + member.Name)) + { + accounted.Add(member.Type.Name + " " + member.Name); + } + } + accounted.AddRange(port.FrameTextures.Select(d => d.Type + " " + d.Name)); + accounted.AddRange(port.ProgramUniforms.Select(d => d.Type + " " + d.Name)); + accounted.Sort(StringComparer.Ordinal); + + Assert.True(declared.SequenceEqual(accounted), + $"{include}: game file declares [{string.Join(", ", declared)}], port accounts for [{string.Join(", ", accounted)}]"); + + foreach (NativeShaderTree.Declaration texture in port.FrameTextures) + { + Assert.Contains(SetConvention.FrameTextures, binding => binding.Name == texture.Name && binding.GlslType == texture.Type); + } + foreach (NativeShaderTree.Declaration programUniform in port.ProgramUniforms) + { + Assert.False(FrameGlobals.OwnerOf(programUniform.Name) == port.GameFile, + include + " lists " + programUniform.Name + " as a program uniform but owns it"); + } + } + + var expectedPorts = includes.Keys + .Where(name => name is not ("default.fsh" or "printvalues.fsh")) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + Assert.True(expectedPorts.SetEquals(ported), + "ported [" + string.Join(", ", ported.OrderBy(s => s)) + "], game includes [" + string.Join(", ", expectedPorts.OrderBy(s => s)) + "]"); + } + + /// + /// Every frame owner has exactly one native include, the port of the owner file, and that + /// include activates its names: it defines the owner macro and includes frame.glsl. + /// + [Fact] + public void EveryFrameOwnerIsActivatedByItsPortAndNoOther() + { + foreach (string owner in FrameGlobals.Owners) + { + var claiming = NativeShaderTree.IncludeNames() + .Where(include => NativeShaderTree.PortOf(include)?.FrameOwner == owner) + .ToList(); + Assert.True(claiming.Count == 1, owner + " is claimed by [" + string.Join(", ", claiming) + "]"); + + NativeShaderTree.Port port = NativeShaderTree.PortOf(claiming[0])!; + Assert.Equal(owner, port.GameFile); + string text = NativeShaderTree.Read(claiming[0]); + Assert.Contains("#define " + FrameGlobals.OwnerMacro(owner) + "\n#include \"frame.glsl\"", text); + + foreach (string other in NativeShaderTree.IncludeNames().Where(include => include != claiming[0])) + { + Assert.DoesNotContain("#define " + FrameGlobals.OwnerMacro(owner), NativeShaderTree.Read(other)); + } + } + } + + /// + /// A verbatim port is the game file's code, token for token, once comments, preprocessor lines + /// and interface declarations (uniform, in, out, with or without a layout) are set aside - the + /// only things a verbatim port may change. + /// + [SkippableFact] + public void VerbatimPortsKeepTheGameFilesCodeTokenForToken() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + Dictionary includes = ShaderCorpus.LoadIncludes(); + + int checkedPorts = 0; + foreach (string include in NativeShaderTree.IncludeNames()) + { + NativeShaderTree.Port? port = NativeShaderTree.PortOf(include); + if (port == null || port.Kind != "verbatim") continue; + checkedPorts++; + + List game = CodeTokens(includes[port.GameFile]); + List native = CodeTokens(NativeShaderTree.Read(include)); + int firstDifference = Enumerable.Range(0, Math.Min(game.Count, native.Count)) + .FirstOrDefault(i => game[i] != native[i], Math.Min(game.Count, native.Count)); + Assert.True(game.SequenceEqual(native), + $"{include} differs from {port.GameFile} at token {firstDifference}: game '{string.Join(" ", game.Skip(firstDifference).Take(12))}', port '{string.Join(" ", native.Skip(firstDifference).Take(12))}'"); + } + Assert.True(checkedPorts >= 10, "only " + checkedPorts + " verbatim ports found"); + } + + /// + /// A transformed port rewrote preprocessor branches into specialization-constant branches; it + /// keeps every function the game file defines, with the same return type, name and parameters, + /// in the same order. + /// + [SkippableFact] + public void TransformedPortsKeepEveryFunctionSignature() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + Dictionary includes = ShaderCorpus.LoadIncludes(); + + int checkedPorts = 0; + foreach (string include in NativeShaderTree.IncludeNames()) + { + NativeShaderTree.Port? port = NativeShaderTree.PortOf(include); + if (port == null || port.Kind != "transformed") continue; + checkedPorts++; + Assert.Equal(Signatures(includes[port.GameFile]), Signatures(NativeShaderTree.Read(include))); + } + Assert.Equal(5, checkedPorts); + } + + /// + /// Section 4's forms: a push block and a program record without instance names compile, and + /// their members are global names a program's code uses directly. The record is a uniform + /// block at set 2, binding OPTIMUM_BINDING_PROGRAM_RECORD, the push block a push-constant + /// block; both are laid out scalar, so a vec3 after a uint sits at offset 4, not 16. + /// + [SkippableTheory] + [InlineData(EnumShaderType.VertexShader)] + [InlineData(EnumShaderType.FragmentShader)] + public void AnonymousPushAndRecordBlocksExposeTheirMembersAsGlobalNames(EnumShaderType stage) + { + Skip.IfNot(NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason), reason); + const string interfaceBlocks = """ + #version 450 + #extension GL_EXT_scalar_block_layout : require + #include "bindings.glsl" + + layout(push_constant, scalar) uniform OptimumDraw + { + uint terrainTex; + vec3 origin; + mat4 modelViewMatrix; + }; + + layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram + { + float alphaTest; + vec4 rgbaFogIn; + }; + + """; + string body = stage == EnumShaderType.VertexShader + ? """ + layout(location = 0) in vec3 xyz; + void main() + { + gl_Position = modelViewMatrix * vec4(xyz + origin, 1.0) * rgbaFogIn.a * alphaTest + vec4(float(terrainTex)); + } + """ + : """ + layout(location = 0) out vec4 outColor; + void main() + { + outColor = texture(optimumTextures2D[terrainTex], vec2(0.5)) * rgbaFogIn; + outColor.rgb += (modelViewMatrix * vec4(origin, 1.0)).rgb; + if (outColor.a < alphaTest) discard; + } + """; + + using (compiler) + { + ShaderCompileResult result = NativeShaderTree.Compile(compiler!, interfaceBlocks + body, stage, "interface-blocks-probe"); + Assert.True(result.Success, result.Error); + + SpirvReader spirv = SpirvReader.Parse(result.Spirv); + uint? record = spirv.BlockAt((uint)SetConvention.StorageSet, (uint)SetConvention.ProgramRecordBinding); + Assert.True(record.HasValue, "no record block at set 2, binding 3"); + Assert.Equal(new uint[] { 0, 4 }, Enumerable.Range(0, spirv.MemberCount(record!.Value)).Select(i => spirv.MemberOffset(record.Value, i))); + + List push = spirv.BlocksIn(SpirvReader.StoragePushConstant); + Assert.Single(push); + Assert.Equal(new uint[] { 0, 4, 16 }, Enumerable.Range(0, spirv.MemberCount(push[0])).Select(i => spirv.MemberOffset(push[0], i))); + } + } + + private static readonly Regex BlockComment = new(@"/\*.*?\*/", RegexOptions.Singleline); + private static readonly Regex LineComment = new(@"//[^\n]*"); + private static readonly Regex InterfaceDeclaration = new( + @"^\s*(layout\s*\([^)]*\)\s*)?(uniform|in|out)\s+[^;{(]*;", RegexOptions.Multiline); + private static readonly Regex Token = new(@"\w+|[^\s\w]"); + + private static string StripComments(string source) => + LineComment.Replace(BlockComment.Replace(source.Replace("\r\n", "\n"), " "), ""); + + private static List CodeTokens(string source) + { + string text = StripComments(source); + text = string.Join("\n", text.Split('\n').Where(line => !line.TrimStart().StartsWith("#", StringComparison.Ordinal))); + text = InterfaceDeclaration.Replace(text, ""); + return Token.Matches(text).Select(m => m.Value).ToList(); + } + + private static readonly Regex FunctionDefinition = new( + @"^[ \t]*(\w+)[ \t]+(\w+)[ \t]*\(([^)]*)\)\s*\{", RegexOptions.Multiline); + + private static List Signatures(string source) => + FunctionDefinition.Matches(StripComments(source)) + .Where(m => m.Groups[1].Value is not ("if" or "for" or "while" or "switch" or "return" or "else")) + .Select(m => m.Groups[1].Value + " " + m.Groups[2].Value + "(" + + Regex.Replace(m.Groups[3].Value.Trim(), @"\s+", " ") + ")") + .ToList(); +} diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderTree.cs b/Optimum.Render.Vulkan.Tests/NativeShaderTree.cs new file mode 100644 index 00000000..71695136 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderTree.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The native shader tree (sources/shaders-vk) as the tests see it: the include files, the +/// machine-readable header every ported include carries, include closures, and a shaderc compiler +/// that resolves the includes. +/// +/// A port's header states what the GLSL 330 file declared and where each name now comes from: +/// +/// // optimum-port-of: fogandlight.fsh +/// // optimum-port: verbatim | transformed +/// // optimum-frame-owner: fogandlight.fsh (members this file owns read the frame block) +/// // optimum-frame-texture: sampler2DShadow shadowMapFar +/// // optimum-program-uniform: float flatFogDensity (declared by the including program) +/// // optimum-program-symbol: vec4 rgbaFog (a non-uniform name the program supplies) +/// +/// +internal static class NativeShaderTree +{ + public static string IncludeDirectory => + Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk", "include"); + + public static string Read(string include) => + File.ReadAllText(Path.Combine(IncludeDirectory, include)).Replace("\r\n", "\n"); + + public static List IncludeNames() => + Directory.EnumerateFiles(IncludeDirectory, "*.glsl") + .Select(path => Path.GetFileName(path)!) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + public readonly record struct Declaration(string Type, string Name, string Text); + + public sealed class Port + { + public string Include = ""; + public string GameFile = ""; + public string Kind = ""; + public string? FrameOwner; + public readonly List FrameTextures = new(); + public readonly List ProgramUniforms = new(); + public readonly List ProgramSymbols = new(); + } + + private static readonly Regex HeaderLine = new(@"^// optimum-([a-z-]+): (.+)$", RegexOptions.Multiline); + private static readonly Regex DeclarationText = new(@"^(\w+)\s+(\w+)\s*(\[[^\]]*\])?\s*(=.*)?$"); + private static readonly Regex IncludeLine = new(@"^\s*#include\s+""([^""]+)""", RegexOptions.Multiline); + + /// The header of a ported include, or null for a file that ports nothing (bindings, motion, ...). + public static Port? PortOf(string include) + { + string text = Read(include); + var port = new Port { Include = include }; + foreach (Match line in HeaderLine.Matches(text)) + { + string value = line.Groups[2].Value.Trim(); + switch (line.Groups[1].Value) + { + case "port-of": port.GameFile = value; break; + case "port": port.Kind = value; break; + case "frame-owner": port.FrameOwner = value; break; + case "frame-texture": port.FrameTextures.Add(Parse(value)); break; + case "program-uniform": port.ProgramUniforms.Add(Parse(value)); break; + case "program-symbol": port.ProgramSymbols.Add(Parse(value)); break; + } + } + return port.GameFile.Length == 0 ? null : port; + } + + private static Declaration Parse(string value) + { + Match match = DeclarationText.Match(value); + if (!match.Success) throw new FormatException("bad header declaration: " + value); + return new Declaration(match.Groups[1].Value, match.Groups[2].Value, + match.Groups[1].Value + " " + match.Groups[2].Value + match.Groups[3].Value); + } + + public static IEnumerable DirectIncludes(string include) => + IncludeLine.Matches(Read(include)).Select(match => match.Groups[1].Value); + + /// The include and everything it pulls in, transitively. + public static List Closure(string include) + { + var seen = new List(); + var pending = new Stack(); + pending.Push(include); + while (pending.Count > 0) + { + string next = pending.Pop(); + if (seen.Contains(next)) continue; + seen.Add(next); + foreach (string child in DirectIncludes(next)) pending.Push(child); + } + return seen; + } + + /// The stages an include can be compiled in: a .vsh port is vertex-only, .fsh and motion fragment-only. + public static EnumShaderType[] StagesOf(string include) + { + Port? port = PortOf(include); + string gameFile = port?.GameFile ?? ""; + if (gameFile.EndsWith(".vsh", StringComparison.Ordinal)) return new[] { EnumShaderType.VertexShader }; + if (gameFile.EndsWith(".fsh", StringComparison.Ordinal) || include == "motion.glsl") + { + return new[] { EnumShaderType.FragmentShader }; + } + return new[] { EnumShaderType.VertexShader, EnumShaderType.FragmentShader }; + } + + public static bool TryCreateCompiler(out ShaderCompiler? compiler, out string reason) + { + try + { + compiler = new ShaderCompiler(); + reason = ""; + return true; + } + catch (Exception error) when (error is DllNotFoundException or InvalidOperationException) + { + compiler = null; + reason = "shaderc unavailable: " + error.Message; + return false; + } + } + + public static ShaderCompileResult Compile(ShaderCompiler compiler, string source, EnumShaderType stage, string name) + { + string extension = stage == EnumShaderType.VertexShader ? ".vert" : ".frag"; + return compiler.CompileNative(source, Path.Combine(IncludeDirectory, name + extension), stage, IncludeDirectory); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SpecializationConventionTests.cs b/Optimum.Render.Vulkan.Tests/SpecializationConventionTests.cs new file mode 100644 index 00000000..5366d316 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SpecializationConventionTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The specialization constants exist twice, like the set convention: +/// sources/shaders-vk/include/specialization.glsl for native shaders and +/// for the pipeline key. A drift specializes the wrong +/// constant - shadows switched by the bloom setting - which renders wrong without failing, so the +/// two are compared constant by constant, and each constant against the define it replaces. +/// +public class SpecializationConventionTests +{ + private static readonly Regex Declaration = new( + @"^layout\(constant_id = (\d+)\) const (\w+) (\w+) = ([^;]+);\s*$", RegexOptions.Multiline); + + /// The defines that stay variant axes or are fixed (docs/vulkan-native-shaders.md section 5). + private static readonly string[] NotConstants = + { "TAAMOTION", "TAAMOTIONLOCATION", "USEOIT", "USESSBO", "GREEDYMESH", "MAXANIMATEDELEMENTS" }; + + [Fact] + public void EveryDeclarationInTheIncludeIsTheConstantTheRendererUses() + { + var declared = Declaration.Matches(ReadInclude()) + .Select(m => $"{m.Groups[1].Value} {m.Groups[2].Value} {m.Groups[3].Value} = {m.Groups[4].Value}") + .ToList(); + var expected = SpecializationConvention.Constants + .Select(c => $"{c.Id} {c.GlslType} {c.Name} = {c.Default}") + .ToList(); + Assert.Equal(expected, declared); + } + + [Fact] + public void IdsAreDenseAndNamesFollowTheDefine() + { + for (int i = 0; i < SpecializationConvention.Constants.Length; i++) + { + SpecializationConvention.Constant constant = SpecializationConvention.Constants[i]; + Assert.Equal((uint)i, constant.Id); + Assert.Equal("OPTIMUM_" + constant.Define, constant.Name); + Assert.True(constant.GlslType is "int" or "float", constant.Name + " has type " + constant.GlslType); + } + } + + /// + /// Every define registerDefaultShaderCodePrefixes stamps is either a constant or one of + /// the contract's variant axes, never both, so a new quality define cannot slip past both lists. + /// + [SkippableFact] + public void EveryPrefixDefineIsExactlyOneOfConstantOrVariantAxis() + { + string path = Path.Combine(ShaderCorpus.RepositoryRoot, "build", "VintagestoryLib", + "Vintagestory.Client.NoObf", "ShaderRegistry.cs"); + Skip.IfNot(File.Exists(path), "No bootstrapped build tree."); + string source = File.ReadAllText(path); + int start = source.IndexOf("private static void registerDefaultShaderCodePrefixes", StringComparison.Ordinal); + Assert.True(start > 0, "registerDefaultShaderCodePrefixes not found"); + int end = source.IndexOf("private static string HandleIncludes", start, StringComparison.Ordinal); + string body = source[start..end]; + + var stamped = new SortedSet(StringComparer.Ordinal); + foreach (Match match in Regex.Matches(body, @"#define (\w+) ")) stamped.Add(match.Groups[1].Value); + + var constants = SpecializationConvention.Constants.Select(c => c.Define).ToHashSet(StringComparer.Ordinal); + foreach (string define in constants) + { + Assert.True(stamped.Contains(define), define + " is not stamped by registerDefaultShaderCodePrefixes"); + } + foreach (string define in stamped) + { + bool isConstant = constants.Contains(define); + bool isAxis = NotConstants.Contains(define); + Assert.True(isConstant ^ isAxis, define + (isConstant ? " is both a constant and an axis" : " is neither a constant nor an axis")); + } + } + + /// No native include keeps a preprocessor branch on a define a constant replaced. + [Fact] + public void NoNativeIncludeBranchesOnAReplacedDefineWithThePreprocessor() + { + var defines = SpecializationConvention.Constants.Select(c => c.Define).ToList(); + foreach (string include in NativeShaderTree.IncludeNames()) + { + foreach (string line in NativeShaderTree.Read(include).Split('\n')) + { + string trimmed = line.TrimStart(); + if (!trimmed.StartsWith("#if", StringComparison.Ordinal) && !trimmed.StartsWith("#elif", StringComparison.Ordinal)) continue; + foreach (string define in defines) + { + Assert.False(Regex.IsMatch(trimmed, @"\b" + define + @"\b"), + include + " branches on " + define + " with the preprocessor: " + trimmed); + } + } + } + } + + /// The compiled module carries every constant under its id with its type. + [SkippableFact] + public void TheCompiledConstantsCarryTheirIdsAndTypes() + { + Skip.IfNot(NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason), reason); + var probe = new StringBuilder("#version 450\n#include \"specialization.glsl\"\nlayout(location = 0) out vec4 outColor;\nvoid main()\n{\n float sum = 0.0;\n"); + foreach (SpecializationConvention.Constant constant in SpecializationConvention.Constants) + { + probe.Append(" sum += float(").Append(constant.Name).Append(");\n"); + } + probe.Append(" outColor = vec4(sum);\n}\n"); + + using (compiler) + { + ShaderCompileResult result = NativeShaderTree.Compile(compiler!, probe.ToString(), EnumShaderType.FragmentShader, "specialization-probe"); + Assert.True(result.Success, result.Error); + + Dictionary ids = SpirvReader.Parse(result.Spirv).SpecIds(); + var expected = SpecializationConvention.Constants.ToDictionary(c => c.Id, c => c.GlslType); + Assert.Equal(expected.OrderBy(p => p.Key), ids.OrderBy(p => p.Key)); + } + } + + private static string ReadInclude() => + File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, SpecializationConvention.IncludePath)).Replace("\r\n", "\n"); +} diff --git a/Optimum.Render.Vulkan.Tests/SpirvReader.cs b/Optimum.Render.Vulkan.Tests/SpirvReader.cs new file mode 100644 index 00000000..34449abb --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SpirvReader.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The few SPIR-V facts the native-shader tests check - names, decorations, struct members, +/// variables and specialization constants - read straight from the word stream. No reflection +/// library exists in the tree (docs/vulkan-native-shaders.md section 6 plans +/// Shaders/SpirvReflection.cs); until it does, this is deliberately minimal. +/// +internal sealed class SpirvReader +{ + private const uint Magic = 0x07230203; + + private const ushort OpName = 5; + private const ushort OpMemberName = 6; + private const ushort OpTypeInt = 21; + private const ushort OpTypeFloat = 22; + private const ushort OpTypeArray = 28; + private const ushort OpTypeStruct = 30; + private const ushort OpTypePointer = 32; + private const ushort OpSpecConstant = 50; + private const ushort OpVariable = 59; + private const ushort OpDecorate = 71; + private const ushort OpMemberDecorate = 72; + + public const uint DecorationSpecId = 1; + public const uint DecorationArrayStride = 6; + public const uint DecorationBinding = 33; + public const uint DecorationDescriptorSet = 34; + public const uint DecorationOffset = 35; + + public const uint StorageUniform = 2; + public const uint StoragePushConstant = 9; + + public readonly Dictionary Names = new(); + public readonly Dictionary<(uint Type, uint Member), string> MemberNames = new(); + public readonly Dictionary> Decorations = new(); + public readonly Dictionary<(uint Type, uint Member), Dictionary> MemberDecorations = new(); + public readonly Dictionary Structs = new(); + public readonly Dictionary ArrayElements = new(); + public readonly Dictionary Pointers = new(); + public readonly Dictionary Variables = new(); + public readonly Dictionary ScalarTypes = new(); + public readonly Dictionary SpecConstantTypes = new(); + + public static SpirvReader Parse(byte[] bytes) + { + if (bytes.Length < 20 || bytes.Length % 4 != 0) throw new ArgumentException("not a SPIR-V module"); + var words = new uint[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length); + if (words[0] != Magic) throw new ArgumentException("bad SPIR-V magic"); + + var reader = new SpirvReader(); + for (int at = 5; at < words.Length;) + { + ushort opcode = (ushort)(words[at] & 0xFFFF); + int count = (int)(words[at] >> 16); + if (count == 0) throw new ArgumentException("zero-length instruction at word " + at); + ReadOnlySpan operands = new ReadOnlySpan(words, at + 1, count - 1); + reader.Take(opcode, operands); + at += count; + } + return reader; + } + + private void Take(ushort opcode, ReadOnlySpan o) + { + switch (opcode) + { + case OpName: Names[o[0]] = ReadString(o[1..]); break; + case OpMemberName: MemberNames[(o[0], o[1])] = ReadString(o[2..]); break; + case OpTypeInt: ScalarTypes[o[0]] = o[2] == 1 ? "int" : "uint"; break; + case OpTypeFloat: ScalarTypes[o[0]] = "float"; break; + case OpTypeArray: ArrayElements[o[0]] = o[1]; break; + case OpTypeStruct: Structs[o[0]] = o[1..].ToArray(); break; + case OpTypePointer: Pointers[o[0]] = (o[1], o[2]); break; + case OpSpecConstant: SpecConstantTypes[o[1]] = o[0]; break; + case OpVariable: Variables[o[1]] = (o[0], o[2]); break; + case OpDecorate: + Get(Decorations, o[0])[o[1]] = o.Length > 2 ? o[2] : 1; + break; + case OpMemberDecorate: + if (!MemberDecorations.TryGetValue((o[0], o[1]), out Dictionary? member)) + { + MemberDecorations[(o[0], o[1])] = member = new Dictionary(); + } + member[o[2]] = o.Length > 3 ? o[3] : 1; + break; + } + } + + /// The block type of the variable at /, or null. + public uint? BlockAt(uint set, uint binding) + { + foreach ((uint id, (uint pointer, uint _)) in Variables) + { + if (!Decorations.TryGetValue(id, out Dictionary? d)) continue; + if (d.TryGetValue(DecorationDescriptorSet, out uint s) && s == set && + d.TryGetValue(DecorationBinding, out uint b) && b == binding) + { + return Pointers[pointer].Type; + } + } + return null; + } + + /// The block types of every variable in . + public List BlocksIn(uint storage) + { + var blocks = new List(); + foreach ((uint _, (uint pointer, uint variableStorage)) in Variables) + { + if (variableStorage == storage) blocks.Add(Pointers[pointer].Type); + } + return blocks; + } + + public int MemberCount(uint structType) => Structs[structType].Length; + + public uint MemberOffset(uint structType, int member) => + MemberDecorations[(structType, (uint)member)][DecorationOffset]; + + public string? MemberName(uint structType, int member) => + MemberNames.TryGetValue((structType, (uint)member), out string? name) ? name : null; + + public uint? ArrayStride(uint structType, int member) + { + uint type = Structs[structType][member]; + if (!ArrayElements.ContainsKey(type)) return null; + return Decorations.TryGetValue(type, out Dictionary? d) && + d.TryGetValue(DecorationArrayStride, out uint stride) ? stride : null; + } + + /// Specialization constant id to its scalar type name. + public Dictionary SpecIds() + { + var ids = new Dictionary(); + foreach ((uint id, Dictionary d) in Decorations) + { + if (d.TryGetValue(DecorationSpecId, out uint specId) && SpecConstantTypes.TryGetValue(id, out uint type)) + { + ids[specId] = ScalarTypes[type]; + } + } + return ids; + } + + private static Dictionary Get(Dictionary> map, uint id) + { + if (!map.TryGetValue(id, out Dictionary? value)) map[id] = value = new Dictionary(); + return value; + } + + private static string ReadString(ReadOnlySpan words) + { + var bytes = new List(); + foreach (uint word in words) + { + for (int shift = 0; shift < 32; shift += 8) + { + byte b = (byte)(word >> shift); + if (b == 0) return Encoding.UTF8.GetString(bytes.ToArray()); + bytes.Add(b); + } + } + return Encoding.UTF8.GetString(bytes.ToArray()); + } +} diff --git a/Optimum.Render.Vulkan.Tests/TaaMotionIncludeTests.cs b/Optimum.Render.Vulkan.Tests/TaaMotionIncludeTests.cs new file mode 100644 index 00000000..f1bcf962 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaMotionIncludeTests.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// sources/shaders-vk/include/motion.glsl, the only motion writer of the native shaders +/// (docs/vulkan-native-shaders.md section 7), rendered on the device and read back. +/// +/// The include is written in the common subset of GLSL 330 and 450, so a fixture fragment shader +/// that inlines its text links through the device the way today's programs do; native linking +/// from SPIR-V does not exist yet. The three cases the contract fixes: +/// +/// 1. a previous position in front of the previous camera writes +/// rg = previousPixel - (gl_FragCoord.xy - jitter), b = reactive, a = writerDepth; +/// 2. a previous position behind it (w <= 1e-6) writes zero rg and a and +/// KEEPS b (temporal-frame-contract section 3.2); +/// 3. the reactive-only writer writes (0, 0, reactive, 0). +/// +/// The fixture's previous clip position is the current NDC position plus a fixed NDC offset, so +/// the expected vector is the same at every pixel and small enough to decode with a fine scale. +/// +public class TaaMotionIncludeTests +{ + private readonly ITestOutputHelper _output; + + public TaaMotionIncludeTests(ITestOutputHelper output) => _output = output; + + private const int Size = 32; + + /// Pixels per unit in the decode pass: mv/DecodeScale * 0.5 + 0.5 into an RGBA8 channel. + private const float DecodeScale = 8f; + + /// One RGBA8 step of the decoded vector, in pixels. + private const float VectorTolerance = DecodeScale / 127.5f + 1e-3f; + + private const float ChannelTolerance = 1.5f / 255f; + + /// The sentinel the motion target is cleared to, so a written zero is distinguishable. + private const float Sentinel = 0.75f; + + [SkippableTheory] + [InlineData(0.0f, 0.0f)] + [InlineData(0.25f, -0.375f)] + [InlineData(-0.5f, 0.125f)] + public void AValidPreviousPositionWritesTheUnjitteredVectorReactiveAndWriterDepth(float jitterX, float jitterY) + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + // Offset NDC (3/16, -2/16) at Size 32 is (3, -2) pixels; the jitter adds itself because + // the current pixel is gl_FragCoord minus the jitter. + Pixel[] pixels = Render(device!, mode: 0, previousW: 2f, offsetPixelsX: 3f, offsetPixelsY: -2f, + jitterX, jitterY, reactive: 0.3f, writerDepth: 0.625f); + + foreach (Pixel pixel in pixels) + { + Assert.InRange(pixel.MotionX, 3f + jitterX - VectorTolerance, 3f + jitterX + VectorTolerance); + Assert.InRange(pixel.MotionY, -2f + jitterY - VectorTolerance, -2f + jitterY + VectorTolerance); + Assert.InRange(pixel.Reactive, 0.3f - ChannelTolerance, 0.3f + ChannelTolerance); + Assert.InRange(pixel.WriterDepth, 0.625f - ChannelTolerance, 0.625f + ChannelTolerance); + } + } + } + + [SkippableTheory] + [InlineData(-1f)] + [InlineData(0f)] + [InlineData(1e-6f)] + public void APreviousPositionBehindThePreviousCameraKeepsReactiveAndZeroesTheRest(float previousW) + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + Pixel[] pixels = Render(device!, mode: 0, previousW, offsetPixelsX: 3f, offsetPixelsY: -2f, + jitterX: 0.25f, jitterY: -0.375f, reactive: 0.3f, writerDepth: 0.625f); + + foreach (Pixel pixel in pixels) + { + Assert.InRange(pixel.MotionX, -VectorTolerance, VectorTolerance); + Assert.InRange(pixel.MotionY, -VectorTolerance, VectorTolerance); + Assert.InRange(pixel.Reactive, 0.3f - ChannelTolerance, 0.3f + ChannelTolerance); + Assert.InRange(pixel.WriterDepth, 0f, ChannelTolerance); + } + } + } + + [SkippableFact] + public void TheReactiveOnlyWriterWritesOnlyReactive() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + Pixel[] pixels = Render(device!, mode: 1, previousW: 2f, offsetPixelsX: 3f, offsetPixelsY: -2f, + jitterX: 0.25f, jitterY: -0.375f, reactive: 0.7f, writerDepth: 0.625f); + + foreach (Pixel pixel in pixels) + { + Assert.InRange(pixel.MotionX, -VectorTolerance, VectorTolerance); + Assert.InRange(pixel.MotionY, -VectorTolerance, VectorTolerance); + Assert.InRange(pixel.Reactive, 0.7f - ChannelTolerance, 0.7f + ChannelTolerance); + Assert.InRange(pixel.WriterDepth, 0f, ChannelTolerance); + } + } + } + + private readonly record struct Pixel(float MotionX, float MotionY, float Reactive, float WriterDepth); + + private static unsafe Pixel[] Render( + VulkanDevice seam, int mode, float previousW, float offsetPixelsX, float offsetPixelsY, + float jitterX, float jitterY, float reactive, float writerDepth) + { + string motionInclude = File.ReadAllText(Path.Combine(NativeShaderTree.IncludeDirectory, "motion.glsl")); + + const string vertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +uniform vec2 offsetNdc; +uniform float previousW; +out vec4 prevClip; +void main(void) +{ + gl_Position = vec4(xyz, 1.0); + prevClip = vec4((xyz.xy + offsetNdc) * previousW, 0.5 * previousW, previousW); +} +"; + string fragment = "#version 330 core\n" + motionInclude + @" +in vec4 prevClip; +uniform vec2 renderSize; +uniform vec2 jitterPx; +uniform float reactive; +uniform float writerDepth; +uniform int mode; +layout(location = 0) out vec4 outMotion; +void main(void) +{ + if (mode == 0) outMotion = optimumWriteMotion(prevClip, renderSize, jitterPx, reactive, writerDepth); + else outMotion = optimumWriteReactiveOnly(reactive); +} +"; + int program = Link(seam, vertex, fragment, "motion-include-fixture"); + + int motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(motion, OptimumGlConstants.TextureMagFilter, 9728); + int target = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment0, motion, 0); + seam.SetDrawBuffers(target, 0b1); + Assert.True(seam.CheckFramebufferComplete(target, out string status), status); + + int quad = seam.CreateMesh(BuildQuad(), staticDraw: true); + Assert.True(quad > 0, seam.GetError() ?? "quad upload failed"); + + seam.BeginFrame(); + seam.BindFramebuffer(target); + seam.ClearColor(0, Sentinel, Sentinel, Sentinel, Sentinel); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + seam.UseProgram(program); + SetFloat2(seam, program, "offsetNdc", offsetPixelsX * 2f / Size, offsetPixelsY * 2f / Size); + SetFloat(seam, program, "previousW", previousW); + SetFloat2(seam, program, "renderSize", Size, Size); + SetFloat2(seam, program, "jitterPx", jitterX, jitterY); + SetFloat(seam, program, "reactive", reactive); + SetFloat(seam, program, "writerDepth", writerDepth); + SetInt(seam, program, "mode", mode); + seam.DrawMesh(quad); + + byte[] decoded = Decode(seam, motion, quad); + seam.Present(); + GpuTest.AssertClean(seam); + + var pixels = new Pixel[Size * Size]; + for (int i = 0; i < pixels.Length; i++) + { + int o = i * 4; + pixels[i] = new Pixel( + (decoded[o] / 255f * 2f - 1f) * DecodeScale, + (decoded[o + 1] / 255f * 2f - 1f) * DecodeScale, + decoded[o + 2] / 255f, + decoded[o + 3] / 255f); + } + return pixels; + } + + /// + /// The seam reads four bytes per pixel from colour attachment 0, so the RGBA16F attachment is + /// decoded into RGBA8 by a texelFetch pass inside the same frame. + /// + private static unsafe byte[] Decode(VulkanDevice seam, int motionTexture, int quad) + { + const string vertex = @"#version 330 core +layout(location = 0) in vec3 xyz; +void main(void) { gl_Position = vec4(xyz, 1.0); } +"; + const string fragment = @"#version 330 core +uniform sampler2D motionTex; +uniform float decodeScale; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 m = texelFetch(motionTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4( + clamp(m.r / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.g / decodeScale * 0.5 + 0.5, 0.0, 1.0), + clamp(m.b, 0.0, 1.0), + clamp(m.a, 0.0, 1.0)); +} +"; + int decode = Link(seam, vertex, fragment, "motion-include-decode"); + int colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, colour, 0); + seam.SetDrawBuffers(framebuffer, 0b1); + + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 0f); + seam.UseProgram(decode); + seam.SetSamplerUnit(decode, "motionTex", 15); + seam.BindTexture(15, motionTexture); + SetFloat(seam, decode, "decodeScale", DecodeScale); + seam.SetViewport(0, 0, Size, Size); + seam.DrawMesh(quad); + + var bytes = new byte[Size * Size * 4]; + fixed (byte* destination = bytes) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return bytes; + } + + private static MeshData BuildQuad() => + new(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false) + { + xyz = new[] { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }, + VerticesCount = 4, + Indices = new[] { 0, 1, 2, 0, 2, 3 }, + IndicesCount = 6, + }; + + private static int Link(VulkanDevice seam, string vertex, string fragment, string name) + { + var vertexShader = new FixtureShader { Type = EnumShaderType.VertexShader, Code = vertex }; + var fragmentShader = new FixtureShader { Type = EnumShaderType.FragmentShader, Code = fragment }; + Assert.True(seam.CompileShader(vertexShader), name + ": " + (seam.GetError() ?? "vertex compile failed")); + Assert.True(seam.CompileShader(fragmentShader), name + ": " + (seam.GetError() ?? "fragment compile failed")); + int program = seam.LinkProgram(new FixtureProgram + { + PassName = name, + VertexShader = vertexShader, + FragmentShader = fragmentShader, + }); + Assert.True(program > 0, name + ": " + (seam.GetError() ?? "link failed")); + return program; + } + + private static void SetFloat(VulkanDevice seam, int program, string name, float value) + { + int location = seam.GetUniformLocation(program, name); + Assert.True(location >= 0, name + " has no location"); + seam.SetUniform(program, location, value); + } + + private static void SetInt(VulkanDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + Assert.True(location >= 0, name + " has no location"); + seam.SetUniform(program, location, value); + } + + private static void SetFloat2(VulkanDevice seam, int program, string name, float x, float y) + { + int location = seam.GetUniformLocation(program, name); + Assert.True(location >= 0, name + " has no location"); + seam.SetUniform(program, location, x, y); + } + + private sealed class FixtureShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + private sealed class FixtureProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = ""; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } +} diff --git a/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs b/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs index 9ff49e5c..ea85980b 100644 --- a/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs +++ b/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs @@ -195,4 +195,102 @@ public static byte[] CreateShadow() private static int Align(int value, int alignment) => alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment; + + // ------------------------------------------------------------ native include + + /// The generated native include (docs/vulkan-native-shaders.md sections 1 and 3). + public const string IncludePath = "sources/shaders-vk/include/frame.glsl"; + + /// The block's instance name in native shaders. + public const string InstanceName = "optimumFrame"; + + /// + /// The macro a native source defines to say the program includes + /// (a game include file name such as fogandlight.vsh) in some stage. + /// + public static string OwnerMacro(string owner) => + "OPTIMUM_FRAME_OWNER_" + owner.Replace('.', '_').ToUpperInvariant(); + + /// Every owner, in the order its first member appears in the block. + public static IReadOnlyList Owners + { + get + { + var owners = new List(); + foreach (Entry entry in Entries) + { + if (!owners.Contains(entry.Owner)) owners.Add(entry.Owner); + } + return owners; + } + } + + /// + /// The text of : the block at its fixed offsets under an instance + /// name, then one group of #define name optimumFrame.name lines per owner. + /// + /// A group is outside the include guard and activates when its owner macro is defined and + /// the group has not been emitted yet, so including the file again after defining another + /// owner macro adds that owner's names. That keeps 's rule in native + /// sources: a name reads the frame block only in a program that includes its owner, and a + /// program that does not keeps a record member of the same name. + /// + public static string GenerateInclude() + { + var text = new System.Text.StringBuilder(); + text.Append(""" + // Generated from Optimum.Render.Vulkan/Shaders/FrameGlobals.cs (FrameGlobals.GenerateInclude). + // Do not edit: FrameGlobalsTests regenerates this file and fails on any difference. + // + // The FrameGlobals block (docs/vulkan-native-shaders.md section 3): set 0, binding 0, scalar + // layout, bound with a dynamic offset. Members sit at the offsets the renderer writes. + // + // No member is a global name here. A member is the shared frame value only in a program that + // includes the member's owner file, so every owner has its own group of defines below. An + // owner include (fogandlight.frag.glsl, vertexwarp.glsl, ...) defines its owner macro and + // includes this file, which activates its group. A program whose other stage includes an + // owner defines that owner's macro itself before its includes (for example + // OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH in a fragment stage that reads flatFogDensity), and a + // program that includes no owner of a name declares the name in its own record instead. + + #ifndef OPTIMUM_FRAME_GLSL + #define OPTIMUM_FRAME_GLSL + + #extension GL_EXT_scalar_block_layout : require + + #include "bindings.glsl" + + + """); + text.Append("layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_FRAME_GLOBALS, scalar) uniform ") + .Append(BlockTypeName).Append('\n').Append("{\n"); + foreach (UniformMember member in MemberList) + { + text.Append(" layout(offset = ").Append(member.Offset).Append(") ") + .Append(member.Type.Name).Append(' ').Append(member.Name); + if (member.ArrayLength > 0) text.Append('[').Append(member.ArrayLength).Append(']'); + text.Append(";\n"); + } + text.Append("} ").Append(InstanceName).Append(";\n\n") + .Append("// Block size: ").Append(BlockSize).Append(" bytes.\n\n") + .Append("#endif\n"); + + foreach (string owner in Owners) + { + string names = "OPTIMUM_FRAME_NAMES_" + owner.Replace('.', '_').ToUpperInvariant(); + text.Append('\n') + .Append("// ").Append(owner).Append('\n') + .Append("#if defined(").Append(OwnerMacro(owner)).Append(") && !defined(").Append(names).Append(")\n") + .Append("#define ").Append(names).Append('\n'); + foreach (Entry entry in Entries) + { + if (entry.Owner != owner) continue; + text.Append("#define ").Append(entry.Name).Append(' ') + .Append(InstanceName).Append('.').Append(entry.Name).Append('\n'); + } + text.Append("#endif\n"); + } + + return text.ToString(); + } } diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.Includes.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.Includes.cs new file mode 100644 index 00000000..db592b72 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.Includes.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Silk.NET.Shaderc; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// Native shader sources (sources/shaders-vk, docs/vulkan-native-shaders.md section 1) +/// resolve their own #include "file.glsl" through shaderc, unlike the game's GLSL 330 +/// programs, whose includes ShaderRegistry expands before the rewriter sees them. The +/// options are the same as 's plus an include resolver, and the result is +/// never cached: the cache key covers only the top-level text, not the files it pulls in. +/// +internal sealed unsafe partial class ShaderCompiler +{ + /// + /// Compiles a native GLSL 450 stage whose quoted includes resolve against the including + /// file's directory first and then . + /// + public ShaderCompileResult CompileNative(string code, string filename, EnumShaderType stage, string includeDirectory) + { + var result = new ShaderCompileResult(); + var resolver = new IncludeResolver(includeDirectory); + GCHandle handle = GCHandle.Alloc(resolver); + + CompileOptions* options = CreateOptions(); + try + { + _api.CompileOptionsSetIncludeCallbacks( + options, + new PfnIncludeResolveFn(&ResolveInclude), + new PfnIncludeResultReleaseFn(&ReleaseInclude), + (void*)GCHandle.ToIntPtr(handle)); + + CompilationResult* compiled = CompileWith(code, filename, stage, options, preprocessOnly: false); + try + { + if (!Succeeded(compiled, out string? error)) + { + result.Error = error; + return result; + } + + nuint length = _api.ResultGetLength(compiled); + byte* bytes = (byte*)_api.ResultGetBytes(compiled); + var spirv = new byte[(int)length]; + fixed (byte* destination = spirv) + { + Buffer.MemoryCopy(bytes, destination, spirv.Length, (long)length); + } + + result.Spirv = spirv; + result.Success = true; + return result; + } + finally + { + _api.ResultRelease(compiled); + } + } + finally + { + _api.CompileOptionsRelease(options); + handle.Free(); + } + } + + private sealed class IncludeResolver + { + private readonly string _directory; + + public IncludeResolver(string directory) => _directory = directory; + + /// The resolved path and text, or null and the reason. + public (string? Path, string Text) Resolve(string requested, string requesting, bool relative) + { + if (relative) + { + string? requestingDirectory = Path.GetDirectoryName(requesting); + if (!string.IsNullOrEmpty(requestingDirectory)) + { + string besideRequester = Path.GetFullPath(Path.Combine(requestingDirectory, requested)); + if (File.Exists(besideRequester)) return (besideRequester, File.ReadAllText(besideRequester)); + } + } + + string inDirectory = Path.GetFullPath(Path.Combine(_directory, requested)); + if (File.Exists(inDirectory)) return (inDirectory, File.ReadAllText(inDirectory)); + + return (null, "include '" + requested + "' not found in " + _directory); + } + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static IncludeResult* ResolveInclude( + void* userData, byte* requestedSource, int type, byte* requestingSource, nuint includeDepth) + { + var resolver = (IncludeResolver)GCHandle.FromIntPtr((IntPtr)userData).Target!; + string requested = Marshal.PtrToStringUTF8((IntPtr)requestedSource) ?? ""; + string requesting = Marshal.PtrToStringUTF8((IntPtr)requestingSource) ?? ""; + + (string? path, string text) = resolver.Resolve(requested, requesting, relative: type == (int)IncludeType.Relative); + + // shaderc's convention: an empty source name marks a failure, and the content is the message. + var include = (IncludeResult*)NativeMemory.AllocZeroed((nuint)sizeof(IncludeResult)); + include->SourceName = CopyUtf8(path ?? "", out nuint nameLength); + include->SourceNameLength = nameLength; + include->Content = CopyUtf8(text, out nuint contentLength); + include->ContentLength = contentLength; + return include; + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void ReleaseInclude(void* userData, IncludeResult* include) + { + if (include == null) return; + NativeMemory.Free(include->SourceName); + NativeMemory.Free(include->Content); + NativeMemory.Free(include); + } + + private static byte* CopyUtf8(string text, out nuint length) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + var copy = (byte*)NativeMemory.Alloc((nuint)Math.Max(1, bytes.Length)); + bytes.AsSpan().CopyTo(new Span(copy, bytes.Length)); + length = (nuint)bytes.Length; + return copy; + } +} diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs index af726acb..92227339 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -28,7 +28,7 @@ internal sealed class ShaderCompileResult /// resolved. Running a real preprocessor first means the rewriter only ever sees /// straight-line declarations and never has to reason about conditionals. /// -internal sealed unsafe class ShaderCompiler : IDisposable +internal sealed unsafe partial class ShaderCompiler : IDisposable { private readonly Shaderc _api; private readonly Compiler* _compiler; diff --git a/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs b/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs new file mode 100644 index 00000000..bc9f3dbd --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs @@ -0,0 +1,46 @@ +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// The specialization constants of the native shaders (docs/vulkan-native-shaders.md section 5). +/// Mirrors sources/shaders-vk/include/specialization.glsl, the source of truth for native +/// shaders; SpecializationConventionTests keeps the two in agreement and checks every constant +/// against the #define ShaderRegistry.registerDefaultShaderCodePrefixes stamps. +/// +/// Every constant replaces one quality or code-path define: a native source branches on +/// if (OPTIMUM_BLOOM != 0) where the GLSL 330 source has #if BLOOM > 0, the +/// declarations the branch uses are unconditional, and a settings change becomes a pipeline-key +/// change instead of a recompile. The defines that change a declaration stay variant axes +/// (TAAMOTION, USEOIT, USESSBO, GREEDYMESH, ...) and are not here. +/// +/// Defaults are 0, the value an undefined macro has in a GLSL #if and the value the +/// game's own includes fall back to (fogandlight.vsh: #ifndef DYNLIGHTS / #define +/// DYNLIGHTS 0, the same for MINBRIGHT). The runtime always specializes every +/// constant from the program's prefix, so a default only decides an unspecialized pipeline. +/// +internal static class SpecializationConvention +{ + public const string IncludePath = "sources/shaders-vk/include/specialization.glsl"; + + /// One constant: its id, GLSL name and type, default, and the define it replaces. + public readonly record struct Constant(uint Id, string Name, string GlslType, string Default, string Define); + + public static readonly Constant[] Constants = + { + new(0, "OPTIMUM_FXAA", "int", "0", "FXAA"), + new(1, "OPTIMUM_SSAOLEVEL", "int", "0", "SSAOLEVEL"), + new(2, "OPTIMUM_NORMALVIEW", "int", "0", "NORMALVIEW"), + new(3, "OPTIMUM_BLOOM", "int", "0", "BLOOM"), + new(4, "OPTIMUM_GODRAYS", "int", "0", "GODRAYS"), + new(5, "OPTIMUM_FOAMEFFECT", "int", "0", "FOAMEFFECT"), + new(6, "OPTIMUM_SHINYEFFECT", "int", "0", "SHINYEFFECT"), + new(7, "OPTIMUM_SHADOWQUALITY", "int", "0", "SHADOWQUALITY"), + new(8, "OPTIMUM_WAVINGSTUFF", "int", "0", "WAVINGSTUFF"), + new(9, "OPTIMUM_MINBRIGHT", "float", "0.0", "MINBRIGHT"), + new(10, "OPTIMUM_GREEDYMESH_GRAD", "int", "0", "GREEDYMESH_GRAD"), + // DYNLIGHTS no longer sizes an array (the frame block's point-light arrays are fixed at + // FrameGlobals.MaxDynamicLights and pointLightQuantity bounds the loop), but its zero + // value still selects fogandlight.vsh's no-point-light path, which also skips the night + // vision, MINBRIGHT and contrast terms. Keeping that path needs the value. + new(11, "OPTIMUM_DYNLIGHTS", "int", "0", "DYNLIGHTS"), + }; +} diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index a28b88ff..572ee9f4 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -23,15 +23,48 @@ Inputs: `colormap.frag.glsl`, `dither.glsl`, `skycolor.glsl`, `underwatereffects.glsl`, `noise2d.glsl`, `noise3d.glsl`, `oit.glsl`, `fxaa.glsl`, `colorutil.glsl`, `normalshading.glsl`, `fogspheres.glsl`, `vertexflagbits.glsl`). `default.fsh` and `printvalues.fsh` are included by nothing and are not ported. + - The effective source is `sources/shaderincludes/` when it exists (today only `vertexwarp.vsh`), + else the vanilla asset. + - Every include has an include guard and `#include`s the other includes its code calls (`skycolor.glsl` + pulls in `dither.glsl` and `fogandlight.frag.glsl`, `underwatereffects.glsl` pulls in + `fogandlight.frag.glsl`, `vertexwarp.glsl` and `fogandlight.vert.glsl` pull in `vertexflagbits.glsl`, + `colormap.vert.glsl` pulls in `noise3d.glsl`), so the order among shared includes stops mattering (owner + macros still come first, section 3). Every registered program that includes those files already includes + the dependencies. The one GLSL 330 file that does not is `cloudmap.fsh`, which ShaderRegistry never + registers: it includes `skycolor.fsh` without `dither.fsh` and defines `NoiseFromPixelPosition` itself, so + a native port of it drops its own copy. + - **Port header** (machine-read by `NativeShaderIncludeTests`): + `// optimum-port-of: `, `// optimum-port: verbatim | transformed`, + `// optimum-frame-owner: ` when the file owns FrameGlobals members, + `// optimum-frame-texture: ` for set-0 textures, + `// optimum-program-uniform: ` for every other uniform the game file declared, and + `// optimum-program-symbol: ` for a non-uniform name the includer supplies (`rgbaFog`). + Family programs read the header to know what their record must declare. + - **Verbatim** ports keep the game file's code token for token once comments, preprocessor lines and + interface declarations are set aside (test-enforced). **Transformed** ports (`fogandlight.frag.glsl`, + `fogandlight.vert.glsl`, `shadowcoords.glsl`, `skycolor.glsl`, `vertexwarp.glsl`) turn `#if` on + specialization defines into branches on the constants, with the same expressions and constants, and keep + every function signature (test-enforced). - **Generated and fixed includes:** - `include/bindings.glsl`: sets and bindings, the source of truth (committed). - - `include/frame.glsl`: the FrameGlobals block, generated from `Shaders/FrameGlobals.cs`. A test regenerates - it and fails when the committed file differs. - - `include/specialization.glsl`: constant ids (section 5), mirrored in C# with an agreement test. + - `include/frame.glsl`: the FrameGlobals block, generated by `FrameGlobals.GenerateInclude()`. + `FrameGlobalsTests` regenerates it and fails when the committed file differs + (`OPTIMUM_REGENERATE_NATIVE_INCLUDES=1` rewrites it), and checks the compiled SPIR-V offsets against the + table. Section 3 describes its owner groups. + - `include/specialization.glsl`: constant ids (section 5), mirrored in `Shaders/SpecializationConvention.cs` + with an agreement test. + - `include/varyings.glsl`: interface locations of the varyings the includes declare (`glowLevel`, + `blockLight`, `blockBrightness`, `shadowCoordsFar/Near`, the five colour-map varyings), at locations 16-25. + SPIR-V matches varyings by location, not name, so a program that reads one (`in float glowLevel`) uses the + define. Program varyings use locations 0-15 (`OPTIMUM_LOCATION_PROGRAM_END`). Location 25 fits the 29 + fragment-input locations Mesa's Intel driver reports (116 components on an ADL-S iGPU). - `include/motion.glsl`: the only motion writer (section 7). - **Language:** GLSL 450 with `GL_EXT_scalar_block_layout`, `GL_EXT_nonuniform_qualifier` and `GL_GOOGLE_include_directive`, compiled by the same shaderc library the runtime uses - (`--target-env=vulkan1.3 -O`). + (`--target-env=vulkan1.3 -O`). Quoted includes resolve through shaderc's include callbacks + (`ShaderCompiler.CompileNative`): beside the including file first, then `sources/shaders-vk/include`. + Programs begin with `#version 450`, `#extension GL_EXT_scalar_block_layout : require`, then include + `bindings.glsl`, `frame.glsl` and `specialization.glsl` before any declaration. ## 2. What must stay identical to the GLSL 330 program @@ -62,6 +95,28 @@ family stage. `liquidDepth`) come from `bindings.glsl` under their game names. - **A frame member is used under its own name** only when the program includes the member's owner file, the same rule `FrameGlobals.TryPlace` applies today. Otherwise the member is an ordinary record member. + - **Mechanism.** `frame.glsl` declares the block with the instance name `optimumFrame` and explicit + `layout(offset = N)` per member, and no global names. After the guarded block it holds one group per owner: + `#if defined(OPTIMUM_FRAME_OWNER_)` ... `#define zNear optimumFrame.zNear` ... `#endif`, where + `` is the game file name upper-cased with `.` as `_` (`OPTIMUM_FRAME_OWNER_FOGANDLIGHT_FSH`). Each + group sits outside the include guard behind a guard of its own, so including `frame.glsl` again after + defining another owner macro adds that owner's names. + - **Owner includes activate their own group:** the port of an owner file defines its macro and includes + `frame.glsl` (`fogandlight.frag.glsl`, `fogandlight.vert.glsl`, `shadowcoords.glsl`, `vertexwarp.glsl`, + `skycolor.glsl`, `colormap.vert.glsl`, `underwatereffects.glsl`). + - **Cross-stage owners:** the rule is per program, not per stage. A fragment stage that reads + `flatFogDensity` (declared by `fogandlight.fsh`, owned by `fogandlight.vsh`) in a program whose vertex + stage includes `fogandlight.vert.glsl` defines `OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH` itself before its + includes. Without the macro the name must be a record member. The header's `optimum-program-uniform` + lines list exactly these names per include. + - **Consequence: an active frame name is a macro.** No identifier in code compiled after the group + activates may reuse it: a struct field, parameter or local of that name is rewritten to + `optimumFrame.` and fails to compile. Two shared-include cases existed and are handled: + `vertexwarp.glsl`'s `WarpState` fields carry a `warp` prefix (`st.warpTimeCounter`; nothing outside the + file reads them), and `dither.glsl` lifts `ditherSeed`/`horizontalResolution` with `#undef` around + `NoiseFromPixelPosition`, whose parameters have those names, and restores them after it. Family programs + must do the same for their own code. `NativeShaderIncludeTests` compiles every include with all owner + groups active to catch it. - **Set 1 (textures):** every other sampler is an index into the bindless array of its GLSL type (`optimumTextures2D`, `optimumTextures2DArray`, `optimumTexturesCube`, `optimumTextures2DShadow`, ...). The index is a `uint` push member carrying the sampler's own name (section 4). Indices are uniform over a draw, so @@ -81,14 +136,21 @@ layout(push_constant, scalar) uniform OptimumDraw { uint terrainTex; // sampler slot, same name as the GLSL 330 sampler vec3 origin; // DRAW-frequency uniforms that fit mat4 modelViewMatrix; -} draw; +}; layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram { float alphaTest; // everything else the program declares vec4 rgbaFogIn; -} program; +}; ``` +- **No instance names (verified 2026-09-15):** both blocks are anonymous, so their members are global names + and the program body keeps the GLSL 330 spelling (`texture(optimumTextures2D[terrainTex], uv)`, + `alphaTest`). shaderc compiles both forms in both stages, and scalar layout puts `origin` at offset 4 and + `modelViewMatrix` at 16 (`NativeShaderIncludeTests.AnonymousPushAndRecordBlocksExposeTheirMembersAsGlobalNames`). + A record or push member must not share a name with a frame member whose owner group is active in that + program (section 3). + - **Push block:** - Every non-frame sampler's slot index first (4 B each). - Then the uniforms the frequency map classifies as DRAW (they change between draws without a `Use()`), in @@ -129,8 +191,25 @@ The prefix is `ShaderRegistry.registerDefaultShaderCodePrefixes`, `ShaderRegistr - **Every other define is a specialization constant** declared in `specialization.glsl`, used as `if (OPTIMUM_BLOOM != 0)`: `FXAA`, `SSAOLEVEL` (its value), `NORMALVIEW`, `BLOOM`, `GODRAYS`, `FOAMEFFECT`, `SHINYEFFECT`, `SHADOWQUALITY`, `WAVINGSTUFF`, `MINBRIGHT` (float), `GREEDYMESH_GRAD`. - - `DYNLIGHTS` becomes the loop bound `pointLightQuantity` over arrays fixed at `FrameGlobals.MaxDynamicLights`. + - `DYNLIGHTS` no longer sizes the arrays: they are fixed at `FrameGlobals.MaxDynamicLights` and + `pointLightQuantity` bounds the loop, as it already did. Its value is still a constant, + `OPTIMUM_DYNLIGHTS` (changed 2026-09-15). `fogandlight.vsh`'s `#if DYNLIGHTS == 0` takes a different + path (`applyLightWithoutPointLight`), which also skips the night-vision, `MINBRIGHT` and 1.05-contrast + terms, so a loop bound alone would change pixels with dynamic lights set to 0. - `MAXANIMATEDELEMENTS` is fixed. + - **Ids** (`specialization.glsl`, mirrored in `Shaders/SpecializationConvention.cs`): 0 `OPTIMUM_FXAA`, + 1 `OPTIMUM_SSAOLEVEL`, 2 `OPTIMUM_NORMALVIEW`, 3 `OPTIMUM_BLOOM`, 4 `OPTIMUM_GODRAYS`, 5 `OPTIMUM_FOAMEFFECT`, + 6 `OPTIMUM_SHINYEFFECT`, 7 `OPTIMUM_SHADOWQUALITY`, 8 `OPTIMUM_WAVINGSTUFF`, 9 `OPTIMUM_MINBRIGHT` (float), + 10 `OPTIMUM_GREEDYMESH_GRAD`, 11 `OPTIMUM_DYNLIGHTS`. Every other constant is `int`. + - Defaults are 0, the value an undefined macro has in `#if` and the fallback `fogandlight.vsh` defines; + the runtime specializes every constant. + - `SpecializationConventionTests` checks the include against the C# table, the ids and types in the + compiled SPIR-V, that every define `registerDefaultShaderCodePrefixes` stamps is exactly one of constant + or axis, and that no native include keeps an `#if` on a replaced define. + - **Branch form:** `#if X > 0 ... #else ... #endif` becomes `if (OPTIMUM_X > 0) { ... } else { ... }`. A + variable the GLSL 330 source declared inside one block and read in a later block of the same condition is + declared before the first branch (`getBrightnessFromShadowMap`'s `b`, `calcShadowMapCoords`'s `len`), + initialised to a value no path reads. - **Consequences:** - Declarations a spec-constant branch uses are declared unconditionally, so the name set of section 2 is unaffected (the oracle already sees names inside inactive `#if`s). @@ -184,6 +263,13 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg - **What stays in each program:** the previous-position reconstruction (warp replay, skinning, instance transforms, liquid waves, z-offset replay). The include starts where a previous clip position exists. - **Enforcement:** a source test fails any assignment to `outMotion` outside the include's return values. +- **Delivered (2026-09-15):** the include uses the three-line arithmetic of today's writers in the same + order, so the vector is bit-identical. It is written in the common subset of GLSL 330 and 450. + `TaaMotionIncludeTests` drives it through the device and reads the RGBA16F attachment back, validation + clean: + - the unjittered vector, reactive and writer depth, at three jitters; + - behind the previous camera (`w` of -1, 0 and 1e-6): zero `rg` and `a`, `b` kept; + - reactive-only: `(0, 0, reactive, 0)`. ## 8. Runtime diff --git a/sources/shaders-vk/include/colormap.frag.glsl b/sources/shaders-vk/include/colormap.frag.glsl new file mode 100644 index 00000000..208c2194 --- /dev/null +++ b/sources/shaders-vk/include/colormap.frag.glsl @@ -0,0 +1,66 @@ +// Native port of the game include colormap.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: colormap.fsh +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_COLORMAP_FRAG_GLSL +#define OPTIMUM_INCLUDE_COLORMAP_FRAG_GLSL + +#include "varyings.glsl" + +layout(location = OPTIMUM_LOCATION_CLIMATE_COLOR_MAP_UV) in vec2 climateColorMapUv; +layout(location = OPTIMUM_LOCATION_SEASON_COLOR_MAP_UV) in vec2 seasonColorMapUv; +layout(location = OPTIMUM_LOCATION_FROST_ALPHA) in float frostAlpha; +layout(location = OPTIMUM_LOCATION_SEASON_WEIGHT) in float seasonWeight; +layout(location = OPTIMUM_LOCATION_HERETEMP) in float heretemp; + +vec4 getColorMapped(sampler2D sourceTex, vec4 color) { + vec4 tint = vec4(1); + bool mapped = false; + + if (climateColorMapUv.x >= 0) { + tint = texture(sourceTex, climateColorMapUv); + mapped=true; + } + + if (seasonColorMapUv.x >= 0 && seasonWeight > 0) { + vec4 seasonColor = texture(sourceTex, seasonColorMapUv); + tint = mix(tint, seasonColor, seasonWeight); + mapped=true; + } + + if (frostAlpha > 0) { + float w = clamp((0.333 - heretemp) * 15, 0, 1); + + if (mapped) { + tint.rgb = mix(tint.rgb, tint.rgb * (1 - frostAlpha) + vec3(1) * frostAlpha, w); + } else { + float b = (color.r + color.g + color.b) / 3.0; + + vec3 frostColor = vec3(b + frostAlpha*0.2); + float faw = frostAlpha * w; + color.rgb = color.rgb * (1 - faw) + frostColor * faw; + return color; + } + } + + return color * tint; +} + +vec4 getFrosted(vec4 color) { + if (heretemp < 0.333 && frostAlpha > 0) { + float w = clamp((0.333 - heretemp) * 15, 0, 1); + + float b = (color.r + color.g + color.b) / 3.0; + + vec3 frostColor = vec3(b + frostAlpha*0.2); + float faw = frostAlpha * w; + color.rgb = color.rgb * (1 - faw) + frostColor * faw; + } + return color; +} + +#endif diff --git a/sources/shaders-vk/include/colormap.vert.glsl b/sources/shaders-vk/include/colormap.vert.glsl new file mode 100644 index 00000000..bf011bd3 --- /dev/null +++ b/sources/shaders-vk/include/colormap.vert.glsl @@ -0,0 +1,101 @@ +// Native port of the game include colormap.vsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: colormap.vsh +// optimum-port: verbatim +// optimum-frame-owner: colormap.vsh +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_COLORMAP_VERT_GLSL +#define OPTIMUM_INCLUDE_COLORMAP_VERT_GLSL + +#define OPTIMUM_FRAME_OWNER_COLORMAP_VSH +#include "frame.glsl" +#include "varyings.glsl" +#include "noise3d.glsl" + +layout(location = OPTIMUM_LOCATION_CLIMATE_COLOR_MAP_UV) out vec2 climateColorMapUv; +layout(location = OPTIMUM_LOCATION_SEASON_COLOR_MAP_UV) out vec2 seasonColorMapUv; + +layout(location = OPTIMUM_LOCATION_SEASON_WEIGHT) out float seasonWeight; +layout(location = OPTIMUM_LOCATION_HERETEMP) out float heretemp; +layout(location = OPTIMUM_LOCATION_FROST_ALPHA) out float frostAlpha; + + +void calcColorMapUvs(int colormapData, vec4 worldPos, float sunlightLevel, bool isLeaves) { + int seasonMapIndex = (colormapData & 0x3f) - 1; // a value less than zero signifies no colormap + int climateMapIndex = ((colormapData >> 8) & 0xf) - 1; + int frostableBit = (colormapData >> 12) & 1; + float tempRel = clamp(((colormapData >> 16) & 0xff) / 255.0, 0.001, 0.999); + float rainfallRel = clamp(((colormapData >> 24) & 0xff) / 255.0, 0.001, 0.999); + + frostAlpha=0; + heretemp = tempRel + seasonTemperature; + if (frostableBit > 0 && heretemp < 0.333) { + frostAlpha = (valuenoise(worldPos.xyz / 2) + valuenoise(worldPos.xyz * 2)) * 1.25 - 0.5; + frostAlpha -= max(0.0, 1 - pow(2*sunlightLevel, 10)); + } + + if (climateMapIndex >= 0) { + vec4 rect = colorMapRects[climateMapIndex]; // previously required a fix for Radeon 5000/6000 series GPUs but apparently they are fine with this code (no expression evaluation required to obtain the array index) + climateColorMapUv = vec2( + rect.x + rect.z * tempRel, + rect.y + rect.w * rainfallRel + ); + } else { + climateColorMapUv = vec2(-1,-1); + } + + if (seasonMapIndex >= 0) { + vec4 rect = colorMapRects[seasonMapIndex]; + + float noise; + float b = valuenoise(worldPos.xyz) + valuenoise(worldPos.xyz/2); + + if (isLeaves) { + int perTreeOffset = (colormapData >> 13) & 7; + if (perTreeOffset != 0) { + b += (perTreeOffset / 7.0 - 0.5) * 2.5; + } + + noise = (valuenoise(worldPos.xyz / 6) + valuenoise(worldPos.xyz / 2) - 0.55) * 1.25; + } else { + noise = (valuenoise(worldPos.xyz / 24) + valuenoise(worldPos.xyz / 12) - 0.55) * 1.25; + } + + seasonColorMapUv = vec2( + rect.x + rect.z * clamp(seasonRel + b/40, 0.01, 0.99), + rect.y + rect.w * clamp(noise, 0.5 / (rect.w * atlasHeight), 15.5 / (rect.w * atlasHeight)) + ); + + + + + // different seasonWeight for tropical seasonTints - rich greens based on varying rainfall, but turn this off (anemic / dying appearance) in colder areas + if ((colormapData & 0xc0) == 0x40) + { + // 0.5 - cos(x/42.0)/2.3 + // http://fooplot.com/#W3sidHlwZSI6MCwiZXEiOiIwLjUtY29zKHgvNDIuMCkvMi4zIiwiY29sb3IiOiIjMDAwMDAwIn0seyJ0eXBlIjoxMDAwLCJ3aW5kb3ciOlsiMCIsIjI1NSIsIjAiLCIxIl19XQ-- + + // we dial this down to nothing (leaving dead-looking climate tinted foliage only) if the temperature is below around 0 degrees, browning starts below around 20 degrees + seasonWeight = clamp((tempRel + seasonTemperature / 2) * 0.9 - 0.1, 0.0, 1.0) * clamp(2 * (0.5 - cos(rainfallRel * 255.0 / 42.0)) / 2.1, 0.1, 0.75); + } else { + + // Lets use temperature and also make it so that cold areas are also more affected by seasons + // http://fooplot.com/#W3sidHlwZSI6MCwiZXEiOiIwLjUtY29zKHgvNDIuMCkvMi4zK21heCgwLDEyOC14KS8yNTYvMi1tYXgoMCx4LTEzMCkvMjAwIiwiY29sb3IiOiIjMDAwMDAwIn0seyJ0eXBlIjoxMDAwLCJ3aW5kb3ciOlsiMCIsIjI1NSIsIjAiLCIxIl19XQ-- + + // We need ground level temperature (i.e. reversing the seaLevel adjustment in ClientWorldMap.GetAdjustedTemperature()). This formula is shamelessly copied from TerraGenConfig.cs + float x = tempRel * 255; + float seaLevelDist = worldPos.y - seaLevel; + x += max(0.0, seaLevelDist * 1.5); + + seasonWeight = clamp(0.5 - cos(x / 42.0) / 2.3 + max(0.0, 128 - x) / 256 / 2 - max(0.0,x - 130)/200, 0.0, 1.0); + } + + } else { + seasonColorMapUv = vec2(-1,-1); + } +} + +#endif diff --git a/sources/shaders-vk/include/colorutil.glsl b/sources/shaders-vk/include/colorutil.glsl new file mode 100644 index 00000000..f142af66 --- /dev/null +++ b/sources/shaders-vk/include/colorutil.glsl @@ -0,0 +1,116 @@ +// Native port of the game include colorutil.ash (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: colorutil.ash +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_COLORUTIL_GLSL +#define OPTIMUM_INCLUDE_COLORUTIL_GLSL + +vec3 rgb2hsv(vec3 c) +{ + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +vec3 hsv2rgb(vec3 c) +{ + vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} + + + + +float hue2rgb(float f1, float f2, float hue) { + if (hue < 0.0) + hue += 1.0; + else if (hue > 1.0) + hue -= 1.0; + float res; + if ((6.0 * hue) < 1.0) + res = f1 + (f2 - f1) * 6.0 * hue; + else if ((2.0 * hue) < 1.0) + res = f2; + else if ((3.0 * hue) < 2.0) + res = f1 + (f2 - f1) * ((2.0 / 3.0) - hue) * 6.0; + else + res = f1; + return res; +} + +vec3 hsl2rgb(vec3 hsl) { + vec3 rgb; + + if (hsl.y == 0.0) { + rgb = vec3(hsl.z); // Luminance + } else { + float f2; + + if (hsl.z < 0.5) + f2 = hsl.z * (1.0 + hsl.y); + else + f2 = hsl.z + hsl.y - hsl.y * hsl.z; + + float f1 = 2.0 * hsl.z - f2; + + rgb.r = hue2rgb(f1, f2, hsl.x + (1.0/3.0)); + rgb.g = hue2rgb(f1, f2, hsl.x); + rgb.b = hue2rgb(f1, f2, hsl.x - (1.0/3.0)); + } + return rgb; +} + +vec3 hsl2rgb(float h, float s, float l) { + return hsl2rgb(vec3(h, s, l)); +} + +vec3 rgb2hsl(vec3 color) { + vec3 hsl; // init to 0 to avoid warnings ? (and reverse if + remove first part) + + float fmin = min(min(color.r, color.g), color.b); //Min. value of RGB + float fmax = max(max(color.r, color.g), color.b); //Max. value of RGB + float delta = fmax - fmin; //Delta RGB value + + hsl.z = (fmax + fmin) / 2.0; // Luminance + + if (delta == 0.0) //This is a gray, no chroma... + { + hsl.x = 0.0; // Hue + hsl.y = 0.0; // Saturation + } else //Chromatic data... + { + if (hsl.z < 0.5) + hsl.y = delta / (fmax + fmin); // Saturation + else + hsl.y = delta / (2.0 - fmax - fmin); // Saturation + + float deltaR = (((fmax - color.r) / 6.0) + (delta / 2.0)) / delta; + float deltaG = (((fmax - color.g) / 6.0) + (delta / 2.0)) / delta; + float deltaB = (((fmax - color.b) / 6.0) + (delta / 2.0)) / delta; + + if (color.r == fmax) + hsl.x = deltaB - deltaG; // Hue + else if (color.g == fmax) + hsl.x = (1.0 / 3.0) + deltaR - deltaB; // Hue + else if (color.b == fmax) + hsl.x = (2.0 / 3.0) + deltaG - deltaR; // Hue + + if (hsl.x < 0.0) + hsl.x += 1.0; // Hue + else if (hsl.x > 1.0) + hsl.x -= 1.0; // Hue + } + + return hsl; + } + +#endif diff --git a/sources/shaders-vk/include/dither.glsl b/sources/shaders-vk/include/dither.glsl new file mode 100644 index 00000000..f81281f5 --- /dev/null +++ b/sources/shaders-vk/include/dither.glsl @@ -0,0 +1,39 @@ +// Native port of the game include dither.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: dither.fsh +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_DITHER_GLSL +#define OPTIMUM_INCLUDE_DITHER_GLSL + +// NoiseFromPixelPosition's last two parameters carry the names of skycolor.fsh's frame members. +// Where skycolor's names are active they are macros, which would rewrite the parameter +// declarations, so they are lifted around the function and restored after it; the function +// reads its parameters, exactly as the GLSL 330 one did. +#ifdef OPTIMUM_FRAME_NAMES_SKYCOLOR_FSH +#undef ditherSeed +#undef horizontalResolution +#endif + +// Excellent dither method by Krishty +// http://old.zfx.info/DisplayThread.php?TID=24491 +vec4 NoiseFromPixelPosition(ivec2 PixelsPosition, int ditherSeed, int horizontalResolution) { + + int PixelsIndex = horizontalResolution * PixelsPosition.y + PixelsPosition.x; + int PixelsArea = PixelsPosition.x * PixelsPosition.y; + + ivec4 vPixelsIndex = ditherSeed + PixelsIndex * ivec4(41, 29, 53, 43); + ivec4 vPixelsArea = ditherSeed + PixelsArea * ivec4(23, 59, 47, 37); + + return (vec4((vPixelsIndex ^ vPixelsArea) % 661) / 330.5 - 1.0) / 128; +} + +#ifdef OPTIMUM_FRAME_NAMES_SKYCOLOR_FSH +#define ditherSeed optimumFrame.ditherSeed +#define horizontalResolution optimumFrame.horizontalResolution +#endif + +#endif diff --git a/sources/shaders-vk/include/fogandlight.frag.glsl b/sources/shaders-vk/include/fogandlight.frag.glsl new file mode 100644 index 00000000..681815c3 --- /dev/null +++ b/sources/shaders-vk/include/fogandlight.frag.glsl @@ -0,0 +1,360 @@ +// Native port of the game include fogandlight.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: fogandlight.fsh +// optimum-port: transformed +// optimum-frame-owner: fogandlight.fsh +// optimum-frame-texture: sampler2DShadow shadowMapFar +// optimum-frame-texture: sampler2DShadow shadowMapNear +// optimum-program-uniform: float flatFogDensity +// optimum-program-uniform: float flatFogStart +// optimum-program-uniform: float viewDistance +// optimum-program-uniform: float viewDistanceLod0 +// optimum-program-uniform: float windWaveCounter +// optimum-program-symbol: vec4 rgbaFog +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_FOGANDLIGHT_FRAG_GLSL +#define OPTIMUM_INCLUDE_FOGANDLIGHT_FRAG_GLSL + +#include "bindings.glsl" +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_FSH +#include "frame.glsl" +#include "varyings.glsl" +#include "specialization.glsl" + +// SHADOWQUALITY is the specialization constant OPTIMUM_SHADOWQUALITY: these inputs are +// declared unconditionally, and getBrightnessFromShadowMap branches on the constant. +layout(location = OPTIMUM_LOCATION_BLOCK_BRIGHTNESS) in float blockBrightness; +layout(location = OPTIMUM_LOCATION_SHADOW_COORDS_FAR) in vec4 shadowCoordsFar; +layout(location = OPTIMUM_LOCATION_SHADOW_COORDS_NEAR) in vec4 shadowCoordsNear; + +const float Epsilon = 0.001; + + + +#include "noise3d.glsl" +#include "vertexflagbits.glsl" +#include "fogspheres.glsl" + +vec4 applyFrostEffect(float frostAlpha, vec4 texColor, vec3 normal, vec3 noisepos) { + if (frostAlpha > 0) { + noisepos = round(noisepos * 32.0) / 32; + noisepos.xyz *= 1.5; + + frostAlpha*=1+max(0.0, normal.y/3); + frostAlpha *= (valuenoise(noisepos * 2) + valuenoise(noisepos * 12)) * 1.25 - 0.25; + + float heretemp = -10; + float w = clamp((0.333 - heretemp) * 15, 0, 1); + + vec3 frostColor = vec3(1); + float faw = frostAlpha * w; + texColor.rgb = texColor.rgb * (1 - faw) + frostColor * faw; + } + + return texColor; +} + +vec3 palette( float t ){ + vec3 a = vec3((-sin(windWaveCounter/15.0*1.32456)), cos(1/25.0*0.76354), sin(windWaveCounter/14.5)); + vec3 b = vec3(.75,.25,.65); + vec3 c = vec3(1.,1.,1.); + vec3 d = vec3(0.263,0.416,0.557); + return a*b-tan( 6.28318*(c*t+d) ); +} + +vec4 applyPsychedelicEffect(vec4 texColor, vec3 rustVec, int sub) { + if (texColor.a <= 0) return texColor; + + float df = clamp((gl_FragCoord.w*20 - 0.3) * 20, 0.09, 1); + + vec3 uv = rustVec; + + vec3 uv0 = uv; + vec3 fcol = vec3(-0.01, -0.01, -0.01); + float f = max(5, 15.0 * clamp((df + 0.2)/3.0, 0, 1)); + + float t = windWaveCounter / 15.5; + + for (float i =1.0; i 0) fcol = -2*fcol; + + float b = min(1, (texColor.r+texColor.g+texColor.b)); + + vec3 outcolor = mix(texColor.rgb, texColor.rgb + b*fcol.rgb, psychedelicStrength); + + return vec4(outcolor.r, outcolor.g, outcolor.b, texColor.a); +} + +vec4 applyRustEffect(vec4 texColor, vec3 normal, vec3 rustVec, int spotty) { + float f = clamp(gl_FragCoord.w*3 - 0.3, 0, 1); + if (f <= 0) return texColor; + + float b = clamp(((texColor.r + texColor.g + texColor.b) / 3.0 ) * 10, 0, 1); + float intensity = b * glitchStrength; + if (spotty > 0) intensity *= max(0.0, cnoise(vec3(rustVec.x * 0.35, rustVec.y * 0.35, rustVec.z * 0.35)) + 0.3); + else intensity *= 1.3 * max(0.0, cnoise(vec3(rustVec.x * 1.5, rustVec.y * 1.5, rustVec.z * 0.35)) + 0.35); + + if (intensity < 0.01) return texColor; + + float uvx = round(rustVec.x * 32.0) / 100.0 / 32; + float uvy = round(rustVec.y * 32.0) / 100.0 / 32; + float uvz = round(rustVec.z * 32.0) / 100.0 / 32; + + if (normal.y < 0.5) { + float val = 0.2 * cnoise(vec3(uvx*3000, uvy*700 + windWaveCounter * 1.5, uvz*3000 + windWaveCounter / 5)) + 0.1 * cnoise(vec3(uvx*15000, uvy*3500 + windWaveCounter, uvz*15000 + windWaveCounter / 5)); + texColor.rgb += intensity * val; + } else { + float val = 0.2 * cnoise(vec3(uvx*700, uvy*700 + windWaveCounter / 2, uvz*700)) + 0.1 * cnoise(vec3(uvx*15000, uvy*3500 + windWaveCounter / 5, uvz*15000)); + texColor.rgb += intensity * val; + } + + return texColor; +} + +vec4 applyReflectiveEffect(vec4 texColor, inout float glow, int renderFlags, vec2 uv, vec3 normal, vec4 worldPos, vec4 camPos, vec3 blockLight) { + if ((renderFlags & ReflectiveBitMask) == 0) return texColor; + + // We use the wind data bits as the reflective mode + // This unfortunately means we can't have something reflective *and* wind affected + int windMode = (renderFlags >> 29) & 0x7; + + if (windMode == ReflectiveModeWeak) { + vec3 worldVec = normalize(worldPos.xyz); + + float uvx = round(uv.x * 64 * 32 * 1.0) * 4.0 / 32; + float uvy = round(uv.y * 64 * 32 * 1.0) * 4.0 / 32; + float uvz = round(1.0 * 32) * 8.0 / 32; + + float fd = 1 * (gnoise(vec3(uvx, uvy, uvz))); + fd *= 25*gnoise(round(worldPos.xyz * 20.0) / 30); + fd = max(0.0,fd + 1); + float nb = max(0.1, 0.5 * dot(normal, lightPosition)); + + texColor.rgb*= 1.0 +vec3(nb * fd) / 2.0; + texColor.a = clamp(texColor.a + nb*fd, texColor.a/2, 1); + glow+=nb*fd * 0.15; + + return texColor; + } + + if (windMode == ReflectiveModeMedium) { + vec3 worldVec = normalize(worldPos.xyz); + + float uvx = round(uv.x * 64 * 32 * 1.0) * 8.0 / 32; + float uvy = round(uv.y * 64 * 32 * 1.0) * 8.0 / 32; + float uvz = round(1 * 32.0) * 8.0 / 32; + + float fd = 1 * (gnoise(vec3(uvx, uvy, uvz))); + fd *= 15*gnoise(round(worldPos.xyz * 30.0) / 30); + fd = max(0.0,fd + 1); + float nb = max(0.1, 0.5 * dot(normal, lightPosition)); + + if (windMode == ReflectiveModeMild) fd/=3; + + texColor.rgb*= 1.0 +vec3(nb * fd) / 2.0; + glow+=nb*fd * 0.15; + + return texColor; + } + + if (windMode == ReflectiveModeStrong || windMode == ReflectiveModeMild) { + vec3 worldVec = normalize(worldPos.xyz); + + float uvx = round(uv.x * 64 * 32 * 1.0) * 8.0 / 32; + float uvy = round(uv.y * 64 * 32 * 1.0) * 8.0 / 32; + float uvz = round(1 * 32.0) * 8.0 / 32; + + float fd = 1 * (gnoise(vec3(uvx, uvy, uvz))); + fd *= 25*gnoise(round(worldPos.xyz * 100.0) / 30); + fd = max(0.0,fd + 1); + float nb = max(0.1, 0.5 * dot(normal, lightPosition)); + texColor.rgb*= 1.4 +vec3(nb * fd) / 2.0; + glow+=nb*fd * 0.15; + + return texColor; + } + + if (windMode == ReflectiveModeSparkly) { + + vec3 worldVec = normalize(worldPos.xyz); + float mul=3; + float uvx = round(uv.x * 64 * 32 * 2.0) * 8.0 / 32; + float uvy = round(uv.y * 64 * 32 * 2.0) * 8.0 / 32; + + float fd = 1 * (gnoise(vec3(uvx, uvy, 0))); + fd *= 50*gnoise(round(camPos.xyz * 150.0) / 30); + fd = max(0.0,fd + 1); + float nb = max(0.1, 0.5 * dot(normal, lightPosition)); + texColor.rgb*=1+vec3(nb * fd) / 2.0; + glow+=nb*fd * 0.03; + + return texColor; + } + + if (windMode==5) { + texColor.rgb = vec3(1); + } + + return texColor; +} + + +float linearDepth(float depthSample) +{ + depthSample = 2.0 * depthSample - 1.0; + float zLinear = 2.0 * zNear / (zFar + zNear - depthSample * (zFar - zNear)); + return zLinear; +} + +// result suitable for assigning to gl_FragDepth +float depthSample(float linearDepth) +{ + float nonLinearDepth = (zFar + zNear - 2.0 * zNear * zFar / linearDepth) / (zFar - zNear); + nonLinearDepth = (nonLinearDepth + 1.0) / 2.0; + return nonLinearDepth; +} + + + + +vec4 applyFog(vec4 rgbaPixel, float fogWeight) { + return vec4(mix(rgbaPixel.rgb, rgbaFog.rgb, fogWeight), rgbaPixel.a); +} + + +float getBrightnessFromShadowMap() { + // b was declared inside the SHADOWQUALITY > 0 block; the > 1 block only ever ran + // after it, so declaring it up front changes no value that is read. + float b = 1.0; + if (OPTIMUM_SHADOWQUALITY > 0) { + float totalFar = 9.0; + if (shadowCoordsFar.w > 0) { + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + totalFar -= texture (shadowMapFar, vec3(shadowCoordsFar.xy + vec2(x * shadowMapWidthInv, y * shadowMapHeightInv), shadowCoordsFar.z - 0.0009)); + } + } + } + totalFar /= 9.0; + + + b = 1.0 - shadowIntensity * totalFar * shadowCoordsFar.w * 0.5; + } + + + if (OPTIMUM_SHADOWQUALITY > 1) { + float totalNear = 9.0; + if (shadowCoordsNear.w > 0) { + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + totalNear -= texture (shadowMapNear, vec3(shadowCoordsNear.xy + vec2(x * shadowMapWidthInv, y * shadowMapHeightInv), shadowCoordsNear.z - 0.0005)); + } + } + } + + totalNear /= 9.0; + + + + + b -= shadowIntensity * totalNear * shadowCoordsNear.w * 0.5; + } + + if (OPTIMUM_SHADOWQUALITY > 0) { + b = clamp(b + blockBrightness, 0, 1); + return b; + } + + return 1.0; +} + + +float getBrightnessFromNormal(vec3 normal, float normalShadeIntensity, float minNormalShade) { + + // Option 2: Completely hides peter panning, but makes semi sunfacing block sides pretty dark + float nb = max(minNormalShade, 0.5 + 0.5 * dot(normal, lightPosition)); + + // Let's also define that diffuse light from the sky provides an additional brightness boost for up facing stuff + // because the top side of blocks being darker than the sides is uncanny o__O + nb = max(nb, normal.y * 0.95); + + return mix(1, nb, normalShadeIntensity); +} + + +vec4 applyFogAndShadow(vec4 rgbaPixel, float fogWeight) { + float b = getBrightnessFromShadowMap(); + rgbaPixel *= vec4(b, b, b, 1); + + return applyFog(rgbaPixel, fogWeight); +} + +vec4 applyFogAndShadowWithNormal(vec4 rgbaPixel, float fogAmount, vec3 normal, float normalShadeIntensity, float minNormalShade, vec3 worldPos) { + float b = getBrightnessFromShadowMap(); + float nb = getBrightnessFromNormal(normal, normalShadeIntensity, minNormalShade); + + b = min(b, nb); + b *= 1+max(0.0, shadowIntensity * 2.0 - 1.66) / 1.5; + + rgbaPixel *= vec4(b, b, b, 1); + + vec4 outcolor = applyFog(rgbaPixel, fogAmount); + outcolor = applySpheresFog(outcolor, fogAmount, worldPos); + return outcolor; +} + +vec4 applyFogAndShadowFromBrightness(vec4 rgbaPixel, float fogAmount, float b, vec3 worldPos) { + b *= 1+max(0.0, shadowIntensity * 2.0 - 1.66) / 1.5; + + rgbaPixel *= vec4(b, b, b, 1); + + vec4 outcolor = applyFog(rgbaPixel, fogAmount); + outcolor = applySpheresFog(outcolor, fogAmount, worldPos); + + return outcolor; +} + + +float getFogLevel(float fogMin, float fogDensity, float worldPosY) { + float depth = gl_FragCoord.z; + float clampedDepth = min(250, depth); + float heightDiff = worldPosY - flatFogStart; + + //float extraDistanceFog = max(-flatFogDensity * flatFogStart / (160 + heightDiff * 3), 0); // heightDiff*3 seems to fix distant mountains being supper fogged on most flat fog values + // ^ this breaks stuff. Also doesn't seem to be needed? Seems to work fine without + + float extraDistanceFog = max(-flatFogDensity * clampedDepth * (flatFogStart) / 60, 0); // div 60 was 160 before, at 160 thick flat fog looks broken when looking at trees + + float distanceFog = 1 - 1 / exp(clampedDepth * (fogDensity + extraDistanceFog)); + float flatFog = 1 - 1 / exp(heightDiff * flatFogDensity); + + float val = max(flatFog, distanceFog); + float nearnessToPlayer = clamp((8-depth)/8, 0, 0.9); + val = max(min(0.04, val), val - nearnessToPlayer); + + // Needs to be added after so that underwater fog still gets applied. + val += fogMin; + + return clamp(val, 0, 1); +} + +#endif diff --git a/sources/shaders-vk/include/fogandlight.vert.glsl b/sources/shaders-vk/include/fogandlight.vert.glsl new file mode 100644 index 00000000..7f1c8907 --- /dev/null +++ b/sources/shaders-vk/include/fogandlight.vert.glsl @@ -0,0 +1,200 @@ +// Native port of the game include fogandlight.vsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: fogandlight.vsh +// optimum-port: transformed +// optimum-frame-owner: fogandlight.vsh +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_FOGANDLIGHT_VERT_GLSL +#define OPTIMUM_INCLUDE_FOGANDLIGHT_VERT_GLSL + +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "frame.glsl" +#include "varyings.glsl" +#include "specialization.glsl" +#include "vertexflagbits.glsl" + +// DYNLIGHTS, MINBRIGHT and SHADOWQUALITY are the specialization constants OPTIMUM_DYNLIGHTS, +// OPTIMUM_MINBRIGHT and OPTIMUM_SHADOWQUALITY, whose defaults (0) are the #ifndef fallbacks +// this file used to define. The point-light arrays live in the frame block at +// FrameGlobals.MaxDynamicLights; pointLightQuantity still bounds the loop, as it did. +layout(location = OPTIMUM_LOCATION_BLOCK_BRIGHTNESS) out float blockBrightness; + +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) out float glowLevel; +layout(location = OPTIMUM_LOCATION_BLOCK_LIGHT) out vec3 blockLight; + +#include "fogspheres.glsl" + + +vec4 applyLightWithoutPointLight(vec4 sunColor, vec4 blockColor, float bGlow) { + float bSun = (sunColor.r + sunColor.g + sunColor.b)/3; + float bBlock = (blockColor.r + blockColor.g + blockColor.b)/3; + + // 1. Mix colors according to their brightness (very bright light has more influence on the color) + vec4 rgba = (2 * bSun * sunColor + bBlock * blockColor) / (2 * bSun + bBlock); + + // 2. Fix brightness + rgba *= max(bGlow, max(bSun, bBlock)) / ((rgba.r + rgba.g + rgba.b) / 3); + + if (OPTIMUM_SHADOWQUALITY > 0) { + blockBrightness = clamp(max(bGlow, bBlock) - bSun/2, 0.0, 1.0); + } + + // 4. Always fully opaque + rgba.a = 1; + + return rgba; +} + +vec4 getPointLightRgbv(vec3 worldPos, float sunlightIntensity) { + if (OPTIMUM_DYNLIGHTS == 0) { + return vec4(0); + } + + vec4 pointColSum = vec4(0); + float bPointBrightSum = 0; + + for (int i = 0; i < pointLightQuantity; i++) { + float lightDistance = length(vec3(worldPos.x - pointLights[i].x, worldPos.y - pointLights[i].y, worldPos.z - pointLights[i].z)); + vec3 plc = pointLightColors[i]; + if (plc.r + plc.g + plc.b < 0.02) continue; + + vec3 color = normalize(plc); + float range = plc.r / color.r; + + // fugly hack for lightning + float extra = 1 - sunlightIntensity; + if (range >= 50) extra = 25; + + range = min(25, range); + if (lightDistance > range) continue; + + float bright = (color.r + color.g + color.b) / 3; + + float strength = max(0, bright * (max(1 - lightDistance / range, 0) + min(range/15, 1 / lightDistance) * extra)); + + pointColSum.w = max(pointColSum.w, strength); + bPointBrightSum += strength; + + pointColSum.r += color.r * strength; + pointColSum.g += color.g * strength; + pointColSum.b += color.b * strength; + } + + if (bPointBrightSum > 0) { + pointColSum.rgb /= max(1, bPointBrightSum); + } + + pointColSum.w /= max(1, glitchStrengthFL * 2); + + return pointColSum; +} + + +// sunColor = color of the ambient light, or the sun color really +// lightColor = rgb is block light, a is sun light brightness +vec4 applyLight(vec3 ambientColor, vec4 lightColor, int renderFlags, vec4 worldPos) { + + float bGlow = glowLevel = (renderFlags & GlowLevelBitMask) / 256.0; + float contrast = 1.05; + + vec3 blockLightColor = lightColor.rgb; + vec3 sunLightColor = max(0.001, lightColor.a) * ambientColor.rgb; // Ugly fix a weird bug where deep ocean gets pitch black at a distance one lightColor.a reaches 0 or negative? + + if (OPTIMUM_DYNLIGHTS == 0) { + return applyLightWithoutPointLight(vec4(sunLightColor ,1), vec4(blockLightColor,1), bGlow); + } + + + // Sun brightness + float bSun = (sunLightColor.r + sunLightColor.g + sunLightColor.b)/3; + // Block brightness + float bBlock = (blockLightColor.r + blockLightColor.g + blockLightColor.b)/3; + + vec4 pointColSum = getPointLightRgbv(worldPos.xyz, bSun); + + + if (nightVisionStrength > 0) { + pointColSum += vec4(0.1, 0.5, 0.1, 0.45) * nightVisionStrength; + sunLightColor = mix(sunLightColor, vec3(0.1, 0.5, 0.1), nightVisionStrength); + bSun += nightVisionStrength; + } + + + // Point light brightness + float bPoint = pointColSum.w; + + bBlock /= max(1, glitchStrengthFL * 2); + + // Light up all caves + bBlock = max(OPTIMUM_MINBRIGHT, bBlock); + + bPoint /= max(1, glitchStrengthFL * 2); + + // 1. Mix colors according to their brightness (very bright light has more influence on the color) + vec3 rgba = (2 * bSun * sunLightColor + bBlock * blockLightColor + bPoint * pointColSum.rgb) / (2 * bSun + bBlock + bPoint); + + // 2. Fix brightness + float bMax = max(bGlow, max(bPoint, max(bSun, bBlock))); + + blockLight = rgba; + + if (OPTIMUM_SHADOWQUALITY > 0) { + blockBrightness = clamp(max(bGlow, max(bPoint, bBlock)) - bSun/2, 0.0, 1.0); + } + + + rgba *= bMax / ((rgba.r + rgba.g + rgba.b) / 3); + + rgba *= 1 + bGlow/4; + + rgba *= contrast; + + /*if (nightVisionStrength > 0) + { + vec3 nightvision = vec3( + clamp(rgba.r - 0.5, 0.0, 1.0) * 2, + clamp(rgba.g - 0.5, 0.0, 1.0) * 1.5, + clamp(rgba.b - 0.5, 0.0, 1.0) * 2 + ); + rgba.rgb = mix(rgba.rgb, nightvision, nightVisionStrength); + }*/ + + return vec4(rgba, 1); +} + + + +float getFogLevel(vec4 worldPos, float fogMin, float fogDensity) { + float depth = length(worldPos.xyz); + float clampedDepth = min(250, depth); + float heightDiff = worldPos.y - flatFogStart; + float extraDistanceFog = max(-flatFogDensity * clampedDepth * (flatFogStart) / 60, 0); // div 60 was 160 before, at 160 thick flat fog looks broken when looking at trees + float distanceFog = 1 - 1 / exp(clampedDepth * fogDensity + extraDistanceFog); + + float flatFog = 1 - 1 / exp(heightDiff * flatFogDensity); + + float val = max(flatFog, distanceFog); + float nearnessToPlayer = clamp((8-depth)/8, 0.0, 0.9); + val = max(min(0.04, val), val - nearnessToPlayer); + + // Needs to be added after so that underwater fog still gets applied. + val += fogMin; + + return clamp(val, 0.0, 1.0); +} + + + +vec4 applyFog(vec4 worldPos, vec4 rgbaPixel, vec4 rgbaFog, float fogMin, float fogDensity) { + float amount = getFogLevel(worldPos, fogMin, fogDensity); + vec4 outcolor = vec4(mix(rgbaPixel.rgb, rgbaFog.rgb, amount), rgbaPixel.a * rgbaFog.a); + + outcolor = applySpheresFog(outcolor, amount, worldPos.xyz); + + return outcolor; +} + +#endif diff --git a/sources/shaders-vk/include/fogspheres.glsl b/sources/shaders-vk/include/fogspheres.glsl new file mode 100644 index 00000000..b0bbd0f2 --- /dev/null +++ b/sources/shaders-vk/include/fogspheres.glsl @@ -0,0 +1,138 @@ +// Native port of the game include fogspheres.ash (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: fogspheres.ash +// optimum-port: verbatim +// optimum-program-uniform: float fogSpheres[3 * 8] +// optimum-program-uniform: int fogSphereQuantity +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_FOGSPHERES_GLSL +#define OPTIMUM_INCLUDE_FOGSPHERES_GLSL + +// const int MaxSpheres = 3; +/// Each sphere has 8 floats: +/// 3 floats x/y/z offset to the player +/// 1 float radius +/// 1 float density +/// 3 floats rgb color + + +float getSpheresFogAmount(vec3 worldPos) { + if (fogSphereQuantity == 0) return 0.0; + + float depth = length(worldPos); + float fogamount = 0; + + for (int i = 0; i < fogSphereQuantity; i++) { + vec3 L = vec3(fogSpheres[i * 8], fogSpheres[i * 8 +1], fogSpheres[i * 8 + 2]); + float radius = fogSpheres[i * 8 + 3]; + float density = fogSpheres[i * 8 + 4]; + + // https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-sphere-intersection.html + // Geometric solution + vec3 D = normalize(worldPos.xyz); + + // Are we inside the sphere? + float outsideness = length(L) / radius; + float tca = dot(L, D); + + // We are inside the sphere looking + if (outsideness < 1) { + float thc=0; + if (tca >= 0) { + thc = sqrt(radius*radius - dot(L,L) + tca*tca); + } else { + thc = sqrt(radius*radius - dot(L,L)); + } + + thc = min(thc, depth); + fogamount += thc * density; + } else { + + if (tca >= 0) { + float d2 = dot(L, L) - tca * tca; + if (d2 < radius * radius) { + float thc = sqrt(radius * radius - d2); + float t0 = tca - thc; + float t1 = tca + thc; + + float tf = depth; // t of the vertex. Might be inside our sphere + + // So either use the exit point of the sphere (t1), or vertex depth, whichever is nearer + t1 = min(tf, t1); + + if (tf > t0) { + fogamount += (t1 - t0) * density; + } + } + } + } + } + + return fogamount; +} + + +vec4 applySpheresFog(vec4 color, float standardFogAmount, vec3 worldPos) { + if (fogSphereQuantity == 0) return color; + + float depth = length(worldPos); + + for (int i = 0; i < fogSphereQuantity; i++) { + vec3 L = vec3(fogSpheres[i * 8], fogSpheres[i * 8 +1], fogSpheres[i * 8 + 2]); + float radius = fogSpheres[i * 8 + 3]; + float density = fogSpheres[i * 8 + 4]; + vec3 fogrgb = vec3(fogSpheres[i * 8 + 5], fogSpheres[i * 8 + 6], fogSpheres[i * 8 + 7]); + + float fogamount = 0; + + // https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-sphere-intersection.html + // Geometric solution + vec3 D = normalize(worldPos.xyz); + + // Are we inside the sphere? + float outsideness = length(L) / radius; + float tca = dot(L, D); + + // We are inside the sphere looking + if (outsideness < 1) { + float thc=0; + if (tca >= 0) { + thc = sqrt(radius*radius - dot(L,L) + tca*tca); + } else { + thc = sqrt(radius*radius - dot(L,L)); + } + + thc = min(thc, depth); + fogamount = thc * density; + } else { + + if (tca >= 0) { + float d2 = dot(L, L) - tca * tca; + if (d2 < radius * radius) { + float thc = sqrt(radius * radius - d2); + float t0 = tca - thc; + float t1 = tca + thc; + + float tf = depth; // t of the vertex. Might be inside our sphere + + // So either use the exit point of the sphere (t1), or vertex depth, whichever is nearer + t1 = min(tf, t1); + + if (tf > t0) { + fogamount = (t1 - t0) * density; + } + } + } + } + + color.rgb = mix(color.rgb, fogrgb, clamp(fogamount - (standardFogAmount - fogamount), 0, 1)); + } + + + return color; +} + +#endif diff --git a/sources/shaders-vk/include/frame.glsl b/sources/shaders-vk/include/frame.glsl new file mode 100644 index 00000000..d29c912e --- /dev/null +++ b/sources/shaders-vk/include/frame.glsl @@ -0,0 +1,157 @@ +// Generated from Optimum.Render.Vulkan/Shaders/FrameGlobals.cs (FrameGlobals.GenerateInclude). +// Do not edit: FrameGlobalsTests regenerates this file and fails on any difference. +// +// The FrameGlobals block (docs/vulkan-native-shaders.md section 3): set 0, binding 0, scalar +// layout, bound with a dynamic offset. Members sit at the offsets the renderer writes. +// +// No member is a global name here. A member is the shared frame value only in a program that +// includes the member's owner file, so every owner has its own group of defines below. An +// owner include (fogandlight.frag.glsl, vertexwarp.glsl, ...) defines its owner macro and +// includes this file, which activates its group. A program whose other stage includes an +// owner defines that owner's macro itself before its includes (for example +// OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH in a fragment stage that reads flatFogDensity), and a +// program that includes no owner of a name declares the name in its own record instead. + +#ifndef OPTIMUM_FRAME_GLSL +#define OPTIMUM_FRAME_GLSL + +#extension GL_EXT_scalar_block_layout : require + +#include "bindings.glsl" + +layout(set = OPTIMUM_SET_FRAME, binding = OPTIMUM_BINDING_FRAME_GLOBALS, scalar) uniform OptimumFrameGlobals +{ + layout(offset = 0) float zNear; + layout(offset = 4) float zFar; + layout(offset = 8) vec3 lightPosition; + layout(offset = 20) float shadowIntensity; + layout(offset = 24) float glitchStrength; + layout(offset = 28) float psychedelicStrength; + layout(offset = 32) float shadowMapWidthInv; + layout(offset = 36) float shadowMapHeightInv; + layout(offset = 40) float viewDistance; + layout(offset = 44) float viewDistanceLod0; + layout(offset = 48) int fogSphereQuantity; + layout(offset = 52) int pointLightQuantity; + layout(offset = 56) float flatFogDensity; + layout(offset = 60) float flatFogStart; + layout(offset = 64) float glitchStrengthFL; + layout(offset = 68) float nightVisionStrength; + layout(offset = 72) float shadowRangeNear; + layout(offset = 76) float shadowRangeFar; + layout(offset = 80) float timeCounter; + layout(offset = 84) float windWaveCounter; + layout(offset = 88) float windWaveCounterHighFreq; + layout(offset = 92) float windSpeed; + layout(offset = 96) float waterWaveCounter; + layout(offset = 100) vec3 playerpos; + layout(offset = 112) float globalWarpIntensity; + layout(offset = 116) float glitchWaviness; + layout(offset = 120) float windWaveIntensity; + layout(offset = 124) float waterWaveIntensity; + layout(offset = 128) int perceptionEffectId; + layout(offset = 132) float perceptionEffectIntensity; + layout(offset = 136) float fogWaveCounter; + layout(offset = 140) float sunsetMod; + layout(offset = 144) int ditherSeed; + layout(offset = 148) int horizontalResolution; + layout(offset = 152) float playerToSealevelOffset; + layout(offset = 156) float seasonRel; + layout(offset = 160) float seaLevel; + layout(offset = 164) float atlasHeight; + layout(offset = 168) float seasonTemperature; + layout(offset = 172) float cameraUnderwater; + layout(offset = 176) vec4 waterMurkColor; + layout(offset = 192) mat4 toShadowMapSpaceMatrixNear; + layout(offset = 256) mat4 toShadowMapSpaceMatrixFar; + layout(offset = 320) float fogSpheres[24]; + layout(offset = 416) vec4 colorMapRects[40]; + layout(offset = 1056) vec3 pointLights[100]; + layout(offset = 2256) vec3 pointLightColors[100]; +} optimumFrame; + +// Block size: 3456 bytes. + +#endif + +// fogandlight.fsh +#if defined(OPTIMUM_FRAME_OWNER_FOGANDLIGHT_FSH) && !defined(OPTIMUM_FRAME_NAMES_FOGANDLIGHT_FSH) +#define OPTIMUM_FRAME_NAMES_FOGANDLIGHT_FSH +#define zNear optimumFrame.zNear +#define zFar optimumFrame.zFar +#define lightPosition optimumFrame.lightPosition +#define shadowIntensity optimumFrame.shadowIntensity +#define glitchStrength optimumFrame.glitchStrength +#define psychedelicStrength optimumFrame.psychedelicStrength +#define shadowMapWidthInv optimumFrame.shadowMapWidthInv +#define shadowMapHeightInv optimumFrame.shadowMapHeightInv +#endif + +// fogandlight.vsh +#if defined(OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH) && !defined(OPTIMUM_FRAME_NAMES_FOGANDLIGHT_VSH) +#define OPTIMUM_FRAME_NAMES_FOGANDLIGHT_VSH +#define viewDistance optimumFrame.viewDistance +#define viewDistanceLod0 optimumFrame.viewDistanceLod0 +#define fogSphereQuantity optimumFrame.fogSphereQuantity +#define pointLightQuantity optimumFrame.pointLightQuantity +#define flatFogDensity optimumFrame.flatFogDensity +#define flatFogStart optimumFrame.flatFogStart +#define glitchStrengthFL optimumFrame.glitchStrengthFL +#define nightVisionStrength optimumFrame.nightVisionStrength +#define fogSpheres optimumFrame.fogSpheres +#define pointLights optimumFrame.pointLights +#define pointLightColors optimumFrame.pointLightColors +#endif + +// shadowcoords.vsh +#if defined(OPTIMUM_FRAME_OWNER_SHADOWCOORDS_VSH) && !defined(OPTIMUM_FRAME_NAMES_SHADOWCOORDS_VSH) +#define OPTIMUM_FRAME_NAMES_SHADOWCOORDS_VSH +#define shadowRangeNear optimumFrame.shadowRangeNear +#define shadowRangeFar optimumFrame.shadowRangeFar +#define toShadowMapSpaceMatrixNear optimumFrame.toShadowMapSpaceMatrixNear +#define toShadowMapSpaceMatrixFar optimumFrame.toShadowMapSpaceMatrixFar +#endif + +// vertexwarp.vsh +#if defined(OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH) && !defined(OPTIMUM_FRAME_NAMES_VERTEXWARP_VSH) +#define OPTIMUM_FRAME_NAMES_VERTEXWARP_VSH +#define timeCounter optimumFrame.timeCounter +#define windWaveCounter optimumFrame.windWaveCounter +#define windWaveCounterHighFreq optimumFrame.windWaveCounterHighFreq +#define windSpeed optimumFrame.windSpeed +#define waterWaveCounter optimumFrame.waterWaveCounter +#define playerpos optimumFrame.playerpos +#define globalWarpIntensity optimumFrame.globalWarpIntensity +#define glitchWaviness optimumFrame.glitchWaviness +#define windWaveIntensity optimumFrame.windWaveIntensity +#define waterWaveIntensity optimumFrame.waterWaveIntensity +#define perceptionEffectId optimumFrame.perceptionEffectId +#define perceptionEffectIntensity optimumFrame.perceptionEffectIntensity +#endif + +// skycolor.fsh +#if defined(OPTIMUM_FRAME_OWNER_SKYCOLOR_FSH) && !defined(OPTIMUM_FRAME_NAMES_SKYCOLOR_FSH) +#define OPTIMUM_FRAME_NAMES_SKYCOLOR_FSH +#define fogWaveCounter optimumFrame.fogWaveCounter +#define sunsetMod optimumFrame.sunsetMod +#define ditherSeed optimumFrame.ditherSeed +#define horizontalResolution optimumFrame.horizontalResolution +#define playerToSealevelOffset optimumFrame.playerToSealevelOffset +#endif + +// colormap.vsh +#if defined(OPTIMUM_FRAME_OWNER_COLORMAP_VSH) && !defined(OPTIMUM_FRAME_NAMES_COLORMAP_VSH) +#define OPTIMUM_FRAME_NAMES_COLORMAP_VSH +#define seasonRel optimumFrame.seasonRel +#define seaLevel optimumFrame.seaLevel +#define atlasHeight optimumFrame.atlasHeight +#define seasonTemperature optimumFrame.seasonTemperature +#define colorMapRects optimumFrame.colorMapRects +#endif + +// underwatereffects.fsh +#if defined(OPTIMUM_FRAME_OWNER_UNDERWATEREFFECTS_FSH) && !defined(OPTIMUM_FRAME_NAMES_UNDERWATEREFFECTS_FSH) +#define OPTIMUM_FRAME_NAMES_UNDERWATEREFFECTS_FSH +#define cameraUnderwater optimumFrame.cameraUnderwater +#define waterMurkColor optimumFrame.waterMurkColor +#endif diff --git a/sources/shaders-vk/include/fxaa.glsl b/sources/shaders-vk/include/fxaa.glsl new file mode 100644 index 00000000..872c4c08 --- /dev/null +++ b/sources/shaders-vk/include/fxaa.glsl @@ -0,0 +1,1010 @@ +// Native port of the game include fxaa.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: fxaa.fsh +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_FXAA_GLSL +#define OPTIMUM_INCLUDE_FXAA_GLSL + +#define FXAA_QUALITY_PRESET 29 + +#define FXAA_PC 1 +#define FXAA_GLSL_120 0 +#define FXAA_GLSL_130 1 +#define FXAA_GREEN_AS_LUMA 0 +#define FXAA_FAST_PIXEL_OFFSET 0 +#define FXAA_GATHER4_ALPHA 0 +#define FXAA_DISCARD 0 + +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_PC_CONSOLE + // + // The console algorithm for PC is included + // for developers targeting really low spec machines. + // Likely better to just run FXAA_PC, and use a really low preset. + // + #define FXAA_PC_CONSOLE 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_GLSL_120 + #define FXAA_GLSL_120 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_GLSL_130 + #define FXAA_GLSL_130 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_HLSL_3 + #define FXAA_HLSL_3 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_HLSL_4 + #define FXAA_HLSL_4 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_HLSL_5 + #define FXAA_HLSL_5 0 +#endif +/*==========================================================================*/ +#ifndef FXAA_GREEN_AS_LUMA + // + // For those using non-linear color, + // and either not able to get luma in alpha, or not wanting to, + // this enables FXAA to run using green as a proxy for luma. + // So with this enabled, no need to pack luma in alpha. + // + // This will turn off AA on anything which lacks some amount of green. + // Pure red and blue or combination of only R and B, will get no AA. + // + // Might want to lower the settings for both, + // fxaaConsoleEdgeThresholdMin + // fxaaQualityEdgeThresholdMin + // In order to insure AA does not get turned off on colors + // which contain a minor amount of green. + // + // 1 = On. + // 0 = Off. + // + #define FXAA_GREEN_AS_LUMA 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_EARLY_EXIT + // + // Controls algorithm's early exit path. + // On PS3 turning this ON adds 2 cycles to the shader. + // On 360 turning this OFF adds 10ths of a millisecond to the shader. + // Turning this off on console will result in a more blurry image. + // So this defaults to on. + // + // 1 = On. + // 0 = Off. + // + #define FXAA_EARLY_EXIT 1 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_DISCARD + // + // Only valid for PC OpenGL currently. + // Probably will not work when FXAA_GREEN_AS_LUMA = 1. + // + // 1 = Use discard on pixels which don't need AA. + // For APIs which enable concurrent TEX+ROP from same surface. + // 0 = Return unchanged color on pixels which don't need AA. + // + #define FXAA_DISCARD 0 +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_FAST_PIXEL_OFFSET + // + // Used for GLSL 120 only. + // + // 1 = GL API supports fast pixel offsets + // 0 = do not use fast pixel offsets + // + #ifdef GL_EXT_gpu_shader4 + #define FXAA_FAST_PIXEL_OFFSET 1 + #endif + #ifdef GL_NV_gpu_shader5 + #define FXAA_FAST_PIXEL_OFFSET 1 + #endif + #ifdef GL_ARB_gpu_shader5 + #define FXAA_FAST_PIXEL_OFFSET 1 + #endif + #ifndef FXAA_FAST_PIXEL_OFFSET + #define FXAA_FAST_PIXEL_OFFSET 0 + #endif +#endif +/*--------------------------------------------------------------------------*/ +#ifndef FXAA_GATHER4_ALPHA + // + // 1 = API supports gather4 on alpha channel. + // 0 = API does not support gather4 on alpha channel. + // + #if (FXAA_HLSL_5 == 1) + #define FXAA_GATHER4_ALPHA 1 + #endif + #ifdef GL_ARB_gpu_shader5 + #define FXAA_GATHER4_ALPHA 1 + #endif + #ifdef GL_NV_gpu_shader5 + #define FXAA_GATHER4_ALPHA 1 + #endif + #ifndef FXAA_GATHER4_ALPHA + #define FXAA_GATHER4_ALPHA 0 + #endif +#endif + + +/*============================================================================ + FXAA QUALITY - TUNING KNOBS +------------------------------------------------------------------------------ +NOTE the other tuning knobs are now in the shader function inputs! +============================================================================*/ +#ifndef FXAA_QUALITY_PRESET + // + // Choose the quality preset. + // This needs to be compiled into the shader as it effects code. + // Best option to include multiple presets is to + // in each shader define the preset, then include this file. + // + // OPTIONS + // ----------------------------------------------------------------------- + // 10 to 15 - default medium dither (10=fastest, 15=highest quality) + // 20 to 29 - less dither, more expensive (20=fastest, 29=highest quality) + // 39 - no dither, very expensive + // + // NOTES + // ----------------------------------------------------------------------- + // 12 = slightly faster then FXAA 3.9 and higher edge quality (default) + // 13 = about same speed as FXAA 3.9 and better than 12 + // 23 = closest to FXAA 3.9 visually and performance wise + // _ = the lowest digit is directly related to performance + // _ = the highest digit is directly related to style + // + #define FXAA_QUALITY_PRESET 12 +#endif + + +/*============================================================================ + + FXAA QUALITY - PRESETS + +============================================================================*/ + +/*============================================================================ + FXAA QUALITY - MEDIUM DITHER PRESETS +============================================================================*/ +#if (FXAA_QUALITY_PRESET == 10) + #define FXAA_QUALITY_PS 3 + #define FXAA_QUALITY_P0 1.5 + #define FXAA_QUALITY_P1 3.0 + #define FXAA_QUALITY_P2 12.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 11) + #define FXAA_QUALITY_PS 4 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 3.0 + #define FXAA_QUALITY_P3 12.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 12) + #define FXAA_QUALITY_PS 5 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 4.0 + #define FXAA_QUALITY_P4 12.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 13) + #define FXAA_QUALITY_PS 6 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 4.0 + #define FXAA_QUALITY_P5 12.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 14) + #define FXAA_QUALITY_PS 7 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 4.0 + #define FXAA_QUALITY_P6 12.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 15) + #define FXAA_QUALITY_PS 8 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 2.0 + #define FXAA_QUALITY_P6 4.0 + #define FXAA_QUALITY_P7 12.0 +#endif + +/*============================================================================ + FXAA QUALITY - LOW DITHER PRESETS +============================================================================*/ +#if (FXAA_QUALITY_PRESET == 20) + #define FXAA_QUALITY_PS 3 + #define FXAA_QUALITY_P0 1.5 + #define FXAA_QUALITY_P1 2.0 + #define FXAA_QUALITY_P2 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 21) + #define FXAA_QUALITY_PS 4 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 22) + #define FXAA_QUALITY_PS 5 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 23) + #define FXAA_QUALITY_PS 6 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 24) + #define FXAA_QUALITY_PS 7 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 3.0 + #define FXAA_QUALITY_P6 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 25) + #define FXAA_QUALITY_PS 8 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 2.0 + #define FXAA_QUALITY_P6 4.0 + #define FXAA_QUALITY_P7 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 26) + #define FXAA_QUALITY_PS 9 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 2.0 + #define FXAA_QUALITY_P6 2.0 + #define FXAA_QUALITY_P7 4.0 + #define FXAA_QUALITY_P8 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 27) + #define FXAA_QUALITY_PS 10 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 2.0 + #define FXAA_QUALITY_P6 2.0 + #define FXAA_QUALITY_P7 2.0 + #define FXAA_QUALITY_P8 4.0 + #define FXAA_QUALITY_P9 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 28) + #define FXAA_QUALITY_PS 11 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 2.0 + #define FXAA_QUALITY_P6 2.0 + #define FXAA_QUALITY_P7 2.0 + #define FXAA_QUALITY_P8 2.0 + #define FXAA_QUALITY_P9 4.0 + #define FXAA_QUALITY_P10 8.0 +#endif +/*--------------------------------------------------------------------------*/ +#if (FXAA_QUALITY_PRESET == 29) + #define FXAA_QUALITY_PS 12 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.5 + #define FXAA_QUALITY_P2 2.0 + #define FXAA_QUALITY_P3 2.0 + #define FXAA_QUALITY_P4 2.0 + #define FXAA_QUALITY_P5 2.0 + #define FXAA_QUALITY_P6 2.0 + #define FXAA_QUALITY_P7 2.0 + #define FXAA_QUALITY_P8 2.0 + #define FXAA_QUALITY_P9 2.0 + #define FXAA_QUALITY_P10 4.0 + #define FXAA_QUALITY_P11 8.0 +#endif + +/*============================================================================ + FXAA QUALITY - EXTREME QUALITY +============================================================================*/ +#if (FXAA_QUALITY_PRESET == 39) + #define FXAA_QUALITY_PS 12 + #define FXAA_QUALITY_P0 1.0 + #define FXAA_QUALITY_P1 1.0 + #define FXAA_QUALITY_P2 1.0 + #define FXAA_QUALITY_P3 1.0 + #define FXAA_QUALITY_P4 1.0 + #define FXAA_QUALITY_P5 1.5 + #define FXAA_QUALITY_P6 2.0 + #define FXAA_QUALITY_P7 2.0 + #define FXAA_QUALITY_P8 2.0 + #define FXAA_QUALITY_P9 2.0 + #define FXAA_QUALITY_P10 4.0 + #define FXAA_QUALITY_P11 8.0 +#endif + + + +/*============================================================================ + + API PORTING + +============================================================================*/ + + #define FxaaBool bool + #define FxaaDiscard discard + #define FxaaFloat float + #define FxaaFloat2 vec2 + #define FxaaFloat3 vec3 + #define FxaaFloat4 vec4 + #define FxaaHalf float + #define FxaaHalf2 vec2 + #define FxaaHalf3 vec3 + #define FxaaHalf4 vec4 + #define FxaaInt2 ivec2 + #define FxaaSat(x) clamp(x, 0.0, 1.0) + #define FxaaTex sampler2D + +/*--------------------------------------------------------------------------*/ + // Requires "#version 130" or better + #define FxaaTexTop(t, p) textureLod(t, p, 0.0) + #define FxaaTexOff(t, p, o, r) textureLodOffset(t, p, 0.0, o) + #if (FXAA_GATHER4_ALPHA == 1) + // use #extension GL_ARB_gpu_shader5 : enable + #define FxaaTexAlpha4(t, p) textureGather(t, p, 3) + #define FxaaTexOffAlpha4(t, p, o) textureGatherOffset(t, p, o, 3) + #define FxaaTexGreen4(t, p) textureGather(t, p, 1) + #define FxaaTexOffGreen4(t, p, o) textureGatherOffset(t, p, o, 1) + #endif + + +/*============================================================================ + GREEN AS LUMA OPTION SUPPORT FUNCTION +============================================================================*/ +#if (FXAA_GREEN_AS_LUMA == 0) + FxaaFloat FxaaLuma(FxaaFloat4 rgba) { return rgba.w; } +#else + FxaaFloat FxaaLuma(FxaaFloat4 rgba) { return rgba.y; } +#endif + + + + +/*============================================================================ + + FXAA3 QUALITY - PC + +============================================================================*/ +#if (FXAA_PC == 1) +/*--------------------------------------------------------------------------*/ +FxaaFloat4 FxaaPixelShader( + // + // Use noperspective interpolation here (turn off perspective interpolation). + // {xy} = center of pixel + FxaaFloat2 pos, + // + // Used only for FXAA Console, and not used on the 360 version. + // Use noperspective interpolation here (turn off perspective interpolation). + // {xy__} = upper left of pixel + // {__zw} = lower right of pixel + FxaaFloat4 fxaaConsolePosPos, + // + // Input color texture. + // {rgb_} = color in linear or perceptual color space + // if (FXAA_GREEN_AS_LUMA == 0) + // {___a} = luma in perceptual color space (not linear) + FxaaTex tex, + // + // Only used on the optimized 360 version of FXAA Console. + // For everything but 360, just use the same input here as for "tex". + // For 360, same texture, just alias with a 2nd sampler. + // This sampler needs to have an exponent bias of -1. + FxaaTex fxaaConsole360TexExpBiasNegOne, + // + // Only used on the optimized 360 version of FXAA Console. + // For everything but 360, just use the same input here as for "tex". + // For 360, same texture, just alias with a 3nd sampler. + // This sampler needs to have an exponent bias of -2. + FxaaTex fxaaConsole360TexExpBiasNegTwo, + // + // Only used on FXAA Quality. + // This must be from a constant/uniform. + // {x_} = 1.0/screenWidthInPixels + // {_y} = 1.0/screenHeightInPixels + FxaaFloat2 fxaaQualityRcpFrame, + // + // Only used on FXAA Console. + // This must be from a constant/uniform. + // This effects sub-pixel AA quality and inversely sharpness. + // Where N ranges between, + // N = 0.50 (default) + // N = 0.33 (sharper) + // {x___} = -N/screenWidthInPixels + // {_y__} = -N/screenHeightInPixels + // {__z_} = N/screenWidthInPixels + // {___w} = N/screenHeightInPixels + FxaaFloat4 fxaaConsoleRcpFrameOpt, + // + // Only used on FXAA Console. + // Not used on 360, but used on PS3 and PC. + // This must be from a constant/uniform. + // {x___} = -2.0/screenWidthInPixels + // {_y__} = -2.0/screenHeightInPixels + // {__z_} = 2.0/screenWidthInPixels + // {___w} = 2.0/screenHeightInPixels + FxaaFloat4 fxaaConsoleRcpFrameOpt2, + // + // Only used on FXAA Console. + // Only used on 360 in place of fxaaConsoleRcpFrameOpt2. + // This must be from a constant/uniform. + // {x___} = 8.0/screenWidthInPixels + // {_y__} = 8.0/screenHeightInPixels + // {__z_} = -4.0/screenWidthInPixels + // {___w} = -4.0/screenHeightInPixels + FxaaFloat4 fxaaConsole360RcpFrameOpt2, + // + // Only used on FXAA Quality. + // This used to be the FXAA_QUALITY_SUBPIX define. + // It is here now to allow easier tuning. + // Choose the amount of sub-pixel aliasing removal. + // This can effect sharpness. + // 1.00 - upper limit (softer) + // 0.75 - default amount of filtering + // 0.50 - lower limit (sharper, less sub-pixel aliasing removal) + // 0.25 - almost off + // 0.00 - completely off + FxaaFloat fxaaQualitySubpix, + // + // Only used on FXAA Quality. + // This used to be the FXAA_QUALITY_EDGE_THRESHOLD define. + // It is here now to allow easier tuning. + // The minimum amount of local contrast required to apply algorithm. + // 0.333 - too little (faster) + // 0.250 - low quality + // 0.166 - default + // 0.125 - high quality + // 0.063 - overkill (slower) + FxaaFloat fxaaQualityEdgeThreshold, + // + // Only used on FXAA Quality. + // This used to be the FXAA_QUALITY_EDGE_THRESHOLD_MIN define. + // It is here now to allow easier tuning. + // Trims the algorithm from processing darks. + // 0.0833 - upper limit (default, the start of visible unfiltered edges) + // 0.0625 - high quality (faster) + // 0.0312 - visible limit (slower) + // Special notes when using FXAA_GREEN_AS_LUMA, + // Likely want to set this to zero. + // As colors that are mostly not-green + // will appear very dark in the green channel! + // Tune by looking at mostly non-green content, + // then start at zero and increase until aliasing is a problem. + FxaaFloat fxaaQualityEdgeThresholdMin, + // + // Only used on FXAA Console. + // This used to be the FXAA_CONSOLE__EDGE_SHARPNESS define. + // It is here now to allow easier tuning. + // This does not effect PS3, as this needs to be compiled in. + // Use FXAA_CONSOLE__PS3_EDGE_SHARPNESS for PS3. + // Due to the PS3 being ALU bound, + // there are only three safe values here: 2 and 4 and 8. + // These options use the shaders ability to a free *|/ by 2|4|8. + // For all other platforms can be a non-power of two. + // 8.0 is sharper (default!!!) + // 4.0 is softer + // 2.0 is really soft (good only for vector graphics inputs) + FxaaFloat fxaaConsoleEdgeSharpness, + // + // Only used on FXAA Console. + // This used to be the FXAA_CONSOLE__EDGE_THRESHOLD define. + // It is here now to allow easier tuning. + // This does not effect PS3, as this needs to be compiled in. + // Use FXAA_CONSOLE__PS3_EDGE_THRESHOLD for PS3. + // Due to the PS3 being ALU bound, + // there are only two safe values here: 1/4 and 1/8. + // These options use the shaders ability to a free *|/ by 2|4|8. + // The console setting has a different mapping than the quality setting. + // Other platforms can use other values. + // 0.125 leaves less aliasing, but is softer (default!!!) + // 0.25 leaves more aliasing, and is sharper + FxaaFloat fxaaConsoleEdgeThreshold, + // + // Only used on FXAA Console. + // This used to be the FXAA_CONSOLE__EDGE_THRESHOLD_MIN define. + // It is here now to allow easier tuning. + // Trims the algorithm from processing darks. + // The console setting has a different mapping than the quality setting. + // This only applies when FXAA_EARLY_EXIT is 1. + // This does not apply to PS3, + // PS3 was simplified to avoid more shader instructions. + // 0.06 - faster but more aliasing in darks + // 0.05 - default + // 0.04 - slower and less aliasing in darks + // Special notes when using FXAA_GREEN_AS_LUMA, + // Likely want to set this to zero. + // As colors that are mostly not-green + // will appear very dark in the green channel! + // Tune by looking at mostly non-green content, + // then start at zero and increase until aliasing is a problem. + FxaaFloat fxaaConsoleEdgeThresholdMin, + // + // Extra constants for 360 FXAA Console only. + // Use zeros or anything else for other platforms. + // These must be in physical constant registers and NOT immedates. + // Immedates will result in compiler un-optimizing. + // {xyzw} = float4(1.0, -1.0, 0.25, -0.25) + FxaaFloat4 fxaaConsole360ConstDir +) { +/*--------------------------------------------------------------------------*/ + FxaaFloat2 posM; + posM.x = pos.x; + posM.y = pos.y; + #if (FXAA_GATHER4_ALPHA == 1) + #if (FXAA_DISCARD == 0) + FxaaFloat4 rgbyM = FxaaTexTop(tex, posM); + #if (FXAA_GREEN_AS_LUMA == 0) + #define lumaM rgbyM.w + #else + #define lumaM rgbyM.y + #endif + #endif + #if (FXAA_GREEN_AS_LUMA == 0) + FxaaFloat4 luma4A = FxaaTexAlpha4(tex, posM); + FxaaFloat4 luma4B = FxaaTexOffAlpha4(tex, posM, FxaaInt2(-1, -1)); + #else + FxaaFloat4 luma4A = FxaaTexGreen4(tex, posM); + FxaaFloat4 luma4B = FxaaTexOffGreen4(tex, posM, FxaaInt2(-1, -1)); + #endif + #if (FXAA_DISCARD == 1) + #define lumaM luma4A.w + #endif + #define lumaE luma4A.z + #define lumaS luma4A.x + #define lumaSE luma4A.y + #define lumaNW luma4B.w + #define lumaN luma4B.z + #define lumaW luma4B.x + #else + FxaaFloat4 rgbyM = FxaaTexTop(tex, posM); + #if (FXAA_GREEN_AS_LUMA == 0) + #define lumaM rgbyM.w + #else + #define lumaM rgbyM.y + #endif + FxaaFloat lumaS = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2( 0, 1), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaE = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2( 1, 0), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaN = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2( 0,-1), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaW = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2(-1, 0), fxaaQualityRcpFrame.xy)); + #endif +/*--------------------------------------------------------------------------*/ + FxaaFloat maxSM = max(lumaS, lumaM); + FxaaFloat minSM = min(lumaS, lumaM); + FxaaFloat maxESM = max(lumaE, maxSM); + FxaaFloat minESM = min(lumaE, minSM); + FxaaFloat maxWN = max(lumaN, lumaW); + FxaaFloat minWN = min(lumaN, lumaW); + FxaaFloat rangeMax = max(maxWN, maxESM); + FxaaFloat rangeMin = min(minWN, minESM); + FxaaFloat rangeMaxScaled = rangeMax * fxaaQualityEdgeThreshold; + FxaaFloat range = rangeMax - rangeMin; + FxaaFloat rangeMaxClamped = max(fxaaQualityEdgeThresholdMin, rangeMaxScaled); + FxaaBool earlyExit = range < rangeMaxClamped; +/*--------------------------------------------------------------------------*/ + if(earlyExit) + #if (FXAA_DISCARD == 1) + FxaaDiscard; + #else + return rgbyM; + #endif +/*--------------------------------------------------------------------------*/ + #if (FXAA_GATHER4_ALPHA == 0) + FxaaFloat lumaNW = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2(-1,-1), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaSE = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2( 1, 1), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaNE = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2( 1,-1), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaSW = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2(-1, 1), fxaaQualityRcpFrame.xy)); + #else + FxaaFloat lumaNE = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2(1, -1), fxaaQualityRcpFrame.xy)); + FxaaFloat lumaSW = FxaaLuma(FxaaTexOff(tex, posM, FxaaInt2(-1, 1), fxaaQualityRcpFrame.xy)); + #endif +/*--------------------------------------------------------------------------*/ + FxaaFloat lumaNS = lumaN + lumaS; + FxaaFloat lumaWE = lumaW + lumaE; + FxaaFloat subpixRcpRange = 1.0/range; + FxaaFloat subpixNSWE = lumaNS + lumaWE; + FxaaFloat edgeHorz1 = (-2.0 * lumaM) + lumaNS; + FxaaFloat edgeVert1 = (-2.0 * lumaM) + lumaWE; +/*--------------------------------------------------------------------------*/ + FxaaFloat lumaNESE = lumaNE + lumaSE; + FxaaFloat lumaNWNE = lumaNW + lumaNE; + FxaaFloat edgeHorz2 = (-2.0 * lumaE) + lumaNESE; + FxaaFloat edgeVert2 = (-2.0 * lumaN) + lumaNWNE; +/*--------------------------------------------------------------------------*/ + FxaaFloat lumaNWSW = lumaNW + lumaSW; + FxaaFloat lumaSWSE = lumaSW + lumaSE; + FxaaFloat edgeHorz4 = (abs(edgeHorz1) * 2.0) + abs(edgeHorz2); + FxaaFloat edgeVert4 = (abs(edgeVert1) * 2.0) + abs(edgeVert2); + FxaaFloat edgeHorz3 = (-2.0 * lumaW) + lumaNWSW; + FxaaFloat edgeVert3 = (-2.0 * lumaS) + lumaSWSE; + FxaaFloat edgeHorz = abs(edgeHorz3) + edgeHorz4; + FxaaFloat edgeVert = abs(edgeVert3) + edgeVert4; +/*--------------------------------------------------------------------------*/ + FxaaFloat subpixNWSWNESE = lumaNWSW + lumaNESE; + FxaaFloat lengthSign = fxaaQualityRcpFrame.x; + FxaaBool horzSpan = edgeHorz >= edgeVert; + FxaaFloat subpixA = subpixNSWE * 2.0 + subpixNWSWNESE; +/*--------------------------------------------------------------------------*/ + if(!horzSpan) lumaN = lumaW; + if(!horzSpan) lumaS = lumaE; + if(horzSpan) lengthSign = fxaaQualityRcpFrame.y; + FxaaFloat subpixB = (subpixA * (1.0/12.0)) - lumaM; +/*--------------------------------------------------------------------------*/ + FxaaFloat gradientN = lumaN - lumaM; + FxaaFloat gradientS = lumaS - lumaM; + FxaaFloat lumaNN = lumaN + lumaM; + FxaaFloat lumaSS = lumaS + lumaM; + FxaaBool pairN = abs(gradientN) >= abs(gradientS); + FxaaFloat gradient = max(abs(gradientN), abs(gradientS)); + if(pairN) lengthSign = -lengthSign; + FxaaFloat subpixC = FxaaSat(abs(subpixB) * subpixRcpRange); +/*--------------------------------------------------------------------------*/ + FxaaFloat2 posB; + posB.x = posM.x; + posB.y = posM.y; + FxaaFloat2 offNP; + offNP.x = (!horzSpan) ? 0.0 : fxaaQualityRcpFrame.x; + offNP.y = ( horzSpan) ? 0.0 : fxaaQualityRcpFrame.y; + if(!horzSpan) posB.x += lengthSign * 0.5; + if( horzSpan) posB.y += lengthSign * 0.5; +/*--------------------------------------------------------------------------*/ + FxaaFloat2 posN; + posN.x = posB.x - offNP.x * FXAA_QUALITY_P0; + posN.y = posB.y - offNP.y * FXAA_QUALITY_P0; + FxaaFloat2 posP; + posP.x = posB.x + offNP.x * FXAA_QUALITY_P0; + posP.y = posB.y + offNP.y * FXAA_QUALITY_P0; + FxaaFloat subpixD = ((-2.0)*subpixC) + 3.0; + FxaaFloat lumaEndN = FxaaLuma(FxaaTexTop(tex, posN)); + FxaaFloat subpixE = subpixC * subpixC; + FxaaFloat lumaEndP = FxaaLuma(FxaaTexTop(tex, posP)); +/*--------------------------------------------------------------------------*/ + if(!pairN) lumaNN = lumaSS; + FxaaFloat gradientScaled = gradient * 1.0/4.0; + FxaaFloat lumaMM = lumaM - lumaNN * 0.5; + FxaaFloat subpixF = subpixD * subpixE; + FxaaBool lumaMLTZero = lumaMM < 0.0; +/*--------------------------------------------------------------------------*/ + lumaEndN -= lumaNN * 0.5; + lumaEndP -= lumaNN * 0.5; + FxaaBool doneN = abs(lumaEndN) >= gradientScaled; + FxaaBool doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P1; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P1; + FxaaBool doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P1; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P1; +/*--------------------------------------------------------------------------*/ + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P2; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P2; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P2; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P2; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 3) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P3; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P3; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P3; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P3; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 4) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P4; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P4; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P4; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P4; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 5) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P5; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P5; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P5; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P5; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 6) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P6; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P6; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P6; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P6; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 7) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P7; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P7; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P7; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P7; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 8) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P8; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P8; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P8; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P8; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 9) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P9; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P9; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P9; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P9; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 10) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P10; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P10; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P10; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P10; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 11) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P11; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P11; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P11; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P11; +/*--------------------------------------------------------------------------*/ + #if (FXAA_QUALITY_PS > 12) + if(doneNP) { + if(!doneN) lumaEndN = FxaaLuma(FxaaTexTop(tex, posN.xy)); + if(!doneP) lumaEndP = FxaaLuma(FxaaTexTop(tex, posP.xy)); + if(!doneN) lumaEndN = lumaEndN - lumaNN * 0.5; + if(!doneP) lumaEndP = lumaEndP - lumaNN * 0.5; + doneN = abs(lumaEndN) >= gradientScaled; + doneP = abs(lumaEndP) >= gradientScaled; + if(!doneN) posN.x -= offNP.x * FXAA_QUALITY_P12; + if(!doneN) posN.y -= offNP.y * FXAA_QUALITY_P12; + doneNP = (!doneN) || (!doneP); + if(!doneP) posP.x += offNP.x * FXAA_QUALITY_P12; + if(!doneP) posP.y += offNP.y * FXAA_QUALITY_P12; +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } + #endif +/*--------------------------------------------------------------------------*/ + } +/*--------------------------------------------------------------------------*/ + FxaaFloat dstN = posM.x - posN.x; + FxaaFloat dstP = posP.x - posM.x; + if(!horzSpan) dstN = posM.y - posN.y; + if(!horzSpan) dstP = posP.y - posM.y; +/*--------------------------------------------------------------------------*/ + FxaaBool goodSpanN = (lumaEndN < 0.0) != lumaMLTZero; + FxaaFloat spanLength = (dstP + dstN); + FxaaBool goodSpanP = (lumaEndP < 0.0) != lumaMLTZero; + FxaaFloat spanLengthRcp = 1.0/spanLength; +/*--------------------------------------------------------------------------*/ + FxaaBool directionN = dstN < dstP; + FxaaFloat dst = min(dstN, dstP); + FxaaBool goodSpan = directionN ? goodSpanN : goodSpanP; + FxaaFloat subpixG = subpixF * subpixF; + FxaaFloat pixelOffset = (dst * (-spanLengthRcp)) + 0.5; + FxaaFloat subpixH = subpixG * fxaaQualitySubpix; +/*--------------------------------------------------------------------------*/ + FxaaFloat pixelOffsetGood = goodSpan ? pixelOffset : 0.0; + FxaaFloat pixelOffsetSubpix = max(pixelOffsetGood, subpixH); + if(!horzSpan) posM.x += pixelOffsetSubpix * lengthSign; + if( horzSpan) posM.y += pixelOffsetSubpix * lengthSign; + #if (FXAA_DISCARD == 1) + return FxaaTexTop(tex, posM); + #else + return FxaaFloat4(FxaaTexTop(tex, posM).xyz, lumaM); + #endif +} +/*==========================================================================*/ +#endif + + + + +//---------------------------------------------------------------------------------- +// File: es3-kepler/FXAA/assets/shaders/FXAA_Default.frag +// SDK Version: v2.11 +// Email: gameworks@nvidia.com +// Site: http://developer.nvidia.com/ +// +// Copyright (c) 2014-2015, NVIDIA CORPORATION. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of NVIDIA CORPORATION nor the names of its +// contributors may be used to endorse or promote products derived +// from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +// OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +// +//---------------------------------------------------------------------------------- +//#version 100 + +//precision highp float; + +vec4 fxaaTexturePixel(sampler2D texture, vec2 texCoord, vec2 size) +{ + return FxaaPixelShader(texCoord, + FxaaFloat4(0.0f, 0.0f, 0.0f, 0.0f), // FxaaFloat4 fxaaConsolePosPos, + texture, // FxaaTex tex, + texture, // FxaaTex fxaaConsole360TexExpBiasNegOne, + texture, // FxaaTex fxaaConsole360TexExpBiasNegTwo, + size, // FxaaFloat2 fxaaQualityRcpFrame, + FxaaFloat4(0.0f, 0.0f, 0.0f, 0.0f), // FxaaFloat4 fxaaConsoleRcpFrameOpt, + FxaaFloat4(0.0f, 0.0f, 0.0f, 0.0f), // FxaaFloat4 fxaaConsoleRcpFrameOpt2, + FxaaFloat4(0.0f, 0.0f, 0.0f, 0.0f), // FxaaFloat4 fxaaConsole360RcpFrameOpt2, + 0.5f, // FxaaFloat fxaaQualitySubpix, + 0.166f, // FxaaFloat fxaaQualityEdgeThreshold, + 0.0833f, // FxaaFloat fxaaQualityEdgeThresholdMin, + 0.0f, // FxaaFloat fxaaConsoleEdgeSharpness, + 0.0f, // FxaaFloat fxaaConsoleEdgeThreshold, + 0.0f, // FxaaFloat fxaaConsoleEdgeThresholdMin, + FxaaFloat4(0.0f, 0.0f, 0.0f, 0.0f) // FxaaFloat fxaaConsole360ConstDir, + ); +} + +#endif diff --git a/sources/shaders-vk/include/motion.glsl b/sources/shaders-vk/include/motion.glsl new file mode 100644 index 00000000..3dd43bd9 --- /dev/null +++ b/sources/shaders-vk/include/motion.glsl @@ -0,0 +1,48 @@ +// The only writer of the motion attachment in native shaders +// (docs/vulkan-native-shaders.md section 7, docs/temporal-frame-contract.md section 3.2). +// +// rg = motion vector: previous pixel - current unjittered pixel, render pixels +// b = reactive [0,1] +// a = writer depth, window depth [0,1] +// +// A program reconstructs its previous clip position itself (warp replay, skinning, +// instance transforms, liquid waves, z-offset replay); this file starts where that +// position exists. Only the current pixel is jitter-corrected: the previous projection +// is the previous frame's UNJITTERED projection, so there is nothing to remove from the +// previous side, and passing a jittered previous matrix here would be a caller bug. +// +// The arithmetic is the three lines every GLSL 330 writer uses (chunkopaque.fsh +// taaMotionVector and its copies), in the same order, so the vector is bit-identical. +// +// Fragment stage only (reads gl_FragCoord). Written in the common subset of GLSL 330 +// and 450 so the GPU test can drive it through the rewriter as well. + +#ifndef OPTIMUM_MOTION_GLSL +#define OPTIMUM_MOTION_GLSL + +// The raw vector, without the behind-camera test. particlescube calls this directly: on +// its behind-camera branch it keeps its writer depth and its reactive value of 1. +vec2 optimumMotionVector(vec4 prevClip, vec2 renderSize, vec2 jitterPx) +{ + vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize; + vec2 currentPixel = gl_FragCoord.xy - jitterPx; + return prevPixel - currentPixel; +} + +// A previous position behind the previous camera (w <= 1e-6) is not a vector: rg and a +// are zero, so the resolve falls back to camera reprojection, and b survives, because +// the resolve reads reactive whether or not the pixel passed the validity test. +vec4 optimumWriteMotion(vec4 prevClip, vec2 renderSize, vec2 jitterPx, float reactive, float writerDepth) +{ + if (prevClip.w <= 1e-6) return vec4(0.0, 0.0, reactive, 0.0); + return vec4(optimumMotionVector(prevClip, renderSize, jitterPx), reactive, writerDepth); +} + +// A writer with no vector at all (the OIT merge): rg and a are zero, which under the +// merge's additive blend leaves the opaque vector underneath bit-for-bit intact. +vec4 optimumWriteReactiveOnly(float reactive) +{ + return vec4(0.0, 0.0, reactive, 0.0); +} + +#endif diff --git a/sources/shaders-vk/include/noise2d.glsl b/sources/shaders-vk/include/noise2d.glsl new file mode 100644 index 00000000..0830a48c --- /dev/null +++ b/sources/shaders-vk/include/noise2d.glsl @@ -0,0 +1,99 @@ +// Native port of the game include noise2d.ash (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: noise2d.ash +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_NOISE2D_GLSL +#define OPTIMUM_INCLUDE_NOISE2D_GLSL + +// https://github.com/ashima/webgl-noise + +vec4 mod2894(vec4 x) +{ + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec4 permute4(vec4 x) +{ + return mod2894(((x*34.0)+1.0)*x); +} + +vec4 taylorInvSqrt4(vec4 r) +{ + return 1.79284291400159 - 0.85373472095314 * r; +} + +vec2 fade2(vec2 t) { + return t*t*t*(t*(t*6.0-15.0)+10.0); +} + +// Classic Perlin noise +float cnoise2(vec2 P) +{ + vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0); + vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0); + Pi = mod2894(Pi); // To avoid truncation effects in permutation + vec4 ix = Pi.xzxz; + vec4 iy = Pi.yyww; + vec4 fx = Pf.xzxz; + vec4 fy = Pf.yyww; + + vec4 i = permute4(permute4(ix) + iy); + + vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0 ; + vec4 gy = abs(gx) - 0.5 ; + vec4 tx = floor(gx + 0.5); + gx = gx - tx; + + vec2 g00 = vec2(gx.x,gy.x); + vec2 g10 = vec2(gx.y,gy.y); + vec2 g01 = vec2(gx.z,gy.z); + vec2 g11 = vec2(gx.w,gy.w); + + vec4 norm = taylorInvSqrt4(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11))); + g00 *= norm.x; + g01 *= norm.y; + g10 *= norm.z; + g11 *= norm.w; + + float n00 = dot(g00, vec2(fx.x, fy.x)); + float n10 = dot(g10, vec2(fx.y, fy.y)); + float n01 = dot(g01, vec2(fx.z, fy.z)); + float n11 = dot(g11, vec2(fx.w, fy.w)); + + vec2 fade_xy = fade2(Pf.xy); + vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x); + float n_xy = mix(n_x.x, n_x.y, fade_xy.y); + return 2.3 * n_xy; +} + + + + + +// Gradient noise by Inigo Quilez +// https://www.shadertoy.com/view/XdXGW8 +vec2 ghash2(vec2 x) +{ + const vec2 k = vec2( 0.3183099, 0.3678794 ); + x = x*k + k.yx; + return -1.0 + 2.0*fract( 16.0 * k*fract( x.x*x.y*(x.x+x.y)) ); +} + +float gnoise2( in vec2 p ) +{ + vec2 i = floor( p ); + vec2 f = fract( p ); + + vec2 u = f*f*(3.0-2.0*f); + + return mix( mix( dot( ghash2( i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ), + dot( ghash2( i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x), + mix( dot( ghash2( i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ), + dot( ghash2( i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y); +} + +#endif diff --git a/sources/shaders-vk/include/noise3d.glsl b/sources/shaders-vk/include/noise3d.glsl new file mode 100644 index 00000000..c7f97925 --- /dev/null +++ b/sources/shaders-vk/include/noise3d.glsl @@ -0,0 +1,212 @@ +// Native port of the game include noise3d.ash (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: noise3d.ash +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_NOISE3D_GLSL +#define OPTIMUM_INCLUDE_NOISE3D_GLSL + +float cmod289(float x){return x - floor(x * (1.0 / 289.0)) * 289.0;} +vec4 cmod289(vec4 x){return x - floor(x * (1.0 / 289.0)) * 289.0;} +vec4 perm(vec4 x){return cmod289(((x * 34.0) + 1.0) * x);} + +float valuenoise(vec3 p){ + vec3 a = floor(p); + vec3 d = p - a; + d = d * d * (3.0 - 2.0 * d); + + vec4 b = a.xxyy + vec4(0.0, 1.0, 0.0, 1.0); + vec4 k1 = perm(b.xyxy); + vec4 k2 = perm(k1.xyxy + b.zzww); + + vec4 c = k2 + a.zzzz; + vec4 k3 = perm(c); + vec4 k4 = perm(c + 1.0); + + vec4 o1 = fract(k3 * (1.0 / 41.0)); + vec4 o2 = fract(k4 * (1.0 / 41.0)); + + vec4 o3 = o2 * d.z + o1 * (1.0 - d.z); + vec2 o4 = o3.yw * d.x + o3.xz * (1.0 - d.x); + + return o4.y * d.y + o4.x * (1.0 - d.y); +} + + + + + +// https://github.com/ashima/webgl-noise + +vec3 mod289(vec3 x) +{ + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec4 mod289(vec4 x) +{ + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec4 permute(vec4 x) +{ + return mod289(((x*34.0)+1.0)*x); +} + +vec4 taylorInvSqrt(vec4 r) +{ + return 1.79284291400159 - 0.85373472095314 * r; +} + +vec3 fade(vec3 t) { + return t*t*t*(t*(t*6.0-15.0)+10.0); +} + +// Classic Perlin noise +float cnoise(vec3 P) +{ + vec3 Pi0 = floor(P); // Integer part for indexing + vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1 + Pi0 = mod289(Pi0); + Pi1 = mod289(Pi1); + vec3 Pf0 = fract(P); // Fractional part for interpolation + vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0 + vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x); + vec4 iy = vec4(Pi0.yy, Pi1.yy); + vec4 iz0 = Pi0.zzzz; + vec4 iz1 = Pi1.zzzz; + + vec4 ixy = permute(permute(ix) + iy); + vec4 ixy0 = permute(ixy + iz0); + vec4 ixy1 = permute(ixy + iz1); + + vec4 gx0 = ixy0 * (1.0 / 7.0); + vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5; + gx0 = fract(gx0); + vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0); + vec4 sz0 = step(gz0, vec4(0.0)); + gx0 -= sz0 * (step(0.0, gx0) - 0.5); + gy0 -= sz0 * (step(0.0, gy0) - 0.5); + + vec4 gx1 = ixy1 * (1.0 / 7.0); + vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5; + gx1 = fract(gx1); + vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1); + vec4 sz1 = step(gz1, vec4(0.0)); + gx1 -= sz1 * (step(0.0, gx1) - 0.5); + gy1 -= sz1 * (step(0.0, gy1) - 0.5); + + vec3 g000 = vec3(gx0.x,gy0.x,gz0.x); + vec3 g100 = vec3(gx0.y,gy0.y,gz0.y); + vec3 g010 = vec3(gx0.z,gy0.z,gz0.z); + vec3 g110 = vec3(gx0.w,gy0.w,gz0.w); + vec3 g001 = vec3(gx1.x,gy1.x,gz1.x); + vec3 g101 = vec3(gx1.y,gy1.y,gz1.y); + vec3 g011 = vec3(gx1.z,gy1.z,gz1.z); + vec3 g111 = vec3(gx1.w,gy1.w,gz1.w); + + vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110))); + g000 *= norm0.x; + g010 *= norm0.y; + g100 *= norm0.z; + g110 *= norm0.w; + vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111))); + g001 *= norm1.x; + g011 *= norm1.y; + g101 *= norm1.z; + g111 *= norm1.w; + + float n000 = dot(g000, Pf0); + float n100 = dot(g100, vec3(Pf1.x, Pf0.yz)); + float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z)); + float n110 = dot(g110, vec3(Pf1.xy, Pf0.z)); + float n001 = dot(g001, vec3(Pf0.xy, Pf1.z)); + float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z)); + float n011 = dot(g011, vec3(Pf0.x, Pf1.yz)); + float n111 = dot(g111, Pf1); + + vec3 fade_xyz = fade(Pf0); + vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z); + vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y); + float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); + return 2.2 * n_xyz; +} + + +// Value noise by https://www.shadertoy.com/view/4sfGzS; 30.6.21 radfast updated with part of the mod289 approach from https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83 +const vec3 vn1 = vec3(0.0,0.0,1.0); +const vec3 vn2 = vec3(0.0,1.0,0.0); +const vec3 vn3 = vec3(0.0,1.0,1.0); +const vec3 vn4 = vec3(1.0,0.0,0.0); +const vec3 vn5 = vec3(1.0,0.0,1.0); +const vec3 vn6 = vec3(1.0,1.0,0.0); +const vec3 vn7 = vec3(1.0,1.0,1.0); + + + +vec3 ghash( vec3 p ) +{ + vec3 o; + // these constants are the matrix m + // individual components multiplied, because the whole matrix multiplication produces float rounding differences from C# equivalent code (Bell pepper) + o.x = 127.1 * p.x + 311.7 * p.y + 74.7 * p.z; + o.y = 269.5 * p.x + 183.3 * p.y + 246.1 * p.z; + o.z = 113.5 * p.x + 271.9 * p.y + 124.6 * p.z; + vec3 q = ((o * 0.025) + 8.0) * o; // the constants 4.25 and 8.0 found empirically to give similar noise distribution to the sin approach + return -1.0 + 2.0*fract(mod(q, 289.0) * (1.0 / 41.0)); +} + + +float gnoise( in vec3 p ) +{ + vec3 i = floor( p ); + vec3 f = p - i; + + vec3 u = f*f*(3.0-2.0*f); + + vec4 a = vec4 ( dot(ghash(i), f), + dot(ghash(i + vn1), f - vn1), + dot(ghash(i + vn2), f - vn2), + dot(ghash(i + vn3), f - vn3)); + vec4 b = vec4 ( dot(ghash(i + vn4), f - vn4), + dot(ghash(i + vn5), f - vn5), + dot(ghash(i + vn6), f - vn6), + dot(ghash(i + vn7), f - vn7)); + + vec4 c = mix(a, b, u.x); + vec2 rg = mix(c.xy, c.zw, u.y); + + // Added 1.2 here because our old noise was stronger + return 1.2 * mix(rg.x, rg.y, u.z); +} + + + + + +// Gradient noise by Inigo Quilez +// https://www.shadertoy.com/view/XdXGW8 +vec2 ghash(vec2 x) +{ + const vec2 k = vec2( 0.3183099, 0.3678794 ); + x = x*k + k.yx; + return -1.0 + 2.0*fract( 16.0 * k*fract( x.x*x.y*(x.x+x.y)) ); +} + +float gnoise( in vec2 p ) +{ + vec2 i = floor( p ); + vec2 f = fract( p ); + + vec2 u = f*f*(3.0-2.0*f); + + return mix( mix( dot( ghash( i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ), + dot( ghash( i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x), + mix( dot( ghash( i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ), + dot( ghash( i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y); +} + +#endif diff --git a/sources/shaders-vk/include/normalshading.glsl b/sources/shaders-vk/include/normalshading.glsl new file mode 100644 index 00000000..52c2705e --- /dev/null +++ b/sources/shaders-vk/include/normalshading.glsl @@ -0,0 +1,30 @@ +// Native port of the game include normalshading.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: normalshading.fsh +// optimum-port: verbatim +// optimum-program-uniform: vec3 lightPosition +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_NORMALSHADING_GLSL +#define OPTIMUM_INCLUDE_NORMALSHADING_GLSL + +float getBrightnessFromNormal(vec3 normal, float normalShadeIntensity, float minNormalShade) { + + // Option 2: Completely hides peter panning, but makes semi sunfacing block sides pretty dark + float nb = max(minNormalShade, 0.5 + 0.5 * dot(normal, lightPosition)); + + // Let's also define that diffuse light from the sky provides an additional brightness post for up facing stuff + // because the top side of blocks being darker than the sides is uncanny o__O + nb = max(nb, dot(normalize(normal), vec3(0, 1, 0)) * 0.95); + + // Let's also lastly define that the north side is always brighter + float northness = max(0.0, dot(vec3(0,0,-1), normal)); + nb += northness * 0.2; + + + return mix(1, nb, normalShadeIntensity); +} + +#endif diff --git a/sources/shaders-vk/include/oit.glsl b/sources/shaders-vk/include/oit.glsl new file mode 100644 index 00000000..f133a50e --- /dev/null +++ b/sources/shaders-vk/include/oit.glsl @@ -0,0 +1,73 @@ +// Native port of the game include oit.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: oit.fsh +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_OIT_GLSL +#define OPTIMUM_INCLUDE_OIT_GLSL + +#if USEOIT > 0 +#define OIT_BINS 3 +#define OIT_BIN_SCALE 30.0 + +layout(location = 0) out vec4 OITreveal; +layout(location = 1) out vec4 outReveal; +layout(location = 2) out vec4 outGlow; +layout(location = 3) out vec4 OITaccumulation0; +layout(location = 4) out vec4 OITaccumulation1; +layout(location = 5) out vec4 OITaccumulation2; + +void OITaccumulate(int bin, vec4 x){ + switch(bin){ + case 0: OITaccumulation0 = x; return; + case 1: OITaccumulation1 = x; return; + default: OITaccumulation2 = x; + } +} + +float OITbellcurve(float t){ + float n = t / 0.832; + return exp(-n * n); +} + +float OITweight(float t, float a){ + return exp(-t / 100.0); +} + +void OIT(vec4 colour, float glow, float depth){ + + depth /= OIT_BIN_SCALE; + + float bin = log(depth + 1.0); + float w = OITweight(depth, colour.a); + + colour.rgb *= colour.a; + + for(int i = 0; i < OIT_BINS; i++){ + + float b = OITbellcurve(bin - float(i)); + + if(i == (OIT_BINS-1) && bin > float(OIT_BINS-1)) b = 1.0; + + OITaccumulate(i, colour * w * b); + OITreveal[i] = 1.0 - colour.a * b; + + } + + outReveal = vec4(1.0 - colour.a); + outGlow = vec4(glow, 0.0, 0.0, colour.a); + +} + +void OIT(vec4 colour, float glow){ + + float depth = (gl_FragCoord.z * 2.0 - 1.0) / gl_FragCoord.w; + + OIT(colour, glow, depth); +} +#endif + +#endif diff --git a/sources/shaders-vk/include/shadowcoords.glsl b/sources/shaders-vk/include/shadowcoords.glsl new file mode 100644 index 00000000..e92396a5 --- /dev/null +++ b/sources/shaders-vk/include/shadowcoords.glsl @@ -0,0 +1,68 @@ +// Native port of the game include shadowcoords.vsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: shadowcoords.vsh +// optimum-port: transformed +// optimum-frame-owner: shadowcoords.vsh +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). +// +// SHADOWQUALITY is the specialization constant OPTIMUM_SHADOWQUALITY: the outputs are declared +// unconditionally and the #if blocks are branches on the constant, with the same expressions. + +#ifndef OPTIMUM_INCLUDE_SHADOWCOORDS_GLSL +#define OPTIMUM_INCLUDE_SHADOWCOORDS_GLSL + +#define OPTIMUM_FRAME_OWNER_SHADOWCOORDS_VSH +#include "frame.glsl" +#include "varyings.glsl" +#include "specialization.glsl" + +layout(location = OPTIMUM_LOCATION_SHADOW_COORDS_FAR) out vec4 shadowCoordsFar; +layout(location = OPTIMUM_LOCATION_SHADOW_COORDS_NEAR) out vec4 shadowCoordsNear; + + +const float transitionDistance = 10.0; + + +void calcShadowMapCoords(mat4 modelviewMat, vec4 worldPos) { + float nearSub = 0; + float len = 0; + if (OPTIMUM_SHADOWQUALITY > 0) { + len = length(worldPos); + } + + if (OPTIMUM_SHADOWQUALITY > 1) { + // Near map + shadowCoordsNear = toShadowMapSpaceMatrixNear * worldPos; + + float distanceNear = clamp( + max(max(0.0, 0.03 - shadowCoordsNear.x) * 100, max(0.0, shadowCoordsNear.x - 0.97) * 100) + + max(max(0.0, 0.03 - shadowCoordsNear.y) * 100, max(0.0, shadowCoordsNear.y - 0.97) * 100) + + max(0.0, shadowCoordsNear.z - 0.98) * 100 + + max(0.0, len / shadowRangeNear - 0.15) + , 0.0, 1.0); + + nearSub = shadowCoordsNear.w = clamp(1.0 - distanceNear, 0.0, 1.0); + if (shadowCoordsNear.z >= 0.999) shadowCoordsNear.w = 0.0; // so no need to test both in fogandlight.fsh + } + + if (OPTIMUM_SHADOWQUALITY > 0) { + // Far map + shadowCoordsFar = toShadowMapSpaceMatrixFar * worldPos; + + float distanceFar = clamp( + max(max(0.0, 0.03 - shadowCoordsFar.x) * 10, max(0.0, shadowCoordsFar.x - 0.97) * 10) + + max(max(0.0, 0.03 - shadowCoordsFar.y) * 10, max(0.0, shadowCoordsFar.y - 0.97) * 10) + + max(0.0, shadowCoordsFar.z - 0.98) * 10 + + max(0.0, len / shadowRangeFar - 0.15) + , 0.0, 1.0); + + distanceFar = distanceFar * 2 - 0.5; + + shadowCoordsFar.w = max(0.0, clamp(1.0 - distanceFar, 0.0, 1.0) - nearSub); + if (shadowCoordsFar.z >= 0.999) shadowCoordsFar.w = 0.0; // so no need to test both in fogandlight.fsh + } +} + +#endif diff --git a/sources/shaders-vk/include/skycolor.glsl b/sources/shaders-vk/include/skycolor.glsl new file mode 100644 index 00000000..4ad0313c --- /dev/null +++ b/sources/shaders-vk/include/skycolor.glsl @@ -0,0 +1,185 @@ +// Native port of the game include skycolor.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: skycolor.fsh +// optimum-port: transformed +// optimum-frame-owner: skycolor.fsh +// optimum-frame-texture: sampler2D glow +// optimum-frame-texture: sampler2D sky +// optimum-program-symbol: vec4 rgbaFog +// optimum-program-symbol: float fogMinIn +// optimum-program-symbol: float fogDensityIn +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_SKYCOLOR_GLSL +#define OPTIMUM_INCLUDE_SKYCOLOR_GLSL + +#include "bindings.glsl" +#define OPTIMUM_FRAME_OWNER_SKYCOLOR_FSH +#include "frame.glsl" +#include "specialization.glsl" +#include "dither.glsl" +#include "fogandlight.frag.glsl" + +// +// By Morgan McGuire @morgan3d, http://graphicscodex.com +// +float hash(float n) { return fract(sin(n) * 1e4); } +float hash(vec2 p) { return fract(1e4 * sin(17.0 * p.x + p.y * 0.1) * (0.1 + abs(sin(p.y * 13.0 + p.x)))); } + +float noise(float x) { + float i = floor(x); + float f = fract(x); + float u = f * f * (3.0 - 2.0 * f); + return mix(hash(i), hash(i + 1.0), u); +} + +float noise(vec3 x) { + const vec3 step = vec3(110, 241, 171); + + vec3 i = floor(x); + vec3 f = fract(x); + + // For performance, compute the base input to a 1D hash from the integer part of the argument and the + // incremental change to the 1D based on the 3D -> 1D wrapping + float n = dot(i, step); + + vec3 u = f * f * (3.0 - 2.0 * f); + return mix(mix(mix( hash(n + dot(step, vec3(0, 0, 0))), hash(n + dot(step, vec3(1, 0, 0))), u.x), + mix( hash(n + dot(step, vec3(0, 1, 0))), hash(n + dot(step, vec3(1, 1, 0))), u.x), u.y), + mix(mix( hash(n + dot(step, vec3(0, 0, 1))), hash(n + dot(step, vec3(1, 0, 1))), u.x), + mix( hash(n + dot(step, vec3(0, 1, 1))), hash(n + dot(step, vec3(1, 1, 1))), u.x), u.y), u.z); +} + + +float getFogAmountForSky(vec3 skyPosition, vec3 skyPosNorm, float sealevelOffsetFactor, float horizonFog) { + float fStart = flatFogDensity < 0 ? flatFogStart : 0; + float earthCurvatureBias = flatFogDensity < 0 ? 0.55 * playerToSealevelOffset : 0; // Add a bit of bias because distant sky has earth curvature? + float invHorizonDistance = (1 - skyPosNorm.y)/2 + 0.3; + + // Apply fog + float fogAmount = max( + fogMinIn + max(fogDensityIn * 120 - 0.12, 0) + max(-flatFogDensity * gl_FragCoord.z * fStart / 3.3, 0), + (1 - 1 / exp((skyPosition.y - fStart - earthCurvatureBias) * flatFogDensity)) + ); + + // Add a little extra fog near the horizon + float f = 0; + + float rnd = fogWaveCounter / 1.0; + vec3 rndPos = vec3(rnd + skyPosNorm.x, skyPosNorm.y, skyPosNorm.z - rnd); + + float density = invHorizonDistance * (0.6 + noise(rndPos)/3); + + float fac = max(0.0, density - 0.5) * max(min(1, 5 * fogAmount), horizonFog); + fogAmount += fac; + + fogAmount = clamp(fogAmount, 0, 1); + + return fogAmount; +} + + +void getSkyColorAt(vec3 skyPosition, vec3 sunPosition, float sealevelOffsetFactor, float skyLightIntensity, float horizonFog, out vec4 skyColor, out vec4 skyGlow) +{ + vec3 V2 = normalize(vec3(skyPosition.x, skyPosition.y + sealevelOffsetFactor * playerToSealevelOffset, skyPosition.z)); + + vec3 V2forGlow = normalize(vec3(skyPosition.x, skyPosition.y, skyPosition.z)); // sealevelOffsetFactor must be 0 for glow + + vec3 skyPosNorm = normalize(V2); + vec3 skyPosNormForGlow = normalize(V2forGlow); + + vec3 sunPos = sunPosition.xyz; + vec4 noiseCol = NoiseFromPixelPosition(ivec2(gl_FragCoord.xy), ditherSeed, horizontalResolution); + + // Compute the proximity of this fragment to the sun. + + float vl = 1 - clamp(distance(skyPosNormForGlow, sunPos)/6, 0, 1); + float invHorizonDistance = pow((1 - skyPosNorm.y), 0.25); // - 0.2 - this was in there. Caused a weird ring in the sky + float u = (sunPos.y + 1)/2; + float v = 1 - vl + noiseCol.y/6; + + // Look up the sky color and glow colors. + int q=0; + vec4 Kg = vec4(0); + + int samples = 1; + for (int dr = -samples; dr <= samples; dr++) { + Kg += texture(glow, vec2(clamp(u - sunsetMod * 2, 0, 1), v + dr/512.0)); + q++; + } + + Kg /= q; + vec4 Ks = texture(sky, vec2(u, invHorizonDistance)); + + // Combine the color and glow + skyColor = vec4(0,0,0,1); + skyColor.rgb = Ks.rgb * (1 - Kg.a) + Kg.rgb * Kg.a; + + // Apply fog + float fogAmount =getFogAmountForSky(skyPosition, skyPosNorm, sealevelOffsetFactor, horizonFog); + + skyColor = applyFog(skyColor, fogAmount); + skyColor = mix(skyColor, vec4(rgbaFog.rgb, fogAmount), 1 - skyLightIntensity); + + skyColor = applySpheresFog(skyColor, fogAmount, skyPosition * 50); + + skyColor += noiseCol; + + if (OPTIMUM_GODRAYS > 0) { + float intensity = clamp(V2.y/4 + (vl/2 - 0.3) , 0, 0.5); + + skyColor.rgb *= 1 - intensity * 2 * 0.2 * (1 - fogAmount); + //skyGlow = vec4(0, vl < 0 ? 0 : min(1, vl*vl), 0, 1); + skyGlow = vec4(0, intensity - fogAmount/2, 0, 1); + } else { + skyGlow = vec4(0, 0, 0, 1); + } +} + + + +vec4 getSkyGlowAt(vec3 skyPosition, vec3 sunPosition, float sealevelOffsetFactor, float skyLightIntensity, float horizonFog, float proximityMul) +{ + vec3 V2 = normalize(vec3(skyPosition.x, skyPosition.y + sealevelOffsetFactor * playerToSealevelOffset, skyPosition.z)); + + vec3 V2forGlow = normalize(vec3(skyPosition.x, skyPosition.y, skyPosition.z)); // sealevelOffsetFactor must be 0 for glow + + vec3 skyPosNorm = normalize(V2); + vec3 skyPosNormForGlow = normalize(V2forGlow); + + vec3 sunPos = sunPosition.xyz; + vec4 noiseCol = NoiseFromPixelPosition(ivec2(gl_FragCoord.xy), ditherSeed, horizontalResolution); + + // Compute the proximity of this fragment to the sun. + + float vl = 1 - clamp(distance(skyPosNormForGlow, sunPos)/6*proximityMul, 0, 1); + float invHorizonDistance = pow((1 - skyPosNorm.y), 0.25); // - 0.2 - this was in there. Caused a weird ring in the sky + float u = (sunPos.y + 1)/2; + float v = 1 - vl + noiseCol.y/6; + + // Look up the sky color and glow colors. + int q=0; + vec4 Kg = vec4(0); + + int samples = 1; + for (int dr = -samples; dr <= samples; dr++) { + Kg += texture(glow, vec2(clamp(u - sunsetMod, 0, 1), v + dr/512.0)); + q++; + } + + Kg /= q; + + // Apply fog + float fogAmount =getFogAmountForSky(skyPosition, skyPosNorm, sealevelOffsetFactor, horizonFog); + + Kg = applyFog(Kg, fogAmount); + Kg = mix(Kg, vec4(rgbaFog.rgb, fogAmount), 1 - skyLightIntensity); + + Kg += noiseCol; + + return Kg; +} + +#endif diff --git a/sources/shaders-vk/include/specialization.glsl b/sources/shaders-vk/include/specialization.glsl new file mode 100644 index 00000000..a3bc5b51 --- /dev/null +++ b/sources/shaders-vk/include/specialization.glsl @@ -0,0 +1,33 @@ +// Specialization constants for Optimum's Vulkan-native shaders +// (docs/vulkan-native-shaders.md section 5). +// +// This file is the source of truth for constant ids. The renderer's +// Shaders/SpecializationConvention.cs mirrors it, and SpecializationConventionTests +// fails when the two disagree or when a constant no longer matches a define +// ShaderRegistry.registerDefaultShaderCodePrefixes stamps. +// +// Each constant replaces the quality or code-path define of the same name: a native +// source writes `if (OPTIMUM_BLOOM != 0)` where the GLSL 330 source has `#if BLOOM > 0`, +// and everything the branch uses is declared unconditionally. Defaults are 0, the value +// an undefined macro has in `#if`; the runtime specializes every constant. + +#ifndef OPTIMUM_SPECIALIZATION_GLSL +#define OPTIMUM_SPECIALIZATION_GLSL + +layout(constant_id = 0) const int OPTIMUM_FXAA = 0; +layout(constant_id = 1) const int OPTIMUM_SSAOLEVEL = 0; +layout(constant_id = 2) const int OPTIMUM_NORMALVIEW = 0; +layout(constant_id = 3) const int OPTIMUM_BLOOM = 0; +layout(constant_id = 4) const int OPTIMUM_GODRAYS = 0; +layout(constant_id = 5) const int OPTIMUM_FOAMEFFECT = 0; +layout(constant_id = 6) const int OPTIMUM_SHINYEFFECT = 0; +layout(constant_id = 7) const int OPTIMUM_SHADOWQUALITY = 0; +layout(constant_id = 8) const int OPTIMUM_WAVINGSTUFF = 0; +layout(constant_id = 9) const float OPTIMUM_MINBRIGHT = 0.0; +layout(constant_id = 10) const int OPTIMUM_GREEDYMESH_GRAD = 0; +// DYNLIGHTS no longer sizes the point-light arrays (fixed at FrameGlobals.MaxDynamicLights; +// pointLightQuantity bounds the loop). Its zero value still selects fogandlight's +// no-point-light path, so the value stays a constant. +layout(constant_id = 11) const int OPTIMUM_DYNLIGHTS = 0; + +#endif diff --git a/sources/shaders-vk/include/underwatereffects.glsl b/sources/shaders-vk/include/underwatereffects.glsl new file mode 100644 index 00000000..6602b8c0 --- /dev/null +++ b/sources/shaders-vk/include/underwatereffects.glsl @@ -0,0 +1,57 @@ +// Native port of the game include underwatereffects.fsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: underwatereffects.fsh +// optimum-port: verbatim +// optimum-frame-owner: underwatereffects.fsh +// optimum-frame-texture: sampler2D liquidDepth +// optimum-program-uniform: vec2 frameSize +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_UNDERWATEREFFECTS_GLSL +#define OPTIMUM_INCLUDE_UNDERWATEREFFECTS_GLSL + +#include "bindings.glsl" +#define OPTIMUM_FRAME_OWNER_UNDERWATEREFFECTS_FSH +#include "frame.glsl" +#include "fogandlight.frag.glsl" + +float getSkyMurkiness() { + if (cameraUnderwater > 0.7) { + return 0.0; + } + + // Smoother ocean edge + float ldepth1 = linearDepth(texture(liquidDepth, gl_FragCoord.xy/frameSize.xy).r); + float ldepth2 = linearDepth(texture(liquidDepth, (gl_FragCoord.xy + vec2(0,3))/frameSize.xy).r); + float ldepth3 = linearDepth(texture(liquidDepth, (gl_FragCoord.xy + vec2(0,6))/frameSize.xy).r); + + return 1-(ldepth1+ldepth2+ldepth3)/3.0; +} + +float getUnderwaterMurkiness() { + if (cameraUnderwater > 0.7) { + return 0.0; + } + + // We render the liquid depth z-buffer at 1/4th the resolution. This seems to cause black lines near the shore line + // when there is strong fog. Probably because of the harsh transition of fog level above vs below water + // Seems to be fixable by either doing full resolution render or doing a second sample. Pretty sure 2 texture reads on a tiny texture is way faster + // so lets do that. + float ldepth = linearDepth( + max( + texture(liquidDepth, gl_FragCoord.xy/frameSize.xy).r, + texture(liquidDepth, (gl_FragCoord.xy + vec2(0,3))/frameSize.xy).r + ) + ); + + float fdepth = linearDepth(gl_FragCoord.z); + return clamp(max(0.0, fdepth - ldepth)*350.0, 0.0, 1.0); +} + +vec3 applyUnderwaterEffects(vec3 color, float murkiness) { + return mix(color.rgb, waterMurkColor.rgb * 0.4, murkiness); +} + +#endif diff --git a/sources/shaders-vk/include/varyings.glsl b/sources/shaders-vk/include/varyings.glsl new file mode 100644 index 00000000..09fb3e4f --- /dev/null +++ b/sources/shaders-vk/include/varyings.glsl @@ -0,0 +1,36 @@ +// Interface locations of the varyings the shared includes declare +// (docs/vulkan-native-shaders.md section 1). +// +// GLSL 330 matches varyings between stages by name; SPIR-V matches them by location, so +// an include's `out` in the vertex stage and the program's (or include's) `in` in the +// fragment stage must name the same number. The includes and the programs that read +// these varyings (a fragment stage's `in float glowLevel`, for example) use these +// defines; a program's own varyings use locations 0 to OPTIMUM_LOCATION_PROGRAM_END - 1. +// +// The block ends at location 25, inside the 29 fragment-input locations Mesa's Intel +// driver reports (maxFragmentInputComponents 116 on an ADL-S iGPU; NVIDIA reports 128). +// The Vulkan floor is 64 components (16 locations), which the device floor's +// descriptor-indexing requirements already rule out in practice. + +#ifndef OPTIMUM_VARYINGS_GLSL +#define OPTIMUM_VARYINGS_GLSL + +#define OPTIMUM_LOCATION_PROGRAM_END 16 + +// fogandlight.vsh / fogandlight.fsh +#define OPTIMUM_LOCATION_BLOCK_BRIGHTNESS 16 +#define OPTIMUM_LOCATION_GLOW_LEVEL 17 +#define OPTIMUM_LOCATION_BLOCK_LIGHT 18 + +// shadowcoords.vsh / fogandlight.fsh +#define OPTIMUM_LOCATION_SHADOW_COORDS_FAR 19 +#define OPTIMUM_LOCATION_SHADOW_COORDS_NEAR 20 + +// colormap.vsh / colormap.fsh +#define OPTIMUM_LOCATION_CLIMATE_COLOR_MAP_UV 21 +#define OPTIMUM_LOCATION_SEASON_COLOR_MAP_UV 22 +#define OPTIMUM_LOCATION_SEASON_WEIGHT 23 +#define OPTIMUM_LOCATION_HERETEMP 24 +#define OPTIMUM_LOCATION_FROST_ALPHA 25 + +#endif diff --git a/sources/shaders-vk/include/vertexflagbits.glsl b/sources/shaders-vk/include/vertexflagbits.glsl new file mode 100644 index 00000000..9a6473ab --- /dev/null +++ b/sources/shaders-vk/include/vertexflagbits.glsl @@ -0,0 +1,141 @@ +// Native port of the game include vertexflagbits.ash (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: vertexflagbits.ash +// optimum-port: verbatim +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_VERTEXFLAGBITS_GLSL +#define OPTIMUM_INCLUDE_VERTEXFLAGBITS_GLSL + +// For most passes, these are the flag bits. For the liquid pass, the wind mode bits are used differently + +// Bit 0..7 +const int GlowLevelBitMask = 0xFF; + +// Bit 8..10 +const int ZOffsetBitMask = 0x7 << 8; + +// Bit 11 +const int ReflectiveBitMask = 1 << 11; + +// Bit 12 +const int Lod0BitMask = 1 << 12; + +// Bit 13..25 +const int NormalBitMask = 0xFFF << 13; + +// Bit 25..28 +const int WindModeBitMask = 0xF << 25; + +const int WindModePosition = 25; + +// Bit 25..27 +const int LiquidWaterModeBitMask = 0xF << 25; + +// Bit 29 +const int LiquidExposedToSkyBitMask = 1 << 29; + +// Bit 29..31 +const int WindDataBitMask = 0x7 << 29; + +const int WindDataPosition = 29; + +// Bit 26..31 +const int WindBitsMask = WindModeBitMask | WindDataBitMask; + + +const int WindModeWeakMask = 1 << 25; +const int WindModeNormalMask = 2 << 25; +const int WindModeLeavesMask = 3 << 25; +const int WindModeBendMask = 4 << 25; +const int WindModeTallBendMask = 5 << 25; +const int WindModeWaterMask = 6 << 25; +const int WindModeExtraWeakMask = 7 << 25; +const int WindModeFruitMask = 8 << 25; +const int WindModeWeakWindNoBendMask = 9 << 25; +const int WindModeWeakWindInversedBendMask = 10 << 25; +const int WindModeWaterPlant = 11 << 25; +const int WindModeLiquidWarp = 12 << 25; +const int WindModeWeakLowAlphaTest = 13 << 25; + + +// We use the wind data bits as the reflective mode. This value is the shape element face Reflective mode minus 1 +// This unfortunately means we can't have something reflective *and* wind affected +const int ReflectiveModeWeak = 0; +const int ReflectiveModeMedium = 1; +const int ReflectiveModeStrong = 2; +const int ReflectiveModeSparkly = 3; +const int ReflectiveModeMild = 4; + +// Liquid shader +const int LiquidIsLavaBitPosition = 27; +const int LiquidWeakFoamBitPosition = 28; +const int LiquidWeakWavePosition = 29; +const int LiquidFullAlphaBitPosition = 30; +const int LiquidSkyExposedBitPosition = 31; + + +// Bit 27 +const int LiquidIsLavaBitMask = 1 << LiquidIsLavaBitPosition; +// Bit 28 +const int LiquidWeakFoamBitMask = 1 << LiquidWeakFoamBitPosition; +// Bit 29 +const int LiquidWeakWaveBitMask = 1 << LiquidWeakWavePosition; +// Bit 30 +const int LiquidFullAlphaBitMask = 1 << LiquidFullAlphaBitPosition; +// Bit 31 +const int LiquidSkyExposedBitMask = 1 << LiquidSkyExposedBitPosition; + + +// Because multiply is sometimes faster than divide (especially if the compiler can MAD) +const float OneOver255 = 1.0 / 255.0; + + +vec3 unpackNormal(int flags) { + int x = (flags >> (13+1)) & 0x7; + int y = (flags >> (13+5)) & 0x7; + int z = (flags >> (13+9)) & 0x7; + + int signx = (flags >> 12) & 2; + int signy = (flags >> (12+4)) & 2; + int signz = (flags >> (12+8)) & 2; + + return normalize(vec3( + (1.0 - signx) * x / 7.0, + (1.0 - signy) * y / 7.0, + (1.0 - signz) * z / 7.0 + )); +} + + +struct FaceData { + // if modifying, vec3s should be 16-byte aligned + vec3 xyz; + int uv; + vec3 xyzA; + int uvSize; + ivec4 flags; + vec3 xyzB; +// Bits 0..7 = season map index +// Bits 8..11 = climate map index +// Bits 12 = Frostable bit +// Bits 13, 14, 15 = If a windmode is set, these 3 bits are used to offset the season position for more varied leaf colors +// Bits 16-23 = temperature +// Bits 24-31 = rainfall + int colormapData; +}; + + +vec2 UnpackUv(FaceData vdata, int vIndex, float subpixelPaddingX, float subpixelPaddingY) { + int uvs = vdata.uvSize; + int uvRotate = (uvs & 0x8000) >> 15; + vec2 duv = vec2( + ((uvs & 0x7FFF) - ((uvs & 0x4000) << 1) - 0.00000001) * ((vIndex + uvRotate) % 4 / 2), + ((uvs >> 16 & 0x7FFF) - ((uvs & 0x40000000) >> 15) - 0.00000001) * ((vIndex + 1 - uvRotate & 3) / 2) + ); + return (vec2(vdata.uv & 0xFFFF, vdata.uv >> 16 & 0xFFFF) + duv) / 32768.0 - vec2(subpixelPaddingX * sign(duv.x), subpixelPaddingY * sign(duv.y)); +} + +#endif diff --git a/sources/shaders-vk/include/vertexwarp.glsl b/sources/shaders-vk/include/vertexwarp.glsl new file mode 100644 index 00000000..78e5e1ff --- /dev/null +++ b/sources/shaders-vk/include/vertexwarp.glsl @@ -0,0 +1,303 @@ +// Native port of the game include vertexwarp.vsh (docs/vulkan-native-shaders.md section 1). +// optimum-port-of: vertexwarp.vsh +// optimum-port: transformed +// optimum-frame-owner: vertexwarp.vsh +// optimum-program-uniform: float prevTimeCounter = 0 +// optimum-program-uniform: float prevWindWaveCounter = 0 +// optimum-program-uniform: float prevWindWaveCounterHighFreq = 0 +// optimum-program-uniform: float prevWaterWaveCounter = 0 +// optimum-program-uniform: float prevWindSpeed = 0 +// optimum-program-uniform: vec3 prevPlayerpos = vec3(0.0, 0.0, 0.0) +// optimum-program-uniform: float prevGlobalWarpIntensity = 0 +// optimum-program-uniform: float prevGlitchWaviness = 0 +// optimum-program-uniform: float prevWindWaveIntensity = 1 +// optimum-program-uniform: float prevWaterWaveIntensity = 1 +// optimum-program-uniform: int prevPerceptionEffectId = 1 +// optimum-program-uniform: float prevPerceptionEffectIntensity = 1 +// +// Loose uniforms: the members this file owns read the FrameGlobals block (frame.glsl), frame +// textures come from bindings.glsl, and every optimum-program-uniform above is declared by the +// including program (push block, record, or the frame block when it includes that name's owner). + +#ifndef OPTIMUM_INCLUDE_VERTEXWARP_GLSL +#define OPTIMUM_INCLUDE_VERTEXWARP_GLSL + +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "frame.glsl" +#include "specialization.glsl" +#include "vertexflagbits.glsl" + +// Optimum override of the vanilla vertexwarp.vsh (TAA P3). +// +// Motion-vector writers have to evaluate the vertex warp twice: once with this +// frame's animation state and once with the previous frame's, through the very +// same code. So every warp function here takes an explicit WarpState carrying +// every uniform it reads, and the vanilla entry points became one-line wrappers +// that pass currentWarpState(). The maths inside is unchanged, line for line, +// which is what keeps every other shader that includes this file - liquids, +// particles, clouds, decals, wireframe, the shadow map - byte-for-byte +// identical to vanilla for current values. +// +// The prev* uniforms default to zero and are set only by a pass that actually +// writes motion (ChunkRenderer sets them from OptimumTemporal.Frame). A shader +// that never calls previousWarpState() drops them at compile time. +// +// The counters wrap (DefaultShaderUniforms.Update takes them modulo 6000), so +// the previous values are snapshotted values, never "current minus dt". + + + + +// Previous frame's values of exactly the same set (TAA P3). + + + +#include "noise3d.glsl" + + + +// Every uniform the warp functions read, so one call site can evaluate them for +// this frame and the previous frame without any hidden global state. +// +// Native port: the members carry a warp prefix because frame.glsl defines the +// uniform names (timeCounter, playerpos, ...) as macros for optimumFrame members, +// and a macro would rewrite a member of the same name. Nothing outside this file +// reads the members; the functions and their signatures are unchanged. +struct WarpState { + float warpTimeCounter; + float warpWindWaveCounter; + float warpWindWaveCounterHighFreq; + float warpWaterWaveCounter; + float warpWindSpeed; + vec3 warpPlayerpos; + float warpGlobalWarpIntensity; + float warpGlitchWaviness; + float warpWindWaveIntensity; + float warpWaterWaveIntensity; + int warpPerceptionEffectId; + float warpPerceptionEffectIntensity; +}; + +WarpState currentWarpState() { + return WarpState( + timeCounter, windWaveCounter, windWaveCounterHighFreq, waterWaveCounter, + windSpeed, playerpos, globalWarpIntensity, glitchWaviness, + windWaveIntensity, waterWaveIntensity, perceptionEffectId, perceptionEffectIntensity); +} + +WarpState previousWarpState() { + return WarpState( + prevTimeCounter, prevWindWaveCounter, prevWindWaveCounterHighFreq, prevWaterWaveCounter, + prevWindSpeed, prevPlayerpos, prevGlobalWarpIntensity, prevGlitchWaviness, + prevWindWaveIntensity, prevWaterWaveIntensity, prevPerceptionEffectId, prevPerceptionEffectIntensity); +} + + +vec3 applyPerceptionWarpingState(WarpState st, vec3 worldPos) { + + if (st.warpPerceptionEffectId == 2 && st.warpPerceptionEffectIntensity > 0) { // Drunk + float pci = st.warpPerceptionEffectIntensity * clamp(length(worldPos)/2 - 2, 0.0, 2.0); + float xf = (worldPos.x + st.warpPlayerpos.x) / 10; + float zf = (worldPos.z + st.warpPlayerpos.z) / 10; + worldPos.x += pci * gnoise(vec3(xf, zf, st.warpTimeCounter/6)) / 2; + worldPos.y += pci * gnoise(vec3(xf, zf, st.warpTimeCounter/10)) / 2; + worldPos.z += pci * gnoise(vec3(xf, zf, st.warpTimeCounter/3.5)) / 2; + } + + return worldPos; +} + + +vec4 applyLiquidWarpingState(WarpState st, bool windAffected, vec4 worldPos, float div) { + if (OPTIMUM_WAVINGSTUFF == 1) { + vec3 noisepos = vec3((worldPos.x + st.warpPlayerpos.x) / 3, (worldPos.z + st.warpPlayerpos.z) / 3, st.warpWaterWaveCounter / 8 + (windAffected ? st.warpWindWaveCounter / 4 : 0)); + worldPos.y += st.warpWaterWaveIntensity * gnoise(noisepos) / div; + + if (windAffected) worldPos.y += st.warpWindWaveIntensity * gnoise(noisepos * 3.5) / (div * 4); + + worldPos.xyz = applyPerceptionWarpingState(st, worldPos.xyz); + + } + + return worldPos; +} + +vec4 applyVertexWarpingState(WarpState st, int renderFlags, vec4 worldPos) { + if (OPTIMUM_WAVINGSTUFF == 1) { + + if ((renderFlags & WindModeBitMask) > 0) { + + int windMode = (renderFlags >> WindModePosition) & 0xF; + + if (windMode==12) { + return applyLiquidWarpingState(st, true, worldPos, 5); + } + + int windData = (renderFlags >> WindDataPosition) & 0x7; + + float x = worldPos.x + st.warpPlayerpos.x; // See also code in PlayerCamera.cs how this is derived from ShaderUniforms.playerReferencePos + float z = worldPos.z + st.warpPlayerpos.z; + + if (windMode != 6) { + float y = worldPos.y + st.warpPlayerpos.y; + + // Fixes jitter due to float rounding errors + y = ceil(y * 10000) / 10000.0; + + float heightBend = 0; + + float strength = st.warpWindWaveIntensity * (1 + st.warpWindSpeed) / 30.0; + float bendCounter = st.warpWindWaveCounter; + float vbendMul = 1.3/5.0; + float wwaveHighFreq = st.warpWindWaveCounterHighFreq * 1.2; + float strengthFactorY = 1; + float bendNoiseFactor = 1.4; + float bendConstant = 0.8; + + int windwaveConfig = 0; + + switch (windMode) { + case 1: // Weak Wind + case 13: // Weak Wind + reduced AlphaTest + strength = 0.005 + 0.015 * st.warpWindSpeed; + heightBend = (fract(y) + windData) / 7.0 * 1.3; + break; + case 2: // Normal wind + strength = 0.005 + 0.015 * st.warpWindSpeed; + heightBend = (fract(y) + windData) / 4 * 1.3; + break; + case 3: // Leaves + strength *= 0.5; + heightBend = (fract(y) + windData) / 12.0 * 1.3; + heightBend = heightBend / 2 + pow(heightBend, 1.5) / 2; // the pow makes the bend neatly rounded + break; + case 4: // Bend (for small stems) + strength = 0; + heightBend = (fract(y) + windData) / 7.0 * 1.3; + break; + case 5: // Tall Bend (for thick and/or tall stems) + strength = 0; + heightBend = (fract(y) + windData) / 14.0 * 1.3; + heightBend = heightBend / 2 + pow(heightBend, 1.5) / 2; // the pow makes the bend neatly rounded + vbendMul = 0.0; + break; + // case 6: Water + case 7: // Extra Weak Wind + strength = 0.01; + heightBend = (fract(y) + windData) / 7.0 * 0.6; + break; + case 8: // Fruit + strength *= 0.15; + if (windData == 0) windData = -1; // Slight fudge for very tall fruit such as pears + y += (windData + 4) / 32.0; // All vertices on the whole fruit should have the same y - or close to it - if windData was set correctly + strengthFactorY = 3; + break; + case 9: // Weak Wind No Bend (for foliage with non bending stems) + strength *= 0.2; + heightBend = 0; + break; + case 10: // Weak Wind, Inverse Bend (for vines) + strength *= 0.5; + //strength = 0.02; // Not sure actually why this looks better and seems to scale just fine with the windspeed + heightBend = ((1 - fract(y)) + windData) / 14.0 * 1.5; + break; + case 11: // WaterPlant for Seaweed + strength = windData * (0.013 + 0.002 * st.warpWindSpeed); + wwaveHighFreq /= 5; + heightBend = windData / 7.0 * 1.3; + bendNoiseFactor = 2.4; + bendConstant = 0.1; + bendCounter /= 1.8; + break; + } + + + // 1. Determine bend + float bend = st.warpWindSpeed * heightBend * st.warpWindWaveIntensity; + if (bend != 0) + { + float bendNoise = st.warpWindSpeed * 0.2 + bendNoiseFactor * gnoise(vec3(x * 0.1, z * 0.1, mod(bendCounter, 1024.0) * 0.25)); + bend *= (bendConstant + bendNoise); + bend = min(4, bend); + } + + // 2. Add more noise + + x += wwaveHighFreq; + y += wwaveHighFreq; + z += wwaveHighFreq; + + // 3. Generate wiggle from a set of curves + // Visualized: https://pfortuny.net/fooplot.com/#W3sidHlwZSI6MCwiZXEiOiIyKnNpbih4LzgpK3Npbih4LzIpK3NpbigwLjUrMip4KStzaW4oMSszKngpIiwiY29sb3IiOiIjMDAwMDAwIn0seyJ0eXBlIjoxMDAwLCJ3aW5kb3ciOlsiLTI0Ljc5NTUzMjIyNjU2MjQ4NiIsIjI0Ljc5NTUzMjIyNjU2MjQ4NiIsIi0xNS4yNTg3ODkwNjI0OTk5OTEiLCIxNS4yNTg3ODkwNjI0OTk5OTEiXX1d + worldPos.x += bend + strength * (2 * sin(x * 0.5) + sin(x + y) + sin(0.5 + 4*x + 2*y) + sin(1 + 6*x + 3*y)/3); + + + // This might need to be a new mode. It makes sunflower leaves nicely wiggly + if (windMode == 1) worldPos.x += sin(x*20)*strength * 0.2 * st.warpWindSpeed; + + worldPos.y += -bend * vbendMul + strength * strengthFactorY * (sin(5*y)/15 + cos(10*x/strengthFactorY) / 10 + sin(3*z/strengthFactorY)/2 + cos(x/strengthFactorY*2)/2.2); + worldPos.z += strength * (2 * sin(z * 0.25) + sin(z + 3 * y) + sin(0.5 + 4*z + 2*y) + sin(1 + 6*z + y)/3); + + } + else { + // Water wave + vec3 noisepos = vec3(x / 3, z / 3, st.warpWaterWaveCounter / 8 + st.warpWindWaveCounter / 4); + worldPos.y += gnoise(noisepos) / 10; + } + } + + } + + return worldPos; +} + +vec4 applyGlobalWarpingState(WarpState st, vec4 worldPos) { + if (OPTIMUM_WAVINGSTUFF == 1) { + + if (st.warpGlitchWaviness > 0.1) { + float str = max(0.0, st.warpGlitchWaviness - 0.1); + str *= clamp(1.5 * length(worldPos) * st.warpGlitchWaviness - 1, 0.0, 250.0); + + float xf = (worldPos.x + st.warpPlayerpos.x) / 10; + float zf = (worldPos.z + st.warpPlayerpos.z) / 10; + worldPos.x += str * gnoise(vec3(xf, zf, st.warpWindWaveCounter/6)) / 5; + worldPos.y += str * gnoise(vec3(xf, zf, st.warpWindWaveCounter/10)) / 5; + worldPos.z += str * gnoise(vec3(xf, zf, st.warpWindWaveCounter/3.5)) / 5; + } + + if (st.warpGlobalWarpIntensity > 0) { + float x = max(0.0, (mod(20*st.warpWindWaveCounter, 30)) + (worldPos.x + st.warpPlayerpos.x) * 0.2 + (worldPos.y + st.warpPlayerpos.y) * 0.125 - 40); + worldPos.x += (sin(x / 2) + sin(0.5 + 2*x) + sin(1 + 3*x)/3) / 30.0 * st.warpGlobalWarpIntensity; + worldPos.z += (cos(x / 3) + cos(0.2 + 2.2*x) + cos(1 + 4*x)/3) / 30.0 * st.warpGlobalWarpIntensity; + } + + + worldPos.xyz = applyPerceptionWarpingState(st, worldPos.xyz); + + } + + return worldPos; +} + + +// ---- vanilla entry points, unchanged behaviour ---------------------------- +// Evaluated with this frame's uniforms, so every existing caller sees exactly +// what vanilla produced. + +vec3 applyPerceptionWarping(vec3 worldPos) { + return applyPerceptionWarpingState(currentWarpState(), worldPos); +} + +vec4 applyLiquidWarping(bool windAffected, vec4 worldPos, float div) { + return applyLiquidWarpingState(currentWarpState(), windAffected, worldPos, div); +} + +vec4 applyVertexWarping(int renderFlags, vec4 worldPos) { + return applyVertexWarpingState(currentWarpState(), renderFlags, worldPos); +} + +vec4 applyGlobalWarping(vec4 worldPos) { + return applyGlobalWarpingState(currentWarpState(), worldPos); +} + +#endif From bf6e6487645c673c1c83e47169cc973f558de9ce Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:50:47 +0200 Subject: [PATCH 157/226] wip(native-shaders): offline shader compiler, SPIR-V reflection, manifest, build hook and packaging Stage C of docs/vulkan-native-shaders.md section 6. - Shaders/SpirvReflection.cs: SPIR-V reader for entry point, stage interfaces, descriptor bindings, push-constant and uniform block members (scalar-layout offsets and sizes from the type graph), spec constants, used variables, written outputs and push-member-to-array dataflow. - Shaders/NativeShaderManifest.cs: schema version 1, toolchain identity, deterministic JSON writer and strict reader. - Shaders/NativeShaderBuilder.cs + tools/shader-compiler: --build / --verify / --single, every axis combination, includes from include/, unoptimised twin module for names, set-convention checks. The build hook sits on the tool project (the tool references the renderer), stamped and incremental; an empty tree writes an empty manifest. - make check-shaders-vk; make deploy and the three packagers ship bin/.../shaders-vk beside Optimum.Render.Vulkan.dll with a completeness check, never under assets/. - Contract: OPTIMUM_SAMPLER_SLOT macro in bindings.glsl, axis tests by value only, reflection from two modules, manifest field list, deploy path /shaders-vk/. Verified: Optimum.Render.Vulkan.Tests 712/712 passed (sync,best validation, implicit layers disabled; 39 of them new: SpirvReflectionTests 9, NativeShaderManifestTests 5, NativeShaderBuildTests 25 incl. SetConvention). Optimum.Tests 1182 passed, 34 skipped, 0 failed (5 new packaging/build coverage tests, one deploy count corrected 2 -> 4). Tool hook verified incremental (reruns on change or missing manifest, skips otherwise); make check-shaders-vk passes on the real tree. --- Makefile | 19 +- .../NativeShaderBuildTests.cs | 471 ++++++++++ .../NativeShaderManifestTests.cs | 135 +++ .../SpirvReflectionTests.cs | 225 +++++ .../Optimum.Render.Vulkan.csproj | 2 + .../Shaders/NativeShaderBuilder.cs | 775 +++++++++++++++++ .../Shaders/NativeShaderManifest.cs | 516 +++++++++++ .../Shaders/ShaderCompiler.cs | 12 +- .../Shaders/SpirvReflection.cs | 822 ++++++++++++++++++ .../installer-release-coverage-tests.cs | 73 ++ Optimum.Tests/taa-settings-coverage-tests.cs | 8 +- VintageStory.slnx | 4 + docs/vulkan-native-shaders.md | 108 ++- scripts/package-linux.sh | 25 + scripts/package-macos.sh | 25 + scripts/package.ps1 | 27 + sources/shaders-vk/include/bindings.glsl | 7 + .../Optimum.Shaders.Compiler.csproj | 72 ++ tools/shader-compiler/Program.cs | 9 + 19 files changed, 3311 insertions(+), 24 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeShaderBuildTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/NativeShaderManifestTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/SpirvReflectionTests.cs create mode 100644 Optimum.Render.Vulkan/Shaders/NativeShaderBuilder.cs create mode 100644 Optimum.Render.Vulkan/Shaders/NativeShaderManifest.cs create mode 100644 Optimum.Render.Vulkan/Shaders/SpirvReflection.cs create mode 100644 tools/shader-compiler/Optimum.Shaders.Compiler.csproj create mode 100644 tools/shader-compiler/Program.cs diff --git a/Makefile b/Makefile index eb0a5f77..c51c9b78 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ ifneq ($(CLIENT_ARCHIVE),) endif BOOTSTRAP_ARGS := --version $(VERSION) -.PHONY: help check check-patches check-compat check-shaders bootstrap bootstrap-git-test build clean refresh patches patch-il deploy run run-creative run-connect \ +.PHONY: help check check-patches check-compat check-shaders check-shaders-vk bootstrap bootstrap-git-test build clean refresh patches patch-il deploy run run-creative run-connect \ package package-linux package-appimage package-macos package-win bench-scaling worldgen-benchmark-test worldgen-benchmark-smoke worldgen-benchmark \ coverage mutate-launcher server-smoke @@ -58,6 +58,12 @@ check-compat: ## Verify patches keep vanilla multiplayer compatibility guards check-shaders: ## Verify optimized shader overlays are not truncated bash scripts/validate-shader-assets.sh sources/shaders +SHADER_COMPILER = tools/shader-compiler/bin/$(CONFIGURATION)/net10.0/Optimum.Shaders.Compiler.dll + +check-shaders-vk: ## Recompile sources/shaders-vk and fail on any SPIR-V or manifest difference from the build output + dotnet build tools/shader-compiler/Optimum.Shaders.Compiler.csproj -c $(CONFIGURATION) --nologo -v quiet -p:OptimumSkipNativeShaders=true + dotnet $(SHADER_COMPILER) --verify sources/shaders-vk $(MOD_OUT) + bootstrap: ## Download client, decompile, clone forks, apply patches bash scripts/bootstrap.sh $(BOOTSTRAP_ARGS) @@ -110,6 +116,15 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client @# directory and LD_LIBRARY_PATH, not Lib/, and a copy it cannot find makes the @# renderer fall back to OpenGL silently. @if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(VANILLA_DIR)/; fi + @# Native SPIR-V and its manifest (docs/vulkan-native-shaders.md section 6), beside the + @# renderer and never under assets/: the asset manager must not read SPIR-V and a mod must + @# not shadow engine shaders by asset priority. The build always writes the manifest, even + @# for an empty source tree, so a missing one means tools/shader-compiler never ran. The + @# directory is replaced whole, so a removed program does not linger, and checked file by + @# file like the overlays below. + @[ -f "$(MOD_OUT)/shaders-vk/shaders.manifest.json" ] || { echo "Error: $(MOD_OUT)/shaders-vk/shaders.manifest.json missing; build tools/shader-compiler (dotnet build VintageStory.slnx)"; exit 1; } + @rm -rf "$(VANILLA_DIR)/shaders-vk" && mkdir -p "$(VANILLA_DIR)/shaders-vk" && cp -f $(MOD_OUT)/shaders-vk/* "$(VANILLA_DIR)/shaders-vk/" + @for f in $(MOD_OUT)/shaders-vk/*; do d="$(VANILLA_DIR)/shaders-vk/$$(basename $$f)"; cmp -s "$$f" "$$d" || { echo "Error: $$f did not reach $$d (missing or content differs)"; exit 1; }; done @# Every file, not *.fsh plus *.vsh: the packagers copy the whole directory, @# and a stage that ships only on one of the two paths is the bug the @# completeness check below exists to catch. @@ -140,6 +155,8 @@ deploy: patch-il check-shaders ## Deploy Cecil-patched DLLs into vanilla client cp $(MOD_OUT)/cairo-sharp.dll $(INSTALL_DIR)/Lib/; \ cp $(MOD_OUT)/Optimum.Render.Vulkan.dll $(INSTALL_DIR)/; cp $(MOD_OUT)/Silk.NET.*.dll $(INSTALL_DIR)/; \ if [ -f "$(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so" ]; then cp $(MOD_OUT)/runtimes/linux-x64/native/libshaderc_shared.so $(INSTALL_DIR)/; fi; \ + rm -rf "$(INSTALL_DIR)/shaders-vk" && mkdir -p "$(INSTALL_DIR)/shaders-vk" && cp -f $(MOD_OUT)/shaders-vk/* "$(INSTALL_DIR)/shaders-vk/" || exit 1; \ + for f in $(MOD_OUT)/shaders-vk/*; do d="$(INSTALL_DIR)/shaders-vk/$$(basename $$f)"; cmp -s "$$f" "$$d" || { echo "Error: $$f did not reach $$d (missing or content differs)"; exit 1; }; done; \ for f in sources/shaders/*; do [ -f "$$f" ] || continue; cp -f "$$f" "$(INSTALL_DIR)/assets/game/shaders/$$(basename $$f)" || exit 1; done; \ if [ -d "sources/shaderincludes" ]; then mkdir -p $(INSTALL_DIR)/assets/game/shaderincludes; for f in sources/shaderincludes/*; do [ -f "$$f" ] || continue; cp -f "$$f" "$(INSTALL_DIR)/assets/game/shaderincludes/$$(basename $$f)" || exit 1; done; fi; \ for f in sources/shaders/* sources/shaderincludes/*; do [ -f "$$f" ] || continue; d="$(INSTALL_DIR)/assets/game/$$(echo $$f | cut -d/ -f2)/$$(basename $$f)"; cmp -s "$$f" "$$d" || { echo "Error: $$f did not reach $$d (missing or content differs)"; exit 1; }; done; \ diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderBuildTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderBuildTests.cs new file mode 100644 index 00000000..4f0bb1f2 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderBuildTests.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using Optimum.Render.Vulkan.Shaders; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The offline native shader compiler, driven through the same entry point +/// tools/shader-compiler runs (), on fixture programs in a +/// temporary source tree - never in sources/shaders-vk. The fixtures include the committed +/// bindings.glsl, so the convention the tool checks is the real one. +/// +public sealed class NativeShaderBuildTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "optimum-native-shaders-" + Guid.NewGuid().ToString("N")); + private string Source => Path.Combine(_root, "src"); + private string Output => Path.Combine(_root, "out"); + private string ShadersVk => Path.Combine(Output, NativeShaderManifest.DirectoryName); + private string ManifestPath => Path.Combine(ShadersVk, NativeShaderManifest.FileName); + + public NativeShaderBuildTests() + { + Directory.CreateDirectory(Path.Combine(Source, "include")); + File.Copy(Path.Combine(ShaderCorpus.RepositoryRoot, SetConvention.IncludePath), Path.Combine(Source, "include", "bindings.glsl")); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + } + + // ------------------------------------------------------------------ fixtures + + private const string OpaqueInterface = """ + layout(push_constant, scalar) uniform OptimumDraw { + OPTIMUM_SAMPLER_SLOT(sampler2DArray, terrainTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2); + vec3 origin; + } draw; + + layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram { + float alphaTest; + vec4 rgbaFogIn; + } program; + """; + + private const string OpaqueVertex = """ + #version 450 + #extension GL_GOOGLE_include_directive : require + #extension GL_EXT_scalar_block_layout : require + #include "bindings.glsl" + #include "fixtureopaque.interface.glsl" + + layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_FACE_DATA) readonly buffer FaceData { uint faces[]; } faceDataBuf; + + layout(location = 0) in vec3 vertexPositionIn; + layout(location = 1) in vec2 uvIn; + #if GREEDYMESH == 1 + layout(location = 2) in ivec2 greedySize; + #endif + layout(location = 0) out vec2 uv; + + void main() { + uv = uvIn; + gl_Position = vec4(vertexPositionIn + draw.origin, 1.0); + } + """; + + private const string OpaqueFragment = """ + #version 450 + #extension GL_GOOGLE_include_directive : require + #extension GL_EXT_scalar_block_layout : require + #include "bindings.glsl" + #include "fixtureopaque.interface.glsl" + #include "fogandlight.frag.glsl" + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 outColor; + #if GBUFFER == 1 + layout(location = 1) out vec4 outGlow; + #endif + #if TAAMOTION == 1 + layout(location = 2 + 2 * GBUFFER) out vec4 outMotion; + #endif + + layout(constant_id = 1) const int OPTIMUM_BLOOM = 0; + + void main() { + vec4 color = texture(optimumTextures2DArray[draw.terrainTex], vec3(uv, 0.0)); + color *= texture(optimumTextures2D[draw.tex2], uv); + if (color.a < program.alphaTest) discard; + outColor = mix(color, program.rgbaFogIn, fixtureFog(uv)); + #if GBUFFER == 1 + outGlow = vec4(OPTIMUM_BLOOM != 0 ? 1.0 : 0.0); + #endif + #if TAAMOTION == 1 + outMotion = vec4(0.0); + #endif + } + """; + + private const string FogInclude = """ + #ifndef FIXTURE_FOGANDLIGHT_FRAG + #define FIXTURE_FOGANDLIGHT_FRAG + float fixtureFog(vec2 at) { return texture(shadowMapFar, vec3(at, 0.5)); } + #endif + """; + + private const string PostVertex = """ + #version 450 + void main() { + vec2 corner = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); + } + """; + + private const string PostFragment = """ + #version 450 + #extension GL_GOOGLE_include_directive : require + #extension GL_EXT_scalar_block_layout : require + #include "bindings.glsl" + layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram { + float exposure; + } program; + layout(location = 0) out vec4 outColor; + void main() { outColor = vec4(program.exposure); } + """; + + private void WriteSource(string name, string text) => File.WriteAllText(Path.Combine(Source, name), text); + + private void WriteFixtures() + { + WriteSource("fixtureopaque.interface.glsl", OpaqueInterface); + WriteSource("fixtureopaque.vert", OpaqueVertex); + WriteSource("fixtureopaque.frag", OpaqueFragment); + File.WriteAllText(Path.Combine(Source, "include", "fogandlight.frag.glsl"), FogInclude); + WriteSource("fixturepost.vert", PostVertex); + WriteSource("fixturepost.frag", PostFragment); + } + + private (int Exit, string Output, string Error) Run(params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + int exit = NativeShaderTool.Run(args, output, error); + return (exit, output.ToString(), error.ToString()); + } + + private void Build() + { + (int exit, _, string error) = Run("--build", Source, Output); + Assert.True(exit == 0, error); + } + + // ------------------------------------------------------------------ build + + [Fact] + public void EveryAxisCombinationOfEveryProgramIsCompiledHashedAndListed() + { + WriteFixtures(); + Build(); + + NativeShaderManifest manifest = NativeShaderManifest.Load(ManifestPath); + Assert.Equal(NativeShaderManifest.CurrentSchemaVersion, manifest.SchemaVersion); + using (var compiler = new ShaderCompiler()) Assert.Equal(compiler.Identity, manifest.Toolchain); + Assert.Equal(new[] { "fixtureopaque", "fixturepost" }, manifest.Programs.Select(p => p.Name)); + + NativeProgram opaque = manifest.Programs[0]; + // GREEDYMESH comes from the vertex stage, GBUFFER and TAAMOTION from the fragment stage. + Assert.Equal(new[] { "GBUFFER", "GREEDYMESH", "TAAMOTION" }, opaque.Axes); + Assert.Equal( + new[] + { + "GBUFFER=0,GREEDYMESH=0,TAAMOTION=0", "GBUFFER=0,GREEDYMESH=0,TAAMOTION=1", + "GBUFFER=0,GREEDYMESH=1,TAAMOTION=0", "GBUFFER=0,GREEDYMESH=1,TAAMOTION=1", + "GBUFFER=1,GREEDYMESH=0,TAAMOTION=0", "GBUFFER=1,GREEDYMESH=0,TAAMOTION=1", + "GBUFFER=1,GREEDYMESH=1,TAAMOTION=0", "GBUFFER=1,GREEDYMESH=1,TAAMOTION=1", + }, + opaque.Variants.Select(v => v.Key)); + + NativeProgram post = manifest.Programs[1]; + Assert.Empty(post.Axes); + NativeVariant postVariant = Assert.Single(post.Variants); + Assert.Equal("", postVariant.Key); + Assert.Equal(new[] { "fixturepost.vert.spv", "fixturepost.frag.spv" }, postVariant.Stages.Select(s => s.Spirv)); + + NativeStage stage = opaque.Variants[5].Stages[1]; + Assert.Equal(("fragment", "fixtureopaque.frag", "fixtureopaque.GBUFFER1.GREEDYMESH0.TAAMOTION1.frag.spv"), (stage.Stage, stage.Source, stage.Spirv)); + + var listed = manifest.Programs.SelectMany(p => p.Variants).SelectMany(v => v.Stages).ToList(); + Assert.Equal(18, listed.Count); + foreach (NativeStage entry in listed) + { + Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(Path.Combine(ShadersVk, entry.Spirv)))), entry.Sha256); + } + Assert.Equal( + listed.Select(s => s.Spirv).Append(NativeShaderManifest.FileName).OrderBy(n => n, StringComparer.Ordinal), + Directory.GetFiles(ShadersVk).Select(Path.GetFileName).OrderBy(n => n, StringComparer.Ordinal)); + } + + [Fact] + public void EachVariantRecordsItsReflectedInterface() + { + WriteFixtures(); + Build(); + NativeShaderManifest manifest = NativeShaderManifest.Load(ManifestPath); + + NativeVariant full = manifest.Find("fixtureopaque", "GBUFFER=1,GREEDYMESH=1,TAAMOTION=1")!; + Assert.Equal("OptimumDraw", full.Push!.TypeName); + Assert.Equal(20, full.Push.Size); + Assert.Equal(new[] { "terrainTex uint @0 4", "tex2 uint @4 4", "origin vec3 @8 12" }, + full.Push.Members.Select(m => m.Name + " " + m.Type + " @" + m.Offset + " " + m.Size)); + Assert.Equal(new[] { "alphaTest float @0 4", "rgbaFogIn vec4 @4 16" }, + full.Record!.Members.Select(m => m.Name + " " + m.Type + " @" + m.Offset + " " + m.Size)); + + Assert.Equal( + new[] { "0 terrainTex sampler2DArray optimumTextures2DArray b1 @0", "1 tex2 sampler2D optimumTextures2D b0 @4" }, + full.Samplers.Select(s => s.Order + " " + s.Name + " " + s.GlslType + " " + s.BindlessArray + " b" + s.ArrayBinding + " @" + s.PushOffset)); + + // fogandlight.frag.glsl stands for fogandlight.fsh, the owner of these FrameGlobals members. + Assert.Equal(FrameGlobals.Members.Where(m => FrameGlobals.OwnerOf(m.Name) == "fogandlight.fsh").Select(m => m.Name), full.FrameMembers); + NativeFrameTexture shadow = Assert.Single(full.FrameTextures); + Assert.Equal(("shadowMapFar", "sampler2DShadow", 1), (shadow.Name, shadow.GlslType, shadow.Binding)); + + NativeStorageBinding faces = Assert.Single(full.StorageBindings); + Assert.Equal(("faceDataBuf", 2, 0, "storageBuffer", false), (faces.Name, faces.Set, faces.Binding, faces.DescriptorType, faces.Used)); + + Assert.Equal(new[] { "0 vertexPositionIn vec3", "1 uvIn vec2", "2 greedySize ivec2" }, + full.VertexInputs.Select(v => v.Location + " " + v.Name + " " + v.Type)); + Assert.Equal(new[] { "0 outColor vec4", "1 outGlow vec4", "4 outMotion vec4" }, + full.FragmentOutputs.Select(v => v.Location + " " + v.Name + " " + v.Type)); + Assert.Equal((1u << 0) | (1u << 1) | (1u << 4), full.WrittenOutputs); + + NativeSpecConstant bloom = Assert.Single(full.SpecializationConstants); + Assert.Equal((1, "OPTIMUM_BLOOM", "int", 0.0), (bloom.Id, bloom.Name, bloom.Type, bloom.Default)); + + NativeVariant bare = manifest.Find("fixtureopaque", "GBUFFER=0,GREEDYMESH=0,TAAMOTION=1")!; + Assert.Equal(new[] { "0 outColor", "2 outMotion" }, bare.FragmentOutputs.Select(v => v.Location + " " + v.Name)); + Assert.Equal((1u << 0) | (1u << 2), bare.WrittenOutputs); + Assert.DoesNotContain(bare.VertexInputs, v => v.Name == "greedySize"); + + NativeVariant post = manifest.Find("fixturepost", "")!; + Assert.Null(post.Push); + Assert.Empty(post.Samplers); + Assert.Empty(post.FrameMembers); + Assert.Empty(post.VertexInputs); + Assert.Equal(1u, post.WrittenOutputs); + } + + [Fact] + public void AnEmptySourceTreeYieldsAValidEmptyManifest() + { + (int exit, string output, string error) = Run("--build", Source, Output); + + Assert.True(exit == 0, error); + Assert.Contains("0 program(s)", output); + NativeShaderManifest manifest = NativeShaderManifest.Load(ManifestPath); + Assert.Empty(manifest.Programs); + Assert.Equal(NativeShaderManifest.CurrentSchemaVersion, manifest.SchemaVersion); + Assert.Equal(new[] { NativeShaderManifest.FileName }, Directory.GetFiles(ShadersVk).Select(Path.GetFileName)); + Assert.Equal(0, Run("--verify", Source, Output).Exit); + } + + [Fact] + public void AnUnchangedRebuildRewritesNothing() + { + WriteFixtures(); + Build(); + var past = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc); + foreach (string file in Directory.GetFiles(ShadersVk)) File.SetLastWriteTimeUtc(file, past); + + Build(); + + Assert.All(Directory.GetFiles(ShadersVk), file => Assert.Equal(past, File.GetLastWriteTimeUtc(file))); + } + + [Fact] + public void ARebuildDeletesTheSpirvOfARemovedProgram() + { + WriteFixtures(); + Build(); + File.Delete(Path.Combine(Source, "fixturepost.vert")); + File.Delete(Path.Combine(Source, "fixturepost.frag")); + + Build(); + + Assert.DoesNotContain(Directory.GetFiles(ShadersVk), f => Path.GetFileName(f).StartsWith("fixturepost", StringComparison.Ordinal)); + Assert.Null(NativeShaderManifest.Load(ManifestPath).FindProgram("fixturepost")); + } + + // ------------------------------------------------------------------ verify and single + + [Fact] + public void VerifyPassesOnAFreshBuildAndFailsOnAnyDifference() + { + WriteFixtures(); + Build(); + Assert.Equal(0, Run("--verify", Source, Output).Exit); + + string blob = Path.Combine(ShadersVk, "fixturepost.frag.spv"); + byte[] original = File.ReadAllBytes(blob); + byte[] tampered = (byte[])original.Clone(); + tampered[^1] ^= 0xFF; + File.WriteAllBytes(blob, tampered); + (int exit, _, string error) = Run("--verify", Source, Output); + Assert.Equal(1, exit); + Assert.Contains("fixturepost.frag.spv differs", error); + File.WriteAllBytes(blob, original); + + File.WriteAllBytes(Path.Combine(ShadersVk, "leftover.vert.spv"), original); + (exit, _, error) = Run("--verify", Source, Output); + Assert.Equal(1, exit); + Assert.Contains("unexpected leftover.vert.spv", error); + File.Delete(Path.Combine(ShadersVk, "leftover.vert.spv")); + + WriteSource("fixturepost.frag", PostFragment.Replace("vec4(program.exposure)", "vec4(program.exposure * 2.0)")); + (exit, _, error) = Run("--verify", Source, Output); + Assert.Equal(1, exit); + Assert.Contains(NativeShaderManifest.FileName + " differs", error); + + File.Delete(ManifestPath); + Assert.Equal(1, Run("--verify", Source, Output).Exit); + } + + [Fact] + public void SingleRebuildsOneProgramAndLeavesTheOthers() + { + WriteFixtures(); + Build(); + string opaqueBefore = File.ReadAllText(Path.Combine(ShadersVk, "fixtureopaque.GBUFFER0.GREEDYMESH0.TAAMOTION0.frag.spv")); + WriteSource("fixturepost.frag", PostFragment.Replace("vec4(program.exposure)", "vec4(program.exposure * 2.0)")); + + (int exit, string output, string error) = Run("--single", "fixturepost", Source, Output); + + Assert.True(exit == 0, error); + Assert.Contains("rebuilt fixturepost", output); + Assert.Equal(0, Run("--verify", Source, Output).Exit); + Assert.Equal(opaqueBefore, File.ReadAllText(Path.Combine(ShadersVk, "fixtureopaque.GBUFFER0.GREEDYMESH0.TAAMOTION0.frag.spv"))); + Assert.Equal(new[] { "fixtureopaque", "fixturepost" }, NativeShaderManifest.Load(ManifestPath).Programs.Select(p => p.Name)); + + Assert.Equal(1, Run("--single", "nosuchprogram", Source, Output).Exit); + } + + [Fact] + public void SingleNeedsAnExistingManifest() + { + WriteFixtures(); + (int exit, _, string error) = Run("--single", "fixturepost", Source, Output); + Assert.Equal(1, exit); + Assert.Contains("run --build first", error); + } + + [Fact] + public void AWrongCommandLineIsAUsageError() + { + Assert.Equal(2, Run().Exit); + Assert.Equal(2, Run("--build", Source).Exit); + Assert.Equal(2, Run("--single", Source, Output).Exit); + Assert.Equal(2, Run("--frobnicate", Source, Output).Exit); + } + + // ------------------------------------------------------------------ what the tool refuses + + private string BuildFails() + { + (int exit, _, string error) = Run("--build", Source, Output); + Assert.Equal(1, exit); + Assert.False(File.Exists(ManifestPath), "a failed build must write nothing"); + return error; + } + + [Fact] + public void ASamplerSlotDeclaredWithTheWrongTypeFails() + { + WriteFixtures(); + WriteSource("fixtureopaque.interface.glsl", OpaqueInterface.Replace("OPTIMUM_SAMPLER_SLOT(sampler2DArray, terrainTex)", "OPTIMUM_SAMPLER_SLOT(samplerCube, terrainTex)")); + Assert.Contains("sampler slot 'terrainTex' is declared samplerCube", BuildFails()); + } + + [Fact] + public void AnUndeclaredSlotIndexingATextureArrayFails() + { + WriteFixtures(); + WriteSource("fixtureopaque.interface.glsl", OpaqueInterface.Replace("OPTIMUM_SAMPLER_SLOT(sampler2D, tex2)", "uint tex2")); + Assert.Contains("push member 'tex2' indexes a texture array but is not declared with OPTIMUM_SAMPLER_SLOT", BuildFails()); + } + + [Fact] + public void ASamplerSlotAfterAnotherPushMemberFails() + { + WriteFixtures(); + WriteSource("fixtureopaque.interface.glsl", OpaqueInterface + .Replace(" OPTIMUM_SAMPLER_SLOT(sampler2D, tex2);\n vec3 origin;", " vec3 origin;\n OPTIMUM_SAMPLER_SLOT(sampler2D, tex2);")); + Assert.Contains("sampler slot 'tex2' follows a non-slot push member", BuildFails()); + } + + [Fact] + public void APushBlockOverTheLimitFails() + { + WriteFixtures(); + WriteSource("fixtureopaque.interface.glsl", OpaqueInterface.Replace("vec3 origin;", "vec3 origin;\n mat4 a;\n mat4 b;")); + Assert.Contains("push block is 148 B, the limit is 128", BuildFails()); + } + + [Fact] + public void ABindingOutsideTheSetConventionFails() + { + WriteFixtures(); + WriteSource("fixturepost.frag", PostFragment.Replace( + "layout(location = 0) out vec4 outColor;", + "layout(set = 1, binding = 0) uniform sampler2DArray wrong[];\nlayout(location = 0) out vec4 outColor;") + .Replace("vec4(program.exposure)", "texture(wrong[0], vec3(0.0)) * program.exposure")); + string error = BuildFails(); + Assert.Contains("'wrong' at set 1 binding 0 (reflected CombinedImageSampler sampler2DArray[1]) must be sampler2D optimumTextures2D[]", error); + } + + [Fact] + public void AnAxisTestedForDefinitionFails() + { + WriteFixtures(); + WriteSource("fixturepost.frag", PostFragment.Replace("layout(location = 0) out", "#ifdef TAAMOTION\n#endif\nlayout(location = 0) out")); + Assert.Contains("#ifdef TAAMOTION", BuildFails()); + } + + [Fact] + public void ALoneStageAMissingIncludeAndACompileErrorFail() + { + WriteFixtures(); + WriteSource("lonely.vert", PostVertex); + Assert.Contains("lonely.vert has no lonely.frag", BuildFails()); + File.Delete(Path.Combine(Source, "lonely.vert")); + + WriteSource("fixturepost.frag", PostFragment.Replace("#include \"bindings.glsl\"", "#include \"bindings.glsl\"\n#include \"missing.glsl\"")); + Assert.Contains("includes 'missing.glsl'", BuildFails()); + + WriteSource("fixturepost.frag", PostFragment.Replace("vec4(program.exposure)", "vec4(undeclaredName)")); + Assert.Contains("fixturepost.frag", BuildFails()); + } + + [Fact] + public void TheSamplerSlotMacroIsDeclaredByBindingsGlsl() + { + string bindings = File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, SetConvention.IncludePath)); + Assert.Contains("#define " + NativeShaderBuilder.SamplerSlotMacro + "(glslType, name) uint name", bindings); + } + + [Fact] + public void OwnerFilesMapToTheirNativeIncludes() + { + Assert.Equal(new[] { "fogandlight.vert.glsl", "fogandlight.glsl" }, NativeShaderBuilder.NativeIncludesFor("fogandlight.vsh")); + Assert.Equal(new[] { "skycolor.frag.glsl", "skycolor.glsl" }, NativeShaderBuilder.NativeIncludesFor("skycolor.fsh")); + } + + [Fact] + public void TheAxisListIsTheContractsAndSorted() + { + Assert.Equal( + new[] { "TAAMOTION", "GBUFFER", "USEOIT", "USESSBO", "GREEDYMESH", "ALLOWDEPTHOFFSET", "GLOWSUB", "VEC3SCALE" }.OrderBy(a => a, StringComparer.Ordinal), + NativeShaderBuilder.VariantAxes); + } +} diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderManifestTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderManifestTests.cs new file mode 100644 index 00000000..209fcec2 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderManifestTests.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.IO; +using Optimum.Render.Vulkan.Shaders; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// shaders.manifest.json: every field survives a write and a read, the text is deterministic +/// (the --verify gate compares bytes), and a manifest of another schema version is refused +/// rather than half-read. +/// +public class NativeShaderManifestTests +{ + private static NativeShaderManifest Sample() => new() + { + Toolchain = "glsl;vulkan1.3;spirv1.5;performance;shaderc-sha256:abc", + Programs = + { + new NativeProgram + { + Name = "chunkopaque", + Axes = { "GBUFFER", "TAAMOTION" }, + Variants = + { + new NativeVariant + { + Key = "GBUFFER=1,TAAMOTION=1", + Stages = + { + new NativeStage { Stage = "vertex", Source = "chunkopaque.vert", Spirv = "chunkopaque.GBUFFER1.TAAMOTION1.vert.spv", Sha256 = "00ff" }, + new NativeStage { Stage = "fragment", Source = "chunkopaque.frag", Spirv = "chunkopaque.GBUFFER1.TAAMOTION1.frag.spv", Sha256 = "ff00" }, + }, + Push = new NativeBlock + { + TypeName = "OptimumDraw", + Size = 20, + Members = + { + new NativeMember { Name = "terrainTex", Type = "uint", Offset = 0, Size = 4 }, + new NativeMember { Name = "origin", Type = "vec3", Offset = 8, Size = 12 }, + }, + }, + Record = null, + FrameMembers = { "zNear", "zFar" }, + Samplers = { new NativeSampler { Name = "terrainTex", GlslType = "sampler2DArray", BindlessArray = "optimumTextures2DArray", ArrayBinding = 1, PushOffset = 0, Order = 0 } }, + FrameTextures = { new NativeFrameTexture { Name = "shadowMapFar", GlslType = "sampler2DShadow", Binding = 1 } }, + StorageBindings = { new NativeStorageBinding { Name = "faceDataBuf", Set = 2, Binding = 0, DescriptorType = "storageBuffer", ArrayLength = 0, RuntimeArray = false, Used = true } }, + VertexInputs = { new NativeInterfaceVariable { Location = 0, Name = "vertexPositionIn", Type = "vec3" } }, + FragmentOutputs = + { + new NativeInterfaceVariable { Location = 0, Name = "outColor", Type = "vec4" }, + new NativeInterfaceVariable { Location = 4, Name = "outMotion", Type = "vec4", ArrayLength = 0 }, + }, + WrittenOutputs = 0b10001, + SpecializationConstants = + { + new NativeSpecConstant { Id = 1, Name = "OPTIMUM_FXAA", Type = "bool", Default = 1 }, + new NativeSpecConstant { Id = 9, Name = "OPTIMUM_MINBRIGHT", Type = "float", Default = 0.125 }, + }, + }, + }, + }, + }, + }; + + [Fact] + public void EveryFieldSurvivesAWriteAndARead() + { + NativeShaderManifest original = Sample(); + string json = original.ToJson(); + + NativeShaderManifest read = NativeShaderManifest.Parse(json); + + Assert.Equal(json, read.ToJson()); + Assert.Equal(original.Toolchain, read.Toolchain); + NativeVariant variant = read.Find("chunkopaque", "GBUFFER=1,TAAMOTION=1")!; + Assert.Equal(new[] { "GBUFFER", "TAAMOTION" }, read.Programs[0].Axes); + Assert.Equal("chunkopaque.GBUFFER1.TAAMOTION1.frag.spv", variant.Stages[1].Spirv); + Assert.Equal("ff00", variant.Stages[1].Sha256); + Assert.Equal(20, variant.Push!.Size); + Assert.Equal(("origin", "vec3", 8, 12), (variant.Push.Members[1].Name, variant.Push.Members[1].Type, variant.Push.Members[1].Offset, variant.Push.Members[1].Size)); + Assert.Null(variant.Record); + Assert.Equal(new[] { "zNear", "zFar" }, variant.FrameMembers); + Assert.Equal(("optimumTextures2DArray", 1, 0, 0), (variant.Samplers[0].BindlessArray, variant.Samplers[0].ArrayBinding, variant.Samplers[0].PushOffset, variant.Samplers[0].Order)); + Assert.Equal("shadowMapFar", variant.FrameTextures[0].Name); + Assert.True(variant.StorageBindings[0].Used); + Assert.Equal(4, variant.FragmentOutputs[1].Location); + Assert.Equal(0b10001u, variant.WrittenOutputs); + Assert.Equal(1.0, variant.SpecializationConstants[0].Default); + Assert.Equal(0.125, variant.SpecializationConstants[1].Default); + Assert.Null(read.Find("chunkopaque", "GBUFFER=0,TAAMOTION=1")); + } + + [Fact] + public void TheTextIsDeterministicWithUnixLineEndingsAndBoolDefaults() + { + string json = Sample().ToJson(); + + Assert.Equal(json, Sample().ToJson()); + Assert.DoesNotContain("\r", json); + Assert.EndsWith("}\n", json); + Assert.Contains("\"default\": true", json); + Assert.Contains("\"record\": null", json); + } + + [Fact] + public void AnotherSchemaVersionIsRefused() + { + string json = Sample().ToJson().Replace( + "\"schemaVersion\": " + NativeShaderManifest.CurrentSchemaVersion, + "\"schemaVersion\": " + (NativeShaderManifest.CurrentSchemaVersion + 1)); + + InvalidDataException error = Assert.Throws(() => NativeShaderManifest.Parse(json)); + Assert.Contains("schema version " + (NativeShaderManifest.CurrentSchemaVersion + 1), error.Message); + } + + [Fact] + public void MalformedOrIncompleteManifestsAreRefused() + { + Assert.Throws(() => NativeShaderManifest.Parse("{ not json")); + Assert.Throws(() => NativeShaderManifest.Parse("{\"schemaVersion\": 1}")); + Assert.Throws(() => NativeShaderManifest.Parse(Sample().ToJson().Replace("\"writtenOutputs\"", "\"writtenOutputz\""))); + } + + [Fact] + public void TheVariantKeyIsTheSortedAxisValueList() + { + var values = new Dictionary { ["TAAMOTION"] = 1, ["GBUFFER"] = 0, ["USEOIT"] = 1 }; + + Assert.Equal("GBUFFER=0,TAAMOTION=1", NativeShaderManifest.VariantKey(new[] { "TAAMOTION", "GBUFFER" }, values)); + Assert.Equal("", NativeShaderManifest.VariantKey(new string[0], values)); + Assert.Equal("GREEDYMESH=0", NativeShaderManifest.VariantKey(new[] { "GREEDYMESH" }, values)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SpirvReflectionTests.cs b/Optimum.Render.Vulkan.Tests/SpirvReflectionTests.cs new file mode 100644 index 00000000..e3a56759 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SpirvReflectionTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The SPIR-V reader the native shader manifest is built from (docs/vulkan-native-shaders.md +/// section 6). Each fixture is compiled by the runtime's own shaderc, then every reflected field is +/// compared with what the GLSL says, so a wrong offset, type or binding shows up here rather than as +/// a uniform written into the wrong bytes in game. +/// +public sealed class SpirvReflectionTests : IDisposable +{ + private readonly ShaderCompiler _compiler = new(); + + public void Dispose() => _compiler.Dispose(); + + private const string Fragment = """ + #version 450 + #extension GL_EXT_scalar_block_layout : require + #extension GL_EXT_nonuniform_qualifier : require + + layout(set = 0, binding = 1) uniform sampler2DShadow shadowMapFar; + layout(set = 1, binding = 0) uniform sampler2D optimumTextures2D[]; + layout(set = 1, binding = 1) uniform sampler2DArray optimumTextures2DArray[]; + layout(set = 1, binding = 6) uniform sampler2DShadow optimumTextures2DShadow[]; + + layout(push_constant, scalar) uniform OptimumDraw { + uint terrainTex; + uint blockTex; + vec3 origin; + mat4 modelViewMatrix; + } draw; + + layout(set = 2, binding = 3, scalar) uniform OptimumProgram { + float alphaTest; + vec4 rgbaFogIn; + float weights[3]; + mat3 normalMatrix; + ivec2 frameSize; + } program; + + layout(set = 2, binding = 0, std430) readonly buffer FaceData { uint faces[]; } faceDataBuf; + + layout(constant_id = 3) const int OPTIMUM_BLOOM = 0; + layout(constant_id = 7) const float OPTIMUM_MINBRIGHT = 0.25; + layout(constant_id = 9) const bool OPTIMUM_FXAA = true; + layout(constant_id = 11) const uint OPTIMUM_UNUSED = 5u; + + layout(location = 0) in vec2 uv; + layout(location = 1) flat in ivec3 flags; + layout(location = 2) in vec3 unusedVarying; + + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + layout(location = 2) out vec4 outNeverWritten; + layout(location = 3) out vec4 outMotion; + + void main() { + vec4 color = texture(optimumTextures2D[draw.terrainTex], uv); + color *= texture(optimumTextures2DArray[draw.blockTex], vec3(uv, 0.0)); + if (OPTIMUM_BLOOM != 0) color *= program.alphaTest; + color.a *= float(faceDataBuf.faces[flags.x]) * OPTIMUM_MINBRIGHT; + color.rgb += texture(shadowMapFar, vec3(uv, 0.5)) * draw.origin; + outColor = color; + outGlow.rgb = program.normalMatrix * draw.origin; + if (OPTIMUM_FXAA) outMotion = vec4(program.weights[1]); + } + """; + + private const string Vertex = """ + #version 450 + layout(location = 0) in vec3 vertexPosition; + layout(location = 1) in vec2 uvIn; + layout(location = 3) in ivec4 colorIn; + layout(location = 4) in mat2 packedIn; + layout(location = 0) out vec2 uv; + void main() { + uv = uvIn + packedIn[0] + vec2(colorIn.xy); + gl_Position = vec4(vertexPosition, 1.0); + } + """; + + private (SpirvModuleReflection Declared, SpirvModuleReflection Shipped) Reflect(string source, EnumShaderType stage) + { + ShaderCompileResult declared = _compiler.CompileForReflection(source, "fixture", stage); + Assert.True(declared.Success, declared.Error); + ShaderCompileResult shipped = _compiler.Compile(source, "fixture", stage); + Assert.True(shipped.Success, shipped.Error); + return (SpirvReflection.Reflect(declared.Spirv), SpirvReflection.Reflect(shipped.Spirv)); + } + + [Fact] + public void TheEntryPointAndStageAreRead() + { + (SpirvModuleReflection fragment, _) = Reflect(Fragment, EnumShaderType.FragmentShader); + (SpirvModuleReflection vertex, _) = Reflect(Vertex, EnumShaderType.VertexShader); + + Assert.Equal("main", fragment.EntryPoint); + Assert.Equal(SpirvReflection.ExecutionModelFragment, fragment.ExecutionModel); + Assert.Equal("main", vertex.EntryPoint); + Assert.Equal(SpirvReflection.ExecutionModelVertex, vertex.ExecutionModel); + } + + [Fact] + public void StageInterfacesCarryLocationNameAndTypeAndSkipBuiltIns() + { + (SpirvModuleReflection vertex, _) = Reflect(Vertex, EnumShaderType.VertexShader); + (SpirvModuleReflection fragment, _) = Reflect(Fragment, EnumShaderType.FragmentShader); + + Assert.Equal( + new[] { "0 vertexPosition vec3", "1 uvIn vec2", "3 colorIn ivec4", "4 packedIn mat2" }, + vertex.Inputs.Select(v => v.Location + " " + v.Name + " " + v.GlslType)); + // gl_Position is a built-in and never an interface variable of the manifest. + Assert.Equal(new[] { "0 uv vec2" }, vertex.Outputs.Select(v => v.Location + " " + v.Name + " " + v.GlslType)); + + Assert.Equal( + new[] { "0 uv vec2", "1 flags ivec3", "2 unusedVarying vec3" }, + fragment.Inputs.Select(v => v.Location + " " + v.Name + " " + v.GlslType)); + Assert.Equal( + new[] { "0 outColor vec4", "1 outGlow vec4", "2 outNeverWritten vec4", "3 outMotion vec4" }, + fragment.Outputs.Select(v => v.Location + " " + v.Name + " " + v.GlslType)); + } + + [Fact] + public void DescriptorBindingsCarrySetBindingKindTypeArrayAndName() + { + (SpirvModuleReflection fragment, _) = Reflect(Fragment, EnumShaderType.FragmentShader); + + Assert.Equal( + string.Join("\n", new[] + { + "0/1 shadowMapFar CombinedImageSampler sampler2DShadow len=0 runtime=False", + "1/0 optimumTextures2D CombinedImageSampler sampler2D len=0 runtime=True", + "1/1 optimumTextures2DArray CombinedImageSampler sampler2DArray len=0 runtime=True", + // Declared `[]` but never indexed: glslang sizes it as a one-element array. + "1/6 optimumTextures2DShadow CombinedImageSampler sampler2DShadow len=1 runtime=False", + "2/0 faceDataBuf StorageBuffer FaceData len=0 runtime=False", + "2/3 program UniformBuffer OptimumProgram len=0 runtime=False", + }), + string.Join("\n", fragment.Bindings.Select(b => b.Set + "/" + b.Binding + " " + b.Name + " " + b.Kind + " " + b.GlslType + + " len=" + b.ArrayLength + " runtime=" + b.RuntimeArray))); + + SpirvBlockMember faces = Assert.Single(fragment.Bindings.Single(b => b.Name == "faceDataBuf").Block!.Members); + Assert.Equal(("faces", "uint", 0, -1), (faces.Name, faces.GlslType, faces.Offset, faces.ArrayLength)); + } + + [Fact] + public void PushConstantMembersHaveScalarLayoutOffsetsAndSizes() + { + (SpirvModuleReflection fragment, _) = Reflect(Fragment, EnumShaderType.FragmentShader); + + SpirvBlock push = fragment.PushConstants!; + Assert.Equal("OptimumDraw", push.TypeName); + Assert.Equal("draw", push.InstanceName); + Assert.Equal( + new[] { "terrainTex uint @0 4", "blockTex uint @4 4", "origin vec3 @8 12", "modelViewMatrix mat4 @20 64" }, + push.Members.Select(m => m.Name + " " + m.GlslType + " @" + m.Offset + " " + m.Size)); + Assert.Equal(84, push.Size); + } + + [Fact] + public void UniformBlockMembersHaveScalarLayoutOffsetsSizesAndArrays() + { + (SpirvModuleReflection fragment, _) = Reflect(Fragment, EnumShaderType.FragmentShader); + + SpirvBlock record = fragment.Bindings.Single(b => b.Name == "program").Block!; + Assert.Equal( + new[] + { + "alphaTest float @0 4 len=0", + "rgbaFogIn vec4 @4 16 len=0", + "weights float @20 12 len=3", + "normalMatrix mat3 @32 36 len=0", + "frameSize ivec2 @68 8 len=0", + }, + record.Members.Select(m => m.Name + " " + m.GlslType + " @" + m.Offset + " " + m.Size + " len=" + m.ArrayLength)); + Assert.Equal(76, record.Size); + } + + [Fact] + public void SpecializationConstantsCarryIdNameTypeAndDefault() + { + (SpirvModuleReflection fragment, _) = Reflect(Fragment, EnumShaderType.FragmentShader); + + Assert.Equal( + new[] { "3 OPTIMUM_BLOOM int 0", "7 OPTIMUM_MINBRIGHT float 0.25", "9 OPTIMUM_FXAA bool 1", "11 OPTIMUM_UNUSED uint 5" }, + fragment.SpecConstants.Select(c => c.SpecId + " " + c.Name + " " + c.GlslType + " " + c.DefaultValue.ToString(System.Globalization.CultureInfo.InvariantCulture))); + } + + [Fact] + public void TheShippedModuleTellsWhichOutputsAreWrittenAndWhichDescriptorsAreUsed() + { + (SpirvModuleReflection declared, SpirvModuleReflection shipped) = Reflect(Fragment, EnumShaderType.FragmentShader); + + // Written through a whole store (0), a swizzle into an access chain (1) and a spec-constant branch (3). + Assert.Equal(new[] { 0, 1, 3 }, shipped.WrittenOutputLocations); + Assert.Equal(new[] { 0, 1, 3 }, declared.WrittenOutputLocations); + + // The shipped module has no names but keeps set and binding: shadow array at binding 6 is unused. + var used = shipped.Bindings.Where(b => shipped.UsedVariables.Contains(b.VariableId)).Select(b => b.Set + "/" + b.Binding); + Assert.Equal(new[] { "0/1", "1/0", "1/1", "2/0", "2/3" }, used); + Assert.All(shipped.Bindings, b => Assert.Equal("", b.Name)); + } + + [Fact] + public void PushMembersAreTiedToTheArraysTheyIndex() + { + (_, SpirvModuleReflection shipped) = Reflect(Fragment, EnumShaderType.FragmentShader); + + Assert.Equal(new[] { 0, 1 }, shipped.PushMemberIndexes.Keys.OrderBy(k => k)); + Assert.Equal(new[] { (1, 0) }, shipped.PushMemberIndexes[0]); + Assert.Equal(new[] { (1, 1) }, shipped.PushMemberIndexes[1]); + } + + [Fact] + public void AnythingButSpirvIsRejected() + { + Assert.Throws(() => SpirvReflection.Reflect(new byte[] { 1, 2, 3 })); + Assert.Throws(() => SpirvReflection.Reflect(new byte[20])); + } +} diff --git a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj index fecb39c8..d3b1a4c7 100644 --- a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -88,6 +88,8 @@ + + diff --git a/Optimum.Render.Vulkan/Shaders/NativeShaderBuilder.cs b/Optimum.Render.Vulkan/Shaders/NativeShaderBuilder.cs new file mode 100644 index 00000000..f1fd8a9c --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/NativeShaderBuilder.cs @@ -0,0 +1,775 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// What one build produced: the manifest, the SPIR-V files by name, and every error. +internal sealed class NativeShaderBuildResult +{ + public NativeShaderManifest Manifest = new(); + /// SPIR-V file name (relative to the manifest directory) to bytes. + public SortedDictionary Files = new(StringComparer.Ordinal); + public List Errors = new(); + public bool Success => Errors.Count == 0; +} + +/// +/// The offline native shader compiler behind tools/shader-compiler +/// (docs/vulkan-native-shaders.md sections 5 and 6). +/// +/// For every <program>.vert/.frag pair in the source directory it resolves +/// #includes (the including file's directory first, then include/), finds the variant +/// axes the source branches on, compiles every combination twice - the shipped optimised module and +/// an unoptimised twin that keeps names and declarations - reflects both, checks the result against +/// the set convention, and records it in a . +/// +internal sealed class NativeShaderBuilder +{ + /// The define symbols that stay compile-time variants (contract section 5), sorted. + public static readonly string[] VariantAxes = + { + "ALLOWDEPTHOFFSET", "GBUFFER", "GLOWSUB", "GREEDYMESH", "TAAMOTION", "USEOIT", "USESSBO", "VEC3SCALE", + }; + + public const string IncludeDirectoryName = "include"; + + /// + /// Declares a sampler slot in the push block: OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTex) + /// expands to uint terrainTex (bindings.glsl). A SPIR-V uint does not say which + /// sampler type it indexes, so the builder reads the declaration from the source and checks it + /// against the array the shipped module actually indexes. + /// + public const string SamplerSlotMacro = "OPTIMUM_SAMPLER_SLOT"; + + private const int MaxIncludeDepth = 16; + + private static readonly Regex IncludeDirective = new(@"^[ \t]*#[ \t]*include[ \t]+[""<]([^"">]+)["">][^\r\n]*", RegexOptions.Multiline); + private static readonly Regex ConditionalDirective = new(@"^[ \t]*#[ \t]*(if|elif|ifdef|ifndef)\b([^\r\n]*)", RegexOptions.Multiline); + private static readonly Regex Identifier = new(@"\b[A-Za-z_][A-Za-z0-9_]*\b"); + private static readonly Regex DefinedAxis = new(@"\bdefined\s*\(?\s*([A-Za-z_][A-Za-z0-9_]*)"); + private static readonly Regex SamplerSlot = new(@"^(?![ \t]*#)[^\r\n]*?\bOPTIMUM_SAMPLER_SLOT\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)", RegexOptions.Multiline); + private static readonly Regex SamplerSlotAnywhere = new(@"\bOPTIMUM_SAMPLER_SLOT\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)"); + + private readonly ShaderCompiler _compiler; + + public NativeShaderBuilder(ShaderCompiler compiler) + { + _compiler = compiler; + } + + // ------------------------------------------------------------------ build + + /// Builds every program in , or only . + public NativeShaderBuildResult Build(string sourceDirectory, string? onlyProgram = null) + { + var result = new NativeShaderBuildResult(); + result.Manifest.Toolchain = _compiler.Identity; + + if (!Directory.Exists(sourceDirectory)) + { + result.Errors.Add("source directory not found: " + sourceDirectory); + return result; + } + + List programs = DiscoverPrograms(sourceDirectory, result.Errors); + if (onlyProgram != null) + { + if (!programs.Contains(onlyProgram)) + { + result.Errors.Add("no program '" + onlyProgram + "' (needs " + onlyProgram + ".vert and " + onlyProgram + ".frag)"); + return result; + } + programs = new List { onlyProgram }; + } + + foreach (string program in programs) + { + NativeProgram? built = BuildProgram(sourceDirectory, program, result); + if (built != null) result.Manifest.Programs.Add(built); + } + return result; + } + + /// Program names with both stages present, sorted; a lone stage is an error. + public static List DiscoverPrograms(string sourceDirectory, List errors) + { + var vertex = new SortedSet(StringComparer.Ordinal); + var fragment = new SortedSet(StringComparer.Ordinal); + foreach (string file in Directory.GetFiles(sourceDirectory)) + { + string extension = Path.GetExtension(file); + if (extension == ".vert") vertex.Add(Path.GetFileNameWithoutExtension(file)); + else if (extension == ".frag") fragment.Add(Path.GetFileNameWithoutExtension(file)); + } + + foreach (string name in vertex.Except(fragment)) errors.Add(name + ".vert has no " + name + ".frag"); + foreach (string name in fragment.Except(vertex)) errors.Add(name + ".frag has no " + name + ".vert"); + + var programs = vertex.Intersect(fragment).ToList(); + programs.Sort(StringComparer.Ordinal); + return programs; + } + + private NativeProgram? BuildProgram(string sourceDirectory, string name, NativeShaderBuildResult result) + { + var stages = new (string Extension, string StageName, EnumShaderType Type)[] + { + ("vert", "vertex", EnumShaderType.VertexShader), + ("frag", "fragment", EnumShaderType.FragmentShader), + }; + + var texts = new string[stages.Length]; + var includes = new SortedSet(StringComparer.Ordinal); + for (int i = 0; i < stages.Length; i++) + { + string path = Path.Combine(sourceDirectory, name + "." + stages[i].Extension); + try + { + texts[i] = ExpandIncludes(path, sourceDirectory, includes); + } + catch (InvalidDataException error) + { + result.Errors.Add(name + ": " + error.Message); + return null; + } + } + + int errorsBefore = result.Errors.Count; + List axes = AxesOf(name, texts, result.Errors); + Dictionary slotTypes = SamplerSlotDeclarations(name, texts, result.Errors); + if (result.Errors.Count > errorsBefore) return null; + + var program = new NativeProgram { Name = name, Axes = axes }; + for (int combination = 0; combination < 1 << axes.Count; combination++) + { + var values = new Dictionary(StringComparer.Ordinal); + var prefix = new StringBuilder(); + for (int a = 0; a < axes.Count; a++) + { + int value = (combination >> (axes.Count - 1 - a)) & 1; + values[axes[a]] = value; + prefix.Append("#define ").Append(axes[a]).Append(' ').Append(value.ToString(CultureInfo.InvariantCulture)).Append('\n'); + } + string key = NativeShaderManifest.VariantKey(axes, values); + string label = name + (key.Length > 0 ? " [" + key + "]" : ""); + string fileStem = name + string.Concat(axes.Select(axis => "." + axis + values[axis].ToString(CultureInfo.InvariantCulture))); + + var shipped = new SpirvModuleReflection[stages.Length]; + var declared = new SpirvModuleReflection[stages.Length]; + var variant = new NativeVariant { Key = key }; + bool compiled = true; + for (int i = 0; i < stages.Length; i++) + { + string code = ShaderCompiler.SplicePrefix(texts[i], prefix.ToString()); + string fileName = name + "." + stages[i].Extension; + ShaderCompileResult optimised = _compiler.Compile(code, fileName, stages[i].Type); + ShaderCompileResult reflection = optimised.Success + ? _compiler.CompileForReflection(code, fileName, stages[i].Type) + : optimised; + if (!optimised.Success || !reflection.Success) + { + result.Errors.Add(label + " " + fileName + ": " + (optimised.Error ?? reflection.Error)); + compiled = false; + continue; + } + + shipped[i] = SpirvReflection.Reflect(optimised.Spirv); + declared[i] = SpirvReflection.Reflect(reflection.Spirv); + + string spirvName = fileStem + "." + stages[i].Extension + ".spv"; + result.Files[spirvName] = optimised.Spirv; + variant.Stages.Add(new NativeStage + { + Stage = stages[i].StageName, + Source = fileName, + Spirv = spirvName, + Sha256 = Convert.ToHexStringLower(SHA256.HashData(optimised.Spirv)), + }); + } + if (!compiled) continue; + + if (DescribeVariant(label, variant, declared[0], declared[1], shipped[0], shipped[1], slotTypes, includes, result.Errors)) + { + program.Variants.Add(variant); + } + } + + program.Variants.Sort((a, b) => string.CompareOrdinal(a.Key, b.Key)); + return program; + } + + /// Fills the variant's reflected fields and checks them; false when anything is wrong. + private static bool DescribeVariant( + string label, NativeVariant variant, + SpirvModuleReflection vertex, SpirvModuleReflection fragment, + SpirvModuleReflection shippedVertex, SpirvModuleReflection shippedFragment, + Dictionary slotTypes, IReadOnlySet includes, List errors) + { + int errorsBefore = errors.Count; + + // Push block: both stages include the same interface file, so they must agree. + SpirvBlock? push = Agree(label, "push block", vertex.PushConstants, fragment.PushConstants, errors); + if (push != null && push.Size > SetConvention.PushConstantBytes) + { + errors.Add(label + ": push block is " + push.Size + " B, the limit is " + SetConvention.PushConstantBytes); + } + variant.Push = ToNative(push); + + foreach (SpirvModuleReflection module in new[] { vertex, fragment }) + { + foreach (SpirvDescriptorBinding binding in module.Bindings) CheckConvention(label, binding, errors); + } + + SpirvBlock? record = Agree(label, "program record", + RecordOf(vertex), RecordOf(fragment), errors); + variant.Record = ToNative(record); + + // Sampler slots: uint push members declared with OPTIMUM_SAMPLER_SLOT, first in the block. + if (push != null) + { + bool seenOther = false; + for (int index = 0; index < push.Members.Count; index++) + { + SpirvBlockMember member = push.Members[index]; + var indexed = new SortedSet<(int Set, int Binding)>(); + foreach (SpirvModuleReflection module in new[] { shippedVertex, shippedFragment }) + { + if (module.PushMemberIndexes.TryGetValue(index, out SortedSet<(int Set, int Binding)>? found)) indexed.UnionWith(found); + } + + if (!slotTypes.TryGetValue(member.Name, out string? glslType)) + { + seenOther = true; + if (indexed.Any(pair => pair.Set == SetConvention.TextureSet)) + { + errors.Add(label + ": push member '" + member.Name + "' indexes a texture array but is not declared with " + SamplerSlotMacro); + } + continue; + } + + if (member.GlslType != "uint" || member.ArrayLength != 0) + { + errors.Add(label + ": sampler slot '" + member.Name + "' must be a uint, is " + member.GlslType); + continue; + } + if (seenOther) + { + errors.Add(label + ": sampler slot '" + member.Name + "' follows a non-slot push member; slots come first"); + } + + SetConvention.Binding array = Array.Find(SetConvention.TextureArrays, b => b.GlslType == glslType); + foreach ((int set, int binding) in indexed) + { + if (set != SetConvention.TextureSet || binding != array.Value) + { + errors.Add(label + ": sampler slot '" + member.Name + "' is declared " + glslType + " (" + array.Name + + ", set 1 binding " + array.Value + ") but indexes set " + set + " binding " + binding); + } + } + + variant.Samplers.Add(new NativeSampler + { + Name = member.Name, + GlslType = glslType, + BindlessArray = array.Name, + ArrayBinding = array.Value, + PushOffset = member.Offset, + Order = variant.Samplers.Count, + }); + } + } + foreach (string slot in slotTypes.Keys.OrderBy(s => s, StringComparer.Ordinal)) + { + bool inRecord = record?.Members.Any(m => m.Name == slot) ?? false; + if (inRecord) errors.Add(label + ": sampler slot '" + slot + "' is declared in the program record; slots live in the push block"); + } + + foreach (UniformMember member in FrameGlobals.Members) + { + string? owner = FrameGlobals.OwnerOf(member.Name); + if (owner != null && NativeIncludesFor(owner).Any(includes.Contains)) variant.FrameMembers.Add(member.Name); + } + + var frameTextures = new SortedDictionary(); + var storage = new SortedDictionary<(int, int), NativeStorageBinding>(); + foreach (SpirvModuleReflection module in new[] { vertex, fragment }) + { + foreach (SpirvDescriptorBinding binding in module.Bindings) + { + if (binding.Set != SetConvention.StorageSet || binding.Binding == SetConvention.ProgramRecordBinding) continue; + storage.TryAdd((binding.Set, binding.Binding), new NativeStorageBinding + { + Name = binding.Name, + Set = binding.Set, + Binding = binding.Binding, + DescriptorType = binding.Kind == SpirvDescriptorKind.StorageBuffer ? "storageBuffer" : "uniformBuffer", + ArrayLength = binding.ArrayLength, + RuntimeArray = binding.RuntimeArray, + }); + } + } + foreach (SpirvModuleReflection module in new[] { shippedVertex, shippedFragment }) + { + foreach (SpirvDescriptorBinding binding in module.Bindings) + { + if (!module.UsedVariables.Contains(binding.VariableId)) continue; + if (binding.Set == SetConvention.FrameSet) + { + SetConvention.Binding texture = Array.Find(SetConvention.FrameTextures, b => b.Value == binding.Binding); + if (texture.Name != null) + { + frameTextures[binding.Binding] = new NativeFrameTexture + { + Name = texture.Name, + GlslType = texture.GlslType, + Binding = texture.Value, + }; + } + } + else if (storage.TryGetValue((binding.Set, binding.Binding), out NativeStorageBinding? declared)) + { + declared.Used = true; + } + } + } + variant.FrameTextures.AddRange(frameTextures.Values); + variant.StorageBindings.AddRange(storage.Values); + + variant.VertexInputs = vertex.Inputs.Select(ToNative).ToList(); + variant.FragmentOutputs = fragment.Outputs.Select(ToNative).ToList(); + foreach (int location in shippedFragment.WrittenOutputLocations) + { + if (location < 32) variant.WrittenOutputs |= 1u << location; + } + + var constants = new SortedDictionary(); + foreach (SpirvModuleReflection module in new[] { vertex, fragment }) + { + foreach (SpirvSpecConstant constant in module.SpecConstants) + { + var native = new NativeSpecConstant + { + Id = constant.SpecId, + Name = constant.Name, + Type = constant.GlslType, + Default = constant.DefaultValue, + }; + if (!constants.TryGetValue(constant.SpecId, out NativeSpecConstant? existing)) + { + constants[constant.SpecId] = native; + } + else if (existing.Name != native.Name || existing.Type != native.Type || !existing.Default.Equals(native.Default)) + { + errors.Add(label + ": specialization constant " + constant.SpecId + " is '" + existing.Name + "' " + existing.Type + + " = " + existing.Default + " in one stage and '" + native.Name + "' " + native.Type + " = " + native.Default + " in the other"); + } + } + } + variant.SpecializationConstants.AddRange(constants.Values); + + return errors.Count == errorsBefore; + } + + /// The native include names that stand for a GLSL 330 owner file (fogandlight.vsh is fogandlight.vert.glsl). + internal static IEnumerable NativeIncludesFor(string owner) + { + string stem = Path.GetFileNameWithoutExtension(owner); + string extension = Path.GetExtension(owner); + string stage = extension switch + { + ".vsh" => "vert", + ".fsh" => "frag", + ".gsh" => "geom", + _ => "", + }; + if (stage.Length > 0) yield return stem + "." + stage + ".glsl"; + yield return stem + ".glsl"; + } + + private static SpirvBlock? RecordOf(SpirvModuleReflection module) => + module.Bindings.Find(b => b.Set == SetConvention.StorageSet && b.Binding == SetConvention.ProgramRecordBinding)?.Block; + + private static SpirvBlock? Agree(string label, string what, SpirvBlock? a, SpirvBlock? b, List errors) + { + if (a == null || b == null) return a ?? b; + if (Describe(a) != Describe(b)) + { + errors.Add(label + ": the " + what + " differs between the stages: " + Describe(a) + " vs " + Describe(b)); + } + return a; + } + + private static string Describe(SpirvBlock block) => + block.TypeName + "{" + string.Join(";", block.Members.Select(m => + m.GlslType + " " + m.Name + (m.ArrayLength != 0 ? "[" + m.ArrayLength + "]" : "") + "@" + m.Offset)) + "}"; + + private static void CheckConvention(string label, SpirvDescriptorBinding binding, List errors) + { + string where = label + ": '" + binding.Name + "' at set " + binding.Set + " binding " + binding.Binding + + " (reflected " + binding.Kind + " " + binding.GlslType + (binding.RuntimeArray ? "[]" : binding.ArrayLength > 0 ? "[" + binding.ArrayLength + "]" : "") + ")"; + switch (binding.Set) + { + case SetConvention.FrameSet: + if (binding.Binding == SetConvention.FrameGlobalsBinding) + { + if (binding.Kind != SpirvDescriptorKind.UniformBuffer) errors.Add(where + " must be the FrameGlobals uniform block"); + return; + } + SetConvention.Binding frame = Array.Find(SetConvention.FrameTextures, b => b.Value == binding.Binding); + if (frame.Name == null) errors.Add(where + " is not a binding of set 0 (bindings.glsl)"); + else if (binding.Kind != SpirvDescriptorKind.CombinedImageSampler || binding.GlslType != frame.GlslType || binding.ArrayLength != 0) + { + errors.Add(where + " must be " + frame.GlslType + " " + frame.Name); + } + return; + case SetConvention.TextureSet: + SetConvention.Binding array = Array.Find(SetConvention.TextureArrays, b => b.Value == binding.Binding); + if (array.Name == null) errors.Add(where + " is not a binding of set 1 (bindings.glsl)"); + // glslang sizes a `[]` array that is never indexed as a one-element array, so an + // unused bindless array in the unoptimised twin is sized, not runtime. + else if (binding.Kind != SpirvDescriptorKind.CombinedImageSampler || binding.GlslType != array.GlslType || + !(binding.RuntimeArray || binding.ArrayLength > 0)) + { + errors.Add(where + " must be " + array.GlslType + " " + array.Name + "[]"); + } + return; + case SetConvention.StorageSet: + if (binding.Binding == SetConvention.ProgramRecordBinding) + { + if (binding.Kind != SpirvDescriptorKind.UniformBuffer) errors.Add(where + " must be the program record, a uniform block"); + return; + } + if (Array.FindIndex(SetConvention.StorageBuffers, b => b.Value == binding.Binding) < 0) + { + errors.Add(where + " is not a binding of set 2 (bindings.glsl)"); + } + else if (binding.Kind != SpirvDescriptorKind.StorageBuffer) + { + errors.Add(where + " must be a storage buffer"); + } + return; + default: + errors.Add(where + ": only sets 0-" + (SetConvention.SetCount - 1) + " exist (bindings.glsl)"); + return; + } + } + + private static NativeBlock? ToNative(SpirvBlock? block) => block == null ? null : new NativeBlock + { + TypeName = block.TypeName, + Size = block.Size, + Members = block.Members.Select(m => new NativeMember + { + Name = m.Name, + Type = m.GlslType, + Offset = m.Offset, + Size = m.Size, + ArrayLength = m.ArrayLength, + }).ToList(), + }; + + private static NativeInterfaceVariable ToNative(SpirvInterfaceVariable variable) => new() + { + Location = variable.Location, + Name = variable.Name, + Type = variable.GlslType, + ArrayLength = variable.ArrayLength, + }; + + // ------------------------------------------------------------------ source scanning + + /// + /// The file's text with every #include replaced by the included file, recursively. + /// Include guards are the files' own business, as with GL_GOOGLE_include_directive. + /// + internal static string ExpandIncludes(string path, string sourceDirectory, ISet included, int depth = 0) + { + if (depth > MaxIncludeDepth) throw new InvalidDataException("includes nest deeper than " + MaxIncludeDepth + " at " + path); + string text = File.ReadAllText(path).Replace("\r\n", "\n"); + string directory = Path.GetDirectoryName(path) ?? sourceDirectory; + + return IncludeDirective.Replace(text, match => + { + string name = match.Groups[1].Value; + string? resolved = new[] + { + Path.Combine(directory, name), + Path.Combine(sourceDirectory, IncludeDirectoryName, name), + } + .FirstOrDefault(File.Exists); + if (resolved == null) + { + throw new InvalidDataException(Path.GetFileName(path) + " includes '" + name + "', found neither beside it nor in " + IncludeDirectoryName + "/"); + } + included.Add(Path.GetFileName(resolved)); + return ExpandIncludes(resolved, sourceDirectory, included, depth + 1); + }); + } + + /// The variant axes any conditional in the expanded stages tests, sorted. + internal static List AxesOf(string program, IEnumerable texts, List errors) + { + var axes = new SortedSet(StringComparer.Ordinal); + foreach (string text in texts) + { + foreach (Match match in ConditionalDirective.Matches(text)) + { + string directive = match.Groups[1].Value; + string condition = match.Groups[2].Value; + foreach (Match token in Identifier.Matches(condition)) + { + if (Array.BinarySearch(VariantAxes, token.Value, StringComparer.Ordinal) < 0) continue; + if (directive is "ifdef" or "ifndef") + { + errors.Add(program + ": #" + directive + " " + token.Value + " - every axis is always defined (0 or 1); test it with #if"); + } + axes.Add(token.Value); + } + foreach (Match defined in DefinedAxis.Matches(condition)) + { + if (Array.BinarySearch(VariantAxes, defined.Groups[1].Value, StringComparer.Ordinal) >= 0) + { + errors.Add(program + ": defined(" + defined.Groups[1].Value + ") - every axis is always defined (0 or 1); test its value"); + } + } + } + } + return axes.ToList(); + } + + /// Sampler slot name to GLSL sampler type, from every use. + internal static Dictionary SamplerSlotDeclarations(string program, IEnumerable texts, List errors) + { + var slots = new Dictionary(StringComparer.Ordinal); + foreach (string text in texts) + { + // bindings.glsl documents the macro with an example use in a comment. + foreach (Match match in SamplerSlot.Matches(StripComments(text))) + { + // The macro's own #define line is excluded by the pattern; a use can still share a + // line with other text, so take every use on the matched line. + foreach (Match use in SamplerSlotAnywhere.Matches(match.Value)) + { + string type = use.Groups[1].Value; + string name = use.Groups[2].Value; + if (Array.FindIndex(SetConvention.TextureArrays, b => b.GlslType == type) < 0) + { + errors.Add(program + ": sampler slot '" + name + "' has type " + type + ", which has no bindless array (bindings.glsl)"); + continue; + } + if (slots.TryGetValue(name, out string? existing) && existing != type) + { + errors.Add(program + ": sampler slot '" + name + "' is declared both " + existing + " and " + type); + continue; + } + slots[name] = type; + } + } + } + return slots; + } + + /// The text with // and /* */ comments blanked out, line breaks kept. + internal static string StripComments(string text) + { + var builder = new StringBuilder(text.Length); + int i = 0; + while (i < text.Length) + { + if (text[i] == '/' && i + 1 < text.Length && text[i + 1] == '/') + { + while (i < text.Length && text[i] != '\n') i++; + } + else if (text[i] == '/' && i + 1 < text.Length && text[i + 1] == '*') + { + i += 2; + while (i < text.Length && !(text[i] == '*' && i + 1 < text.Length && text[i + 1] == '/')) + { + if (text[i] == '\n') builder.Append('\n'); + i++; + } + i = Math.Min(i + 2, text.Length); + builder.Append(' '); + } + else + { + builder.Append(text[i]); + i++; + } + } + return builder.ToString(); + } + + // ------------------------------------------------------------------ output + + /// + /// Writes a full build to <outputRoot>/shaders-vk: every SPIR-V file and the + /// manifest, rewriting only files whose bytes changed, and deleting SPIR-V the build no longer + /// produces. + /// + public static void Write(NativeShaderBuildResult result, string outputRoot) + { + string directory = Path.Combine(outputRoot, NativeShaderManifest.DirectoryName); + Directory.CreateDirectory(directory); + foreach (string stale in Directory.GetFiles(directory, "*.spv")) + { + if (!result.Files.ContainsKey(Path.GetFileName(stale))) File.Delete(stale); + } + foreach ((string name, byte[] bytes) in result.Files) WriteIfChanged(Path.Combine(directory, name), bytes); + WriteIfChanged(Path.Combine(directory, NativeShaderManifest.FileName), Encoding.UTF8.GetBytes(result.Manifest.ToJson())); + } + + /// + /// Merges a one-program build into the manifest already in , which + /// must have been written by the same toolchain; replaces that program's SPIR-V. + /// + public static void WriteSingle(NativeShaderBuildResult result, string outputRoot) + { + string directory = Path.Combine(outputRoot, NativeShaderManifest.DirectoryName); + string manifestPath = Path.Combine(directory, NativeShaderManifest.FileName); + if (!File.Exists(manifestPath)) throw new InvalidDataException("no manifest at " + manifestPath + "; run --build first"); + + NativeShaderManifest existing = NativeShaderManifest.Load(manifestPath); + if (existing.Toolchain != result.Manifest.Toolchain) + { + throw new InvalidDataException("the manifest at " + manifestPath + " was built by another toolchain; run --build"); + } + + foreach (NativeProgram program in result.Manifest.Programs) + { + NativeProgram? old = existing.FindProgram(program.Name); + if (old != null) + { + foreach (NativeStage stage in old.Variants.SelectMany(v => v.Stages)) + { + string stalePath = Path.Combine(directory, stage.Spirv); + if (!result.Files.ContainsKey(stage.Spirv) && File.Exists(stalePath)) File.Delete(stalePath); + } + existing.Programs.Remove(old); + } + existing.Programs.Add(program); + } + existing.Programs.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name)); + + foreach ((string name, byte[] bytes) in result.Files) WriteIfChanged(Path.Combine(directory, name), bytes); + WriteIfChanged(manifestPath, Encoding.UTF8.GetBytes(existing.ToJson())); + } + + /// Every way the files in <outputRoot>/shaders-vk differ from a fresh build; empty when identical. + public static List Compare(NativeShaderBuildResult result, string outputRoot) + { + var differences = new List(); + string directory = Path.Combine(outputRoot, NativeShaderManifest.DirectoryName); + string manifestPath = Path.Combine(directory, NativeShaderManifest.FileName); + + if (!File.Exists(manifestPath)) + { + differences.Add("missing " + manifestPath); + } + else if (File.ReadAllText(manifestPath) != result.Manifest.ToJson()) + { + differences.Add(NativeShaderManifest.FileName + " differs from a fresh build"); + } + + foreach ((string name, byte[] bytes) in result.Files) + { + string path = Path.Combine(directory, name); + if (!File.Exists(path)) differences.Add("missing " + name); + else if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes)) differences.Add(name + " differs (sha256 " + + Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))) + ", fresh build " + Convert.ToHexStringLower(SHA256.HashData(bytes)) + ")"); + } + + if (Directory.Exists(directory)) + { + foreach (string file in Directory.GetFiles(directory, "*.spv")) + { + if (!result.Files.ContainsKey(Path.GetFileName(file))) differences.Add("unexpected " + Path.GetFileName(file)); + } + } + return differences; + } + + private static void WriteIfChanged(string path, byte[] bytes) + { + if (File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(bytes)) return; + File.WriteAllBytes(path, bytes); + } +} + +/// +/// The command line of tools/shader-compiler, kept here so tests drive exactly what the +/// build runs: +/// --build <src> <out>, --verify <src> <out>, +/// --single <program> <src> <out>. Output lands in <out>/shaders-vk. +/// Exit codes: 0 success, 1 build or verify failure, 2 usage. +/// +internal static class NativeShaderTool +{ + public const string Usage = + "usage: Optimum.Shaders.Compiler --build \n" + + " Optimum.Shaders.Compiler --verify \n" + + " Optimum.Shaders.Compiler --single "; + + public static int Run(string[] args, TextWriter output, TextWriter error) + { + string mode = args.Length > 0 ? args[0] : ""; + int expected = mode == "--single" ? 4 : 3; + if (mode is not ("--build" or "--verify" or "--single") || args.Length != expected) + { + error.WriteLine(Usage); + return 2; + } + + string? program = mode == "--single" ? args[1] : null; + string source = args[expected - 2]; + string outputRoot = args[expected - 1]; + + using var compiler = new ShaderCompiler(); + NativeShaderBuildResult result = new NativeShaderBuilder(compiler).Build(source, program); + if (!result.Success) + { + foreach (string message in result.Errors) error.WriteLine("error: " + message); + error.WriteLine("shader-compiler: " + result.Errors.Count + " error(s); nothing written"); + return 1; + } + + int variants = result.Manifest.Programs.Sum(p => p.Variants.Count); + string summary = result.Manifest.Programs.Count + " program(s), " + variants + " variant(s), " + result.Files.Count + " SPIR-V file(s)"; + try + { + switch (mode) + { + case "--build": + NativeShaderBuilder.Write(result, outputRoot); + output.WriteLine("shader-compiler: built " + summary + " into " + Path.Combine(outputRoot, NativeShaderManifest.DirectoryName)); + return 0; + case "--single": + NativeShaderBuilder.WriteSingle(result, outputRoot); + output.WriteLine("shader-compiler: rebuilt " + program + ", " + summary); + return 0; + default: + List differences = NativeShaderBuilder.Compare(result, outputRoot); + foreach (string difference in differences) error.WriteLine("differs: " + difference); + if (differences.Count > 0) + { + error.WriteLine("shader-compiler: " + differences.Count + " difference(s) against a fresh build of " + source); + return 1; + } + output.WriteLine("shader-compiler: verified " + summary); + return 0; + } + } + catch (Exception failure) when (failure is IOException or InvalidDataException or UnauthorizedAccessException) + { + error.WriteLine("error: " + failure.Message); + return 1; + } + } +} diff --git a/Optimum.Render.Vulkan/Shaders/NativeShaderManifest.cs b/Optimum.Render.Vulkan/Shaders/NativeShaderManifest.cs new file mode 100644 index 00000000..ed81bcfd --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/NativeShaderManifest.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// shaders.manifest.json: what the offline compiler (tools/shader-compiler) produced +/// from sources/shaders-vk and what the runtime links against +/// (docs/vulkan-native-shaders.md section 6). +/// +/// Written and read with and directly: +/// the output is byte-for-byte deterministic (the --verify gate compares it as bytes), and +/// no reflection-based serializer is involved at runtime. +/// +internal sealed class NativeShaderManifest +{ + /// Bumped whenever a field is added, removed or changes meaning; readers refuse any other value. + public const int CurrentSchemaVersion = 1; + + public const string FileName = "shaders.manifest.json"; + + /// The output directory, beside Optimum.Render.Vulkan.dll; never under assets/. + public const string DirectoryName = "shaders-vk"; + + public int SchemaVersion = CurrentSchemaVersion; + + /// of the compiler that produced every blob. + public string Toolchain = ""; + + /// Sorted by name. + public List Programs = new(); + + public NativeProgram? FindProgram(string name) => Programs.Find(p => p.Name == name); + + public NativeVariant? Find(string program, string variantKey) => + FindProgram(program)?.Variants.Find(v => v.Key == variantKey); + + /// + /// The variant key for a set of axis values: the sorted NAME=value list, comma separated, + /// restricted to (the axes the program branches on). Empty when none. + /// + public static string VariantKey(IEnumerable axes, IReadOnlyDictionary values) + { + var names = new List(axes); + names.Sort(StringComparer.Ordinal); + var parts = new List(names.Count); + foreach (string name in names) + { + parts.Add(name + "=" + (values.TryGetValue(name, out int value) ? value : 0).ToString(CultureInfo.InvariantCulture)); + } + return string.Join(",", parts); + } + + // ------------------------------------------------------------------ writer + + public string ToJson() + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true, NewLine = "\n" })) + { + writer.WriteStartObject(); + writer.WriteNumber("schemaVersion", SchemaVersion); + writer.WriteString("toolchain", Toolchain); + writer.WriteStartArray("programs"); + foreach (NativeProgram program in Programs) WriteProgram(writer, program); + writer.WriteEndArray(); + writer.WriteEndObject(); + } + return Encoding.UTF8.GetString(stream.ToArray()) + "\n"; + } + + private static void WriteProgram(Utf8JsonWriter writer, NativeProgram program) + { + writer.WriteStartObject(); + writer.WriteString("name", program.Name); + WriteStrings(writer, "axes", program.Axes); + writer.WriteStartArray("variants"); + foreach (NativeVariant variant in program.Variants) + { + writer.WriteStartObject(); + writer.WriteString("key", variant.Key); + + writer.WriteStartArray("stages"); + foreach (NativeStage stage in variant.Stages) + { + writer.WriteStartObject(); + writer.WriteString("stage", stage.Stage); + writer.WriteString("source", stage.Source); + writer.WriteString("spirv", stage.Spirv); + writer.WriteString("sha256", stage.Sha256); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + WriteBlock(writer, "push", variant.Push); + WriteBlock(writer, "record", variant.Record); + WriteStrings(writer, "frameMembers", variant.FrameMembers); + + writer.WriteStartArray("samplers"); + foreach (NativeSampler sampler in variant.Samplers) + { + writer.WriteStartObject(); + writer.WriteString("name", sampler.Name); + writer.WriteString("glslType", sampler.GlslType); + writer.WriteString("bindlessArray", sampler.BindlessArray); + writer.WriteNumber("arrayBinding", sampler.ArrayBinding); + writer.WriteNumber("pushOffset", sampler.PushOffset); + writer.WriteNumber("order", sampler.Order); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + writer.WriteStartArray("frameTextures"); + foreach (NativeFrameTexture texture in variant.FrameTextures) + { + writer.WriteStartObject(); + writer.WriteString("name", texture.Name); + writer.WriteString("glslType", texture.GlslType); + writer.WriteNumber("binding", texture.Binding); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + writer.WriteStartArray("storageBindings"); + foreach (NativeStorageBinding binding in variant.StorageBindings) + { + writer.WriteStartObject(); + writer.WriteString("name", binding.Name); + writer.WriteNumber("set", binding.Set); + writer.WriteNumber("binding", binding.Binding); + writer.WriteString("descriptorType", binding.DescriptorType); + writer.WriteNumber("arrayLength", binding.ArrayLength); + writer.WriteBoolean("runtimeArray", binding.RuntimeArray); + writer.WriteBoolean("used", binding.Used); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + WriteInterface(writer, "vertexInputs", variant.VertexInputs); + WriteInterface(writer, "fragmentOutputs", variant.FragmentOutputs); + writer.WriteNumber("writtenOutputs", variant.WrittenOutputs); + + writer.WriteStartArray("specializationConstants"); + foreach (NativeSpecConstant constant in variant.SpecializationConstants) + { + writer.WriteStartObject(); + writer.WriteNumber("id", constant.Id); + writer.WriteString("name", constant.Name); + writer.WriteString("type", constant.Type); + if (constant.Type == "bool") writer.WriteBoolean("default", constant.Default != 0); + else writer.WriteNumber("default", constant.Default); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + private static void WriteBlock(Utf8JsonWriter writer, string property, NativeBlock? block) + { + if (block == null) + { + writer.WriteNull(property); + return; + } + writer.WriteStartObject(property); + writer.WriteString("typeName", block.TypeName); + writer.WriteNumber("size", block.Size); + writer.WriteStartArray("members"); + foreach (NativeMember member in block.Members) + { + writer.WriteStartObject(); + writer.WriteString("name", member.Name); + writer.WriteString("type", member.Type); + writer.WriteNumber("offset", member.Offset); + writer.WriteNumber("size", member.Size); + writer.WriteNumber("arrayLength", member.ArrayLength); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + private static void WriteInterface(Utf8JsonWriter writer, string property, List variables) + { + writer.WriteStartArray(property); + foreach (NativeInterfaceVariable variable in variables) + { + writer.WriteStartObject(); + writer.WriteNumber("location", variable.Location); + writer.WriteString("name", variable.Name); + writer.WriteString("type", variable.Type); + writer.WriteNumber("arrayLength", variable.ArrayLength); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + private static void WriteStrings(Utf8JsonWriter writer, string property, List values) + { + writer.WriteStartArray(property); + foreach (string value in values) writer.WriteStringValue(value); + writer.WriteEndArray(); + } + + // ------------------------------------------------------------------ reader + + /// + /// Parses a manifest. Throws for malformed JSON, a missing + /// field, or a schema version other than . + /// + public static NativeShaderManifest Parse(string json) + { + JsonDocument document; + try + { + document = JsonDocument.Parse(json); + } + catch (JsonException error) + { + throw new InvalidDataException("shader manifest is not valid JSON: " + error.Message, error); + } + + using (document) + { + JsonElement root = document.RootElement; + int version = Int(root, "schemaVersion"); + if (version != CurrentSchemaVersion) + { + throw new InvalidDataException( + "shader manifest schema version " + version + ", this build reads " + CurrentSchemaVersion); + } + + var manifest = new NativeShaderManifest + { + SchemaVersion = version, + Toolchain = Str(root, "toolchain"), + }; + foreach (JsonElement program in Arr(root, "programs")) + { + manifest.Programs.Add(ReadProgram(program)); + } + return manifest; + } + } + + public static NativeShaderManifest Load(string path) => Parse(File.ReadAllText(path)); + + private static NativeProgram ReadProgram(JsonElement element) + { + var program = new NativeProgram { Name = Str(element, "name"), Axes = Strings(element, "axes") }; + foreach (JsonElement v in Arr(element, "variants")) + { + var variant = new NativeVariant + { + Key = Str(v, "key"), + Push = ReadBlock(v, "push"), + Record = ReadBlock(v, "record"), + FrameMembers = Strings(v, "frameMembers"), + VertexInputs = ReadInterface(v, "vertexInputs"), + FragmentOutputs = ReadInterface(v, "fragmentOutputs"), + WrittenOutputs = Get(v, "writtenOutputs").GetUInt32(), + }; + foreach (JsonElement s in Arr(v, "stages")) + { + variant.Stages.Add(new NativeStage + { + Stage = Str(s, "stage"), + Source = Str(s, "source"), + Spirv = Str(s, "spirv"), + Sha256 = Str(s, "sha256"), + }); + } + foreach (JsonElement s in Arr(v, "samplers")) + { + variant.Samplers.Add(new NativeSampler + { + Name = Str(s, "name"), + GlslType = Str(s, "glslType"), + BindlessArray = Str(s, "bindlessArray"), + ArrayBinding = Int(s, "arrayBinding"), + PushOffset = Int(s, "pushOffset"), + Order = Int(s, "order"), + }); + } + foreach (JsonElement t in Arr(v, "frameTextures")) + { + variant.FrameTextures.Add(new NativeFrameTexture + { + Name = Str(t, "name"), + GlslType = Str(t, "glslType"), + Binding = Int(t, "binding"), + }); + } + foreach (JsonElement b in Arr(v, "storageBindings")) + { + variant.StorageBindings.Add(new NativeStorageBinding + { + Name = Str(b, "name"), + Set = Int(b, "set"), + Binding = Int(b, "binding"), + DescriptorType = Str(b, "descriptorType"), + ArrayLength = Int(b, "arrayLength"), + RuntimeArray = Get(b, "runtimeArray").GetBoolean(), + Used = Get(b, "used").GetBoolean(), + }); + } + foreach (JsonElement c in Arr(v, "specializationConstants")) + { + string type = Str(c, "type"); + JsonElement value = Get(c, "default"); + variant.SpecializationConstants.Add(new NativeSpecConstant + { + Id = Int(c, "id"), + Name = Str(c, "name"), + Type = type, + Default = value.ValueKind switch + { + JsonValueKind.True => 1, + JsonValueKind.False => 0, + _ => value.GetDouble(), + }, + }); + } + program.Variants.Add(variant); + } + return program; + } + + private static NativeBlock? ReadBlock(JsonElement parent, string property) + { + JsonElement element = Get(parent, property); + if (element.ValueKind == JsonValueKind.Null) return null; + var block = new NativeBlock { TypeName = Str(element, "typeName"), Size = Int(element, "size") }; + foreach (JsonElement m in Arr(element, "members")) + { + block.Members.Add(new NativeMember + { + Name = Str(m, "name"), + Type = Str(m, "type"), + Offset = Int(m, "offset"), + Size = Int(m, "size"), + ArrayLength = Int(m, "arrayLength"), + }); + } + return block; + } + + private static List ReadInterface(JsonElement parent, string property) + { + var list = new List(); + foreach (JsonElement e in Arr(parent, property)) + { + list.Add(new NativeInterfaceVariable + { + Location = Int(e, "location"), + Name = Str(e, "name"), + Type = Str(e, "type"), + ArrayLength = Int(e, "arrayLength"), + }); + } + return list; + } + + private static JsonElement Get(JsonElement parent, string property) + { + if (parent.ValueKind != JsonValueKind.Object || !parent.TryGetProperty(property, out JsonElement value)) + { + throw new InvalidDataException("shader manifest: missing '" + property + "'"); + } + return value; + } + + private static string Str(JsonElement parent, string property) => + Get(parent, property).GetString() ?? throw new InvalidDataException("shader manifest: null '" + property + "'"); + + private static int Int(JsonElement parent, string property) => Get(parent, property).GetInt32(); + + private static JsonElement.ArrayEnumerator Arr(JsonElement parent, string property) + { + JsonElement value = Get(parent, property); + if (value.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException("shader manifest: '" + property + "' is not an array"); + } + return value.EnumerateArray(); + } + + private static List Strings(JsonElement parent, string property) + { + var list = new List(); + foreach (JsonElement e in Arr(parent, property)) list.Add(e.GetString() ?? ""); + return list; + } +} + +internal sealed class NativeProgram +{ + /// The program's PassName, which is also its source file base name. + public string Name = ""; + /// The variant axes the source branches on, sorted. + public List Axes = new(); + /// One per combination of values, sorted by key. + public List Variants = new(); +} + +internal sealed class NativeVariant +{ + /// : sorted NAME=value, comma separated. + public string Key = ""; + public List Stages = new(); + /// The push-constant block; null when the program declares none. + public NativeBlock? Push; + /// The program record at set 2, OPTIMUM_BINDING_PROGRAM_RECORD; null when none. + public NativeBlock? Record; + /// FrameGlobals members the program reads under their own name (owner include rule), in block order. + public List FrameMembers = new(); + /// Bindless sampler slots, in push-block (GLSL 330 declaration) order. + public List Samplers = new(); + /// The fixed set 0 textures the shipped modules sample. + public List FrameTextures = new(); + public List StorageBindings = new(); + public List VertexInputs = new(); + public List FragmentOutputs = new(); + /// Bit n set when the shipped fragment module stores to the output at location n. + public uint WrittenOutputs; + public List SpecializationConstants = new(); +} + +internal sealed class NativeStage +{ + /// vertex or fragment. + public string Stage = ""; + /// Source file name relative to the source directory. + public string Source = ""; + /// SPIR-V file name relative to the manifest's directory. + public string Spirv = ""; + /// Lower-case hex SHA-256 of the SPIR-V file. + public string Sha256 = ""; +} + +internal sealed class NativeBlock +{ + public string TypeName = ""; + public int Size; + public List Members = new(); +} + +internal sealed class NativeMember +{ + public string Name = ""; + public string Type = ""; + public int Offset; + public int Size; + /// 0 when not an array, -1 for a runtime-sized array. + public int ArrayLength; +} + +internal sealed class NativeSampler +{ + /// The GLSL 330 sampler name, which is also the push member's name. + public string Name = ""; + public string GlslType = ""; + /// The set 1 array the slot indexes (optimumTextures2D, ...). + public string BindlessArray = ""; + public int ArrayBinding; + /// Offset of the uint slot index in the push block. + public int PushOffset; + /// Position among the program's sampler slots; matches the GLSL 330 declaration order by authoring. + public int Order; +} + +internal sealed class NativeFrameTexture +{ + public string Name = ""; + public string GlslType = ""; + public int Binding; +} + +internal sealed class NativeStorageBinding +{ + public string Name = ""; + public int Set; + public int Binding; + /// storageBuffer or uniformBuffer. + public string DescriptorType = ""; + public int ArrayLength; + public bool RuntimeArray; + /// Whether a shipped (optimised) module still reads it. + public bool Used; +} + +internal sealed class NativeInterfaceVariable +{ + public int Location; + public string Name = ""; + public string Type = ""; + public int ArrayLength; +} + +internal sealed class NativeSpecConstant +{ + public int Id; + public string Name = ""; + /// bool, int, uint, float or double. + public string Type = ""; + /// The declared default; a bool is 0 or 1. + public double Default; +} diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs index af726acb..b6b3cec0 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -157,11 +157,21 @@ public ShaderCompileResult Compile(string code, string filename, EnumShaderType return compiled; } - private ShaderCompileResult CompileUncached(string code, string filename, EnumShaderType stage) + /// + /// Compiles with optimisation off and never through the cache. The optimiser strips every + /// OpName and drops declarations nothing uses, so the offline shader compiler reflects + /// names and declared interfaces from this twin of the shipped module + /// (docs/vulkan-native-shaders.md section 6). Never shipped. + /// + public ShaderCompileResult CompileForReflection(string code, string filename, EnumShaderType stage) => + CompileUncached(code, filename, stage, optimize: false); + + private ShaderCompileResult CompileUncached(string code, string filename, EnumShaderType stage, bool optimize = true) { var result = new ShaderCompileResult(); CompileOptions* options = CreateOptions(); + if (!optimize) _api.CompileOptionsSetOptimizationLevel(options, OptimizationLevel.Zero); try { CompilationResult* compiled = CompileWith(code, filename, stage, options, preprocessOnly: false); diff --git a/Optimum.Render.Vulkan/Shaders/SpirvReflection.cs b/Optimum.Render.Vulkan/Shaders/SpirvReflection.cs new file mode 100644 index 00000000..bc152a8c --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/SpirvReflection.cs @@ -0,0 +1,822 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Optimum.Render.Vulkan.Shaders; + +/// What a descriptor binding holds, as the SPIR-V declares it. +internal enum SpirvDescriptorKind +{ + CombinedImageSampler, + SampledImage, + StorageImage, + Sampler, + UniformBuffer, + StorageBuffer, +} + +/// A member of a uniform, push-constant or storage block. +internal sealed class SpirvBlockMember +{ + public string Name = ""; + /// The GLSL spelling of the element type (mat4, vec3, a struct's type name). + public string GlslType = ""; + public int Offset; + /// Bytes the member occupies: element size, or length times the array stride. + public int Size; + /// 0 when not an array, -1 for a runtime-sized array. + public int ArrayLength; +} + +/// A struct used as a block, with explicit member offsets. +internal sealed class SpirvBlock +{ + public string TypeName = ""; + public string InstanceName = ""; + /// End of the last member, which is what a scalar-layout block needs in bytes. + public int Size; + public List Members = new(); +} + +internal sealed class SpirvDescriptorBinding +{ + public uint VariableId; + public string Name = ""; + public int Set; + public int Binding; + public SpirvDescriptorKind Kind; + /// For images and samplers the GLSL type (sampler2DArrayShadow); for blocks the block type name. + public string GlslType = ""; + /// 0 when not an array. + public int ArrayLength; + public bool RuntimeArray; + /// The block layout for uniform and storage buffers, null otherwise. + public SpirvBlock? Block; +} + +internal sealed class SpirvInterfaceVariable +{ + public uint VariableId; + public string Name = ""; + public int Location; + public string GlslType = ""; + /// 0 when not an array. + public int ArrayLength; +} + +internal sealed class SpirvSpecConstant +{ + public int SpecId; + public string Name = ""; + /// bool, int, uint, float or double. + public string GlslType = ""; + /// The default as a number; a bool is 0 or 1. + public double DefaultValue; +} + +/// Everything the native shader manifest needs from one SPIR-V module. +internal sealed class SpirvModuleReflection +{ + public string EntryPoint = ""; + /// The SPIR-V execution model: 0 vertex, 3 geometry, 4 fragment, 5 compute. + public int ExecutionModel; + public List Inputs = new(); + public List Outputs = new(); + public List Bindings = new(); + public SpirvBlock? PushConstants; + public List SpecConstants = new(); + /// Descriptor, push and interface variables some function body actually uses. + public HashSet UsedVariables = new(); + /// Locations of the outputs a function body stores to (array outputs contribute every element). + public SortedSet WrittenOutputLocations = new(); + /// + /// For every push-constant member whose value is used as the first index into an arrayed + /// descriptor: the (set, binding) pairs it indexes. This is how a sampler slot is tied to the + /// bindless array it selects from, independent of names (an optimised module has none). + /// + public Dictionary> PushMemberIndexes = new(); +} + +/// +/// A small SPIR-V reader for the offline shader compiler and the native program manifest +/// (docs/vulkan-native-shaders.md section 6). It reads entry points, names, decorations and the +/// type graph; it does not validate the module, which shaderc produced moments earlier. +/// +/// An optimised module has no OpNames and has dropped whatever nothing uses, so the +/// builder reflects two modules per stage: the unoptimised one for declarations and names, +/// the shipped one for use (, written outputs, +/// slot dataflow). +/// +internal static class SpirvReflection +{ + public const uint Magic = 0x07230203; + + public const int ExecutionModelVertex = 0; + public const int ExecutionModelGeometry = 3; + public const int ExecutionModelFragment = 4; + + // Opcodes. + private const int OpName = 5; + private const int OpMemberName = 6; + private const int OpEntryPoint = 15; + private const int OpTypeVoid = 19; + private const int OpTypeBool = 20; + private const int OpTypeInt = 21; + private const int OpTypeFloat = 22; + private const int OpTypeVector = 23; + private const int OpTypeMatrix = 24; + private const int OpTypeImage = 25; + private const int OpTypeSampler = 26; + private const int OpTypeSampledImage = 27; + private const int OpTypeArray = 28; + private const int OpTypeRuntimeArray = 29; + private const int OpTypeStruct = 30; + private const int OpTypePointer = 32; + private const int OpConstantTrue = 41; + private const int OpConstantFalse = 42; + private const int OpConstant = 43; + private const int OpSpecConstantTrue = 48; + private const int OpSpecConstantFalse = 49; + private const int OpSpecConstant = 50; + private const int OpFunction = 54; + private const int OpFunctionCall = 57; + private const int OpVariable = 59; + private const int OpImageTexelPointer = 60; + private const int OpLoad = 61; + private const int OpStore = 62; + private const int OpCopyMemory = 63; + private const int OpAccessChain = 65; + private const int OpInBoundsAccessChain = 66; + private const int OpPtrAccessChain = 67; + private const int OpArrayLength = 68; + private const int OpCompositeExtract = 81; + private const int OpCopyObject = 83; + private const int OpUConvert = 113; + private const int OpSConvert = 114; + private const int OpBitcast = 124; + + // Decorations. + private const int DecorationSpecId = 1; + private const int DecorationBlock = 2; + private const int DecorationBufferBlock = 3; + private const int DecorationArrayStride = 6; + private const int DecorationMatrixStride = 7; + private const int DecorationBuiltIn = 11; + private const int DecorationLocation = 30; + private const int DecorationBinding = 33; + private const int DecorationDescriptorSet = 34; + private const int DecorationOffset = 35; + + // Storage classes. + private const int StorageUniformConstant = 0; + private const int StorageInput = 1; + private const int StorageUniform = 2; + private const int StorageOutput = 3; + private const int StoragePushConstant = 9; + private const int StorageStorageBuffer = 12; + + private sealed class TypeInfo + { + public int Op; + public uint[] Operands = Array.Empty(); + } + + private sealed class Module + { + public readonly Dictionary Names = new(); + public readonly Dictionary<(uint, int), string> MemberNames = new(); + public readonly Dictionary> Decorations = new(); + public readonly Dictionary<(uint, int), List<(int Decoration, uint[] Literals)>> MemberDecorations = new(); + public readonly Dictionary Types = new(); + public readonly Dictionary Constants = new(); + public readonly List<(uint Id, uint Type, int Op, uint[] Literals)> SpecConstants = new(); + public readonly Dictionary Variables = new(); + public readonly List<(int Op, uint[] Operands)> Body = new(); + public string EntryName = ""; + public int ExecutionModel = -1; + public uint[] Interface = Array.Empty(); + } + + public static SpirvModuleReflection Reflect(byte[] spirv) + { + if (spirv == null || spirv.Length < 20 || spirv.Length % 4 != 0) + { + throw new FormatException("not a SPIR-V module: length " + (spirv?.Length ?? 0)); + } + + var words = new uint[spirv.Length / 4]; + Buffer.BlockCopy(spirv, 0, words, 0, spirv.Length); + if (words[0] != Magic) + { + throw new FormatException("not a SPIR-V module: magic 0x" + words[0].ToString("x8")); + } + + Module module = Parse(words); + return Build(module); + } + + private static Module Parse(uint[] words) + { + var module = new Module(); + bool inFunctions = false; + int position = 5; + while (position < words.Length) + { + int count = (int)(words[position] >> 16); + int op = (int)(words[position] & 0xFFFF); + if (count == 0 || position + count > words.Length) + { + throw new FormatException("truncated SPIR-V instruction at word " + position); + } + var operands = new uint[count - 1]; + Array.Copy(words, position + 1, operands, 0, count - 1); + position += count; + + if (op == OpFunction) inFunctions = true; + if (inFunctions) + { + module.Body.Add((op, operands)); + continue; + } + + switch (op) + { + case OpName: + module.Names[operands[0]] = ReadString(operands, 1, out _); + break; + case OpMemberName: + module.MemberNames[(operands[0], (int)operands[1])] = ReadString(operands, 2, out _); + break; + case OpEntryPoint: + if (module.ExecutionModel < 0) + { + module.ExecutionModel = (int)operands[0]; + module.EntryName = ReadString(operands, 2, out int next); + module.Interface = operands[next..]; + } + break; + case 71: // OpDecorate + Add(module.Decorations, operands[0], ((int)operands[1], operands[2..])); + break; + case 72: // OpMemberDecorate + Add(module.MemberDecorations, (operands[0], (int)operands[1]), ((int)operands[2], operands[3..])); + break; + case OpTypeVoid: + case OpTypeBool: + case OpTypeInt: + case OpTypeFloat: + case OpTypeVector: + case OpTypeMatrix: + case OpTypeImage: + case OpTypeSampler: + case OpTypeSampledImage: + case OpTypeArray: + case OpTypeRuntimeArray: + case OpTypeStruct: + module.Types[operands[0]] = new TypeInfo { Op = op, Operands = operands[1..] }; + break; + case OpTypePointer: + module.Types[operands[0]] = new TypeInfo { Op = op, Operands = operands[1..] }; + break; + case OpConstant: + module.Constants[operands[1]] = (operands[0], operands[2..]); + break; + case OpConstantTrue: + case OpConstantFalse: + module.Constants[operands[1]] = (operands[0], new uint[] { op == OpConstantTrue ? 1u : 0u }); + break; + case OpSpecConstant: + case OpSpecConstantTrue: + case OpSpecConstantFalse: + module.SpecConstants.Add((operands[1], operands[0], op, operands[2..])); + break; + case OpVariable: + module.Variables[operands[1]] = (operands[0], (int)operands[2]); + break; + } + } + return module; + } + + private static SpirvModuleReflection Build(Module module) + { + var result = new SpirvModuleReflection + { + EntryPoint = module.EntryName, + ExecutionModel = module.ExecutionModel, + }; + + uint? pushVariable = null; + foreach ((uint id, (uint pointerType, int storageClass)) in module.Variables) + { + uint pointee = module.Types.TryGetValue(pointerType, out TypeInfo? pointer) && pointer.Op == OpTypePointer + ? pointer.Operands[1] + : 0; + if (pointee == 0) continue; + + switch (storageClass) + { + case StorageInput: + case StorageOutput: + { + if (HasDecoration(module, id, DecorationBuiltIn) || IsBuiltInBlock(module, pointee)) continue; + if (!TryDecoration(module, id, DecorationLocation, out uint location)) continue; + UnwrapArray(module, pointee, out uint element, out int length, out _); + var variable = new SpirvInterfaceVariable + { + VariableId = id, + Name = NameOf(module, id), + Location = (int)location, + GlslType = GlslTypeName(module, element), + ArrayLength = length, + }; + (storageClass == StorageInput ? result.Inputs : result.Outputs).Add(variable); + break; + } + case StoragePushConstant: + pushVariable = id; + result.PushConstants = ReadBlock(module, pointee, id); + break; + case StorageUniformConstant: + case StorageUniform: + case StorageStorageBuffer: + { + if (!TryDecoration(module, id, DecorationDescriptorSet, out uint set) || + !TryDecoration(module, id, DecorationBinding, out uint binding)) + { + continue; + } + UnwrapArray(module, pointee, out uint element, out int length, out bool runtime); + var descriptor = new SpirvDescriptorBinding + { + VariableId = id, + Name = NameOf(module, id), + Set = (int)set, + Binding = (int)binding, + ArrayLength = length, + RuntimeArray = runtime, + }; + TypeInfo elementType = module.Types[element]; + if (elementType.Op == OpTypeStruct) + { + bool bufferBlock = HasDecoration(module, element, DecorationBufferBlock); + descriptor.Kind = storageClass == StorageStorageBuffer || bufferBlock + ? SpirvDescriptorKind.StorageBuffer + : SpirvDescriptorKind.UniformBuffer; + descriptor.Block = ReadBlock(module, element, id); + descriptor.GlslType = descriptor.Block.TypeName; + } + else + { + descriptor.Kind = elementType.Op switch + { + OpTypeSampledImage => SpirvDescriptorKind.CombinedImageSampler, + OpTypeSampler => SpirvDescriptorKind.Sampler, + OpTypeImage when elementType.Operands[5] == 2 => SpirvDescriptorKind.StorageImage, + _ => SpirvDescriptorKind.SampledImage, + }; + descriptor.GlslType = GlslTypeName(module, element); + } + result.Bindings.Add(descriptor); + break; + } + } + } + + result.Inputs.Sort((a, b) => a.Location.CompareTo(b.Location)); + result.Outputs.Sort((a, b) => a.Location.CompareTo(b.Location)); + result.Bindings.Sort((a, b) => a.Set != b.Set ? a.Set.CompareTo(b.Set) : a.Binding.CompareTo(b.Binding)); + + foreach ((uint id, uint type, int op, uint[] literals) in module.SpecConstants) + { + if (!TryDecoration(module, id, DecorationSpecId, out uint specId)) continue; + string typeName = GlslTypeName(module, type); + result.SpecConstants.Add(new SpirvSpecConstant + { + SpecId = (int)specId, + Name = NameOf(module, id), + GlslType = typeName, + DefaultValue = op switch + { + OpSpecConstantTrue => 1, + OpSpecConstantFalse => 0, + _ => DecodeScalar(module, type, literals), + }, + }); + } + result.SpecConstants.Sort((a, b) => a.SpecId.CompareTo(b.SpecId)); + + AnalyseBodies(module, result, pushVariable); + return result; + } + + /// + /// One pass over the function bodies: which variables are used, which outputs are stored to, + /// and which push members index which arrayed descriptors. + /// + private static void AnalyseBodies(Module module, SpirvModuleReflection result, uint? pushVariable) + { + // Pointer results rooted at a global variable. + var roots = new Dictionary(); + // Pointer results that address one top-level member of the push block. + var pushMemberPointers = new Dictionary(); + // Values that carry a push member's value unchanged (or through an integer cast). + var pushMemberValues = new Dictionary(); + // Values that hold the whole push block. + var pushStructValues = new HashSet(); + + uint Root(uint id) => roots.TryGetValue(id, out uint root) ? root : id; + + void Use(uint pointer) + { + uint root = Root(pointer); + if (module.Variables.ContainsKey(root)) result.UsedVariables.Add(root); + } + + void Write(uint pointer) + { + uint root = Root(pointer); + if (!module.Variables.TryGetValue(root, out (uint PointerType, int StorageClass) variable) || + variable.StorageClass != StorageOutput) + { + return; + } + if (!TryDecoration(module, root, DecorationLocation, out uint location)) return; + uint pointee = module.Types[variable.PointerType].Operands[1]; + UnwrapArray(module, pointee, out _, out int length, out _); + for (int i = 0; i < Math.Max(1, length); i++) result.WrittenOutputLocations.Add((int)location + i); + } + + foreach ((int op, uint[] operands) in module.Body) + { + switch (op) + { + case OpAccessChain: + case OpInBoundsAccessChain: + case OpPtrAccessChain: + { + uint id = operands[1]; + uint baseId = operands[2]; + roots[id] = Root(baseId); + Use(baseId); + + int firstIndex = op == OpPtrAccessChain ? 4 : 3; + if (pushVariable.HasValue && baseId == pushVariable.Value && operands.Length == firstIndex + 1 && + TryConstant(module, operands[firstIndex], out long member)) + { + pushMemberPointers[id] = (int)member; + } + + if (operands.Length > firstIndex && + module.Variables.TryGetValue(baseId, out (uint PointerType, int StorageClass) arrayVariable) && + arrayVariable.StorageClass is StorageUniformConstant or StorageUniform or StorageStorageBuffer && + pushMemberValues.TryGetValue(operands[firstIndex], out int slotMember) && + TryDecoration(module, baseId, DecorationDescriptorSet, out uint set) && + TryDecoration(module, baseId, DecorationBinding, out uint binding)) + { + if (!result.PushMemberIndexes.TryGetValue(slotMember, out SortedSet<(int, int)>? indexed)) + { + indexed = new SortedSet<(int, int)>(); + result.PushMemberIndexes[slotMember] = indexed; + } + indexed.Add(((int)set, (int)binding)); + } + break; + } + case OpLoad: + { + uint id = operands[1]; + uint pointer = operands[2]; + Use(pointer); + if (pushMemberPointers.TryGetValue(pointer, out int member)) pushMemberValues[id] = member; + if (pushVariable.HasValue && pointer == pushVariable.Value) pushStructValues.Add(id); + break; + } + case OpStore: + Use(operands[0]); + Write(operands[0]); + break; + case OpCopyMemory: + Use(operands[0]); + Use(operands[1]); + Write(operands[0]); + break; + case OpImageTexelPointer: + case OpArrayLength: + Use(operands[2]); + break; + case OpFunctionCall: + for (int i = 3; i < operands.Length; i++) Use(operands[i]); + break; + case OpCompositeExtract: + if (pushStructValues.Contains(operands[2]) && operands.Length == 4) + { + pushMemberValues[operands[1]] = (int)operands[3]; + } + break; + case OpCopyObject: + case OpUConvert: + case OpSConvert: + case OpBitcast: + if (pushMemberValues.TryGetValue(operands[2], out int carried)) pushMemberValues[operands[1]] = carried; + if (roots.ContainsKey(operands[2]) || module.Variables.ContainsKey(operands[2])) + { + roots[operands[1]] = Root(operands[2]); + } + break; + } + } + } + + private static SpirvBlock ReadBlock(Module module, uint structType, uint variable) + { + var block = new SpirvBlock + { + TypeName = NameOf(module, structType), + InstanceName = NameOf(module, variable), + }; + TypeInfo type = module.Types[structType]; + if (type.Op != OpTypeStruct) return block; + + int end = 0; + for (int index = 0; index < type.Operands.Length; index++) + { + uint memberType = type.Operands[index]; + UnwrapArray(module, memberType, out uint element, out int length, out bool runtime); + TryMemberDecoration(module, structType, index, DecorationOffset, out uint offset); + TryMemberDecoration(module, structType, index, DecorationMatrixStride, out uint matrixStride); + + int elementSize = SizeOf(module, element, (int)matrixStride); + int size; + if (runtime) + { + size = 0; + } + else if (length > 0) + { + size = TryDecoration(module, memberType, DecorationArrayStride, out uint stride) + ? (int)stride * length + : elementSize * length; + } + else + { + size = elementSize; + } + + block.Members.Add(new SpirvBlockMember + { + Name = module.MemberNames.TryGetValue((structType, index), out string? name) ? name : "", + GlslType = GlslTypeName(module, element), + Offset = (int)offset, + Size = size, + ArrayLength = runtime ? -1 : length, + }); + end = Math.Max(end, (int)offset + size); + } + block.Size = end; + return block; + } + + /// Size of a non-array type under its explicit layout; a matrix uses its member's stride. + private static int SizeOf(Module module, uint typeId, int matrixStride) + { + TypeInfo type = module.Types[typeId]; + switch (type.Op) + { + case OpTypeBool: + return 4; + case OpTypeInt: + case OpTypeFloat: + return (int)type.Operands[0] / 8; + case OpTypeVector: + return SizeOf(module, type.Operands[0], 0) * (int)type.Operands[1]; + case OpTypeMatrix: + { + int columns = (int)type.Operands[1]; + int columnSize = SizeOf(module, type.Operands[0], 0); + return matrixStride > 0 ? (columns - 1) * matrixStride + Math.Max(columnSize, matrixStride) : columns * columnSize; + } + case OpTypeArray: + { + UnwrapArray(module, typeId, out uint element, out int length, out _); + return TryDecoration(module, typeId, DecorationArrayStride, out uint stride) + ? (int)stride * length + : SizeOf(module, element, 0) * length; + } + case OpTypeStruct: + { + int end = 0; + for (int index = 0; index < type.Operands.Length; index++) + { + TryMemberDecoration(module, typeId, index, DecorationOffset, out uint offset); + TryMemberDecoration(module, typeId, index, DecorationMatrixStride, out uint stride); + end = Math.Max(end, (int)offset + SizeOf(module, type.Operands[index], (int)stride)); + } + return end; + } + default: + return 0; + } + } + + private static void UnwrapArray(Module module, uint typeId, out uint element, out int length, out bool runtime) + { + element = typeId; + length = 0; + runtime = false; + TypeInfo type = module.Types[typeId]; + if (type.Op == OpTypeRuntimeArray) + { + element = type.Operands[0]; + runtime = true; + } + else if (type.Op == OpTypeArray) + { + element = type.Operands[0]; + length = TryConstant(module, type.Operands[1], out long value) ? (int)value : 0; + } + } + + private static bool IsBuiltInBlock(Module module, uint typeId) + { + UnwrapArray(module, typeId, out uint element, out _, out _); + TypeInfo type = module.Types[element]; + if (type.Op != OpTypeStruct) return false; + for (int index = 0; index < type.Operands.Length; index++) + { + if (module.MemberDecorations.TryGetValue((element, index), out var list) && + list.Exists(d => d.Decoration == DecorationBuiltIn)) + { + return true; + } + } + return false; + } + + /// The GLSL spelling of a non-pointer type; arrays spell only their element. + private static string GlslTypeName(Module module, uint typeId) + { + TypeInfo type = module.Types[typeId]; + switch (type.Op) + { + case OpTypeVoid: + return "void"; + case OpTypeBool: + return "bool"; + case OpTypeInt: + return type.Operands[1] != 0 ? "int" : "uint"; + case OpTypeFloat: + return type.Operands[0] == 64 ? "double" : "float"; + case OpTypeVector: + return ScalarPrefix(module, type.Operands[0]) + "vec" + type.Operands[1]; + case OpTypeMatrix: + { + TypeInfo column = module.Types[type.Operands[0]]; + int rows = (int)column.Operands[1]; + int columns = (int)type.Operands[1]; + string prefix = ScalarPrefix(module, column.Operands[0]) == "d" ? "dmat" : "mat"; + return rows == columns ? prefix + columns : prefix + columns + "x" + rows; + } + case OpTypeSampledImage: + return ImageName(module, type.Operands[0], "sampler"); + case OpTypeImage: + return ImageName(module, typeId, type.Operands[5] == 2 ? "image" : "texture"); + case OpTypeSampler: + return "sampler"; + case OpTypeArray: + case OpTypeRuntimeArray: + return GlslTypeName(module, type.Operands[0]); + case OpTypeStruct: + return NameOf(module, typeId); + default: + return "?"; + } + } + + private static string ScalarPrefix(Module module, uint scalarType) + { + TypeInfo scalar = module.Types[scalarType]; + return scalar.Op switch + { + OpTypeBool => "b", + OpTypeInt => scalar.Operands[1] != 0 ? "i" : "u", + OpTypeFloat when scalar.Operands[0] == 64 => "d", + _ => "", + }; + } + + private static string ImageName(Module module, uint imageTypeId, string stem) + { + uint[] image = module.Types[imageTypeId].Operands; + // sampled type, dim, depth, arrayed, multisampled, sampled, format + string prefix = ScalarPrefix(module, image[0]); + string dim = image[1] switch + { + 0 => "1D", + 1 => "2D", + 2 => "3D", + 3 => "Cube", + 4 => "2DRect", + 5 => "Buffer", + 6 => "SubpassData", + _ => "?", + }; + return prefix + stem + dim + (image[4] != 0 ? "MS" : "") + (image[3] != 0 ? "Array" : "") + + (image[2] == 1 ? "Shadow" : ""); + } + + private static double DecodeScalar(Module module, uint typeId, uint[] literals) + { + TypeInfo type = module.Types[typeId]; + if (literals.Length == 0) return 0; + switch (type.Op) + { + case OpTypeFloat when type.Operands[0] == 32: + return BitConverter.Int32BitsToSingle((int)literals[0]); + case OpTypeFloat: + return BitConverter.Int64BitsToDouble((long)((ulong)literals[0] | (literals.Length > 1 ? (ulong)literals[1] << 32 : 0))); + case OpTypeInt when type.Operands[1] != 0: + return (int)literals[0]; + default: + return literals[0]; + } + } + + private static bool TryConstant(Module module, uint id, out long value) + { + value = 0; + if (!module.Constants.TryGetValue(id, out (uint Type, uint[] Literals) constant) || constant.Literals.Length == 0) + { + return false; + } + TypeInfo type = module.Types[constant.Type]; + value = type.Op == OpTypeInt && type.Operands[1] != 0 ? (int)constant.Literals[0] : constant.Literals[0]; + return true; + } + + private static string NameOf(Module module, uint id) => module.Names.TryGetValue(id, out string? name) ? name : ""; + + private static bool HasDecoration(Module module, uint id, int decoration) => + module.Decorations.TryGetValue(id, out var list) && list.Exists(d => d.Decoration == decoration); + + private static bool TryDecoration(Module module, uint id, int decoration, out uint value) + { + value = 0; + if (!module.Decorations.TryGetValue(id, out var list)) return false; + foreach ((int kind, uint[] literals) in list) + { + if (kind != decoration) continue; + value = literals.Length > 0 ? literals[0] : 0; + return true; + } + return false; + } + + private static bool TryMemberDecoration(Module module, uint type, int member, int decoration, out uint value) + { + value = 0; + if (!module.MemberDecorations.TryGetValue((type, member), out var list)) return false; + foreach ((int kind, uint[] literals) in list) + { + if (kind != decoration) continue; + value = literals.Length > 0 ? literals[0] : 0; + return true; + } + return false; + } + + private static void Add(Dictionary> map, TKey key, (int, uint[]) value) + where TKey : notnull + { + if (!map.TryGetValue(key, out List<(int, uint[])>? list)) + { + list = new List<(int, uint[])>(); + map[key] = list; + } + list.Add(value); + } + + private static string ReadString(uint[] operands, int start, out int next) + { + var bytes = new List(); + int index = start; + for (; index < operands.Length; index++) + { + uint word = operands[index]; + bool terminated = false; + for (int shift = 0; shift < 32; shift += 8) + { + byte value = (byte)(word >> shift); + if (value == 0) + { + terminated = true; + break; + } + bytes.Add(value); + } + if (terminated) break; + } + next = Math.Min(index + 1, operands.Length); + return Encoding.UTF8.GetString(bytes.ToArray()); + } +} diff --git a/Optimum.Tests/installer-release-coverage-tests.cs b/Optimum.Tests/installer-release-coverage-tests.cs index 7fc4e609..7626e052 100644 --- a/Optimum.Tests/installer-release-coverage-tests.cs +++ b/Optimum.Tests/installer-release-coverage-tests.cs @@ -657,6 +657,79 @@ public void LinuxInstallerStagingShellTestPasses() Assert.True(process.ExitCode == 0, process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd()); } + // Native SPIR-V (docs/vulkan-native-shaders.md section 6) ships beside the renderer DLL in + // shaders-vk/, never under assets/, is copied whole from the build output with its own + // completeness check, and is never fed to the GLSL text-corruption scan. + + [Theory] + [InlineData("scripts/package-linux.sh", "$STAGE_DIR")] + [InlineData("scripts/package-macos.sh", "$APP_DIR")] + public void BashPackagesStageNativeShadersBesideTheRenderer(string relativePath, string stageRoot) + { + string script = Read(relativePath); + + Assert.Contains("SHADERS_VK_SRC=\"$MOD_OUT/shaders-vk\"", script); + Assert.Contains($"SHADERS_VK_DST=\"{stageRoot}/shaders-vk\"", script); + Assert.Contains($"cp -f \"$MOD_OUT/Optimum.Render.Vulkan.dll\" \"{stageRoot}/\"", script); + Assert.Contains("[[ ! -f \"$SHADERS_VK_SRC/shaders.manifest.json\" ]]", script); + Assert.Contains("rm -rf \"$SHADERS_VK_DST\"", script); + Assert.Contains("cmp -s \"$f\" \"$SHADERS_VK_DST/$(basename \"$f\")\" || MISSING_SHADERS_VK=", script); + Assert.Contains("done < <(find \"$SHADERS_VK_SRC\" -maxdepth 1 -type f -print0)", script); + Assert.DoesNotContain("assets/game/shaders-vk", script); + Assert.DoesNotContain("sources/shaders-vk", script); + + // The corruption scan reads only the GLSL overlay directory, by GLSL extension. + Assert.Equal("$SHADER_DST", Match(script, @"done < <\(find ""(\$[A-Z_]+)"" -maxdepth 1 -type f \\\( -name '\*\.vsh'")); + Assert.Equal($"\"{stageRoot}/assets/game/shaders\"", Match(script, @"\nSHADER_DST=(""[^""]+"")")); + } + + [Fact] + public void WindowsPackageStagesNativeShadersBesideTheRenderer() + { + string script = Read("scripts/package.ps1"); + + Assert.Contains("$shadersVkSrc = Join-Path $apiOut 'shaders-vk'", script); + Assert.Contains("$shadersVkDst = Join-Path $stageDir 'shaders-vk'", script); + Assert.Contains("Copy-Item -Force (Join-Path $apiOut 'Optimum.Render.Vulkan.dll') $stageDir", script); + Assert.Contains("Test-Path (Join-Path $shadersVkSrc 'shaders.manifest.json')", script); + Assert.Contains("(Get-FileHash $stagedSpirv).Hash -ne (Get-FileHash $spirvFile.FullName).Hash", script); + Assert.Contains("throw \"Native shader file(s) never reached", script); + Assert.Contains("'shaders-vk/shaders.manifest.json',", script); + Assert.DoesNotContain("assets/game/shaders-vk", script); + Assert.Contains("$badShaders = @(Get-ChildItem -Path (Join-Path $stageAssets 'game/shaders') -File |", script); + } + + [Fact] + public void DeployCopiesNativeShadersBesideTheRendererAndCheckShadersVkVerifiesThem() + { + string makefile = Read("Makefile"); + + Assert.Contains("check-shaders-vk", Match(makefile, @"(\.PHONY:[^\n]*)")); + Assert.Contains("dotnet $(SHADER_COMPILER) --verify sources/shaders-vk $(MOD_OUT)", makefile); + Assert.Contains("[ -f \"$(MOD_OUT)/shaders-vk/shaders.manifest.json\" ] || {", makefile); + foreach (string destination in new[] { "$(VANILLA_DIR)", "$(INSTALL_DIR)" }) + { + Assert.Contains($"rm -rf \"{destination}/shaders-vk\" && mkdir -p \"{destination}/shaders-vk\" && cp -f $(MOD_OUT)/shaders-vk/* \"{destination}/shaders-vk/\"", makefile); + Assert.Contains($"for f in $(MOD_OUT)/shaders-vk/*; do d=\"{destination}/shaders-vk/$$(basename $$f)\"; cmp -s \"$$f\" \"$$d\" ||", makefile); + } + Assert.DoesNotContain("assets/game/shaders-vk", makefile); + } + + [Fact] + public void TheShaderCompilerBuildsWithTheSolutionIncrementally() + { + Assert.Contains("", Read("VintageStory.slnx")); + + string project = Read("tools/shader-compiler/Optimum.Shaders.Compiler.csproj"); + Assert.Contains("", project); + Assert.Contains("AfterTargets=\"WriteNativeShaderInputList\"", project); + Assert.Contains("Inputs=\"@(NativeShaderSource);$(NativeShaderInputList);$(TargetPath);$(TargetDir)Optimum.Render.Vulkan.dll\"", project); + Assert.Contains("Outputs=\"$(NativeShaderStamp)\"", project); + Assert.Contains("", project); + Assert.Contains("--build "$(NativeShaderSourceDir)" "$(NativeShaderOutputRoot)"", project); + Assert.Contains("WriteOnlyWhenDifferent=\"true\"", project); + } + private static string Match(string source, string pattern) { System.Text.RegularExpressions.Match match = Regex.Match(source, pattern); diff --git a/Optimum.Tests/taa-settings-coverage-tests.cs b/Optimum.Tests/taa-settings-coverage-tests.cs index b9857ec5..c8df0359 100644 --- a/Optimum.Tests/taa-settings-coverage-tests.cs +++ b/Optimum.Tests/taa-settings-coverage-tests.cs @@ -388,8 +388,12 @@ public void MakeDeployCopiesEveryShaderAndFailsWhenOneDoesNotArrive() // The completeness check compares CONTENT, not mere existence: a vanilla // file of the same name that was never overwritten used to satisfy // [ -f "$$d" ] and pass. - Assert.Equal(2, Occurrences(makefile, "did not reach")); - Assert.Equal(2, Occurrences(makefile, "cmp -s \"$$f\" \"$$d\"")); + // Two per destination (vanilla dir, install dir): the GLSL overlay check and the + // native SPIR-V check (shaders-vk, pinned in installer-release-coverage-tests.cs). + Assert.Equal(4, Occurrences(makefile, "did not reach")); + Assert.Equal(4, Occurrences(makefile, "cmp -s \"$$f\" \"$$d\"")); + Assert.Equal(2, Occurrences(makefile, "for f in sources/shaders/* sources/shaderincludes/*; do [ -f \"$$f\" ] || continue; d=\"$(")); + Assert.Equal(2, Occurrences(makefile, "for f in $(MOD_OUT)/shaders-vk/*; do d=\"$(")); Assert.DoesNotContain("[ -f \"$$d\" ] ||", makefile); } diff --git a/VintageStory.slnx b/VintageStory.slnx index 46c86dd8..e93a6d69 100644 --- a/VintageStory.slnx +++ b/VintageStory.slnx @@ -24,6 +24,10 @@ no vanilla assembly gains a reference to a renderer implementation. See VULKAN-BACKEND-PLAN.md. --> + + diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index a28b88ff..d1dcfa7b 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -78,7 +78,7 @@ family stage. ```glsl // .interface.glsl layout(push_constant, scalar) uniform OptimumDraw { - uint terrainTex; // sampler slot, same name as the GLSL 330 sampler + OPTIMUM_SAMPLER_SLOT(sampler2DArray, terrainTex); // uint slot, the GLSL 330 sampler's name and type vec3 origin; // DRAW-frequency uniforms that fit mat4 modelViewMatrix; } draw; @@ -90,7 +90,12 @@ layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scal ``` - **Push block:** - - Every non-frame sampler's slot index first (4 B each). + - Every non-frame sampler's slot index first (4 B each), declared with + `OPTIMUM_SAMPLER_SLOT(, )` from `bindings.glsl`, which expands to `uint `. + SPIR-V keeps no trace of which array a `uint` indexes, so the offline compiler reads the type from the macro + and checks it against the set 1 array the shipped module actually indexes (section 6). A plain `uint` push + member that indexes a texture array is a build error. (Added 2026-09-15 with the compiler: the first draft + wrote a bare `uint` with a comment, which reflection cannot read.) - Then the uniforms the frequency map classifies as DRAW (they change between draws without a `Use()`), in declaration order, while the block stays within 128 B. - **Record:** every remaining non-frame uniform (PROGRAM-frequency ones, and DRAW ones that did not fit). @@ -135,33 +140,96 @@ The prefix is `ShaderRegistry.registerDefaultShaderCodePrefixes`, `ShaderRegistr - Declarations a spec-constant branch uses are declared unconditionally, so the name set of section 2 is unaffected (the oracle already sees names inside inactive `#if`s). - A settings change becomes a pipeline-key change, not a recompile. -- **Variant key** in the manifest: the sorted `NAME=value` list of the axis symbols the program branches on. +- **Variant key** in the manifest: the sorted `NAME=value` list of the axis symbols the program branches on, + joined by commas (`GBUFFER=1,TAAMOTION=0`; empty when the program has none). +- **Testing an axis:** only by value (`#if TAAMOTION == 1`, `#if GBUFFER`). Every variant defines every axis it + branches on as 0 or 1, so `#ifdef`, `#ifndef` and `defined()` on an axis are build errors. A program branches on + an axis when any `#if`/`#elif` in either stage, includes expanded, names it. ## 6. Offline compile, manifest, reflection -- **Tool:** `tools/shader-compiler` (C#, references the renderer for `ShaderCompiler`, `SetConvention` and - reflection), with three modes: - - `--build`: compiles every program and variant into `shaders-vk/*.spv` plus `shaders.manifest.json`. - - `--verify`: recompiles and compares hashes; this is the `make check-shaders-vk` gate. - - `--single`: one program. -- **Build wiring:** an MSBuild target runs it with a content-hash cache. Deploy puts the output beside the - renderer DLL, never under `assets/`. +- **Tool:** `tools/shader-compiler/Optimum.Shaders.Compiler.csproj`, a thin front end. The work is + `Shaders/NativeShaderBuilder.cs` in the renderer, so the runtime `OPTIMUM_VK_SHADER_SOURCE` loop (section 8) + compiles the same way; the command line is `NativeShaderTool.Run`, which the tests drive. Output always lands in + `/shaders-vk/`. Exit codes: 0 success, 1 build or verify failure (a failed build writes nothing), + 2 usage. + - `--build `: compiles every `.vert`/`.frag` pair (a lone stage is an error) + for every combination of the program's axes. It writes `[....]..spv`, axes + sorted (`chunkopaque.GBUFFER1.TAAMOTION0.frag.spv`), plus `shaders.manifest.json`. Only files whose bytes + changed are rewritten, and SPIR-V no program produces any more is deleted. A tree with only `include/` + yields a valid manifest with no programs. + - `--verify `: recompiles in memory and fails on any difference: manifest bytes, + SPIR-V bytes (reported with both SHA-256s), a missing or an unexpected `.spv`. `make check-shaders-vk` runs + it against `bin//net10.0`. + - `--single `: rebuilds one program into an existing manifest written by + the same toolchain. +- **Includes:** `#include "x"` resolves beside the including file first, then in `include/`. Expansion is + textual and recursive; include guards are the files' own. The includes of both stages together are the + program's include set for the frame-member rule (section 3). +- **Build wiring:** the MSBuild target sits on the tool project (after `Build`), not on + `Optimum.Render.Vulkan.csproj`, because the tool references the renderer and a renderer target that ran it would + be a build cycle. It is incremental: + - inputs: `sources/shaders-vk/**`, a source list written only when it changes (so deleting a file reruns the + target), the tool and the renderer DLL; + - output: a stamp in `obj/`, deleted first when the manifest is missing. The manifest itself cannot be an + output: write-if-changed leaves an unchanged manifest with an old timestamp, which would rerun the target on + every build; + - `-p:OptimumSkipNativeShaders=true` skips it. + + The content-hash cache is the tool's write-if-changed. Output goes to `bin//net10.0/shaders-vk/`, + the directory `make deploy` and the packagers read. +- **Deploy and packaging:** `make deploy` replaces `/shaders-vk/` beside `Optimum.Render.Vulkan.dll` + (vanilla dir and install dir) and compares every file with `cmp`. `scripts/package-linux.sh`, + `scripts/package-macos.sh` and `scripts/package.ps1` stage it the same way and fail when the manifest is missing. + The GLSL "void main" corruption scan never reads it. Never under `assets/`. The plan's + `/Optimum/shaders-vk/` is superseded: the renderer DLL sits in the client root, so beside it is + `/shaders-vk/`. - **Reflection** is a small SPIR-V reader in the renderer (`Shaders/SpirvReflection.cs`), not a new package. - It reads `OpEntryPoint` interfaces, `OpName`/`OpMemberName`, and `OpDecorate`/`OpMemberDecorate` (`Location`, `DescriptorSet`, `Binding`, `Offset`, `SpecId`) plus the type graph for sizes. - Set and binding numbers are fixed by `bindings.glsl`, so reflection only confirms them. No SPIR-V reflection dependency exists in the tree (integration map, section 5), and this avoids adding one to packaging. -- **Manifest per program and variant:** - - the variant key; - - stage files with SHA-256; - - push members (name, type, offset, size) and record members; - - frame members; - - samplers (name, GLSL type, array kind, push offset, GLSL 330 order); - - storage bindings, vertex inputs, fragment outputs and `writtenOutputs`; - - specialization constants (id, name, type, default). - - Schema version and toolchain identity sit at the top. + - **Two modules per stage.** An optimised module has no `OpName`s and has dropped every declaration nothing + uses (unused inputs, specialization constants and descriptors; unused outputs survive). Every stage is + therefore compiled twice: the shipped `-O` module, and an unoptimised twin + (`ShaderCompiler.CompileForReflection`, never shipped) that keeps names and every declaration. + - From the twin: names, block layouts, stage interfaces and specialization constants. + - From the shipped module: `writtenOutputs`, which descriptors are used, and which push member indexes which + array. + - glslang sizes a bindless array that is declared `[]` but never indexed as a one-element array rather than a + runtime array; the convention check accepts either. +- **Checks the tool enforces** (build errors, not warnings): + - the push block is at most 128 B; + - the push block and the record agree between the stages; + - every descriptor sits at a binding `bindings.glsl` defines, with its type: set 0 textures, set 1 arrays by + sampler type, set 2 storage buffers and the record as a uniform block; there is no set above 2; + - sampler slots use `OPTIMUM_SAMPLER_SLOT`, are `uint`, come before every other push member, and index the + set 1 array of their declared type; + - no plain `uint` push member indexes a texture array; + - specialization constants agree between the stages. +- **Manifest per program and variant** (schema version 1; written with `Utf8JsonWriter`, `\n` line endings, + byte-deterministic because `--verify` compares bytes). The program lists its `name` and sorted `axes`; each + variant has: + - `key`: the variant key (section 5); + - `stages`: stage, source file, SPIR-V file, SHA-256; + - `push` and `record`: type name, size and members (name, type, offset, size, `arrayLength`: 0 not an array, + -1 runtime), or `null`; + - `frameMembers`: the FrameGlobals members, in block order, whose owner include the program includes. + `fogandlight.vsh` stands for `fogandlight.vert.glsl` or `fogandlight.glsl`; `.fsh` likewise with `.frag`; + - `samplers`: name, GLSL type, `bindlessArray`, `arrayBinding`, `pushOffset`, `order`. The order is push-block + order, which is the GLSL 330 declaration order by authoring (section 4); + - `frameTextures`: the set 0 textures the shipped modules sample (name, GLSL type, binding). These are taken + from use, not declaration, because `bindings.glsl` declares all five in every program; the family parity + tests apply the owner rule to them; + - `storageBindings`: set 2 buffers other than the record (name, set, binding, descriptor type, array length, + runtime array, `used` by a shipped module); + - `vertexInputs` and `fragmentOutputs`: location, name, type and array length, as declared; + - `writtenOutputs`: bit n set when the shipped fragment module stores to location n; + - `specializationConstants`: id, name, type and default (a bool is a JSON bool). + + Schema version and toolchain identity (`ShaderCompiler.Identity`) sit at the top. A reader refuses any other + schema version. ## 7. Motion: `include/motion.glsl` diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 36171c80..abc8b05c 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -306,6 +306,31 @@ else echo "warning: no native shaderc at $SHADERC_NATIVE; the Vulkan renderer will not load" >&2 fi +# Native SPIR-V programs and their manifest (docs/vulkan-native-shaders.md section 6), +# beside Optimum.Render.Vulkan.dll and never under assets/: the asset manager must not +# read SPIR-V and a mod must not shadow engine shaders by asset priority. The build +# always writes the manifest, even for an empty source tree, so a missing one means +# tools/shader-compiler never ran. SPIR-V is binary: the "void main" corruption scan +# below covers the GLSL overlay in assets/game/shaders only and must never be pointed +# at this directory. +SHADERS_VK_SRC="$MOD_OUT/shaders-vk" +SHADERS_VK_DST="$STAGE_DIR/shaders-vk" +if [[ ! -f "$SHADERS_VK_SRC/shaders.manifest.json" ]]; then + echo "Error: $SHADERS_VK_SRC/shaders.manifest.json missing; build tools/shader-compiler (dotnet build VintageStory.slnx -c Release)" >&2 + exit 1 +fi +rm -rf "$SHADERS_VK_DST" +mkdir -p "$SHADERS_VK_DST" +find "$SHADERS_VK_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADERS_VK_DST/" \; +MISSING_SHADERS_VK="" +while IFS= read -r -d '' f; do + cmp -s "$f" "$SHADERS_VK_DST/$(basename "$f")" || MISSING_SHADERS_VK="$MISSING_SHADERS_VK $f" +done < <(find "$SHADERS_VK_SRC" -maxdepth 1 -type f -print0) +if [[ -n "$MISSING_SHADERS_VK" ]]; then + echo "Error: native shader file(s) never reached $SHADERS_VK_DST:$MISSING_SHADERS_VK" >&2 + exit 1 +fi + # 5a. Set up runtime donors for the launcher. # The launcher patches assemblies at first run and needs donor DLLs in .optimum/donors/. # It also needs the vanilla mod DLLs in .optimum/vanilla/Mods/ as baselines. diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 3de12401..17c86772 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -177,6 +177,31 @@ else echo "warning: no native shaderc at $SHADERC_NATIVE; the Vulkan renderer will not load" >&2 fi +# Native SPIR-V programs and their manifest (docs/vulkan-native-shaders.md section 6), +# beside Optimum.Render.Vulkan.dll and never under assets/: the asset manager must not +# read SPIR-V and a mod must not shadow engine shaders by asset priority. The build +# always writes the manifest, even for an empty source tree, so a missing one means +# tools/shader-compiler never ran. SPIR-V is binary: the "void main" corruption scan +# below covers the GLSL overlay in assets/game/shaders only and must never be pointed +# at this directory. +SHADERS_VK_SRC="$MOD_OUT/shaders-vk" +SHADERS_VK_DST="$APP_DIR/shaders-vk" +if [[ ! -f "$SHADERS_VK_SRC/shaders.manifest.json" ]]; then + echo "Error: $SHADERS_VK_SRC/shaders.manifest.json missing; build tools/shader-compiler (dotnet build VintageStory.slnx -c Release)" >&2 + exit 1 +fi +rm -rf "$SHADERS_VK_DST" +mkdir -p "$SHADERS_VK_DST" +find "$SHADERS_VK_SRC" -maxdepth 1 -type f -exec cp -f {} "$SHADERS_VK_DST/" \; +MISSING_SHADERS_VK="" +while IFS= read -r -d '' f; do + cmp -s "$f" "$SHADERS_VK_DST/$(basename "$f")" || MISSING_SHADERS_VK="$MISSING_SHADERS_VK $f" +done < <(find "$SHADERS_VK_SRC" -maxdepth 1 -type f -print0) +if [[ -n "$MISSING_SHADERS_VK" ]]; then + echo "Error: native shader file(s) never reached $SHADERS_VK_DST:$MISSING_SHADERS_VK" >&2 + exit 1 +fi + # 5a. Set up runtime donors for the launcher. # The launcher patches assemblies at first run and needs donor DLLs in .optimum/donors/. # It also needs the vanilla mod DLLs in .optimum/vanilla/Mods/ as baselines. diff --git a/scripts/package.ps1 b/scripts/package.ps1 index 02a7c9de..d1215040 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -285,6 +285,32 @@ try { Write-Warning "No native shaderc at $shadercNative; the Vulkan renderer will not load" } + # Native SPIR-V programs and their manifest (docs/vulkan-native-shaders.md section 6), + # beside Optimum.Render.Vulkan.dll and never under assets\: the asset manager must not + # read SPIR-V and a mod must not shadow engine shaders by asset priority. The build + # always writes the manifest, even for an empty source tree, so a missing one means + # tools/shader-compiler never ran. SPIR-V is binary: the "void main" corruption scan + # below covers the GLSL overlay in assets/game/shaders only and must never be pointed + # at this directory. + $shadersVkSrc = Join-Path $apiOut 'shaders-vk' + $shadersVkDst = Join-Path $stageDir 'shaders-vk' + if (-not (Test-Path (Join-Path $shadersVkSrc 'shaders.manifest.json'))) { + throw "Native shader manifest missing at $shadersVkSrc; build tools/shader-compiler (dotnet build VintageStory.slnx -c Release)" + } + if (Test-Path $shadersVkDst) { Remove-Item -Recurse -Force $shadersVkDst } + New-Item -ItemType Directory -Force -Path $shadersVkDst | Out-Null + Get-ChildItem $shadersVkSrc -File | ForEach-Object { Copy-Item -Force $_.FullName $shadersVkDst } + $missingShadersVk = @() + foreach ($spirvFile in (Get-ChildItem $shadersVkSrc -File)) { + $stagedSpirv = Join-Path $shadersVkDst $spirvFile.Name + if (-not (Test-Path $stagedSpirv) -or (Get-FileHash $stagedSpirv).Hash -ne (Get-FileHash $spirvFile.FullName).Hash) { + $missingShadersVk += $spirvFile.FullName + } + } + if ($missingShadersVk.Count -gt 0) { + throw "Native shader file(s) never reached ${shadersVkDst}: $($missingShadersVk -join ', ')" + } + foreach ($launcherFile in @('Optimum.exe', 'Optimum.dll', 'Optimum.deps.json', 'Optimum.runtimeconfig.json')) { Copy-Item -Force (Join-Path $launcherOut $launcherFile) $stageDir } @@ -437,6 +463,7 @@ try { 'Optimum.Patcher.dll', 'uninstall.ps1', 'Vintagestory.exe', + 'shaders-vk/shaders.manifest.json', '.optimum/donors/VintagestoryLib.Donor.dll', '.optimum/donors/VintagestoryAPI.Contracts.dll', '.optimum/donors/VSEssentials.Donor.dll', diff --git a/sources/shaders-vk/include/bindings.glsl b/sources/shaders-vk/include/bindings.glsl index dc459051..be84b8e1 100644 --- a/sources/shaders-vk/include/bindings.glsl +++ b/sources/shaders-vk/include/bindings.glsl @@ -26,6 +26,13 @@ #define OPTIMUM_PUSH_CONSTANT_BYTES 128 +// A sampler's slot index in the push block, under the GLSL 330 sampler's own name: +// OPTIMUM_SAMPLER_SLOT(sampler2DArray, terrainTex); +// declares `uint terrainTex`. SPIR-V keeps no trace of which array a uint indexes, so the +// offline compiler reads the type from this declaration and checks it against the set 1 +// array the shipped module actually indexes (docs/vulkan-native-shaders.md section 4). +#define OPTIMUM_SAMPLER_SLOT(glslType, name) uint name + // Set 0. The FrameGlobals block itself is generated from Shaders/FrameGlobals.cs. #define OPTIMUM_BINDING_FRAME_GLOBALS 0 #define OPTIMUM_BINDING_SHADOW_MAP_FAR 1 diff --git a/tools/shader-compiler/Optimum.Shaders.Compiler.csproj b/tools/shader-compiler/Optimum.Shaders.Compiler.csproj new file mode 100644 index 00000000..68eb66d2 --- /dev/null +++ b/tools/shader-compiler/Optimum.Shaders.Compiler.csproj @@ -0,0 +1,72 @@ + + + + + + Exe + net10.0 + Optimum.Shaders.Compiler + Optimum.Shaders.Compiler + annotations + true + + true + + + + + + + + + + ..\..\.vanilla\win-x64\vintagestory\VintagestoryAPI.dll + true + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\')) + $(OptimumRepositoryRoot)sources$([System.IO.Path]::DirectorySeparatorChar)shaders-vk + + $(OptimumRepositoryRoot)bin$([System.IO.Path]::DirectorySeparatorChar)$(Configuration)$([System.IO.Path]::DirectorySeparatorChar)net10.0 + $(NativeShaderOutputRoot)$([System.IO.Path]::DirectorySeparatorChar)shaders-vk$([System.IO.Path]::DirectorySeparatorChar)shaders.manifest.json + $(ProjectDir)$(IntermediateOutputPath)shaders-vk.inputs + $(ProjectDir)$(IntermediateOutputPath)shaders-vk.stamp + $(DOTNET_HOST_PATH) + dotnet + + + + + + + + + + + + + + + + + + + diff --git a/tools/shader-compiler/Program.cs b/tools/shader-compiler/Program.cs new file mode 100644 index 00000000..3c60ce7e --- /dev/null +++ b/tools/shader-compiler/Program.cs @@ -0,0 +1,9 @@ +using System; +using Optimum.Render.Vulkan.Shaders; + +namespace Optimum.Shaders.Compiler; + +internal static class Program +{ + private static int Main(string[] args) => NativeShaderTool.Run(args, Console.Out, Console.Error); +} From b0fc6ff0b4fe15c99fcf3268a77652420ae4bcb5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 22:51:28 +0200 Subject: [PATCH 158/226] wip(native-shaders): shader-compiler stamp and input list under obj/ IntermediateOutputPath is still empty where the project body's properties are evaluated, so the stamp and source list landed in tools/shader-compiler/ itself. BaseIntermediateOutputPath is set by then. Verified: the hook writes both into obj/, reruns on a fresh stamp and skips an unchanged rebuild; InstallerReleaseCoverageTests and TaaSettingsCoverageTests 45/45 passed. --- tools/shader-compiler/Optimum.Shaders.Compiler.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/shader-compiler/Optimum.Shaders.Compiler.csproj b/tools/shader-compiler/Optimum.Shaders.Compiler.csproj index 68eb66d2..4a841841 100644 --- a/tools/shader-compiler/Optimum.Shaders.Compiler.csproj +++ b/tools/shader-compiler/Optimum.Shaders.Compiler.csproj @@ -40,8 +40,8 @@ $(OptimumRepositoryRoot)bin$([System.IO.Path]::DirectorySeparatorChar)$(Configuration)$([System.IO.Path]::DirectorySeparatorChar)net10.0 $(NativeShaderOutputRoot)$([System.IO.Path]::DirectorySeparatorChar)shaders-vk$([System.IO.Path]::DirectorySeparatorChar)shaders.manifest.json - $(ProjectDir)$(IntermediateOutputPath)shaders-vk.inputs - $(ProjectDir)$(IntermediateOutputPath)shaders-vk.stamp + $(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)shaders-vk.inputs + $(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)shaders-vk.stamp $(DOTNET_HOST_PATH) dotnet From 1217c949969e739278d2ce78b972eef548eda141 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:08:00 +0200 Subject: [PATCH 159/226] wip(compute): frame-graph compute pass kind Compute pass declarations with storage writes and sampled/storage reads per mip range; barriers derive from new ResourceUsage cases (SampleCompute, StorageReadCompute, StorageWrite, StorageReadWrite: GENERAL for storage, SHADER_READ_ONLY for sampled, compute stage), one flush per pass with the rendering scope closed. Compute programs from SPIR-V with specialization constants through the shared driver pipeline cache, work group size reflected from the module; per-slot compute descriptor arena with per-level views; group counts from a binding's level extent; storage textures with a storage+sampled format-feature probe and a same-kind fallback ending in RGBA8; the frame plan sees compute writes as persistent uses; stats.counters gains compute_passes and dispatches. Verified: dotnet test Optimum.Render.Vulkan.Tests 743 passed, 0 failed, 0 skipped (sync,best validation, implicit layers disabled, only MESA_device_select inserted), including new GPU readback tests for a storage write with specialization and push constants, a mip chain storing level n+1 from sampled level n, and compute-then-raster sampling over 8 presented frames; dotnet test Optimum.Tests -c Release 1179 passed, 34 skipped, 0 failed. --- .../ComputePassPlanTests.cs | 304 +++++++++++++ .../ComputePassTests.cs | 403 ++++++++++++++++++ .../PacingStatsTests.cs | 4 +- .../Core/ComputeDescriptorArena.cs | 133 ++++++ .../Core/ComputePipelineCache.cs | 392 +++++++++++++++++ Optimum.Render.Vulkan/Core/PipelineCache.cs | 3 + Optimum.Render.Vulkan/Core/StorageFormats.cs | 53 +++ Optimum.Render.Vulkan/Core/TextureManager.cs | 88 ++++ Optimum.Render.Vulkan/Core/VulkanContext.cs | 11 + Optimum.Render.Vulkan/Core/VulkanStats.cs | 22 +- Optimum.Render.Vulkan/Graph/ComputePass.cs | 216 ++++++++++ Optimum.Render.Vulkan/Graph/FrameGraph.cs | 37 ++ Optimum.Render.Vulkan/Graph/ResourceUsage.cs | 19 + .../Shaders/ShaderCompiler.cs | 26 ++ .../Transfer/ReadbackManager.cs | 4 +- Optimum.Render.Vulkan/VulkanDevice.cs | 217 ++++++++++ Optimum.Tests/frame-graph-coverage-tests.cs | 2 +- docs/taa-acceptance.md | 6 +- 18 files changed, 1929 insertions(+), 11 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/ComputePassPlanTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/ComputePassTests.cs create mode 100644 Optimum.Render.Vulkan/Core/ComputeDescriptorArena.cs create mode 100644 Optimum.Render.Vulkan/Core/ComputePipelineCache.cs create mode 100644 Optimum.Render.Vulkan/Core/StorageFormats.cs create mode 100644 Optimum.Render.Vulkan/Graph/ComputePass.cs diff --git a/Optimum.Render.Vulkan.Tests/ComputePassPlanTests.cs b/Optimum.Render.Vulkan.Tests/ComputePassPlanTests.cs new file mode 100644 index 00000000..f65b40d6 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ComputePassPlanTests.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The compute pass kind without a device: the compute usages' layouts, stages and +/// accesses; the barriers a storage write, a sampled read and a mip chain derive from +/// them; declaration validation; group counts from an image; the signature the frame +/// plan sees; and the storage format fallback. +/// +public class ComputePassPlanTests +{ + private static List Require(ResourceStateTracker tracker, uint baseMip, uint mipCount, + ResourceUsage usage) + { + var output = new List(); + int count = tracker.Require(baseMip, mipCount, 0, tracker.Layers, usage, false, output); + Assert.Equal(output.Count, count); + return output; + } + + private static List Require(ResourceStateTracker tracker, ResourceUsage usage) => + Require(tracker, 0, tracker.MipLevels, usage); + + [Theory] + [InlineData(ResourceUsage.SampleCompute, ImageLayout.ShaderReadOnlyOptimal, AccessFlags2.ShaderSampledReadBit)] + [InlineData(ResourceUsage.StorageReadCompute, ImageLayout.General, AccessFlags2.ShaderStorageReadBit)] + [InlineData(ResourceUsage.StorageWrite, ImageLayout.General, AccessFlags2.ShaderStorageWriteBit)] + [InlineData(ResourceUsage.StorageReadWrite, ImageLayout.General, AccessFlags2.ShaderStorageReadBit | AccessFlags2.ShaderStorageWriteBit)] + public void ComputeUsagesAreGeneralForStorageAndShaderReadOnlyForSampling(ResourceUsage usage, ImageLayout layout, + AccessFlags2 access) + { + Assert.Equal(new UsageState(layout, PipelineStageFlags2.ComputeShaderBit, access), UsageState.For(usage, depth: false)); + } + + [Theory] + [InlineData(ComputeAccess.Sampled, ResourceUsage.SampleCompute)] + [InlineData(ComputeAccess.StorageRead, ResourceUsage.StorageReadCompute)] + [InlineData(ComputeAccess.StorageWrite, ResourceUsage.StorageWrite)] + [InlineData(ComputeAccess.StorageReadWrite, ResourceUsage.StorageReadWrite)] + public void EveryAccessHasItsUsage(ComputeAccess access, ResourceUsage usage) => + Assert.Equal(usage, ComputePassPlanner.UsageOf(access)); + + [Fact] + public void AStorageWriteThenASampledReadMakesTheWriteAvailableToTheFragmentShader() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + ImageTransition first = Assert.Single(Require(tracker, ResourceUsage.StorageWrite)); + Assert.Equal(ImageLayout.Undefined, first.Sides.OldLayout); + Assert.Equal(ImageLayout.General, first.Sides.NewLayout); + Assert.Equal(AccessFlags2.ShaderStorageWriteBit, first.Sides.DstAccess); + + // The next raster pass samples it: GENERAL -> SHADER_READ_ONLY naming the dispatch's write. + ImageTransition read = Assert.Single(Require(tracker, ResourceUsage.SampleFragment)); + Assert.Equal(ImageLayout.General, read.Sides.OldLayout); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, read.Sides.NewLayout); + Assert.Equal(PipelineStageFlags2.ComputeShaderBit, read.Sides.SrcStage); + Assert.Equal(AccessFlags2.ShaderStorageWriteBit, read.Sides.SrcAccess); + Assert.Equal(PipelineStageFlags2.FragmentShaderBit, read.Sides.DstStage); + + // The frame after writes it again: back to GENERAL, naming the fragment read. + ImageTransition again = Assert.Single(Require(tracker, ResourceUsage.StorageWrite)); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, again.Sides.OldLayout); + Assert.Equal(ImageLayout.General, again.Sides.NewLayout); + Assert.Equal(PipelineStageFlags2.FragmentShaderBit, again.Sides.SrcStage); + Assert.Equal(AccessFlags2.ShaderSampledReadBit, again.Sides.SrcAccess); + } + + [Fact] + public void RepeatedStorageWritesAndStorageReadsInGeneralNeedTheBarriersTheOrderingRulesAsk() + { + var tracker = new ResourceStateTracker(1, 1, depth: false); + Require(tracker, ResourceUsage.StorageWrite); + // Write after write at the same stage: none. + Assert.Empty(Require(tracker, ResourceUsage.StorageWrite)); + // Read after write at the stage the write is visible to: none. + Assert.Empty(Require(tracker, ResourceUsage.StorageReadCompute)); + // A second compute read: none. + Assert.Empty(Require(tracker, ResourceUsage.StorageReadCompute)); + // A compute-only write is not visible to the fragment stage: one GENERAL -> GENERAL barrier. + Require(tracker, ResourceUsage.StorageReadWrite); + ImageTransition fragment = Assert.Single(Require(tracker, ResourceUsage.StorageRead)); + Assert.Equal(ImageLayout.General, fragment.Sides.OldLayout); + Assert.Equal(ImageLayout.General, fragment.Sides.NewLayout); + Assert.Equal(AccessFlags2.ShaderStorageWriteBit | AccessFlags2.ShaderStorageReadBit, fragment.Sides.SrcAccess); + } + + [Fact] + public void ASampledComputeReadAfterAnUploadNeedsNoBarrierOnceTheImageIsShaderReadable() + { + var tracker = new ResourceStateTracker(1, 1, depth: true); + Require(tracker, ResourceUsage.TransferDst); + Require(tracker, ResourceUsage.SampleFragment); + // The barrier into SHADER_READ_ONLY already made the upload available: read after read. + Assert.Empty(Require(tracker, ResourceUsage.SampleCompute)); + Assert.Empty(Require(tracker, ResourceUsage.SampleCompute)); + Assert.Equal(PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.ComputeShaderBit, + tracker.StateOf(0, 0).ReadStages); + } + + [Fact] + public void AMipChainReadsLevelNAndWritesLevelNPlusOneWithOneBarrierPerLevel() + { + const uint levels = 4; + var tracker = new ResourceStateTracker(levels, 1, depth: false); + + // Level 0 is stored first (the prefilter's input copy). + ImageTransition level0 = Assert.Single(Require(tracker, 0, 1, ResourceUsage.StorageWrite)); + Assert.Equal((0u, 1u), (level0.BaseMip, level0.MipCount)); + Assert.True(tracker.IsSplit); + + for (uint n = 0; n + 1 < levels; n++) + { + ImageTransition read = Assert.Single(Require(tracker, n, 1, ResourceUsage.SampleCompute)); + Assert.Equal((n, 1u), (read.BaseMip, read.MipCount)); + Assert.Equal(ImageLayout.General, read.Sides.OldLayout); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, read.Sides.NewLayout); + Assert.Equal(AccessFlags2.ShaderStorageWriteBit, read.Sides.SrcAccess); + + ImageTransition write = Assert.Single(Require(tracker, n + 1, 1, ResourceUsage.StorageWrite)); + Assert.Equal((n + 1, 1u), (write.BaseMip, write.MipCount)); + Assert.Equal(ImageLayout.Undefined, write.Sides.OldLayout); + Assert.Equal(ImageLayout.General, write.Sides.NewLayout); + + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, tracker.StateOf(n, 0).Layout); + Assert.Equal(ImageLayout.General, tracker.StateOf(n + 1, 0).Layout); + } + + // The consumer samples the whole chain: only the last level still needs a barrier. + ImageTransition last = Assert.Single(Require(tracker, ResourceUsage.SampleFragment)); + Assert.Equal((levels - 1, 1u), (last.BaseMip, last.MipCount)); + Assert.Equal(AccessFlags2.ShaderStorageWriteBit, last.Sides.SrcAccess); + for (uint n = 0; n < levels; n++) Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, tracker.StateOf(n, 0).Layout); + + // The next frame's chain starts over: every level leaves SHADER_READ_ONLY in one rectangle. + ImageTransition next = Assert.Single(Require(tracker, 0, 1, ResourceUsage.StorageWrite)); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, next.Sides.OldLayout); + } + + private static Func Images(params (int Id, ComputeImageInfo Info)[] images) + { + var map = new Dictionary(); + foreach ((int id, ComputeImageInfo info) in images) map[id] = info; + return id => map.TryGetValue(id, out ComputeImageInfo info) ? info : null; + } + + private static ComputePassDeclaration Pass(params ComputeBinding[] bindings) => new() + { + Name = "test", + ProgramId = 1, + Bindings = bindings, + Dispatches = new[] { ComputeDispatch.Covering(0) }, + }; + + [Fact] + public void AMipChainPassValidatesAndAConflictingUseOfOneLevelDoesNot() + { + var images = Images((5, new ComputeImageInfo(64, 32, 5, 1)), (6, new ComputeImageInfo(8, 8, 1, 4))); + + Assert.Null(ComputePassPlanner.Validate(Pass( + new ComputeBinding(1, 5, ComputeAccess.StorageWrite, BaseMip: 1), + new ComputeBinding(0, 5, ComputeAccess.Sampled, BaseMip: 0)), images)); + + Assert.Contains("two ways", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 5, ComputeAccess.StorageWrite, BaseMip: 1), + new ComputeBinding(1, 5, ComputeAccess.Sampled, BaseMip: 0, MipCount: 2)), images)); + Assert.Contains("two ways", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 5, ComputeAccess.StorageWrite, BaseMip: 2), + new ComputeBinding(1, 5, ComputeAccess.StorageWrite, BaseMip: 2)), images)); + // Two sampled reads of one level are one layout: allowed. + Assert.Null(ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 5, ComputeAccess.Sampled), + new ComputeBinding(1, 5, ComputeAccess.Sampled)), images)); + + Assert.Contains("exactly one level", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 5, ComputeAccess.StorageWrite, MipCount: 2)), images)); + Assert.Contains("of a 5-level texture", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 5, ComputeAccess.Sampled, BaseMip: 4, MipCount: 2)), images)); + Assert.Contains("bound twice", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 5, ComputeAccess.Sampled), + new ComputeBinding(0, 5, ComputeAccess.StorageWrite, BaseMip: 1)), images)); + Assert.Contains("no texture", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 99, ComputeAccess.StorageWrite)), images)); + Assert.Contains("layered", ComputePassPlanner.Validate(Pass( + new ComputeBinding(0, 6, ComputeAccess.StorageWrite)), images)); + Assert.Contains("no dispatch", ComputePassPlanner.Validate(new ComputePassDeclaration + { + Bindings = new[] { new ComputeBinding(0, 5, ComputeAccess.StorageWrite) }, + }, images)); + Assert.Contains("binding index", ComputePassPlanner.Validate(new ComputePassDeclaration + { + Bindings = new[] { new ComputeBinding(0, 5, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(3) }, + }, images)); + } + + [Theory] + [InlineData(64u, 8u, 8u)] + [InlineData(65u, 8u, 9u)] + [InlineData(1u, 16u, 1u)] + [InlineData(1920u, 16u, 120u)] + [InlineData(1081u, 16u, 68u)] + public void GroupCountsCoverTheImage(uint extent, uint local, uint groups) => + Assert.Equal(groups, ComputePassPlanner.GroupsCovering(extent, local)); + + [Fact] + public void ADispatchSizedFromABindingCoversThatLevel() + { + var images = Images((5, new ComputeImageInfo(100, 30, 5, 1))); + ComputePassDeclaration pass = Pass(new ComputeBinding(0, 5, ComputeAccess.StorageWrite, BaseMip: 2)); + // Level 2 is 25x7: 4x1 groups of 8x8. + Assert.Equal((4u, 1u, 1u), ComputePassPlanner.Groups(pass.Dispatches[0], pass, images, 8, 8)); + Assert.Equal((3u, 2u, 1u), ComputePassPlanner.Groups(ComputeDispatch.Explicit(3, 2), pass, images, 8, 8)); + Assert.Equal(1u, ComputePassPlanner.LevelExtent(3, 7)); + } + + [Fact] + public void TheSignatureNamesWritesAsPersistentUsesAndReadsAsReads() + { + var images = Images((5, new ComputeImageInfo(64, 32, 5, 1)), (7, new ComputeImageInfo(64, 32, 1, 1)), + (8, new ComputeImageInfo(64, 32, 1, 1))); + PassSignature signature = ComputePassPlanner.Signature(3, Pass( + new ComputeBinding(0, 7, ComputeAccess.Sampled), + new ComputeBinding(1, 5, ComputeAccess.Sampled, BaseMip: 0), + new ComputeBinding(2, 5, ComputeAccess.StorageWrite, BaseMip: 1), + new ComputeBinding(3, 8, ComputeAccess.StorageReadWrite)), images); + + Assert.Equal(3, signature.NameId); + Assert.Equal(new[] { 7, 5, 8 }, signature.Reads); + Assert.Equal(new[] + { + new AttachmentUse(5, ResourceUsage.StorageWrite, false), + new AttachmentUse(8, ResourceUsage.StorageReadWrite, false), + }, signature.Attachments); + Assert.Equal((32, 16), (signature.Width, signature.Height)); + + // A raster pass that attaches the dispatch's output later must load it, never DONT_CARE. + var raster = new PassSignature + { + NameId = 4, Width = 64, Height = 32, FormatsId = 1, + Attachments = new[] { new AttachmentUse(8, ResourceUsage.ColorWrite, true) }, + }; + FramePlan plan = FramePlan.Build(new[] { signature, raster }); + Assert.Equal(AttachmentLoadOp.Load, plan.LoadOp(1, 0)); + Assert.Equal(-1, plan.AliasSlot(8)); + } + + [SkippableFact] + public void TheWorkGroupSizeIsReadFromTheModule() + { + Shaders.ShaderCompiler compiler; + try + { + compiler = new Shaders.ShaderCompiler(); + } + catch (Exception error) when (error is DllNotFoundException or InvalidOperationException) + { + throw new SkipException("shaderc unavailable: " + error.Message); + } + using (compiler) + { + Shaders.ShaderCompileResult literal = compiler.CompileCompute(""" + #version 450 + layout(local_size_x = 16, local_size_y = 4, local_size_z = 2) in; + void main() {} + """, "literal.comp"); + Assert.True(literal.Success, literal.Error); + Assert.True(SpirvLocalSize.TryRead(literal.Spirv, out uint x, out uint y, out uint z)); + Assert.Equal((16u, 4u, 2u), (x, y, z)); + + // A specialization-constant size is not a literal: the description's size applies. + Shaders.ShaderCompileResult specialized = compiler.CompileCompute(""" + #version 450 + layout(local_size_x_id = 0, local_size_y_id = 1) in; + void main() {} + """, "specialized.comp"); + Assert.True(specialized.Success, specialized.Error); + Assert.False(SpirvLocalSize.TryRead(specialized.Spirv, out _, out _, out _)); + } + Assert.False(SpirvLocalSize.TryRead(new byte[16], out _, out _, out _)); + } + + [Fact] + public void AStorageFormatFallsBackToAWiderFormatOfItsKindAndFinallyRgba8() + { + const FormatFeatureFlags storage = StorageFormats.Required; + Func only(params Format[] supported) => + format => Array.IndexOf(supported, format) >= 0 ? storage : FormatFeatureFlags.SampledImageBit; + + Assert.Equal(Format.R8Unorm, StorageFormats.Choose(Format.R8Unorm, only(Format.R8Unorm, Format.R8G8B8A8Unorm))); + Assert.Equal(Format.R8G8B8A8Unorm, StorageFormats.Choose(Format.R8Unorm, only(Format.R8G8B8A8Unorm))); + Assert.Equal(Format.R32Sfloat, StorageFormats.Choose(Format.R16Sfloat, only(Format.R32Sfloat))); + Assert.Equal(Format.R32G32B32A32Sfloat, StorageFormats.Choose(Format.R32Sfloat, only(Format.R32G32B32A32Sfloat))); + Assert.Equal(Format.R8G8B8A8Unorm, StorageFormats.Choose(Format.R32Sfloat, only())); + // Storage alone is not enough: the next candidate that also samples wins. + Assert.Equal(Format.R8G8Unorm, StorageFormats.Choose(Format.R8Unorm, + format => format == Format.R8Unorm ? FormatFeatureFlags.StorageImageBit : storage)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/ComputePassTests.cs b/Optimum.Render.Vulkan.Tests/ComputePassTests.cs new file mode 100644 index 00000000..afba2bdb --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ComputePassTests.cs @@ -0,0 +1,403 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The frame graph's compute pass kind on a device, validation with sync and best +/// practices on: a dispatch stores into a storage image in the format the device +/// supports (or its fallback) with its specialization constants and push constants; a +/// mip chain samples level n and stores level n + 1 of one image; a compute pass +/// followed by a raster pass sampling its output, frame after frame with Present +/// between frames and no readback in the loop; and a declaration that does not fit is +/// refused without recording anything. +/// +public class ComputePassTests(ITestOutputHelper output) +{ + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + private static string Qualifier(Format format) => format switch + { + Format.R8Unorm => "r8", + Format.R8G8Unorm => "rg8", + Format.R8G8B8A8Unorm => "rgba8", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, null), + }; + + private static int Channels(Format format) => format switch + { + Format.R8Unorm => 1, + Format.R8G8Unorm => 2, + _ => 4, + }; + + private int Program(VulkanDevice seam, string code, string name, ComputeSlot[] slots, uint push = 0) + { + int program = seam.CreateComputeProgram(code, name, slots, push); + Assert.True(program > 0, seam.GetError()); + return program; + } + + [SkippableFact] + public void ADispatchStoresIntoAStorageImageWithItsSpecializationAndPushConstants() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + VulkanDevice seam = device!; + const int width = 13, height = 11; + + // The AO working term's format: the device's choice, or its fallback. + int target = seam.CreateStorageTexture(width, height, Format.R8Unorm); + VulkanTexture texture = seam.TexturesForTests.Get(target)!; + Assert.Equal(StorageFormats.Choose(Format.R8Unorm, seam.ContextForTests.OptimalFormatFeatures), texture.Format); + Assert.NotEqual((ImageUsageFlags)0, texture.Usage & ImageUsageFlags.StorageBit); + output.WriteLine("R8_UNORM storage texture created as " + texture.Format); + int channels = Channels(texture.Format); + + string code = $$""" + #version 450 + layout(local_size_x = 8, local_size_y = 8) in; + layout(constant_id = 0) const uint LEVEL = 0u; + layout(push_constant) uniform Push { uint offset; } push; + layout(binding = 3, {{Qualifier(texture.Format)}}) uniform writeonly image2D target; + void main() + { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(p, imageSize(target)))) return; + uint value = (uint(p.x) * 16u + uint(p.y) + LEVEL + push.offset) & 255u; + imageStore(target, p, vec4(float(value) / 255.0)); + } + """; + int program = Program(seam, code, "store", new[] { new ComputeSlot(3, ComputeSlotKind.Storage) }, 4); + + uint[] levels = { 7, 100, 7 }; + long dispatchesBefore = seam.FrameGraphForTests.Dispatches; + for (int frame = 0; frame < levels.Length; frame++) + { + seam.BeginFrame(); + uint offset = (uint)frame * 3; + Assert.True(seam.RecordComputePass(new ComputePassDeclaration + { + Name = "store", + ProgramId = program, + Specialization = new[] { levels[frame] }, + Bindings = new[] { new ComputeBinding(3, target, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0, BitConverter.GetBytes(offset)) }, + }), seam.GetError()); + + byte[] pixels = seam.ReadBackLevel0ForTests(target); + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + int expected = (int)((x * 16 + y + levels[frame] + offset) & 255); + Assert.Equal(expected, pixels[(y * width + x) * channels]); + } + seam.Present(); + } + + // Two distinct specialization values: two pipelines, the third frame a hit. + Assert.Equal(2, seam.ComputeForTests.Get(program)!.PipelineCount); + Assert.Equal(1, seam.ComputeForTests.Hits); + // 13x11 at 8x8 is one dispatch of 2x2 groups per frame. + Assert.Equal(levels.Length, seam.FrameGraphForTests.Dispatches - dispatchesBefore); + GpuTest.AssertClean(seam); + } + } + + [SkippableFact] + public void AMipChainSamplesLevelNAndStoresLevelNPlusOneOfOneImage() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + VulkanDevice seam = device!; + const int size = 16, levels = 3; + int chain = seam.CreateStorageTexture(size, size, Format.R8G8B8A8Unorm, levels); + Assert.Equal((uint)levels, seam.TexturesForTests.Get(chain)!.MipLevels); + + int seed = Program(seam, """ + #version 450 + layout(local_size_x = 8, local_size_y = 8) in; + layout(push_constant) uniform Push { uint salt; } push; + layout(binding = 0, rgba8) uniform writeonly image2D level0; + void main() + { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(p, imageSize(level0)))) return; + uint value = (uint(p.x) * 37u + uint(p.y) * 11u + push.salt) & 255u; + imageStore(level0, p, vec4(float(value) / 255.0, 0.0, 0.0, 1.0)); + } + """, "seed", new[] { new ComputeSlot(0, ComputeSlotKind.Storage) }, 4); + + // The prefilter shape: the sampled binding's view starts at level n, so lod 0 is level n. + int reduce = Program(seam, """ + #version 450 + layout(local_size_x = 8, local_size_y = 8) in; + layout(binding = 0) uniform sampler2D source; + layout(binding = 1, rgba8) uniform writeonly image2D destination; + void main() + { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(p, imageSize(destination)))) return; + float m = 0.0; + for (int i = 0; i < 4; i++) m = max(m, texelFetch(source, 2 * p + ivec2(i & 1, i >> 1), 0).r); + imageStore(destination, p, vec4(m, 0.0, 0.0, 1.0)); + } + """, "reduce", new[] + { + new ComputeSlot(0, ComputeSlotKind.Sampled), new ComputeSlot(1, ComputeSlotKind.Storage), + }); + + for (uint frame = 0; frame < 3; frame++) + { + seam.BeginFrame(); + uint salt = frame * 29; + Assert.True(seam.RecordComputePass(new ComputePassDeclaration + { + Name = "seed", + ProgramId = seed, + Bindings = new[] { new ComputeBinding(0, chain, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0, BitConverter.GetBytes(salt)) }, + }), seam.GetError()); + for (uint n = 0; n + 1 < levels; n++) + { + Assert.True(seam.RecordComputePass(new ComputePassDeclaration + { + Name = "reduce", + ProgramId = reduce, + Bindings = new[] + { + new ComputeBinding(0, chain, ComputeAccess.Sampled, BaseMip: n), + new ComputeBinding(1, chain, ComputeAccess.StorageWrite, BaseMip: n + 1), + }, + Dispatches = new[] { ComputeDispatch.Covering(1) }, + }), seam.GetError()); + } + + // Only after the whole chain is recorded: the expected chain on the CPU. + var expected = new int[levels][]; + expected[0] = new int[size * size]; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + expected[0][y * size + x] = (int)((x * 37 + y * 11 + salt) & 255); + for (int n = 1; n < levels; n++) + { + int s = size >> n, parent = size >> (n - 1); + expected[n] = new int[s * s]; + for (int y = 0; y < s; y++) + for (int x = 0; x < s; x++) + { + int m = 0; + for (int i = 0; i < 4; i++) + m = Math.Max(m, expected[n - 1][(2 * y + (i >> 1)) * parent + 2 * x + (i & 1)]); + expected[n][y * s + x] = m; + } + } + + for (uint n = 0; n < levels; n++) + { + int s = size >> (int)n; + byte[] pixels = seam.ReadBackLevelForTests(chain, n); + Assert.Equal(s * s * 4, pixels.Length); + for (int i = 0; i < s * s; i++) + { + Assert.Equal(expected[n][i], pixels[i * 4]); + Assert.Equal(255, pixels[i * 4 + 3]); + } + } + seam.Present(); + } + GpuTest.AssertClean(seam); + } + } + + [SkippableFact] + public void ARasterPassSamplesTheComputePassOutputOfTheSameFrameFrameAfterFrame() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + VulkanDevice seam = device!; + const int size = 8, frames = 8; + + int computed = seam.CreateStorageTexture(size, size, Format.R8G8B8A8Unorm); + int store = Program(seam, """ + #version 450 + layout(local_size_x = 4, local_size_y = 4) in; + layout(push_constant) uniform Push { uint frame; } push; + layout(binding = 0, rgba8) uniform writeonly image2D target; + void main() + { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(p, imageSize(target)))) return; + imageStore(target, p, vec4(float((push.frame + 1u) * 20u) / 255.0, float(p.x * 16) / 255.0, + float(p.y * 16) / 255.0, 1.0)); + } + """, "store", new[] { new ComputeSlot(0, ComputeSlotKind.Storage) }, 4); + + int background = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(1.0, 0.0, 1.0, 1.0); } + """, "background"); + int sample = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D computed; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = texelFetch(computed, ivec2(gl_FragCoord.xy), 0); } + """, "sample"); + + var colours = new int[frames]; + var targets = new int[frames]; + for (int i = 0; i < frames; i++) + { + colours[i] = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4); + targets[i] = seam.CreateFramebuffer(size, size); + seam.AttachTexture(targets[i], EnumFramebufferAttachment.ColorAttachment0, colours[i], 0); + seam.SetDrawBuffers(targets[i], 1); + } + + seam.SetSamplerUnit(sample, "computed", 0); + long passesBefore = seam.FrameGraphForTests.ComputePasses; + long dispatchesBefore = seam.FrameGraphForTests.Dispatches; + + for (int i = 0; i < frames; i++) + { + seam.BeginFrame(); + // A draw first, so the frame has a rendering scope open when the compute pass comes. + seam.BindFramebuffer(targets[i]); + seam.SetViewport(0, 0, size, size); + seam.SetCullFace(false); + seam.SetDepthTest(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.UseProgram(background); + seam.DrawFullscreenTriangle(); + + Assert.True(seam.RecordComputePass(new ComputePassDeclaration + { + Name = "store", + ProgramId = store, + Bindings = new[] { new ComputeBinding(0, computed, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0, BitConverter.GetBytes((uint)i)) }, + }), seam.GetError()); + + seam.UseProgram(sample); + seam.BindTexture(0, computed); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + seam.Present(); + } + + // The work group size comes from the module (4x4), not the description's default 8x8. + ComputeProgram stored = seam.ComputeForTests.Get(store)!; + Assert.Equal((4u, 4u), (stored.LocalSizeX, stored.LocalSizeY)); + Assert.Equal(frames, seam.FrameGraphForTests.ComputePasses - passesBefore); + Assert.Equal(frames, seam.FrameGraphForTests.Dispatches - dispatchesBefore); + + // The sequence completes before any readback or CPU wait. + seam.BeginFrame(); + for (int i = 0; i < frames; i++) + { + byte[] pixels = seam.ReadBackLevel0ForTests(colours[i]); + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + int offset = (y * size + x) * 4; + Assert.Equal((i + 1) * 20, pixels[offset]); + Assert.Equal(x * 16, pixels[offset + 1]); + Assert.Equal(y * 16, pixels[offset + 2]); + Assert.Equal(255, pixels[offset + 3]); + } + } + seam.Present(); + GpuTest.AssertClean(seam); + } + } + + [SkippableFact] + public void ADeclarationThatDoesNotFitItsProgramIsRefusedWithoutRecording() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + VulkanDevice seam = device!; + int plain = seam.CreateTexture2DRaw(4, 4, 0x8058, IntPtr.Zero, 4); + int storage = seam.CreateStorageTexture(4, 4, Format.R8G8B8A8Unorm, 2); + int program = Program(seam, """ + #version 450 + layout(local_size_x = 4, local_size_y = 4) in; + layout(binding = 0, rgba8) uniform writeonly image2D target; + void main() { imageStore(target, ivec2(gl_GlobalInvocationID.xy), vec4(1.0)); } + """, "store", new[] { new ComputeSlot(0, ComputeSlotKind.Storage) }); + + Assert.False(seam.RecordComputePass(new ComputePassDeclaration + { + ProgramId = program, + Bindings = new[] { new ComputeBinding(0, storage, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0) }, + }), "no frame is open"); + + long passesBefore = seam.FrameGraphForTests.ComputePasses; + seam.BeginFrame(); + void Refused(ComputePassDeclaration pass, string reason) + { + Assert.False(seam.RecordComputePass(pass)); + Assert.Contains(reason, seam.GetError()); + } + Refused(new ComputePassDeclaration + { + Name = "plain", ProgramId = program, + Bindings = new[] { new ComputeBinding(0, plain, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0) }, + }, "not created as a storage texture"); + Refused(new ComputePassDeclaration + { + Name = "kind", ProgramId = program, + Bindings = new[] { new ComputeBinding(0, storage, ComputeAccess.Sampled) }, + Dispatches = new[] { ComputeDispatch.Covering(0) }, + }, "is declared Storage"); + Refused(new ComputePassDeclaration + { + Name = "unbound", ProgramId = program, + Bindings = Array.Empty(), + Dispatches = new[] { ComputeDispatch.Explicit(1, 1) }, + }, "binding 0 is not bound"); + Refused(new ComputePassDeclaration + { + Name = "push", ProgramId = program, + Bindings = new[] { new ComputeBinding(0, storage, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0, new byte[4]) }, + }, "pushes 4 bytes"); + Refused(new ComputePassDeclaration + { + Name = "missing", ProgramId = 999, + Bindings = new[] { new ComputeBinding(0, storage, ComputeAccess.StorageWrite) }, + Dispatches = new[] { ComputeDispatch.Covering(0) }, + }, "no compute program 999"); + Assert.Equal(passesBefore, seam.FrameGraphForTests.ComputePasses); + Assert.Equal(0, seam.CreateComputeProgram("#version 450\nvoid main() { broken }", "broken", + Array.Empty())); + Assert.Contains("failed to compile", seam.GetError()); + seam.Present(); + + // A deleted program retires on the timeline, after the frames that could bind it. + seam.DeleteComputeProgram(program); + Assert.Null(seam.ComputeForTests.Get(program)); + GpuTest.AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index af6b9a88..767d36df 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -134,8 +134,8 @@ public void NewStatsLinesCarryStableKeyValueTokens() "stats.counters blocking_uploads=1 uploads=2 scopes=3 barriers=4 rebar_fallbacks=5 " + "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 barrier_commands=9 barriers_per_frame=2.0 " + "mask_restarts=10 feedback_splits=11 passes=12 plan_hits=13 plan_misses=14 in_pass_clears=15 " + - "promoted_clears=16 standalone_clears=17 pass_splits=18", - VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18))); + "promoted_clears=16 standalone_clears=17 pass_splits=18 compute_passes=19 dispatches=20", + VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20))); Assert.Equal( "stats.transients transient_mib=1.5 aliased_mib=0.5 heap_peak_mib=64.0 leases=3 aliased_leases=1 " + diff --git a/Optimum.Render.Vulkan/Core/ComputeDescriptorArena.cs b/Optimum.Render.Vulkan/Core/ComputeDescriptorArena.cs new file mode 100644 index 00000000..76591f27 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/ComputeDescriptorArena.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// One image descriptor of a compute pass set. +internal readonly record struct ComputeImageWrite(uint Binding, DescriptorType Type, ImageView View, Sampler Sampler, + ImageLayout Layout); + +/// +/// One frame slot's descriptor sets for compute passes, reset wholesale when the slot +/// begins its next frame (after the Frame timeline says its previous frame finished). +/// +/// A compute pass set names per-level views whose layouts change from pass to pass +/// (storage in one, sampled in the next), so the sets live exactly one frame, like +/// 's; the pools carry the storage image type the +/// draw pools do not. +/// +internal sealed unsafe class ComputeDescriptorArena : IDisposable +{ + public const uint SetsPerPool = 64; + public const uint ImagesPerSet = 8; + + private readonly VulkanContext _context; + private readonly List _pools = new(); + private int _poolIndex; + private bool _disposed; + + public ComputeDescriptorArena(VulkanContext context) => _context = context; + + public int PoolCount => _pools.Count; + public long Allocations { get; private set; } + + public void Reset() + { + foreach (DescriptorPool pool in _pools) _context.Api.ResetDescriptorPool(_context.Device, pool, 0); + _poolIndex = 0; + } + + /// Allocates a set of and writes into it. + public DescriptorSet Get(DescriptorSetLayout layout, ReadOnlySpan writes) + { + DescriptorSet set = Allocate(layout); + Allocations++; + if (writes.Length == 0) return set; + + var images = new DescriptorImageInfo[writes.Length]; + var descriptorWrites = new WriteDescriptorSet[writes.Length]; + fixed (DescriptorImageInfo* imagesPtr = images) + fixed (WriteDescriptorSet* writesPtr = descriptorWrites) + { + for (int i = 0; i < writes.Length; i++) + { + ComputeImageWrite write = writes[i]; + images[i] = new DescriptorImageInfo + { + ImageView = write.View, + Sampler = write.Type == DescriptorType.CombinedImageSampler ? write.Sampler : default, + ImageLayout = write.Layout, + }; + descriptorWrites[i] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = write.Binding, + DescriptorCount = 1, + DescriptorType = write.Type, + PImageInfo = imagesPtr + i, + }; + } + _context.Api.UpdateDescriptorSets(_context.Device, (uint)writes.Length, writesPtr, 0, null); + } + return set; + } + + private DescriptorSet Allocate(DescriptorSetLayout layout) + { + while (true) + { + bool freshPool = _poolIndex == _pools.Count; + if (freshPool) _pools.Add(CreatePool()); + + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _pools[_poolIndex], + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + DescriptorSet set; + Result result = _context.Api.AllocateDescriptorSets(_context.Device, &allocateInfo, &set); + if (result == Result.Success) return set; + if (result != Result.ErrorOutOfPoolMemory && result != Result.ErrorFragmentedPool) + { + throw new InvalidOperationException("vkAllocateDescriptorSets failed in the compute arena: " + result); + } + if (freshPool) + { + throw new InvalidOperationException("a compute descriptor set does not fit an empty arena pool: " + result); + } + _poolIndex++; + } + } + + private DescriptorPool CreatePool() + { + var sizes = stackalloc DescriptorPoolSize[2] + { + new DescriptorPoolSize(DescriptorType.StorageImage, SetsPerPool * ImagesPerSet), + new DescriptorPoolSize(DescriptorType.CombinedImageSampler, SetsPerPool * ImagesPerSet), + }; + var createInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + PoolSizeCount = 2, + PPoolSizes = sizes, + MaxSets = SetsPerPool, + }; + DescriptorPool pool; + VulkanResult.Check(_context.Api.CreateDescriptorPool(_context.Device, &createInfo, null, &pool), + "vkCreateDescriptorPool for the compute arena"); + return pool; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (DescriptorPool pool in _pools) _context.Api.DestroyDescriptorPool(_context.Device, pool, null); + _pools.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Core/ComputePipelineCache.cs b/Optimum.Render.Vulkan/Core/ComputePipelineCache.cs new file mode 100644 index 00000000..e01bb4ea --- /dev/null +++ b/Optimum.Render.Vulkan/Core/ComputePipelineCache.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// Whether a compute program's binding is a storage image or a combined image sampler. +internal enum ComputeSlotKind +{ + Sampled, + Storage, +} + +/// One binding of a compute program's pass set. +internal readonly record struct ComputeSlot(uint Binding, ComputeSlotKind Kind); + +/// What a compute program is built from. +internal sealed class ComputeProgramDescription +{ + public string Name = "compute"; + + /// The compiled module (), entry point main. + public byte[] Spirv = Array.Empty(); + + /// The pass set's bindings: set , in any order. + public ComputeSlot[] Slots = Array.Empty(); + + /// Bytes of the compute stage's push constant block; 0 for none. At most 128 (the spec minimum). + public uint PushConstantBytes; + + /// + /// The work group size used to size group counts from an image when the module does not + /// state a literal one (local_size_x_id). A literal local_size_x / + /// local_size_y in the module always wins (), so the + /// two can never disagree. + /// + public uint LocalSizeX = 8; + public uint LocalSizeY = 8; +} + +/// +/// Reads a compute module's literal work group size (OpExecutionMode LocalSize) from its +/// SPIR-V. glslang also emits gl_WorkGroupSize as a composite decorated BuiltIn +/// WorkgroupSize: a plain OpConstantComposite for literal sizes, an OpSpecConstantComposite +/// when the size comes from specialization constants (local_size_x_id). The latter +/// overrides LocalSize (which then reads 1x1x1), so such a module has no literal size and +/// the reader says so. +/// +internal static class SpirvLocalSize +{ + private const uint MagicNumber = 0x07230203; + private const ushort OpExecutionMode = 16; + private const ushort OpSpecConstantComposite = 51; + private const ushort OpDecorate = 71; + private const uint ExecutionModeLocalSize = 17; + private const uint DecorationBuiltIn = 11; + private const uint BuiltInWorkgroupSize = 25; + + public static bool TryRead(ReadOnlySpan spirv, out uint x, out uint y, out uint z) + { + x = y = z = 0; + if (spirv.Length < 20 || spirv.Length % 4 != 0) return false; + ReadOnlySpan words = System.Runtime.InteropServices.MemoryMarshal.Cast(spirv); + if (words[0] != MagicNumber) return false; + + bool found = false; + uint workgroupSizeId = 0; + var specComposites = new System.Collections.Generic.HashSet(); + int index = 5; + while (index < words.Length) + { + uint word = words[index]; + int count = (int)(word >> 16); + ushort opcode = (ushort)(word & 0xFFFF); + if (count == 0 || index + count > words.Length) return false; + if (opcode == OpExecutionMode && count >= 6 && words[index + 2] == ExecutionModeLocalSize) + { + x = words[index + 3]; + y = words[index + 4]; + z = words[index + 5]; + found = true; + } + else if (opcode == OpDecorate && count >= 4 && words[index + 2] == DecorationBuiltIn && + words[index + 3] == BuiltInWorkgroupSize) + { + workgroupSizeId = words[index + 1]; + } + else if (opcode == OpSpecConstantComposite && count >= 3) + { + specComposites.Add(words[index + 2]); + } + index += count; + } + + if (!found || (workgroupSizeId != 0 && specComposites.Contains(workgroupSizeId))) + { + x = y = z = 0; + return false; + } + return true; + } +} + +/// +/// A compute program: its module, the layout of its one pass set (set 0), its pipeline +/// layout with the compute push constant range, and one pipeline per set of +/// specialization constant values. Destroyed on the timeline after the last frame that +/// could have bound it. +/// +internal sealed unsafe class ComputeProgram : IDisposable +{ + /// The set a compute pass's bindings live in. + public const uint PassSet = 0; + + public const uint MaxPushConstantBytes = 128; + + private readonly VulkanContext _context; + private readonly Dictionary _pipelines = new(); + private bool _disposed; + + public int Id { get; } + public string Name { get; } + public ShaderModule Module { get; } + public DescriptorSetLayout SetLayout { get; } + public PipelineLayout Layout { get; } + public ComputeSlot[] Slots { get; } + public uint PushConstantBytes { get; } + public uint LocalSizeX { get; } + public uint LocalSizeY { get; } + + public int PipelineCount => _pipelines.Count; + + public ComputeProgram(VulkanContext context, int id, ComputeProgramDescription description) + { + if (description.Spirv.Length == 0 || description.Spirv.Length % 4 != 0) + throw new ArgumentException("compute program '" + description.Name + "' has no valid SPIR-V"); + if (description.PushConstantBytes > MaxPushConstantBytes) + throw new ArgumentException("compute program '" + description.Name + "' pushes more than " + MaxPushConstantBytes + " bytes"); + + _context = context; + Id = id; + Name = description.Name; + Slots = (ComputeSlot[])description.Slots.Clone(); + PushConstantBytes = description.PushConstantBytes; + if (SpirvLocalSize.TryRead(description.Spirv, out uint localX, out uint localY, out _)) + { + LocalSizeX = Math.Max(1, localX); + LocalSizeY = Math.Max(1, localY); + } + else + { + LocalSizeX = Math.Max(1, description.LocalSizeX); + LocalSizeY = Math.Max(1, description.LocalSizeY); + } + Vk api = context.Api; + + fixed (byte* code = description.Spirv) + { + var moduleInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)description.Spirv.Length, + PCode = (uint*)code, + }; + ShaderModule module; + VulkanResult.Check(api.CreateShaderModule(context.Device, &moduleInfo, null, &module), + "vkCreateShaderModule for compute program '" + Name + "'"); + Module = module; + } + + var bindings = new DescriptorSetLayoutBinding[Slots.Length]; + for (int i = 0; i < Slots.Length; i++) + { + bindings[i] = new DescriptorSetLayoutBinding + { + Binding = Slots[i].Binding, + DescriptorType = TypeOf(Slots[i].Kind), + DescriptorCount = 1, + StageFlags = ShaderStageFlags.ComputeBit, + }; + } + fixed (DescriptorSetLayoutBinding* bindingsPtr = bindings) + { + var setInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = (uint)bindings.Length, + PBindings = bindings.Length == 0 ? null : bindingsPtr, + }; + DescriptorSetLayout setLayout; + Result result = api.CreateDescriptorSetLayout(context.Device, &setInfo, null, &setLayout); + if (result != Result.Success) + { + api.DestroyShaderModule(context.Device, Module, null); + VulkanResult.Check(result, "vkCreateDescriptorSetLayout for compute program '" + Name + "'"); + } + SetLayout = setLayout; + } + + DescriptorSetLayout set = SetLayout; + var push = new PushConstantRange + { + StageFlags = ShaderStageFlags.ComputeBit, + Offset = 0, + Size = PushConstantBytes, + }; + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &set, + PushConstantRangeCount = PushConstantBytes > 0 ? 1u : 0u, + PPushConstantRanges = PushConstantBytes > 0 ? &push : null, + }; + PipelineLayout layout; + Result layoutResult = api.CreatePipelineLayout(context.Device, &layoutInfo, null, &layout); + if (layoutResult != Result.Success) + { + api.DestroyDescriptorSetLayout(context.Device, SetLayout, null); + api.DestroyShaderModule(context.Device, Module, null); + VulkanResult.Check(layoutResult, "vkCreatePipelineLayout for compute program '" + Name + "'"); + } + Layout = layout; + } + + public static DescriptorType TypeOf(ComputeSlotKind kind) => + kind == ComputeSlotKind.Storage ? DescriptorType.StorageImage : DescriptorType.CombinedImageSampler; + + public bool TryGetSlot(uint binding, out ComputeSlot slot) + { + foreach (ComputeSlot candidate in Slots) + { + if (candidate.Binding != binding) continue; + slot = candidate; + return true; + } + slot = default; + return false; + } + + /// The pipeline for these specialization values, compiled through the driver cache on first use. + internal Pipeline PipelineFor(ReadOnlySpan specialization, PipelineCache driverCache, out bool compiled) + { + var probe = new SpecializationKey(specialization.ToArray()); + if (_pipelines.TryGetValue(probe, out Pipeline existing)) + { + compiled = false; + return existing; + } + + Pipeline pipeline = Compile(specialization, driverCache); + _pipelines[probe] = pipeline; + compiled = true; + return pipeline; + } + + private Pipeline Compile(ReadOnlySpan specialization, PipelineCache driverCache) + { + byte* entry = (byte*)SilkMarshal.StringToPtr("main"); + try + { + var entries = new SpecializationMapEntry[specialization.Length]; + for (int i = 0; i < entries.Length; i++) + { + entries[i] = new SpecializationMapEntry((uint)i, (uint)(i * sizeof(uint)), sizeof(uint)); + } + uint[] values = specialization.ToArray(); + + fixed (SpecializationMapEntry* entriesPtr = entries) + fixed (uint* valuesPtr = values) + { + var info = new SpecializationInfo + { + MapEntryCount = (uint)entries.Length, + PMapEntries = entries.Length == 0 ? null : entriesPtr, + DataSize = (nuint)(values.Length * sizeof(uint)), + PData = values.Length == 0 ? null : valuesPtr, + }; + var createInfo = new ComputePipelineCreateInfo + { + SType = StructureType.ComputePipelineCreateInfo, + Stage = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.ComputeBit, + Module = Module, + PName = entry, + PSpecializationInfo = entries.Length == 0 ? null : &info, + }, + Layout = Layout, + }; + Pipeline pipeline; + Result result = _context.Api.CreateComputePipelines(_context.Device, driverCache, 1, &createInfo, null, &pipeline); + if (result != Result.Success) + { + throw new InvalidOperationException("vkCreateComputePipelines failed for '" + Name + "': " + result); + } + return pipeline; + } + } + finally + { + SilkMarshal.Free((nint)entry); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Vk api = _context.Api; + foreach (Pipeline pipeline in _pipelines.Values) api.DestroyPipeline(_context.Device, pipeline, null); + _pipelines.Clear(); + api.DestroyPipelineLayout(_context.Device, Layout, null); + api.DestroyDescriptorSetLayout(_context.Device, SetLayout, null); + if (Module.Handle != 0) api.DestroyShaderModule(_context.Device, Module, null); + } + + /// Specialization values compared element by element. + private readonly struct SpecializationKey : IEquatable + { + private readonly uint[] _values; + private readonly int _hash; + + public SpecializationKey(uint[] values) + { + _values = values; + var hash = new HashCode(); + foreach (uint value in values) hash.Add(value); + _hash = hash.ToHashCode(); + } + + public bool Equals(SpecializationKey other) => + _hash == other._hash && _values.AsSpan().SequenceEqual(other._values); + + public override bool Equals(object? obj) => obj is SpecializationKey other && Equals(other); + public override int GetHashCode() => _hash; + } +} + +/// +/// The compute programs of a device and their pipelines. Pipelines compile through the +/// driver's the graphics cache owns, so one cache file +/// warms both kinds on the next launch. Render thread only. +/// +internal sealed class ComputePipelineCache : IDisposable +{ + private readonly VulkanContext _context; + private readonly Func _driverCache; + private readonly Dictionary _programs = new(); + private int _nextId = 1; + private bool _disposed; + + public ComputePipelineCache(VulkanContext context, Func driverCache) + { + _context = context; + _driverCache = driverCache; + } + + public int ProgramCount => _programs.Count; + public long Hits { get; private set; } + public long Misses { get; private set; } + + public int Create(ComputeProgramDescription description) + { + int id = _nextId++; + _programs[id] = new ComputeProgram(_context, id, description); + return id; + } + + public ComputeProgram? Get(int id) => _programs.TryGetValue(id, out ComputeProgram? program) ? program : null; + + /// Removes a program; the caller retires it on the timeline (a submitted frame may still bind it). + public ComputeProgram? Remove(int id) => _programs.Remove(id, out ComputeProgram? program) ? program : null; + + public Pipeline PipelineFor(ComputeProgram program, ReadOnlySpan specialization) + { + Pipeline pipeline = program.PipelineFor(specialization, _driverCache(), out bool compiled); + if (compiled) Misses++; + else Hits++; + return pipeline; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (ComputeProgram program in _programs.Values) program.Dispose(); + _programs.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index 5c779979..76b8b6d3 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -100,6 +100,9 @@ public GraphicsPipelineCache(VulkanContext context, ColorWriteTier tier, bool dy } } + /// The driver's pipeline cache; compute pipelines compile through it too, so one file warms both. + public Silk.NET.Vulkan.PipelineCache DriverCache => _driverCache; + /// The driver created its cache from the initial data rather than empty. public bool SeedAccepted { get; } diff --git a/Optimum.Render.Vulkan/Core/StorageFormats.cs b/Optimum.Render.Vulkan/Core/StorageFormats.cs new file mode 100644 index 00000000..6f9d6a3c --- /dev/null +++ b/Optimum.Render.Vulkan/Core/StorageFormats.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Which format a storage image is created in. A compute pass names the format it +/// wants (the AO working term wants R8_UNORM, the prefiltered depth R32F); a device +/// that cannot use that format as a storage image and sample it gets the first +/// wider format of the same kind that it can, ending in RGBA8 for unsigned +/// normalised formats and RGBA32F for float ones (the formats Vulkan guarantees +/// storage support for). A shader writing the first channel of the wider format +/// reads the same value back, so a fallback costs memory, never correctness. +/// +/// Pure: the feature lookup is passed in, so the choice is testable without a device. +/// +internal static class StorageFormats +{ + /// What a storage image must support: storage writes and sampling. + public const FormatFeatureFlags Required = FormatFeatureFlags.StorageImageBit | FormatFeatureFlags.SampledImageBit; + + /// The candidates for , the requested format first. + public static IReadOnlyList CandidatesFor(Format requested) => requested switch + { + Format.R8Unorm => new[] { Format.R8Unorm, Format.R8G8Unorm, Format.R8G8B8A8Unorm }, + Format.R8G8Unorm => new[] { Format.R8G8Unorm, Format.R8G8B8A8Unorm }, + Format.R16Unorm => new[] { Format.R16Unorm, Format.R16G16B16A16Unorm, Format.R8G8B8A8Unorm }, + Format.R16Sfloat => new[] { Format.R16Sfloat, Format.R32Sfloat, Format.R16G16B16A16Sfloat, Format.R32G32B32A32Sfloat }, + Format.R32Sfloat => new[] { Format.R32Sfloat, Format.R32G32B32A32Sfloat }, + Format.R16G16Sfloat => new[] { Format.R16G16Sfloat, Format.R16G16B16A16Sfloat, Format.R32G32B32A32Sfloat }, + Format.R16G16B16A16Sfloat => new[] { Format.R16G16B16A16Sfloat, Format.R32G32B32A32Sfloat }, + Format.R8G8B8A8Unorm => new[] { Format.R8G8B8A8Unorm }, + _ => new[] { requested, Format.R8G8B8A8Unorm }, + }; + + /// + /// The first candidate whose optimal-tiling features include ; + /// RGBA8 when none does (every Vulkan device supports it as a storage image). + /// + public static Format Choose(Format requested, Func optimalFeatures) + { + foreach (Format candidate in CandidatesFor(requested)) + { + if ((optimalFeatures(candidate) & Required) == Required) return candidate; + } + return Format.R8G8B8A8Unorm; + } + + /// Whether a colour attachment usage may be added: the format must support it. + public static bool SupportsColorAttachment(FormatFeatureFlags features) => + (features & FormatFeatureFlags.ColorAttachmentBit) != 0; +} diff --git a/Optimum.Render.Vulkan/Core/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs index 5ea77217..7942b399 100644 --- a/Optimum.Render.Vulkan/Core/TextureManager.cs +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -95,6 +95,9 @@ public bool Released public uint MipLevels { get; init; } public uint Layers { get; init; } + /// The image usage it was created with; 0 for images created outside 's paths. + public ImageUsageFlags Usage { get; init; } + /// Whether the view is a cube rather than a six-layer array. public bool Cube { get; init; } @@ -172,6 +175,35 @@ public ImageView ViewOfLayer(uint layer) return view; } + /// + /// 2D views of a mip range, created on demand. A storage image descriptor names + /// exactly one level, and a sampled read of level n must not name the levels the + /// same pass writes: validation checks the layout of every level a view covers. + /// + private readonly Dictionary<(uint BaseMip, uint MipCount), ImageView> _mipViews = new(); + + public ImageView ViewOfMips(uint baseMip, uint mipCount) + { + if (baseMip == 0 && mipCount >= MipLevels && Layers <= 1 && !Cube && !Volume) return View; + lock (_mipViews) + { + if (_mipViews.TryGetValue((baseMip, mipCount), out ImageView existing)) return existing; + + var createInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = Image, + ViewType = ImageViewType.Type2D, + Format = Format, + SubresourceRange = new ImageSubresourceRange(Aspect, baseMip, mipCount, 0, 1), + }; + VulkanResult.Check(_context.Api.CreateImageView(_context.Device, &createInfo, null, out ImageView view), + "vkCreateImageView for mips " + baseMip + "+" + mipCount); + _mipViews[(baseMip, mipCount)] = view; + return view; + } + } + public void Dispose() { if (_disposed) return; @@ -183,6 +215,11 @@ public void Dispose() if (layerView.Handle != 0) api.DestroyImageView(_context.Device, layerView, null); } _layerViews.Clear(); + foreach (ImageView mipView in _mipViews.Values) + { + if (mipView.Handle != 0) api.DestroyImageView(_context.Device, mipView, null); + } + _mipViews.Clear(); if (View.Handle != 0) api.DestroyImageView(_context.Device, View, null); if (Image.Handle != 0) api.DestroyImage(_context.Device, Image, null); if (Allocation.IsValid) _context.Allocator.Free(Allocation); @@ -374,6 +411,44 @@ public int Create( ? ImageUsageFlags.DepthStencilAttachmentBit : ImageUsageFlags.ColorAttachmentBit); + return CreateImage(width, height, format, mipLevels, layers, cube, usage, aspect, poolClass); + } + + /// + /// Creates a texture a compute pass writes as a storage image (and later passes + /// sample): when the device can store to and sample + /// it, otherwise the first wider format of the same kind that it can + /// (, ending in RGBA8). Transfer usage is + /// always included, as for every texture; a colour attachment usage only where + /// the chosen format supports it. is explicit: a + /// prefiltered chain keeps as many levels as its consumer reads, not a full chain. + /// + public int CreateStorage(uint width, uint height, Format requested, uint mipLevels = 1, + MemoryPoolClass poolClass = MemoryPoolClass.DeviceImages) + { + width = Math.Max(1, width); + height = Math.Max(1, height); + mipLevels = Math.Clamp(mipLevels, 1, MipLevelsFor(width, height)); + + Format format = StorageFormats.Choose(requested, _context.OptimalFormatFeatures); + if (format != requested) + { + RenderTrace.Write("storage texture: " + requested + " is not storage-capable here; using " + format); + } + + ImageUsageFlags usage = ImageUsageFlags.StorageBit | ImageUsageFlags.SampledBit | + ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit; + if (StorageFormats.SupportsColorAttachment(_context.OptimalFormatFeatures(format))) + { + usage |= ImageUsageFlags.ColorAttachmentBit; + } + + return CreateImage(width, height, format, mipLevels, 1, false, usage, ImageAspectFlags.ColorBit, poolClass); + } + + private int CreateImage(uint width, uint height, Format format, uint mipLevels, uint layers, bool cube, + ImageUsageFlags usage, ImageAspectFlags aspect, MemoryPoolClass poolClass) + { var imageInfo = new ImageCreateInfo { SType = StructureType.ImageCreateInfo, @@ -437,6 +512,7 @@ public int Create( Layers = viewLayers, Cube = cube, Aspect = aspect, + Usage = usage, }; if (_context.PoisonFreshResources) Poison(texture); @@ -891,6 +967,18 @@ public void Require(BarrierBatcher batcher, CommandBuffer commandBuffer, VulkanT batcher.Require(texture, 0, texture.MipLevels, 0, texture.Layers, usage); } + /// + /// for + /// a range of mip levels (every layer): a compute pass reading level n and writing + /// level n + 1 of one image moves each level into its own layout. + /// + public void Require(BarrierBatcher batcher, CommandBuffer commandBuffer, VulkanTexture texture, + uint baseMip, uint mipCount, ResourceUsage usage) + { + _uploads.NoteUse(commandBuffer, texture); + batcher.Require(texture, baseMip, mipCount, 0, texture.Layers, usage); + } + /// A transition named by layout (tests, a readback restoring what it found); see . public void TransitionTexture(CommandBuffer commandBuffer, VulkanTexture texture, ImageLayout target) => TransitionTexture(commandBuffer, texture, UsageState.ForLayout(target)); diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 7558a005..cf1b83f4 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -1157,6 +1157,17 @@ public void CmdSetCheckpoint(CommandBuffer commandBuffer, nint marker) return text.ToString(); } + private readonly System.Collections.Concurrent.ConcurrentDictionary _formatFeatures = new(); + + /// The optimal-tiling features of on the selected device, cached. + public FormatFeatureFlags OptimalFormatFeatures(Format format) => + _formatFeatures.GetOrAdd(format, f => + { + FormatProperties properties; + Api.GetPhysicalDeviceFormatProperties(PhysicalDevice, f, &properties); + return properties.OptimalTilingFeatures; + }); + private VulkanCapabilities ReadCapabilities() { PhysicalDeviceProperties properties = Api.GetPhysicalDeviceProperties(PhysicalDevice); diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 2cd075cd..1aa1868f 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -237,6 +237,15 @@ public static void NoteUpload(long elapsedTicks) /// A second rendering scope inside one declared pass. public static void NotePassSplit() => Interlocked.Increment(ref _passSplits); + private static long _computePasses; + private static long _dispatches; + + /// A compute pass recorded (its barriers, pipeline and set), outside any rendering scope. + public static void NoteComputePass() => Interlocked.Increment(ref _computePasses); + + /// One vkCmdDispatch. + public static void NoteDispatch() => Interlocked.Increment(ref _dispatches); + private static long _transientBytes; private static long _aliasedBytesPeak; private static long _transientLeases; @@ -424,7 +433,9 @@ public static Result WaitDeviceIdle(Vk api, Device device) InPassClears: Interlocked.Exchange(ref _inPassClears, 0), PromotedClears: Interlocked.Exchange(ref _promotedClears, 0), StandaloneClears: Interlocked.Exchange(ref _standaloneClears, 0), - PassSplits: Interlocked.Exchange(ref _passSplits, 0)); + PassSplits: Interlocked.Exchange(ref _passSplits, 0), + ComputePasses: Interlocked.Exchange(ref _computePasses, 0), + Dispatches: Interlocked.Exchange(ref _dispatches, 0)); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -507,12 +518,13 @@ public static string FormatCountersLine(CounterSample counters) => "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7} " + "barrier_commands={8} barriers_per_frame={9:F1} mask_restarts={10} feedback_splits={11} " + "passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} " + - "standalone_clears={17} pass_splits={18}", + "standalone_clears={17} pass_splits={18} compute_passes={19} dispatches={20}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, counters.BarrierCommands, counters.Frames > 0 ? counters.Barriers / (double)counters.Frames : 0.0, counters.MaskRestarts, counters.FeedbackSplits, counters.Passes, counters.PlanHits, counters.PlanMisses, - counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits); + counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits, + counters.ComputePasses, counters.Dispatches); private static long _lastSample; } @@ -537,7 +549,9 @@ internal readonly record struct CounterSample( long InPassClears = 0, long PromotedClears = 0, long StandaloneClears = 0, - long PassSplits = 0); + long PassSplits = 0, + long ComputePasses = 0, + long Dispatches = 0); /// The values on the stats.transients line. internal readonly record struct TransientSample( diff --git a/Optimum.Render.Vulkan/Graph/ComputePass.cs b/Optimum.Render.Vulkan/Graph/ComputePass.cs new file mode 100644 index 00000000..377d7ca1 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/ComputePass.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; + +namespace Optimum.Render.Vulkan.Graph; + +/// How a compute pass uses one bound image. +public enum ComputeAccess +{ + /// A combined image sampler over levels: SHADER_READ_ONLY_OPTIMAL. + Sampled, + /// A storage image read with imageLoad and never written: GENERAL. + StorageRead, + /// A storage image written with imageStore without reading it first: GENERAL. + StorageWrite, + /// A storage image read and written: GENERAL. + StorageReadWrite, +} + +/// +/// One image a compute pass binds: the descriptor binding in the program's pass set, +/// the texture, how it is used and which levels. A storage binding names exactly one +/// level (a storage image descriptor is one level); a sampled binding names a range, +/// and only that range moves to SHADER_READ_ONLY_OPTIMAL, so a prefilter can sample +/// level n and store level n + 1 of the same image in one pass. +/// +/// The layout(binding = N) of the pass set. +/// A texture id of the device. +/// What the dispatch does with it. +/// The first level the binding covers. +/// The number of levels; exactly 1 for storage. +/// Sampled only: linear min/mag filtering instead of nearest (always clamp to edge, nearest mip). +public readonly record struct ComputeBinding( + uint Binding, int TextureId, ComputeAccess Access, uint BaseMip = 0, uint MipCount = 1, bool Linear = false); + +/// +/// One vkCmdDispatch of a pass. Either explicit group counts, or +/// naming the index (into ) +/// of the image whose level extent the groups must cover with the program's local size. +/// +public sealed class ComputeDispatch +{ + public uint GroupsX = 1; + public uint GroupsY = 1; + public uint GroupsZ = 1; + + /// Index into the pass's bindings whose level extent sizes the dispatch; -1 uses the explicit counts. + public int SizeFromBinding = -1; + + /// Pushed for the compute stage before the dispatch; at most the program's push constant size. + public byte[]? PushConstants; + + public static ComputeDispatch Explicit(uint x, uint y, uint z = 1, byte[]? pushConstants = null) => + new() { GroupsX = x, GroupsY = y, GroupsZ = z, PushConstants = pushConstants }; + + public static ComputeDispatch Covering(int bindingIndex, byte[]? pushConstants = null) => + new() { SizeFromBinding = bindingIndex, PushConstants = pushConstants }; +} + +/// +/// A compute pass as its owner declares it (the frame graph's second pass kind; the +/// AO is its first user). The recorder closes any open rendering scope, queues one +/// barrier per binding from its (GENERAL for storage, +/// SHADER_READ_ONLY_OPTIMAL for sampled, per level), flushes them as one command, +/// binds the pipeline for and one descriptor set, and +/// records every dispatch. No rendering scope is open at any point of it. +/// +public sealed class ComputePassDeclaration +{ + public string Name = ""; + + /// A program from VulkanDevice.CreateComputeProgram. + public int ProgramId; + + /// Specialization constant values; constant_id = i takes element i (4 bytes each: uint, int, float bits or bool). + public uint[] Specialization = Array.Empty(); + + public ComputeBinding[] Bindings = Array.Empty(); + + public ComputeDispatch[] Dispatches = Array.Empty(); +} + +/// The size and level count of a bound texture, for validation without a device. +internal readonly record struct ComputeImageInfo(uint Width, uint Height, uint MipLevels, uint Layers); + +/// +/// The device-free half of a compute pass: which usage each binding is, whether the +/// bindings are consistent, how many groups cover an image, and the signature the +/// frame plan sees. +/// +internal static class ComputePassPlanner +{ + public static ResourceUsage UsageOf(ComputeAccess access) => access switch + { + ComputeAccess.Sampled => ResourceUsage.SampleCompute, + ComputeAccess.StorageRead => ResourceUsage.StorageReadCompute, + ComputeAccess.StorageWrite => ResourceUsage.StorageWrite, + ComputeAccess.StorageReadWrite => ResourceUsage.StorageReadWrite, + _ => throw new ArgumentOutOfRangeException(nameof(access), access, null), + }; + + public static bool IsStorage(ComputeAccess access) => access != ComputeAccess.Sampled; + + public static bool Writes(ComputeAccess access) => + access is ComputeAccess.StorageWrite or ComputeAccess.StorageReadWrite; + + /// The extent of one level: the base extent halved per level, never below 1. + public static uint LevelExtent(uint extent, uint mip) => Math.Max(1u, mip >= 32 ? 1u : extent >> (int)mip); + + /// Work groups of that cover texels. + public static uint GroupsCovering(uint extent, uint localSize) => + localSize == 0 ? 0 : (extent + localSize - 1) / localSize; + + /// + /// Why the declaration cannot be recorded, or null. Checks every binding names a + /// live single-layer texture and levels inside it, that storage bindings name one + /// level, that no binding number repeats, that no level is bound twice where one + /// of the uses writes (one layout per level per pass), and that every dispatch + /// sizes from an existing binding. + /// + public static string? Validate(ComputePassDeclaration pass, Func image) + { + if (pass.Dispatches.Length == 0) return "compute pass '" + pass.Name + "' has no dispatch"; + + var numbers = new HashSet(); + for (int i = 0; i < pass.Bindings.Length; i++) + { + ComputeBinding binding = pass.Bindings[i]; + if (!numbers.Add(binding.Binding)) return "binding " + binding.Binding + " is bound twice"; + + ComputeImageInfo? info = image(binding.TextureId); + if (info == null) return "binding " + binding.Binding + " names no texture (" + binding.TextureId + ")"; + if (info.Value.Layers > 1) return "binding " + binding.Binding + " names a layered texture"; + if (binding.MipCount == 0) return "binding " + binding.Binding + " covers no level"; + if (IsStorage(binding.Access) && binding.MipCount != 1) + return "storage binding " + binding.Binding + " must name exactly one level"; + if (binding.BaseMip + binding.MipCount > info.Value.MipLevels) + return "binding " + binding.Binding + " names levels " + binding.BaseMip + "+" + binding.MipCount + + " of a " + info.Value.MipLevels + "-level texture"; + + for (int j = 0; j < i; j++) + { + ComputeBinding other = pass.Bindings[j]; + if (other.TextureId != binding.TextureId) continue; + bool overlap = binding.BaseMip < other.BaseMip + other.MipCount && + other.BaseMip < binding.BaseMip + binding.MipCount; + if (!overlap) continue; + if (UsageOf(binding.Access) != UsageOf(other.Access) || Writes(binding.Access)) + return "bindings " + other.Binding + " and " + binding.Binding + + " use one level of texture " + binding.TextureId + " in two ways"; + } + } + + foreach (ComputeDispatch dispatch in pass.Dispatches) + { + if (dispatch.SizeFromBinding >= pass.Bindings.Length) + return "a dispatch sizes from binding index " + dispatch.SizeFromBinding + " of " + pass.Bindings.Length; + } + return null; + } + + /// The group counts of . + public static (uint X, uint Y, uint Z) Groups(ComputeDispatch dispatch, ComputePassDeclaration pass, + Func image, uint localSizeX, uint localSizeY) + { + if (dispatch.SizeFromBinding < 0) return (dispatch.GroupsX, dispatch.GroupsY, dispatch.GroupsZ); + ComputeBinding binding = pass.Bindings[dispatch.SizeFromBinding]; + ComputeImageInfo info = image(binding.TextureId) ?? default; + return (GroupsCovering(LevelExtent(info.Width, binding.BaseMip), localSizeX), + GroupsCovering(LevelExtent(info.Height, binding.BaseMip), localSizeY), 1); + } + + /// + /// What the frame plan sees of the pass: every written texture as a non-transient + /// attachment use (so a raster pass that attaches it later never loads DONT_CARE + /// over the dispatch's result) and every read texture in . + /// The extent is the first written level's, or the first binding's. + /// + public static PassSignature Signature(int nameId, ComputePassDeclaration pass, Func image) + { + var uses = new List(); + var reads = new List(); + uint width = 0, height = 0; + foreach (ComputeBinding binding in pass.Bindings) + { + if (Writes(binding.Access)) + { + var use = new AttachmentUse(binding.TextureId, UsageOf(binding.Access), false); + if (!uses.Contains(use)) uses.Add(use); + if (width == 0 && image(binding.TextureId) is { } written) + { + width = LevelExtent(written.Width, binding.BaseMip); + height = LevelExtent(written.Height, binding.BaseMip); + } + } + if (binding.Access != ComputeAccess.StorageWrite && !reads.Contains(binding.TextureId)) + { + reads.Add(binding.TextureId); + } + } + if (width == 0 && pass.Bindings.Length > 0 && image(pass.Bindings[0].TextureId) is { } first) + { + width = LevelExtent(first.Width, pass.Bindings[0].BaseMip); + height = LevelExtent(first.Height, pass.Bindings[0].BaseMip); + } + + return new PassSignature + { + NameId = nameId, + Attachments = uses.ToArray(), + Reads = reads.ToArray(), + Width = (int)width, + Height = (int)height, + FormatsId = -1, + }; + } +} diff --git a/Optimum.Render.Vulkan/Graph/FrameGraph.cs b/Optimum.Render.Vulkan/Graph/FrameGraph.cs index 86ea14cc..281e81bf 100644 --- a/Optimum.Render.Vulkan/Graph/FrameGraph.cs +++ b/Optimum.Render.Vulkan/Graph/FrameGraph.cs @@ -109,6 +109,8 @@ internal sealed class FrameGraph public long PromotedClears { get; private set; } public long StandaloneClears { get; private set; } public long PlannedDontCareLoads { get; private set; } + public long ComputePasses { get; private set; } + public long Dispatches { get; private set; } /// Passes opened in the frame being recorded. public int PassesThisFrame => _frame.Count; @@ -156,6 +158,41 @@ public int OpenPass(PassSignature signature, bool declared) return index; } + /// + /// A compute pass was recorded: counted, and, while the graph is on, its signature + /// (storage writes as attachment uses, reads as reads) joins the frame so the plan + /// sees the dispatch's writes and reads between the raster passes around it. It opens + /// no scope, so it is not one of . + /// + public int OpenComputePass(PassSignature signature) + { + ComputePasses++; + VulkanStats.NoteComputePass(); + if (!Enabled) return -1; + + int index = _frame.Count; + for (int k = 0; k < _plans.Length; k++) + { + FramePlan? plan = _plans[k]; + _prefixMatches[k] = _prefixMatches[k] && plan != null && !plan.IsConservative && + plan.MatchesPass(index, signature); + } + _frame.Add(signature); + if (RenderTrace.Enabled) + { + RenderTrace.Write("compute pass " + index + " name=" + signature.NameId + " writes=" + + signature.Attachments.Length + " reads=" + signature.Reads.Length + " " + signature.Width + "x" + + signature.Height + " plan=" + (PrefixMatchesPlan ? "match" : "conservative")); + } + return index; + } + + public void NoteDispatch() + { + Dispatches++; + VulkanStats.NoteDispatch(); + } + /// /// The load op for attachment of pass /// when no clear was promoted into it: the plan's op diff --git a/Optimum.Render.Vulkan/Graph/ResourceUsage.cs b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs index 8361fa3f..c796de38 100644 --- a/Optimum.Render.Vulkan/Graph/ResourceUsage.cs +++ b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs @@ -27,6 +27,14 @@ public enum ResourceUsage SampleVertex, /// Read as a storage image. StorageRead, + /// Sampled by a compute shader. + SampleCompute, + /// Read as a storage image by a compute shader, never written. + StorageReadCompute, + /// Written as a storage image by a compute shader without reading it first. + StorageWrite, + /// Read and written as a storage image by a compute shader. + StorageReadWrite, /// Source of a copy or blit. TransferSrc, /// Destination of a copy, blit or clear. @@ -79,6 +87,14 @@ internal readonly record struct UsageState(ImageLayout Layout, PipelineStageFlag ResourceUsage.StorageRead => new(ImageLayout.General, PipelineStageFlags2.FragmentShaderBit | PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderStorageReadBit), + ResourceUsage.SampleCompute => new(ImageLayout.ShaderReadOnlyOptimal, + PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderSampledReadBit), + ResourceUsage.StorageReadCompute => new(ImageLayout.General, + PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderStorageReadBit), + ResourceUsage.StorageWrite => new(ImageLayout.General, + PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderStorageWriteBit), + ResourceUsage.StorageReadWrite => new(ImageLayout.General, + PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderStorageReadBit | AccessFlags2.ShaderStorageWriteBit), ResourceUsage.TransferSrc => new(ImageLayout.TransferSrcOptimal, PipelineStageFlags2.TransferBit, AccessFlags2.TransferReadBit), ResourceUsage.TransferDst => new(ImageLayout.TransferDstOptimal, @@ -103,6 +119,9 @@ public static (PipelineStageFlags2 Stage, AccessFlags2 Access) WriteOf(ResourceU ResourceUsage.DepthWrite or ResourceUsage.DepthReadOnly or ResourceUsage.DepthReadOnlySampled => (DepthTests, AccessFlags2.DepthStencilAttachmentWriteBit), ResourceUsage.TransferDst => (PipelineStageFlags2.TransferBit, AccessFlags2.TransferWriteBit), + // A dispatch's storage write: the next barrier must make it available. + ResourceUsage.StorageWrite or ResourceUsage.StorageReadWrite => + (PipelineStageFlags2.ComputeShaderBit, AccessFlags2.ShaderStorageWriteBit), _ => (PipelineStageFlags2.None, AccessFlags2.None), }; diff --git a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs index af726acb..ba805e20 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -157,6 +157,31 @@ public ShaderCompileResult Compile(string code, string filename, EnumShaderType return compiled; } + /// + /// Stage tag for compute modules in the binary cache key: GL_COMPUTE_SHADER, which no + /// client stage uses, so a compute module never shares a key with a vertex or fragment one. + /// + internal const EnumShaderType ComputeStageTag = (EnumShaderType)37305; + + /// + /// Compiles a native Vulkan GLSL compute shader (sources/shaders-vk/**.comp) + /// to SPIR-V. No prefix, no rewriter: native shaders are written for the backend. + /// + public ShaderCompileResult CompileCompute(string code, string filename) + { + string? key = null; + if (BinaryCache != null) + { + key = ShaderBinaryCache.KeyFor(code, ComputeStageTag, Identity); + byte[]? cached = BinaryCache.TryGet(key); + if (cached != null) return new ShaderCompileResult { Success = true, Spirv = cached }; + } + + ShaderCompileResult compiled = CompileUncached(code, filename, ComputeStageTag); + if (key != null && compiled.Success) BinaryCache!.Put(key, compiled.Spirv); + return compiled; + } + private ShaderCompileResult CompileUncached(string code, string filename, EnumShaderType stage) { var result = new ShaderCompileResult(); @@ -331,6 +356,7 @@ private string ReadBytesAsText(CompilationResult* result) EnumShaderType.VertexShader => ShaderKind.VertexShader, EnumShaderType.FragmentShader => ShaderKind.FragmentShader, EnumShaderType.GeometryShader => ShaderKind.GeometryShader, + ComputeStageTag => ShaderKind.ComputeShader, _ => ShaderKind.VertexShader, }; diff --git a/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs index 28751c5b..edea8c91 100644 --- a/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs +++ b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs @@ -60,7 +60,7 @@ public ReadbackManager(VulkanContext context, TextureManager textures, FrameRing /// The caller has closed any open rendering scope and submits afterwards. /// public ReadbackTicket CopyToHost(VulkanTexture texture, int x, int y, uint width, uint height, - ImageAspectFlags aspect, ulong bytes) + ImageAspectFlags aspect, ulong bytes, uint mipLevel = 0) { FrameSlot slot = _frames.Current; CommandBuffer commandBuffer = slot.CommandBuffer; @@ -76,7 +76,7 @@ public ReadbackTicket CopyToHost(VulkanTexture texture, int x, int y, uint width var region = new BufferImageCopy { BufferOffset = offset, - ImageSubresource = new ImageSubresourceLayers(aspect, 0, 0, 1), + ImageSubresource = new ImageSubresourceLayers(aspect, mipLevel, 0, 1), ImageOffset = new Offset3D(x, y, 0), ImageExtent = new Extent3D(width, height, 1), }; diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index ebe258be..785a0023 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -39,6 +39,12 @@ public sealed unsafe class VulkanDevice : IDisposable /// private readonly Graph.FrameGraph _graph = new(); private GraphicsPipelineCache _pipelines = null!; + + /// Compute programs and their pipelines (the frame graph's compute pass kind). + private ComputePipelineCache _compute = null!; + + /// Per-slot descriptor sets of compute passes, reset when the slot begins a frame. + private ComputeDescriptorArena[] _computeArenas = Array.Empty(); private DescriptorCache _descriptors = null!; /// How many descriptor sets the cache currently holds. For tests. @@ -450,6 +456,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _pipelines = new GraphicsPipelineCache(_context, _context.Capabilities.ColorWriteTier, _context.Capabilities.DynamicColorBlend, pipelineSeed); _descriptors = new DescriptorCache(_context); + _compute = new ComputePipelineCache(_context, () => _pipelines.DriverCache); // One layout for the shared frame block, named by every program's pipeline layout. _frameSetLayout = ShaderProgramResources.CreateFrameSetLayout(_context); // Decision 9: the bindless table retires a texture's slots on the timeline @@ -459,6 +466,8 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _sharedLayout = new SharedPipelineLayout(_context, _bindless.Layout); _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; for (int i = 0; i < _descriptorArenas.Length; i++) _descriptorArenas[i] = new DescriptorArena(_context); + _computeArenas = new ComputeDescriptorArena[_frames.FramesInFlight]; + for (int i = 0; i < _computeArenas.Length; i++) _computeArenas[i] = new ComputeDescriptorArena(_context); _indirectRing = new IndirectRing(_frames.FramesInFlight); _indirectBuffers = new VulkanBuffer?[_frames.FramesInFlight]; _queryRing = new QueryRing(_context, _frames.Timeline, _frames.FramesInFlight); @@ -802,6 +811,7 @@ public void BeginFrame() // its indirect cursor and descriptor arena reset wholesale. BeginIndirectFrame(slot.Index); _descriptorArenas[slot.Index].Reset(); + _computeArenas[slot.Index].Reset(); _resourceAge.NoteFrame(ResourceIds.Highest); _dynamicState.Invalidate(); @@ -893,6 +903,194 @@ internal void DrawBindlessForTests(Pipeline pipeline, int width, int height, byt _dynamicState.Invalidate(); } + // ------------------------------------------------------------------ compute + + /// The compute programs. Tests only. + internal ComputePipelineCache ComputeForTests => _compute; + + /// A compute program from compiled SPIR-V; see . + internal int CreateComputeProgram(ComputeProgramDescription description) => _compute.Create(description); + + /// + /// Compiles a native compute shader and creates its program; 0, with the compiler's + /// message in , when it does not compile. + /// + internal int CreateComputeProgram(string code, string name, ComputeSlot[] slots, uint pushConstantBytes = 0, + uint localSizeX = 8, uint localSizeY = 8) + { + ShaderCompileResult compiled = _shaderCompiler.CompileCompute(code, name); + if (!compiled.Success) + { + AddDiagnostic(VulkanContext.ErrorPrefix + "compute shader '" + name + "' failed to compile: " + compiled.Error); + return 0; + } + return _compute.Create(new ComputeProgramDescription + { + Name = name, + Spirv = compiled.Spirv, + Slots = slots, + PushConstantBytes = pushConstantBytes, + LocalSizeX = localSizeX, + LocalSizeY = localSizeY, + }); + } + + /// Deletes a compute program once no submitted frame can still bind it. + internal void DeleteComputeProgram(int programId) + { + ComputeProgram? program = _compute.Remove(programId); + if (program != null) _frames.DeferDeletion(program); + } + + /// + /// A texture compute passes store to: where the device can + /// store to and sample it, else a wider format of the same kind (RGBA8 last); + /// levels. The chosen format is the texture's + /// . + /// + internal int CreateStorageTexture(int width, int height, Format format, int mipLevels = 1) => + _textures.CreateStorage((uint)Math.Max(1, width), (uint)Math.Max(1, height), format, (uint)Math.Max(1, mipLevels)); + + private Graph.ComputeImageInfo? ComputeImageInfoOf(int textureId) => + _textures.Get(textureId) is { } texture + ? new Graph.ComputeImageInfo(texture.Width, texture.Height, texture.MipLevels, texture.Cube ? 6u : texture.Layers) + : null; + + private static readonly SamplerState ComputeNearest = new(Filter.Nearest, Filter.Nearest, SamplerMipmapMode.Nearest, + SamplerAddressMode.ClampToEdge, SamplerAddressMode.ClampToEdge, 0f, false, 1f, BorderColor.FloatOpaqueBlack, + Mipmapped: true); + + private static readonly SamplerState ComputeLinear = ComputeNearest with + { + MagFilter = Filter.Linear, + MinFilter = Filter.Linear, + }; + + /// + /// Records a compute pass into the frame (): + /// closes any open rendering scope, lands clears pending on its images, queues one + /// barrier per binding level range from its access and flushes them as one command, + /// binds the pipeline for the pass's specialization values and one descriptor set, + /// and records every dispatch. False, with the reason in , + /// when no frame is open or the declaration does not fit its program. + /// + internal bool RecordComputePass(Graph.ComputePassDeclaration pass) + { + if (!_frameActive) return false; + + ComputeProgram? program = _compute.Get(pass.ProgramId); + string? error = program == null + ? "no compute program " + pass.ProgramId + : Graph.ComputePassPlanner.Validate(pass, ComputeImageInfoOf); + if (error == null && program != null) + { + foreach (Graph.ComputeBinding binding in pass.Bindings) + { + if (!program.TryGetSlot(binding.Binding, out ComputeSlot slot)) + { + error = "binding " + binding.Binding + " is not in program '" + program.Name + "'"; + break; + } + bool storage = Graph.ComputePassPlanner.IsStorage(binding.Access); + if (storage != (slot.Kind == ComputeSlotKind.Storage)) + { + error = "binding " + binding.Binding + " is declared " + slot.Kind + " by program '" + program.Name + + "' but bound " + binding.Access; + break; + } + if (storage && (_textures.Get(binding.TextureId)!.Usage & ImageUsageFlags.StorageBit) == 0) + { + error = "binding " + binding.Binding + " stores to texture " + binding.TextureId + + ", which was not created as a storage texture"; + break; + } + } + foreach (ComputeSlot slot in program.Slots) + { + if (error != null) break; + if (Array.FindIndex(pass.Bindings, b => b.Binding == slot.Binding) < 0) + error = "program '" + program.Name + "' binding " + slot.Binding + " is not bound"; + } + foreach (Graph.ComputeDispatch dispatch in pass.Dispatches) + { + if (error != null) break; + if (dispatch.PushConstants is { Length: > 0 } push && push.Length > program.PushConstantBytes) + error = "a dispatch pushes " + push.Length + " bytes; program '" + program.Name + "' declares " + + program.PushConstantBytes; + } + } + if (error != null) + { + AddDiagnostic(VulkanContext.ErrorPrefix + "compute pass '" + pass.Name + "': " + error); + return false; + } + + CommandBuffer commandBuffer = Commands; + Vk api = _context.Api; + + // No rendering scope encloses a dispatch, and a clear promoted into one of the + // pass's images lands before the pass reads or writes it. + _targets.EndRendering(commandBuffer); + foreach (Graph.ComputeBinding binding in pass.Bindings) + { + _targets.FlushPendingClears(commandBuffer, _textures.Get(binding.TextureId)!); + } + + _graph.OpenComputePass(Graph.ComputePassPlanner.Signature(_graph.NameId("compute:" + pass.Name), pass, + ComputeImageInfoOf)); + + foreach (Graph.ComputeBinding binding in pass.Bindings) + { + _textures.Require(_barriers, commandBuffer, _textures.Get(binding.TextureId)!, binding.BaseMip, + binding.MipCount, Graph.ComputePassPlanner.UsageOf(binding.Access)); + } + _barriers.Flush(commandBuffer); + + Span writes = pass.Bindings.Length <= 16 + ? stackalloc ComputeImageWrite[pass.Bindings.Length] + : new ComputeImageWrite[pass.Bindings.Length]; + for (int i = 0; i < pass.Bindings.Length; i++) + { + Graph.ComputeBinding binding = pass.Bindings[i]; + VulkanTexture texture = _textures.Get(binding.TextureId)!; + bool storage = Graph.ComputePassPlanner.IsStorage(binding.Access); + writes[i] = new ComputeImageWrite(binding.Binding, + storage ? DescriptorType.StorageImage : DescriptorType.CombinedImageSampler, + texture.ViewOfMips(binding.BaseMip, binding.MipCount), + storage ? default : _textures.Samplers.Get(binding.Linear ? ComputeLinear : ComputeNearest), + storage ? ImageLayout.General : ImageLayout.ShaderReadOnlyOptimal); + } + DescriptorSet set = _computeArenas[_frames.Current.Index].Get(program!.SetLayout, writes); + Pipeline pipeline = _compute.PipelineFor(program, pass.Specialization); + + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Compute, pipeline); + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Compute, program.Layout, ComputeProgram.PassSet, 1, + &set, 0, null); + + foreach (Graph.ComputeDispatch dispatch in pass.Dispatches) + { + if (dispatch.PushConstants is { Length: > 0 } push) + { + fixed (byte* data = push) + { + api.CmdPushConstants(commandBuffer, program.Layout, ShaderStageFlags.ComputeBit, 0, (uint)push.Length, + data); + } + } + (uint x, uint y, uint z) = Graph.ComputePassPlanner.Groups(dispatch, pass, ComputeImageInfoOf, + program.LocalSizeX, program.LocalSizeY); + if (x == 0 || y == 0 || z == 0) continue; + api.CmdDispatch(commandBuffer, x, y, z); + _graph.NoteDispatch(); + if (RenderTrace.Enabled) + { + RenderTrace.Write("dispatch '" + pass.Name + "' program " + program.Id + " '" + program.Name + "' groups=" + + x + "x" + y + "x" + z); + } + } + return true; + } + /// Static meshes on device-local memory through staging (Phase 1B step 5's default). Tests only. internal bool DeviceLocalStaticMeshesForTests { @@ -3212,6 +3410,23 @@ private void DumpRequestedTextures() internal byte[] ReadBackLevel0ForTests(int textureId) => ReadBackLevel0(_textures.Get(textureId) ?? throw new ArgumentException("no texture " + textureId)); + /// One mip level of a texture through the in-frame readback. Tests only; a frame must be open. + internal byte[] ReadBackLevelForTests(int textureId, uint mipLevel) + { + VulkanTexture texture = _textures.Get(textureId) ?? throw new ArgumentException("no texture " + textureId); + if (!_frameActive) throw new InvalidOperationException("a mip readback needs an open frame"); + uint width = Math.Max(1, texture.Width >> (int)mipLevel); + uint height = Math.Max(1, texture.Height >> (int)mipLevel); + ulong bytes = (ulong)width * height * (ulong)BytesPerPixel(texture.Format); + var data = new byte[bytes]; + _targets.FlushPendingClears(Commands, texture); + _targets.EndRendering(Commands); + ReadbackTicket ticket = _readbacks.CopyToHost(texture, 0, 0, width, height, texture.Aspect, bytes, mipLevel); + SubmitPartial(); + fixed (byte* destination = data) _readbacks.WaitAndCopy(ticket, (IntPtr)destination); + return data; + } + private byte[] ReadBackLevel0(VulkanTexture texture) { int width = (int)texture.Width; @@ -3394,6 +3609,7 @@ public void Dispose() foreach (ShaderProgramResources program in _programs.Values) program.Dispose(); _programs.Clear(); + _compute?.Dispose(); // After every pipeline layout that named it. if (_context != null && _frameSetLayout.Handle != 0) { @@ -3414,6 +3630,7 @@ public void Dispose() foreach (VulkanBuffer overflow in _indirectOverflow) overflow.Dispose(); _indirectOverflow.Clear(); foreach (DescriptorArena arena in _descriptorArenas) arena.Dispose(); + foreach (ComputeDescriptorArena arena in _computeArenas) arena.Dispose(); _defaultAttributes?.Dispose(); _placeholderUniforms?.Dispose(); _swapchain?.Dispose(); diff --git a/Optimum.Tests/frame-graph-coverage-tests.cs b/Optimum.Tests/frame-graph-coverage-tests.cs index 780d98d7..8e1d2d76 100644 --- a/Optimum.Tests/frame-graph-coverage-tests.cs +++ b/Optimum.Tests/frame-graph-coverage-tests.cs @@ -95,7 +95,7 @@ public void TheStatsLineCarriesTheFrameGraphCounters() { string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); Assert.Contains("\"passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} \"", stats); - Assert.Contains("\"standalone_clears={17} pass_splits={18}\"", stats); + Assert.Contains("\"standalone_clears={17} pass_splits={18} compute_passes={19} dispatches={20}\"", stats); string doc = Read("docs/taa-acceptance.md"); Assert.Contains("OPTIMUM_VULKAN_FRAMEGRAPH=0", doc); diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 5133f559..2b3a38e6 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -272,7 +272,7 @@ unchanged from earlier builds; the other four carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= compute_passes= dispatches= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= ``` @@ -308,7 +308,9 @@ stats.transients transient_mib= aliased_mib= heap_peak_mib= lease frame), `in_pass_clears` (clears recorded as vkCmdClearAttachments inside an open pass), `promoted_clears` (clears issued with no pass open that became LOAD_OP_CLEAR) and `standalone_clears` (promoted clears whose image was used before a pass attached it, recorded - as a clear-image command). The colour write tier is on the device-up validation log line; + as a clear-image command). Compute pass kind: `compute_passes` (compute passes recorded, their barriers + flushed with no rendering scope open; not part of `passes`) and `dispatches` (vkCmdDispatch calls). + The colour write tier is on the device-up validation log line; `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. - `stats.transients` (Phase 2 step 4, `TransientAllocator` and `FeedbackCopyPool`): `transient_mib` (at the last frame boundary: the post-chain colour textures of framebuffer slots 2, 3, 4, 7, 8, 9, From efddfc7cc2861492caf3dafa214d50138592eef4 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:15:54 +0200 Subject: [PATCH 160/226] wip(native-shaders): family 1 pilot (blit, final, luma) and the static parity harness NativeShaderParityTests is data-driven over sources/shaders-vk: it builds the manifest through NativeShaderBuilder.Build and compares every program and variant with its GLSL 330 program (collectUniformNames names and types, sampler order, include port uniforms, push limit, vertex inputs and fragment outputs under three prefix bases), and checks that every variant compiles, passes spirv-val (vulkan1.3, scalar block layout as the device enables) and reflects its push block and record as declared. Verified: parity harness 7/7 (3 programs, 3 variants), and it fails naming the entry on a planted sampler swap, renamed record member and moved output; Optimum.Render.Vulkan.Tests 858/858 with implicit layers off (no SYNC- outside SyncValidationControlTests); Optimum.Tests -c Release 1184 passed, 34 skipped. --- .../NativeShaderParityTests.cs | 526 ++++++++++++++++++ docs/vulkan-native-shaders.md | 87 ++- sources/shaders-vk/blit.frag | 19 + sources/shaders-vk/blit.interface.glsl | 6 + sources/shaders-vk/blit.vert | 23 + sources/shaders-vk/final.frag | 146 +++++ sources/shaders-vk/final.interface.glsl | 42 ++ sources/shaders-vk/final.vert | 34 ++ sources/shaders-vk/luma.frag | 23 + sources/shaders-vk/luma.interface.glsl | 6 + sources/shaders-vk/luma.vert | 22 + 11 files changed, 933 insertions(+), 1 deletion(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs create mode 100644 sources/shaders-vk/blit.frag create mode 100644 sources/shaders-vk/blit.interface.glsl create mode 100644 sources/shaders-vk/blit.vert create mode 100644 sources/shaders-vk/final.frag create mode 100644 sources/shaders-vk/final.interface.glsl create mode 100644 sources/shaders-vk/final.vert create mode 100644 sources/shaders-vk/luma.frag create mode 100644 sources/shaders-vk/luma.interface.glsl create mode 100644 sources/shaders-vk/luma.vert diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs new file mode 100644 index 00000000..ea4472d8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs @@ -0,0 +1,526 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Static parity between every native program in sources/shaders-vk and the GLSL 330 program it +/// replaces (docs/vulkan-native-shaders.md section 2). Data-driven over the tree: a family stage adds +/// shaders, never test code. +/// +/// The native side is the manifest the offline compiler's library entry point +/// () produces for the tree. The GLSL 330 side is the program +/// builds from the effective sources (sources/shaders override, else +/// the vanilla asset) with the prefix defines of the matching variant. No GPU is needed. +/// +public sealed class NativeShaderParityTests +{ + private static string SourceDirectory => Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + + private static readonly Lazy<(NativeShaderBuildResult? Result, string Reason)> Built = new(() => + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return (null, reason); + using (compiler) + { + return (new NativeShaderBuilder(compiler!).Build(SourceDirectory), ""); + } + }); + + public static IEnumerable Programs() => + NativeShaderBuilder.DiscoverPrograms(SourceDirectory, new List()).Select(name => new object[] { name }); + + private static NativeShaderBuildResult RequireBuild() + { + (NativeShaderBuildResult? result, string reason) = Built.Value; + Skip.If(result == null, reason); + return result!; + } + + private static List ErrorsOf(NativeShaderBuildResult result, string program) => + result.Errors.Where(e => e.StartsWith(program + " ", StringComparison.Ordinal) || e.StartsWith(program + ":", StringComparison.Ordinal)).ToList(); + + // ------------------------------------------------------------------ the GLSL 330 oracle + + /// + /// ShaderProgram.collectUniformNames (build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgram.cs:57-68), + /// character for character: the same pattern and options, run over each stage's include-expanded + /// Code (never the prefix, never preprocessed) in the order Compile calls it (vertex, + /// fragment, geometry). It therefore sees names inside inactive #if blocks and comments, and a + /// type outside its list is invisible to it. + /// + private static readonly Regex CollectUniformNames = new( + "(\\s|\\r\\n)uniform\\s*(?float|int|ivec2|ivec3|ivec4|vec2|vec3|vec4|sampler2DShadow|sampler2D|samplerCube|mat3|mat4x3|mat4)\\s*(\\[[\\d\\w]+\\])?\\s*(?[\\d\\w]+)", + RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); + + internal sealed class Oracle + { + /// The name set, each with every type the pattern captured for it. + public readonly SortedDictionary> Names = new(StringComparer.Ordinal); + /// textureLocations: a repeated sampler name is reassigned the current count, as the client does. + public readonly Dictionary TextureLocations = new(StringComparer.Ordinal); + } + + internal static Oracle CollectOracle(IEnumerable stages) + { + var oracle = new Oracle(); + foreach (ShaderStageSource stage in stages.OrderBy(s => s.Stage switch + { + EnumShaderType.VertexShader => 0, + EnumShaderType.FragmentShader => 1, + _ => 2, + })) + { + foreach (Match item in CollectUniformNames.Matches(stage.Code)) + { + string value = item.Groups["var"].Value; + string type = item.Groups["type"].ToString(); + if (!oracle.Names.TryGetValue(value, out SortedSet? types)) + { + oracle.Names[value] = types = new SortedSet(StringComparer.Ordinal); + } + types.Add(type); + if (type.Contains("sampler")) + { + oracle.TextureLocations[value] = oracle.TextureLocations.Count; + } + } + } + return oracle; + } + + // ------------------------------------------------------------------ variants + + /// + /// The defines every value of every axis is checked under. Axes a native program branches on override + /// the base; everything else keeps the base's value, so a define that changes a GLSL 330 declaration + /// without being an axis of the native program makes the bases disagree and fails. + /// + private static readonly string[] BaseVariants = { "everything-off", "everything-on", "taa-with-ssao" }; + + internal static Dictionary ParseKey(string key) + { + var values = new Dictionary(StringComparer.Ordinal); + if (key.Length == 0) return values; + foreach (string pair in key.Split(',')) + { + string[] parts = pair.Split('='); + values[parts[0]] = int.Parse(parts[1], CultureInfo.InvariantCulture); + } + return values; + } + + /// + /// Maps a native variant back to ShaderRegistry's prefix defines (registerDefaultShaderCodePrefixes, + /// ShaderRegistry.cs:462-540). GBUFFER is SSAOLEVEL > 0; TAAMOTIONLOCATION follows it (4 with the + /// G-buffer, 2 without); the per-registration defines are stamped as the program's own prefix, which + /// the GLSL 330 sources test with defined(). + /// + internal static ShaderCorpus.ShaderVariant VariantFor(string baseName, IReadOnlyDictionary axes) + { + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().Single(v => v.Name == baseName); + var extra = new List(); + foreach ((string axis, int value) in axes.OrderBy(a => a.Key, StringComparer.Ordinal)) + { + switch (axis) + { + case "TAAMOTION": variant.TaaMotion = value; break; + case "GBUFFER": variant.SsaoLevel = value == 0 ? 0 : Math.Max(1, variant.SsaoLevel); break; + case "USEOIT": variant.UseOit = value; break; + case "USESSBO": variant.UseSsbo = value; break; + case "GREEDYMESH": variant.GreedyMesh = value; break; + case "ALLOWDEPTHOFFSET" or "GLOWSUB" or "VEC3SCALE": + if (value != 0) extra.Add("#define " + axis + " 1"); + break; + default: throw new InvalidOperationException("no GLSL 330 mapping for axis " + axis); + } + } + variant.TaaMotionLocation = variant.SsaoLevel > 0 ? 4 : 2; + variant.ExtraPrefix = string.Join("\r\n", extra); + variant.Name = baseName + (axes.Count > 0 ? " [" + NativeShaderManifest.VariantKey(axes.Keys.OrderBy(k => k, StringComparer.Ordinal), axes) + "]" : ""); + return variant; + } + + // ------------------------------------------------------------------ interface extraction + + private static int LocationSpan(string type, int arrayLength) + { + Match matrix = Regex.Match(type, @"^d?mat(\d)(x\d)?$"); + int columns = matrix.Success ? int.Parse(matrix.Groups[1].Value, CultureInfo.InvariantCulture) : 1; + return columns * Math.Max(1, arrayLength); + } + + /// + /// The GLSL 330 stage's vertex inputs or fragment outputs after preprocessing, with the locations the + /// Vulkan path assigns: explicit ones first, then each unlocated declaration takes the lowest free span in + /// declaration order (ProgramInterfaceLayout.AssignInterfaceLocations; for a single unlocated output that + /// is location 0, as GL assigns it). + /// + internal static List<(int Location, string Name, string Type, int ArrayLength)> Glsl330Interface( + ShaderCompiler compiler, ShaderStageSource stage, GlslDeclarationKind kind) + { + ShaderCompileResult preprocessed = compiler.Preprocess(stage.Code, stage.PrefixCode, stage.Filename, stage.Stage); + Assert.True(preprocessed.Success, stage.Filename + ": " + preprocessed.Error); + + var declarations = GlslParser.Parse(preprocessed.PreprocessedText).Declarations.Where(d => d.Kind == kind).ToList(); + var used = new HashSet(); + var result = new List<(int, string, string, int)>(); + foreach (GlslDeclaration declaration in declarations.Where(d => d.Location >= 0)) + { + for (int i = 0; i < LocationSpan(declaration.TypeName, declaration.ArrayLength); i++) used.Add(declaration.Location + i); + result.Add((declaration.Location, declaration.Name, declaration.TypeName, declaration.ArrayLength)); + } + foreach (GlslDeclaration declaration in declarations.Where(d => d.Location < 0)) + { + int span = LocationSpan(declaration.TypeName, declaration.ArrayLength); + int location = 0; + while (Enumerable.Range(location, span).Any(used.Contains)) location++; + for (int i = 0; i < span; i++) used.Add(location + i); + result.Add((location, declaration.Name, declaration.TypeName, declaration.ArrayLength)); + } + return result.OrderBy(entry => entry.Item1).ToList(); + } + + private static string Describe(IEnumerable entries) => "[" + string.Join(", ", entries) + "]"; + + private static void CompareLists(List failures, string label, string what, IList native, IList glsl330) + { + if (native.SequenceEqual(glsl330)) return; + failures.Add(label + ": " + what + " differ: native " + Describe(native) + ", GLSL 330 " + Describe(glsl330) + + "; only native " + Describe(native.Except(glsl330)) + ", only GLSL 330 " + Describe(glsl330.Except(native))); + } + + /// The include file names a program's two stages pull in, transitively (the builder's own expansion). + internal static SortedSet IncludesOf(string program) + { + var included = new SortedSet(StringComparer.Ordinal); + foreach (string extension in new[] { ".vert", ".frag" }) + { + NativeShaderBuilder.ExpandIncludes(Path.Combine(SourceDirectory, program + extension), SourceDirectory, included); + } + return included; + } + + internal static List PortsOf(IEnumerable includes) => + includes.Where(name => File.Exists(Path.Combine(NativeShaderTree.IncludeDirectory, name))) + .Select(NativeShaderTree.PortOf).Where(port => port != null).Select(port => port!).ToList(); + + // ------------------------------------------------------------------ parity + + [SkippableFact] + public void TheNativeTreeBuildsWithoutErrors() + { + NativeShaderBuildResult result = RequireBuild(); + Assert.True(result.Success, string.Join("\n", result.Errors)); + Assert.NotEmpty(result.Manifest.Programs); + } + + /// + /// Section 2, per program and per variant: the name set with its GLSL types, the sampler order, the push + /// block limit, the include port headers' program uniforms, and - under every base variant - the vertex + /// inputs and fragment outputs. + /// + [SkippableTheory] + [MemberData(nameof(Programs))] + public void TheNativeProgramMatchesItsGlsl330Program(string program) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + NativeShaderBuildResult result = RequireBuild(); + List errors = ErrorsOf(result, program); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + NativeProgram native = result.Manifest.FindProgram(program) + ?? throw new InvalidOperationException(program + " is missing from the manifest"); + + Dictionary files = ShaderCorpus.LoadShaderFiles(); + Dictionary includes = ShaderCorpus.LoadIncludes(); + Assert.True(files.ContainsKey(program + ".vsh") && files.ContainsKey(program + ".fsh"), + program + ": no GLSL 330 program " + program + ".vsh/.fsh to compare with (a native program is named after the GLSL 330 files it replaces)"); + + // The name set does not depend on defines: the oracle reads unpreprocessed text. + Oracle oracle = CollectOracle(ShaderCorpus.BuildProgram(program, files, includes, VariantFor("everything-off", new Dictionary()))); + List ports = PortsOf(IncludesOf(program)); + + Skip.IfNot(NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason), reason); + var failures = new List(); + using (compiler) + { + foreach (NativeVariant variant in native.Variants) + { + string label = program + (variant.Key.Length > 0 ? " [" + variant.Key + "]" : " [no axes]"); + CheckNames(failures, label, variant, oracle, ports); + CheckSamplerOrder(failures, label, variant, oracle); + CheckPortUniforms(failures, label, variant, ports); + if (variant.Push != null && variant.Push.Size > SetConvention.PushConstantBytes) + { + failures.Add(label + ": push block is " + variant.Push.Size + " B, the limit is " + SetConvention.PushConstantBytes); + } + + Dictionary axes = ParseKey(variant.Key); + foreach (string baseName in BaseVariants) + { + ShaderCorpus.ShaderVariant defines = VariantFor(baseName, axes); + List stages = ShaderCorpus.BuildProgram(program, files, includes, defines); + string where = label + " vs GLSL 330 " + defines.Name; + + var inputs = Glsl330Interface(compiler!, stages.Single(s => s.Stage == EnumShaderType.VertexShader), GlslDeclarationKind.Input); + CompareLists(failures, where, "vertex inputs (location type)", + variant.VertexInputs.OrderBy(v => v.Location).Select(v => v.Location + " " + v.Type + (v.ArrayLength != 0 ? "[" + v.ArrayLength + "]" : "")).ToList(), + inputs.Select(v => v.Location + " " + v.Type + (v.ArrayLength != 0 ? "[" + v.ArrayLength + "]" : "")).ToList()); + + var outputs = Glsl330Interface(compiler!, stages.Single(s => s.Stage == EnumShaderType.FragmentShader), GlslDeclarationKind.Output); + CompareLists(failures, where, "fragment outputs (location name)", + variant.FragmentOutputs.OrderBy(v => v.Location).Select(v => v.Location + " " + v.Name).ToList(), + outputs.Select(v => v.Location + " " + v.Name).ToList()); + } + } + } + + Assert.True(failures.Count == 0, string.Join("\n", failures)); + } + + /// Frame members through owner includes, frame textures of the ported includes, push and record members, sampler names. + private static void CheckNames(List failures, string label, NativeVariant variant, Oracle oracle, List ports) + { + var native = new SortedDictionary>(StringComparer.Ordinal); + void Add(string name, string type, string source) + { + if (!native.TryGetValue(name, out SortedSet? types)) native[name] = types = new SortedSet(StringComparer.Ordinal); + if (types.Count > 0 && !types.Contains(type)) failures.Add(label + ": '" + name + "' is declared twice with different types (" + string.Join("/", types) + " and " + type + " from " + source + ")"); + types.Add(type); + } + + foreach (string member in variant.FrameMembers) + { + Assert.True(FrameGlobals.TryGetMember(member, out UniformMember frame), member + " is not a FrameGlobals member"); + Add(member, frame.Type.Name, "the frame block"); + } + foreach (NativeShaderTree.Port port in ports) + { + foreach (NativeShaderTree.Declaration texture in port.FrameTextures) Add(texture.Name, texture.Type, port.Include); + } + var slots = variant.Samplers.Select(s => s.Name).ToHashSet(StringComparer.Ordinal); + foreach (NativeMember member in variant.Push?.Members ?? new List()) + { + if (!slots.Contains(member.Name)) Add(member.Name, member.Type, "the push block"); + } + foreach (NativeMember member in variant.Record?.Members ?? new List()) Add(member.Name, member.Type, "the record"); + foreach (NativeSampler sampler in variant.Samplers) Add(sampler.Name, sampler.GlslType, "a sampler slot"); + + var onlyNative = native.Keys.Except(oracle.Names.Keys).ToList(); + var onlyGlsl330 = oracle.Names.Keys.Except(native.Keys).ToList(); + var typeDiffers = native.Keys.Intersect(oracle.Names.Keys) + .Where(name => !native[name].SetEquals(oracle.Names[name])) + .Select(name => name + " native " + string.Join("/", native[name]) + " GLSL 330 " + string.Join("/", oracle.Names[name])) + .ToList(); + if (onlyNative.Count + onlyGlsl330.Count + typeDiffers.Count > 0) + { + failures.Add(label + ": uniform names differ from collectUniformNames: only native " + Describe(onlyNative) + + ", only GLSL 330 " + Describe(onlyGlsl330) + ", type differs " + Describe(typeDiffers)); + } + } + + /// The sampler order is the texture unit collectUniformNames assigns. + private static void CheckSamplerOrder(List failures, string label, NativeVariant variant, Oracle oracle) + { + CompareLists(failures, label, "samplers (texture unit name)", + variant.Samplers.OrderBy(s => s.Order).Select(s => s.Order + " " + s.Name).ToList(), + oracle.TextureLocations.Where(t => variant.Samplers.Any(s => s.Name == t.Key) || !IsFrameTexture(t.Key)) + .OrderBy(t => t.Value).Select((t, index) => index + " " + t.Key).ToList()); + + var duplicateUnits = oracle.TextureLocations.GroupBy(t => t.Value).Where(g => g.Count() > 1).Select(g => g.Key + ": " + string.Join("/", g.Select(t => t.Key))).ToList(); + if (duplicateUnits.Count > 0) failures.Add(label + ": the GLSL 330 program assigns one texture unit to several samplers " + Describe(duplicateUnits)); + } + + private static bool IsFrameTexture(string name) => SetConvention.FrameTextures.Any(binding => binding.Name == name); + + /// + /// Every program uniform an included port's header lists is a frame member when the program includes that + /// name's owner, and otherwise a push or record member with the header's type. + /// + private static void CheckPortUniforms(List failures, string label, NativeVariant variant, List ports) + { + var members = (variant.Push?.Members ?? new List()).Concat(variant.Record?.Members ?? new List()) + .ToDictionary(m => m.Name, m => m, StringComparer.Ordinal); + foreach (NativeShaderTree.Port port in ports) + { + foreach (NativeShaderTree.Declaration uniform in port.ProgramUniforms) + { + if (variant.FrameMembers.Contains(uniform.Name)) continue; + if (!members.TryGetValue(uniform.Name, out NativeMember? member)) + { + failures.Add(label + ": " + port.Include + " needs program uniform '" + uniform.Text + "' in the push block or record"); + continue; + } + string declared = member.Type + " " + member.Name + (member.ArrayLength > 0 ? "[" + member.ArrayLength + "]" : ""); + string wanted = Regex.Replace(uniform.Text, @"\[\s*([^\]]*?)\s*\]", m => + GlslParser.TryEvaluateConstantInt(m.Groups[1].Value, out int length) ? "[" + length + "]" : m.Value); + if (declared != wanted) failures.Add(label + ": " + port.Include + " needs '" + wanted + "', the program declares '" + declared + "'"); + } + } + } + + // ------------------------------------------------------------------ compile + + /// + /// Every variant of the program is built (2^axes of them), every shipped module passes spirv-val when it is on + /// PATH, and the push block and record reflect exactly as the source declares them: the same members in the + /// same order with scalar-layout offsets. + /// + [SkippableTheory] + [MemberData(nameof(Programs))] + public void EveryVariantCompilesValidatesAndReflectsItsDeclaredBlocks(string program) + { + NativeShaderBuildResult result = RequireBuild(); + List errors = ErrorsOf(result, program); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + NativeProgram native = result.Manifest.FindProgram(program) + ?? throw new InvalidOperationException(program + " is missing from the manifest"); + Assert.Equal(1 << native.Axes.Count, native.Variants.Count); + + string? spirvVal = FindOnPath("spirv-val"); + var failures = new List(); + Skip.IfNot(NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason), reason); + using (compiler) + { + foreach (NativeVariant variant in native.Variants) + { + string label = program + (variant.Key.Length > 0 ? " [" + variant.Key + "]" : " [no axes]"); + Dictionary axes = ParseKey(variant.Key); + string prefix = string.Concat(axes.OrderBy(a => a.Key, StringComparer.Ordinal).Select(a => "#define " + a.Key + " " + a.Value + "\n")); + + Assert.Equal(new[] { "vertex", "fragment" }, variant.Stages.Select(s => s.Stage)); + foreach (NativeStage stage in variant.Stages) + { + if (spirvVal != null) Validate(failures, label, spirvVal, stage.Spirv, result.Files[stage.Spirv]); + + EnumShaderType type = stage.Stage == "vertex" ? EnumShaderType.VertexShader : EnumShaderType.FragmentShader; + string expanded = NativeShaderBuilder.ExpandIncludes(Path.Combine(SourceDirectory, stage.Source), SourceDirectory, new HashSet()); + ShaderCompileResult preprocessed = compiler!.Preprocess(expanded, prefix, stage.Source, type); + Assert.True(preprocessed.Success, label + " " + stage.Source + ": " + preprocessed.Error); + + CheckDeclaredBlock(failures, label + " " + stage.Source + " push block", DeclaredBlock(preprocessed.PreprocessedText, push: true), variant.Push); + CheckDeclaredBlock(failures, label + " " + stage.Source + " record", DeclaredBlock(preprocessed.PreprocessedText, push: false), variant.Record); + } + } + } + if (spirvVal == null) Console.WriteLine("spirv-val is not on PATH; the SPIR-V validation part of " + program + " was skipped"); + + Assert.True(failures.Count == 0, string.Join("\n", failures)); + } + + private static readonly Regex BlockDeclaration = new(@"layout\s*\(([^)]*)\)\s*uniform\s+(\w+)\s*\{([^}]*)\}", RegexOptions.Singleline); + private static readonly Regex MemberDeclaration = new(@"^\s*(\w+)\s+(\w+)\s*(?:\[\s*(\d+)\s*\])?\s*$"); + + /// The members (type, name, array length) of the push block or the record as the preprocessed source declares them, or null. + internal static List<(string Type, string Name, int ArrayLength)>? DeclaredBlock(string preprocessed, bool push) + { + foreach (Match block in BlockDeclaration.Matches(preprocessed)) + { + string layout = Regex.Replace(block.Groups[1].Value, @"\s+", ""); + bool isPush = layout.Split(',').Contains("push_constant"); + bool isRecord = layout.Split(',').Contains("set=" + SetConvention.StorageSet) && + layout.Split(',').Contains("binding=" + SetConvention.ProgramRecordBinding); + if (push ? !isPush : !isRecord) continue; + + var members = new List<(string, string, int)>(); + foreach (string statement in block.Groups[3].Value.Split(';')) + { + if (statement.Trim().Length == 0) continue; + Match member = MemberDeclaration.Match(statement); + Assert.True(member.Success, "cannot read block member '" + statement.Trim() + "'"); + members.Add((member.Groups[1].Value, member.Groups[2].Value, + member.Groups[3].Success ? int.Parse(member.Groups[3].Value, CultureInfo.InvariantCulture) : 0)); + } + return members; + } + return null; + } + + /// Bytes of one element under GL_EXT_scalar_block_layout, where every member aligns to its 4-byte scalar. + private static int ScalarSize(string type) + { + Match vector = Regex.Match(type, @"^[iub]?vec(\d)$"); + if (vector.Success) return 4 * int.Parse(vector.Groups[1].Value, CultureInfo.InvariantCulture); + Match matrix = Regex.Match(type, @"^mat(\d)(?:x(\d))?$"); + if (matrix.Success) + { + int columns = int.Parse(matrix.Groups[1].Value, CultureInfo.InvariantCulture); + int rows = matrix.Groups[2].Success ? int.Parse(matrix.Groups[2].Value, CultureInfo.InvariantCulture) : columns; + return 4 * columns * rows; + } + return type is "float" or "int" or "uint" or "bool" ? 4 : throw new InvalidOperationException("no scalar size for " + type); + } + + private static void CheckDeclaredBlock(List failures, string label, List<(string Type, string Name, int ArrayLength)>? declared, NativeBlock? reflected) + { + if (declared == null || reflected == null) + { + if ((declared == null) != (reflected == null)) failures.Add(label + ": declared " + (declared != null) + ", reflected " + (reflected != null)); + return; + } + + int offset = 0; + var expected = new List(); + foreach ((string type, string name, int arrayLength) in declared) + { + offset = (offset + 3) & ~3; + int size = ScalarSize(type) * Math.Max(1, arrayLength); + expected.Add(type + " " + name + (arrayLength != 0 ? "[" + arrayLength + "]" : "") + " @" + offset + " " + size); + offset += size; + } + CompareLists(failures, label, "members (type name @offset size)", + reflected.Members.Select(m => m.Type + " " + m.Name + (m.ArrayLength != 0 ? "[" + m.ArrayLength + "]" : "") + " @" + m.Offset + " " + m.Size).ToList(), + expected); + if (reflected.Size != offset) failures.Add(label + ": reflected size " + reflected.Size + " B, declared members end at " + offset + " B"); + } + + private static string? FindOnPath(string tool) + { + foreach (string directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)) + { + if (directory.Length == 0) continue; + string candidate = Path.Combine(directory, OperatingSystem.IsWindows() ? tool + ".exe" : tool); + if (File.Exists(candidate)) return candidate; + } + return null; + } + + private static void Validate(List failures, string label, string spirvVal, string name, byte[] spirv) + { + string path = Path.Combine(Path.GetTempPath(), "optimum-native-parity-" + Guid.NewGuid().ToString("N") + ".spv"); + try + { + File.WriteAllBytes(path, spirv); + var start = new ProcessStartInfo(spirvVal) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + // The environment the renderer creates: Vulkan 1.3 with scalarBlockLayout, which the device floor + // requires and the device enables (Core/VulkanContext.cs: the floor lists a missing scalarBlockLayout, + // device creation sets ScalarBlockLayout = true in the Vulkan 1.2 features). Every push block and + // record is scalar-laid-out (contract section 4), so without the flag + // spirv-val checks a device this renderer never runs on. + start.ArgumentList.Add("--target-env"); + start.ArgumentList.Add("vulkan1.3"); + start.ArgumentList.Add("--scalar-block-layout"); + start.ArgumentList.Add(path); + using Process process = Process.Start(start)!; + string output = process.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode != 0) failures.Add(label + ": spirv-val rejects " + name + ":\n" + output); + } + finally + { + File.Delete(path); + } + } +} diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 1a18d4a1..22d93014 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -17,7 +17,16 @@ Inputs: (`chunkopaque`, `taa-resolve`, ...). Never `.vsh`/`.fsh` (`SetConventionTests` enforces it), so no packager glob over `sources/shaders/` can pick them up. - **Per-program interface:** `sources/shaders-vk/.interface.glsl` declares the program's push block - and record (section 4). Both stages include it, so the two declarations cannot drift. + and record (section 4). Both stages include it, so the two declarations cannot drift. It is included right + after `specialization.glsl` (it uses `OPTIMUM_SAMPLER_SLOT` and the set/binding defines) and needs no guard. +- **What the rewriter did that a native stage now does itself** (settled by the family 1 pilot, 2026-09-15): + - the last vertex stage ends `main` with `gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;`, the GL to + Vulkan clip-depth remap `ShaderRewriter` wraps around every GLSL 330 `main`. No Y flip and no `gl_FragCoord` + change: the rewriter makes none, so `gl_FragCoord` reads stay as written; + - `gl_VertexID` is spelled `gl_VertexIndex`; + - every varying gets an explicit location (0-15), both stages declaring the same one; + - an uninitialised-by-design local that a spec-constant branch assigns is declared before it with a value no + path reads (section 5). - **Shared includes:** `sources/shaders-vk/include/*.glsl`, one per game include, same base name (`fogandlight.frag.glsl`, `fogandlight.vert.glsl`, `vertexwarp.glsl`, `shadowcoords.glsl`, `colormap.vert.glsl`, `colormap.frag.glsl`, `dither.glsl`, `skycolor.glsl`, `underwatereffects.glsl`, `noise2d.glsl`, `noise3d.glsl`, @@ -88,6 +97,46 @@ A static parity test per program per variant (names, sampler order, inputs, outp `ShaderCorpus` on the GLSL 330 side and the manifest on the native side. It needs no GPU and is part of every family stage. +**The harness (delivered 2026-09-15 with the family 1 pilot):** `Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs`, +data-driven over every `.vert`/`.frag` pair in `sources/shaders-vk`. A family stage adds shaders and +never touches the test. Per program it: +- builds the manifest for the tree through `NativeShaderBuilder.Build` (the library entry point the tool runs), and + fails on any build error of the program; +- compares against the GLSL 330 files of the same base name (`.vsh`/`.fsh`, `sources/shaders` override + else vanilla asset). A native program is named after the GLSL 330 files it replaces, never after a registration + alias (`Chunkshadowmap_NoSSBOs` is `chunkshadowmap` with `USESSBO=0`); +- **names and types:** reproduces `collectUniformNames` (pattern, options, stage order) over each stage's + include-expanded, unpreprocessed `Code`, and requires it to equal the native set. The native set is + `frameMembers` + the port headers' `optimum-frame-texture` names of the includes the program pulls in + non-slot + push members + record members + sampler slots (with their `OPTIMUM_SAMPLER_SLOT` type); +- **samplers:** the native slot order equals the oracle's `textureLocations` order with the set 0 frame textures + removed, and no two GLSL 330 samplers share a unit; +- **port headers:** every `optimum-program-uniform` of every included port is a frame member (its owner is included) + or a push/record member with the header's type and array length; +- **push block** at most 128 B; +- **inputs and outputs, per variant and per base:** the variant's axes are mapped back to prefix defines + (`TAAMOTION`, `GBUFFER` as `SSAOLEVEL` 0 or at least 1, `TAAMOTIONLOCATION` = 4 with the G-buffer else 2, `USEOIT`, + `USESSBO`, `GREEDYMESH`; `ALLOWDEPTHOFFSET`/`GLOWSUB`/`VEC3SCALE` as `#define X 1` in the program's own prefix) + on top of each of three `ShaderCorpus` bases (`everything-off`, `everything-on`, `taa-with-ssao`). The GLSL 330 + stage is preprocessed and parsed; vertex inputs compare as (location, type), fragment outputs as (location, + name). Unlocated GLSL 330 declarations take the lowest free span in declaration order, as + `ProgramInterfaceLayout` assigns them. Because the non-axis defines differ between the bases, a define that + changes a GLSL 330 declaration but is not an axis of the native program fails here; +- **compile:** all 2^axes variants exist, every shipped module passes `spirv-val --target-env vulkan1.3 + --scalar-block-layout` when `spirv-val` is on PATH (the device floor requires and enables `scalarBlockLayout`; + without the flag every scalar record is rejected as straddling), and the push block and record reflect exactly + as the preprocessed source declares them: same members in order, scalar-layout offsets, size. + +Failures name program, variant key and base, and list the entries only on one side. + +**Oracle quirks the harness reproduces rather than hides** (a family that meets one decides it in its stage and +records the decision here): +- the pattern sees commented-out and `#if 0` uniforms, so they are names; +- a type outside its list (`sampler2DArray`, `uint`, `bool`, `usampler2D`, ...) is invisible to it. Worse, + `sampler2DArray OITaccumulation` matches as type `sampler2D` with the name `Array` (`transparentcompose.fsh`, + family 6); +- a sampler name seen twice is reassigned the current unit count. + ## 3. Descriptor use - **Set 0 (frame):** `frame.glsl` declares the FrameGlobals UBO at `OPTIMUM_BINDING_FRAME_GLOBALS` (scalar @@ -353,3 +402,39 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg - `OPTIMUM_VK_NATIVE_SHADERS=0` forces the rewriter for A/B runs. - `OPTIMUM_VK_SHADER_SOURCE=` compiles the source tree at runtime for the development loop. - **Mod shaders:** they stay on the rewriter, retargeted to the same shared layout (handoff item 4, first half). +- **Initializers:** a block member cannot carry a GLSL 330 initializer (`uniform float maxlight = 1;`), and + some are never set by the client (`final`'s `minlight`, `maxlight`, `minsat`, `maxsat`; a zero `maxlight` + divides by zero in `ColorGrade`). On a hit the runtime seeds the push shadow and the record from the GLSL 330 + declarations' initializers, which it holds at the seam, the way `ProgramInterfaceLayout.WriteInitializer` + seeds the rewriter's block. The manifest does not carry them (found by the family 1 pilot, 2026-09-15). + +## 9. Adding a family + +Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage touches only +`sources/shaders-vk/` and this document; the parity harness (section 2) picks the programs up by itself. + +1. **Read the effective GLSL 330 sources:** `sources/shaders/.vsh/.fsh` when present, else the vanilla + asset, plus the includes they pull in. List every uniform in declaration order per stage, the sampler order, + the vertex inputs and fragment outputs, and every `#if` with the define it tests. +2. **Classify the defines** (section 5): a define that gates a declaration (input, output, uniform, buffer, + varying) is an axis and stays `#if AXIS == 1` / `#if GBUFFER` by value. Every other one becomes + `if (OPTIMUM_X ...)` with the same comparison, the gated declarations unconditional. +3. **Write `.interface.glsl`** (section 4): `OPTIMUM_SAMPLER_SLOT(, )` for every + non-frame sampler in GLSL 330 declaration order (vertex stage first, then fragment), then DRAW uniforms that fit; + the record holds the rest, vertex-stage uniforms first, each in declaration order. Leave out frame members whose + owner the program includes, and list each included port's `optimum-program-uniform` names that are not. +4. **Write `.vert` and `.frag`:** `#version 450`, the two extensions, then `bindings.glsl`, + `frame.glsl`, `specialization.glsl` and the interface, with any `OPTIMUM_FRAME_OWNER_*` a cross-stage name + needs defined first (section 3). Keep the bodies token for token except: `texture(name, ...)` becomes + `texture(optimumTextures[name], ...)`, a sampler passed to a function becomes the indexed array + element, `gl_VertexID` becomes `gl_VertexIndex`, `#if` on a constant becomes a branch, the vertex stage ends + with the depth remap (section 1), varyings get locations, and `#include x.fsh` becomes `#include "x.glsl"`. +5. **Run** `dotnet test Optimum.Render.Vulkan.Tests --filter "FullyQualifiedName~NativeShaderParityTests"`. It + fails with the program, variant key and GLSL 330 base, and the entries on only one side. A harness failure is + fixed in the shader, never by changing the harness for one program; an oracle quirk (section 2) is decided and + written down here first. +6. **Numeric behaviour** is argued from the diff to the GLSL 330 body (the list in step 4 is the whole allowed + difference). A family whose port needs more than that (a transformed expression, a changed precision) states + its differential GPU test in its stage. +7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from + `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. diff --git a/sources/shaders-vk/blit.frag b/sources/shaders-vk/blit.frag new file mode 100644 index 00000000..e986130f --- /dev/null +++ b/sources/shaders-vk/blit.frag @@ -0,0 +1,19 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of blit.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "blit.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + + +void main(void) +{ + outColor = texture(optimumTextures2D[scene], texCoord); + outColor.a = 1; +} diff --git a/sources/shaders-vk/blit.interface.glsl b/sources/shaders-vk/blit.interface.glsl new file mode 100644 index 00000000..52683c40 --- /dev/null +++ b/sources/shaders-vk/blit.interface.glsl @@ -0,0 +1,6 @@ +// Program interface of blit (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and there is no program record. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, scene); +}; diff --git a/sources/shaders-vk/blit.vert b/sources/shaders-vk/blit.vert new file mode 100644 index 00000000..cf5eaab2 --- /dev/null +++ b/sources/shaders-vk/blit.vert @@ -0,0 +1,23 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of blit.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "blit.interface.glsl" + +layout(location = 0) in vec2 position; + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + texCoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/final.frag b/sources/shaders-vk/final.frag new file mode 100644 index 00000000..3efdbb9c --- /dev/null +++ b/sources/shaders-vk/final.frag @@ -0,0 +1,146 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of final.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// The FXAA, BLOOM, SSAOLEVEL and GODRAYS preprocessor branches are specialization-constant branches +// with the same expressions; nothing they gate is a declaration, so the program has no variant axes. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "final.interface.glsl" + +layout(location = 0) in vec2 texCoord; +layout(location = 1) in vec2 invFrameSize; +layout(location = 2) flat in float godrayIntensity; + +layout(location = 0) out vec4 outColor; + +#include "fxaa.glsl" +#include "colorutil.glsl" +#include "noise3d.glsl" + +// ============================================================ +// Color grading (vanilla, unchanged) +// ============================================================ +float SmoothStep(float x) { return x * x * (3.0 - 2.0 * x); } + +vec4 ColorGrade(vec4 color) { + color.a = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + vec3 hsl = rgb2hsl(color.rgb); + float lightRange = maxlight - minlight; + float satRange = maxsat - minsat; + hsl.z = pow((clamp(hsl.z, minlight, maxlight) - minlight) / lightRange, 1/gammaLevel); + hsl.y = pow((clamp(hsl.y, minsat, maxsat) - minsat) / satRange, 1); + color.rgb = hsl2rgb(hsl); + color.rgb = pow(color.rgb, vec3(1.0 / extraGamma)); + color.rgb *= brightnessLevel; + vec3 sepia = vec3( + (color.r * 0.393) + (color.g * 0.769) + (color.b * 0.189), + (color.r * 0.349) + (color.g * 0.686) + (color.b * 0.168), + (color.r * 0.272) + (color.g * 0.534) + (color.b * 0.131) + ) * 0.85; + color.rgb = mix(color.rgb, sepia, sepiaLevel); + color.rgb = color.rgb * (contrastLevel+1) - contrastLevel; + if (glitchEffectStrength > 0) { + float g = gnoise(vec3(texCoord.x * 2000.0, texCoord.y * 2000.0, mod(windWaveCounter*30, 100))); + color.rgb *= mix(1, clamp(0.7 + g / 2, 0.7, 1), glitchEffectStrength); + vec3 rust = vec3( + (color.r * 0.393) + (color.g * 0.769) + (color.b * 0.189), + (color.r * 0.349) + (color.g * 0.686) + (color.b * 0.168), + (color.r * 0.272) + (color.g * 0.534) + (color.b * 0.131) + ); + float gdiff = min(color.g, 0.1); + float bdiff = min(color.b, 0.1); + rust.g -= gdiff; + rust.b -= bdiff; + rust.r += gdiff + bdiff; + color.rgb = mix(color.rgb, rust, glitchEffectStrength); + color.a += glitchEffectStrength/3; + } + float brt = (color.r + color.b + color.g) / 3; + color.rgb /= max(1, brt); + color.r = min(1, color.r); + color.b = min(1, color.b); + color.g = min(1, color.g); + return color; +} + +// ============================================================ +// Main +// ============================================================ +void main(void) +{ + // Declared before the branch that assigns it (contract section 5); every path overwrites the 0. + vec4 color = vec4(0.0); + if (OPTIMUM_FXAA == 1) { + color = fxaaTexturePixel(optimumTextures2D[primaryScene], texCoord, invFrameSize); + } else { + color = texture(optimumTextures2D[primaryScene], texCoord); + } + + color.a=1; + float bloomSub = 0; + if (OPTIMUM_BLOOM == 1) { + vec4 bloomCol = texture(optimumTextures2D[bloomParts], texCoord); + float glowLevel = texture(optimumTextures2D[glowParts], texCoord).r; + float ambLevel = ambientBloomLevel / 2.0; + color.rgb = (color.rgb + bloomCol.rgb * (ambLevel * 1.5)) / (1 + ambLevel); + bloomSub = glowLevel * (bloomCol.r + bloomCol.b + bloomCol.g); + } + + if (OPTIMUM_SSAOLEVEL > 0) { + // Optimum TAA: skipped when the AO was already multiplied into the scene + // before the resolve, so it is never applied twice. + if (optimumSsaoInScene == 0) { + float ssao = 0.0; + if (OPTIMUM_SSAOLEVEL > 1) { + ssao = min(texture(optimumTextures2D[ssaoScene], texCoord).r, texture(optimumTextures2D[ssaoScene], texCoord - vec2(0, invFrameSize.y*1)).r); + } else { + ssao = texture(optimumTextures2D[ssaoScene], texCoord).r; + } + color.rgb *= min(1, ssao + bloomSub); + } + } + + if (OPTIMUM_GODRAYS > 0) { + vec4 grc = texture(optimumTextures2D[godrayParts], texCoord); + color.rgb += grc.rgb; + color.rgb = min(color.rgb, vec3(1)); + color.a=1; + } + + vec4 gradedColor = ColorGrade(color); + outColor = mix(color, gradedColor, gradedColor.a); + + // Vignetting + vec2 position = (gl_FragCoord.xy * invFrameSize.xy) - vec2(0.5); + float grayvignette = 1 - smoothstep(1.1, 0.75 - 0.45, length(position)); + + if (frostVignetting > 0) { + float str = -0.05 + 1.05*clamp(1 - smoothstep(1.1 - frostVignetting / 4, 0.75 - 0.45, length(position)), 0, 1) - grayvignette; + float wx = gnoise(vec3(gl_FragCoord.x / 20.0, str, gl_FragCoord.x / 11.0 + gl_FragCoord.y / 10.0)); + float wy = gnoise(vec3(gl_FragCoord.x / 20.0, str, gl_FragCoord.x / 10.0 - gl_FragCoord.y / 9.0)); + float g = 2*gnoise(vec3(wx / 3.0, wy / 3.0, 0.2)) + 0.8; + g *= gnoise(vec3(gl_FragCoord.x / 20.0, gl_FragCoord.y / 20.0, 1.5)) + 0.2; + g -= gnoise(vec3(wx * 2.0, wy * 2.0, 1))/5; + g -= str*2; + g *= frostVignetting; + float v = 0.9 + gnoise(vec3(wx, -wy, 0)) / 15.0; + vec3 vignetteColor = vec3(v, v, 0.95); + outColor.rgb = mix(outColor.rgb, vignetteColor, max(0.0, str - g) + 0.5*str); + } + + if (damageVignetting > 0) { + float str = clamp(1 - smoothstep(1.1 - damageVignetting / 4, 0.75 - 0.45, length(position)), 0, 1) - grayvignette; + float g = gnoise(vec3(gl_FragCoord.x / 20.0, gl_FragCoord.y / 20.0, 0)) + 0.5; + g += gnoise(vec3(gl_FragCoord.x / 5.0, gl_FragCoord.y / 5.0, 0))/5; + g -= str*2; + g*=damageVignetting; + vec3 vignetteColor = vec3(0.8 * damageVignetting/2, 0, 0); + float centerness = pow(1 - abs(damageVignettingSide), 3); + float side = clamp(centerness + pow(mix(texCoord.x, 1 - texCoord.x, (1 + damageVignettingSide) / 2), 1.5), 0, 1); + outColor.rgb = mix(outColor.rgb, vignetteColor, max(0.0, str - g) * side); + } + + outColor.a=1; +} diff --git a/sources/shaders-vk/final.interface.glsl b/sources/shaders-vk/final.interface.glsl new file mode 100644 index 00000000..c28f5d38 --- /dev/null +++ b/sources/shaders-vk/final.interface.glsl @@ -0,0 +1,42 @@ +// Program interface of final (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots, in final.fsh's declaration order, and every other +// uniform is a record member: final.vsh's four, then final.fsh's, each in declaration order. +// +// A block member cannot carry the GLSL 330 initializers (extraGamma = 1.0, minlight = 0.0, maxlight = 1, +// minsat = 0, maxsat = 1). The client never sets minlight, maxlight, minsat or maxsat, so the runtime seeds +// the record from the GLSL 330 declarations' initializers (docs/vulkan-native-shaders.md section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, primaryScene); + OPTIMUM_SAMPLER_SLOT(sampler2D, glowParts); + OPTIMUM_SAMPLER_SLOT(sampler2D, bloomParts); + OPTIMUM_SAMPLER_SLOT(sampler2D, godrayParts); + OPTIMUM_SAMPLER_SLOT(sampler2D, ssaoScene); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 invFrameSizeIn; + vec3 sunPosScreenIn; + vec3 sunPos3dIn; + vec3 playerViewVector; + + int optimumSsaoInScene; + + float gammaLevel; + float brightnessLevel; + float contrastLevel; + float sepiaLevel; + float ambientBloomLevel; + float damageVignetting; + float damageVignettingSide; + float frostVignetting; + float extraGamma; + float windWaveCounter; + float glitchEffectStrength; + + float minlight; + float maxlight; + float minsat; + float maxsat; +}; diff --git a/sources/shaders-vk/final.vert b/sources/shaders-vk/final.vert new file mode 100644 index 00000000..047be49f --- /dev/null +++ b/sources/shaders-vk/final.vert @@ -0,0 +1,34 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of final.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "final.interface.glsl" + +layout(location = 0) out vec2 texCoord; +layout(location = 1) out vec2 invFrameSize; +layout(location = 2) flat out float godrayIntensity; + +void main(void) +{ + // https://rauwendaal.net/2014/06/14/rendering-a-screen-covering-triangle-in-opengl/ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + texCoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + invFrameSize = invFrameSizeIn; + + // Copied from godrays.vsh, should be a #include + float sunPlrAngle = 0.5 + 0.25 * (dot(sunPos3dIn, playerViewVector) + 1); + float dawnDuskMul = max(1, 1.75 * (1 - 6*abs(sunPos3dIn.y - 0.22))); + float nightFade = max(0.0, -2*sunPos3dIn.y + 0.4); + + // Intensity is determined by how directly the player is looking at the sun + godrayIntensity = max(0.0, sunPlrAngle * dawnDuskMul - nightFade) / 2; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/luma.frag b/sources/shaders-vk/luma.frag new file mode 100644 index 00000000..081de04b --- /dev/null +++ b/sources/shaders-vk/luma.frag @@ -0,0 +1,23 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of luma.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "luma.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +float luma(vec3 color) { + return dot(color, vec3(0.299, 0.587, 0.114)); +} + +void main(void) +{ + vec4 color = texture(optimumTextures2D[scene], texCoord); + color.a = luma(color.rgb); + outColor = color; +} diff --git a/sources/shaders-vk/luma.interface.glsl b/sources/shaders-vk/luma.interface.glsl new file mode 100644 index 00000000..1bdd0f10 --- /dev/null +++ b/sources/shaders-vk/luma.interface.glsl @@ -0,0 +1,6 @@ +// Program interface of luma (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and there is no program record. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, scene); +}; diff --git a/sources/shaders-vk/luma.vert b/sources/shaders-vk/luma.vert new file mode 100644 index 00000000..d7acce13 --- /dev/null +++ b/sources/shaders-vk/luma.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of luma.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "luma.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + // https://rauwendaal.net/2014/06/14/rendering-a-screen-covering-triangle-in-opengl/ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + texCoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From 438afcdb64c48629faa680ffdb09c9ae948fa6f9 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:26:34 +0200 Subject: [PATCH 161/226] wip(native-shaders): post - 8 of 9 programs ported (ssao, godrays, findbright, blur, bilateralblur, colorgrade, debugdepthbuffer, woittest) Verified: NativeShaderParityTests 23/23 green (16 for this family, spirv-val on PATH); Optimum.Tests -c Release 1184 passed, 34 skipped, 0 failed. The full Optimum.Render.Vulkan.Tests run had not finished at commit time. transparentcompose not shipped: the Array oracle quirk needs a harness mapping; decision recorded in docs/vulkan-native-shaders.md section 10. --- docs/vulkan-native-shaders.md | 40 +++++ sources/shaders-vk/bilateralblur.frag | 53 ++++++ .../shaders-vk/bilateralblur.interface.glsl | 17 ++ sources/shaders-vk/bilateralblur.vert | 36 ++++ sources/shaders-vk/blur.frag | 38 ++++ sources/shaders-vk/blur.interface.glsl | 17 ++ sources/shaders-vk/blur.vert | 36 ++++ sources/shaders-vk/colorgrade.frag | 62 +++++++ sources/shaders-vk/colorgrade.interface.glsl | 26 +++ sources/shaders-vk/colorgrade.vert | 23 +++ sources/shaders-vk/debugdepthbuffer.frag | 33 ++++ .../debugdepthbuffer.interface.glsl | 12 ++ sources/shaders-vk/debugdepthbuffer.vert | 22 +++ sources/shaders-vk/findbright.frag | 22 +++ sources/shaders-vk/findbright.interface.glsl | 13 ++ sources/shaders-vk/findbright.vert | 22 +++ sources/shaders-vk/godrays.frag | 77 ++++++++ sources/shaders-vk/godrays.interface.glsl | 21 +++ sources/shaders-vk/godrays.vert | 40 +++++ sources/shaders-vk/ssao.frag | 170 ++++++++++++++++++ sources/shaders-vk/ssao.interface.glsl | 22 +++ sources/shaders-vk/ssao.vert | 22 +++ sources/shaders-vk/woittest.frag | 32 ++++ sources/shaders-vk/woittest.interface.glsl | 7 + sources/shaders-vk/woittest.vert | 24 +++ 25 files changed, 887 insertions(+) create mode 100644 sources/shaders-vk/bilateralblur.frag create mode 100644 sources/shaders-vk/bilateralblur.interface.glsl create mode 100644 sources/shaders-vk/bilateralblur.vert create mode 100644 sources/shaders-vk/blur.frag create mode 100644 sources/shaders-vk/blur.interface.glsl create mode 100644 sources/shaders-vk/blur.vert create mode 100644 sources/shaders-vk/colorgrade.frag create mode 100644 sources/shaders-vk/colorgrade.interface.glsl create mode 100644 sources/shaders-vk/colorgrade.vert create mode 100644 sources/shaders-vk/debugdepthbuffer.frag create mode 100644 sources/shaders-vk/debugdepthbuffer.interface.glsl create mode 100644 sources/shaders-vk/debugdepthbuffer.vert create mode 100644 sources/shaders-vk/findbright.frag create mode 100644 sources/shaders-vk/findbright.interface.glsl create mode 100644 sources/shaders-vk/findbright.vert create mode 100644 sources/shaders-vk/godrays.frag create mode 100644 sources/shaders-vk/godrays.interface.glsl create mode 100644 sources/shaders-vk/godrays.vert create mode 100644 sources/shaders-vk/ssao.frag create mode 100644 sources/shaders-vk/ssao.interface.glsl create mode 100644 sources/shaders-vk/ssao.vert create mode 100644 sources/shaders-vk/woittest.frag create mode 100644 sources/shaders-vk/woittest.interface.glsl create mode 100644 sources/shaders-vk/woittest.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 22d93014..94c74e38 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -438,3 +438,43 @@ Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage its differential GPU test in its stage. 7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. + +## 10. Family decisions + +### Family post (`ssao`, `godrays`, `findbright`, `blur`, `bilateralblur`, `colorgrade`, `transparentcompose`, `debugdepthbuffer`, `woittest`), 2026-09-15 + +All are one draw per `Use()`: the push block holds only sampler slots (none for `woittest`), everything else is record. +Beyond the step 4 list, the ports differ from GLSL 330 in these places only. None of them changes a pixel, so +the family adds no differential GPU test. + +- **`transparentcompose`: the `Array` quirk.** `collectUniformNames` reads `uniform sampler2DArray OITaccumulation` as + a `sampler2D` named `Array` at unit 4. + - **Decision:** the native slot is `OPTIMUM_SAMPLER_SLOT(sampler2DArray, OITaccumulation)`, fifth in the push block, + the unit the oracle gives `Array`. The native name set is the oracle's with `Array`/`sampler2D` read as + `OITaccumulation`/`sampler2DArray`. + - **Why the quirk is not reproduced:** no client call uses the name `Array`. + `ShaderProgramTransparentcompose.OITaccumulation2D` binds `"OITaccumulation"` at unit 4. + `SystemRenderOITLayers` points `"OITaccumulation"` at unit 7 through `SetProgramSamplerUnit`, and the device + resolves that by name. + - **No shader-side form exists:** a slot named `Array` must be declared `sampler2D` to match the oracle, and the + compiler rejects a `sampler2D` slot that indexes `optimumTextures2DArray` (section 6). + - **Needed:** the parity harness has to apply this mapping for any `uniform sampler2DArray `. + - **Status: not shipped yet.** The port compiles, passes `spirv-val` and reflects in all four variants + (`GBUFFER`, `TAAMOTION`). Its only parity failure is exactly this name and unit-4 entry. + - **Motion:** it writes `optimumWriteReactiveOnly(clamp(anet, 0.0, 1.0))` at location 4 with the G-buffer, else 2. + The merge's additive (ONE, ONE) blend is unchanged. +- **`blur`, `bilateralblur`: fragment inputs.** + - The unused fragment input `in vec2 frameSize` is dropped. No vertex stage writes it, nothing reads it, and it + would redeclare the record member `frameSize`. + - `texCoords[21]` and `texCoords[11]` start at location 0 and span past `OPTIMUM_LOCATION_PROGRAM_END`. Neither + program includes a file that `varyings.glsl` places at 16 and above, and 21 locations fit the 29 that Intel's + Mesa driver reports. + - `blur.fsh` reads `texCoords[16]`, which `blur.vsh` never writes. That read is undefined on both APIs, and the + port keeps it as the rewriter does. +- **`ssao`:** + - The loop local `sample` becomes `samplePos`: `sample` is a reserved word in GLSL 450. + - `SSAOLEVEL == 2` becomes a specialization-constant ternary for `kernelSize` and a branch for the lower clamp. + - `TAAMOTION` stays an axis for the temporal dither step. + - `temporalFrameIndex` is declared in the record unconditionally: the oracle sees it in every variant. +- **`colorgrade`:** the initializers of `minlight`, `maxlight`, `minsat` and `maxsat` come from the runtime seed + (section 8), as for `final`. diff --git a/sources/shaders-vk/bilateralblur.frag b/sources/shaders-vk/bilateralblur.frag new file mode 100644 index 00000000..0803f32c --- /dev/null +++ b/sources/shaders-vk/bilateralblur.frag @@ -0,0 +1,53 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of bilateralblur.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "bilateralblur.interface.glsl" + +// bilateralblur.fsh's `in vec2 frameSize;` is dropped: see bilateralblur.interface.glsl. +layout(location = 0) in vec2 texCoords[11]; + +layout(location = 0) out vec4 outColor; + +// For info on the bilateral blur see https://www.gamasutra.com/blogs/PeterWester/20140116/208742/Generating_smooth_and_cheap_SSAO_using_Temporal_Blur.php +// http://dev.theomader.com/gaussian-kernel-calculator/ +void main(void) +{ + vec4 out_colour = vec4(0.0); + + float refDepth = texture(optimumTextures2D[depthTexture], texCoords[5]).r; + float fac = 300; + + + float w0 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[0]).r - refDepth) * fac, 0, 1)) * 0.003456; + float w1 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[1]).r - refDepth) * fac, 0, 1)) * 0.015715; + float w2 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[2]).r - refDepth) * fac, 0, 1)) * 0.051008; + float w3 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[3]).r - refDepth) * fac, 0, 1)) * 0.118235; + float w4 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[4]).r - refDepth) * fac, 0, 1)) * 0.195779; + float w5 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[5]).r - refDepth) * fac, 0, 1)) * 0.231613; + float w6 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[6]).r - refDepth) * fac, 0, 1)) * 0.195779; + float w7 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[7]).r - refDepth) * fac, 0, 1)) * 0.118235; + float w8 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[8]).r - refDepth) * fac, 0, 1)) * 0.051008; + float w9 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[9]).r - refDepth) * fac, 0, 1)) * 0.015715; + float w10 = (1 - clamp(abs(texture(optimumTextures2D[depthTexture], texCoords[10]).r - refDepth) * fac, 0, 1)) * 0.003456; + + float wsum = w0 + w1 + w2 + w3 + w4 + w5 + w6 + w7 + w8 + w9 + w10; + + out_colour += texture(optimumTextures2D[inputTexture], texCoords[0]) * w0; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[1]) * w1; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[2]) * w2; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[3]) * w3; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[4]) * w4; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[5]) * w5; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[6]) * w6; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[7]) * w7; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[8]) * w8; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[9]) * w9; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[10]) * w10; + + outColor = out_colour / wsum; + //outColor = out_colour / 1; // Borderlands mode XD +} diff --git a/sources/shaders-vk/bilateralblur.interface.glsl b/sources/shaders-vk/bilateralblur.interface.glsl new file mode 100644 index 00000000..05c321f9 --- /dev/null +++ b/sources/shaders-vk/bilateralblur.interface.glsl @@ -0,0 +1,17 @@ +// Program interface of bilateralblur (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw +// per Use(), so the push block holds only the sampler slots and bilateralblur.vsh's two uniforms are the record. +// +// bilateralblur.fsh also declares an input named frameSize that no vertex stage writes and nothing reads. +// The record's frameSize is a global name in both stages, so that input would redeclare it: the fragment +// stage drops it (docs/vulkan-native-shaders.md, "Family post"). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, inputTexture); + OPTIMUM_SAMPLER_SLOT(sampler2D, depthTexture); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 frameSize; + int isVertical; +}; diff --git a/sources/shaders-vk/bilateralblur.vert b/sources/shaders-vk/bilateralblur.vert new file mode 100644 index 00000000..4491bd74 --- /dev/null +++ b/sources/shaders-vk/bilateralblur.vert @@ -0,0 +1,36 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of bilateralblur.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "bilateralblur.interface.glsl" + +layout(location = 0) out vec2 texCoords[11]; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + vec2 texCoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + if (isVertical == 1) { + float pixelSize = 1.0 / frameSize.y; + + for (int i = -5; i < 5; i++) { + texCoords[i + 5] = texCoord + vec2(0, pixelSize * i); + } + + } else { + float pixelSize = 1.0 / frameSize.x; + + for (int i = -5; i < 5; i++) { + texCoords[i + 5] = texCoord + vec2(pixelSize * i, 0); + } + } + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/blur.frag b/sources/shaders-vk/blur.frag new file mode 100644 index 00000000..3ae0a19c --- /dev/null +++ b/sources/shaders-vk/blur.frag @@ -0,0 +1,38 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of blur.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "blur.interface.glsl" + +// blur.fsh's `in vec2 frameSize;` is dropped: see blur.interface.glsl. +layout(location = 0) in vec2 texCoords[21]; + +layout(location = 0) out vec4 outColor; + +// http://dev.theomader.com/gaussian-kernel-calculator/ +void main(void) +{ + vec4 out_colour = vec4(0.0); + out_colour += texture(optimumTextures2D[inputTexture], texCoords[0]) * 0.001422; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[1]) * 0.004255; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[2]) * 0.011001; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[3]) * 0.024574; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[4]) * 0.047431; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[5]) * 0.0791; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[6]) * 0.113978; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[7]) * 0.141908; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[8]) * 0.152663; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[9]) * 0.141908; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[10]) * 0.113978; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[11]) * 0.0791; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[12]) * 0.047431; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[13]) * 0.024574; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[14]) * 0.011001; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[15]) * 0.004255; + out_colour += texture(optimumTextures2D[inputTexture], texCoords[16]) * 0.001422; + + outColor = out_colour; +} diff --git a/sources/shaders-vk/blur.interface.glsl b/sources/shaders-vk/blur.interface.glsl new file mode 100644 index 00000000..cec5cc15 --- /dev/null +++ b/sources/shaders-vk/blur.interface.glsl @@ -0,0 +1,17 @@ +// Program interface of blur (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and blur.vsh's two uniforms are the record. +// +// blur.fsh also declares an input named frameSize that no vertex stage writes and nothing reads. The +// record's frameSize is a global name in both stages, so that input would redeclare it: the fragment stage +// drops it (docs/vulkan-native-shaders.md, "Family post"). texCoords[21] spans locations 0-20; the program +// includes none of the includes varyings.glsl places at 16 and above. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, inputTexture); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 frameSize; + int isVertical; +}; diff --git a/sources/shaders-vk/blur.vert b/sources/shaders-vk/blur.vert new file mode 100644 index 00000000..aced3515 --- /dev/null +++ b/sources/shaders-vk/blur.vert @@ -0,0 +1,36 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of blur.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "blur.interface.glsl" + +layout(location = 0) out vec2 texCoords[21]; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + vec2 texCoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + if (isVertical == 1) { + float pixelSize = 1.0 / frameSize.y; + + for (int i = -8; i < 8; i++) { + texCoords[i + 8] = texCoord + vec2(0, pixelSize * i); + } + + } else { + float pixelSize = 1.0 / frameSize.x; + + for (int i = -8; i < 8; i++) { + texCoords[i + 8] = texCoord + vec2(pixelSize * i, 0); + } + } + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/colorgrade.frag b/sources/shaders-vk/colorgrade.frag new file mode 100644 index 00000000..04af1c4c --- /dev/null +++ b/sources/shaders-vk/colorgrade.frag @@ -0,0 +1,62 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of colorgrade.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "colorgrade.interface.glsl" + +layout(location = 1) in vec2 invFrameSize; +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +#include "fxaa.glsl" +#include "colorutil.glsl" + +float SmoothStep(float x) { return x * x * (3.0f - 2.0f * x); } + +vec4 ColorGrade(vec4 color) { + // I don't know why, but this seems to make the scene look a lot better + color.a = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + + vec3 hsl = rgb2hsl(color.rgb); + + float lightRange = maxlight - minlight; + float satRange = maxsat - minsat; + + hsl.z = pow((clamp(hsl.z, minlight, maxlight) - minlight) / lightRange, 1/gammaLevel); + hsl.y = pow((clamp(hsl.y, minsat, maxsat) - minsat) / satRange, 1); + + color.rgb = hsl2rgb(hsl); + + //c.rgb *= brightnessLevel; + + // Sepia + vec3 sepia = vec3( + (color.r * 0.393) + (color.g * 0.769) + (color.b * 0.189), + (color.r * 0.349) + (color.g * 0.686) + (color.b * 0.168), + (color.r * 0.272) + (color.g * 0.534) + (color.b * 0.131) + ); + + color.rgb = mix(color.rgb, sepia, sepiaLevel); + + // Vignetting + vec2 position = (gl_FragCoord.xy * invFrameSize.xy) - vec2(0.5); + float vignette = 1 - smoothstep(1.1- damageVignetting / 4, 0.75 - 0.45, length(position)); + color.rgb = mix(color.rgb, vec3(0.8 * damageVignetting/2, 0, 0), vignette); + + // Limit brightness + //float b = (color.r + color.b + color.g) / 3; + //color.rgb /= max(1, b); + + return color; +} + + +void main(void) +{ + vec4 color = texture(optimumTextures2D[primaryScene], texCoord); + outColor = ColorGrade(color); +} diff --git a/sources/shaders-vk/colorgrade.interface.glsl b/sources/shaders-vk/colorgrade.interface.glsl new file mode 100644 index 00000000..8b7ce158 --- /dev/null +++ b/sources/shaders-vk/colorgrade.interface.glsl @@ -0,0 +1,26 @@ +// Program interface of colorgrade (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and every other uniform is a record member: +// colorgrade.vsh's one, then colorgrade.fsh's, each in declaration order. +// +// A block member cannot carry the GLSL 330 initializers (minlight = 0.0, maxlight = 1, minsat = 0, +// maxsat = 1); the runtime seeds the record from the GLSL 330 declarations (docs/vulkan-native-shaders.md +// section 8), as for final. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, primaryScene); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 invFrameSizeIn; + + float gammaLevel; + float brightnessLevel; + float sepiaLevel; + float damageVignetting; + + float minlight; + float maxlight; + float minsat; + float maxsat; +}; diff --git a/sources/shaders-vk/colorgrade.vert b/sources/shaders-vk/colorgrade.vert new file mode 100644 index 00000000..5f1e3588 --- /dev/null +++ b/sources/shaders-vk/colorgrade.vert @@ -0,0 +1,23 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of colorgrade.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "colorgrade.interface.glsl" + +layout(location=0) in vec2 position; + +layout(location = 0) out vec2 texCoord; +layout(location = 1) out vec2 invFrameSize; + +void main(void) +{ + gl_Position = vec4(position, 0, 1); + texCoord = (position+1.0) / 2.0; + invFrameSize = invFrameSizeIn; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/debugdepthbuffer.frag b/sources/shaders-vk/debugdepthbuffer.frag new file mode 100644 index 00000000..86f103fe --- /dev/null +++ b/sources/shaders-vk/debugdepthbuffer.frag @@ -0,0 +1,33 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of debugdepthbuffer.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "debugdepthbuffer.interface.glsl" + +layout(location = 0) in vec3 vertexPosition; + +layout(location = 0) out vec4 outColor; + +float LinearizeDepth(vec2 uv) +{ + float n = 0.3; // camera z near + float f = 1500.0; // camera z far + float z = texture(optimumTextures2D[depthSampler], uv).x; + return z; //(2.0 * n) / (f + n - z * (f - n)); +} + +void main() +{ + vec2 uv = vertexPosition.xy; + + // Don't ask me why this is nieeded, i guess our quad is weird + uv.x = uv.x / 2 + 0.5; + uv.y = uv.y / 2 + 0.5; + + float d; + d = LinearizeDepth(uv); + outColor = vec4(d, d, d, 1.0); +} diff --git a/sources/shaders-vk/debugdepthbuffer.interface.glsl b/sources/shaders-vk/debugdepthbuffer.interface.glsl new file mode 100644 index 00000000..1ea47183 --- /dev/null +++ b/sources/shaders-vk/debugdepthbuffer.interface.glsl @@ -0,0 +1,12 @@ +// Program interface of debugdepthbuffer (docs/vulkan-native-shaders.md section 4). One draw per Use(), so the +// push block holds only the sampler slot and debugdepthbuffer.vsh's two matrices are the record. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, depthSampler); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; +}; diff --git a/sources/shaders-vk/debugdepthbuffer.vert b/sources/shaders-vk/debugdepthbuffer.vert new file mode 100644 index 00000000..470a4b3f --- /dev/null +++ b/sources/shaders-vk/debugdepthbuffer.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of debugdepthbuffer.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "debugdepthbuffer.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; + +layout(location = 0) out vec3 vertexPosition; + +void main(void) +{ + vertexPosition = vertexPositionIn; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(vertexPositionIn, 1.0); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/findbright.frag b/sources/shaders-vk/findbright.frag new file mode 100644 index 00000000..f216f4ad --- /dev/null +++ b/sources/shaders-vk/findbright.frag @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of findbright.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "findbright.interface.glsl" + +layout(location = 0) in vec2 texcoord; + +layout(location = 0) out vec4 outColor; + +void main(void) +{ + vec4 color = texture(optimumTextures2D[colorTex], texcoord); + float glowLevel = texture(optimumTextures2D[glowTex], texcoord).r * color.a; + float bloomIntensity = ambientBloomLevel + 3*glowLevel + extraBloom; + + outColor = color * bloomIntensity; + //outColor = color * 2; - night vision +} diff --git a/sources/shaders-vk/findbright.interface.glsl b/sources/shaders-vk/findbright.interface.glsl new file mode 100644 index 00000000..b65e07dc --- /dev/null +++ b/sources/shaders-vk/findbright.interface.glsl @@ -0,0 +1,13 @@ +// Program interface of findbright (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots and every other uniform is a record member. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, colorTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, glowTex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float extraBloom; + float ambientBloomLevel; +}; diff --git a/sources/shaders-vk/findbright.vert b/sources/shaders-vk/findbright.vert new file mode 100644 index 00000000..fdadb503 --- /dev/null +++ b/sources/shaders-vk/findbright.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of findbright.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "findbright.interface.glsl" + +layout(location = 0) out vec2 texcoord; + +void main(void) +{ + // https://rauwendaal.net/2014/06/14/rendering-a-screen-covering-triangle-in-opengl/ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + texcoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/godrays.frag b/sources/shaders-vk/godrays.frag new file mode 100644 index 00000000..c89da8d0 --- /dev/null +++ b/sources/shaders-vk/godrays.frag @@ -0,0 +1,77 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of godrays.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "godrays.interface.glsl" + +layout(location = 0) in vec2 texCoord; +layout(location = 1) in vec3 sunPosScreen; +layout(location = 2) in float iGlobalTime; +layout(location = 3) in float intensity; +layout(location = 4) in float direction; + +layout(location = 0) out vec4 outColor; + + +// Falloff over distance +const float decay = 0.9985; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(41, 289)))*45758.5453); } + + +vec2 clampDeltas(vec2 dtuv) { + // When looking 90 degrees away from the sun, dTuv gets very large and causes significant frame drops. + // I presume this is because the graphics card local texture cache is no longer effective due to the large uv coord jumps + if (length(dtuv) > 0.005) { + dtuv = normalize(dtuv) * 0.005; + } + + return dtuv; +} + +vec4 applyGodRays(in vec2 uv, in vec2 nSunPos) { + // Sample weight. Decays as we radiate outwards. + float weight = intensity / 23.0 / 1.5; + + int vanillaSamples = int(180 * min(1, intensity * 1.2)); + int samples = min(maxGodRaySamples, vanillaSamples); + + // Short deltas near the sun + vec2 sdTuv = clampDeltas((nSunPos - uv) * intensity / 200 * direction); + + // Large deltas far away from the sun where precision matters less and where is more important that the ray travels as far as possible + vec2 ldTuv = clampDeltas((nSunPos - uv) * intensity / 64 * direction); + + vec2 dTuv = sdTuv; + + + float glow = texture(optimumTextures2D[glowParts], uv).g; + vec4 col = texture(optimumTextures2D[inputTexture], uv) * glow; + + for (float i=0.0; i < samples; i++) { + uv.x = clamp(uv.x + dTuv.x, 0, 1); + uv.y = clamp(uv.y + dTuv.y, 0, 1); + col += texture(optimumTextures2D[inputTexture], uv) * texture(optimumTextures2D[glowParts], uv).g * weight; + weight *= decay; + + dTuv = mix(sdTuv, ldTuv, i/samples); + } + + // Seems to greatly reduce the sun turning into one massive white blob + col.rgb *= clamp(1 - max((col.r+col.g+col.b)/3 - 0.7, 0), 0, 1); + + col.a = min(1, col.a); + + return col; +} + + +void main(void) { + vec2 nSunPos = (clamp(sunPosScreen.xy, -10, 10) + 1) / 2; + outColor = applyGodRays(texCoord, nSunPos); + + outColor.a=1; +} diff --git a/sources/shaders-vk/godrays.interface.glsl b/sources/shaders-vk/godrays.interface.glsl new file mode 100644 index 00000000..a2e6f66b --- /dev/null +++ b/sources/shaders-vk/godrays.interface.glsl @@ -0,0 +1,21 @@ +// Program interface of godrays (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots and every other uniform is a record member: +// godrays.vsh's seven, then godrays.fsh's one, each in declaration order. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, inputTexture); + OPTIMUM_SAMPLER_SLOT(sampler2D, glowParts); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 invFrameSizeIn; + vec3 sunPosScreenIn; + vec3 sunPos3dIn; + vec3 playerViewVector; + float iGlobalTimeIn; + float directionIn; + int dusk; + + int maxGodRaySamples; +}; diff --git a/sources/shaders-vk/godrays.vert b/sources/shaders-vk/godrays.vert new file mode 100644 index 00000000..d2a4b76a --- /dev/null +++ b/sources/shaders-vk/godrays.vert @@ -0,0 +1,40 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of godrays.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "godrays.interface.glsl" + +layout(location = 0) out vec2 texCoord; +layout(location = 1) out vec3 sunPosScreen; +layout(location = 2) out float iGlobalTime; +layout(location = 3) out float intensity; +layout(location = 4) out float direction; + +void main(void) +{ + // https://randallr.wordpress.com/2014/06/14/rendering-a-screen-covering-triangle-in-opengl + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + texCoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + sunPosScreen = sunPosScreenIn; + iGlobalTime = iGlobalTimeIn; + + float sunPlrAngle = (dot(sunPos3dIn, playerViewVector) + 1) / 3; + + direction = dot(sunPos3dIn, playerViewVector) >= 0 ? 1 : -1; + + // https://www.toolfk.com/online-plotter-frame/#W3sidHlwZSI6MCwiZXEiOiJtYXgoMSwxLjc1KigxLTYqYWJzKHgtMC4yMikpKSIsImNvbG9yIjoiIzAwMDAwMCJ9LHsidHlwZSI6MTAwMCwid2luZG93IjpbIi0xIiwiMSIsIjAiLCIyIl19XQ-- + float dawnMul = max(1, (1-dusk) * 2 * (1 - 6*abs(sunPos3dIn.y - 0.1))); + + // Intensity is determined by how directly the player is looking at the sun + // above intensity 0.8 we get godrays where they shouldn't be o.o + intensity = clamp(sunPlrAngle * dawnMul, 0, 0.8); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/ssao.frag b/sources/shaders-vk/ssao.frag new file mode 100644 index 00000000..a5a84fa0 --- /dev/null +++ b/sources/shaders-vk/ssao.frag @@ -0,0 +1,170 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of ssao.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// SSAOLEVEL == 2 is a specialization-constant branch with the same values. TAAMOTION stays a variant axis +// (section 5). The loop local `sample` is `samplePos`: `sample` is a reserved word in GLSL 450. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "ssao.interface.glsl" + +layout(location = 0) in vec2 texcoord; +layout(location = 0) out vec4 outOcclusion; + +int kernelSize = (OPTIMUM_SSAOLEVEL == 2) ? 24 : 20; +float radius = 0.9; + +float bias = 0.01; + + +// Useful numbers +#define PI radians(180.0) +#define TAU PI * 2.0 +#define RCPPI 1.0 / PI +#define PHI sqrt(5.0) * 0.5 + 0.5 +#define GOLDEN_ANGLE TAU / PHI / PHI +#define LOG2 log(2.0) + +#define cubicSmooth(x) (x * x) * (3.0 - 2.0 * x) + + +float bayer2(vec2 a){ + a = floor(a); + return fract(dot(a, vec2(0.5, a.y * 0.75))); +} + +float bayer4(vec2 a) { return bayer2( 0.5 *a) * 0.25 + bayer2(a); } +float bayer8(vec2 a) { return bayer4( 0.5 *a) * 0.25 + bayer2(a); } +float bayer16(vec2 a) { return bayer4( 0.25 *a) * 0.0625 + bayer4(a); } +float bayer32(vec2 a) { return bayer8( 0.25 *a) * 0.0625 + bayer4(a); } +float bayer64(vec2 a) { return bayer8( 0.125*a) * 0.015625 + bayer8(a); } +float bayer128(vec2 a) { return bayer16(0.125*a) * 0.015625 + bayer8(a); } + +// Fermats golden spiral, input a dither pattern as the index and its size as the total to generate coordinates following the spiral. +vec2 goldenSpiralN(float index, float total) { + float theta = index * GOLDEN_ANGLE; + return vec2(sin(theta), cos(theta)) * sqrt(index / total); +} + +vec2 goldenSpiralS(float index, float total) { + float theta = index * GOLDEN_ANGLE; + return vec2(sin(theta), cos(theta)) * pow(index / total, 2.0); +} + +// Useful tool to convert 2D offset patterns into 3D. Looks great with screen space stuff, more complicated things such as path tracing should use a rand. +vec3 sphereMap(vec2 a) { + float phi = a.y * 2.0 * PI; + float cosTheta = 1.0 - a.x; + float sinTheta = sqrt(1.0 - cosTheta * cosTheta); + + return vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); +} + + +void main() +{ + float wboitatn = max(0.0, 1 - texture(optimumTextures2D[revealage], texcoord).r) * 0.75; + + // tile noise texture over screen based on screen dimensions divided by noise size + vec2 noiseScale = vec2(screenSize.x/8.0, screenSize.y/8.0); + + vec4 texVal = texture(optimumTextures2D[gPosition], texcoord); + + vec3 fragPos = texVal.xyz; + float attenuate = texVal.w + wboitatn; + + texVal = texture(optimumTextures2D[gNormal], texcoord); + vec3 normal = normalize(texVal.xyz); + bool leavesHack = texVal.w > 0; + + // This seems to completely fix any distant ssao flickering artifacts while perservering everything else + // Tyron Mar 9: Completely borks fragments behind leaves, during heavy rain + // Tyron Mar10: Breaks distant cliff walls, changed 90 to 150 + if (!leavesHack) { + fragPos += normal * clamp(-fragPos.z/150 - 0.05, 0, 10); + } + + + float distanceFade = clamp(1.2 - (-fragPos.z) / 250, 0, 1); + + if (fragPos.x == 0 || distanceFade == 0) { + outOcclusion = vec4(1); + return; + } + + //vec3 randomVec = texture(texNoise, texcoord * noiseScale).xyz; + + const float ditherSize = pow(64.0, 2.0); + float dither = bayer128(texcoord * screenSize); +#if TAAMOTION == 1 + // Give that screen-locked dither a temporal dimension. Vanilla's Bayer-128 + // is fixed to the pixel grid, so under a jittered camera every surface point + // draws a different kernel every frame and nothing can average it. Advancing + // the dither by the golden ratio per frame makes successive frames sample + // complementary spiral directions instead, which is what a temporal + // accumulator converges on. Gated on TAAMOTION (stamped from + // OptimumConfig.EffectiveTemporalPipeline): with no accumulator behind it a + // per-frame-varying dither is strictly worse than the fixed one. + dither = fract(dither + fract(temporalFrameIndex * (PHI - 1.0))); +#endif + vec3 randomVec = sphereMap(goldenSpiralN(ditherSize + dither, ditherSize)); + + + vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal)); + vec3 bitangent = cross(normal, tangent); + mat3 TBN = mat3(tangent, bitangent, normal); + + float occlusion = 0.0; + + for( int i = 0; i < kernelSize; ++i) + { + vec3 samplePos = TBN * samples[i]; + samplePos = fragPos + samplePos * radius; + + vec4 offset = vec4(samplePos, 1.0); + offset = projection * offset; + offset.xyz /= offset.w; + offset.xyz = offset.xyz * 0.5 + 0.5; + + offset.x = clamp(offset.x, texcoord.x - 0.04, texcoord.x + 0.04); + offset.y = clamp(offset.y, texcoord.y - 0.04, texcoord.y + 0.04); + + float sampleDepth = texture(optimumTextures2D[gPosition], offset.xy).z; + float depthDiff = sampleDepth - (samplePos.z + bias); + float rangeCheck = 0; + + if (leavesHack) { + + if (depthDiff >= 0.02 && depthDiff < 0.2 && abs(dot(texture(optimumTextures2D[gNormal], offset.xy).rgb, normal) - 1) > 0.25) { + rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth)); + } + + } else { + + if (depthDiff > 0 && depthDiff < 0.2) { + rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth)); + } + + } + + occlusion += rangeCheck; + } + + float occ = clamp(1.0 - min(1, occlusion / kernelSize * distanceFade) * (1-attenuate), 0, 1); + + // Some distant geometry gets overly dark, lets clamp the lower limit + if (OPTIMUM_SSAOLEVEL == 2) { + occ = max(occ, 0.5); + } else { + occ = max(occ, 0.7); + } + + // We need MOAR SSAO >:D + if (!leavesHack) { + occ = 1 - (1-occ) * 1.4; + } + + + outOcclusion = vec4(occ, occ, occ, 1); +} diff --git a/sources/shaders-vk/ssao.interface.glsl b/sources/shaders-vk/ssao.interface.glsl new file mode 100644 index 00000000..795a5989 --- /dev/null +++ b/sources/shaders-vk/ssao.interface.glsl @@ -0,0 +1,22 @@ +// Program interface of ssao (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots, in ssao.fsh's declaration order, and every other +// uniform is a record member in declaration order. +// +// temporalFrameIndex sits inside `#if TAAMOTION == 1` in ssao.fsh. collectUniformNames sees it in every +// variant, and the record keeps one layout per program, so it is declared unconditionally; only the code +// that reads it stays behind the TAAMOTION axis. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, gPosition); + OPTIMUM_SAMPLER_SLOT(sampler2D, gNormal); + OPTIMUM_SAMPLER_SLOT(sampler2D, texNoise); + OPTIMUM_SAMPLER_SLOT(sampler2D, revealage); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec3 samples[64]; + vec2 screenSize; + mat4 projection; + float temporalFrameIndex; +}; diff --git a/sources/shaders-vk/ssao.vert b/sources/shaders-vk/ssao.vert new file mode 100644 index 00000000..74e35fb2 --- /dev/null +++ b/sources/shaders-vk/ssao.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of ssao.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "ssao.interface.glsl" + +layout(location = 0) out vec2 texcoord; + +void main(void) +{ + // https://rauwendaal.net/2014/06/14/rendering-a-screen-covering-triangle-in-opengl/ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + texcoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/woittest.frag b/sources/shaders-vk/woittest.frag new file mode 100644 index 00000000..23e48625 --- /dev/null +++ b/sources/shaders-vk/woittest.frag @@ -0,0 +1,32 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of woittest.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "woittest.interface.glsl" + +layout(location = 0) in vec4 v_color; + +layout(location = 0) out vec4 outAccu; +layout(location = 1) out vec4 outReveal; + + +void drawPixel(vec4 color) { + float alpha = color.a; + + float weight = max(0.01, min(3000, 0.03 / (0.00001 + pow(gl_FragCoord.z/200, 4)))); + + // RGBA32F texture (accumulation) + outAccu = vec4(color.rgb * alpha, alpha) * weight; + + // R32F texture (revealage) + // Make sure to use the red channel (and GL_RED target in your texture) + outReveal.r = alpha; +} + +void main() +{ + drawPixel(v_color); +} diff --git a/sources/shaders-vk/woittest.interface.glsl b/sources/shaders-vk/woittest.interface.glsl new file mode 100644 index 00000000..d5296bac --- /dev/null +++ b/sources/shaders-vk/woittest.interface.glsl @@ -0,0 +1,7 @@ +// Program interface of woittest (docs/vulkan-native-shaders.md section 4). No samplers, so no push block; +// woittest.vsh's two matrices are the record. +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; +}; diff --git a/sources/shaders-vk/woittest.vert b/sources/shaders-vk/woittest.vert new file mode 100644 index 00000000..ff618575 --- /dev/null +++ b/sources/shaders-vk/woittest.vert @@ -0,0 +1,24 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of woittest.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "woittest.interface.glsl" + +layout(location = 0) in vec3 vertexPosition; +layout(location = 1) in vec4 colorIn; + +layout(location = 0) out vec4 v_color; +//out float depth; + +void main () { + v_color = colorIn; + gl_Position = projectionMatrix * modelViewMatrix * vec4(vertexPosition, 1.0); + + //depth = -(modelViewMatrix * vec4(vertexPosition, 1.0)).z; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From cedbb0d770ed6d91bb3a56d028275c2d66940558 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:28:04 +0200 Subject: [PATCH 162/226] wip(native-shaders): optimum-programs - 8 of 8 programs ported (taa-resolve, taa-sharpen, taa-debug, taa-skymotion, chunkliquidmotion, scene-ssao, fsr-easu, fsr-rcas) NativeShaderParityTests 23/23 (16 for this family, spirv-val on PATH), Optimum.Render.Vulkan.Tests 874 passed 0 failed, Optimum.Tests -c Release 1184 passed 34 skipped 0 failed. taa-resolve keeps the rule-11 invariants verbatim; the two motion writers go through include/motion.glsl with the TAA-off dummy output as a variant. --- docs/vulkan-native-shaders.md | 22 ++ sources/shaders-vk/chunkliquidmotion.frag | 84 +++++ .../chunkliquidmotion.interface.glsl | 40 +++ sources/shaders-vk/chunkliquidmotion.vert | 113 +++++++ sources/shaders-vk/fsr-easu.frag | 174 ++++++++++ sources/shaders-vk/fsr-easu.interface.glsl | 11 + sources/shaders-vk/fsr-easu.vert | 21 ++ sources/shaders-vk/fsr-rcas.frag | 35 ++ sources/shaders-vk/fsr-rcas.interface.glsl | 11 + sources/shaders-vk/fsr-rcas.vert | 21 ++ sources/shaders-vk/scene-ssao.frag | 21 ++ sources/shaders-vk/scene-ssao.interface.glsl | 11 + sources/shaders-vk/scene-ssao.vert | 21 ++ sources/shaders-vk/taa-debug.frag | 74 ++++ sources/shaders-vk/taa-debug.interface.glsl | 15 + sources/shaders-vk/taa-debug.vert | 21 ++ sources/shaders-vk/taa-resolve.frag | 316 ++++++++++++++++++ sources/shaders-vk/taa-resolve.interface.glsl | 26 ++ sources/shaders-vk/taa-resolve.vert | 21 ++ sources/shaders-vk/taa-sharpen.frag | 64 ++++ sources/shaders-vk/taa-sharpen.interface.glsl | 12 + sources/shaders-vk/taa-sharpen.vert | 24 ++ sources/shaders-vk/taa-skymotion.frag | 124 +++++++ .../shaders-vk/taa-skymotion.interface.glsl | 19 ++ sources/shaders-vk/taa-skymotion.vert | 36 ++ 25 files changed, 1337 insertions(+) create mode 100644 sources/shaders-vk/chunkliquidmotion.frag create mode 100644 sources/shaders-vk/chunkliquidmotion.interface.glsl create mode 100644 sources/shaders-vk/chunkliquidmotion.vert create mode 100644 sources/shaders-vk/fsr-easu.frag create mode 100644 sources/shaders-vk/fsr-easu.interface.glsl create mode 100644 sources/shaders-vk/fsr-easu.vert create mode 100644 sources/shaders-vk/fsr-rcas.frag create mode 100644 sources/shaders-vk/fsr-rcas.interface.glsl create mode 100644 sources/shaders-vk/fsr-rcas.vert create mode 100644 sources/shaders-vk/scene-ssao.frag create mode 100644 sources/shaders-vk/scene-ssao.interface.glsl create mode 100644 sources/shaders-vk/scene-ssao.vert create mode 100644 sources/shaders-vk/taa-debug.frag create mode 100644 sources/shaders-vk/taa-debug.interface.glsl create mode 100644 sources/shaders-vk/taa-debug.vert create mode 100644 sources/shaders-vk/taa-resolve.frag create mode 100644 sources/shaders-vk/taa-resolve.interface.glsl create mode 100644 sources/shaders-vk/taa-resolve.vert create mode 100644 sources/shaders-vk/taa-sharpen.frag create mode 100644 sources/shaders-vk/taa-sharpen.interface.glsl create mode 100644 sources/shaders-vk/taa-sharpen.vert create mode 100644 sources/shaders-vk/taa-skymotion.frag create mode 100644 sources/shaders-vk/taa-skymotion.interface.glsl create mode 100644 sources/shaders-vk/taa-skymotion.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 22d93014..f11863e3 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -438,3 +438,25 @@ Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage its differential GPU test in its stage. 7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. + +### Family 7 decisions (optimum-programs, 2026-09-15) + +`taa-resolve`, `taa-sharpen`, `taa-debug`, `taa-skymotion`, `chunkliquidmotion`, `scene-ssao`, `fsr-easu`, `fsr-rcas`. +- **Bodies:** only the step-4 differences. `taa-resolve` keeps its body and both DO NOT REVERT notes line for line + (3x3 nearest-depth disocclusion, motion and the writer-depth tolerance from the nearest-depth tap, the pixel's + own reactive, luminance anti-flicker `mix(1.2, 0.3, w*w) * blendAlpha`); its locals `glow` and `sky` shadow the + set 0 samplers of those names in function scope, which is legal and left as written. +- **Uniforms behind an axis** (`taa-skymotion`, `chunkliquidmotion` declare theirs inside `#if TAAMOTION > 0`): the + oracle reads unpreprocessed text, so they are names of every variant and sit unconditionally in the record. +- **Motion location:** `#if TAAMOTION == 1` then `#if GBUFFER == 1` location 4 else 2, so both writers carry the + axes `GBUFFER,TAAMOTION`; the two `TAAMOTION=0` variants are identical. +- **TAA-off dummy output** stays (one `vec4` at location 0, never bound at runtime) and is written as + `optimumWriteReactiveOnly(0.0)`, bit-identical to the GLSL 330 `vec4(0.0)`, so no `outMotion` assignment exists + outside the include's return values. The TAA-on write is `optimumWriteMotion(..., writerDepth = gl_FragCoord.z)`, + whose behind-camera return equals the GLSL 330 early return in both writers. +- **Placement:** the six fullscreen programs push only their slots. `chunkliquidmotion` is a chunk draw: push holds + `origin` and `modelViewMatrix` (76 B, no sampler); `projectionMatrix`, the previous-frame matrices, + `cameraPosDelta`, vertexwarp's twelve `prev*` uniforms and the fragment's TAA uniforms are record members. +- **Depth remap:** `taa-skymotion.vert` keeps `z = w = 1`, so the remap yields window depth 1 as in GL. + `chunkliquidmotion.vert` remaps after its `w` offset, as the rewriter's wrapper did; `taaPrevClip` stays in GL clip + convention, which the motion arithmetic expects. diff --git a/sources/shaders-vk/chunkliquidmotion.frag b/sources/shaders-vk/chunkliquidmotion.frag new file mode 100644 index 00000000..b611687d --- /dev/null +++ b/sources/shaders-vk/chunkliquidmotion.frag @@ -0,0 +1,84 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkliquidmotion.fsh (docs/vulkan-native-shaders.md). +// +// Variant axes: TAAMOTION (the output set and the taaPrevClip varying) and GBUFFER (TAAMOTIONLOCATION: 4 with +// the G-buffer, else 2). The TAA-off variant keeps the GLSL 330 dummy output: one vec4 at location 0 holding +// zero. The motion value goes through include/motion.glsl (section 7): optimumWriteMotion's behind-camera +// branch returns the same vec4(0, 0, reactive, 0) the GLSL 330 early return wrote, and its vector is the same +// three lines in the same order; the dummy's vec4(0.0) is optimumWriteReactiveOnly(0.0). +// +// Optimum TAA (P4): the fragment half of the liquid velocity pass. +// +// This shader writes ONE attachment - Primary's motion attachment - and no +// colour, no glow and no G-buffer: ChunkRenderer.RenderLiquidMotion masks every +// other attachment out of the draw-buffer set for the duration of the pass, on +// both backends, so the shaded image the OIT merge already produced is left +// exactly as it was. +// +// It does write depth. The OIT liquid draw cannot: LoadFrameBuffer(Transparent) +// disables the depth mask, so the water surface never reaches Primary's depth +// attachment (which the Transparent target shares) and Primary's depth at a +// water pixel is the opaque surface BEHIND the water. The resolve accepts a +// motion vector only where the writer's own window depth matches the depth +// buffer within abs(a - depth) <= max(2e-4, 8e-4 * depth), so a velocity pass +// that left depth alone would have every one of its vectors rejected and the +// water would fall back to camera reprojection - which is the ghosting this +// pass exists to remove. Writing the surface's depth here makes the two agree +// and gives the resolve the water surface's own linear depth for its +// disocclusion test, which is the depth the motion vector belongs to. +// TAA-PLAN.md rule 7 states this ("writes the surface's motion and depth"). +// +// rg = previousPixel - currentUnjitteredPixel in render pixels, b = reactive, +// a = this fragment's window depth - the same contract chunkopaque.fsh writes. +// +// Record (chunkliquidmotion.interface.glsl): taaRenderSize is the render-target size in pixels, taaJitterPx +// this frame's sub-pixel shear in pixels. taaLiquidReactive: foam, flow-UV scrolling and the specular sparkle +// animate in place: the surface does not move, but its shading does, so history that reprojects perfectly +// still has to be weighted down. 0.3 is the plan's starting value (Conventions: "animated liquid textures 0.3 +// initial, tuned by measurement"). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkliquidmotion.interface.glsl" + +#if TAAMOTION == 1 +layout(location = 0) in vec4 taaPrevClip; + +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#else +// TAA off: this program is never used - ChunkRenderer.RenderLiquidMotion +// returns before binding it - but it is still registered and compiled, and a +// fragment stage with no output at all is not worth handing to two different +// shader translators. One dummy attachment keeps it trivially valid. +layout(location = 0) out vec4 outMotion; +#endif + +#include "motion.glsl" + + +void main() +{ +#if TAAMOTION == 1 + // A previous position behind the previous camera is not a motion vector; a + // zero alpha routes the pixel to the resolve's camera fallback, exactly as + // in chunkopaque.fsh. Depth is still written for it, because the fragment + // is genuinely the visible surface either way. + // + // The reactive value is delivered anyway: taa-resolve.fsh reads motion.b + // whether or not the writer-depth test accepted the pixel (P3 finding (h)), + // and the foam and flow-UV animation that 0.3 stands for is happening on + // this fragment regardless of where it was last frame. Zeroing b here would + // hand a water pixel FULL history weight in exactly the frames the camera + // swung hardest - the worst case, not the safe one. taa-skymotion.fsh keeps + // its reactive value on the same branch for the same reason. + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, clamp(taaLiquidReactive, 0.0, 1.0), gl_FragCoord.z); +#else + outMotion = optimumWriteReactiveOnly(0.0); +#endif +} diff --git a/sources/shaders-vk/chunkliquidmotion.interface.glsl b/sources/shaders-vk/chunkliquidmotion.interface.glsl new file mode 100644 index 00000000..34b391b7 --- /dev/null +++ b/sources/shaders-vk/chunkliquidmotion.interface.glsl @@ -0,0 +1,40 @@ +// Program interface of chunkliquidmotion (docs/vulkan-native-shaders.md section 4). A chunk-family program: one +// draw per mesh pool per Use(), so the DRAW uniforms origin and modelViewMatrix sit in the push block (76 B, no +// samplers). The record holds the rest: chunkliquidmotion.vsh's uniforms in declaration order, then the +// vertexwarp.glsl optimum-program-uniform names (vertexwarp.vsh owns only the current-frame members; the prev* +// mirrors are program uniforms), then chunkliquidmotion.fsh's. +// +// The GLSL 330 sources declare prevProjectionMatrix, prevModelViewMatrix, cameraPosDelta, taaRenderSize, +// taaJitterPx and taaLiquidReactive inside #if TAAMOTION > 0; the oracle reads the unpreprocessed text, so they +// are names of every variant and are declared unconditionally. The GLSL 330 initializers (taaLiquidReactive +// = 0.3 and vertexwarp's prev* defaults) are seeded by the runtime (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + vec3 origin; + mat4 modelViewMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 prevProjectionMatrix; + mat4 prevModelViewMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 taaRenderSize; + vec2 taaJitterPx; + float taaLiquidReactive; +}; diff --git a/sources/shaders-vk/chunkliquidmotion.vert b/sources/shaders-vk/chunkliquidmotion.vert new file mode 100644 index 00000000..d4867047 --- /dev/null +++ b/sources/shaders-vk/chunkliquidmotion.vert @@ -0,0 +1,113 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkliquidmotion.vsh (docs/vulkan-native-shaders.md). Variant axis: TAAMOTION (the +// taaPrevClip varying and the previous-position replay). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkliquidmotion.interface.glsl" + +// Optimum TAA (P4): the liquid velocity pass (TAA-PLAN.md accuracy rule 7). +// +// The OIT liquid draw cannot write Primary's motion attachment - it renders +// into the Transparent target and its six oit.fsh outputs already fill that +// framebuffer's attachment set - so the liquid pools are drawn a second time, +// into Primary, by this program, which writes nothing but the motion +// attachment (ChunkRenderer.RenderLiquidMotion opens the draw-buffer window +// with every other colour attachment masked out). +// +// The whole point is that the position this program computes is the SAME +// position chunkliquid.vsh computed for the same vertex: same liquid wave warp, +// same divisor from the same water flags, and the same "pretend the surface is +// closer" w-offset at the end. Anything else and the velocity pass would +// depth-test against a surface a fraction of a pixel away from the one that was +// shaded, and the motion vector would belong to a neighbouring fragment. +// +// The previous position follows accuracy rule 4, exactly as chunkopaque.vsh +// does: the chunk's camera-relative position moved by the camera's own motion, +// the warp re-evaluated through previousWarpState(), and the previous +// UNJITTERED projection with the previous CameraMatrixOrigin. +// +// prevProjectionMatrix: previous frame's UNJITTERED world projection; prevModelViewMatrix: previous frame's +// CameraMatrixOrigin; cameraPosDelta: cameraPos(this frame) - cameraPos(previous frame). + +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlags; +layout(location = 4) in vec2 flowVector; +layout(location = 5) in int colormapData; +layout(location = 6) in int waterFlagsIn; + +#if TAAMOTION == 1 +layout(location = 0) out vec4 taaPrevClip; +#endif + +#include "vertexflagbits.glsl" +#include "vertexwarp.glsl" + + +// chunkliquid.vsh's position path, verbatim, as a function of the warp state so +// the same code can be evaluated for this frame and for the previous one. The +// vanilla body reads: +// +// if ((waterFlagsIn & 1) == 1) { +// float div = ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) ? 90 : 5; +// float oceanity = ((waterFlagsIn >> 2) & 0xff) * OneOver255; +// div *= max(0.2, 1 - oceanity); +// worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, div); +// } +// else if ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) { +// worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, 90); +// } +// +// with applyLiquidWarping being the currentWarpState() wrapper of the overload +// called here. +vec4 taaLiquidWorldPos(WarpState st, vec4 worldPos) +{ + if ((waterFlagsIn & 1) == 1) { + float div = ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) ? 90 : 5; + + float oceanity = ((waterFlagsIn >> 2) & 0xff) * OneOver255; + div *= max(0.2, 1 - oceanity); + + worldPos = applyLiquidWarpingState(st, (waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, div); + } + else if ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) { + worldPos = applyLiquidWarpingState(st, (waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, 90); + } + + return worldPos; +} + + +void main(void) +{ + vec4 truePos = vec4(xyz + origin, 1.0); + + vec4 worldPos = taaLiquidWorldPos(currentWarpState(), truePos); + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + // chunkliquid.vsh's last line: the liquid surface is pretended to be closer + // than it is, so it always wins against stairs and slabs beside it. It moves + // where the fragment lands, so it belongs on both clip positions. + gl_Position.w += 0.0008 / max(0.1, gl_Position.z); + +#if TAAMOTION == 1 + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0); + taaPrevPos = taaLiquidWorldPos(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + taaPrevClip.w += 0.0008 / max(0.1, taaPrevClip.z); + } +#endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + // It runs after the w offset above, as the rewriter's wrapper does; taaPrevClip is a varying, not a + // clip position, and is left in GL convention, which is what the motion arithmetic expects. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/fsr-easu.frag b/sources/shaders-vk/fsr-easu.frag new file mode 100644 index 00000000..6a273640 --- /dev/null +++ b/sources/shaders-vk/fsr-easu.frag @@ -0,0 +1,174 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of fsr-easu.fsh (docs/vulkan-native-shaders.md). +// AMD FidelityFX Super Resolution 1 EASU, adapted for the Vintage Story final blit. +// FidelityFX FSR 1 source carries the MIT license, AMD 2021. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "fsr-easu.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +float FsrRcp(float value) +{ + return 1.0 / max(value, 1.0 / 65536.0); +} + +vec3 FsrSample(vec2 pixelCenter) +{ + vec2 halfTexel = inputTexelSize * 0.5; + vec2 uv = clamp(pixelCenter * inputTexelSize, halfTexel, vec2(1.0) - halfTexel); + return texture(optimumTextures2D[inputScene], uv).rgb; +} + +void FsrEasuTap( + inout vec3 accumulatedColor, + inout float accumulatedWeight, + vec2 offset, + vec2 direction, + vec2 length, + float lobe, + float clippingPoint, + vec3 color) +{ + vec2 distance; + distance.x = offset.x * direction.x + offset.y * direction.y; + distance.y = offset.x * -direction.y + offset.y * direction.x; + distance *= length; + float distanceSquared = min(dot(distance, distance), clippingPoint); + float baseWindow = (2.0 / 5.0) * distanceSquared - 1.0; + float lobeWindow = lobe * distanceSquared - 1.0; + baseWindow *= baseWindow; + lobeWindow *= lobeWindow; + baseWindow = (25.0 / 16.0) * baseWindow - (25.0 / 16.0 - 1.0); + float weight = baseWindow * lobeWindow; + accumulatedColor += color * weight; + accumulatedWeight += weight; +} + +void FsrEasuSet( + inout vec2 direction, + inout float length, + vec2 fractionalPosition, + bool quadrantS, + bool quadrantT, + bool quadrantU, + bool quadrantV, + float lumaA, + float lumaB, + float lumaC, + float lumaD, + float lumaE) +{ + float weight = 0.0; + if (quadrantS) weight = (1.0 - fractionalPosition.x) * (1.0 - fractionalPosition.y); + if (quadrantT) weight = fractionalPosition.x * (1.0 - fractionalPosition.y); + if (quadrantU) weight = (1.0 - fractionalPosition.x) * fractionalPosition.y; + if (quadrantV) weight = fractionalPosition.x * fractionalPosition.y; + + float lengthX = FsrRcp(max(abs(lumaD - lumaC), abs(lumaC - lumaB))); + float directionX = lumaD - lumaB; + lengthX = clamp(abs(directionX) * lengthX, 0.0, 1.0); + lengthX *= lengthX; + + float lengthY = FsrRcp(max(abs(lumaE - lumaC), abs(lumaC - lumaA))); + float directionY = lumaE - lumaA; + lengthY = clamp(abs(directionY) * lengthY, 0.0, 1.0); + lengthY *= lengthY; + + direction += vec2(directionX, directionY) * weight; + length += (lengthX + lengthY) * weight; +} + +float FsrLuma(vec3 color) +{ + return color.g + 0.5 * (color.r + color.b); +} + +vec3 FsrEasu(vec2 outputUv) +{ + vec2 inputSize = 1.0 / inputTexelSize; + vec2 position = outputUv * inputSize - vec2(0.5); + vec2 base = floor(position); + vec2 fractionalPosition = position - base; + + vec3 b = FsrSample(base + vec2(0.5, -0.5)); + vec3 c = FsrSample(base + vec2(1.5, -0.5)); + vec3 e = FsrSample(base + vec2(-0.5, 0.5)); + vec3 f = FsrSample(base + vec2(0.5, 0.5)); + vec3 g = FsrSample(base + vec2(1.5, 0.5)); + vec3 h = FsrSample(base + vec2(2.5, 0.5)); + vec3 i = FsrSample(base + vec2(-0.5, 1.5)); + vec3 j = FsrSample(base + vec2(0.5, 1.5)); + vec3 k = FsrSample(base + vec2(1.5, 1.5)); + vec3 l = FsrSample(base + vec2(2.5, 1.5)); + vec3 n = FsrSample(base + vec2(0.5, 2.5)); + vec3 o = FsrSample(base + vec2(1.5, 2.5)); + + float bL = FsrLuma(b); + float cL = FsrLuma(c); + float eL = FsrLuma(e); + float fL = FsrLuma(f); + float gL = FsrLuma(g); + float hL = FsrLuma(h); + float iL = FsrLuma(i); + float jL = FsrLuma(j); + float kL = FsrLuma(k); + float lL = FsrLuma(l); + float nL = FsrLuma(n); + float oL = FsrLuma(o); + + vec2 direction = vec2(0.0); + float length = 0.0; + FsrEasuSet(direction, length, fractionalPosition, true, false, false, false, bL, eL, fL, gL, jL); + FsrEasuSet(direction, length, fractionalPosition, false, true, false, false, cL, fL, gL, hL, kL); + FsrEasuSet(direction, length, fractionalPosition, false, false, true, false, fL, iL, jL, kL, nL); + FsrEasuSet(direction, length, fractionalPosition, false, false, false, true, gL, jL, kL, lL, oL); + + float directionLengthSquared = dot(direction, direction); + bool zeroDirection = directionLengthSquared < (1.0 / 32768.0); + if (zeroDirection) + { + direction = vec2(1.0, 0.0); + } + else + { + direction *= inversesqrt(directionLengthSquared); + } + + length *= 0.5; + length *= length; + float stretch = FsrRcp(max(abs(direction.x), abs(direction.y))); + vec2 anisotropicLength = vec2(1.0 + (stretch - 1.0) * length, 1.0 - 0.5 * length); + float lobe = 0.5 + ((0.25 - 0.04) - 0.5) * length; + float clippingPoint = FsrRcp(lobe); + + vec3 accumulatedColor = vec3(0.0); + float accumulatedWeight = 0.0; + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(0.0, -1.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, b); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(1.0, -1.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, c); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(-1.0, 1.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, i); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(0.0, 1.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, j); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(0.0, 0.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, f); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(-1.0, 0.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, e); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(1.0, 1.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, k); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(2.0, 1.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, l); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(2.0, 0.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, h); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(1.0, 0.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, g); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(1.0, 2.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, o); + FsrEasuTap(accumulatedColor, accumulatedWeight, vec2(0.0, 2.0) - fractionalPosition, direction, anisotropicLength, lobe, clippingPoint, n); + + vec3 result = accumulatedColor / accumulatedWeight; + vec3 minimumColor = min(min(f, g), min(j, k)); + vec3 maximumColor = max(max(f, g), max(j, k)); + return clamp(result, minimumColor, maximumColor); +} + +void main(void) +{ + outColor = vec4(clamp(FsrEasu(texCoord), 0.0, 1.0), 1.0); +} diff --git a/sources/shaders-vk/fsr-easu.interface.glsl b/sources/shaders-vk/fsr-easu.interface.glsl new file mode 100644 index 00000000..0b806c25 --- /dev/null +++ b/sources/shaders-vk/fsr-easu.interface.glsl @@ -0,0 +1,11 @@ +// Program interface of fsr-easu (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and every other uniform is a record member. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, inputScene); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 inputTexelSize; +}; diff --git a/sources/shaders-vk/fsr-easu.vert b/sources/shaders-vk/fsr-easu.vert new file mode 100644 index 00000000..244b2fcd --- /dev/null +++ b/sources/shaders-vk/fsr-easu.vert @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of fsr-easu.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "fsr-easu.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/fsr-rcas.frag b/sources/shaders-vk/fsr-rcas.frag new file mode 100644 index 00000000..a10970be --- /dev/null +++ b/sources/shaders-vk/fsr-rcas.frag @@ -0,0 +1,35 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of fsr-rcas.fsh (docs/vulkan-native-shaders.md). +// AMD FidelityFX Super Resolution 1 RCAS, adapted for a fragment pass. +// FidelityFX FSR 1 source carries the MIT license, AMD 2021. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "fsr-rcas.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +void main(void) +{ + vec3 b = texture(optimumTextures2D[inputScene], texCoord + vec2(0.0, -inputTexelSize.y)).rgb; + vec3 d = texture(optimumTextures2D[inputScene], texCoord + vec2(-inputTexelSize.x, 0.0)).rgb; + vec3 e = texture(optimumTextures2D[inputScene], texCoord).rgb; + vec3 f = texture(optimumTextures2D[inputScene], texCoord + vec2(inputTexelSize.x, 0.0)).rgb; + vec3 h = texture(optimumTextures2D[inputScene], texCoord + vec2(0.0, inputTexelSize.y)).rgb; + + vec3 minimumRing = min(min(b, d), min(f, h)); + vec3 maximumRing = max(max(b, d), max(f, h)); + vec3 hitMinimum = min(minimumRing, e) / max(4.0 * maximumRing, vec3(1.0 / 65536.0)); + vec3 hitMaximumDenominator = min(4.0 * minimumRing - vec3(4.0), vec3(-1.0 / 65536.0)); + vec3 hitMaximum = (vec3(1.0) - max(maximumRing, e)) / hitMaximumDenominator; + vec3 lobeChannels = max(-hitMinimum, hitMaximum); + float lobe = max(-0.1875, min(max(max(lobeChannels.r, lobeChannels.g), lobeChannels.b), 0.0)); + lobe *= exp2(-0.2); + + vec3 sharpened = (lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0); + outColor = vec4(clamp(sharpened, 0.0, 1.0), 1.0); +} diff --git a/sources/shaders-vk/fsr-rcas.interface.glsl b/sources/shaders-vk/fsr-rcas.interface.glsl new file mode 100644 index 00000000..1b1fe8c2 --- /dev/null +++ b/sources/shaders-vk/fsr-rcas.interface.glsl @@ -0,0 +1,11 @@ +// Program interface of fsr-rcas (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and every other uniform is a record member. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, inputScene); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 inputTexelSize; +}; diff --git a/sources/shaders-vk/fsr-rcas.vert b/sources/shaders-vk/fsr-rcas.vert new file mode 100644 index 00000000..e251ffcf --- /dev/null +++ b/sources/shaders-vk/fsr-rcas.vert @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of fsr-rcas.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "fsr-rcas.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/scene-ssao.frag b/sources/shaders-vk/scene-ssao.frag new file mode 100644 index 00000000..013557e2 --- /dev/null +++ b/sources/shaders-vk/scene-ssao.frag @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of scene-ssao.fsh (docs/vulkan-native-shaders.md). The SSAOLEVEL > 1 preprocessor branch is a +// specialization-constant branch with the same expression; it gates no declaration, so no variant axes. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "scene-ssao.interface.glsl" + +layout(location = 0) in vec2 texCoord; +layout(location = 0) out vec4 outColor; +void main() +{ + float ao = texture(optimumTextures2D[ssaoScene], texCoord).r; + if (OPTIMUM_SSAOLEVEL > 1) { + ao = min(ao, texture(optimumTextures2D[ssaoScene], texCoord - vec2(0.0, invRenderHeight)).r); + } + // EnumBlendMode.Multiply: dstRGB * (1 - srcAlpha). RGB is not read. + outColor = vec4(0.0, 0.0, 0.0, 1.0 - clamp(ao, 0.0, 1.0)); +} diff --git a/sources/shaders-vk/scene-ssao.interface.glsl b/sources/shaders-vk/scene-ssao.interface.glsl new file mode 100644 index 00000000..cd6a52b1 --- /dev/null +++ b/sources/shaders-vk/scene-ssao.interface.glsl @@ -0,0 +1,11 @@ +// Program interface of scene-ssao (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and every other uniform is a record member. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, ssaoScene); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float invRenderHeight; +}; diff --git a/sources/shaders-vk/scene-ssao.vert b/sources/shaders-vk/scene-ssao.vert new file mode 100644 index 00000000..5060e01c --- /dev/null +++ b/sources/shaders-vk/scene-ssao.vert @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of scene-ssao.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "scene-ssao.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main() +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/taa-debug.frag b/sources/shaders-vk/taa-debug.frag new file mode 100644 index 00000000..00c7b17f --- /dev/null +++ b/sources/shaders-vk/taa-debug.frag @@ -0,0 +1,74 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-debug.fsh (docs/vulkan-native-shaders.md). +// +// Optimum TAA debug views (P1). Reads the Primary motion attachment, depth +// buffer and resolved/raw scene colour and visualises them for the developer +// debug switch OptimumConfig.TaaDebugView. Does not affect the normal blit +// path; only reached when a debug mode is selected and the motion +// attachment exists. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-debug.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +void main(void) +{ + ivec2 pixel = ivec2(clamp(texCoord * renderSize, vec2(0.0), renderSize - vec2(1.0))); + + // Motion attachment: rg = mv (render-resolution px, jitter excluded), + // b = reactive, a = writerDepth (NDC depth at write time, 0 when unwritten). + vec4 motion = texelFetch(optimumTextures2D[motionTex], pixel, 0); + float sceneDepth = texelFetch(optimumTextures2D[depthTex], pixel, 0).r; + + if (mode == 1) + { + // Motion as colour: map +/-16px to the full 0..1 range per channel. + vec2 mapped = clamp(motion.rg / 16.0, vec2(-1.0), vec2(1.0)) * 0.5 + 0.5; + outColor = vec4(mapped, 0.0, 1.0); + } + else if (mode == 2) + { + // Reactive mask (b channel) as greyscale. + outColor = vec4(vec3(motion.b), 1.0); + } + else if (mode == 3) + { + // Validity: green where the writer's recorded depth still matches the + // final depth buffer, red where it does not (occluded/overwritten, + // falls back to camera-motion reprojection), black where nothing wrote + // motion for this pixel at all. + if (motion.a == 0.0) + { + outColor = vec4(0.0, 0.0, 0.0, 1.0); + } + // Same tolerance as taa-resolve.fsh's `written` test: half precision on + // the RGBA16F alpha costs ~5e-4 near 1.0, so a fixed 1e-4 here reported + // mismatches for writers the resolve pass happily accepts. + else if (abs(motion.a - sceneDepth) <= max(2e-4, 8e-4 * sceneDepth)) + { + outColor = vec4(0.0, 1.0, 0.0, 1.0); + } + else + { + outColor = vec4(1.0, 0.0, 0.0, 1.0); + } + } + else if (mode == 4) + { + // Scene colour with a motion-vector colour overlay blended on top. + vec3 scene = texture(optimumTextures2D[sceneTex], texCoord).rgb; + vec2 mapped = clamp(motion.rg / 16.0, vec2(-1.0), vec2(1.0)) * 0.5 + 0.5; + vec3 overlay = vec3(mapped, 0.0); + outColor = vec4(mix(scene, overlay, 0.5), 1.0); + } + else + { + outColor = texture(optimumTextures2D[sceneTex], texCoord); + } +} diff --git a/sources/shaders-vk/taa-debug.interface.glsl b/sources/shaders-vk/taa-debug.interface.glsl new file mode 100644 index 00000000..0a2ccd8e --- /dev/null +++ b/sources/shaders-vk/taa-debug.interface.glsl @@ -0,0 +1,15 @@ +// Program interface of taa-debug (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots, in taa-debug.fsh's declaration order, and every other +// uniform is a record member. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, motionTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, depthTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, sceneTex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + int mode; + vec2 renderSize; +}; diff --git a/sources/shaders-vk/taa-debug.vert b/sources/shaders-vk/taa-debug.vert new file mode 100644 index 00000000..d8c34fe2 --- /dev/null +++ b/sources/shaders-vk/taa-debug.vert @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-debug.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-debug.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/taa-resolve.frag b/sources/shaders-vk/taa-resolve.frag new file mode 100644 index 00000000..26c88bcd --- /dev/null +++ b/sources/shaders-vk/taa-resolve.frag @@ -0,0 +1,316 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-resolve.fsh (the Optimum program in sources/shaders, docs/vulkan-native-shaders.md). +// The body is the GLSL 330 body token for token; the only differences are the bindless sampler reads +// (texture(optimumTextures2D[name], ...)). The CLAUDE.md rule-11 invariants - 3x3 nearest-depth +// disocclusion with motion from the nearest-depth tap, the closest-tap writer-depth tolerance and the +// luminance anti-flicker 0.3x..1.2x blendAlpha - are reproduced verbatim, with their DO NOT REVERT notes. +// +// Optimum TAA resolve (P2). One fullscreen pass per frame after all scene +// geometry: reprojects last frame's history by the motion attachment (or by +// camera motion from depth where nothing wrote a vector), rectifies it +// against the current 3x3 neighbourhood in YCoCg, and blends. Writes the new +// history: colour (RGBA16F, alpha = scene alpha), glow (RGBA8) and linear +// view depth (R32F) for next frame's disocclusion test. +// +// Conventions (see TAA-PLAN.md): motion = previousPixel - currentUnjitteredPixel +// in render pixels; a raster pixel centre sits at unjittered position +// centre - jitterPx; history is stored at unjittered pixel centres; the +// motion attachment's alpha is the writer's WINDOW depth in [0,1] (the same +// space as the depth attachment), not NDC depth. +// +// Samplers (push slots, taa-resolve.interface.glsl): +// sceneTex Primary colour 0, jittered +// glowTex Primary colour 1, jittered +// motionTex rg mv px, b reactive, a writerDepth [0,1] (0 = unwritten) +// depthTex Primary depth, [0,1], 0 = near +// historyColor previous resolve colour +// historyGlow previous resolve glow +// historyDepth previous resolve linear depth +// Record: renderSize; jitterPx (this frame's raster displacement); invViewProjJittered (raster NDC -> +// camera-relative world, this frame); prevViewProj (camera-relative world (previous camera) -> previous +// unjittered clip); viewMatrix (camera-relative world -> view, for linear depth); cameraDelta +// (currentCameraPos - previousCameraPos); resetHistory; blendAlpha (0.1 default); varianceGamma (1.25 default). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-resolve.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +layout(location = 2) out vec4 outDepth; + +vec3 rgbToYCoCg(vec3 c) { + return vec3(0.25 * c.r + 0.5 * c.g + 0.25 * c.b, + 0.5 * c.r - 0.5 * c.b, + -0.25 * c.r + 0.5 * c.g - 0.25 * c.b); +} + +vec3 yCoCgToRgb(vec3 c) { + return vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z); +} + +// Intersects the history colour with the neighbourhood box (clip, not clamp). +// `keep` reports how much of the history survived the clip: 1 when it was +// already inside the box, 1/maxUnit when it had to be pulled in. The alpha +// channel has no neighbourhood box of its own, so it is rectified toward the +// current alpha by this same factor instead of drifting unchecked. +vec3 clipToBox(vec3 boxMin, vec3 boxMax, vec3 history, out float keep) { + vec3 centre = 0.5 * (boxMax + boxMin); + vec3 extent = 0.5 * (boxMax - boxMin) + 1e-5; + vec3 offset = history - centre; + vec3 unit = abs(offset / extent); + float maxUnit = max(unit.x, max(unit.y, unit.z)); + keep = maxUnit > 1.0 ? 1.0 / maxUnit : 1.0; + return maxUnit > 1.0 ? centre + offset / maxUnit : history; +} + +// 9-tap Catmull-Rom on a bilinear sampler (the usual 5-tap optimisation would +// drop corners; keep the full quality for history colour). +vec4 sampleCatmullRom(sampler2D tex, vec2 uv) { + vec2 samplePos = uv * renderSize; + vec2 texPos1 = floor(samplePos - 0.5) + 0.5; + vec2 f = samplePos - texPos1; + vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f)); + vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f); + vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f)); + vec2 w3 = f * f * (-0.5 + 0.5 * f); + vec2 w12 = w1 + w2; + vec2 offset12 = w2 / w12; + vec2 texPos0 = (texPos1 - 1.0) / renderSize; + vec2 texPos3 = (texPos1 + 2.0) / renderSize; + vec2 texPos12 = (texPos1 + offset12) / renderSize; + vec4 result = vec4(0.0); + result += texture(tex, vec2(texPos0.x, texPos0.y)) * w0.x * w0.y; + result += texture(tex, vec2(texPos12.x, texPos0.y)) * w12.x * w0.y; + result += texture(tex, vec2(texPos3.x, texPos0.y)) * w3.x * w0.y; + result += texture(tex, vec2(texPos0.x, texPos12.y)) * w0.x * w12.y; + result += texture(tex, vec2(texPos12.x, texPos12.y)) * w12.x * w12.y; + result += texture(tex, vec2(texPos3.x, texPos12.y)) * w3.x * w12.y; + result += texture(tex, vec2(texPos0.x, texPos3.y)) * w0.x * w3.y; + result += texture(tex, vec2(texPos12.x, texPos3.y)) * w12.x * w3.y; + result += texture(tex, vec2(texPos3.x, texPos3.y)) * w3.x * w3.y; + return max(result, vec4(0.0)); +} + +float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); } + +void main(void) +{ + vec2 invSize = 1.0 / renderSize; + ivec2 pixel = ivec2(clamp(texCoord * renderSize, vec2(0.0), renderSize - vec2(1.0))); + vec2 pixelCentre = vec2(pixel) + 0.5; + + // ---- current frame: 3x3 neighbourhood, un-jittered reconstruction and statistics + vec4 centreSample = texelFetch(optimumTextures2D[sceneTex], pixel, 0); + // Nearest window depth in the 3x3 (0 = near): its motion and its linear depth + // drive the reprojection and the disocclusion test, so a sub-pixel leaf in front + // of a far background keeps one consistent answer across jitter phases. + float closestDepth = 2.0; + ivec2 closestPixel = pixel; + vec4 filtered = vec4(0.0); + float filteredWeight = 0.0; + vec3 m1 = vec3(0.0), m2 = vec3(0.0); + vec3 boxMin = vec3(1e9), boxMax = vec3(-1e9); + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + { + ivec2 p = clamp(pixel + ivec2(x, y), ivec2(0), ivec2(renderSize) - ivec2(1)); + vec4 c = texelFetch(optimumTextures2D[sceneTex], p, 0); + float tapDepth = texelFetch(optimumTextures2D[depthTex], p, 0).r; + if (tapDepth < closestDepth) { closestDepth = tapDepth; closestPixel = p; } + vec3 ycc = rgbToYCoCg(c.rgb); + m1 += ycc; m2 += ycc * ycc; + boxMin = min(boxMin, ycc); boxMax = max(boxMax, ycc); + // Reconstruct at this pixel's unjittered centre. The tap's raster + // centre (pixel + (x,y) + 0.5) sits at unjittered position + // pixelCentre + (x,y) - jitterPx, so its offset from the + // reconstruction point is (x, y) - jitterPx. Blackman-Harris, radius ~1. + vec2 d = vec2(x, y) - jitterPx; + float r = length(d); + float w = r < 1.0 ? (0.35875 + 0.48829 * cos(3.14159265 * r) + 0.14128 * cos(2.0 * 3.14159265 * r) + 0.01168 * cos(3.0 * 3.14159265 * r)) : 0.0; + filtered += c * w; filteredWeight += w; + } + vec4 current = filteredWeight > 1e-4 ? filtered / filteredWeight : centreSample; + current = max(current, vec4(0.0)); + vec3 mu = m1 / 9.0; + vec3 sigma = sqrt(max(m2 / 9.0 - mu * mu, vec3(0.0))); + vec3 clipMin = max(boxMin, mu - varianceGamma * sigma); + vec3 clipMax = min(boxMax, mu + varianceGamma * sigma); + + // ---- depth and linear view depth of this pixel + float depth = texelFetch(optimumTextures2D[depthTex], pixel, 0).r; + vec2 ndc = pixelCentre * invSize * 2.0 - 1.0; + vec4 worldH = invViewProjJittered * vec4(ndc, depth * 2.0 - 1.0, 1.0); + vec3 world = worldH.xyz / max(abs(worldH.w), 1e-6) * sign(worldH.w); + float linearDepth = -(viewMatrix * vec4(world, 1.0)).z; + vec2 closestCentre = vec2(closestPixel) + 0.5; + vec2 closestNdc = closestCentre * invSize * 2.0 - 1.0; + vec4 closestH = invViewProjJittered * vec4(closestNdc, closestDepth * 2.0 - 1.0, 1.0); + vec3 closestWorld = closestH.xyz / max(abs(closestH.w), 1e-6) * sign(closestH.w); + float closestLinearDepth = -(viewMatrix * vec4(closestWorld, 1.0)).z; + + vec4 glow = texelFetch(optimumTextures2D[glowTex], pixel, 0); + + // ---- motion: written vector when its depth matches, else camera reprojection + float reactive = clamp(texelFetch(optimumTextures2D[motionTex], pixel, 0).b, 0.0, 1.0); + vec4 motion = texelFetch(optimumTextures2D[motionTex], closestPixel, 0); + vec2 currentUnjittered = closestCentre - jitterPx; + vec2 mv; + // motion.a is the writer's window depth in [0,1], stored in an RGBA16F + // attachment: half precision alone costs ~5e-4 near 1.0, so the tolerance + // has to scale with the value and keep a floor for depths near the near + // plane. A fixed 1e-4 rejected every legitimate writer past mid-range. + bool written = motion.a > 0.0 && abs(motion.a - closestDepth) <= max(2e-4, 8e-4 * closestDepth); + if (written) + { + mv = motion.rg; + } + else + { + // Sky (depth == 1, nothing wrote depth) is a direction, not a point: + // reproject it with w = 0 so camera translation cannot move it (plan: + // "infinite-direction reprojection where depth == 1"). Finite surfaces + // translate by cameraDelta into the previous camera's frame. + bool sky = closestDepth >= 0.999999; + // The sky direction is far point minus near point, never the far point's + // position alone: the view matrix's eye sits ~1.7 blocks above the origin + // (CameraMatrixOrigin is a look-at from LocalEyePos), and that offset in a + // "direction" is a fixed ~0.6 px error at 3000 blocks. Homogeneous + // difference with the sign of worldH.w * nearH.w, w == 0 counting as + // positive, exactly as taa-skymotion.fsh does. + vec4 nearH = invViewProjJittered * vec4(closestNdc, -1.0, 1.0); + vec3 skyDirection = closestH.xyz * nearH.w - nearH.xyz * closestH.w; + if ((closestH.w < 0.0) != (nearH.w < 0.0)) skyDirection = -skyDirection; + vec4 prevClip = sky ? prevViewProj * vec4(skyDirection, 0.0) + : prevViewProj * vec4(closestWorld + cameraDelta, 1.0); + if (prevClip.w <= 1e-6) { outColor = current; outGlow = glow; outDepth = vec4(linearDepth); return; } + vec2 prevPixel = (prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize; + mv = prevPixel - currentUnjittered; + } + // The history grid is the unjittered pixel-centre grid (see the + // reconstruction kernel above), so the lookup anchor is pixelCentre; mv is + // a displacement field, and subtracting the jitter here would re-sample the + // converged history at a different sub-pixel offset every frame - exactly + // the wobble jitter is supposed to remove. + vec2 historyUv = (pixelCentre + mv) * invSize; + + // ---- history sample and rejection + float alpha = blendAlpha; + bool offscreen = any(lessThan(historyUv, vec2(0.0))) || any(greaterThan(historyUv, vec2(1.0))); + bool rejected = resetHistory != 0 || offscreen; + if (rejected) alpha = 1.0; + + vec4 history = sampleCatmullRom(optimumTextures2D[historyColor], historyUv); + vec4 historyGlowSample = texture(optimumTextures2D[historyGlow], historyUv); + float historyLinear = texture(optimumTextures2D[historyDepth], historyUv).r; + // A history slot that was never written (freshly allocated after a + // framebuffer rebuild) or that caught a division blow-up holds NaN/Inf, + // and NaN survives any weighted blend, poisoning the pixel forever. Treat + // it exactly like a reset: this frame's own values, full current weight. + if (any(isnan(history)) || any(isinf(history)) + || any(isnan(historyGlowSample)) || any(isinf(historyGlowSample)) + || isnan(historyLinear) || isinf(historyLinear)) + { + history = current; + historyGlowSample = glow; + historyLinear = linearDepth; + alpha = 1.0; + rejected = true; + } + // Disocclusion: the surface seen last frame at that location must be at a + // comparable distance. Tolerance grows with distance; camera translation + // along the view axis is covered by the relative term. A disoccluded pixel + // has no valid history at all, so it is rejected outright - half-rejecting + // it just blends in whatever surface used to be in front. + // ==== 2026-09-11: distant foliage jitter was THIS test ==================== + // Root cause: a single-sample depth test (this pixel's linear depth against + // the one history depth under historyUv) rejected history on ~3.7% of distant + // leaf pixels per frame, on BOTH backends (parity dumps). A sub-pixel leaf + // covers the leaf in one jitter phase and the far background in the next, so + // the two depths disagree by tens of blocks and the pixel reset to the raw + // aliased sample - the shimmer the user saw on distant trees. + // Fix: the nearest current depth in the 3x3 (closestLinearDepth, the tap the + // motion vector also comes from) against the nearest finite history depth in + // the 3x3 around historyUv, tolerance 0.5 + 0.08 * closestLinearDepth. A leaf + // that moves one pixel between phases stays inside both windows and keeps its + // history. Measured: leaf-far rejection ~3.7% -> ~1.1% per frame; the user + // confirmed on Vulkan that the distant-foliage flicker is gone. + // Guard: scripts/dev/taa-rejection.py on a parity dump (3x3 leaf-far <= 1.5%). + // DO NOT REVERT to a single-sample depth test. Pinned by + // TaaResolveTests.FlippingSubPixelLeafKeepsItsHistory, + // TaaResolveTests.DisocclusionLargerThanTheNeighbourhoodStillResets, + // TaaResolveTests.MotionComesFromTheNearestDepthTapAtAnEdge and + // Optimum.Tests TaaAntiFlickerCoverageTests. + // ========================================================================== + // Nearest history depth in the 3x3 around the reprojected point, against the + // nearest current depth: a single-sample test flips on sub-pixel foliage every + // few frames (leaf in one jitter phase, background in the next) and threw the + // history away on ~3.7% of distant leaf pixels per frame. + float historyNearest = historyLinear; + for (int hy = -1; hy <= 1; hy++) + for (int hx = -1; hx <= 1; hx++) + { + float h = texture(optimumTextures2D[historyDepth], historyUv + vec2(hx, hy) * invSize).r; + if (!isnan(h) && !isinf(h)) historyNearest = min(historyNearest, h); + } + float depthTolerance = 0.5 + 0.08 * closestLinearDepth; + if (abs(historyNearest - closestLinearDepth) > depthTolerance) { alpha = 1.0; rejected = true; } + + // ---- rectify and blend in YCoCg with luminance weighting + float clipKeep = 1.0; + vec3 histYcc = clipToBox(clipMin, clipMax, rgbToYCoCg(history.rgb), clipKeep); + vec3 curYcc = rgbToYCoCg(current.rgb); + // ==== 2026-09-11: distant foliage jitter, second half ===================== + // Root cause: with a fixed current weight (blendAlpha for every pixel that + // survived rejection), a sub-pixel leaf that enters and leaves the 3x3 moves + // the neighbourhood clip box every frame, and the clip drags the history + // with it at full blendAlpha - the history itself oscillates. + // Fix: current weight mix(1.2, 0.3, w * w) * blendAlpha with + // w = 1 - |lumCur - lumHist| / max(lumCur, max(lumHist, 0.2)) on the + // rectified YCoCg luminance, only for pixels not rejected above (reset, + // off-screen, NaN history, disocclusion keep alpha = 1); reactive is applied + // after it. Together with the 3x3 nearest-depth test above this took distant + // leaf rejection from ~3.7% to ~1.1% per frame and removed the flicker in game. + // DO NOT REVERT to a fixed blend weight. Pinned by + // TaaResolveTests.AntiFlickerWeightsFollowTheLuminanceDifference and + // Optimum.Tests TaaAntiFlickerCoverageTests. + // ========================================================================== + // Anti-flicker feedback (Playdead INSIDE TAA, 2016): a sub-pixel feature that + // appears in some jitter phases and not in others moves the neighbourhood box + // every frame, and a fixed current weight lets the clip drag the history back + // and forth - the shimmer on distant foliage. Weight the current sample by how + // different it is from the rectified history in luminance: near-identical + // pixels keep more history (0.3 x blendAlpha), real changes take more of the + // current frame (1.2 x blendAlpha). Rejected pixels keep their full reset. + if (!rejected) + { + float lumCur = max(curYcc.x, 0.0); + float lumHist = max(histYcc.x, 0.0); + float unbiasedDiff = abs(lumCur - lumHist) / max(lumCur, max(lumHist, 0.2)); + float unbiasedWeight = 1.0 - unbiasedDiff; + alpha = mix(blendAlpha * 1.2, blendAlpha * 0.3, unbiasedWeight * unbiasedWeight); + } + alpha = max(alpha, reactive); + float wCur = alpha / (1.0 + curYcc.x); + float wHist = (1.0 - alpha) / (1.0 + histYcc.x); + vec3 resolvedYcc = (curYcc * wCur + histYcc * wHist) / max(wCur + wHist, 1e-5); + vec3 resolved = max(yCoCgToRgb(resolvedYcc), vec3(0.0)); + // Rectify the history alpha by the same factor the colour clip applied, + // then blend it with the same weight, so scene alpha cannot drift away + // from the colour it belongs to. + float histAlpha = mix(current.a, history.a, clipKeep); + float resolvedAlpha = mix(histAlpha, current.a, alpha); + + // Glow blends with the same alpha as colour: a separate 0.2 floor made the + // two signals converge at different rates, so bloom lagged or led the image + // it is derived from. + vec4 resolvedGlow = mix(historyGlowSample, glow, alpha); + + outColor = vec4(resolved, resolvedAlpha); + outGlow = resolvedGlow; + outDepth = vec4(linearDepth); +} diff --git a/sources/shaders-vk/taa-resolve.interface.glsl b/sources/shaders-vk/taa-resolve.interface.glsl new file mode 100644 index 00000000..3ab695cd --- /dev/null +++ b/sources/shaders-vk/taa-resolve.interface.glsl @@ -0,0 +1,26 @@ +// Program interface of taa-resolve (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots, in taa-resolve.fsh's declaration order, and every other +// uniform is a record member in declaration order. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, sceneTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, glowTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, motionTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, depthTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, historyColor); + OPTIMUM_SAMPLER_SLOT(sampler2D, historyGlow); + OPTIMUM_SAMPLER_SLOT(sampler2D, historyDepth); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 renderSize; + vec2 jitterPx; + mat4 invViewProjJittered; + mat4 prevViewProj; + mat4 viewMatrix; + vec3 cameraDelta; + int resetHistory; + float blendAlpha; + float varianceGamma; +}; diff --git a/sources/shaders-vk/taa-resolve.vert b/sources/shaders-vk/taa-resolve.vert new file mode 100644 index 00000000..6f7e6a1b --- /dev/null +++ b/sources/shaders-vk/taa-resolve.vert @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-resolve.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-resolve.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/taa-sharpen.frag b/sources/shaders-vk/taa-sharpen.frag new file mode 100644 index 00000000..970ee69a --- /dev/null +++ b/sources/shaders-vk/taa-sharpen.frag @@ -0,0 +1,64 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-sharpen.fsh (docs/vulkan-native-shaders.md). +// +// Optimum TAA (P5): post-resolve sharpening. +// +// AMD FidelityFX Super Resolution 1 RCAS, adapted for a fragment pass exactly +// as fsr-rcas.fsh is, with two differences that the TAA placement requires. +// FidelityFX FSR 1 source carries the MIT license, AMD 2021. +// +// 1. The lobe strength is a uniform instead of the baked exp2(-0.2) constant, +// so OptimumConfig.TaaSharpness drives it. sharpness <= 0 is a TRUE bypass: +// the centre texel is returned untouched, bit for bit, without going +// through the filter or any clamp. That is what makes "TAA sharpen off" +// indistinguishable from not running the pass at all. +// 2. This pass runs on the resolved HDR colour (RGBA16F), not on the LDR +// image RCAS normally finishes. Clamping the result to [0,1] the way +// fsr-rcas.fsh does would crush every highlight above 1, so only the +// lower bound is kept. RCAS's own lobe term already guards the upper end: +// above 1 its hitMaximum turns positive, the lobe clamps to 0 and such a +// pixel is passed through unsharpened rather than being driven anywhere. +// +// sharpness (record): 0 = bypass (see above), 1 = full RCAS strength. Mapped onto RCAS's own +// attenuation in stops: sharpness 1 is 0 stops of attenuation, and the linear +// factor in front takes the lobe continuously to zero as sharpness does, so +// there is no step between "almost off" and the bypass. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-sharpen.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +void main(void) +{ + vec4 center = texture(optimumTextures2D[inputScene], texCoord); + if (!(sharpness > 0.0)) + { + outColor = center; + return; + } + + vec3 b = texture(optimumTextures2D[inputScene], texCoord + vec2(0.0, -inputTexelSize.y)).rgb; + vec3 d = texture(optimumTextures2D[inputScene], texCoord + vec2(-inputTexelSize.x, 0.0)).rgb; + vec3 e = center.rgb; + vec3 f = texture(optimumTextures2D[inputScene], texCoord + vec2(inputTexelSize.x, 0.0)).rgb; + vec3 h = texture(optimumTextures2D[inputScene], texCoord + vec2(0.0, inputTexelSize.y)).rgb; + + vec3 minimumRing = min(min(b, d), min(f, h)); + vec3 maximumRing = max(max(b, d), max(f, h)); + vec3 hitMinimum = min(minimumRing, e) / max(4.0 * maximumRing, vec3(1.0 / 65536.0)); + vec3 hitMaximumDenominator = min(4.0 * minimumRing - vec3(4.0), vec3(-1.0 / 65536.0)); + vec3 hitMaximum = (vec3(1.0) - max(maximumRing, e)) / hitMaximumDenominator; + vec3 lobeChannels = max(-hitMinimum, hitMaximum); + float lobe = max(-0.1875, min(max(max(lobeChannels.r, lobeChannels.g), lobeChannels.b), 0.0)); + float strength = clamp(sharpness, 0.0, 1.0); + lobe *= strength * exp2(-2.0 * (1.0 - strength)); + + vec3 sharpened = (lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0); + outColor = vec4(max(sharpened, vec3(0.0)), center.a); +} diff --git a/sources/shaders-vk/taa-sharpen.interface.glsl b/sources/shaders-vk/taa-sharpen.interface.glsl new file mode 100644 index 00000000..4e15ea40 --- /dev/null +++ b/sources/shaders-vk/taa-sharpen.interface.glsl @@ -0,0 +1,12 @@ +// Program interface of taa-sharpen (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slot and every other uniform is a record member. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, inputScene); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 inputTexelSize; + float sharpness; +}; diff --git a/sources/shaders-vk/taa-sharpen.vert b/sources/shaders-vk/taa-sharpen.vert new file mode 100644 index 00000000..7cd86991 --- /dev/null +++ b/sources/shaders-vk/taa-sharpen.vert @@ -0,0 +1,24 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-sharpen.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-sharpen.interface.glsl" + +// Optimum TAA (P5): the sharpen pass's vertex stage - the same fullscreen +// triangle fsr-rcas.vsh generates from gl_VertexID, with no vertex inputs. + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/taa-skymotion.frag b/sources/shaders-vk/taa-skymotion.frag new file mode 100644 index 00000000..7f53001c --- /dev/null +++ b/sources/shaders-vk/taa-skymotion.frag @@ -0,0 +1,124 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-skymotion.fsh (docs/vulkan-native-shaders.md). +// +// Variant axes: TAAMOTION (the output set) and GBUFFER (TAAMOTIONLOCATION: 4 with the G-buffer, else 2). +// The TAA-off variant keeps the GLSL 330 dummy output: one vec4 at location 0 holding zero. The motion +// value goes through include/motion.glsl (section 7): optimumWriteMotion's behind-camera branch returns +// the same vec4(0, 0, reactive, 0) the GLSL 330 early return wrote, and its vector is the same three +// lines in the same order; the dummy's vec4(0.0) is optimumWriteReactiveOnly(0.0). +// +// Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. +// +// TAA-PLAN.md's inventory row for "Clouds (volumetric, map), aurora, night sky, +// sun/moon, sky colour" reads "fallback + reactive; sky uses infinite-direction +// reprojection (P4)". This pass is that row. +// +// WHAT ALREADY WORKED WITHOUT IT. Sky colour, the night sky, the sun and the +// moon all draw on Primary with the depth test off or the depth mask off, so +// they leave the depth buffer at 1.0 and never touch the motion attachment. +// taa-resolve.fsh's writer-depth test then fails (motion.a is 0), and its +// camera fallback unprojects a depth of 1 - a point at infinity - and +// reprojects it through the previous view-projection. That is already the +// infinite-direction reprojection those layers need, and it is exact for +// anything painted on the celestial sphere. None of them needs a writer, and +// none is given one. +// +// WHAT DID NOT. Volumetric clouds and the aurora are drawn into the Transparent +// target during the OIT stage, where Primary's motion attachment does not +// exist, and they MOVE independently of the camera: the cloud map scrolls with +// cloudOffset and the aurora's noise animates with auroraCounter. Reprojected +// by camera rotation alone their history lands on the cloud that used to be +// there, and a cloud edge sweeping across the sky smears. They need a reactive +// value, and the only place their coverage is known per pixel is the Transparent +// target's revealage attachment - the same texture transparentcompose.fsh reads +// as `revealage` and turns into `anet`. +// +// So this pass runs on Primary after the OIT merge, covers the sky pixels only +// (see the vertex shader), and writes: +// rg = the camera-ROTATION-only reprojection of this pixel's view direction, +// b = the reactive value, scaled by how much transparent content covers the +// pixel, and +// a = gl_FragCoord.z = 1.0, which matches the depth buffer at every pixel +// this pass survives, so the resolve accepts the vector instead of +// recomputing its own. +// +// A clear-sky pixel comes out with coverage 0 and therefore reactive 0: the sky +// gradient is dithered (ShaderProgramSky's DitherSeed) and is exactly the kind +// of static, noisy signal temporal accumulation is best at, so it keeps its +// full history weight. +// +// Record (taa-skymotion.interface.glsl): transparentRevealTex is Transparent colour 1 (oit.fsh's outReveal); +// taaRenderSize is the render-target size in pixels; taaJitterPx this frame's sub-pixel shear in pixels; +// taaInvViewProjJittered raster NDC -> camera-relative world (this frame); taaPrevViewProj camera-relative +// world -> previous unjittered clip; taaCloudReactive the reactive value a fully covered sky pixel gets. +// 1 = never trust the history there. Partial coverage interpolates towards it from the coverage itself, so +// a wisp at 20% alpha is not treated like a solid cloud bank. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-skymotion.interface.glsl" + +#if TAAMOTION == 1 +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#else +// TAA off: the pass never runs (ClientPlatformWindows.RenderOptimumSkyMotion +// returns before binding it), but the program is still registered and compiled, +// and a fragment stage with no output at all is not worth handing to two shader +// translators. One dummy attachment keeps it trivially valid. +layout(location = 0) out vec4 outMotion; +#endif + +layout(location = 0) in vec2 texCoord; + +#include "motion.glsl" + +void main() +{ +#if TAAMOTION == 1 + // The transparent layer's coverage of this pixel, which is 1 - revealage - + // the identical term transparentcompose.fsh composites with. Clouds + // multiply their own (1 - density) into that attachment through the + // per-attachment blend factors SystemRenderOITLayers sets, the aurora and + // the quad particles through oit.fsh's outReveal, so every transparent + // thing that can sit in front of the sky is in here. + float coverage = clamp(1.0 - texelFetch(optimumTextures2D[transparentRevealTex], ivec2(gl_FragCoord.xy), 0).r, 0.0, 1.0); + float reactive = mix(coverage, clamp(taaCloudReactive, 0.0, 1.0), coverage); + + // The view direction through this raster position. The inverse projection + // is the JITTERED one, so the direction belongs to the sample that was + // actually taken, not to the pixel centre. + vec2 ndc = gl_FragCoord.xy / taaRenderSize * 2.0 - 1.0; + vec4 farH = taaInvViewProjJittered * vec4(ndc, 1.0, 1.0); + vec4 nearH = taaInvViewProjJittered * vec4(ndc, -1.0, 1.0); + // The far point's position is NOT the view direction: CameraMatrixOrigin is + // a look-at with the eye at LocalEyePos (~1.7 blocks above the origin), so + // every reconstructed point carries that eye offset, and treating the far + // point as a direction projected it into a fixed ~0.6 px vertical error on + // every sky vector (1.7 / 3000 far-plane blocks, at 745 rows over tan 35 deg). + // far - near cancels the eye position exactly. Kept homogeneous - the + // difference of the two points scaled by farH.w * nearH.w - so a projection + // whose far plane sits at infinity (farH.w == 0) still yields a direction. + // The sign carries the product's sign; farH.w == 0 counts as positive, which + // is the +farH.xyz the pass has always used at infinity. + vec3 direction = farH.xyz * nearH.w - nearH.xyz * farH.w; + if ((farH.w < 0.0) != (nearH.w < 0.0)) direction = -direction; + + // w = 0 drops the previous view-projection's translation column, which is + // exactly "the camera may have rotated, it may not have moved": a point on + // the celestial sphere does not parallax. + vec4 prevClip = taaPrevViewProj * vec4(direction, 0.0); + // Behind the previous camera (prevClip.w <= 1e-6) is not a motion vector: the include returns a zero + // alpha, which routes the pixel back to the resolve's own camera fallback, exactly as in + // chunkopaque.fsh; the reactive value is still delivered, because taa-resolve.fsh reads motion.b + // whether or not the pixel was accepted. + outMotion = optimumWriteMotion(prevClip, taaRenderSize, taaJitterPx, reactive, gl_FragCoord.z); +#else + outMotion = optimumWriteReactiveOnly(0.0); +#endif +} diff --git a/sources/shaders-vk/taa-skymotion.interface.glsl b/sources/shaders-vk/taa-skymotion.interface.glsl new file mode 100644 index 00000000..3031e352 --- /dev/null +++ b/sources/shaders-vk/taa-skymotion.interface.glsl @@ -0,0 +1,19 @@ +// Program interface of taa-skymotion (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw +// per Use(), so the push block holds only the sampler slot and every other uniform is a record member. +// +// The GLSL 330 source declares all of these inside #if TAAMOTION > 0; the oracle reads the unpreprocessed +// text, so they are names of every variant and are declared unconditionally here. taaCloudReactive's GLSL 330 +// initializer (1.0) is seeded by the runtime (docs/vulkan-native-shaders.md section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, transparentRevealTex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec2 taaRenderSize; + vec2 taaJitterPx; + mat4 taaInvViewProjJittered; + mat4 taaPrevViewProj; + float taaCloudReactive; +}; diff --git a/sources/shaders-vk/taa-skymotion.vert b/sources/shaders-vk/taa-skymotion.vert new file mode 100644 index 00000000..a940d4bd --- /dev/null +++ b/sources/shaders-vk/taa-skymotion.vert @@ -0,0 +1,36 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of taa-skymotion.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "taa-skymotion.interface.glsl" + +// Optimum TAA (P4): the vertex half of the sky/cloud motion pass. +// +// A fullscreen triangle generated from gl_VertexID, exactly like the other +// Optimum post passes (taa-resolve, fsr-easu), with one difference that is the +// whole point of the pass: gl_Position.z equals gl_Position.w, so the NDC depth +// is 1 and the window depth is 1.0 - the far plane, which is the value Primary's +// depth attachment still holds wherever no geometry was drawn. The depth remap +// at the end keeps that: (1 + 1) * 0.5 = 1 = w. +// +// With the depth test on and GL_LEQUAL the triangle therefore passes on sky +// pixels only (1.0 <= 1.0) and is rejected by every pixel any surface wrote +// depth for (depth < 1.0). That is what keeps this pass from overwriting the +// motion vectors the terrain, entity, liquid and particle writers already put +// down. The pass never writes depth itself. + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 1.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From e4ef88eaa6193f9a63afb09ace7b4080d9781529 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:32:48 +0200 Subject: [PATCH 163/226] wip(native-shaders): chunks - chunkopaque, chunktopsoil, chunktransparent, chunkliquid, chunkliquiddepth, chunkshadowmap 6 programs, 33 variants (chunkopaque 16, chunktopsoil 8, chunktransparent 4, chunkliquid 2, chunkshadowmap 2 incl. the NoSSBOs USESSBO=0 variant, chunkliquiddepth 1). Family decisions recorded in docs/vulkan-native-shaders.md section 9. Verified: NativeShaderParityTests 19/19 (spirv-val on PATH), full Optimum.Render.Vulkan.Tests 870/870 with implicit layers disabled, Optimum.Tests -c Release 1184 passed / 34 skipped / 0 failed. Not run in game. --- docs/vulkan-native-shaders.md | 32 ++ sources/shaders-vk/chunkliquid.frag | 309 ++++++++++++++++++ sources/shaders-vk/chunkliquid.interface.glsl | 54 +++ sources/shaders-vk/chunkliquid.vert | 130 ++++++++ sources/shaders-vk/chunkliquiddepth.frag | 16 + .../chunkliquiddepth.interface.glsl | 28 ++ sources/shaders-vk/chunkliquiddepth.vert | 45 +++ sources/shaders-vk/chunkopaque.frag | 220 +++++++++++++ sources/shaders-vk/chunkopaque.interface.glsl | 53 +++ sources/shaders-vk/chunkopaque.vert | 231 +++++++++++++ sources/shaders-vk/chunkshadowmap.frag | 20 ++ .../shaders-vk/chunkshadowmap.interface.glsl | 28 ++ sources/shaders-vk/chunkshadowmap.vert | 60 ++++ sources/shaders-vk/chunktopsoil.frag | 137 ++++++++ .../shaders-vk/chunktopsoil.interface.glsl | 49 +++ sources/shaders-vk/chunktopsoil.vert | 131 ++++++++ sources/shaders-vk/chunktransparent.frag | 68 ++++ .../chunktransparent.interface.glsl | 37 +++ sources/shaders-vk/chunktransparent.vert | 98 ++++++ 19 files changed, 1746 insertions(+) create mode 100644 sources/shaders-vk/chunkliquid.frag create mode 100644 sources/shaders-vk/chunkliquid.interface.glsl create mode 100644 sources/shaders-vk/chunkliquid.vert create mode 100644 sources/shaders-vk/chunkliquiddepth.frag create mode 100644 sources/shaders-vk/chunkliquiddepth.interface.glsl create mode 100644 sources/shaders-vk/chunkliquiddepth.vert create mode 100644 sources/shaders-vk/chunkopaque.frag create mode 100644 sources/shaders-vk/chunkopaque.interface.glsl create mode 100644 sources/shaders-vk/chunkopaque.vert create mode 100644 sources/shaders-vk/chunkshadowmap.frag create mode 100644 sources/shaders-vk/chunkshadowmap.interface.glsl create mode 100644 sources/shaders-vk/chunkshadowmap.vert create mode 100644 sources/shaders-vk/chunktopsoil.frag create mode 100644 sources/shaders-vk/chunktopsoil.interface.glsl create mode 100644 sources/shaders-vk/chunktopsoil.vert create mode 100644 sources/shaders-vk/chunktransparent.frag create mode 100644 sources/shaders-vk/chunktransparent.interface.glsl create mode 100644 sources/shaders-vk/chunktransparent.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 22d93014..0e7cd1ff 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -438,3 +438,35 @@ Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage its differential GPU test in its stage. 7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. + +### Family 3: chunks (2026-09-15) + +`chunkopaque`, `chunktopsoil`, `chunktransparent`, `chunkliquid`, `chunkliquiddepth`, `chunkshadowmap` (its +`USESSBO=0` variant is `Chunkshadowmap_NoSSBOs`). Decisions this family took: +- **Axes.** `chunkopaque`: `GBUFFER`, `GREEDYMESH`, `TAAMOTION`, `USESSBO` (16 variants); `chunktopsoil`: `GBUFFER`, + `TAAMOTION`, `USESSBO`; `chunktransparent`: `USEOIT`, `USESSBO`; `chunkliquid`: `USEOIT`; `chunkshadowmap`: + `USESSBO`; `chunkliquiddepth`: none. A GLSL 330 `#if SSAOLEVEL > 0` that gates a varying or an output becomes + `#if GBUFFER == 1` (in both stages, including the fragment `in vec4 gnormal` the GLSL 330 stage declared + unconditionally), and `layout(location = TAAMOTIONLOCATION)` becomes location 4 under `GBUFFER == 1`, else 2. +- **`USEOIT` on the chunk OIT programs.** `oit.glsl` gates its outputs and `OIT()` on `USEOIT`, so both programs + branch on it although the client only links them with `Oit = true`. The `USEOIT=0` variant wraps the `OIT()` + call in `#if USEOIT > 0` and has no outputs, exactly what the GLSL 330 stage preprocesses to. +- **Varying locations.** With every axis on `chunkopaque` has 17 varyings of its own; `lod0Fade` and `nb` + share location 8 as components 0 and 1 rather than leave locations 0-15. `chunkliquid.fsh` declares + `flat in int renderFoam`, which no stage writes or reads; it keeps location 14 and the optimiser drops it. +- **Cross-stage owners.** Vertex stages that read `lightPosition`/`shadowIntensity` (`chunkopaque`) define + `OPTIMUM_FRAME_OWNER_FOGANDLIGHT_FSH`; every shaded fragment stage defines `OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH` + and `OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH` (`fogandlight.frag.glsl`, `fogspheres.glsl`, and `chunkliquid.fsh`'s own + `waterWaveCounter`/`windSpeed`). Names a program declares itself but whose owner it includes + (`chunkopaque.vsh`'s `cameraUnderwater`, `shadowIntensity`, `lightPosition`) are frame members, not record + members. `chunkliquiddepth` includes no owner of `viewDistance`, so there it is a record member. +- **Placement.** Push: sampler slots, `origin`, `modelViewMatrix` (`mvpMatrix` for the shadow map), and + `forcedTransparency` for `chunktransparent`: 76-84 B. Everything else, `projectionMatrix` and the twelve + `vertexwarp.glsl` `prev*` members included, is record. +- **Braces across a spec-constant `#if`.** `chunkliquid.fsh` opens `if (skyExposed > 0) {` under + `#if SHADOWQUALITY == 0` and closes it under a second one; the port is + `if (OPTIMUM_SHADOWQUALITY != 0 || skyExposed > 0) {`, the same control flow. +- **Motion.** `chunkopaque` and `chunktopsoil` write `optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, + 0.0, gl_FragCoord.z)`; the previous-position reconstruction in the vertex stages is unchanged. +- **Initializers** the runtime seeds (section 8): `chunktopsoil`'s `alphaTest = 0.01`, `chunkliquid`'s + `dropletIntensity = 0`. diff --git a/sources/shaders-vk/chunkliquid.frag b/sources/shaders-vk/chunkliquid.frag new file mode 100644 index 00000000..40152eb6 --- /dev/null +++ b/sources/shaders-vk/chunkliquid.frag @@ -0,0 +1,309 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkliquid.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axis: USEOIT, through include/oit.glsl, which declares the six OIT outputs and OIT() only when it is 1. +// The client registers chunkliquid with Oit = true (ShaderProgramBase's default), so USEOIT=0 is never +// linked; the GLSL 330 program has no outputs and no OIT() there either, so the 0 variant skips the call and +// writes nothing. FOAMEFFECT and SHADOWQUALITY are specialization-constant branches. +// +// waterWaveCounter, windSpeed and the names fogandlight.frag.glsl and fogspheres.glsl read belong to +// vertexwarp.vsh and fogandlight.vsh, which the vertex stage includes, so this stage activates those owners' +// names itself (contract section 3). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkliquid.interface.glsl" +#include "varyings.glsl" + +layout(location = 1) in vec4 rgba; +layout(location = 2) in vec4 rgbaFog; +layout(location = 3) in float fogAmount; +layout(location = 4) in vec2 uv; +layout(location = 5) in vec2 uvSize; +layout(location = 6) in float waterStillCounterOff; +layout(location = 12) flat in vec2 uvBase; +layout(location = 7) in vec3 fragWorldPos; +layout(location = 9) in vec3 fWorldPos; +layout(location = 8) in vec3 fragNormal; +layout(location = 10) in float fresnel; +layout(location = 11) flat in int skyExposed; + +layout(location = 0) in vec2 flowVectorf; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; + +layout(location = 13) flat in int waterFlags; +// Declared and never written by chunkliquid.vsh or read here, as in the GLSL 330 stage. +layout(location = 14) flat in int renderFoam; + +#include "fogandlight.frag.glsl" +#include "noise3d.glsl" +#include "colormap.frag.glsl" +#include "underwatereffects.glsl" +#include "oit.glsl" + + +vec2 droplethash3( vec2 p ) +{ + //vec2 q = vec2(dot(p,vec2(127.1,311.7)), dot(p,vec2(269.5,183.3))); - causes too high values and weird distortions + + vec2 q = vec2(dot(p,vec2(12.71,31.17)), dot(p,vec2(26.95,18.33))); + return fract(sin(q)*43758.5453); +} + +float dropletnoise(in vec2 x) +{ + if (dropletIntensity < 0.001) return 0.; + + x *= dropletIntensity; + + vec2 p = floor(x); + vec2 f = fract(x); + + + float va = 0.0; + for( int j=-1; j<=1; j++ ) + for( int i=-1; i<=1; i++ ) + { + vec2 g = vec2(float(i), float(j)); + vec2 o = droplethash3(p + g); + vec2 r = g - f + o; + float d = length(r) / dropletIntensity; + + float a = max(cos(d - waterWaveCounter * 2.7 + (o.x + o.y) * 5.0), 0.); + a = smoothstep(0.99, 0.999, a); + + float ripple = mix(a, 0., d); + va += max(ripple, 0.); + } + + return va; +} + +void main() +{ + // When looking through tinted glass you can clearly see the edges where we fade to sky color + // Using this discard seems to completely fix that + if (rgba.a < 0.005) discard; + float murkiness=max(0, getUnderwaterMurkiness() - fogAmount); + if (murkiness > 0.05) discard; + + + + vec4 texColor; + + float vn = max(0, 0.9 - abs(fragNormal.y)); + float wfc; + + bool isLava = (waterFlags & LiquidIsLavaBitMask) > 0; + bool fullAlpha = (waterFlags & LiquidFullAlphaBitMask) > 0; + + if (isLava) wfc = waterFlowCounter * 0.1 * (1 + 5 * vn); + else wfc = waterFlowCounter * (1 + 5 * vn); + + float flowSpeed = length(flowVectorf); + if (flowSpeed > 0.001) { + vec2 flowVec = normalize(flowVectorf) * flowSpeed; + + if (fragNormal.y < 0) wfc*=-1; + + vec2 uvxOffset = + clamp( + mod((uv - uvBase) + flowVec * wfc * blockTextureSize, blockTextureSize), + vec2(1 / textureAtlasSize), + blockTextureSize - 1 / textureAtlasSize) + ; + + texColor = texture(optimumTextures2D[terrainTex], uvBase + uvxOffset); + + } else { + // Needs to be rewritten to not do weird uv-inverse math but simply use a second texture so json blocks can use it too + + vec2 uvxOffset = + clamp( + blockTextureSize - uvSize, + vec2(1 / textureAtlasSize), + blockTextureSize - 1 / textureAtlasSize) + ; + + texColor = texture(optimumTextures2D[terrainTex], uv) * waterStillCounterOff + (1-waterStillCounterOff) * texture(optimumTextures2D[terrainTex], uvBase + uvxOffset); + } + + texColor = getColorMapped(optimumTextures2D[terrainTex], texColor); + + if (psychedelicStrength > Epsilon) texColor = applyPsychedelicEffect(texColor, fragWorldPos, 0); + + + vec4 rgbaFinal = rgba; + if (OPTIMUM_FOAMEFFECT > 0) { + rgbaFinal.a = rgba.a + max(0, texColor.a - 0.4); + } else { + rgbaFinal.a = rgba.a + texColor.a; + } + float bright = (rgba.r + rgba.g + rgba.b)/3; + + float shadowBright = getBrightnessFromShadowMap(); + + + float x = gl_FragCoord.x / frameSize.x; + float y = gl_FragCoord.y / frameSize.y; + + // This seems to fix being able to see rivers when looking up from inside a lake + //if (fogAmount > 0.98) discard; - Breaks new murky water rendering + + if (fullAlpha) { + rgbaFinal.a=1; + } + + if (isLava) { + texColor *= vec4(vec3((rgbaFinal.r + rgbaFinal.g + rgbaFinal.b)/3 * shadowBright), rgbaFinal.a); + } else { + texColor *= vec4(rgbaFinal.rgb * shadowBright, rgbaFinal.a); + } + + if (flowSpeed > 0) { + texColor.a *= 1.2 * flowSpeed; + } + + bool doLightFoam = (waterFlags & LiquidWeakFoamBitMask) != 0; + + // Was * 2 but that made water behind quartz glass super visible in the night + // Was * 0.5 but that made water columns hardly visible + float accuWeight = 1; + + if (OPTIMUM_FOAMEFFECT > 0) { + if (rgbaFinal.a > 0) { + + // Water edge + shinyness shading effect, kinda nice + float ownDepth = linearDepth(gl_FragCoord.z); + float diffTotal = 0; + int range = 2; + for (int dx = -range; dx <= range; dx++) { + for (int dy = -range; dy <= range; dy++) { + float diff = ownDepth - linearDepth(texture(optimumTextures2D[depthTex], vec2(x + dx/frameSize.x, y + dy/frameSize.y)).x); + if (diff < 0.001) { // This check prevents foam not rendered when looking through grass at distant water + diffTotal += abs(diff); + } + } + } + + diffTotal /= (4*range * range); + diffTotal = min(diffTotal, -vn/10 + 0.05); + + if (isLava) { + float intensity = clamp(dot(fragNormal, vec3(0, 1, 0)), 0, 1) * 0.5; + float a = fragWorldPos.x + fragWorldPos.y - 1.5 * flowVectorf.x * wfc; + float b = fragWorldPos.z - 1.5 * flowVectorf.y * wfc; + + float diff = intensity * clamp(1 - diffTotal*1000 - gnoise(vec3(a*35, b*35, wfc))/2 + gnoise(vec3(a*2, b*2, wfc))/2, 0, 1); + float noise = intensity * (gnoise(vec3(a, b, wfc)) + 0.5) / 2; + float rgbAdd = bright*(diff * 0.3 + noise/10); + texColor.rgb -= vec3(rgbAdd, rgbAdd, rgbAdd); + texColor.a=1; // Let's just do lava as opaque as we can + accuWeight=1; + + float blackSpots = gnoise(fragWorldPos.xyz) + 0.5; + + texColor.rgb -= blackSpots * 1.5 * 0.5; + texColor.g += 0.2 * 0.5; + + } else { + // Cold liquids + vec3 localPos = fragWorldPos.xyz; + + // Foam + + float intensity = clamp(dot(fragNormal, vec3(0, 1, 0)), 0, 1); + + float a = localPos.x + localPos.y - 1.5 * flowVectorf.x * wfc; + float b = localPos.z - 1.5 * flowVectorf.y * wfc; + + // without the -abs() one diagonal direction goes derpy noise + float noise1 = gnoise(vec3(a*15, -abs(b*15), wfc)) + gnoise(vec3(a*5, b*5, wfc)); + float noise2 = gnoise(vec3(a, b, wfc)); + + float diff = intensity * clamp(1 - diffTotal*1500 - noise1/2 + noise2/2, 0, 1); + float noise = intensity * (gnoise(vec3(a * 0.4, b * 0.4, wfc))/2 + gnoise(vec3(a, b, wfc))/2 + 0.5) / 2; + + float rgbAdd = max(0, bright*(diff * 0.3 + noise/10)); + if (doLightFoam) { + rgbAdd *= 0.5; + } + + texColor.rgb += vec3(rgbAdd, rgbAdd, rgbAdd); + texColor.a += (max(0, diff/16 + noise/(12 - 8*min(1,windSpeed))) + vn / 4) / clamp(fresnel, 0.5, 1); + + + + // Droplet noise + float f = 0; + if (skyExposed > 0) { + vec2 uv = localPos.xz * (5 + noise1/20000.0); + f = dropletnoise(uv); + } + + + + // Specular reflection + // GLSL 330: `#if SHADOWQUALITY == 0` opens `if (skyExposed > 0) {` here and closes it below, so + // the block runs unconditionally with shadows on and only on sky-exposed liquid with them off. + if (OPTIMUM_SHADOWQUALITY != 0 || skyExposed > 0) { + vec3 noisepos = vec3(localPos.x , localPos.z, waterWaveCounter / 8 + windWaveCounter / 6); + + //float dy = clamp(noise2 / 10, 0, 1) + gnoise(noisepos); - trippy specular rings + + float dy = noise2 / 20 + clamp(gnoise(noisepos) / 10, 0, 0.6); + + vec3 normal = normalize(vec3(dy, 1, -dy)); + + float upness = max(0, dot(fragNormal, vec3(0,1,0))); // Only do specular reflections on up faces + + vec3 eye = normalize(vec3(fWorldPos.x, fWorldPos.y - 2, fWorldPos.z)); + vec3 reflectionVec = reflect(sunPosRel, normal); + float p = dot(reflectionVec, eye); + if (p > 0) { + float sunb = clamp(sunPosRel.y * 10, 0, 1) * clamp(1.5 - sunPosRel.y, 0, 1) * sunSpecularIntensity; + + float specular = pow(p, 50) * sunb; + + // Declared before the branch that assigns it (contract section 5); both paths overwrite the 0. + float weight = 0.0; + if (OPTIMUM_SHADOWQUALITY > 0) { + weight = upness * clamp(specular * clamp(pow(shadowBright, 4), 0, 1) * clamp(1.5 * shadowIntensity, 0, 1), 0, 1); + } else { + weight = upness * clamp(specular * clamp(pow(shadowBright, 4), 0, 1) * clamp(1.5, 0.0, 1.0), 0, 1); + } + + vec3 sunColf = applyFog(vec4(reflectColor, 1), fogAmount).rgb; + + texColor.rgb = mix(texColor.rgb, sunColf + noise1 * 0.2, weight); + texColor.a = mix(texColor.a, texColor.a + specular/2, weight); + } + } + + texColor.rgb *= 1 + f; + texColor.a += max(0, 0.5 - texColor.a)*0.5*f; // Some extra alpha for droplet noise where there is low alpha + + } + } + + } else { + if (isLava) { + texColor.a=1; + accuWeight=1; + } + } + + + texColor = applyFog(texColor, fogAmount); + texColor.a = clamp(texColor.a + fogAmount, 0, 1); + + texColor = applySpheresFog(texColor, fogAmount, fWorldPos.xyz); + +#if USEOIT > 0 + OIT(texColor, glowLevel); +#endif + +} diff --git a/sources/shaders-vk/chunkliquid.interface.glsl b/sources/shaders-vk/chunkliquid.interface.glsl new file mode 100644 index 00000000..b1791b59 --- /dev/null +++ b/sources/shaders-vk/chunkliquid.interface.glsl @@ -0,0 +1,54 @@ +// Program interface of chunkliquid (docs/vulkan-native-shaders.md section 4). One draw per mesh pool: the +// push block holds the two sampler slots in chunkliquid.fsh's declaration order, then origin and +// modelViewMatrix (84 B). The record holds every other uniform, chunkliquid.vsh's first (the previous-frame +// warp state vertexwarp.glsl reads comes with its include), then chunkliquid.fsh's and underwatereffects' +// frameSize. blockTextureSize and sunPosRel, declared by both stages, appear once. +// +// waterWaveCounter and windSpeed, which chunkliquid.fsh declares itself, are frame members here (their owner +// vertexwarp.vsh is included by the vertex stage). A block member cannot carry chunkliquid.fsh's initializer +// (dropletIntensity = 0); the runtime seeds the record from the GLSL 330 declarations (contract section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, depthTex); + vec3 origin; + mat4 modelViewMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float waterStillCounter; + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogDensityIn; + float fogMinIn; + mat4 projectionMatrix; + vec2 blockTextureSize; + vec3 playerViewVec; + vec3 sunPosRel; + vec3 playerPosForFoam; + float subpixelPaddingX; + float subpixelPaddingY; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 textureAtlasSize; + float waterFlowCounter; + vec3 sunColor; + vec3 reflectColor; + float sunSpecularIntensity; + float dropletIntensity; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/chunkliquid.vert b/sources/shaders-vk/chunkliquid.vert new file mode 100644 index 00000000..1bd0293a --- /dev/null +++ b/sources/shaders-vk/chunkliquid.vert @@ -0,0 +1,130 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkliquid.vsh (vanilla, docs/vulkan-native-shaders.md). No variant axes in this stage. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkliquid.interface.glsl" + +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlags; // Check out chunkvertexflags.ash for understanding the contents of this data +layout(location = 4) in vec2 flowVector; +layout(location = 5) in int colormapData; + +// Bit 0: Should animate yes/no +// Bit 1: Should texture fade yes/no +// Bit 2-9: Oceanity +// Bits 10-17: x-Distance to upper left corner, where 255 = size of the block texture +// Bits 18-26: y-Distance to upper left corner, where 255 = size of the block texture +// Bit 27: Lava yes/no - use LiquidIsLavaBitPosition + +// Bit 28: Weak foamy yes/no - use LiquidWeakFoamBitPosition +// Bit 29: Weak Wavy yes/no - use LiquidWeakWaveBitPosition +// Bit 30: Don't tweak alpha channel - use LiquidFullAlphaBitPosition +// Bit 31: LiquidExposedToSky - use LiquidSkyExposedBitPosition + +layout(location = 6) in int waterFlagsIn; + +layout(location = 0) out vec2 flowVectorf; +layout(location = 1) out vec4 rgba; +layout(location = 2) out vec4 rgbaFog; +layout(location = 3) out float fogAmount; +layout(location = 4) out vec2 uv; +layout(location = 5) out vec2 uvSize; +layout(location = 6) out float waterStillCounterOff; +layout(location = 7) out vec3 fragWorldPos; +layout(location = 8) out vec3 fragNormal; +layout(location = 9) out vec3 fWorldPos; +layout(location = 10) out float fresnel; +layout(location = 11) flat out int skyExposed; + +layout(location = 12) flat out vec2 uvBase; +layout(location = 13) flat out int waterFlags; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" +#include "colormap.vert.glsl" + + +void main(void) +{ + vec4 truePos = vec4(xyz + origin, 1.0); + vec4 worldPos = truePos; + + if ((waterFlagsIn & 1) == 1) { + float div = ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) ? 90 : 5; + + float oceanity = ((waterFlagsIn >> 2) & 0xff) * OneOver255; + div *= max(0.2, 1 - oceanity); + + worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, div); + } + else if ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) { + worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, 90); + } + + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + float x = mod(waterStillCounter + length(worldPos.xz + playerpos.xz) * 0.3333333, 2); + + waterStillCounterOff = smoothstep(0, 1, abs(x - 1)); + if ((waterFlagsIn & 2) == 0) { + waterStillCounterOff = 1; + } + + + fragWorldPos = worldPos.xyz + playerPosForFoam; + fWorldPos = worldPos.xyz; + + rgba = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos); + rgbaFog = rgbaFogIn; + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + uv = uvIn; + + uvSize = vec2((waterFlagsIn >> 10) & 0xff, (waterFlagsIn >> 18) & 0xff) * OneOver255 * blockTextureSize; + uvBase = uv - uvSize; + + flowVectorf = flowVector; + + waterFlags = waterFlagsIn; + fragNormal = unpackNormal(renderFlags); + skyExposed = (renderFlags >> LiquidSkyExposedBitPosition) & 1; + + + + vec3 eyeFresnel = normalize(vec3(worldPos.x, worldPos.y - 3.5, worldPos.z)); + float bias = 0.01; + float scale = 4.5; + float power = 3.0; + + if ((renderFlags & GlowLevelBitMask) == 0) { // Don't apply to glowing liquids for now, looks weird on lava (makes it less glowy) + fresnel = max(0.2, bias + scale * pow(1 + dot(eyeFresnel, fragNormal), power)); + + fresnel = min(fresnel, clamp(20 * (1.05 - length(worldPos.xz) / viewDistance) - 5 + max(0.0, worldPos.y * 0.02), -1.0, 1.5)); + + rgba.a = clamp(0.8*fresnel, 0, 2); + + if (fragNormal.y < 0.5) { + rgba.a *= 0.3333333; + } + } + + + calcShadowMapCoords(modelViewMatrix, worldPos); + calcColorMapUvs(colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + + // We pretend the decal is closer to the camera to enforce it always being drawn on top + // Required e.g. when water is besides stairs or slabs + gl_Position.w += 0.0008 / max(0.1, gl_Position.z); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/chunkliquiddepth.frag b/sources/shaders-vk/chunkliquiddepth.frag new file mode 100644 index 00000000..6d0cfd31 --- /dev/null +++ b/sources/shaders-vk/chunkliquiddepth.frag @@ -0,0 +1,16 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkliquiddepth.fsh (vanilla, docs/vulkan-native-shaders.md). The GLSL 330 output has no +// location; it is the only one, so it takes location 0. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkliquiddepth.interface.glsl" + +layout(location = 0) out vec4 outColor; + +void main() +{ + outColor=vec4(1); +} diff --git a/sources/shaders-vk/chunkliquiddepth.interface.glsl b/sources/shaders-vk/chunkliquiddepth.interface.glsl new file mode 100644 index 00000000..2faf2cfe --- /dev/null +++ b/sources/shaders-vk/chunkliquiddepth.interface.glsl @@ -0,0 +1,28 @@ +// Program interface of chunkliquiddepth (docs/vulkan-native-shaders.md section 4). One draw per mesh pool: +// the push block holds origin and modelViewMatrix (76 B). The program includes no owner of viewDistance +// (fogandlight.vsh), so it is a record member here, with projectionMatrix and the previous-frame warp state +// vertexwarp.glsl reads. +layout(push_constant, scalar) uniform OptimumDraw +{ + vec3 origin; + mat4 modelViewMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + float viewDistance; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; +}; diff --git a/sources/shaders-vk/chunkliquiddepth.vert b/sources/shaders-vk/chunkliquiddepth.vert new file mode 100644 index 00000000..7ce63d2d --- /dev/null +++ b/sources/shaders-vk/chunkliquiddepth.vert @@ -0,0 +1,45 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkliquiddepth.vsh (vanilla, docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkliquiddepth.interface.glsl" + +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlags; +layout(location = 4) in vec2 flowVector; +layout(location = 5) in int colormapData; +layout(location = 6) in int waterFlagsIn; + +#include "vertexflagbits.glsl" +#include "vertexwarp.glsl" + + +void main(void) +{ + vec4 worldPos = vec4(xyz + origin, 1.0); + + float div = ((waterFlagsIn & LiquidWeakWaveBitMask) > 0) ? 90 : 5; + + float oceanity = ((waterFlagsIn >> 2) & 0xff) / 255.0; + div *= max(0.2, 1 - oceanity); + + if ((waterFlagsIn & 1) == 1) { + worldPos = applyLiquidWarping((waterFlagsIn & LiquidIsLavaBitMask) == 0, worldPos, div); + } + + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + // Distance fade out + float a = length(worldPos.xz) / viewDistance; + gl_Position.w -= max(0.0, (a-0.75)*5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/chunkopaque.frag b/sources/shaders-vk/chunkopaque.frag new file mode 100644 index 00000000..dacabfe2 --- /dev/null +++ b/sources/shaders-vk/chunkopaque.frag @@ -0,0 +1,220 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkopaque.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: GREEDYMESH (tile varyings), GBUFFER (G-buffer outputs; the motion output moves from 2 to 4 with it), +// TAAMOTION (motion output, written through include/motion.glsl). GREEDYMESH_GRAD, NORMALVIEW and +// SHINYEFFECT are specialization-constant branches. +// +// fogandlight.frag.glsl and fogspheres.glsl read names owned by fogandlight.vsh and vertexwarp.vsh, which the +// vertex stage includes, so this stage activates those owners' names itself (contract section 3). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkopaque.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec4 rgba; +layout(location = 2) in vec4 rgbaFog; +layout(location = 3) in float fogAmount; +layout(location = 1) in vec2 uv; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 9) flat in int renderFlags; +layout(location = 4) in vec3 normal; +layout(location = 6) in vec4 worldPos; +layout(location = 5) in vec3 vertexPosition; +layout(location = OPTIMUM_LOCATION_BLOCK_LIGHT) in vec3 blockLight; +#if GBUFFER == 1 +layout(location = 14) in vec4 gnormal; +#endif +layout(location = 7) in vec4 camPos; +layout(location = 8, component = 0) in float lod0Fade; +layout(location = 8, component = 1) in float nb; + +// Greedy mesh tile repeat (Optimum). Compiled in only when the feature +// is on (GREEDYMESH stamped from OptimumConfig at shader load); at 0 +// this whole shader preprocesses to vanilla. +#if GREEDYMESH > 0 +layout(location = 10) flat in int tileWidth; +layout(location = 11) flat in int tileHeight; +layout(location = 12) flat in vec2 tileBoundsMin; +layout(location = 13) flat in vec2 tileBoundsSize; +#endif + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +// TAA motion vectors (Optimum P3). The location is the Primary colour attachment the motion texture +// occupies (TAAMOTIONLOCATION: 2 without the SSAO G-buffer, 4 with it). The vector, reactive and writer +// depth come from include/motion.glsl (contract section 7). +#if TAAMOTION > 0 +layout(location = 15) in vec4 taaPrevClip; +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#include "motion.glsl" +#endif + +#include "vertexflagbits.glsl" +#include "fogandlight.frag.glsl" +#include "dither.glsl" +#include "skycolor.glsl" +#include "colormap.frag.glsl" +#include "underwatereffects.glsl" + +void main() +{ +#if GREEDYMESH > 0 + vec2 sampledUv = uv; + + // Greedy mesh UV tiling (Optimum): when tile counts > 1, the UV + // interpolated from vertex data covers one tile stretched over N + // blocks. To repeat the texture, normalize uv into [0,1] within the + // tile rect, scale by tile count, fract to wrap, and map back to + // atlas coordinates. This works regardless of axis inversion because + // it operates purely in UV interpolation space. + if (tileWidth > 1 || tileHeight > 1) { + // Normalize uv to [0,1] within the tile sub-rect. + vec2 normalizedUV = (uv - tileBoundsMin) / tileBoundsSize; + // Scale by tile count and wrap. + vec2 tileCount = vec2(float(tileWidth), float(tileHeight)); + vec2 repeatedUV = fract(normalizedUV * tileCount); + // Map back to atlas coordinates. + sampledUv = tileBoundsMin + tileBoundsSize * repeatedUV; + + // Declared before the branch that assigns it (contract section 5); both paths overwrite it. + vec4 texColor = vec4(0.0); + if (OPTIMUM_GREEDYMESH_GRAD > 0) { + // Use textureGrad to avoid mipmap seams at fract() boundaries. + // Derivatives come from the unwrapped uv (smooth across the quad). + vec2 dx = dFdx(uv) * tileCount; + vec2 dy = dFdy(uv) * tileCount; + texColor = getColorMapped(optimumTextures2D[terrainTexLinear], textureGrad(optimumTextures2D[terrainTex], sampledUv, dx, dy)) * rgba; + } else { + // A/B path (GreedyMeshTextureGrad false): plain sampler, implicit + // derivatives spike at the fract() wrap so distant merged quads can + // show mip seams. Trades that artifact for skipping the explicit- + // gradient sampler, which runs at reduced rate on some GPUs. + texColor = getColorMapped(optimumTextures2D[terrainTexLinear], texture(optimumTextures2D[terrainTex], sampledUv)) * rgba; + } + + if (psychedelicStrength > Epsilon) texColor = applyPsychedelicEffect(texColor, vertexPosition*2, 0); + if (glitchStrength > Epsilon) texColor = applyRustEffect(texColor, normal, vertexPosition, 1); + + float b = getBrightnessFromShadowMap(); + float murkiness = getUnderwaterMurkiness(); + outColor = applyFogAndShadowFromBrightness(texColor, clamp(fogAmount - 50*murkiness, 0, 1), min(b, nb), worldPos.xyz); + + float glow = 0; + float godrayLevel = 0; + + if (haxyFade > 0) { + if (rgba.a < 0.999) { + vec4 skyColor = vec4(1); + vec4 skyGlow = vec4(1); + float sealevelOffsetFactor = 0.25; + getSkyColorAt(worldPos.xyz, sunPosition, sealevelOffsetFactor, clamp(dayLight, 0, 1), horizonFog, skyColor, skyGlow); + godrayLevel = skyGlow.g; + outColor.rgb = mix(skyColor.rgb, outColor.rgb, max(1-dayLight, max(0.0, rgba.a))); + } + } + + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + + if (OPTIMUM_NORMALVIEW == 0) { + float aTest = outColor.a + max(0.0, 1 - rgba.a) * min(1, outColor.a * 10) - lod0Fade; + if (aTest < alphaTest || rgba.a < 0.005) discard; + } + + if (OPTIMUM_SHINYEFFECT > 0) { + if ((renderFlags & ReflectiveBitMask) != 0) { + outColor = mix(applyReflectiveEffect(outColor, glow, renderFlags, sampledUv, normal, worldPos, camPos, blockLight), outColor, clamp(2 * fogAmount + 2*(1-b), 0, 1)); + } + glow += pow(max(0.0, dot(normal, lightPosition)), 6) * 0.125 * shadowIntensity * (1 - fogAmount - murkiness); + } + +#if GBUFFER == 1 + outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); + outGNormal = gnormal; +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + outGlow = vec4(glowLevel + glow, godrayLevel, 0, min(1, fogAmount + outColor.a)); +#if TAAMOTION > 0 + // Opaque terrain is not reactive. + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, 0.0, gl_FragCoord.z); +#endif + return; + } +#endif + + // --- Vanilla path (no tiling) --- + vec4 texColor = getColorMapped(optimumTextures2D[terrainTexLinear], texture(optimumTextures2D[terrainTex], uv)) * rgba; + + if (psychedelicStrength > Epsilon) texColor = applyPsychedelicEffect(texColor, vertexPosition*2, 0); + if (glitchStrength > Epsilon) texColor = applyRustEffect(texColor, normal, vertexPosition, 1); + + float b = getBrightnessFromShadowMap(); + + float murkiness=getUnderwaterMurkiness(); + outColor = applyFogAndShadowFromBrightness(texColor, clamp(fogAmount - 50*murkiness, 0, 1), min(b, nb), worldPos.xyz); + + float glow = 0; + float godrayLevel = 0; + + if (haxyFade > 0) { + if (rgba.a < 0.999) { + vec4 skyColor = vec4(1); + vec4 skyGlow = vec4(1); + float sealevelOffsetFactor = 0.25; + + getSkyColorAt(worldPos.xyz, sunPosition, sealevelOffsetFactor, clamp(dayLight, 0, 1), horizonFog, skyColor, skyGlow); + godrayLevel = skyGlow.g; + outColor.rgb = mix(skyColor.rgb, outColor.rgb, max(1-dayLight, max(0.0, rgba.a))); + } + } + + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + + + if (OPTIMUM_NORMALVIEW == 0) { + float aTest = outColor.a + max(0.0, 1 - rgba.a) * min(1, outColor.a * 10) - lod0Fade; + + if ((renderFlags & WindModeBitMask) == WindModeWeakLowAlphaTest) aTest *= 4; + + if (aTest < alphaTest || rgba.a < 0.005) discard; + } + + + if (OPTIMUM_SHINYEFFECT > 0) { + if ((renderFlags & ReflectiveBitMask) != 0) { + outColor = mix(applyReflectiveEffect(outColor, glow, renderFlags, uv, normal, worldPos, camPos, blockLight), outColor, clamp(2 * fogAmount + 2*(1-b), 0, 1)); + } + glow += pow(max(0.0, dot(normal, lightPosition)), 6) * 0.125 * shadowIntensity * (1 - fogAmount - murkiness); + } + + +#if GBUFFER == 1 + outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); + outGNormal = gnormal; +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + + outGlow = vec4(glowLevel + glow, godrayLevel, 0, min(1, fogAmount + outColor.a)); +#if TAAMOTION > 0 + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, 0.0, gl_FragCoord.z); +#endif +} diff --git a/sources/shaders-vk/chunkopaque.interface.glsl b/sources/shaders-vk/chunkopaque.interface.glsl new file mode 100644 index 00000000..2f97e8b3 --- /dev/null +++ b/sources/shaders-vk/chunkopaque.interface.glsl @@ -0,0 +1,53 @@ +// Program interface of chunkopaque (docs/vulkan-native-shaders.md section 4). One draw per mesh pool: the +// push block holds the two sampler slots in chunkopaque.fsh's declaration order, then origin and +// modelViewMatrix (84 B). The record holds every other uniform, chunkopaque.vsh's first (the previous-frame +// warp state vertexwarp.glsl reads comes with its include), then chunkopaque.fsh's and underwatereffects' +// frameSize. +// +// Every uniform is declared whatever the axes: the GLSL 330 name set does not depend on defines. +// cameraUnderwater, shadowIntensity and lightPosition, which chunkopaque.vsh declares itself, are frame +// members here (their owners underwatereffects.fsh and fogandlight.fsh are included by the fragment stage). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTexLinear); + vec3 origin; + mat4 modelViewMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogDensityIn; + float fogMinIn; + mat4 projectionMatrix; + float subpixelPaddingX; + float subpixelPaddingY; + mat4 prevProjectionMatrix; + mat4 prevModelViewMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + float alphaTest; + float horizonFog; + vec3 sunPosition; + float dayLight; + int haxyFade; + vec2 taaRenderSize; + vec2 taaJitterPx; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/chunkopaque.vert b/sources/shaders-vk/chunkopaque.vert new file mode 100644 index 00000000..ac1e7e9c --- /dev/null +++ b/sources/shaders-vk/chunkopaque.vert @@ -0,0 +1,231 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkopaque.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: USESSBO (attribute layout and FaceData), GREEDYMESH (tile varyings), GBUFFER (gnormal), TAAMOTION +// (taaPrevClip). SHADOWQUALITY is a specialization-constant branch. +// +// lightPosition and shadowIntensity belong to fogandlight.fsh, which the fragment stage includes, so this +// stage activates that owner's names itself (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_FSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkopaque.interface.glsl" + + #if USESSBO > 0 +// rgb = block light, a=sun light level +layout(location = 0) in vec4 rgbaLightIn; + #else +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; // Check out vertexflagbits.ash for understanding the contents of this data +layout(location = 4) in int colormapData; + #endif + +layout(location = 0) out vec4 rgba; +layout(location = 1) out vec2 uv; +layout(location = 2) out vec4 rgbaFog; +layout(location = 3) out float fogAmount; +layout(location = 4) out vec3 normal; +layout(location = 5) out vec3 vertexPosition; +layout(location = 6) out vec4 worldPos; +layout(location = 7) out vec4 camPos; +// With every axis on the program has 17 varyings of its own, one more than locations 0-15 hold, so the +// two scalar floats share location 8 as components 0 and 1. +layout(location = 8, component = 0) out float lod0Fade; +layout(location = 8, component = 1) out float nb; + +// Greedy mesh tile repeat (Optimum): tile counts and sub-texture bounds. +// Compiled in only when the feature is on (GREEDYMESH stamped from +// OptimumConfig at shader load); at 0 this whole shader preprocesses to +// vanilla, so disabled greedy meshing costs nothing. +#if GREEDYMESH > 0 +layout(location = 10) flat out int tileWidth; +layout(location = 11) flat out int tileHeight; +layout(location = 12) flat out vec2 tileBoundsMin; +layout(location = 13) flat out vec2 tileBoundsSize; +#endif + + #if GBUFFER == 1 +layout(location = 14) out vec4 gnormal; + #endif + + +layout(location = 9) flat out int renderFlags; + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +layout(location = 15) out vec4 taaPrevClip; +#endif + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" +#include "colormap.vert.glsl" + + #if USESSBO > 0 +layout(std430, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_FACE_DATA) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + + +void main(void) +{ + #if USESSBO > 0 + FaceData vdata = faces[gl_VertexIndex / 4]; + int vIndex = gl_VertexIndex & 0x03; + renderFlags = vdata.flags[vIndex]; + vertexPosition = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #else + renderFlags = renderFlagsIn; + vertexPosition = xyz; + #endif + +#if GREEDYMESH > 0 + // Decode greedy tile counts from flags (Optimum). + // Bit 11 (ReflectiveBitMask) is the sentinel: set only on greedy- + // tiled quads (eligible blocks are never reflective). When set, + // bits 29-31 = tileWidth - 1, bits 8-10 = tileHeight - 1. + // When clear, this is a vanilla vertex: do not touch those bits. + int greedyTiled = (renderFlags >> 11) & 1; + if (greedyTiled != 0) { + tileWidth = ((renderFlags >> 29) & 0x7) + 1; + tileHeight = ((renderFlags >> 8) & 0x7) + 1; + // Clear sentinel + tile bits so downstream code (normal unpack, + // wind, zoffset) does not misinterpret them. + renderFlags = renderFlags & ~(0x7 << 29) & ~(0x7 << 8) & ~(1 << 11); + } else { + tileWidth = 1; + tileHeight = 1; + } +#endif + + vec4 truePos = vec4(vertexPosition + origin, 1.0); + bool isLeaves = ((renderFlags & WindModeBitMask) > 0); + + worldPos = applyVertexWarping(renderFlags, truePos); + worldPos = applyGlobalWarping(worldPos); + + camPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * camPos; + + calcShadowMapCoords(modelViewMatrix, worldPos); + #if USESSBO > 0 + calcColorMapUvs(vdata.colormapData, truePos + vec4(playerpos, 1.0), rgbaLightIn.a, isLeaves); + uv = UnpackUv(vdata, vIndex, subpixelPaddingX, subpixelPaddingY); + +#if GREEDYMESH > 0 + // Extract tile sub-texture bounds from FaceData for the fragment + // shader's tiling wrap. vdata.uv is the origin (vertex 0 UV packed + // as 16-bit fixed point), vdata.uvSize carries the delta to vertex 2. + // Unpack to float atlas coords matching UnpackUv's scale. + if (greedyTiled != 0) { + tileBoundsMin = vec2(vdata.uv & 0xFFFF, vdata.uv >> 16 & 0xFFFF) / 32768.0; + int uvs = vdata.uvSize; + // Preserve sign: negative delta means the axis is inverted + // (vertex 0 sits at the high end, vertex 2 at the low end). + // The fragment shader needs this to map fract(position) correctly. + tileBoundsSize = vec2( + (uvs & 0x7FFF) - ((uvs & 0x4000) << 1), + (uvs >> 16 & 0x7FFF) - ((uvs & 0x40000000) >> 15) + ) / 32768.0; + } else { + tileBoundsMin = vec2(0.0); + tileBoundsSize = vec2(0.0); + } +#endif + #else + calcColorMapUvs(colormapData, truePos + vec4(playerpos, 1.0), rgbaLightIn.a, isLeaves); + uv = uvIn; + +#if GREEDYMESH > 0 + // GL 3.3 path has no channel to carry tile bounds (would need an extra + // vertex attribute via CustomFloats, which the opaque pass's MeshData + // doesn't allocate). The emitter (OptimumGreedyMeshEmitter) forces + // 1x1-only merges whenever UseSSBOs is false, so greedyTiled should + // never be set here - this just forces the tile count back to 1x1 as + // a second guard, so a merged quad can never reach the fragment + // shader's tileBoundsSize division (which would be 0/0 = NaN) even if + // that invariant is ever violated (e.g. SSBOs toggled mid-session + // before chunks retesselate). + if (greedyTiled != 0) { + tileWidth = 1; + tileHeight = 1; + } + tileBoundsMin = vec2(0.0); + tileBoundsSize = vec2(0.0); +#endif + #endif + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + rgba = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, camPos); + + // Distance fade out + rgba.a = clamp(17.0 - 20.0 * length(worldPos.xz) / viewDistance + max(0.0, worldPos.y * 0.02), -1.0, 1.0); + + rgbaFog = rgbaFogIn; + + normal = unpackNormal(renderFlags); + +#if GBUFFER == 1 + gnormal = modelViewMatrix * vec4(normal.xyz, 0); + gnormal.w = isLeaves ? 1 : 0; +#endif + + + // To fix Z-Fighting on blocks over certain other blocks + if (gl_Position.z > -1) { + int zOffset = (renderFlags & ZOffsetBitMask) >> 8; + gl_Position.w += zOffset * 0.00025 / ((gl_Position.z + 3) * 0.05); + } + + +#if TAAMOTION > 0 + // The same vertex, one frame ago, through the same code path: the chunk's + // camera-relative position moved by exactly the camera's own motion + // (accuracy rule 4), the warp is re-evaluated with the previous frame's + // counters, and the previous unjittered projection replaces this frame's + // jittered one. The warp noise consumes an absolute-ish position, which is + // prevRel + prevPlayerpos - that is what previousWarpState() carries. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0); + taaPrevPos = applyVertexWarpingState(taaPrev, renderFlags, taaPrevPos); + taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + + // The z-fighting w-offset shifts where the fragment lands on screen, so + // leaving it off the previous position would report that shift as motion. + if (taaPrevClip.z > -1) { + int taaPrevZOffset = (renderFlags & ZOffsetBitMask) >> 8; + taaPrevClip.w += taaPrevZOffset * 0.00025 / ((taaPrevClip.z + 3) * 0.05); + } + } +#endif + + + if ((renderFlags & Lod0BitMask) != 0) { + float b = clamp(10 * (1.05 - length(worldPos.xz) / viewDistanceLod0) - 2.5, 0.0, 1.0); + lod0Fade = 1 - b; + } + else lod0Fade = 0.0; + + + // Declared before the branch that assigns it (contract section 5); every path overwrites the 0.45. + float intensity = 0.45; + if (OPTIMUM_SHADOWQUALITY > 0) { + intensity = 0.34 + (1 - shadowIntensity)/8.0; + } else { + intensity = 0.45; + } + nb = max(max(intensity, 0.5 + 0.5 * dot(normal, lightPosition)), normal.y * 0.95); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/chunkshadowmap.frag b/sources/shaders-vk/chunkshadowmap.frag new file mode 100644 index 00000000..7f8bddff --- /dev/null +++ b/sources/shaders-vk/chunkshadowmap.frag @@ -0,0 +1,20 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkshadowmap.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkshadowmap.interface.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 0) out vec4 outColor; + +void main () { + outColor = texture(optimumTextures2D[tex2d], uv); + // Optimum: raise discard threshold from 0.02 to 0.15. + // Skips more near-transparent shadow fragments (grass edges, leaf fringes) + // with no visible shadow quality loss at typical view distances. + if (outColor.a < 0.15) discard; + +} diff --git a/sources/shaders-vk/chunkshadowmap.interface.glsl b/sources/shaders-vk/chunkshadowmap.interface.glsl new file mode 100644 index 00000000..c7d18b73 --- /dev/null +++ b/sources/shaders-vk/chunkshadowmap.interface.glsl @@ -0,0 +1,28 @@ +// Program interface of chunkshadowmap (docs/vulkan-native-shaders.md section 4). One draw per mesh pool +// per cascade: the push block holds the sampler slot, then origin and mvpMatrix (80 B). The record holds +// the subpixel padding and the previous-frame warp state vertexwarp.glsl reads. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2d); + vec3 origin; + mat4 mvpMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float subpixelPaddingX; + float subpixelPaddingY; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; +}; diff --git a/sources/shaders-vk/chunkshadowmap.vert b/sources/shaders-vk/chunkshadowmap.vert new file mode 100644 index 00000000..de6776a8 --- /dev/null +++ b/sources/shaders-vk/chunkshadowmap.vert @@ -0,0 +1,60 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunkshadowmap.vsh (vanilla, docs/vulkan-native-shaders.md). USESSBO is the variant axis: +// 1 reads FaceData from set 2, 0 is the Chunkshadowmap_NoSSBOs registration with the per-vertex layout. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunkshadowmap.interface.glsl" + + #if USESSBO > 0 +// rgb = block light, a=sun light level +layout(location = 0) in vec4 rgbaLightIn; + #else +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; + #endif + +layout(location = 0) out vec2 uv; + +#include "vertexflagbits.glsl" +#include "vertexwarp.glsl" + + #if USESSBO > 0 +layout(std430, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_FACE_DATA) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + + +void main(void) +{ + #if USESSBO > 0 + FaceData vdata = faces[gl_VertexIndex / 4]; + int vIndex = gl_VertexIndex & 0x03; + vec3 xyz = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #endif + + vec4 worldPos = vec4(xyz + origin, 1.0); + #if USESSBO > 0 + worldPos = applyVertexWarping(vdata.flags[vIndex], worldPos); + #else + worldPos = applyVertexWarping(renderFlagsIn, worldPos); + #endif + worldPos = applyGlobalWarping(worldPos); + + gl_Position = mvpMatrix * worldPos; + + #if USESSBO > 0 + uv = UnpackUv(vdata, vIndex, subpixelPaddingX, subpixelPaddingY); + #else + uv = uvIn; + #endif + + // We could use this to fix peter panninng on tall grass, but needs an extra render pass or extra vertex data for grass + //gl_Position.w += 1 * 0.00025 / max(0.1, gl_Position.z * 0.05); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/chunktopsoil.frag b/sources/shaders-vk/chunktopsoil.frag new file mode 100644 index 00000000..2caf5bb7 --- /dev/null +++ b/sources/shaders-vk/chunktopsoil.frag @@ -0,0 +1,137 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunktopsoil.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: GBUFFER (G-buffer outputs; the motion output moves from 2 to 4 with it), TAAMOTION (motion output, +// written through include/motion.glsl). SHADOWQUALITY, NORMALVIEW and SHINYEFFECT are +// specialization-constant branches. +// +// Optimum override of the vanilla chunktopsoil.fsh: adds the TAA motion-vector +// output (P3). Everything else is vanilla, line for line. +// +// fogandlight.frag.glsl and fogspheres.glsl read names owned by fogandlight.vsh and vertexwarp.vsh, which the +// vertex stage includes, so this stage activates those owners' names itself (contract section 3). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunktopsoil.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec4 rgba; +layout(location = 1) in vec4 rgbaFog; +layout(location = 2) in float fogAmount; +layout(location = 3) in vec2 uv; +layout(location = 4) in vec2 uv2; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = OPTIMUM_LOCATION_BLOCK_LIGHT) in vec3 blockLight; +layout(location = 9) in vec4 worldPos; +layout(location = 8) in vec3 vertexPosition; + +layout(location = 10) flat in int renderFlags; +layout(location = 5) in vec3 normal; +#if GBUFFER == 1 +layout(location = 7) in vec4 gnormal; +#endif + + + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 6) in vec4 fragPosition; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +// TAA motion vectors (Optimum P3); see chunkopaque.frag for the contract. +#if TAAMOTION > 0 +layout(location = 11) in vec4 taaPrevClip; +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#include "motion.glsl" +#endif + +#include "vertexflagbits.glsl" +#include "fogandlight.frag.glsl" +#include "colormap.frag.glsl" +#include "noise3d.glsl" +#include "underwatereffects.glsl" + +void main() +{ + vec4 brownSoilColor = texture(optimumTextures2D[terrainTex], uv) * rgba; + + if (normal.y >= 0) { + // Top (normal.y == 1) or Sides (normal.y == 0) + vec4 grassColor = getColorMapped(optimumTextures2D[terrainTexLinear], texture(optimumTextures2D[terrainTex], uv2 + vec2(blockTextureSize.x * normal.y, 0))) * rgba; + outColor = brownSoilColor * (1 - grassColor.a) + grassColor * grassColor.a; + } else { + // Bottom + outColor = applyFog(brownSoilColor, fogAmount); + } + + if (psychedelicStrength > Epsilon) outColor = applyPsychedelicEffect(outColor, vertexPosition*2, 0); + if (glitchStrength > Epsilon) outColor = applyRustEffect(outColor, normal, vertexPosition, 1); + + + // Declared before the branch that assigns it (contract section 5); every path overwrites the 0.45. + float intensity = 0.45; + if (OPTIMUM_SHADOWQUALITY > 0) { + intensity = 0.34 + (1 - shadowIntensity)/8.0; // this was 0.45, which makes shadow acne visible on blocks + } else { + intensity = 0.45; + } + + + + float murkiness=getUnderwaterMurkiness(); + outColor = applyFogAndShadowWithNormal(outColor, clamp(fogAmount - 50*murkiness, 0, 1), normal, 1, intensity, worldPos.xyz); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + + outColor.a = rgbaFog.a; + + float aTest = outColor.a; + aTest += max(0.0, 1 - rgba.a) * min(1, outColor.a * 10); + if (OPTIMUM_NORMALVIEW == 0) { + // Fade to sky color + // Also, when looking through tinted glass you can clearly see the edges where we fade to sky color; using the outColor.a < 0.005 discard seems to completely fix that + if (aTest < alphaTest || outColor.a < 0.005) discard; + } + + + float glow = 0; + + if (OPTIMUM_SHINYEFFECT > 0) { + if ((renderFlags & ReflectiveBitMask) > 0) { + vec3 worldVec = normalize(worldPos.xyz); + + float angle = 2 * dot(normalize(normal), worldVec); + angle += gnoise(vec3(uv.x*500, uv.y*500, worldVec.z/10)) / 7.5; + outColor.rgb *= max(vec3(1), vec3(1) + 3*blockLight * gnoise(vec3(worldVec.x/10 + angle, worldVec.y/10 + angle, worldVec.z/10 + angle))); + } + + glow = pow(max(0.0, dot(normal, lightPosition)), 6) * 0.1 * shadowIntensity * (1 - fogAmount); + } + + + +#if GBUFFER == 1 + outGPosition = vec4(fragPosition.xyz, fogAmount * 2 + glowLevel); + outGNormal = gnormal; +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + + outGlow = vec4(glowLevel + glow, 0, 0, outColor.a); +#if TAAMOTION > 0 + // Opaque terrain is not reactive. + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, 0.0, gl_FragCoord.z); +#endif +} diff --git a/sources/shaders-vk/chunktopsoil.interface.glsl b/sources/shaders-vk/chunktopsoil.interface.glsl new file mode 100644 index 00000000..1ead5bf0 --- /dev/null +++ b/sources/shaders-vk/chunktopsoil.interface.glsl @@ -0,0 +1,49 @@ +// Program interface of chunktopsoil (docs/vulkan-native-shaders.md section 4). One draw per mesh pool: the +// push block holds the two sampler slots in chunktopsoil.fsh's declaration order, then origin and +// modelViewMatrix (84 B). The record holds every other uniform, chunktopsoil.vsh's first (the previous-frame +// warp state vertexwarp.glsl reads comes with its include), then chunktopsoil.fsh's and underwatereffects' +// frameSize. Every uniform is declared whatever the axes: the GLSL 330 name set does not depend on defines. +// +// A block member cannot carry chunktopsoil.fsh's initializer (alphaTest = 0.01); the runtime seeds the record +// from the GLSL 330 declarations (contract section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTexLinear); + vec3 origin; + mat4 modelViewMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogDensityIn; + float fogMinIn; + mat4 projectionMatrix; + float subpixelPaddingX; + float subpixelPaddingY; + mat4 prevProjectionMatrix; + mat4 prevModelViewMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + float alphaTest; + vec2 blockTextureSize; + vec2 taaRenderSize; + vec2 taaJitterPx; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/chunktopsoil.vert b/sources/shaders-vk/chunktopsoil.vert new file mode 100644 index 00000000..f2d4ad0c --- /dev/null +++ b/sources/shaders-vk/chunktopsoil.vert @@ -0,0 +1,131 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunktopsoil.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: USESSBO (attribute layout and FaceData), GBUFFER (fragPosition, gnormal), TAAMOTION (taaPrevClip). +// +// Optimum override of the vanilla chunktopsoil.vsh: adds the TAA motion-vector +// writer (P3). Everything else is vanilla, line for line. +// +// Topsoil deliberately does NOT call applyVertexWarping - vanilla has that call +// commented out and only applies the global warp - so the previous position +// must reproduce exactly that asymmetry, not the chunkopaque path. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunktopsoil.interface.glsl" + + #if USESSBO > 0 +// rgb = block light, a=sun light level +layout(location = 0) in vec4 rgbaLightIn; +layout(location = 1) in vec2 uv2In; + #else +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; // Check out vertexflagbits.ash for understanding the contents of this data +layout(location = 4) in vec2 uv2In; +layout(location = 5) in int colormapData; + #endif + + +layout(location = 0) out vec4 rgba; +layout(location = 1) out vec4 rgbaFog; +layout(location = 2) out float fogAmount; +layout(location = 3) out vec2 uv; +layout(location = 4) out vec2 uv2; +layout(location = 5) out vec3 normal; + + #if GBUFFER == 1 +layout(location = 6) out vec4 fragPosition; +layout(location = 7) out vec4 gnormal; + #endif + +layout(location = 8) out vec3 vertexPosition; +layout(location = 9) out vec4 worldPos; + +layout(location = 10) flat out int renderFlags; + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION > 0 +layout(location = 11) out vec4 taaPrevClip; +#endif + + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" +#include "colormap.vert.glsl" + + #if USESSBO > 0 +layout(std430, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_FACE_DATA) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + +const float uvEpsilon = 1.0 / 32768.0; + +void main(void) +{ + #if USESSBO > 0 + FaceData vdata = faces[gl_VertexIndex / 4]; + int vIndex = gl_VertexIndex & 0x03; + renderFlags = vdata.flags[vIndex]; + vertexPosition = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #else + renderFlags = renderFlagsIn; + vertexPosition = xyz; + #endif + + vec4 truePos = vec4(vertexPosition + origin, 1.0); + worldPos = truePos; + //worldPos = applyVertexWarping(renderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + +#if TAAMOTION > 0 + // The same vertex, one frame ago: camera-relative position displaced by the + // camera's own motion (accuracy rule 4), the global warp re-evaluated with + // the previous frame's counters - and no vertex warp, matching the vanilla + // path above - through the previous unjittered projection. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(truePos.xyz + cameraPosDelta, 1.0); + taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + } +#endif + + calcShadowMapCoords(modelViewMatrix, worldPos); + + #if USESSBO > 0 + calcColorMapUvs(vdata.colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + uv = UnpackUv(vdata, vIndex, subpixelPaddingX, subpixelPaddingY); + #else + calcColorMapUvs(colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + uv = uvIn; + #endif + uv2 = uv2In * 2.0 - vec2((int(uv2In.x * 0x10000) & 1) * (uvEpsilon + subpixelPaddingX * 2.0), (int(uv2In.y * 0x10000) & 1) * (uvEpsilon + subpixelPaddingY * 2.0)); // uv2In least significant bit is a flag which tells whether this coordinate is (for .x) u1 or u2, or (for .y) v1 or v2 + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + rgba = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos); + rgbaFog = rgbaFogIn; + + rgbaFog.a = clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5 + max(0.0, worldPos.y * 0.02), 0.0, 1.0); + + normal = unpackNormal(renderFlags); + +#if GBUFFER == 1 + fragPosition = cameraPos; + gnormal = modelViewMatrix * vec4(normal, 0); + gnormal.w=0; +#endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/chunktransparent.frag b/sources/shaders-vk/chunktransparent.frag new file mode 100644 index 00000000..502d8a68 --- /dev/null +++ b/sources/shaders-vk/chunktransparent.frag @@ -0,0 +1,68 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunktransparent.fsh (vanilla, docs/vulkan-native-shaders.md). Axis: USEOIT, through +// include/oit.glsl, which declares the six OIT outputs and OIT() only when it is 1. The client registers +// chunktransparent with Oit = true (ShaderProgramBase's default), so USEOIT=0 is never linked; the GLSL 330 +// program has no outputs and no OIT() there either, so the 0 variant skips the call and writes nothing. +// SHINYEFFECT is a specialization-constant branch. +// +// fogandlight.frag.glsl and fogspheres.glsl read names owned by fogandlight.vsh and vertexwarp.vsh, which the +// vertex stage includes, so this stage activates those owners' names itself (contract section 3). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunktransparent.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec4 rgba; +layout(location = 1) in vec4 rgbaFog; +layout(location = 2) in float fogAmount; +layout(location = 3) in vec2 uv; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 4) in vec4 worldPos; +layout(location = OPTIMUM_LOCATION_BLOCK_LIGHT) in vec3 blockLight; +layout(location = 5) in vec3 vertexPos; + +layout(location = 8) in float normalShadeIntensity; +layout(location = 6) flat in int renderFlags; +layout(location = 7) flat in vec3 normal; + +#include "vertexflagbits.glsl" +#include "fogandlight.frag.glsl" +#include "noise3d.glsl" +#include "colormap.frag.glsl" +#include "underwatereffects.glsl" +#include "oit.glsl" + +void main() +{ + // When looking through tinted glass you can clearly see the edges where we fade to sky color + // Using this discard seems to completely fix that + if (rgba.a < 0.005) discard; + + vec4 texColor = rgba * getColorMapped(optimumTextures2D[terrainTex], texture(optimumTextures2D[terrainTex], uv)); + + if (psychedelicStrength > Epsilon) texColor = applyPsychedelicEffect(texColor, vertexPos.xyz, 0); + + float murkiness=getUnderwaterMurkiness(); + if (murkiness > 0) { + texColor = applyFogAndShadowWithNormal(texColor, 0, normal, normalShadeIntensity, 0.45, worldPos.xyz); + texColor.rgb = applyUnderwaterEffects(texColor.rgb, murkiness); + } else { + texColor = applyFogAndShadowWithNormal(texColor, fogAmount, normal, normalShadeIntensity, 0.45, worldPos.xyz); + } + + + if (OPTIMUM_SHINYEFFECT > 0) { + float glow=0; + texColor = mix(applyReflectiveEffect(texColor, glow, renderFlags, uv, normal, worldPos, worldPos, blockLight), texColor, min(1, 2 * fogAmount)); + } + +#if USEOIT > 0 + OIT(texColor, glowLevel); +#endif + +} diff --git a/sources/shaders-vk/chunktransparent.interface.glsl b/sources/shaders-vk/chunktransparent.interface.glsl new file mode 100644 index 00000000..30db6f99 --- /dev/null +++ b/sources/shaders-vk/chunktransparent.interface.glsl @@ -0,0 +1,37 @@ +// Program interface of chunktransparent (docs/vulkan-native-shaders.md section 4). One draw per mesh pool: +// the push block holds the sampler slot, then origin, modelViewMatrix and forcedTransparency (84 B). The +// record holds every other uniform, chunktransparent.vsh's first (the previous-frame warp state +// vertexwarp.glsl reads comes with its include), then underwatereffects' frameSize. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTex); + vec3 origin; + mat4 modelViewMatrix; + float forcedTransparency; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogDensityIn; + float fogMinIn; + mat4 projectionMatrix; + float subpixelPaddingX; + float subpixelPaddingY; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/chunktransparent.vert b/sources/shaders-vk/chunktransparent.vert new file mode 100644 index 00000000..8cfcb538 --- /dev/null +++ b/sources/shaders-vk/chunktransparent.vert @@ -0,0 +1,98 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of chunktransparent.vsh (vanilla, docs/vulkan-native-shaders.md). Axis: USESSBO (attribute +// layout and FaceData). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "chunktransparent.interface.glsl" + + #if USESSBO > 0 +// rgb = block light, a=sun light level +layout(location = 0) in vec4 rgbaLightIn; + #else +layout(location = 0) in vec3 xyz; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; +layout(location = 4) in int colormapData; + #endif + + + +layout(location = 0) out vec4 rgba; +layout(location = 1) out vec4 rgbaFog; +layout(location = 2) out float fogAmount; +layout(location = 3) out vec2 uv; +layout(location = 4) out vec4 worldPos; +layout(location = 5) out vec3 vertexPos; + +layout(location = 6) flat out int renderFlags; +layout(location = 7) flat out vec3 normal; +layout(location = 8) out float normalShadeIntensity; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" +#include "colormap.vert.glsl" + + #if USESSBO > 0 +layout(std430, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_FACE_DATA) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + + +void main(void) +{ + #if USESSBO > 0 + FaceData vdata = faces[gl_VertexIndex / 4]; + int vIndex = gl_VertexIndex & 0x03; + renderFlags = vdata.flags[vIndex]; + vec3 xyz = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #else + renderFlags = renderFlagsIn; + + #endif + + vertexPos = xyz; + + vec4 truePos = vec4(xyz + origin, 1.0); + worldPos = truePos; + + worldPos = applyVertexWarping(renderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + calcShadowMapCoords(modelViewMatrix, worldPos); + + #if USESSBO > 0 + calcColorMapUvs(vdata.colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + uv = UnpackUv(vdata, vIndex, subpixelPaddingX, subpixelPaddingY); + #else + calcColorMapUvs(colormapData, truePos + vec4(playerpos, 1), rgbaLightIn.a, false); + uv = uvIn; + #endif + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + rgba = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos); + rgba.a = clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5 + max(0.0, worldPos.y * 0.02), -1.0, 1.0) - forcedTransparency; + + rgbaFog = rgbaFogIn; + + // To fix Z-Fighting on blocks over certain other blocks. + if (gl_Position.z > 0) { + int zOffset = (renderFlags & ZOffsetBitMask) >> 8; + gl_Position.w += zOffset * 0.00025 / max(3, gl_Position.z * 0.05); + } + + normal = unpackNormal(renderFlags); + normalShadeIntensity = min(1, rgbaLightIn.a * 1.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From 62dd7f48ccc2f53418b867728babeb08cf0cabc1 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:35:24 +0200 Subject: [PATCH 164/226] wip(shared-layout): every rewritten program on the one shared pipeline layout Samplers are push-constant slot indices over set 1's bindless arrays (frame texture names read set 0), every reference rewritten to optimumTextures[name] with scope-aware shadowing; loose uniforms are the program record at set 2 binding 3; named uniform blocks are std140 readonly storage buffers (Animation 1, AnimationPrev 2, others 4..7); storage blocks move to FaceData 0. ShaderProgramResources owns no layouts; the device binds set 1 once per recording, set 0/2 and push constants only on change, resolves units through ReadSelf copies and transient rebinds to physical slots, keys depth-read-only slots on DEPTH_READ_ONLY_OPTIMAL. Uniform ring gains STORAGE usage and aligns to both offset limits. New stats tokens push_constants, storage_set_binds, bindless_slots, bindless_placeholders. Verified: dotnet build VintageStory.slnx -c Release clean; dotnet test Optimum.Render.Vulkan.Tests 870/870 (sync,best validation, overlay layers disabled, no SYNC- hazards); dotnet test Optimum.Tests 1184 passed, 34 skipped. Game not launched. --- .../AttachmentSemanticsTests.cs | 32 +- .../ChunkRenderPathTests.cs | 17 +- .../FrameGlobalsTests.cs | 2 +- Optimum.Render.Vulkan.Tests/FrameRingTests.cs | 29 +- .../PacingStatsTests.cs | 6 +- .../PerDrawCostTests.cs | 8 +- .../PipelineCacheTests.cs | 19 +- .../SetConventionTests.cs | 96 +++ .../ShaderTranslationTests.cs | 7 +- .../ShaderTranslationUnitTests.cs | 296 ++++++- .../SharedLayoutTestBinding.cs | 142 ++++ .../SharedPipelineLayoutDrawTests.cs | 294 +++++++ .../TaaResolveTests.cs | 62 +- .../TaaSharpenTests.cs | 48 +- .../VulkanDeviceIntegrationTests.cs | 14 +- Optimum.Render.Vulkan/Core/BindlessSlots.cs | 2 +- .../Core/BindlessTextureTable.cs | 7 +- Optimum.Render.Vulkan/Core/DescriptorCache.cs | 48 +- Optimum.Render.Vulkan/Core/FrameRing.cs | 15 +- .../Core/ShaderProgramResources.cs | 188 +---- .../Core/SharedPipelineLayout.cs | 68 +- Optimum.Render.Vulkan/Core/VulkanContext.cs | 2 + Optimum.Render.Vulkan/Core/VulkanStats.cs | 67 +- Optimum.Render.Vulkan/Shaders/GlslParser.cs | 4 + .../Shaders/ProgramInterfaceLayout.cs | 212 +++-- .../Shaders/SetConvention.cs | 32 +- .../Shaders/ShaderRewriter.cs | 311 +++++++- Optimum.Render.Vulkan/VulkanDevice.cs | 741 ++++++++---------- Optimum.Tests/frame-graph-coverage-tests.cs | 2 +- docs/taa-acceptance.md | 9 +- docs/vulkan-native-shaders.md | 37 +- sources/shaders-vk/include/bindings.glsl | 7 +- 32 files changed, 1913 insertions(+), 911 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/SharedLayoutTestBinding.cs create mode 100644 Optimum.Render.Vulkan.Tests/SharedPipelineLayoutDrawTests.cs diff --git a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs index c6cc9680..aaa3a169 100644 --- a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -438,10 +438,10 @@ private static unsafe void RenderFullscreen( } /// - /// Like , but binds one combined-image-sampler - /// descriptor referring to at set 1, - /// binding 0 - the shape a resolve pass reading its own depth attachment - /// needs. The framebuffer's depth attachment is put in + /// Like , but samples + /// through a bindless slot keyed on the + /// read-only depth layout - the shape a resolve pass reading its own depth + /// attachment needs. The framebuffer's depth attachment is put in /// DEPTH_READ_ONLY_OPTIMAL for the scope rather than the write layout, and /// depth test/write stay off throughout. /// @@ -470,32 +470,24 @@ private static unsafe void RenderFullscreenSamplingDepth( Topology = state.Topology, }); - using var descriptors = new DescriptorCache(context); + using var binding = new SharedLayoutTestBinding(context, textures); + VulkanTexture depthTexture = textures.Get(sampledDepthTextureId)!; + var samplers = new Dictionary + { + [program.Interface.Samplers[0].Name] = new(sampledDepthTextureId, depthTexture.State, ImageLayout.DepthReadOnlyOptimal), + }; commands.SubmitAndWait(commandBuffer => { Vk api = context.Api; + binding.Transition(commandBuffer, samplers.Values); targets.Bind(commandBuffer, framebuffer); targets.SetDepthReadOnly(true); targets.EnsureRendering(commandBuffer); - VulkanTexture depthTexture = textures.Get(sampledDepthTextureId)!; - var samplerBinding = new SamplerBindingValue( - (uint)program.Interface.Samplers[0].Binding, - depthTexture.View, - textures.Samplers.Get(depthTexture.State), - depthTexture.Id, - ImageLayout.DepthReadOnlyOptimal); - - DescriptorSet samplerSet = descriptors.Get( - new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.SamplerSet, - new[] { samplerBinding }, Array.Empty()), - program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); - api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); + binding.Bind(commandBuffer, program, samplers); var viewport = new Viewport(0, 0, size, size, 0, 1); api.CmdSetViewport(commandBuffer, 0, 1, &viewport); diff --git a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs index 36532809..6318998a 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -362,18 +362,10 @@ void main(void) }); Assert.NotEqual((ulong)0, pipeline.Handle); - using var descriptors = new DescriptorCache(context!); + using var binding = new SharedLayoutTestBinding(context!, textures); VulkanBuffer faceBuffer = meshes.BufferOf(mesh, MeshManager.BufferXyz)!; BlockBinding storageBlock = Assert.Single(program.Interface.StorageBlocks); - DescriptorSet storageSet = descriptors.Get( - new DescriptorSetContents(1, ProgramInterfaceLayout.StorageSet, - Array.Empty(), - new[] - { - new BufferBindingValue( - (uint)storageBlock.Binding, faceBuffer.Handle, 0, faceBuffer.Size, faceBuffer.Id), - }), - program.SetLayouts[ProgramInterfaceLayout.StorageSet]); + Assert.Equal(SetConvention.FaceDataBinding, storageBlock.Binding); commands.SubmitAndWait(commandBuffer => { @@ -383,9 +375,8 @@ void main(void) Vk api = context!.Api; api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); - DescriptorSet boundStorageSet = storageSet; - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.StorageSet, 1, &boundStorageSet, 0, null); + binding.Bind(commandBuffer, program, new Dictionary(), + storage: faceBuffer); var viewport = new Viewport(0, 0, size, size, 0, 1); api.CmdSetViewport(commandBuffer, 0, 1, &viewport); diff --git a/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs index 7560cf6f..37ff06b1 100644 --- a/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs @@ -101,7 +101,7 @@ void main() {} Assert.Contains("layout(scalar, set = 0, binding = 0) uniform OptimumFrameGlobals", code); Assert.Contains($"layout(offset = {lights.Offset}) vec3 pointLights[4];", code); Assert.Contains($"layout(offset = {distance.Offset}) float viewDistance;", code); - Assert.Contains("layout(scalar, set = 3, binding = 0) uniform OptimumUniforms", code); + Assert.Contains("layout(scalar, set = 2, binding = 3) uniform OptimumUniforms", code); Assert.DoesNotContain("uniform vec3 pointLights[4];", code); } diff --git a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs index dccb9fa1..f5a29274 100644 --- a/Optimum.Render.Vulkan.Tests/FrameRingTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs @@ -219,23 +219,6 @@ public void SlotsAllocateFromDisjointRegionsOfOneSharedBuffer() // -------------------------------------------------------- descriptor cache - /// - /// The set and binding numbers are decided by the shader rewriter and - /// duplicated as constants in the descriptor layer so it does not depend on - /// the translation types. If the two ever drift, samplers get written into - /// the wrong set and nothing renders. - /// - [Fact] - public void DescriptorBindingConstantsAgreeWithTheShaderRewriter() - { - Assert.Equal(ProgramInterfaceLayout.FrameSet, ProgramInterfaceLayoutBindings.FrameSet); - Assert.Equal(FrameGlobals.Binding, ProgramInterfaceLayoutBindings.FrameBinding); - Assert.Equal(ProgramInterfaceLayout.DefaultBlockSet, ProgramInterfaceLayoutBindings.DefaultBlockSet); - Assert.Equal(ProgramInterfaceLayout.DefaultBlockBinding, ProgramInterfaceLayoutBindings.DefaultBlockBinding); - Assert.Equal(ProgramInterfaceLayout.SamplerSet, ProgramInterfaceLayoutBindings.SamplerSet); - Assert.Equal(ProgramInterfaceLayout.StorageSet, ProgramInterfaceLayoutBindings.StorageSet); - } - [Fact] public void DescriptorContentsCompareByValue() { @@ -278,10 +261,10 @@ public unsafe void RepeatedIdenticalBindingsReuseOneDescriptorSet() ImageUsageFlags.SampledBit, ImageAspectFlags.ColorBit); Sampler sampler = CreateSampler(context!); - DescriptorSetLayout layout = program.SetLayouts[ProgramInterfaceLayout.SamplerSet]; + DescriptorSetLayout layout = program.StandaloneLayout!.FrameSetLayout; - DescriptorSetContents Contents() => new(1, ProgramInterfaceLayout.SamplerSet, - new[] { new SamplerBindingValue(0, image.View, sampler) }, + DescriptorSetContents Contents() => new(1, SetConvention.FrameSet, + new[] { new SamplerBindingValue((uint)SetConvention.FrameTextures[0].Value, image.View, sampler) }, Array.Empty()); DescriptorSet first = cache.Get(Contents(), layout); @@ -319,7 +302,7 @@ public unsafe void TheCacheGrowsBeyondASinglePool() ImageUsageFlags.SampledBit, ImageAspectFlags.ColorBit); Sampler sampler = CreateSampler(context!); - DescriptorSetLayout layout = program.SetLayouts[ProgramInterfaceLayout.SamplerSet]; + DescriptorSetLayout layout = program.StandaloneLayout!.FrameSetLayout; // Distinct views over one image: cheap, and enough to make each set's // contents unique without one device allocation per entry. @@ -340,8 +323,8 @@ public unsafe void TheCacheGrowsBeyondASinglePool() context!.Api.CreateImageView(context.Device, &viewInfo, null, out ImageView view); views.Add(view); - cache.Get(new DescriptorSetContents(1, ProgramInterfaceLayout.SamplerSet, - new[] { new SamplerBindingValue(0, view, sampler) }, + cache.Get(new DescriptorSetContents(1, SetConvention.FrameSet, + new[] { new SamplerBindingValue((uint)SetConvention.FrameTextures[0].Value, view, sampler) }, Array.Empty()), layout); } diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index ef87f746..ccd5272e 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -134,8 +134,10 @@ public void NewStatsLinesCarryStableKeyValueTokens() "stats.counters blocking_uploads=1 uploads=2 scopes=3 barriers=4 rebar_fallbacks=5 " + "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 barrier_commands=9 barriers_per_frame=2.0 " + "mask_restarts=10 feedback_splits=11 passes=12 plan_hits=13 plan_misses=14 in_pass_clears=15 " + - "promoted_clears=16 standalone_clears=17 pass_splits=18", - VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18))); + "promoted_clears=16 standalone_clears=17 pass_splits=18 push_constants=19 storage_set_binds=20 " + + "bindless_slots=21 bindless_placeholders=22", + VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22))); Assert.Equal( "stats.transients transient_mib=1.5 aliased_mib=0.5 heap_peak_mib=64.0 leases=3 aliased_leases=1 " + diff --git a/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs index 0932c57e..92b5a277 100644 --- a/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs @@ -42,10 +42,10 @@ void main(void) { outColor = vec4( private const string SampleFragment = """ #version 330 core - uniform sampler2D source; + uniform sampler2D sky; in vec2 uv; out vec4 outColor; - void main(void) { outColor = texture(source, uv); } + void main(void) { outColor = texture(sky, uv); } """; private const string MeshVertex = """ @@ -295,7 +295,7 @@ public void TheDescriptorArenaResetsEveryFrameAndAgedSetsMoveToTheCache() seam.BindFramebuffer(target); seam.ClearColor(0, 0f, 0f, 0f, 1f); seam.UseProgram(program); - seam.SetSamplerUnit(program, "source", 0); + seam.SetSamplerUnit(program, "sky", 0); seam.BindTexture(0, texture); BaseState(seam); seam.DrawFullscreenTriangle(); @@ -363,7 +363,7 @@ public void AnArenaSetNamingADeletedTextureNeverReachesALaterFrame() seam.BeginFrame(); seam.BindFramebuffer(target); seam.UseProgram(program); - seam.SetSamplerUnit(program, "source", 0); + seam.SetSamplerUnit(program, "sky", 0); seam.BindTexture(0, texture); BaseState(seam); seam.DrawFullscreenTriangle(); diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs index c2f47496..5476c73e 100644 --- a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -18,9 +18,9 @@ namespace Optimum.Render.Vulkan.Tests; /// check the cache behaves. /// /// This is where a mistake in the descriptor-set design surfaces. The rewriter -/// decides that samplers live in set 1 and storage buffers in set 2; nothing -/// validates that decision until a driver is asked to build a pipeline layout -/// from it alongside the SPIR-V that assumes it. +/// decides where samplers, the record and storage buffers live in the shared +/// pipeline layout; nothing validates that decision until a driver is asked to +/// build a pipeline from the SPIR-V that assumes it against that layout. /// public class PipelineCacheTests { @@ -69,7 +69,8 @@ public void AVanillaProgramProducesUsableDescriptorAndPipelineLayouts() _output.WriteLine($"storage blocks: {translated.Layout.StorageBlocks.Count}"); Assert.NotEqual(0ul, program.PipelineLayout.Handle); - foreach (DescriptorSetLayout layout in program.SetLayouts) + SharedPipelineLayout shared = program.StandaloneLayout!; + foreach (DescriptorSetLayout layout in new[] { shared.FrameSetLayout, shared.TextureSetLayout, shared.StorageSetLayout }) { Assert.NotEqual(0ul, layout.Handle); } @@ -86,9 +87,9 @@ public void AVanillaProgramProducesUsableDescriptorAndPipelineLayouts() } /// - /// The chunk program is the one with a storage buffer at a binding the shader - /// declared. Building a layout for it checks that set 2 and binding 3 line up - /// between the rewriter and the descriptor layout. + /// The chunk program is the one with a storage buffer, declared at binding 3 in + /// the shader - the record's binding under the shared layout. The rewriter moves it + /// to FaceData's binding in set 2, where the draw path binds the mesh buffer. /// [SkippableFact] public void TheChunkProgramsStorageBufferLandsWhereTheShaderExpectsIt() @@ -104,8 +105,8 @@ public void TheChunkProgramsStorageBufferLandsWhereTheShaderExpectsIt() Assert.True(translated.Success, string.Join("; ", translated.Errors)); BlockBinding storage = Assert.Single(translated.Layout.StorageBlocks); - Assert.Equal(ProgramInterfaceLayout.StorageSet, storage.Set); - Assert.Equal(3, storage.Binding); + Assert.Equal(SetConvention.StorageSet, storage.Set); + Assert.Equal(SetConvention.FaceDataBinding, storage.Binding); using var program = new ShaderProgramResources(context!, programId: 2, translated); Assert.NotEqual(0ul, program.PipelineLayout.Handle); diff --git a/Optimum.Render.Vulkan.Tests/SetConventionTests.cs b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs index d2dd8148..5dd28f95 100644 --- a/Optimum.Render.Vulkan.Tests/SetConventionTests.cs +++ b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using Optimum.Render.Vulkan.Core; using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; using Vintagestory.API.Client; using Xunit; @@ -34,6 +35,8 @@ public void EveryDefineInTheIncludeHasTheValueTheRendererUses() ["OPTIMUM_PUSH_CONSTANT_BYTES"] = (int)SetConvention.PushConstantBytes, ["OPTIMUM_BINDING_FRAME_GLOBALS"] = SetConvention.FrameGlobalsBinding, ["OPTIMUM_BINDING_PROGRAM_RECORD"] = SetConvention.ProgramRecordBinding, + ["OPTIMUM_BINDING_NAMED_BLOCK_FIRST"] = SetConvention.NamedBlockFirstBinding, + ["OPTIMUM_BINDING_NAMED_BLOCK_LAST"] = SetConvention.NamedBlockLastBinding, }; foreach (SetConvention.Binding binding in SetConvention.FrameTextures) expected[binding.Define] = binding.Value; foreach (SetConvention.Binding binding in SetConvention.StorageBuffers) expected[binding.Define] = binding.Value; @@ -89,8 +92,101 @@ public void BindingsAreUniqueWithinEachSet() AssertUnique(SetConvention.FrameGlobalsBinding, SetConvention.FrameTextures); AssertUnique(null, SetConvention.TextureArrays); AssertUnique(SetConvention.ProgramRecordBinding, SetConvention.StorageBuffers); + foreach (SetConvention.Binding buffer in SetConvention.StorageBuffers) + { + Assert.False(buffer.Value is >= SetConvention.NamedBlockFirstBinding and <= SetConvention.NamedBlockLastBinding, + buffer.Define + " sits in the named-block range"); + } + Assert.False(SetConvention.ProgramRecordBinding is >= SetConvention.NamedBlockFirstBinding and <= SetConvention.NamedBlockLastBinding); + Assert.Equal(SetConvention.NamedBlockLastBinding + 1, SetConvention.StorageSetBindingCount); + Assert.Equal(SetConvention.StorageSetBindingCount, SharedPipelineLayout.StorageBindings().Length); } + /// + /// The rewriter is the other place the convention's numbers are written down: a + /// mod-shader program's compiled SPIR-V must name exactly the sets and bindings the + /// shared layout declares for what it reads - frame block and frame texture in set 0, + /// the sampler's array in set 1, FaceData, Animation, the record and a named block in + /// set 2 - and its push block must fit the layout's range. + /// + [SkippableFact] + public void TheRewritersSetsAndBindingsAreTheConventions() + { + const string vertex = """ + #version 330 core + layout(binding = 3, std430) readonly buffer faceDataBuf { vec4 faces[]; }; + layout(std140) uniform Animation { mat4 values[2]; }; + layout(std140) uniform Extra { vec4 extra; }; + uniform float viewDistance; + void main() { gl_Position = faces[0] * values[1] * extra * viewDistance; } + """; + const string fragment = """ + #version 330 core + uniform sampler2DShadow shadowMapFar; + uniform sampler2DArray terrainTex; + uniform float alphaTest; + out vec4 outColor; + void main() { outColor = texture(terrainTex, vec3(alphaTest)) * texture(shadowMapFar, vec3(0.5)); } + """; + + ShaderCompiler compiler; + try + { + compiler = new ShaderCompiler(); + } + catch (Exception error) when (error is DllNotFoundException or InvalidOperationException) + { + Skip.If(true, "shaderc unavailable: " + error.Message); + return; + } + + using (compiler) + { + TranslatedProgram translated = ShaderTranslator.Translate(new[] + { + new ShaderStageSource { Stage = EnumShaderType.VertexShader, Code = vertex, Filename = "convention.vsh" }, + new ShaderStageSource { Stage = EnumShaderType.FragmentShader, Code = fragment, Filename = "convention.fsh" }, + }, compiler, includes: new HashSet(StringComparer.Ordinal) { "fogandlight.vsh" }); + Assert.True(translated.Success, string.Join("; ", translated.Errors)); + + var declared = new HashSet<(int Set, int Binding, SpirvDescriptorKind Kind)>(); + foreach (DescriptorSetLayoutBinding binding in SharedPipelineLayout.FrameBindings()) + declared.Add((SetConvention.FrameSet, (int)binding.Binding, KindOf(binding.DescriptorType))); + foreach (SetConvention.Binding array in SetConvention.TextureArrays) + declared.Add((SetConvention.TextureSet, array.Value, SpirvDescriptorKind.CombinedImageSampler)); + foreach (DescriptorSetLayoutBinding binding in SharedPipelineLayout.StorageBindings()) + declared.Add((SetConvention.StorageSet, (int)binding.Binding, KindOf(binding.DescriptorType))); + + var used = new HashSet<(int, int, SpirvDescriptorKind)>(); + foreach (byte[] spirv in translated.Spirv.Values) + { + SpirvModuleReflection reflection = SpirvReflection.Reflect(spirv); + foreach (SpirvDescriptorBinding binding in reflection.Bindings) + { + var key = (binding.Set, binding.Binding, binding.Kind); + Assert.True(declared.Contains(key), $"{binding.Name} at set {binding.Set} binding {binding.Binding} ({binding.Kind}) is not in the shared layout"); + used.Add(key); + } + } + + Assert.Contains((SetConvention.FrameSet, SetConvention.FrameGlobalsBinding, SpirvDescriptorKind.UniformBuffer), used); + Assert.Contains((SetConvention.FrameSet, SetConvention.FrameTextures[0].Value, SpirvDescriptorKind.CombinedImageSampler), used); + Assert.Contains((SetConvention.TextureSet, SetConvention.TextureArrays[1].Value, SpirvDescriptorKind.CombinedImageSampler), used); + Assert.Contains((SetConvention.StorageSet, SetConvention.FaceDataBinding, SpirvDescriptorKind.StorageBuffer), used); + Assert.Contains((SetConvention.StorageSet, SetConvention.AnimationBinding, SpirvDescriptorKind.StorageBuffer), used); + Assert.Contains((SetConvention.StorageSet, SetConvention.ProgramRecordBinding, SpirvDescriptorKind.UniformBuffer), used); + Assert.Contains((SetConvention.StorageSet, SetConvention.NamedBlockFirstBinding, SpirvDescriptorKind.StorageBuffer), used); + Assert.True(translated.Layout.PushConstantSize <= SetConvention.PushConstantBytes); + } + } + + private static SpirvDescriptorKind KindOf(DescriptorType type) => type switch + { + DescriptorType.UniformBuffer or DescriptorType.UniformBufferDynamic => SpirvDescriptorKind.UniformBuffer, + DescriptorType.StorageBuffer or DescriptorType.StorageBufferDynamic => SpirvDescriptorKind.StorageBuffer, + _ => SpirvDescriptorKind.CombinedImageSampler, + }; + /// /// The include is real GLSL: a fragment shader that includes it and samples a /// frame texture and a bindless array element compiles to SPIR-V. diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs index 8b6c029e..22d2ad31 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs @@ -288,15 +288,14 @@ public void ChunkShadersTranslateWithSsboAndGreedyMeshEnabled() Assert.True(result.Success, $"{program}: {string.Join("; ", result.Errors)}"); - // The storage buffer must keep the binding the shader declared: the - // mesh path binds the vertex buffer to that exact index. + // The storage buffer moves to FaceData's binding in set 2, whatever the + // shader declared: the mesh path binds the vertex buffer there. if (program is "chunkopaque" or "chunktransparent" or "chunktopsoil") { BlockBinding? faceData = result.Layout.StorageBlocks .FirstOrDefault(b => b.BlockName == "faceDataBuf"); Assert.NotNull(faceData); - Assert.Equal(3, faceData!.Binding); - Assert.True(faceData.Explicit, "declared binding should be preserved, not reassigned"); + Assert.Equal(SetConvention.FaceDataBinding, faceData!.Binding); } } } diff --git a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs index fae745d5..75b5fce3 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Optimum.Render.Vulkan.Core; using Optimum.Render.Vulkan.Shaders; using Vintagestory.API.Client; using Xunit; @@ -153,12 +154,13 @@ public void FunctionsNamedLikeMainDoNotCountAsTheEntryPoint() /// /// chunkopaque.vsh declares layout(binding = 3, std430) readonly buffer - /// faceDataBuf and the mesh path binds the vertex buffer to that exact - /// index, so the declared binding has to survive. It also has to be - /// recognised at all: failing to step over "readonly" left it unclassified. + /// faceDataBuf. Binding 3 is the program record's under the shared layout, so + /// the block moves to FaceData's binding, where the mesh path binds the vertex + /// buffer. It also has to be recognised at all: failing to step over "readonly" + /// left it unclassified. /// [Fact] - public void DeclaredStorageBufferBindingsSurviveMemoryQualifiers() + public void DeclaredStorageBuffersMoveToTheFaceDataBindingThroughMemoryQualifiers() { ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ #version 330 core @@ -169,13 +171,12 @@ void main() {} BlockBinding block = Assert.Single(layout.StorageBlocks); Assert.Equal("faceDataBuf", block.BlockName); - Assert.Equal(3, block.Binding); - Assert.True(block.Explicit); - Assert.Equal(ProgramInterfaceLayout.StorageSet, block.Set); + Assert.Equal(SetConvention.FaceDataBinding, block.Binding); + Assert.Equal(SetConvention.StorageSet, block.Set); } [Fact] - public void SamplersBecomeDescriptorsRatherThanBlockMembers() + public void SamplersBecomeSlotsOrFrameTexturesRatherThanBlockMembers() { ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, """ #version 330 core @@ -186,8 +187,11 @@ void main() {} """)); Assert.Equal(2, layout.Samplers.Count); - Assert.Equal(0, layout.SamplersByName["terrainTex"].Binding); - Assert.Equal(1, layout.SamplersByName["shadowMapFar"].Binding); + Assert.Equal(0, layout.SamplersByName["terrainTex"].Order); + Assert.Equal(1, layout.SamplersByName["shadowMapFar"].Order); + Assert.Equal(0, layout.SamplersByName["terrainTex"].PushOffset); + Assert.Equal(SetConvention.FrameTextures[0].Value, layout.SamplersByName["shadowMapFar"].FrameBinding); + Assert.Equal(4, layout.PushConstantSize); Assert.DoesNotContain("terrainTex", layout.MembersByName.Keys); Assert.Equal(4, layout.BlockSize); } @@ -256,23 +260,27 @@ private static int CountOf(string text, string needle) } /// - /// Set 0, binding 0 belongs to the generated OptimumUniforms block. A shader - /// that claims it for its own block would double-register that descriptor. + /// A shader's own binding numbers mean nothing under the shared layout: binding 0 + /// is FaceData's and binding 3 the record's. Named blocks take the named-block + /// range in declaration order; the game's Animation pair takes its own bindings. /// [Fact] - public void AUserBlockAtBindingZeroMovesOffTheOptimumUniformsBinding() + public void NamedBlocksTakeTheConventionsSetTwoBindingsWhateverTheShaderStated() { ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ #version 330 core layout(std140, binding = 0) uniform Lights { vec4 pos; }; - layout(std140, binding = 2) uniform Fog { vec4 colour; }; + layout(std140) uniform AnimationPrev { mat4 prev[2]; }; + layout(std140, binding = 3) uniform Fog { vec4 colour; }; + layout(std140) uniform Animation { mat4 values[2]; }; void main() {} """)); - Assert.Equal(2, layout.UniformBlocks.Count); - Assert.NotEqual(ProgramInterfaceLayout.DefaultBlockBinding, layout.UniformBlocks[0].Binding); - Assert.NotEqual(layout.UniformBlocks[1].Binding, layout.UniformBlocks[0].Binding); - Assert.Equal(2, layout.UniformBlocks[1].Binding); + Assert.Empty(layout.Errors); + Assert.Equal( + new[] { ("Lights", 4), ("AnimationPrev", SetConvention.AnimationPrevBinding), ("Fog", 5), ("Animation", SetConvention.AnimationBinding) }, + layout.UniformBlocks.Select(b => (b.BlockName, b.Binding))); + Assert.All(layout.UniformBlocks, b => Assert.Equal(SetConvention.StorageSet, b.Set)); } [Fact] @@ -310,7 +318,7 @@ void main() {} ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, source)); string code = RewriteVertex(source, layout); - Assert.Contains("layout(scalar, set = 3, binding = 0) uniform OptimumUniforms", code); + Assert.Contains("layout(scalar, set = 2, binding = 3) uniform OptimumUniforms", code); Assert.Contains("layout(offset = 0) float zNear;", code); Assert.Contains("layout(offset = 4) vec3 tint;", code); // The originals are gone, so the names resolve to the block members. @@ -574,4 +582,254 @@ public void PreprocessingLeavesAcceptableVersionsAlone(string source) { Assert.Equal(source, ShaderCompiler.RaiseVersionForPreprocessing(source)); } + + // ------------------------------------------------------- shared layout: samplers + + private static string RewriteFragment(string source, ProgramInterfaceLayout layout) => + ShaderRewriter.Rewrite(Parse(source), layout, EnumShaderType.FragmentShader, emitDepthRemap: false).Code; + + /// + /// Every non-frame sampler becomes a uint slot in the push block under its own + /// name, four bytes each in GLSL 330 declaration order, and the stage declares + /// the set 1 array of each kind it indexes. The sampler declaration itself is gone. + /// + [Fact] + public void SamplersBecomePushSlotsInDeclarationOrderBesideTheirBindlessArrays() + { + const string source = """ + #version 330 core + uniform sampler2DArray terrainTex; + uniform float alphaTest; + uniform sampler2D glowTex; + out vec4 outColor; + void main() { outColor = texture(terrainTex, vec3(0.5)) + texture(glowTex, vec2(0.5)) * alphaTest; } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + string code = RewriteFragment(source, layout); + + Assert.Empty(layout.Errors); + Assert.Equal(8, layout.PushConstantSize); + Assert.Equal((TextureKind.Texture2DArray, 0), (layout.SamplersByName["terrainTex"].Kind, layout.SamplersByName["terrainTex"].PushOffset)); + Assert.Equal((TextureKind.Texture2D, 4), (layout.SamplersByName["glowTex"].Kind, layout.SamplersByName["glowTex"].PushOffset)); + + Assert.Contains("layout(push_constant, scalar) uniform OptimumDraw", code); + Assert.Contains("layout(offset = 0) uint terrainTex;", code); + Assert.Contains("layout(offset = 4) uint glowTex;", code); + Assert.Contains("#extension GL_EXT_nonuniform_qualifier : require", code); + Assert.Contains("layout(set = 1, binding = 0) uniform sampler2D optimumTextures2D[];", code); + Assert.Contains("layout(set = 1, binding = 1) uniform sampler2DArray optimumTextures2DArray[];", code); + Assert.DoesNotContain("uniform sampler2DArray terrainTex", code); + Assert.Contains("texture(optimumTextures2DArray[terrainTex], vec3(0.5)) + texture(optimumTextures2D[glowTex], vec2(0.5))", code); + } + + /// + /// The sampling call forms the corpus uses (texture 140x, texelFetch 19x, + /// textureGather 2x, textureLod and textureGrad once each) plus textureSize all + /// take the sampler as an argument; each is rewritten to the indexed array element. + /// + [Theory] + [InlineData("texture(tex, uv)")] + [InlineData("texelFetch(tex, ivec2(0), 0)")] + [InlineData("textureLod(tex, uv, 0.0)")] + [InlineData("textureGather(tex, uv, 1)")] + [InlineData("textureGrad(tex, uv, vec2(0.0), vec2(0.0))")] + [InlineData("vec4(textureSize(tex, 0), 0.0, 1.0)")] + [InlineData("textureProj(tex, vec3(uv, 1.0))")] + public void EverySamplingCallFormReadsTheIndexedArrayElement(string call) + { + string source = "#version 330 core\nuniform sampler2D tex;\nin vec2 uv;\nout vec4 outColor;\nvoid main() { outColor = " + + call + "; }\n"; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + string code = RewriteFragment(source, layout); + + Assert.Contains("outColor = " + call.Replace("(tex,", "(optimumTextures2D[tex],", StringComparison.Ordinal) + ";", code); + using var compiler = new ShaderCompiler(); + ShaderCompileResult compiled = compiler.Compile(code, "call.frag", EnumShaderType.FragmentShader); + Assert.True(compiled.Success, compiled.Error + "\n" + code); + } + + /// + /// A sampler handed to a function - colormap's getColorMapped, FXAA's texture chain, + /// the TAA resolve's Catmull-Rom - is rewritten at the call, while the function's own + /// sampler parameter, and every use of it, keep their names even when the parameter + /// shadows a global sampler of the same name. + /// + [Fact] + public void SamplersPassedToFunctionsAreRewrittenAtTheCallAndParametersKeepTheirNames() + { + const string source = """ + #version 330 core + uniform sampler2D terrainTex; + uniform sampler2D tex; + in vec2 uv; + out vec4 outColor; + vec4 getColorMapped(sampler2D sourceTex, vec4 color) { return texture(sourceTex, uv) * color; } + vec4 sampleTwice(sampler2D tex, vec2 at) { return texture(tex, at) + textureLod(tex, at, 0.0); } + float shadowing(float terrainTex) { return terrainTex * 2.0; } + void main() + { + float tex2 = 1.0; + outColor = getColorMapped(terrainTex, sampleTwice(tex, uv)) * shadowing(tex2); + { + float terrainTex = 0.5; + outColor *= terrainTex; + } + outColor += texture(terrainTex, uv); + } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + string code = RewriteFragment(source, layout); + + Assert.Contains("vec4 getColorMapped(sampler2D sourceTex, vec4 color) { return texture(sourceTex, uv) * color; }", code); + Assert.Contains("vec4 sampleTwice(sampler2D tex, vec2 at) { return texture(tex, at) + textureLod(tex, at, 0.0); }", code); + Assert.Contains("float shadowing(float terrainTex) { return terrainTex * 2.0; }", code); + Assert.Contains("getColorMapped(optimumTextures2D[terrainTex], sampleTwice(optimumTextures2D[tex], uv))", code); + Assert.Contains("float terrainTex = 0.5;\n outColor *= terrainTex;", code.Replace("\r", "")); + Assert.Contains("outColor += texture(optimumTextures2D[terrainTex], uv);", code); + + using var compiler = new ShaderCompiler(); + ShaderCompileResult compiled = compiler.Compile(code, "functions.frag", EnumShaderType.FragmentShader); + Assert.True(compiled.Success, compiled.Error + "\n" + code); + } + + /// Comments, fields and preprocessor lines that mention a sampler's name are left alone. + [Fact] + public void CommentsFieldsAndDirectivesNamingASamplerAreNotRewritten() + { + const string source = """ + #version 330 core + #line 7 + uniform sampler2D bloom; + struct Light { float bloom; }; + out vec4 outColor; + // texture(bloom, ...) in a comment + void main() { Light l; l.bloom = 1.0; /* bloom */ outColor = texture(bloom, vec2(l.bloom)); } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + string code = RewriteFragment(source, layout); + + Assert.Contains("#line 7", code); + Assert.Contains("struct Light { float bloom; };", code); + Assert.Contains("// texture(bloom, ...) in a comment", code); + Assert.Contains("l.bloom = 1.0; /* bloom */ outColor = texture(optimumTextures2D[bloom], vec2(l.bloom));", code); + } + + /// + /// A sampler named and typed like one of set 0's frame textures reads that binding + /// under its own name; the same name with another type is an ordinary slot. + /// + [Fact] + public void FrameTextureNamesResolveToSetZeroOnlyWithTheConventionsType() + { + const string source = """ + #version 330 core + uniform sampler2DShadow shadowMapFar; + uniform sampler2D sky; + uniform sampler2DArray glow; + out vec4 outColor; + void main() { outColor = texture(sky, vec2(0.5)) * texture(shadowMapFar, vec3(0.5)) + texture(glow, vec3(0.5)); } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + string code = RewriteFragment(source, layout); + + Assert.True(layout.UsesFrameTextures); + Assert.Contains("layout(set = 0, binding = 1) uniform sampler2DShadow shadowMapFar;", code); + Assert.Contains("layout(set = 0, binding = 3) uniform sampler2D sky;", code); + Assert.Equal(-1, layout.SamplersByName["glow"].FrameBinding); + Assert.Equal(0, layout.SamplersByName["glow"].PushOffset); + Assert.Contains("texture(sky, vec2(0.5)) * texture(shadowMapFar, vec3(0.5)) + texture(optimumTextures2DArray[glow], vec3(0.5))", code); + } + + [Fact] + public void SamplerArraysUnknownKindsAndAFullPushBlockFailTheLink() + { + Assert.Contains(LayoutOf((EnumShaderType.FragmentShader, "#version 330 core\nuniform sampler2D many[4];\nvoid main() {}\n")).Errors, + e => e.Contains("array", StringComparison.Ordinal)); + Assert.Contains(LayoutOf((EnumShaderType.FragmentShader, "#version 330 core\nuniform sampler1D line;\nvoid main() {}\n")).Errors, + e => e.Contains("no bindless array", StringComparison.Ordinal)); + + var declarations = string.Concat(Enumerable.Range(0, 33).Select(i => $"uniform sampler2D s{i};\n")); + Assert.Contains(LayoutOf((EnumShaderType.FragmentShader, "#version 330 core\n" + declarations + "void main() {}\n")).Errors, + e => e.Contains("push byte 132", StringComparison.Ordinal)); + } + + // ---------------------------------------------------- shared layout: set 2 blocks + + /// + /// A named uniform block becomes a std140 readonly storage buffer in set 2, whatever + /// memory layout the shader wrote, with its instance name and members untouched, so + /// the client's std140 upload is read as it was written. + /// + [Fact] + public void NamedUniformBlocksBecomeStd140ReadonlyStorageBuffersInSetTwo() + { + const string source = """ + #version 330 core + layout (std140) uniform Animation + { + mat4 values[4]; + } ElementTransforms; + uniform Tint { vec4 tint; }; + void main() { gl_Position = ElementTransforms.values[1] * tint; } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, source)); + string code = RewriteVertex(source, layout); + + Assert.Empty(layout.Errors); + Assert.Contains("layout(std140, set = 2, binding = 1) readonly buffer Animation", code); + Assert.Contains("} ElementTransforms;", code); + Assert.Contains("layout(std140, set = 2, binding = 4) readonly buffer Tint { vec4 tint; };", code); + + using var compiler = new ShaderCompiler(); + ShaderCompileResult compiled = compiler.Compile(code, "blocks.vert", EnumShaderType.VertexShader); + Assert.True(compiled.Success, compiled.Error + "\n" + code); + var reflection = SpirvReflection.Reflect(compiled.Spirv); + Assert.Contains(reflection.Bindings, b => b.Set == 2 && b.Binding == 1 && b.Kind == SpirvDescriptorKind.StorageBuffer); + } + + [Fact] + public void MoreNamedBlocksThanTheRangeHoldsFailTheLink() + { + var blocks = string.Concat(Enumerable.Range(0, 5).Select(i => $"layout(std140) uniform B{i} {{ vec4 v{i}; }};\n")); + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, "#version 330 core\n" + blocks + "void main() {}\n")); + Assert.Contains(layout.Errors, e => e.Contains("'B4' does not fit set 2", StringComparison.Ordinal)); + } + + /// + /// Loose uniforms are the program record at set 2's record binding, scalar layout, + /// with the offsets LocationOf hands out; the push block and the record never share + /// a name, and both compile side by side. + /// + [Fact] + public void LooseUniformsAreTheProgramRecordBesideThePushBlock() + { + const string source = """ + #version 330 core + uniform sampler2D tex; + uniform float alphaTest; + uniform vec3 rgbaFog; + out vec4 outColor; + void main() { outColor = texture(tex, vec2(alphaTest)) + vec4(rgbaFog, 1.0); } + """; + + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.FragmentShader, source)); + string code = RewriteFragment(source, layout); + + Assert.Contains("layout(scalar, set = 2, binding = 3) uniform OptimumUniforms", code); + Assert.Contains("layout(offset = 0) float alphaTest;", code); + Assert.Contains("layout(offset = 4) vec3 rgbaFog;", code); + Assert.True(layout.UsesStorageSet); + + using var compiler = new ShaderCompiler(); + ShaderCompileResult compiled = compiler.Compile(code, "record.frag", EnumShaderType.FragmentShader); + Assert.True(compiled.Success, compiled.Error + "\n" + code); + var reflection = SpirvReflection.Reflect(compiled.Spirv); + Assert.Contains(reflection.Bindings, b => b.Set == 2 && b.Binding == 3); + } } diff --git a/Optimum.Render.Vulkan.Tests/SharedLayoutTestBinding.cs b/Optimum.Render.Vulkan.Tests/SharedLayoutTestBinding.cs new file mode 100644 index 00000000..046c10aa --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SharedLayoutTestBinding.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Binds a program built outside a device the way the device's draw path does under +/// the shared pipeline layout (plan decision 9): each sampler's texture gets a slot in +/// a bindless table of its own and the index goes into the push block, set 1 is the +/// table's set, and set 2 carries the record buffer at its dynamic binding, a storage +/// buffer at every storage block's binding and a zero-filled placeholder everywhere else. +/// For component tests that draw through with a +/// command buffer of their own. +/// +internal sealed unsafe class SharedLayoutTestBinding : IDisposable +{ + private sealed class StillClock : ITimelineClock + { + public ulong FrameRecorded => 1; + public ulong TransferRecorded => 1; + public ulong FrameCompleted => 0; + public ulong TransferCompleted => 0; + } + + /// What one sampler reads. + public readonly record struct SampledTexture(int TextureId, SamplerState State, + ImageLayout Layout = ImageLayout.ShaderReadOnlyOptimal); + + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly DescriptorCache _descriptors; + private readonly VulkanBuffer _placeholder; + + public BindlessTextureTable Table { get; } + + public SharedLayoutTestBinding(VulkanContext context, TextureManager textures) + { + _context = context; + _textures = textures; + Table = new BindlessTextureTable(context, textures, new StillClock()); + _descriptors = new DescriptorCache(context); + _placeholder = new VulkanBuffer(context, 64 * 1024, + BufferUsageFlags.UniformBufferBit | BufferUsageFlags.StorageBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + new Span((void*)_placeholder.Mapped, 64 * 1024).Clear(); + } + + /// + /// Before the rendering scope opens: the table's placeholders and every texture in + /// that is read shader-read-only go to that layout. + /// + public void Transition(CommandBuffer commandBuffer, IEnumerable sampled) + { + for (int kind = 0; kind < BindlessKinds.Count; kind++) + { + VulkanTexture placeholder = _textures.Get(Table.PlaceholderTextureId((TextureKind)kind))!; + _textures.TransitionTexture(commandBuffer, placeholder, ImageLayout.ShaderReadOnlyOptimal); + } + foreach (SampledTexture texture in sampled) + { + if (texture.Layout != ImageLayout.ShaderReadOnlyOptimal) continue; + _textures.TransitionTexture(commandBuffer, _textures.Get(texture.TextureId)!, ImageLayout.ShaderReadOnlyOptimal); + } + } + + /// + /// Binds sets 1 and 2 and pushes the slot indices for one draw of + /// through its own pipeline layout. Every sampler the + /// program declares must be in ; frame textures (set 0) + /// are not supported here. + /// + public void Bind(CommandBuffer commandBuffer, ShaderProgramResources program, + IReadOnlyDictionary samplers, VulkanBuffer? record = null, VulkanBuffer? storage = null) + { + Vk api = _context.Api; + PipelineLayout layout = program.PipelineLayout; + SharedPipelineLayout shape = program.StandaloneLayout + ?? throw new InvalidOperationException("the program was built by a device; bind through the device"); + + int pushSize = program.Interface.PushConstantSize; + if (pushSize > 0) + { + var push = new byte[pushSize]; + foreach (SamplerBinding sampler in program.Interface.Samplers) + { + Assert.False(sampler.IsFrameTexture, "frame textures are not bound by this helper: " + sampler.Name); + SampledTexture sampled = samplers[sampler.Name]; + uint slot = Table.Resolve(_textures.Get(sampled.TextureId), sampler.Kind, sampled.State, sampled.Layout); + Assert.NotEqual(0u, slot); + BitConverter.TryWriteBytes(push.AsSpan(sampler.PushOffset, ProgramInterfaceLayout.SlotBytes), slot); + } + Table.Flush(); + + DescriptorSet textureSet = Table.Set; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, layout, + (uint)SetConvention.TextureSet, 1, &textureSet, 0, null); + fixed (byte* bytes = push) + { + api.CmdPushConstants(commandBuffer, layout, SharedPipelineLayout.Stages, 0, (uint)pushSize, bytes); + } + } + + if (!program.Interface.UsesStorageSet) return; + + var buffers = new BufferBindingValue[SetConvention.StorageSetBindingCount]; + for (int binding = 0; binding < buffers.Length; binding++) + { + buffers[binding] = new BufferBindingValue((uint)binding, _placeholder.Handle, 0, _placeholder.Size, _placeholder.Id); + } + if (record != null) + { + buffers[SetConvention.ProgramRecordBinding] = new BufferBindingValue(SetConvention.ProgramRecordBinding, + record.Handle, 0, (ulong)Math.Max(program.UniformShadow.Length, 4), record.Id); + } + if (storage != null) + { + foreach (BlockBinding block in program.Interface.StorageBlocks) + { + buffers[block.Binding] = new BufferBindingValue((uint)block.Binding, storage.Handle, 0, storage.Size, storage.Id); + } + } + + DescriptorSet storageSet = _descriptors.Get( + new DescriptorSetContents(0, SetConvention.StorageSet, Array.Empty(), buffers), + shape.StorageSetLayout); + uint recordOffset = 0; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, layout, + (uint)SetConvention.StorageSet, 1, &storageSet, 1, &recordOffset); + } + + public void Dispose() + { + _context.Api.DeviceWaitIdle(_context.Device); + _descriptors.Dispose(); + _placeholder.Dispose(); + Table.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SharedPipelineLayoutDrawTests.cs b/Optimum.Render.Vulkan.Tests/SharedPipelineLayoutDrawTests.cs new file mode 100644 index 00000000..f301513a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SharedPipelineLayoutDrawTests.cs @@ -0,0 +1,294 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using static Optimum.Render.Vulkan.Tests.GpuTest; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The device's draw path under the one shared pipeline layout (plan decision 9): every +/// rewritten program's samplers are slot indices in the push block over set 1's bindless +/// arrays, its loose uniforms the record in set 2, its named blocks std140 storage +/// buffers in set 2. Readbacks happen after the frame, through the seam. +/// +public class SharedPipelineLayoutDrawTests +{ + private const int Size = 8; + + private const string FullscreenVertex = """ + #version 330 core + out vec2 uv; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + } + """; + + private readonly ITestOutputHelper _output; + + public SharedPipelineLayoutDrawTests(ITestOutputHelper output) => _output = output; + + private static unsafe int SolidTexture(VulkanDevice seam, byte r, byte g, byte b) + { + var pixels = new byte[Size * Size * 4]; + for (int i = 0; i < pixels.Length; i += 4) + { + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = 255; + } + fixed (byte* data = pixels) + { + return seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)data, false); + } + } + + private static (int Texture, int Framebuffer) Target(VulkanDevice seam) + { + int texture = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + return (texture, framebuffer); + } + + /// The centre texel of a target, read from the texture itself (binding a framebuffer between frames is a no-op). + private static byte[] Centre(VulkanDevice seam, int texture) + { + byte[] pixels = seam.ReadBackLevel0ForTests(texture); + int centre = (Size / 2 * Size + Size / 2) * 4; + return pixels[centre..(centre + 3)]; + } + + private static void Draw(VulkanDevice seam, int program, int framebuffer) + { + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + } + + /// + /// Two programs with different sampler lists (one sampler; two, the first unused by + /// the second's output mix) drawn alternately into one pass: each draw samples its own + /// textures through its own push indices, while set 1 is bound once for the recording + /// and set 0 is never rebound by a program switch - the point of one layout. + /// + [SkippableFact] + public void TwoProgramsWithDifferentSamplersAlternateInOnePassWithoutRebindingSetsZeroAndOne() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int single = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D colour; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(colour, uv); } + """, "shared-single"); + int pair = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D first; + uniform sampler2D second; + uniform float weight; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = mix(texture(first, uv), texelFetch(second, ivec2(0), 0), weight); } + """, "shared-pair"); + + int red = SolidTexture(seam, 255, 0, 0); + int green = SolidTexture(seam, 0, 255, 0); + int blue = SolidTexture(seam, 0, 0, 255); + var targets = new (int Texture, int Framebuffer)[4]; + for (int i = 0; i < targets.Length; i++) targets[i] = Target(seam); + + seam.SetSamplerUnit(single, "colour", 0); + seam.SetSamplerUnit(pair, "first", 1); + seam.SetSamplerUnit(pair, "second", 2); + int weight = seam.GetUniformLocation(pair, "weight"); + Assert.True(weight >= 0); + seam.SetUniform(pair, weight, 1f); + + seam.BeginFrame(); + long textureBinds = seam.TextureSetBindsForTests; + long frameBinds = seam.FrameSetBindsForTests; + seam.BindTexture(0, red); + seam.BindTexture(1, green); + seam.BindTexture(2, blue); + Draw(seam, single, targets[0].Framebuffer); + Draw(seam, pair, targets[1].Framebuffer); + seam.BindTexture(0, green); + Draw(seam, single, targets[2].Framebuffer); + seam.BindTexture(2, red); + Draw(seam, pair, targets[3].Framebuffer); + long textureBindsInFrame = seam.TextureSetBindsForTests - textureBinds; + long frameBindsInFrame = seam.FrameSetBindsForTests - frameBinds; + seam.Present(); + + Assert.Equal(new byte[] { 255, 0, 0 }, Centre(seam, targets[0].Texture)); + Assert.Equal(new byte[] { 0, 0, 255 }, Centre(seam, targets[1].Texture)); + Assert.Equal(new byte[] { 0, 255, 0 }, Centre(seam, targets[2].Texture)); + Assert.Equal(new byte[] { 255, 0, 0 }, Centre(seam, targets[3].Texture)); + // Scopes reopen per target, but a scope is not a recording: set 1 stays bound. + Assert.True(textureBindsInFrame <= 1, "set 1 bound " + textureBindsInFrame + " times in one recording"); + Assert.Equal(0, frameBindsInFrame); + AssertClean(seam); + } + } + + /// + /// entityanimated's Animation block, read as a std140 storage buffer at set 2's + /// animation binding: two draws in one frame, each with the bone matrix uploaded + /// right before it, each come out the colour its own upload encodes. + /// + [SkippableFact] + public unsafe void AnAnimationBlockProgramReadsEachDrawsOwnUpload() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, """ + #version 330 core + layout (std140) uniform Animation + { + mat4 values[4]; + } ElementTransforms; + out vec4 tint; + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + tint = ElementTransforms.values[2][3]; + } + """, """ + #version 330 core + in vec4 tint; + out vec4 outColor; + void main(void) { outColor = tint; } + """, "shared-animation"); + + const int blockBytes = 4 * 64; + int ubo = seam.CreateUniformBuffer(program, 0, "Animation", blockBytes); + var first = Target(seam); + var second = Target(seam); + + void Upload(float r, float g, float b) + { + var block = new float[blockBytes / 4]; + // values[2], column 3 (std140: a mat4 is four 16-byte columns). + int at = 2 * 16 + 3 * 4; + block[at] = r; + block[at + 1] = g; + block[at + 2] = b; + block[at + 3] = 1f; + fixed (float* values = block) seam.UpdateUniformBuffer(ubo, (IntPtr)values, 0, blockBytes); + } + + seam.BeginFrame(); + Upload(1f, 0f, 0f); + Draw(seam, program, first.Framebuffer); + Upload(0f, 0f, 1f); + Draw(seam, program, second.Framebuffer); + seam.Present(); + + Assert.Equal(new byte[] { 255, 0, 0 }, Centre(seam, first.Texture)); + Assert.Equal(new byte[] { 0, 0, 255 }, Centre(seam, second.Texture)); + AssertClean(seam); + } + } + + /// + /// A transient rebound onto another transient's physical image, and a feedback draw + /// that samples its own attachment through a ReadSelf copy, both resolve the bindless + /// slot of the physical texture the draw reads - not a slot of the GL id. + /// + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void TransientRebindsAndFeedbackCopiesSampleThePhysicalTexture(bool aliasing) + { + VulkanDevice seam = NewDevice(); + seam.TransientAliasingOverride = aliasing; + if (!seam.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + _output.WriteLine("Vulkan unavailable: " + failureReason); + seam.Dispose(); + Skip.If(true, "No usable Vulkan device."); + } + + using (seam) + { + int fill = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform vec4 colourIn; + out vec4 outColor; + void main(void) { outColor = colourIn; } + """, "shared-fill"); + int copy = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D src; + out vec4 outColor; + void main(void) { outColor = texelFetch(src, ivec2(gl_FragCoord.xy), 0); } + """, "shared-copy"); + int feedback = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D self; + out vec4 outColor; + void main(void) { outColor = texelFetch(self, ivec2(gl_FragCoord.xy), 0).gbra; } + """, "shared-feedback"); + seam.SetSamplerUnit(copy, "src", 0); + seam.SetSamplerUnit(feedback, "self", 0); + int colourIn = seam.GetUniformLocation(fill, "colourIn"); + + var transients = new (int Texture, int Framebuffer)[3]; + for (int i = 0; i < transients.Length; i++) + { + int texture = seam.CreateTransientTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, 2 + i); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(framebuffer, 1); + transients[i] = (texture, framebuffer); + } + var output = Target(seam); + + byte[] pixels = Array.Empty(); + for (int frame = 0; frame < 3; frame++) + { + seam.BeginFrame(); + // Lifetimes [0,1], [1,2], [2,3]: with aliasing on, transient 2 takes transient 0's image. + for (int pass = 0; pass < 3; pass++) seam.BindTransientForFrame(transients[pass].Texture, pass, pass + 1); + + seam.SetUniform(fill, colourIn, 1f, 0f, 0f, 1f); + Draw(seam, fill, transients[0].Framebuffer); + seam.BindTexture(0, transients[0].Texture); + Draw(seam, copy, transients[1].Framebuffer); + seam.BindTexture(0, transients[1].Texture); + Draw(seam, copy, transients[2].Framebuffer); + // Feedback: sample transient 2 while drawing into it (red -> green through .gbra). + seam.BindTexture(0, transients[2].Texture); + Draw(seam, feedback, transients[2].Framebuffer); + seam.BindTexture(0, transients[2].Texture); + Draw(seam, copy, output.Framebuffer); + seam.BindTexture(0, 0); + if (frame == 2) pixels = Centre(seam, output.Texture); + seam.Present(); + } + + Assert.Equal(new byte[] { 0, 0, 255 }, pixels); + AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index 6a02976b..6c0713ef 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -71,7 +71,7 @@ public unsafe void ResetHistoryIgnoresTheHistoryEntirely() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); const float currentR = 0.7f, currentG = 0.3f, currentB = 0.2f; @@ -129,7 +129,7 @@ public unsafe void StaticSceneConvergesToTheCurrentColour() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); const float currentValue = 0.6f; @@ -209,7 +209,7 @@ public unsafe void UniformMotionReprojectsTheHistoryByThatOffset() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); var inputs = CreateInputSet(textures); @@ -280,7 +280,7 @@ public unsafe void AnOutlierHistoryValueIsClippedTowardTheNeighbourhood() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); const float currentValue = 0.5f; @@ -357,7 +357,7 @@ public unsafe void SkyDoesNotMoveUnderCameraTranslation() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); var inputs = CreateInputSet(textures); @@ -435,7 +435,7 @@ public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); const float edgeCentre = 16f; @@ -477,7 +477,7 @@ public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() private static unsafe float ResolveEdgeCentroid( VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, - DescriptorCache descriptors, (float x, float y) jitterPx, Func sceneAt) + SharedLayoutTestBinding descriptors, (float x, float y) jitterPx, Func sceneAt) { var inputs = CreateInputSet(textures); UploadRgba16F(textures, inputs.SceneTex, @@ -532,7 +532,7 @@ public unsafe void LinearHistorySamplingSpreadsAOnePixelLineOverTwoColumns() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); const int brightColumn = 16; @@ -602,7 +602,7 @@ public unsafe void NanInHistoryIsTreatedAsAReset() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); const float currentR = 0.65f, currentG = 0.4f, currentB = 0.25f; @@ -987,7 +987,7 @@ private sealed class TemporalRun using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state, fragmentTransform); var inputs = CreateInputSet(textures); @@ -1124,7 +1124,7 @@ public unsafe void SkyStaysPutWhenTheCameraSitsAboveTheOrigin(double eyeHeight) using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); using ShaderProgramResources program = LoadProgram(context!, compiler, state); var inputs = CreateInputSet(textures); @@ -1281,7 +1281,7 @@ private static TaaAttachmentSet CreateAttachmentSet(TextureManager textures, Ren private static unsafe void ResolveOnce( VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, - DescriptorCache descriptors, TaaInputSet inputs, TaaUniforms uniforms, TaaAttachmentSet output) + SharedLayoutTestBinding descriptors, TaaInputSet inputs, TaaUniforms uniforms, TaaAttachmentSet output) { SetUniformFloats(program, "renderSize", uniforms.RenderSize); SetUniformFloats(program, "jitterPx", uniforms.JitterPx); @@ -1322,16 +1322,15 @@ private static unsafe void ResolveOnce( AddressV = SamplerAddressMode.ClampToEdge, }; - var samplerBindings = new SamplerBindingValue[program.Interface.Samplers.Count]; - var sampledTextures = new VulkanTexture[samplerBindings.Length]; - for (int i = 0; i < samplerBindings.Length; i++) + var samplers = new Dictionary(StringComparer.Ordinal); + var sampledTextures = new VulkanTexture[program.Interface.Samplers.Count]; + for (int i = 0; i < sampledTextures.Length; i++) { SamplerBinding declared = program.Interface.Samplers[i]; VulkanTexture texture = textures.Get(textureByName[declared.Name]) ?? throw new InvalidOperationException("no texture bound for sampler '" + declared.Name + "'"); sampledTextures[i] = texture; - Sampler samplerHandle = textures.Samplers.Get(samplerState); - samplerBindings[i] = new SamplerBindingValue((uint)declared.Binding, texture.View, samplerHandle, texture.Id); + samplers[declared.Name] = new SharedLayoutTestBinding.SampledTexture(textureByName[declared.Name], samplerState); } VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; @@ -1365,6 +1364,7 @@ private static unsafe void ResolveOnce( { textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); } + descriptors.Transition(commandBuffer, Array.Empty()); targets.Bind(commandBuffer, output.Framebuffer); targets.EnsureRendering(commandBuffer); @@ -1389,33 +1389,7 @@ private static unsafe void ResolveOnce( api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); api.CmdSetLineWidth(commandBuffer, 1.0f); - if (program.Interface.HasUniformBlock) - { - var uniformContents = new DescriptorSetContents( - program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, - Array.Empty(), - new[] - { - new BufferBindingValue(ProgramInterfaceLayout.DefaultBlockBinding, - uniformBuffer.Handle, 0, (ulong)program.UniformShadow.Length, uniformBuffer.Id), - }); - DescriptorSet uniformSet = descriptors.Get( - uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); - uint dynamicOffset = 0; - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, 1, &dynamicOffset); - } - - if (samplerBindings.Length > 0) - { - var samplerContents = new DescriptorSetContents( - program.ProgramId, ProgramInterfaceLayout.SamplerSet, - samplerBindings, Array.Empty()); - DescriptorSet samplerSet = descriptors.Get( - samplerContents, program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); - } + descriptors.Bind(commandBuffer, program, samplers, record: uniformBuffer); api.CmdDraw(commandBuffer, 3, 1, 0, 0); targets.EndRendering(commandBuffer); diff --git a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs index d976c202..597e2563 100644 --- a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs @@ -60,7 +60,7 @@ public unsafe void SharpnessZeroIsBitForBitIdenticalToTheInput() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); ShaderProgramResources program = LoadProgram(context!, compiler, state); int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); @@ -99,7 +99,7 @@ public unsafe void SharpenIncreasesContrastAcrossAKnownEdge() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); ShaderProgramResources program = LoadProgram(context!, compiler, state); int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); @@ -157,7 +157,7 @@ public unsafe void SharpnessScalesTheEffectMonotonically() using var targets = new RenderTargetManager(context!, textures, state); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); - using var descriptors = new DescriptorCache(context!); + using var descriptors = new SharedLayoutTestBinding(context!, textures); ShaderProgramResources program = LoadProgram(context!, compiler, state); int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); @@ -184,7 +184,7 @@ public unsafe void SharpnessScalesTheEffectMonotonically() private unsafe float EdgeStep( VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, - DescriptorCache descriptors, int input, float sharpness) + SharedLayoutTestBinding descriptors, int input, float sharpness) { SharpenTarget output = CreateTarget(textures, targets); SharpenOnce(context, commands, textures, state, targets, pipelines, program, descriptors, @@ -236,7 +236,7 @@ private static SharpenTarget CreateTarget(TextureManager textures, RenderTargetM private static unsafe void SharpenOnce( VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, - DescriptorCache descriptors, int input, float sharpness, SharpenTarget output) + SharedLayoutTestBinding descriptors, int input, float sharpness, SharpenTarget output) { SetUniformFloats(program, "inputTexelSize", new[] { 1f / Size, 1f / Size }); SetUniformFloats(program, "sharpness", new[] { sharpness }); @@ -262,16 +262,15 @@ private static unsafe void SharpenOnce( AddressV = SamplerAddressMode.ClampToEdge, }; - var samplerBindings = new SamplerBindingValue[program.Interface.Samplers.Count]; - var sampledTextures = new VulkanTexture[samplerBindings.Length]; - for (int i = 0; i < samplerBindings.Length; i++) + var samplers = new Dictionary(StringComparer.Ordinal); + var sampledTextures = new VulkanTexture[program.Interface.Samplers.Count]; + for (int i = 0; i < sampledTextures.Length; i++) { SamplerBinding declared = program.Interface.Samplers[i]; Assert.Equal("inputScene", declared.Name); VulkanTexture texture = textures.Get(input)!; sampledTextures[i] = texture; - Sampler samplerHandle = textures.Samplers.Get(samplerState); - samplerBindings[i] = new SamplerBindingValue((uint)declared.Binding, texture.View, samplerHandle, texture.Id); + samplers[declared.Name] = new SharedLayoutTestBinding.SampledTexture(input, samplerState); } VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; @@ -302,6 +301,7 @@ private static unsafe void SharpenOnce( { textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); } + descriptors.Transition(commandBuffer, Array.Empty()); targets.Bind(commandBuffer, output.Framebuffer); targets.EnsureRendering(commandBuffer); @@ -326,33 +326,7 @@ private static unsafe void SharpenOnce( api.CmdSetStencilReference(commandBuffer, StencilFaceFlags.FaceFrontAndBack, 0); api.CmdSetLineWidth(commandBuffer, 1.0f); - if (program.Interface.HasUniformBlock) - { - var uniformContents = new DescriptorSetContents( - program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, - Array.Empty(), - new[] - { - new BufferBindingValue(ProgramInterfaceLayout.DefaultBlockBinding, - uniformBuffer.Handle, 0, (ulong)program.UniformShadow.Length, uniformBuffer.Id), - }); - DescriptorSet uniformSet = descriptors.Get( - uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); - uint dynamicOffset = 0; - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, 1, &dynamicOffset); - } - - if (samplerBindings.Length > 0) - { - var samplerContents = new DescriptorSetContents( - program.ProgramId, ProgramInterfaceLayout.SamplerSet, - samplerBindings, Array.Empty()); - DescriptorSet samplerSet = descriptors.Get( - samplerContents, program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); - } + descriptors.Bind(commandBuffer, program, samplers, record: uniformBuffer); api.CmdDraw(commandBuffer, 3, 1, 0, 0); targets.EndRendering(commandBuffer); diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs index da7c8213..e08073ba 100644 --- a/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -1556,7 +1556,9 @@ private static unsafe void SetTint(VulkanDevice device, int ubo, byte r, byte g, /// GPU reads freed memory. The cache must drop a deleted texture's sets and /// serve a successor its own, and the deferred free must land only after /// every frame that could have bound the old set has finished - which the - /// validation layer checks for us. + /// validation layer checks for us. Under the shared pipeline layout the sets that + /// name a texture are set 0's, holding the frame textures, so the program samples + /// `sky`; a bindless slot's retirement is BindlessTextureTableTests' subject. /// [SkippableFact] public unsafe void ADeletedTextureTakesItsDescriptorSetsWithIt() @@ -1579,10 +1581,10 @@ void main(void) } """, """ #version 330 core - uniform sampler2D source; + uniform sampler2D sky; in vec2 uv; out vec4 outColor; - void main(void) { outColor = texture(source, uv); } + void main(void) { outColor = texture(sky, uv); } """); int target = seam.CreateTexture2D(size, size, @@ -1600,14 +1602,14 @@ void main(void) seam.BeginFrame(); seam.BindFramebuffer(framebuffer); seam.UseProgram(program); - seam.SetSamplerUnit(program, "source", 0); + seam.SetSamplerUnit(program, "sky", 0); seam.BindTexture(0, first); seam.SetViewport(0, 0, size, size); seam.DrawFullscreenTriangle(); seam.Present(); int cachedWhileAlive = device!.CachedDescriptorSets; - Assert.True(cachedWhileAlive >= 1, "the draw should have cached a sampler set"); + Assert.True(cachedWhileAlive >= 1, "the draw should have cached a frame set naming the texture"); seam.DeleteTexture(first); @@ -1625,7 +1627,7 @@ void main(void) seam.BeginFrame(); seam.BindFramebuffer(framebuffer); seam.UseProgram(program); - seam.SetSamplerUnit(program, "source", 0); + seam.SetSamplerUnit(program, "sky", 0); seam.BindTexture(0, second); seam.SetViewport(0, 0, size, size); seam.DrawFullscreenTriangle(); diff --git a/Optimum.Render.Vulkan/Core/BindlessSlots.cs b/Optimum.Render.Vulkan/Core/BindlessSlots.cs index 1c487ffc..5f703270 100644 --- a/Optimum.Render.Vulkan/Core/BindlessSlots.cs +++ b/Optimum.Render.Vulkan/Core/BindlessSlots.cs @@ -72,7 +72,7 @@ public static bool IsInteger(TextureKind kind) => /// /// Whether a texture of can legally sit behind /// . The dimensionality rules are the ones - /// TextureSuitsSampler applies to per-program sets (a GL texture target + /// the draw path applies to every sampler (a GL texture target /// cannot change): 2D kinds need one layer, array kinds more than one, cube /// kinds a cube, 3D a volume. Shadow kinds need a depth format, integer kinds /// the matching signedness, and float kinds a non-integer format (depth reads diff --git a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs index d52d6911..07edad3e 100644 --- a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs +++ b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs @@ -70,7 +70,7 @@ public BindlessTextureTable(VulkanContext context, TextureManager textures, ITim try { - _layout = CreateLayout(capacities); + _layout = CreateSetLayout(context, capacities); _pool = CreatePool(capacities); Set = AllocateSet(); CreatePlaceholders(); @@ -242,7 +242,8 @@ private void QueuePlaceholder(TextureKind kind, uint slot) // ---------------------------------------------------------------- creation - private DescriptorSetLayout CreateLayout(uint[] capacities) + /// Set 1's layout for the given per-kind capacities. The table's own, and a standalone shared layout's. + internal static DescriptorSetLayout CreateSetLayout(VulkanContext context, uint[] capacities) { var bindings = new DescriptorSetLayoutBinding[BindlessKinds.Count]; var flags = new DescriptorBindingFlags[BindlessKinds.Count]; @@ -276,7 +277,7 @@ private DescriptorSetLayout CreateLayout(uint[] capacities) PBindings = bindingsPtr, }; DescriptorSetLayout layout; - VulkanResult.Check(_context.Api.CreateDescriptorSetLayout(_context.Device, &info, null, &layout), + VulkanResult.Check(context.Api.CreateDescriptorSetLayout(context.Device, &info, null, &layout), "vkCreateDescriptorSetLayout for the bindless texture set"); return layout; } diff --git a/Optimum.Render.Vulkan/Core/DescriptorCache.cs b/Optimum.Render.Vulkan/Core/DescriptorCache.cs index 131fe468..411be914 100644 --- a/Optimum.Render.Vulkan/Core/DescriptorCache.cs +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; using Silk.NET.Vulkan; using Buffer = Silk.NET.Vulkan.Buffer; @@ -332,24 +333,19 @@ private PoolSlot GrowPool() return slot; } - /// A pool sized for the rewriter's three set kinds; shared with . + /// A pool sized for the shared layout's set 0 and set 2; shared with . internal static DescriptorPool CreatePool(VulkanContext context, uint maxSets, DescriptorPoolCreateFlags flags) { - // A pool can only satisfy the descriptor types it was sized for. Set 0 - // holds the generated block plus every block the shader declares for - // itself - entityanimated's ElementTransforms is one - and all of them - // are dynamic uniform buffers, so that budget covers several per set. - // Without a size for a type the allocation fails, the set is never - // written, and the first draw that uses it takes the device down. + // A pool can only satisfy the descriptor types it was sized for. Without + // a size for a type the allocation fails, the set is never written, and + // the first draw that uses it takes the device down. Set 0 holds one + // dynamic uniform buffer and the frame textures; set 2 one dynamic + // uniform buffer (the record) and every other binding a storage buffer. var sizes = stackalloc DescriptorPoolSize[3] { - // Set 0 holds the generated block plus every named block, all dynamic. - // Eight per set is Vulkan's guaranteed minimum for - // maxDescriptorSetUniformBuffersDynamic, so a set that fits the - // device limit always fits the pool. - new DescriptorPoolSize(DescriptorType.UniformBufferDynamic, SetsPerPool * 8), + new DescriptorPoolSize(DescriptorType.UniformBufferDynamic, SetsPerPool * 2), new DescriptorPoolSize(DescriptorType.CombinedImageSampler, SetsPerPool * 8), - new DescriptorPoolSize(DescriptorType.StorageBuffer, SetsPerPool * 2), + new DescriptorPoolSize(DescriptorType.StorageBuffer, SetsPerPool * (uint)SetConvention.StorageSetBindingCount), }; var createInfo = new DescriptorPoolCreateInfo @@ -416,18 +412,16 @@ internal static void Write(VulkanContext context, DescriptorSet set, DescriptorS Range = buffer.Range, }; - // Every buffer in set 0 is a uniform block - the generated one at - // binding 0 and the shader's own after it - and every one of them - // is dynamic, so the per-draw ring offset travels separately and - // the set itself never has to change. + // Set 0's one buffer is the frame block, a dynamic uniform buffer; set 2 + // declares its record dynamic and every other binding a storage buffer. writes[index++] = new WriteDescriptorSet { SType = StructureType.WriteDescriptorSet, DstSet = set, DstBinding = buffer.Binding, DescriptorCount = 1, - DescriptorType = contents.SetIndex == ProgramInterfaceLayoutBindings.StorageSet - ? DescriptorType.StorageBuffer + DescriptorType = contents.SetIndex == SetConvention.StorageSet + ? SharedPipelineLayout.StorageSetDescriptorType(buffer.Binding) : DescriptorType.UniformBufferDynamic, PBufferInfo = bufferPtr + i, }; @@ -454,19 +448,3 @@ public void Dispose() _pools.Clear(); } } - -/// -/// The set and binding numbers the shader rewriter assigns. -/// -/// Duplicated here as plain constants so the descriptor layer does not depend on -/// the shader translation types; the pair is checked against each other by test. -/// -internal static class ProgramInterfaceLayoutBindings -{ - public const int FrameSet = 0; - public const int FrameBinding = 0; - public const int SamplerSet = 1; - public const int StorageSet = 2; - public const int DefaultBlockSet = 3; - public const int DefaultBlockBinding = 0; -} diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 68fe29a9..72e4b2ab 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -72,7 +72,7 @@ public FrameSlot(VulkanContext context, FrameTimeline timeline, UploadManager up _uniformRing = uniformRing; _regionStart = regionStart; _regionSize = regionSize; - _alignment = Math.Max(1, context.Capabilities.MinUniformBufferOffsetAlignment); + _alignment = FrameRing.OffsetAlignment(context.Capabilities); Index = index; var poolInfo = new CommandPoolCreateInfo @@ -385,14 +385,16 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin _allocator = context.Allocator; // Per-frame dynamic data: the ReBAR class, falling through to host memory // (counted and logged) when the cap or the device says no. + // Storage usage too: a rewritten program's named blocks read their per-draw + // snapshot from here as std140 storage buffers (shared layout, set 2). _uniformRing = new VulkanBuffer(context, uniformRingSize, - BufferUsageFlags.UniformBufferBit, + BufferUsageFlags.UniformBufferBit | BufferUsageFlags.StorageBufferBit, MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, MemoryPoolClass.ReBar); // Each region must start on a uniform-offset boundary, otherwise every // dynamic offset handed out from slot 1 onwards inherits the misalignment. - ulong alignment = Math.Max(1UL, context.Capabilities.MinUniformBufferOffsetAlignment); + ulong alignment = OffsetAlignment(context.Capabilities); ulong regionSize = uniformRingSize / (ulong)framesInFlight / alignment * alignment; _slots = new FrameSlot[framesInFlight]; for (int i = 0; i < framesInFlight; i++) @@ -403,6 +405,13 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin public int FramesInFlight => _slots.Length; + /// + /// Every ring offset is a legal uniform and storage buffer offset: both limits are + /// powers of two, so the larger is a multiple of the smaller. + /// + internal static ulong OffsetAlignment(VulkanCapabilities capabilities) => + Math.Max(1UL, Math.Max(capabilities.MinUniformBufferOffsetAlignment, capabilities.MinStorageBufferOffsetAlignment)); + /// The Frame and Transfer timelines every submission signals. public FrameTimeline Timeline => _timeline; diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs index b9b7eb9e..631b2b16 100644 --- a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -8,9 +8,10 @@ namespace Optimum.Render.Vulkan.Core; /// -/// Everything the GPU needs for one linked shader program: the modules, the -/// descriptor set layouts derived from its interface, the pipeline layout, and -/// the CPU-side shadow of its uniform block. +/// Everything the GPU needs for one linked shader program: the modules and the +/// CPU-side shadow of its program record. Every program's pipelines are built +/// against the one shared pipeline layout (plan decision 9, ), +/// so a program owns no set layouts and no pipeline layout of its own. /// /// The shadow buffer is what makes GL's uniform protocol work. The game sets /// uniforms one at a time by name, at any point before a draw, and expects the @@ -25,7 +26,6 @@ namespace Optimum.Render.Vulkan.Core; internal sealed unsafe class ShaderProgramResources : IDisposable { private readonly VulkanContext _context; - private readonly bool _ownsFrameLayout; private bool _disposed; public int ProgramId { get; } @@ -33,14 +33,16 @@ internal sealed unsafe class ShaderProgramResources : IDisposable public Dictionary Modules { get; } = new(); + /// The shared pipeline layout this program's pipelines are created against. Not owned. + public PipelineLayout PipelineLayout { get; } + /// - /// Set 0 the shared frame block, set 1 samplers, set 2 storage buffers, set 3 - /// the program's own uniform blocks. + /// A shared layout of the program's own, for a program built outside a device (tests): + /// the same shape as the device's, set 1 included. Null for a device's programs. /// - public DescriptorSetLayout[] SetLayouts { get; } = new DescriptorSetLayout[ProgramInterfaceLayout.SetCount]; - public PipelineLayout PipelineLayout { get; private set; } + public SharedPipelineLayout? StandaloneLayout { get; } - /// CPU mirror of the generated uniform block. + /// CPU mirror of the program record. public byte[] UniformShadow { get; } /// Bumped by every write that changes the shadow. @@ -57,12 +59,12 @@ internal sealed unsafe class ShaderProgramResources : IDisposable /// public Dictionary SamplerUnits { get; } = new(StringComparer.Ordinal); - /// - /// The device's shared frame set layout. Programs built outside a device - in - /// tests - pass none and get a layout of their own with the same shape. + /// + /// The device's shared pipeline layout. Programs built outside a device - in + /// tests - pass none and get a of the same shape. /// public ShaderProgramResources( - VulkanContext context, int programId, TranslatedProgram translated, DescriptorSetLayout frameLayout = default) + VulkanContext context, int programId, TranslatedProgram translated, PipelineLayout sharedLayout = default) { _context = context; ProgramId = programId; @@ -75,22 +77,19 @@ public ShaderProgramResources( } SourceHash = HashSpirv(translated.Spirv); - // Sampler uniforms default to the unit matching their binding, which is - // the order the game's own texture-location bookkeeping assigns. + // Sampler uniforms default to the unit matching their declaration order, + // which is the order the game's own texture-location bookkeeping assigns. foreach (SamplerBinding sampler in Interface.Samplers) { - SamplerUnits[sampler.Name] = sampler.Binding; + SamplerUnits[sampler.Name] = sampler.Order; } - if (frameLayout.Handle == 0) + if (sharedLayout.Handle == 0) { - frameLayout = CreateFrameSetLayout(context); - _ownsFrameLayout = true; + StandaloneLayout = SharedPipelineLayout.CreateStandalone(context); + sharedLayout = StandaloneLayout.Layout; } - SetLayouts[ProgramInterfaceLayout.FrameSet] = frameLayout; - - CreateSetLayouts(); - CreatePipelineLayout(); + PipelineLayout = sharedLayout; } /// @@ -139,135 +138,6 @@ private ShaderModule CreateModule(byte[] spirv) } } - /// - /// Stage visibility is set to all graphics stages rather than tracked per - /// binding: the sets are tiny, the cost of a wider visibility is nil, and a - /// uniform shared between stages - which GL makes routine - would otherwise - /// need its visibility recomputed on every link. - /// - private const ShaderStageFlags AllGraphics = - ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit | ShaderStageFlags.GeometryBit; - - /// - /// The shared frame block's set layout: one dynamic uniform buffer. Identical - /// for every program, so one descriptor set in the frame's uniform ring serves - /// all of them and only the dynamic offset moves when the block changes. - /// - public static DescriptorSetLayout CreateFrameSetLayout(VulkanContext context) - { - return CreateSetLayout(context, new List - { - new() - { - Binding = FrameGlobals.Binding, - DescriptorType = DescriptorType.UniformBufferDynamic, - DescriptorCount = 1, - StageFlags = AllGraphics, - }, - }); - } - - private void CreateSetLayouts() - { - var uniformBindings = new List(); - if (Interface.HasUniformBlock) - { - uniformBindings.Add(new DescriptorSetLayoutBinding - { - Binding = ProgramInterfaceLayout.DefaultBlockBinding, - DescriptorType = DescriptorType.UniformBufferDynamic, - DescriptorCount = 1, - StageFlags = AllGraphics, - }); - } - // A block the shader declares for itself is dynamic for the same reason - // the generated one is: the client re-uploads it between draws that are - // only recorded, so each draw needs its own slice of the frame's uniform - // ring, reached through an offset rather than through a set of its own. - foreach (BlockBinding block in Interface.UniformBlocks) - { - uniformBindings.Add(new DescriptorSetLayoutBinding - { - Binding = (uint)block.Binding, - DescriptorType = DescriptorType.UniformBufferDynamic, - DescriptorCount = 1, - StageFlags = AllGraphics, - }); - } - - var samplerBindings = new List(); - foreach (SamplerBinding sampler in Interface.Samplers) - { - samplerBindings.Add(new DescriptorSetLayoutBinding - { - Binding = (uint)sampler.Binding, - DescriptorType = DescriptorType.CombinedImageSampler, - DescriptorCount = 1, - StageFlags = AllGraphics, - }); - } - - var storageBindings = new List(); - foreach (BlockBinding block in Interface.StorageBlocks) - { - storageBindings.Add(new DescriptorSetLayoutBinding - { - Binding = (uint)block.Binding, - DescriptorType = DescriptorType.StorageBuffer, - DescriptorCount = 1, - StageFlags = AllGraphics, - }); - } - - SetLayouts[ProgramInterfaceLayout.DefaultBlockSet] = CreateSetLayout(_context, uniformBindings); - SetLayouts[ProgramInterfaceLayout.SamplerSet] = CreateSetLayout(_context, samplerBindings); - SetLayouts[ProgramInterfaceLayout.StorageSet] = CreateSetLayout(_context, storageBindings); - } - - private static DescriptorSetLayout CreateSetLayout(VulkanContext context, List bindings) - { - // An empty set is still created rather than skipped, so set numbering - // stays fixed: samplers are always set 1 whether or not the program has - // uniforms, which keeps the rewriter's binding decisions valid. - DescriptorSetLayoutBinding[] array = bindings.ToArray(); - fixed (DescriptorSetLayoutBinding* bindingsPtr = array) - { - var createInfo = new DescriptorSetLayoutCreateInfo - { - SType = StructureType.DescriptorSetLayoutCreateInfo, - BindingCount = (uint)array.Length, - PBindings = array.Length == 0 ? null : bindingsPtr, - }; - - if (context.Api.CreateDescriptorSetLayout( - context.Device, &createInfo, null, out DescriptorSetLayout layout) != Result.Success) - { - throw new InvalidOperationException("vkCreateDescriptorSetLayout failed"); - } - return layout; - } - } - - private void CreatePipelineLayout() - { - fixed (DescriptorSetLayout* setLayouts = SetLayouts) - { - var createInfo = new PipelineLayoutCreateInfo - { - SType = StructureType.PipelineLayoutCreateInfo, - SetLayoutCount = (uint)SetLayouts.Length, - PSetLayouts = setLayouts, - }; - - if (_context.Api.CreatePipelineLayout( - _context.Device, &createInfo, null, out PipelineLayout layout) != Result.Success) - { - throw new InvalidOperationException("vkCreatePipelineLayout failed"); - } - PipelineLayout = layout; - } - } - // ------------------------------------------------------------------ uniforms /// @@ -295,8 +165,8 @@ private void CreatePipelineLayout() /// Resolves a uniform name to an opaque location, the way glGetUniformLocation /// does. /// - /// Samplers are not members of the generated block - they are descriptor - /// bindings - but the client looks every declared uniform up by name and + /// Samplers are not members of the program record - they are push slots or + /// frame textures - but the client looks every declared uniform up by name and /// treats a -1 as "the shader does not use this". Returning -1 for samplers /// would tell it that every texture uniform in the game is unused, so they /// get locations of their own from a disjoint range. Members of the shared @@ -368,14 +238,8 @@ public void Dispose() _disposed = true; Vk api = _context.Api; - api.DestroyPipelineLayout(_context.Device, PipelineLayout, null); - - for (int set = 0; set < SetLayouts.Length; set++) - { - // The shared frame layout belongs to the device. - if (set == ProgramInterfaceLayout.FrameSet && !_ownsFrameLayout) continue; - if (SetLayouts[set].Handle != 0) api.DestroyDescriptorSetLayout(_context.Device, SetLayouts[set], null); - } + // A device's shared layout belongs to the device. + StandaloneLayout?.Dispose(); foreach (ShaderModule module in Modules.Values) { api.DestroyShaderModule(_context.Device, module, null); diff --git a/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs index 987ded64..c5decfed 100644 --- a/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs +++ b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs @@ -10,7 +10,7 @@ namespace Optimum.Render.Vulkan.Core; /// /// | set 0 | FrameGlobals dynamic UBO and the fixed frame textures; a normal set (dynamic buffers cannot be update-after-bind) | /// | set 1 | the bindless texture table's layout (), not owned here | -/// | set 2 | the storage buffers, a normal set | +/// | set 2 | FaceData, the animation blocks, the program record (dynamic) and the named-block range, a normal set built per draw | /// | push | for vertex and fragment | /// /// Created once at device bring-up; destroyed at teardown after the device-idle @@ -21,6 +21,7 @@ internal sealed unsafe class SharedPipelineLayout : IDisposable public const ShaderStageFlags Stages = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit; private readonly VulkanContext _context; + private readonly bool _ownsTextureSetLayout; private bool _disposed; public DescriptorSetLayout FrameSetLayout { get; } @@ -28,9 +29,36 @@ internal sealed unsafe class SharedPipelineLayout : IDisposable public DescriptorSetLayout StorageSetLayout { get; } public PipelineLayout Layout { get; } + /// + /// A layout of the same shape with a set 1 layout of its own, which it owns: for + /// programs built outside a device (tests). Pipelines built against it are + /// compatible with any set 1 layout built from the same capacities. + /// + public static SharedPipelineLayout CreateStandalone(VulkanContext context) + { + uint[] capacities = BindlessKinds.ClampCapacities(context.Capabilities.DescriptorIndexing, + DescriptorIndexingFloor.FrameTextures); + DescriptorSetLayout textures = BindlessTextureTable.CreateSetLayout(context, capacities); + try + { + return new SharedPipelineLayout(context, textures, ownsTextureSetLayout: true); + } + catch + { + context.Api.DestroyDescriptorSetLayout(context.Device, textures, null); + throw; + } + } + public SharedPipelineLayout(VulkanContext context, DescriptorSetLayout textureSetLayout) + : this(context, textureSetLayout, ownsTextureSetLayout: false) + { + } + + private SharedPipelineLayout(VulkanContext context, DescriptorSetLayout textureSetLayout, bool ownsTextureSetLayout) { _context = context; + _ownsTextureSetLayout = ownsTextureSetLayout; TextureSetLayout = textureSetLayout; Vk api = context.Api; @@ -102,34 +130,51 @@ internal static DescriptorSetLayoutBinding[] FrameBindings() } /// - /// Set 2: the storage buffers and the program record, a dynamic uniform buffer - /// (docs/vulkan-native-shaders.md section 3). Set 2 is a normal set, so the - /// dynamic buffer is legal beside the update-after-bind set 1; the device floor - /// counts it (). + /// Set 2: the storage buffers, the program record (a dynamic uniform buffer, + /// docs/vulkan-native-shaders.md section 3) and the named-block range, where a + /// rewritten program's other named blocks sit as std140 storage buffers. Set 2 is + /// a normal set, so the dynamic buffer is legal beside the update-after-bind set 1; + /// the device floor counts it (). /// internal static DescriptorSetLayoutBinding[] StorageBindings() { - var bindings = new DescriptorSetLayoutBinding[SetConvention.StorageBuffers.Length + 1]; - for (int i = 0; i < SetConvention.StorageBuffers.Length; i++) + int named = SetConvention.NamedBlockLastBinding - SetConvention.NamedBlockFirstBinding + 1; + var bindings = new DescriptorSetLayoutBinding[SetConvention.StorageBuffers.Length + 1 + named]; + int index = 0; + foreach (SetConvention.Binding buffer in SetConvention.StorageBuffers) { - bindings[i] = new DescriptorSetLayoutBinding + bindings[index++] = new DescriptorSetLayoutBinding { - Binding = (uint)SetConvention.StorageBuffers[i].Value, + Binding = (uint)buffer.Value, DescriptorType = DescriptorType.StorageBuffer, - DescriptorCount = SetConvention.StorageBuffers[i].Capacity, + DescriptorCount = buffer.Capacity, StageFlags = Stages, }; } - bindings[^1] = new DescriptorSetLayoutBinding + bindings[index++] = new DescriptorSetLayoutBinding { Binding = (uint)SetConvention.ProgramRecordBinding, DescriptorType = DescriptorType.UniformBufferDynamic, DescriptorCount = 1, StageFlags = Stages, }; + for (int binding = SetConvention.NamedBlockFirstBinding; binding <= SetConvention.NamedBlockLastBinding; binding++) + { + bindings[index++] = new DescriptorSetLayoutBinding + { + Binding = (uint)binding, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = 1, + StageFlags = Stages, + }; + } return bindings; } + /// The descriptor type set 2 declares at . + public static DescriptorType StorageSetDescriptorType(uint binding) => + binding == SetConvention.ProgramRecordBinding ? DescriptorType.UniformBufferDynamic : DescriptorType.StorageBuffer; + private DescriptorSetLayout CreateSetLayout(DescriptorSetLayoutBinding[] bindings, string what) { fixed (DescriptorSetLayoutBinding* bindingsPtr = bindings) @@ -155,5 +200,6 @@ public void Dispose() api.DestroyPipelineLayout(_context.Device, Layout, null); api.DestroyDescriptorSetLayout(_context.Device, StorageSetLayout, null); api.DestroyDescriptorSetLayout(_context.Device, FrameSetLayout, null); + if (_ownsTextureSetLayout) api.DestroyDescriptorSetLayout(_context.Device, TextureSetLayout, null); } } diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 10983b20..9fbfadae 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -71,6 +71,7 @@ internal sealed class VulkanCapabilities public float MaxSamplerLodBias; public int MaxBoundDescriptorSets; public ulong MinUniformBufferOffsetAlignment; + public ulong MinStorageBufferOffsetAlignment; public ulong MaxUniformBufferRange; public uint MaxColorAttachments = 8; /// The bindless features and limits of plan decision 9; every selected device meets . @@ -1210,6 +1211,7 @@ private VulkanCapabilities ReadCapabilities() MaxSamplerLodBias = properties.Limits.MaxSamplerLodBias, MaxBoundDescriptorSets = (int)properties.Limits.MaxBoundDescriptorSets, MinUniformBufferOffsetAlignment = properties.Limits.MinUniformBufferOffsetAlignment, + MinStorageBufferOffsetAlignment = properties.Limits.MinStorageBufferOffsetAlignment, MaxUniformBufferRange = properties.Limits.MaxUniformBufferRange, MaxColorAttachments = properties.Limits.MaxColorAttachments, DescriptorIndexing = ReadDescriptorIndexingSupport(PhysicalDevice), diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 071de887..0e30df10 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -305,7 +305,54 @@ public static void NoteBindlessFlush(int writes) } /// A bindless lookup that resolved to a placeholder slot: no texture, a texture of the wrong kind, or a full array. - public static void NoteBindlessPlaceholderResolution() => Interlocked.Increment(ref _bindlessPlaceholderResolutions); + public static void NoteBindlessPlaceholderResolution() + { + Interlocked.Increment(ref _bindlessPlaceholderResolutions); + Interlocked.Increment(ref _intervalBindlessPlaceholders); + } + + // Cumulative (tests read them) and per sample interval (the counters line). + private static long _pushConstantWrites; + private static long _storageSetBinds; + private static long _bindlessSlotResolutions; + private static long _samplerPlaceholders; + private static long _intervalPushConstantWrites; + private static long _intervalStorageSetBinds; + private static long _intervalBindlessSlots; + private static long _intervalBindlessPlaceholders; + + /// A draw whose slot indices differed from what its recording last received: one vkCmdPushConstants. + public static void NotePushConstantWrite() + { + Interlocked.Increment(ref _pushConstantWrites); + Interlocked.Increment(ref _intervalPushConstantWrites); + } + + /// A draw that bound set 2 because its set or its record's dynamic offset changed. + public static void NoteStorageSetBind() + { + Interlocked.Increment(ref _storageSetBinds); + Interlocked.Increment(ref _intervalStorageSetBinds); + } + + /// A draw's sampler resolved through the bindless table (placeholder slots included). + public static void NoteBindlessSlotResolution() + { + Interlocked.Increment(ref _bindlessSlotResolutions); + Interlocked.Increment(ref _intervalBindlessSlots); + } + + /// A draw's frame texture (set 0) resolved to its placeholder: nothing suitable bound. + public static void NoteSamplerPlaceholder() + { + Interlocked.Increment(ref _samplerPlaceholders); + Interlocked.Increment(ref _intervalBindlessPlaceholders); + } + + public static long PushConstantWrites => Interlocked.Read(ref _pushConstantWrites); + public static long StorageSetBinds => Interlocked.Read(ref _storageSetBinds); + public static long BindlessSlotResolutions => Interlocked.Read(ref _bindlessSlotResolutions); + public static long SamplerPlaceholders => Interlocked.Read(ref _samplerPlaceholders); public static long BindlessWrites => Interlocked.Read(ref _bindlessWrites); public static long BindlessFlushes => Interlocked.Read(ref _bindlessFlushes); @@ -424,7 +471,11 @@ public static Result WaitDeviceIdle(Vk api, Device device) InPassClears: Interlocked.Exchange(ref _inPassClears, 0), PromotedClears: Interlocked.Exchange(ref _promotedClears, 0), StandaloneClears: Interlocked.Exchange(ref _standaloneClears, 0), - PassSplits: Interlocked.Exchange(ref _passSplits, 0)); + PassSplits: Interlocked.Exchange(ref _passSplits, 0), + PushConstantWrites: Interlocked.Exchange(ref _intervalPushConstantWrites, 0), + StorageSetBinds: Interlocked.Exchange(ref _intervalStorageSetBinds, 0), + BindlessSlots: Interlocked.Exchange(ref _intervalBindlessSlots, 0), + BindlessPlaceholders: Interlocked.Exchange(ref _intervalBindlessPlaceholders, 0)); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -508,12 +559,14 @@ public static string FormatCountersLine(CounterSample counters) => "dynamic_state={5} uniform_ring_used={6} uniform_ring_capacity={7} " + "barrier_commands={8} barriers_per_frame={9:F1} mask_restarts={10} feedback_splits={11} " + "passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} " + - "standalone_clears={17} pass_splits={18}", + "standalone_clears={17} pass_splits={18} push_constants={19} storage_set_binds={20} " + + "bindless_slots={21} bindless_placeholders={22}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, counters.BarrierCommands, counters.Frames > 0 ? counters.Barriers / (double)counters.Frames : 0.0, counters.MaskRestarts, counters.FeedbackSplits, counters.Passes, counters.PlanHits, counters.PlanMisses, - counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits); + counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits, + counters.PushConstantWrites, counters.StorageSetBinds, counters.BindlessSlots, counters.BindlessPlaceholders); private static long _lastSample; @@ -606,7 +659,11 @@ internal readonly record struct CounterSample( long InPassClears = 0, long PromotedClears = 0, long StandaloneClears = 0, - long PassSplits = 0); + long PassSplits = 0, + long PushConstantWrites = 0, + long StorageSetBinds = 0, + long BindlessSlots = 0, + long BindlessPlaceholders = 0); /// The values on the stats.transients line. internal readonly record struct TransientSample( diff --git a/Optimum.Render.Vulkan/Shaders/GlslParser.cs b/Optimum.Render.Vulkan/Shaders/GlslParser.cs index 102deae8..f7f1800f 100644 --- a/Optimum.Render.Vulkan/Shaders/GlslParser.cs +++ b/Optimum.Render.Vulkan/Shaders/GlslParser.cs @@ -58,6 +58,9 @@ internal sealed class GlslDeclaration /// Interpolation and auxiliary qualifiers preceding the type. public string Qualifiers = ""; + /// Absolute start of the storage keyword (uniform, buffer, in, ...), or -1. + public int StorageKeywordStart = -1; + public int End => Start + Length; } @@ -345,6 +348,7 @@ private static int ReadTopLevelStatement(string source, int position, out bool h word == "attribute" || word == "varying" || word == "shared") { storage = word; + declaration.StorageKeywordStart = start + cursor - word.Length; break; } diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs index 7f6e0cc5..18c5e0d4 100644 --- a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -1,14 +1,15 @@ using System; using System.Collections.Generic; using System.Globalization; +using Optimum.Render.Vulkan.Core; using Vintagestory.API.Client; namespace Optimum.Render.Vulkan.Shaders; -/// One member of the generated default-uniform block. /// One vertex input a program declares, and where it lives. internal readonly record struct VertexInputSlot(string Name, int Location, GlslType Type); +/// One member of the program record (the generated default-uniform block). internal sealed class UniformMember { public string Name = ""; @@ -25,20 +26,41 @@ internal sealed class UniformMember public int ElementCount => ArrayLength == 0 ? 1 : ArrayLength; } +/// +/// One sampler a program declares. Under the shared pipeline layout (plan decision 9) +/// a sampler is either one of set 0's fixed frame textures, read under its own name, +/// or a slot index into set 1's bindless array of its kind, carried in the push block +/// under the sampler's name. +/// internal sealed class SamplerBinding { public string Name = ""; public string TypeName = ""; - public int Binding; + + /// + /// Declaration order across the program, vertex stage first: the texture unit the + /// client's own bookkeeping (ShaderProgram.collectUniformNames) assigns by default. + /// + public int Order; + + /// The set 0 binding when this is a fixed frame texture (), else -1. + public int FrameBinding = -1; + + /// The set 1 array the slot indexes; meaningful only when is false. + public TextureKind Kind; + + /// Byte offset of the slot index in the push block, or -1 for a frame texture. + public int PushOffset = -1; + + public bool IsFrameTexture => FrameBinding >= 0; } +/// A named uniform or storage block and its set 2 binding. internal sealed class BlockBinding { public string BlockName = ""; - public int Set; + public int Set = SetConvention.StorageSet; public int Binding; - /// True when the shader declared the binding itself. - public bool Explicit; } /// @@ -64,17 +86,10 @@ internal sealed class BlockBinding internal sealed class ProgramInterfaceLayout { public const string BlockTypeName = "OptimumUniforms"; - public const string BlockInstanceName = "_optimum"; - - // Sets are ordered by how often their contents change: the frame block every - // program shares (FrameGlobals), then the program's textures, its storage - // buffers, and last its own uniform blocks, which change between draws. - public const int FrameSet = FrameGlobals.Set; - public const int SamplerSet = 1; - public const int StorageSet = 2; - public const int DefaultBlockSet = 3; - public const int DefaultBlockBinding = 0; - public const int SetCount = 4; + public const string PushBlockTypeName = "OptimumDraw"; + + /// Bytes of one sampler slot index in the push block. + public const int SlotBytes = 4; /// /// The shared frame members each stage declared (see ). @@ -108,9 +123,25 @@ internal sealed class ProgramInterfaceLayout /// public Dictionary> MembersByStage { get; } = new(); + /// Every sampler, frame textures included, in declaration order. public List Samplers { get; } = new(); public Dictionary SamplersByName { get; } = new(StringComparer.Ordinal); + /// + /// Which samplers each stage declared: a stage gets push members and body rewrites + /// only for its own, for the reason exists. + /// + public Dictionary> SamplersByStage { get; } = new(); + + /// Bytes of the push block: one slot index per non-frame sampler; 0 when there are none. + public int PushConstantSize { get; private set; } + + /// Whether any sampler reads a set 0 frame texture. + public bool UsesFrameTextures { get; private set; } + + /// Whether a draw of the program needs set 2: a record, a named block or a storage block. + public bool UsesStorageSet => HasUniformBlock || UniformBlocks.Count > 0 || StorageBlocks.Count > 0; + public List UniformBlocks { get; } = new(); public List StorageBlocks { get; } = new(); @@ -146,7 +177,7 @@ internal sealed class ProgramInterfaceLayout /// public HashSet WrittenFragmentOutputs { get; } = new(); - /// Size of the generated block in bytes; 0 when it has no members. + /// Size of the program record in bytes; 0 when it has no members. public int BlockSize { get; private set; } public bool HasUniformBlock => BlockSize > 0; @@ -223,8 +254,7 @@ public static ProgramInterfaceLayout Build( { var layout = new ProgramInterfaceLayout(); int offset = 0; - int nextUniformBlockBinding = DefaultBlockBinding + 1; - int nextStorageBinding = 0; + int nextNamedBinding = SetConvention.NamedBlockFirstBinding; foreach ((EnumShaderType stage, ParsedShader parsed) in stages) { @@ -236,13 +266,13 @@ public static ProgramInterfaceLayout Build( AddDefaultUniform(layout, declaration, stage, includes, ref offset); break; case GlslDeclarationKind.OpaqueUniform: - AddSampler(layout, declaration); + AddSampler(layout, declaration, stage); break; case GlslDeclarationKind.UniformBlock: - AddBlock(layout.UniformBlocks, declaration, DefaultBlockSet, ref nextUniformBlockBinding); + AddBlock(layout, layout.UniformBlocks, declaration, ref nextNamedBinding); break; case GlslDeclarationKind.StorageBlock: - AddBlock(layout.StorageBlocks, declaration, StorageSet, ref nextStorageBinding); + AddBlock(layout, layout.StorageBlocks, declaration, ref nextNamedBinding); break; } } @@ -335,49 +365,124 @@ private static void AddDefaultUniform( layout.MembersByName[member.Name] = member; } - private static void AddSampler(ProgramInterfaceLayout layout, GlslDeclaration declaration) + /// + /// Classifies a sampler under the shared layout. A name and type that match one of + /// set 0's fixed frame textures read that binding; every other sampler takes the + /// next push-block slot, in declaration order, and indexes the set 1 array of its + /// GLSL type. A type set 1 has no array for, a sampler array, or more slots than + /// the push block holds is a link error. + /// + private static void AddSampler(ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage) { - if (layout.SamplersByName.ContainsKey(declaration.Name)) return; + if (!layout.SamplersByStage.TryGetValue(stage, out HashSet? stageSamplers)) + { + stageSamplers = new HashSet(StringComparer.Ordinal); + layout.SamplersByStage[stage] = stageSamplers; + } + stageSamplers.Add(declaration.Name); + + if (layout.SamplersByName.TryGetValue(declaration.Name, out SamplerBinding? existing)) + { + if (!string.Equals(existing.TypeName, declaration.TypeName, StringComparison.Ordinal)) + { + layout.Errors.Add($"sampler '{declaration.Name}' is declared as '{existing.TypeName}' " + + $"and '{declaration.TypeName}' in different stages"); + } + return; + } var binding = new SamplerBinding { Name = declaration.Name, TypeName = declaration.TypeName, - Binding = layout.Samplers.Count, + Order = layout.Samplers.Count, }; layout.Samplers.Add(binding); layout.SamplersByName[binding.Name] = binding; + + if (declaration.ArrayLength != 0 || declaration.UnresolvedArraySize != null) + { + layout.Errors.Add($"sampler '{declaration.Name}' is an array, which the shared layout's push slots cannot index"); + return; + } + + foreach (SetConvention.Binding frame in SetConvention.FrameTextures) + { + if (string.Equals(frame.Name, declaration.Name, StringComparison.Ordinal) && + string.Equals(frame.GlslType, declaration.TypeName, StringComparison.Ordinal)) + { + binding.FrameBinding = frame.Value; + layout.UsesFrameTextures = true; + return; + } + } + + if (!BindlessKinds.TryFromGlslType(declaration.TypeName, out TextureKind kind)) + { + layout.Errors.Add($"sampler '{declaration.Name}' has type '{declaration.TypeName}', " + + "for which set 1 has no bindless array"); + return; + } + + binding.Kind = kind; + binding.PushOffset = layout.PushConstantSize; + layout.PushConstantSize += SlotBytes; + if (layout.PushConstantSize > SetConvention.PushConstantBytes) + { + layout.Errors.Add($"sampler '{declaration.Name}' needs push byte {layout.PushConstantSize}, " + + $"past the {SetConvention.PushConstantBytes} the shared layout holds"); + } } + /// + /// Gives a named block its set 2 binding. The game's Animation and + /// AnimationPrev blocks take the convention's animation bindings, the first + /// storage block takes FaceData's, and every other block takes the next binding of + /// the named-block range in declaration order. A binding the shader stated is not + /// kept: chunkopaque.vsh's binding = 3 is the record's binding under the + /// shared layout, and the mesh path binds FaceData by the convention's number. + /// private static void AddBlock( - List blocks, GlslDeclaration declaration, int set, ref int nextBinding) + ProgramInterfaceLayout layout, List blocks, GlslDeclaration declaration, ref int nextNamedBinding) { foreach (BlockBinding existing in blocks) { if (existing.BlockName == declaration.Name) return; } - // A shader that names its own binding keeps it: chunkopaque.vsh declares - // "layout(binding = 3, std430) readonly buffer faceDataBuf", and the mesh - // path binds the vertex buffer to that exact index. - int declared = ReadQualifierInt(declaration.LayoutQualifiers, "binding"); + int binding; + bool storage = declaration.Kind == GlslDeclarationKind.StorageBlock; + if (!storage && declaration.Name == "Animation" && !HasBinding(layout, SetConvention.AnimationBinding)) + { + binding = SetConvention.AnimationBinding; + } + else if (!storage && declaration.Name == "AnimationPrev" && !HasBinding(layout, SetConvention.AnimationPrevBinding)) + { + binding = SetConvention.AnimationPrevBinding; + } + else if (storage && !HasBinding(layout, SetConvention.FaceDataBinding)) + { + binding = SetConvention.FaceDataBinding; + } + else if (nextNamedBinding <= SetConvention.NamedBlockLastBinding) + { + binding = nextNamedBinding++; + } + else + { + layout.Errors.Add($"block '{declaration.Name}' does not fit set 2: the shared layout holds " + + $"{SetConvention.NamedBlockLastBinding - SetConvention.NamedBlockFirstBinding + 1} named blocks"); + return; + } - // Set 0, binding 0 is where the generated OptimumUniforms block lives. - // A shader that names that binding itself would register two blocks at - // one descriptor binding, so it is treated as unnumbered and moves to - // the next free binding; the rewriter re-emits the qualifier from here. - if (set == DefaultBlockSet && declared == DefaultBlockBinding) declared = -1; + blocks.Add(new BlockBinding { BlockName = declaration.Name, Binding = binding }); + } - blocks.Add(new BlockBinding - { - BlockName = declaration.Name, - Set = set, - Binding = declared >= 0 ? declared : nextBinding, - Explicit = declared >= 0, - }); - - if (declared < 0) nextBinding++; - else if (declared >= nextBinding) nextBinding = declared + 1; + private static bool HasBinding(ProgramInterfaceLayout layout, int binding) + { + foreach (BlockBinding block in layout.UniformBlocks) if (block.Binding == binding) return true; + foreach (BlockBinding block in layout.StorageBlocks) if (block.Binding == binding) return true; + return false; } // ----------------------------------------------------------------- locations @@ -633,23 +738,6 @@ private static int Reserve(HashSet used, int span) } } - private static int ReadQualifierInt(string? qualifiers, string key) - { - if (qualifiers == null) return -1; - foreach (string part in qualifiers.Split(',')) - { - int equals = part.IndexOf('='); - if (equals < 0) continue; - if (part.AsSpan(0, equals).Trim().SequenceEqual(key) && - int.TryParse(part.AsSpan(equals + 1).Trim(), NumberStyles.Integer, - CultureInfo.InvariantCulture, out int value)) - { - return value; - } - } - return -1; - } - private static int Align(int value, int alignment) => alignment <= 1 ? value : (value + alignment - 1) / alignment * alignment; } diff --git a/Optimum.Render.Vulkan/Shaders/SetConvention.cs b/Optimum.Render.Vulkan/Shaders/SetConvention.cs index 0f31b89d..ec7ed4fd 100644 --- a/Optimum.Render.Vulkan/Shaders/SetConvention.cs +++ b/Optimum.Render.Vulkan/Shaders/SetConvention.cs @@ -9,7 +9,7 @@ namespace Optimum.Render.Vulkan.Shaders; /// | Set | Update | Contents | /// | 0 frame | once per frame | FrameGlobals UBO (dynamic offset) and the fixed frame textures | /// | 1 textures | when a texture is created or retired | bindless combined-image-sampler arrays, one per GLSL sampled type, PARTIALLY_BOUND and UPDATE_AFTER_BIND | -/// | 2 storage | when a buffer is created or retired | FaceData and the animation buffers | +/// | 2 storage | per draw | FaceData, the animation buffers, the program record and named blocks | /// | push | per draw | texture slot indices and per-draw scalars, at most | /// /// Array sizes are docs/research/vulkan-bindless.md's starting sizes; the device @@ -75,11 +75,6 @@ internal static class SetConvention new("OPTIMUM_BINDING_TEXTURES_CUBE_SHADOW", 8, "samplerCubeShadow", "optimumTexturesCubeShadow", ShadowCubeCapacity), }; - /// - /// Set 2's storage buffers. Only FaceData exists in the game today (the chunk - /// shaders' faceDataBuf); the animation pair is the plan's move of bone - /// matrices off the 64 KiB UBO limit. - /// /// /// Set 2's program record: every non-frame uniform that is not in the push block /// (docs/vulkan-native-shaders.md section 4), a dynamic uniform buffer whose offset @@ -88,10 +83,31 @@ internal static class SetConvention /// public const int ProgramRecordBinding = 3; + /// + /// Set 2's storage buffers. FaceData is the chunk shaders' faceDataBuf; the + /// animation pair holds the game's Animation and AnimationPrev blocks, + /// read as std140 storage buffers (named after the blocks the rewriter maps there). + /// public static readonly Binding[] StorageBuffers = { new("OPTIMUM_BINDING_FACE_DATA", 0, "buffer", "faceDataBuf", 1), - new("OPTIMUM_BINDING_ANIMATION", 1, "buffer", "animationBuf", 1), - new("OPTIMUM_BINDING_ANIMATION_PREV", 2, "buffer", "animationPrevBuf", 1), + new("OPTIMUM_BINDING_ANIMATION", 1, "buffer", "Animation", 1), + new("OPTIMUM_BINDING_ANIMATION_PREV", 2, "buffer", "AnimationPrev", 1), }; + + public const int FaceDataBinding = 0; + public const int AnimationBinding = 1; + public const int AnimationPrevBinding = 2; + + /// + /// Set 2 bindings for every other named block a rewritten program declares, in + /// declaration order: a GLSL 330 uniform Block { ... } becomes a + /// layout(std140) readonly buffer here, so the client's std140 bytes are read + /// unchanged. A program with more named blocks than this range fails to link. + /// + public const int NamedBlockFirstBinding = 4; + public const int NamedBlockLastBinding = 7; + + /// Every set 2 binding: storage buffers, the record, and the named-block range. + public const int StorageSetBindingCount = NamedBlockLastBinding + 1; } diff --git a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs index 7841f9a5..08d56ca5 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs @@ -29,11 +29,19 @@ internal sealed class RewrittenShader /// since they name GL extensions that either do not exist or are already core in /// Vulkan GLSL. /// -/// Loose uniforms move into one generated block. GL's default uniform block has -/// no Vulkan equivalent, and this game declares 488 of them. +/// Loose uniforms move into the program record, set 2's dynamic uniform buffer +/// (GL's default uniform block has no Vulkan equivalent, and this game declares +/// 488 of them), or into the shared frame block at set 0. /// -/// Samplers, uniform blocks and storage buffers gain descriptor set and binding -/// numbers, keeping any the shader already stated. +/// Every program targets the one shared pipeline layout (plan decision 9, +/// ). A sampler named and typed like one of set 0's +/// frame textures reads that binding. Every other sampler becomes a slot index in +/// the push block under its own name, and every reference to it in the body - a +/// sampling call or an argument to a function - reads +/// optimumTextures<Kind>[name] from set 1. Named uniform blocks become +/// layout(std140) readonly buffer blocks in set 2, so the client's std140 +/// bytes are read unchanged; storage blocks move to set 2 at the convention's +/// bindings. /// /// Vertex inputs, varyings and fragment outputs gain the explicit locations /// SPIR-V requires and GLSL 330 left implicit. @@ -80,20 +88,28 @@ public static RewrittenShader Rewrite( case GlslDeclarationKind.OpaqueUniform: if (layout.SamplersByName.TryGetValue(declaration.Name, out SamplerBinding? sampler)) { - edits.Add(LayoutEdit(declaration, new (string, string)[] + if (sampler.IsFrameTexture) { - ("set", ProgramInterfaceLayout.SamplerSet.ToString(CultureInfo.InvariantCulture)), - ("binding", sampler.Binding.ToString(CultureInfo.InvariantCulture)), - })); + edits.Add(LayoutEdit(declaration, new (string, string?)[] + { + ("set", Number(SetConvention.FrameSet)), + ("binding", Number(sampler.FrameBinding)), + })); + } + else + { + // The name is now the slot index in the push block. + edits.Add(new Edit(declaration.Start, declaration.Length, "")); + } } break; case GlslDeclarationKind.UniformBlock: - AddBlockEdit(layout.UniformBlocks, declaration, edits); + AddBlockEdit(layout.UniformBlocks, declaration, edits, asStorage: true); break; case GlslDeclarationKind.StorageBlock: - AddBlockEdit(layout.StorageBlocks, declaration, edits); + AddBlockEdit(layout.StorageBlocks, declaration, edits, asStorage: false); break; case GlslDeclarationKind.Input: @@ -103,6 +119,8 @@ public static RewrittenShader Rewrite( } } + AddSamplerReferenceEdits(parsed, layout, stage, edits); + if (emitDepthRemap) { AddDepthRemapEdits(parsed, stage, edits, result); @@ -120,15 +138,23 @@ private static void AddHeaderEdits( { string frameBlock = BuildFrameBlock(layout, stage); string block = BuildUniformBlock(layout, stage); + string push = BuildPushBlock(layout, stage); + string arrays = BuildTextureArrays(layout, stage); var header = new StringBuilder(); header.Append("#version 450\n"); - if (frameBlock.Length > 0 || block.Length > 0) + if (frameBlock.Length > 0 || block.Length > 0 || push.Length > 0) { header.Append("#extension GL_EXT_scalar_block_layout : require\n"); } + if (arrays.Length > 0) + { + header.Append("#extension GL_EXT_nonuniform_qualifier : require\n"); + } header.Append(frameBlock); header.Append(block); + header.Append(push); + header.Append(arrays); // The geometry stage's EmitVertex() replacement lives in the header so // it precedes every function that may call it. @@ -190,8 +216,8 @@ private static string BuildFrameBlock(ProgramInterfaceLayout layout, EnumShaderT } /// - /// Emits the block that replaces GL's default uniform block, carrying only - /// the members this stage declared. + /// Emits the program record - the block that replaces GL's default uniform + /// block, at set 2's record binding - carrying only the members this stage declared. /// /// Members keep their original names and the block is anonymous, so every /// reference in the shader body resolves unchanged. Each member states its @@ -206,8 +232,8 @@ private static string BuildUniformBlock(ProgramInterfaceLayout layout, EnumShade if (stageMembers.Count == 0) return ""; var builder = new StringBuilder(); - builder.Append(CultureInfo.InvariantCulture, $"\nlayout(scalar, set = {ProgramInterfaceLayout.DefaultBlockSet}"); - builder.Append(CultureInfo.InvariantCulture, $", binding = {ProgramInterfaceLayout.DefaultBlockBinding}) uniform "); + builder.Append(CultureInfo.InvariantCulture, $"\nlayout(scalar, set = {SetConvention.StorageSet}"); + builder.Append(CultureInfo.InvariantCulture, $", binding = {SetConvention.ProgramRecordBinding}) uniform "); builder.Append(ProgramInterfaceLayout.BlockTypeName); builder.Append("\n{\n"); @@ -228,26 +254,241 @@ private static string BuildUniformBlock(ProgramInterfaceLayout layout, EnumShade return builder.ToString(); } + /// + /// Emits the push block with one slot index per bindless sampler this stage + /// declared, under the sampler's own name, at the offset the whole program agrees + /// on. Anonymous, so the rewritten references read the index by that name. + /// + private static string BuildPushBlock(ProgramInterfaceLayout layout, EnumShaderType stage) + { + if (!layout.SamplersByStage.TryGetValue(stage, out HashSet? stageSamplers)) return ""; + + var builder = new StringBuilder(); + foreach (SamplerBinding sampler in layout.Samplers) + { + if (sampler.IsFrameTexture || !stageSamplers.Contains(sampler.Name)) continue; + if (builder.Length == 0) + { + builder.Append("\nlayout(push_constant, scalar) uniform ").Append(ProgramInterfaceLayout.PushBlockTypeName); + builder.Append("\n{\n"); + } + // OPTIMUM_SAMPLER_SLOT(, ) in bindings.glsl expands to the same declaration. + builder.Append(CultureInfo.InvariantCulture, $" layout(offset = {sampler.PushOffset}) uint {sampler.Name};"); + builder.Append(CultureInfo.InvariantCulture, $" // {sampler.TypeName}\n"); + } + if (builder.Length == 0) return ""; + builder.Append("};\n"); + return builder.ToString(); + } + + /// The set 1 arrays this stage's bindless samplers index, declared as bindings.glsl declares them. + private static string BuildTextureArrays(ProgramInterfaceLayout layout, EnumShaderType stage) + { + if (!layout.SamplersByStage.TryGetValue(stage, out HashSet? stageSamplers)) return ""; + + var kinds = new SortedSet(); + foreach (SamplerBinding sampler in layout.Samplers) + { + if (!sampler.IsFrameTexture && stageSamplers.Contains(sampler.Name)) kinds.Add((int)sampler.Kind); + } + var builder = new StringBuilder(); + foreach (int kind in kinds) + { + SetConvention.Binding array = SetConvention.TextureArrays[kind]; + builder.Append(CultureInfo.InvariantCulture, + $"layout(set = {SetConvention.TextureSet}, binding = {array.Value}) uniform {array.GlslType} {array.Name}[];\n"); + } + return builder.ToString(); + } + // -------------------------------------------------------------- declarations - private static void AddBlockEdit(List blocks, GlslDeclaration declaration, List edits) + private static readonly string[] MemoryLayouts = { "std140", "std430", "shared", "packed" }; + + /// + /// Moves a named block to its set 2 binding. A uniform block becomes a + /// layout(std140) readonly buffer: std140 is what the client's UBO uploads + /// already stride to, and a storage buffer defaults to std430, so the memory + /// layout is stated explicitly whatever the shader wrote. + /// + private static void AddBlockEdit(List blocks, GlslDeclaration declaration, List edits, + bool asStorage) { foreach (BlockBinding block in blocks) { if (block.BlockName != declaration.Name) continue; - // The memory layout qualifier the shader chose (std140 / std430) is - // preserved: those blocks are filled by UBO uploads whose striding - // already matches, and only the default block needs scalar rules. - edits.Add(LayoutEdit(declaration, new (string, string)[] + if (asStorage) { - ("set", block.Set.ToString(CultureInfo.InvariantCulture)), - ("binding", block.Binding.ToString(CultureInfo.InvariantCulture)), + edits.Add(LayoutEdit(declaration, new (string, string?)[] + { + ("std140", null), + ("set", Number(block.Set)), + ("binding", Number(block.Binding)), + }, MemoryLayouts)); + if (declaration.StorageKeywordStart >= 0) + { + edits.Add(new Edit(declaration.StorageKeywordStart, "uniform".Length, "readonly buffer")); + } + return; + } + + // A storage block keeps the memory layout it chose (std430 for faceDataBuf). + edits.Add(LayoutEdit(declaration, new (string, string?)[] + { + ("set", Number(block.Set)), + ("binding", Number(block.Binding)), })); return; } } + /// + /// Rewrites every reference to a bindless sampler this stage declared into + /// optimumTextures<Kind>[name], where name is now the slot index + /// in the push block. A sampler can only ever appear as a function argument - to + /// texture, texelFetch, textureLod, textureGather, + /// textureSize or to a function of the shader's own such as colormap's + /// getColorMapped - so every reference is rewritten and no call form is + /// singled out. + /// + /// Not rewritten: the global declarations (they have edits of their own), a field + /// after a dot, a declaration of the same name (a parameter sampler2D tex, a + /// local or struct member), and every use inside the scope such a declaration + /// shadows the global in. Comments and preprocessor lines are skipped. + /// + private static void AddSamplerReferenceEdits( + ParsedShader parsed, ProgramInterfaceLayout layout, EnumShaderType stage, List edits) + { + if (!layout.SamplersByStage.TryGetValue(stage, out HashSet? stageSamplers)) return; + + var replacements = new Dictionary(StringComparer.Ordinal); + foreach (SamplerBinding sampler in layout.Samplers) + { + if (sampler.IsFrameTexture || !stageSamplers.Contains(sampler.Name)) continue; + replacements[sampler.Name] = SetConvention.TextureArrays[(int)sampler.Kind].Name + "[" + sampler.Name + "]"; + } + if (replacements.Count == 0) return; + + var skipped = new List<(int Start, int End)>(); + foreach (GlslDeclaration declaration in parsed.Declarations) + { + if (declaration.Kind is GlslDeclarationKind.Other) continue; + skipped.Add((declaration.Start, declaration.End)); + } + skipped.Sort(static (a, b) => a.Start.CompareTo(b.Start)); + + string source = parsed.Source; + var scopes = new List> { new(StringComparer.Ordinal) }; + HashSet? parameters = null; + int parenDepth = 0; + string previous = ";"; + bool previousIsWord = false; + int skip = 0; + int i = 0; + bool lineStart = true; + + while (i < source.Length) + { + while (skip < skipped.Count && skipped[skip].End <= i) skip++; + if (skip < skipped.Count && skipped[skip].Start <= i) + { + i = skipped[skip].End; + previous = ";"; + previousIsWord = false; + continue; + } + + char c = source[i]; + if (c == '\n') { lineStart = true; i++; continue; } + if (char.IsWhiteSpace(c)) { i++; continue; } + + if (c == '#' && lineStart) + { + while (i < source.Length && source[i] != '\n') + { + if (source[i] == '\\' && i + 1 < source.Length && source[i + 1] == '\n') i++; + i++; + } + continue; + } + lineStart = false; + + if (c == '/' && i + 1 < source.Length && source[i + 1] == '/') + { + while (i < source.Length && source[i] != '\n') i++; + continue; + } + if (c == '/' && i + 1 < source.Length && source[i + 1] == '*') + { + int close = source.IndexOf("*/", i + 2, StringComparison.Ordinal); + i = close < 0 ? source.Length : close + 2; + continue; + } + + if (char.IsLetter(c) || c == '_') + { + int start = i; + while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_')) i++; + string word = source.Substring(start, i - start); + + if (replacements.TryGetValue(word, out string? replacement) && previous != ".") + { + bool declaration = previousIsWord && previous is not ("return" or "case"); + if (declaration) + { + (scopes.Count == 1 && parenDepth > 0 ? parameters ??= new(StringComparer.Ordinal) : scopes[^1]) + .Add(word); + } + else if (!Shadowed(scopes, word)) + { + edits.Add(new Edit(start, word.Length, replacement)); + } + } + + previous = word; + previousIsWord = true; + continue; + } + + switch (c) + { + case '(': + if (scopes.Count == 1 && parenDepth == 0) parameters = null; + parenDepth++; + break; + case ')': + if (parenDepth > 0) parenDepth--; + break; + case '{': + // A function body sees its parameters; any other brace opens a plain scope. + scopes.Add(scopes.Count == 1 && parameters != null ? parameters : new(StringComparer.Ordinal)); + parameters = null; + break; + case '}': + if (scopes.Count > 1) scopes.RemoveAt(scopes.Count - 1); + break; + case ';': + if (scopes.Count == 1) parameters = null; + break; + } + previous = c.ToString(); + previousIsWord = false; + i++; + } + } + + private static bool Shadowed(List> scopes, string name) + { + // The outermost set only ever holds names declared at global scope inside a + // struct or prototype parenthesis, which never shadow a use; start above it. + for (int i = scopes.Count - 1; i >= 1; i--) + { + if (scopes[i].Contains(name)) return true; + } + return false; + } + private static void AddLocationEdit( ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage, List edits) { @@ -265,21 +506,26 @@ private static void AddLocationEdit( if (!layout.VaryingLocations.TryGetValue(declaration.Name, out location)) return; } - edits.Add(LayoutEdit(declaration, new (string, string)[] + edits.Add(LayoutEdit(declaration, new (string, string?)[] { - ("location", location.ToString(CultureInfo.InvariantCulture)), + ("location", Number(location)), })); } + private static string Number(int value) => value.ToString(CultureInfo.InvariantCulture); + /// /// Produces an edit that replaces the declaration's layout(...) clause - /// with one carrying the given keys, preserving any others it already had. - /// When there was no clause, the span is empty and this inserts one. + /// with one carrying the given keys (a null value adds a bare word such as + /// std140), preserving any others it already had except + /// . When there was no clause, the span is empty + /// and this inserts one. /// - private static Edit LayoutEdit(GlslDeclaration declaration, (string Key, string Value)[] additions) + private static Edit LayoutEdit(GlslDeclaration declaration, (string Key, string? Value)[] additions, + params string[] removals) { var parts = new List(); - var overridden = new HashSet(StringComparer.Ordinal); + var overridden = new HashSet(removals, StringComparer.Ordinal); foreach ((string key, _) in additions) overridden.Add(key); if (declaration.LayoutQualifiers != null) @@ -297,12 +543,15 @@ private static Edit LayoutEdit(GlslDeclaration declaration, (string Key, string } } - foreach ((string key, string value) in additions) + foreach ((string key, string? value) in additions) { - parts.Add($"{key} = {value}"); + parts.Add(value == null ? key : $"{key} = {value}"); } - return new Edit(declaration.LayoutStart, declaration.LayoutLength, $"layout({string.Join(", ", parts)}) "); + // A replaced clause keeps the whitespace that followed it; an inserted one brings its own. + string clause = $"layout({string.Join(", ", parts)})"; + return new Edit(declaration.LayoutStart, declaration.LayoutLength, + declaration.LayoutLength == 0 ? clause + " " : clause); } // ---------------------------------------------------------------- depth remap diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index e9f10ac7..fa3d14ab 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -145,20 +145,18 @@ private static bool NamesCaptureDirectory(string? value) => private bool _lastUniformAllocationOk = true; /// - /// The shared frame block (set 0, ): the one set - /// layout every program's pipeline layout names for it, the CPU shadow every - /// frame-global write lands in, and the ring snapshot draws bind until a write - /// changes something. Replaces up to 56 per-program copies of the same values. + /// Decision 9's set 1 and the one shared pipeline layout every program's pipelines + /// are built against. Created at bring-up and kept current (slots retire with their + /// textures, writes flush before every submission). /// - private DescriptorSetLayout _frameSetLayout; + private BindlessTextureTable? _bindless; + private SharedPipelineLayout? _sharedLayout; /// - /// Decision 9's set 1 and the one shared pipeline layout. Created at bring-up and - /// kept current (slots retire with their textures, writes flush before every - /// submission); no draw uses them until shaders target the shared layout. + /// The shared frame block (set 0, ): the CPU shadow every + /// frame-global write lands in, and the ring snapshot draws bind until a write + /// changes something. Replaces up to 56 per-program copies of the same values. /// - private BindlessTextureTable? _bindless; - private SharedPipelineLayout? _sharedLayout; private readonly byte[] _frameGlobals = FrameGlobals.CreateShadow(); private uint _frameGlobalsVersion = 1; private uint _frameGlobalsSnapshotFrame; @@ -171,13 +169,9 @@ private static bool NamesCaptureDirectory(string? value) => private VulkanBuffer? _defaultAttributes; /// - /// A one-texel image that stands in for any sampler the client has not bound. - /// See the placeholder note in BindDescriptors. + /// The zero-filled buffer at every set 2 binding a draw has nothing for. An unbound + /// sampler reads the bindless table's placeholder of its kind instead. /// - private int _placeholderTexture; - private int _placeholderArrayTexture; - private int _placeholderCubeTexture; - private int _placeholderDepthTexture; private VulkanBuffer? _placeholderUniforms; /// Texture bound to each unit, and any sampler overriding the texture's own state. @@ -486,12 +480,14 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _pipelines.AsyncCompiles = !synchronousPipelines; _pipelines.KeyLog = _pipelinePersistence?.KeyLog; _descriptors = new DescriptorCache(_context); - // One layout for the shared frame block, named by every program's pipeline layout. - _frameSetLayout = ShaderProgramResources.CreateFrameSetLayout(_context); // Decision 9: the bindless table retires a texture's slots on the timeline // values of its deletion, and the shared layout names the table's set layout. _bindless = new BindlessTextureTable(_context, _textures, _frames.Timeline); - _textures.Deleted = texture => _bindless.Release(texture.Id); + _textures.Deleted = texture => + { + _bindless.Release(texture.Id); + ForgetFrameTexture(texture.Id); + }; _sharedLayout = new SharedPipelineLayout(_context, _bindless.Layout); _descriptorArenas = new DescriptorArena[_frames.FramesInFlight]; for (int i = 0; i < _descriptorArenas.Length; i++) _descriptorArenas[i] = new DescriptorArena(_context); @@ -517,7 +513,6 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa ? "compile in the background" : synchronousPipelines ? "compile blocking (OPTIMUM_VULKAN_SYNC_PIPELINES, a frame capture or the device setting)" : "compile blocking (no pipelineCreationCacheControl)")); CreateDefaultAttributeBuffer(); - CreatePlaceholderTexture(); CreatePlaceholderUniformBuffer(); if (!headless) @@ -619,87 +614,10 @@ private void CreateDefaultAttributeBuffer() } } - /// - /// Builds the one-texel image that fills any sampler binding the client left - /// empty. Opaque black, which is what GL reads from an unbound texture. - /// - private void CreatePlaceholderTexture() - { - var texel = new byte[] { 0, 0, 0, 255 }; - fixed (byte* pixels = texel) - { - _placeholderTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm); - _textures.Upload(_placeholderTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4); - - // A descriptor's view type has to match the sampler's dimensionality - // - a 2D view in a sampler2DArray slot is invalid, not merely black - - // so an arrayed and a cube placeholder stand in for those samplers. - _placeholderArrayTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm, layers: 2); - for (uint layer = 0; layer < 2; layer++) - { - _textures.Upload(_placeholderArrayTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4, layer); - } - - _placeholderCubeTexture = _textures.Create(1, 1, Format.R8G8B8A8Unorm, layers: 6, cube: true); - for (uint face = 0; face < 6; face++) - { - _textures.Upload(_placeholderCubeTexture, 0, 0, 0, 1, 1, (IntPtr)pixels, 4, face); - } - } - - // A shadow sampler compares against depth, so its placeholder is a depth - // texel at the far plane: every comparison passes and nothing is shadowed, - // which is what a missing shadow map looks like on GL. The state enables - // comparison so the sampler object matches the sampler declaration too. - float far = 1f; - _placeholderDepthTexture = _textures.Create(1, 1, Format.D32Sfloat); - _textures.Upload(_placeholderDepthTexture, 0, 0, 0, 1, 1, (IntPtr)(&far), 4); - VulkanTexture? depthPlaceholder = _textures.Get(_placeholderDepthTexture); - if (depthPlaceholder != null) - { - depthPlaceholder.State = depthPlaceholder.State with { CompareEnable = true }; - } - } - - /// - /// The placeholder that fits a sampler's declaration: a shadow sampler - /// compares against depth and needs a depth format, the others need the - /// matching view type. An arrayed shadow sampler gets the 2D depth - /// placeholder, which the trace will show should the game ever declare one. - /// - private int PlaceholderFor(string samplerType) => - samplerType.Contains("Shadow", StringComparison.Ordinal) ? _placeholderDepthTexture - : samplerType.Contains("Cube", StringComparison.Ordinal) ? _placeholderCubeTexture - : samplerType.Contains("Array", StringComparison.Ordinal) ? _placeholderArrayTexture - : _placeholderTexture; - - /// - /// Whether a texture can legally sit behind a sampler of the given type. A - /// shadow sampler on a colour texture is the case that matters: GL leaves the - /// comparison undefined, Vulkan rejects the descriptor, and the game reaches - /// it whenever a shadow map slot exists without a shadow map behind it. - /// - private static bool TextureSuitsSampler(VulkanTexture texture, string samplerType) - { - if (samplerType.Contains("Shadow", StringComparison.Ordinal) - && !TextureManager.IsDepthFormat(texture.Format)) - { - return false; - } - - // The view type has to match the sampler's dimensionality, which GL - // enforces through its texture targets: a 2D texture cannot be bound - // where a sampler2DArray reads, nor an array where a sampler2D does. - bool wantsCube = samplerType.Contains("Cube", StringComparison.Ordinal); - bool wantsArray = samplerType.Contains("Array", StringComparison.Ordinal); - if (wantsCube) return texture.Cube; - if (wantsArray) return texture.Layers > 1 && !texture.Cube; - return texture.Layers == 1 && !texture.Cube; - } - /// /// Builds the zero-filled buffer that fills any shader-declared uniform block - /// the client has not supplied a buffer for yet. + /// the client has not supplied a buffer for yet, and every other set 2 binding + /// a draw does not read (the shared layout's set is written whole). /// /// Same reasoning as the placeholder texture: leaving the binding undefined /// makes every draw with that program invalid, so a program whose UBO has not @@ -714,7 +632,7 @@ private void CreatePlaceholderUniformBuffer() ulong size = Math.Min(65536UL, Math.Max(16384UL, _context!.Capabilities.MaxUniformBufferRange)); _placeholderUniforms = new VulkanBuffer(_context, size, - BufferUsageFlags.UniformBufferBit, + BufferUsageFlags.UniformBufferBit | BufferUsageFlags.StorageBufferBit, MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); if (_placeholderUniforms.Mapped != IntPtr.Zero) @@ -936,6 +854,7 @@ internal void DrawBindlessForTests(Pipeline pipeline, int width, int height, byt api.CmdDraw(commandBuffer, 3, 1, 0, 0); // The raw bind and states above are not what the cache believes the buffer holds. _dynamicState.Invalidate(); + ForgetBoundDescriptors(); } /// Static meshes on device-local memory through staging (Phase 1B step 5's default). Tests only. @@ -1247,14 +1166,14 @@ public int LinkProgram(IShaderProgram program) { RenderTrace.DumpProgramSources(program.PassName, translated); RenderTrace.Write("program " + programId + " '" + program.PassName + "' uniformBlockBytes=" + - translated.Layout.BlockSize); + translated.Layout.BlockSize + " pushBytes=" + translated.Layout.PushConstantSize); foreach (UniformMember member in translated.Layout.Members) { RenderTrace.Write(" uniform " + member.Name + " offset=" + member.Offset + " type=" + member.Type + " count=" + member.ArrayLength); } } - var resources = new ShaderProgramResources(_context, programId, translated, _frameSetLayout); + var resources = new ShaderProgramResources(_context, programId, translated, _sharedLayout!.Layout); _programs[programId] = resources; _programNames[programId] = program.PassName ?? ""; // Pipelines an earlier launch used with this exact program start compiling now. @@ -2359,7 +2278,7 @@ private bool SamplesBoundDepthWithoutWriting(ShaderProgramResources program) foreach (SamplerBinding declared in program.Interface.Samplers) { - int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Binding; + int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Order; if ((uint)unit >= GlStateTracker.MaxTextureUnits) continue; if (_targets.IsBoundDepth(_boundTextures[unit])) return true; } @@ -2377,26 +2296,33 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra ReleaseReadSelfCopies(); if (program.Interface.Samplers.Count == 0) return; - bool placeholderNeeded = false; + // Set 1's placeholders (and set 0's, which are the same textures) are written + // shader-read-only and never used any other way: put them there once. + if (!_bindlessPlaceholdersReadable && _bindless != null) + { + for (int kind = 0; kind < BindlessKinds.Count; kind++) + { + VulkanTexture? placeholder = _textures.Get(_bindless.PlaceholderTextureId((TextureKind)kind)); + if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; + _targets.EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, placeholder, Graph.ResourceUsage.SampleFragment); + } + _bindlessPlaceholdersReadable = true; + } for (int i = 0; i < program.Interface.Samplers.Count; i++) { SamplerBinding declared = program.Interface.Samplers[i]; int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped - : declared.Binding; + : declared.Order; VulkanTexture? texture = (uint)unit < GlStateTracker.MaxTextureUnits ? _textures.Get(_boundTextures[unit]) : null; - if (texture == null) - { - // BindDescriptors will reach for the placeholder here, so that is - // what this draw actually samples. - placeholderNeeded = true; - continue; - } + // Nothing bound: the draw reads a placeholder, readable since above. + if (texture == null) continue; // A clear promoted into it has to land before the read (frame graph). _targets.FlushPendingClears(commandBuffer, texture); @@ -2436,23 +2362,6 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra _textures.Require(_barriers, commandBuffer, texture, Graph.ResourceUsage.SampleFragment); } - if (!placeholderNeeded) - { - _barriers.Flush(commandBuffer); - return; - } - - foreach (int id in new[] - { - _placeholderTexture, _placeholderArrayTexture, _placeholderCubeTexture, _placeholderDepthTexture, - }) - { - VulkanTexture? placeholder = _textures.Get(id); - if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; - - _targets.EndRendering(commandBuffer); - _textures.Require(_barriers, commandBuffer, placeholder, Graph.ResourceUsage.SampleFragment); - } _barriers.Flush(commandBuffer); } @@ -2548,339 +2457,371 @@ private void ReportUniformExhaustion(ShaderProgramResources program, string what MirrorValidationMessage(message); } + // ----------------------------------------------- shared layout: what is bound + /// - /// Binds the shared frame block (set 0). Its bytes go into the ring once per - /// change of its contents: every draw that follows in the frame reads the same - /// snapshot, through the one descriptor set every program shares, and only the - /// dynamic offset moves when a value does. + /// What the current recording holds at each set of the shared pipeline layout (plan + /// decision 9), and the push bytes it last received. Every program's pipelines are + /// built against the one layout, so binding another program's pipeline disturbs none + /// of it: set 1 is bound once per recording, set 0 and set 2 only when their set or + /// dynamic offset changes, and push constants only when the bytes do. A new recording + /// (another serial) or a raw bind outside this path () + /// starts over. + /// + private ulong _boundSerial; + private DescriptorSet _boundFrameSet; + private uint _boundFrameOffset; + private bool _boundTextureSet; + private DescriptorSet _boundStorageSet; + private uint _boundRecordOffset; + private int _pushedLength; + private readonly byte[] _pushShadow = new byte[SetConvention.PushConstantBytes]; + private readonly byte[] _pushedBytes = new byte[SetConvention.PushConstantBytes]; + + /// + /// Set 0's frame textures as the last draw that samples each resolved them, in + /// order; an empty value is that binding's + /// placeholder. Only a program that samples a frame texture reads the binding, and it + /// resolves it again first, so a value left from another program is never read. + /// A texture's deletion clears its values (any thread, hence the lock). /// - private void BindFrameGlobals(CommandBuffer commandBuffer, ShaderProgramResources program) + private readonly SamplerBindingValue[] _frameTextureValues = new SamplerBindingValue[SetConvention.FrameTextures.Length]; + private readonly object _frameTextureLock = new(); + + /// Whether set 1's placeholders have been put in the layout their descriptors name. + private bool _bindlessPlaceholdersReadable; + + /// Binds of set 0 and set 1 this device recorded. Tests only. + internal long FrameSetBindsForTests { get; private set; } + internal long TextureSetBindsForTests { get; private set; } + + /// Forgets what the recording holds bound, after a bind this path did not make. + private void ForgetBoundDescriptors() => _boundSerial = 0; + + private void SyncBoundDescriptors(CommandBuffer commandBuffer) { - uint offset = 0; - if (_frameGlobalsSnapshotFrame == _frameCounter && _frameGlobalsSnapshotVersion == _frameGlobalsVersion) - { - offset = _frameGlobalsSnapshotOffset; - } - else if (_frames.Current.TryAllocateUniforms(_frameGlobals.Length, out RingAllocation allocation)) + FrameSlot slot = _frames.Current; + ulong serial = slot.CommandBuffer.Handle == commandBuffer.Handle ? slot.RecordingSerial : 0; + if (serial != 0 && serial == _boundSerial) return; + + _boundSerial = serial; + _boundFrameSet = default; + _boundFrameOffset = 0; + _boundTextureSet = false; + _boundStorageSet = default; + _boundRecordOffset = 0; + _pushedLength = 0; + } + + private void ForgetFrameTexture(ulong textureId) + { + lock (_frameTextureLock) { - fixed (byte* source = _frameGlobals) + for (int i = 0; i < _frameTextureValues.Length; i++) { - System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, _frameGlobals.Length, _frameGlobals.Length); + if (_frameTextureValues[i].Resource == textureId) _frameTextureValues[i] = default; } - offset = allocation.Offset; - _frameGlobalsSnapshotFrame = _frameCounter; - _frameGlobalsSnapshotVersion = _frameGlobalsVersion; - _frameGlobalsSnapshotOffset = offset; } - else + } + + private static int FrameTextureIndex(int binding) + { + for (int i = 0; i < SetConvention.FrameTextures.Length; i++) { - ReportUniformExhaustion(program, "the shared frame block"); + if (SetConvention.FrameTextures[i].Value == binding) return i; } + throw new ArgumentOutOfRangeException(nameof(binding), binding, "not a frame texture binding"); + } - var contents = new DescriptorSetContents( - 0, ProgramInterfaceLayout.FrameSet, Array.Empty(), - new[] { new BufferBindingValue(FrameGlobals.Binding, _frames.UniformBuffer, 0, (ulong)_frameGlobals.Length) }); - DescriptorSet frameSet = GetDescriptorSet(contents, _frameSetLayout); - _context.Api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.FrameSet, 1, &frameSet, 1, &offset); + private static TextureKind KindOf(SamplerBinding sampler) + { + if (!sampler.IsFrameTexture) return sampler.Kind; + BindlessKinds.TryFromGlslType(sampler.TypeName, out TextureKind kind); + return kind; } - private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) + /// Set 0's placeholder for a frame texture: the bindless table's placeholder of the declared kind. + private SamplerBindingValue FrameTexturePlaceholder(int index) { - Vk api = _context.Api; + SetConvention.Binding frame = SetConvention.FrameTextures[index]; + BindlessKinds.TryFromGlslType(frame.GlslType, out TextureKind kind); + VulkanTexture placeholder = _textures.Get(_bindless!.PlaceholderTextureId(kind))!; + return new SamplerBindingValue((uint)frame.Value, placeholder.View, + _textures.Samplers.Get(BindlessKinds.EffectiveState(SamplerState.Default, kind)), placeholder.Id); + } - if (program.Interface.UsesFrameBlock) + /// + /// Takes a snapshot of the shared frame block when it changed and returns its ring + /// offset. Every draw that follows in the frame reads the same snapshot and only the + /// dynamic offset moves when a value does. + /// + private uint SnapshotFrameGlobals(ShaderProgramResources program) + { + if (_frameGlobalsSnapshotFrame == _frameCounter && _frameGlobalsSnapshotVersion == _frameGlobalsVersion) { - BindFrameGlobals(commandBuffer, program); + return _frameGlobalsSnapshotOffset; } - - // Set 3: the generated uniform block plus one entry for every block the - // shader declared for itself. Every one of them is a dynamic descriptor - // pointing at this frame's uniform ring, so the set itself never changes - // - the per-draw offset travels alongside it instead. - bool hasGeneratedBlock = program.Interface.HasUniformBlock; - int dynamicCount = (hasGeneratedBlock ? 1 : 0) + program.Interface.UniformBlocks.Count; - - if (dynamicCount > 0) + if (!_frames.Current.TryAllocateUniforms(_frameGlobals.Length, out RingAllocation allocation)) + { + ReportUniformExhaustion(program, "the shared frame block"); + return 0; + } + fixed (byte* source = _frameGlobals) { - var buffers = new List(dynamicCount); + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, _frameGlobals.Length, _frameGlobals.Length); + } + _frameGlobalsSnapshotFrame = _frameCounter; + _frameGlobalsSnapshotVersion = _frameGlobalsVersion; + _frameGlobalsSnapshotOffset = allocation.Offset; + return allocation.Offset; + } + + private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) + { + Vk api = _context.Api; + SharedPipelineLayout shared = _sharedLayout!; + SyncBoundDescriptors(commandBuffer); - // Dynamic offsets are consumed in increasing order of binding number, - // not in the order the bindings were written, so each one is carried - // with its binding and sorted below. - uint* offsetBindings = stackalloc uint[dynamicCount]; - uint* offsetValues = stackalloc uint[dynamicCount]; - int offsetCount = 0; - bool allocationOk = true; + ResolveSamplers(program); - if (hasGeneratedBlock) + // Set 0: the frame block and the fixed frame textures. + if (program.Interface.UsesFrameBlock || program.Interface.UsesFrameTextures) + { + uint offset = SnapshotFrameGlobals(program); + var samplers = new SamplerBindingValue[SetConvention.FrameTextures.Length]; + lock (_frameTextureLock) { - uint generatedOffset = 0; - if (program.HasSnapshotFor(_frameCounter)) - { - // Nothing written since this program's last draw this frame - // took its snapshot: bind the same bytes again. - generatedOffset = program.SnapshotOffset; - } - else if (_frames.Current.TryAllocateUniforms( - program.UniformShadow.Length, out RingAllocation allocation)) + for (int i = 0; i < samplers.Length; i++) { - fixed (byte* source = program.UniformShadow) - { - System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, - program.UniformShadow.Length, program.UniformShadow.Length); - } - generatedOffset = allocation.Offset; - program.NoteSnapshot(_frameCounter, allocation.Offset); + samplers[i] = _frameTextureValues[i].View.Handle != 0 ? _frameTextureValues[i] : FrameTexturePlaceholder(i); } - else + } + var contents = new DescriptorSetContents(0, SetConvention.FrameSet, samplers, + new[] { - // The draw goes ahead reading offset zero of the ring, which - // is some other draw's block: wrong, and for a shader that - // loops on a uniform count, possibly fatal. - allocationOk = false; - ReportUniformExhaustion(program, "its generated uniform block"); - } - - buffers.Add(new BufferBindingValue( - ProgramInterfaceLayout.DefaultBlockBinding, - _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length)); - offsetBindings[offsetCount] = ProgramInterfaceLayout.DefaultBlockBinding; - offsetValues[offsetCount++] = generatedOffset; + new BufferBindingValue((uint)SetConvention.FrameGlobalsBinding, _frames.UniformBuffer, 0, + (ulong)_frameGlobals.Length), + }); + DescriptorSet frameSet = GetDescriptorSet(contents, shared.FrameSetLayout); + if (frameSet.Handle != _boundFrameSet.Handle || offset != _boundFrameOffset) + { + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, shared.Layout, + (uint)SetConvention.FrameSet, 1, &frameSet, 1, &offset); + _boundFrameSet = frameSet; + _boundFrameOffset = offset; + FrameSetBindsForTests++; } + } - // A block the shader declares is fed by whichever UBO the client - // created under that name; one it has not created yet reads zeroes - // rather than leaving the descriptor undefined. - foreach (BlockBinding block in program.Interface.UniformBlocks) + // Set 1 once per recording, and the slot indices when they changed. + int pushSize = program.Interface.PushConstantSize; + if (pushSize > 0) + { + if (!_boundTextureSet) { - ClientUniformBuffer? ubo = null; - if (_boundUniformBuffers.TryGetValue(block.BlockName, out int handle)) - { - _uniformBuffers.TryGetValue(handle, out ubo); - } - - if (ubo == null) - { - // Zeroes, at dynamic offset zero. The placeholder has existed - // since the device came up; should it somehow not, the ring - // stands in, because a set with a hole in it - or a dynamic - // offset count that disagrees with the layout - is an invalid - // draw rather than merely a wrong colour. - buffers.Add(_placeholderUniforms != null - ? new BufferBindingValue((uint)block.Binding, _placeholderUniforms.Handle, - 0, _placeholderUniforms.Size, _placeholderUniforms.Id) - : new BufferBindingValue((uint)block.Binding, _frames.UniformBuffer, - 0, Math.Min(16384UL, _context.Capabilities.MaxUniformBufferRange))); - offsetBindings[offsetCount] = (uint)block.Binding; - offsetValues[offsetCount++] = 0; - continue; - } - - if (TrySnapshotClientBlock(ubo, program, out uint blockOffset)) - { - buffers.Add(new BufferBindingValue((uint)block.Binding, - _frames.UniformBuffer, 0, (ulong)ubo.Shadow.Length)); - offsetBindings[offsetCount] = (uint)block.Binding; - offsetValues[offsetCount++] = blockOffset; - } - else - { - // No room left in the ring. Rather than aliasing the block's - // persistent buffer - which would hand every remaining draw - // in the frame the last upload, the exact bug the ring - // exists to fix - this draw gets its own transient copy. - // Slower, but still correct; the overflow is counted so a - // scene that lives in this path shows up in the stats. - allocationOk = false; - VulkanStats.NoteUniformOverflow(); - var overflow = new VulkanBuffer(_context, (ulong)ubo.Shadow.Length, - BufferUsageFlags.UniformBufferBit, - MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); - fixed (byte* shadow = ubo.Shadow) - { - System.Buffer.MemoryCopy(shadow, (void*)overflow.Mapped, - ubo.Shadow.Length, ubo.Shadow.Length); - } - buffers.Add(new BufferBindingValue((uint)block.Binding, - overflow.Handle, 0, overflow.Size, overflow.Id)); - offsetBindings[offsetCount] = (uint)block.Binding; - offsetValues[offsetCount++] = 0; - // Released and deferred in that order: the cached set naming - // this buffer must not outlive it under a reused handle. - // (This is the only VulkanBuffer a client UBO ever owns - - // the block itself is host-side shadow plus a ring snapshot.) - _descriptors.Release(overflow.Id); - _frames.DeferDeletion(overflow); - } + DescriptorSet textureSet = _bindless!.Set; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, shared.Layout, + (uint)SetConvention.TextureSet, 1, &textureSet, 0, null); + _boundTextureSet = true; + TextureSetBindsForTests++; } - - _lastUniformAllocationOk = allocationOk; - - // Insertion sort by binding: at most a handful of entries, and the - // generated block is already the lowest of them. - for (int i = 1; i < offsetCount; i++) + if (pushSize > _pushedLength || + !_pushShadow.AsSpan(0, pushSize).SequenceEqual(_pushedBytes.AsSpan(0, pushSize))) { - uint binding = offsetBindings[i]; - uint value = offsetValues[i]; - int j = i - 1; - while (j >= 0 && offsetBindings[j] > binding) + fixed (byte* push = _pushShadow) { - offsetBindings[j + 1] = offsetBindings[j]; - offsetValues[j + 1] = offsetValues[j]; - j--; + api.CmdPushConstants(commandBuffer, shared.Layout, SharedPipelineLayout.Stages, 0, (uint)pushSize, push); } - offsetBindings[j + 1] = binding; - offsetValues[j + 1] = value; + _pushShadow.AsSpan(0, pushSize).CopyTo(_pushedBytes); + _pushedLength = Math.Max(_pushedLength, pushSize); + VulkanStats.NotePushConstantWrite(); } - - var uniformContents = new DescriptorSetContents( - program.ProgramId, ProgramInterfaceLayout.DefaultBlockSet, - Array.Empty(), buffers.ToArray()); - - DescriptorSet uniformSet = GetDescriptorSet( - uniformContents, program.SetLayouts[ProgramInterfaceLayout.DefaultBlockSet]); - - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.DefaultBlockSet, 1, &uniformSet, - (uint)offsetCount, offsetCount == 0 ? null : offsetValues); } - // Set 1: one combined image sampler per declared sampler, resolved through - // the unit each sampler uniform points at. - if (program.Interface.Samplers.Count > 0) + if (program.Interface.UsesStorageSet) { - var bindings = new SamplerBindingValue[program.Interface.Samplers.Count]; - for (int i = 0; i < bindings.Length; i++) - { - SamplerBinding declared = program.Interface.Samplers[i]; - int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) - ? mapped - : declared.Binding; + BindStorageSet(commandBuffer, program, meshId); + } + } - ImageView view = default; - Sampler sampler = default; - ulong resource = 0; + /// + /// Resolves every sampler the program declares through the unit its uniform points + /// at, to the texture bound there - a feedback draw's ReadSelf copy in place of the + /// attachment it copies, and through the physical + /// texture behind a transient rebind - with the unit's sampler object overriding the + /// texture's own state, as glBindSampler does. A frame texture's value goes to set 0; + /// every other sampler gets the bindless slot of its kind, keyed on the read-only + /// depth layout when it samples the bound depth attachment with writes off, and its + /// index lands in the push shadow. A texture that cannot sit behind the declared kind + /// reads that kind's placeholder, as an unbound unit does. + /// + private void ResolveSamplers(ShaderProgramResources program) + { + foreach (SamplerBinding declared in program.Interface.Samplers) + { + int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Order; + TextureKind kind = KindOf(declared); + VulkanTexture? texture = null; + SamplerState sampling = SamplerState.Default; + ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal; - if ((uint)unit < GlStateTracker.MaxTextureUnits) + if ((uint)unit < GlStateTracker.MaxTextureUnits) + { + int bound = _boundTextures[unit]; + int textureId = _sampledTextureOverrides.TryGetValue(bound, out int copy) ? copy : bound; + texture = _textures.Get(textureId); + if (texture != null && !BindlessKinds.Suits(TextureShape.Of(texture), kind)) { - int textureId = _sampledTextureOverrides.TryGetValue(_boundTextures[unit], out int copy) - ? copy : _boundTextures[unit]; - VulkanTexture? texture = _textures.Get(textureId); - if (texture != null && !TextureSuitsSampler(texture, declared.TypeName)) - { - // Left unbound on purpose, so the placeholder that suits - // the sampler takes the slot below. - if (RenderTrace.Enabled) - { - RenderTrace.Write("sampler '" + declared.Name + "' (" + declared.TypeName + - ") on program " + program.ProgramId + " has texture " + _boundTextures[unit] + - " of format " + texture.Format + " bound, which it cannot sample; using a placeholder"); - } - texture = null; - } - if (texture != null) + if (RenderTrace.Enabled) { - view = texture.View; - resource = texture.Id; - // A sampler bound to the unit overrides the texture's own - // state, which is what glBindSampler means. - // MAX_LEVEL belongs to the texture, even when a sampler - // overrides its filters. Resolve at draw time so changes - // to either object also affect an already-bound unit. - SamplerState sampling = _standaloneSamplers.TryGetValue(_unitSamplerOverrides[unit], out SamplerState custom) - ? custom with { MaxLevel = texture.State.MaxLevel } - : texture.State; - sampler = _textures.Samplers.Get(sampling); + RenderTrace.Write("sampler '" + declared.Name + "' (" + declared.TypeName + + ") on program " + program.ProgramId + " has texture " + bound + + " of format " + texture.Format + " bound, which it cannot sample; using a placeholder"); } + texture = null; + } + if (texture != null) + { + // MAX_LEVEL belongs to the texture, even when a sampler overrides its filters. + sampling = _standaloneSamplers.TryGetValue(_unitSamplerOverrides[unit], out SamplerState custom) + ? custom with { MaxLevel = texture.State.MaxLevel } + : texture.State; + // The bound depth attachment, sampled with writes off, is read in the + // layout the scope holds it in rather than shader-read-only. + if (_targets.DepthReadOnly && _targets.IsBoundDepth(bound)) layout = ImageLayout.DepthReadOnlyOptimal; } - - // The bound depth attachment, sampled with writes off, is read in - // the layout the scope holds it in rather than shader-read-only. - ImageLayout layout = view.Handle != 0 && _targets.DepthReadOnly - && _targets.IsBoundDepth(_boundTextures[unit]) - ? ImageLayout.DepthReadOnlyOptimal - : ImageLayout.ShaderReadOnlyOptimal; - - bindings[i] = new SamplerBindingValue((uint)declared.Binding, view, sampler, resource, layout); } - // A sampler the client left unbound gets the placeholder rather than - // an empty descriptor. Leaving the set unbound is not an option: the - // shader statically uses set 1, and drawing without it is undefined - // behaviour that costs the device rather than one texture. GL is - // permissive here - sampling an unbound texture reads black and the - // draw proceeds - so the placeholder is also the closer emulation. - for (int i = 0; i < bindings.Length; i++) + if (declared.IsFrameTexture) { - if (bindings[i].View.Handle != 0 && bindings[i].Sampler.Handle != 0) continue; + SamplerBindingValue value = texture == null + ? default + : new SamplerBindingValue((uint)declared.FrameBinding, texture.View, + _textures.Samplers.Get(BindlessKinds.EffectiveState(sampling, kind)), texture.Id, layout); + if (texture == null) VulkanStats.NoteSamplerPlaceholder(); + lock (_frameTextureLock) _frameTextureValues[FrameTextureIndex(declared.FrameBinding)] = value; + continue; + } - VulkanTexture? placeholder = - _textures.Get(PlaceholderFor(program.Interface.Samplers[i].TypeName)); - if (placeholder == null) continue; + uint slot = _bindless!.Resolve(texture, kind, sampling, layout); + VulkanStats.NoteBindlessSlotResolution(); + BitConverter.TryWriteBytes(_pushShadow.AsSpan(declared.PushOffset, ProgramInterfaceLayout.SlotBytes), slot); + } + } - bindings[i] = new SamplerBindingValue( - bindings[i].Binding, placeholder.View, _textures.Samplers.Get(placeholder.State), - placeholder.Id); - } + /// + /// Set 2, built per draw against the shared layout: the program record at its + /// dynamic binding (a ring snapshot when the shadow changed), each named block from + /// the client's UBO snapshot as a std140 storage buffer at the snapshot's offset, + /// each storage block from the mesh's vertex buffer, and the zero-filled placeholder + /// buffer at every binding the program does not read. A set naming a ring offset is + /// new every frame, so it comes from the slot's arena. + /// + private void BindStorageSet(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) + { + SharedPipelineLayout shared = _sharedLayout!; + VulkanBuffer placeholder = _placeholderUniforms!; + var buffers = new BufferBindingValue[SetConvention.StorageSetBindingCount]; + for (int binding = 0; binding < buffers.Length; binding++) + { + buffers[binding] = new BufferBindingValue((uint)binding, placeholder.Handle, 0, placeholder.Size, placeholder.Id); + } + + bool allocationOk = true; + bool namesRingOffset = false; + uint recordOffset = 0; + const int record = SetConvention.ProgramRecordBinding; - bool complete = true; - foreach (SamplerBindingValue binding in bindings) + if (program.Interface.HasUniformBlock) + { + if (program.HasSnapshotFor(_frameCounter)) { - if (binding.View.Handle == 0 || binding.Sampler.Handle == 0) { complete = false; break; } + // Nothing written since this program's last draw this frame took its snapshot. + recordOffset = program.SnapshotOffset; } - - if (complete) + else if (_frames.Current.TryAllocateUniforms(program.UniformShadow.Length, out RingAllocation allocation)) { - DescriptorSet samplerSet = GetDescriptorSet( - new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.SamplerSet, - bindings, Array.Empty()), - program.SetLayouts[ProgramInterfaceLayout.SamplerSet]); - - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.SamplerSet, 1, &samplerSet, 0, null); + fixed (byte* source = program.UniformShadow) + { + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, + program.UniformShadow.Length, program.UniformShadow.Length); + } + recordOffset = allocation.Offset; + program.NoteSnapshot(_frameCounter, allocation.Offset); } - else if (RenderTrace.Enabled) + else { - RenderTrace.Write("draw with an incomplete sampler set on program " + program.ProgramId); + // The draw reads offset zero of the ring, which is some other draw's record: + // wrong, and for a shader that loops on a uniform count, possibly fatal. + allocationOk = false; + ReportUniformExhaustion(program, "its program record"); } + buffers[record] = new BufferBindingValue(record, _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length); } - // Set 2: the storage buffers a shader reads its own vertices from. - // - // The SSBO chunk path does not use vertex attributes at all - the chunk - // shaders declare `readonly buffer faceDataBuf` and index it by - // gl_VertexID, with the packed face records living in the mesh's xyz - // slot. Without this set bound the shader reads nothing and the terrain - // is simply absent, which is exactly how it presented. - if (program.Interface.StorageBlocks.Count > 0 && meshId > 0) + // A block the shader declares is fed by whichever UBO the client created under + // that name; one it has not created yet reads the placeholder's zeroes. + foreach (BlockBinding block in program.Interface.UniformBlocks) { - var storage = new List(program.Interface.StorageBlocks.Count); - foreach (BlockBinding block in program.Interface.StorageBlocks) - { - VulkanBuffer? buffer = _meshes.BufferOf(meshId, MeshManager.BufferXyz); - if (buffer == null) continue; + ClientUniformBuffer? ubo = null; + if (_boundUniformBuffers.TryGetValue(block.BlockName, out int handle)) _uniformBuffers.TryGetValue(handle, out ubo); + if (ubo == null) continue; - storage.Add(new BufferBindingValue( - (uint)block.Binding, buffer.Handle, 0, buffer.Size, buffer.Id)); + if (TrySnapshotClientBlock(ubo, program, out uint blockOffset)) + { + buffers[block.Binding] = new BufferBindingValue((uint)block.Binding, _frames.UniformBuffer, + blockOffset, (ulong)ubo.Shadow.Length); + namesRingOffset = true; + continue; } - if (storage.Count == program.Interface.StorageBlocks.Count) + // No room left in the ring. Rather than aliasing a buffer every remaining draw + // would share - the exact bug the ring exists to fix - this draw gets its own + // transient copy. Counted, so a scene that lives in this path shows in the stats. + allocationOk = false; + VulkanStats.NoteUniformOverflow(); + var overflow = new VulkanBuffer(_context, (ulong)ubo.Shadow.Length, + BufferUsageFlags.UniformBufferBit | BufferUsageFlags.StorageBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + fixed (byte* shadow = ubo.Shadow) { - DescriptorSet storageSet = GetDescriptorSet( - new DescriptorSetContents(program.ProgramId, ProgramInterfaceLayout.StorageSet, - Array.Empty(), storage.ToArray()), - program.SetLayouts[ProgramInterfaceLayout.StorageSet]); + System.Buffer.MemoryCopy(shadow, (void*)overflow.Mapped, ubo.Shadow.Length, ubo.Shadow.Length); + } + buffers[block.Binding] = new BufferBindingValue((uint)block.Binding, overflow.Handle, 0, overflow.Size, overflow.Id); + namesRingOffset = true; + // Released and deferred in that order: no cached set naming it may outlive it. + _descriptors.Release(overflow.Id); + _frames.DeferDeletion(overflow); + } - api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, program.PipelineLayout, - ProgramInterfaceLayout.StorageSet, 1, &storageSet, 0, null); + // The SSBO chunk path reads its vertices from the mesh's xyz buffer by gl_VertexIndex. + foreach (BlockBinding block in program.Interface.StorageBlocks) + { + VulkanBuffer? buffer = meshId > 0 ? _meshes.BufferOf(meshId, MeshManager.BufferXyz) : null; + if (buffer != null) + { + buffers[block.Binding] = new BufferBindingValue((uint)block.Binding, buffer.Handle, 0, buffer.Size, buffer.Id); } else if (RenderTrace.Enabled) { - RenderTrace.Write("draw with an incomplete storage set on program " + program.ProgramId + - " mesh " + meshId); + RenderTrace.Write("storage block '" + block.BlockName + "' on program " + program.ProgramId + + " has no mesh buffer (mesh " + meshId + "); it reads the placeholder"); } } + + _lastUniformAllocationOk = allocationOk; + + var contents = new DescriptorSetContents(0, SetConvention.StorageSet, Array.Empty(), buffers); + DescriptorSet storageSet = namesRingOffset + ? _descriptorArenas[_frames.Current.Index].Get(contents, shared.StorageSetLayout) + : GetDescriptorSet(contents, shared.StorageSetLayout); + if (storageSet.Handle == _boundStorageSet.Handle && recordOffset == _boundRecordOffset) return; + + _context.Api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, shared.Layout, + (uint)SetConvention.StorageSet, 1, &storageSet, 1, &recordOffset); + _boundStorageSet = storageSet; + _boundRecordOffset = recordOffset; + VulkanStats.NoteStorageSetBind(); } private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target, ShaderProgramResources program) @@ -3460,12 +3401,6 @@ public void Dispose() foreach (ShaderProgramResources program in _programs.Values) program.Dispose(); _programs.Clear(); - // After every pipeline layout that named it. - if (_context != null && _frameSetLayout.Handle != 0) - { - _context.Api.DestroyDescriptorSetLayout(_context.Device, _frameSetLayout, null); - _frameSetLayout = default; - } // The shared pipeline layout before the table's set layout it names; the // table's placeholders are textures and go with the texture manager. _sharedLayout?.Dispose(); diff --git a/Optimum.Tests/frame-graph-coverage-tests.cs b/Optimum.Tests/frame-graph-coverage-tests.cs index 780d98d7..8bfbdbe0 100644 --- a/Optimum.Tests/frame-graph-coverage-tests.cs +++ b/Optimum.Tests/frame-graph-coverage-tests.cs @@ -95,7 +95,7 @@ public void TheStatsLineCarriesTheFrameGraphCounters() { string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); Assert.Contains("\"passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} \"", stats); - Assert.Contains("\"standalone_clears={17} pass_splits={18}\"", stats); + Assert.Contains("\"standalone_clears={17} pass_splits={18} push_constants={19} storage_set_binds={20} \"", stats); string doc = Read("docs/taa-acceptance.md"); Assert.Contains("OPTIMUM_VULKAN_FRAMEGRAPH=0", doc); diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 00c67203..757dcd94 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -272,7 +272,7 @@ unchanged from earlier builds; the other six carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= push_constants= storage_set_binds= bindless_slots= bindless_placeholders= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draws_skipped= pending= cache_bytes= saves= @@ -309,7 +309,12 @@ stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draw frame), `in_pass_clears` (clears recorded as vkCmdClearAttachments inside an open pass), `promoted_clears` (clears issued with no pass open that became LOAD_OP_CLEAR) and `standalone_clears` (promoted clears whose image was used before a pass attached it, recorded - as a clear-image command). The colour write tier is on the device-up validation log line; + as a clear-image command). Shared pipeline layout (plan decision 9): `push_constants` + (vkCmdPushConstants calls: draws whose slot indices differed from what the recording last + received), `storage_set_binds` (set 2 binds: the draw's set or its record's dynamic offset + changed), `bindless_slots` (sampler resolutions through the bindless table) and + `bindless_placeholders` (resolutions, bindless or set 0 frame texture, that read a placeholder + because nothing suitable was bound). The colour write tier is on the device-up validation log line; `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. - `stats.transients` (Phase 2 step 4, `TransientAllocator` and `FeedbackCopyPool`): `transient_mib` (at the last frame boundary: the post-chain colour textures of framebuffer slots 2, 3, 4, 7, 8, 9, diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 1a18d4a1..006fbfab 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -126,6 +126,11 @@ family stage. - `OPTIMUM_BINDING_ANIMATION` and `OPTIMUM_BINDING_ANIMATION_PREV` hold the bone matrices, read as storage buffers. - `OPTIMUM_BINDING_PROGRAM_RECORD` (binding 3) is the program record: a dynamic uniform buffer. + - `OPTIMUM_BINDING_NAMED_BLOCK_FIRST`..`_LAST` (4..7) hold a rewritten program's other named blocks, as + std140 storage buffers in declaration order (section 8). Native programs do not use the range. + - Set 2 is written whole: a binding a draw has nothing for holds a zero-filled placeholder buffer. It is + built per draw (a set naming a uniform-ring offset comes from the frame slot's arena) and bound only + when the set or the record's dynamic offset changed. - **Push constants:** one block per program, at most `OPTIMUM_PUSH_CONSTANT_BYTES` (128). ## 4. Placement: push block and program record @@ -352,4 +357,34 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg - **Environment overrides:** - `OPTIMUM_VK_NATIVE_SHADERS=0` forces the rewriter for A/B runs. - `OPTIMUM_VK_SHADER_SOURCE=` compiles the source tree at runtime for the development loop. -- **Mod shaders:** they stay on the rewriter, retargeted to the same shared layout (handoff item 4, first half). +- **Mod shaders:** they stay on the rewriter, retargeted to the same shared layout (handoff item 4, first half, + delivered 2026-09-15). `ShaderProgramResources` owns no set or pipeline layout; every pipeline is built + against `SharedPipelineLayout`. The rewriter (`ProgramInterfaceLayout.Build`, `ShaderRewriter`) emits: + - **Samplers.** A name and type equal to a `SetConvention.FrameTextures` entry reads set 0 at that binding. + Every other sampler becomes `layout(offset = 4n) uint ` in an anonymous + `layout(push_constant, scalar) uniform OptimumDraw` block, in GLSL 330 declaration order (the order + `collectUniformNames` assigns units in), and the stage declares the set 1 arrays it indexes. Every + reference to the sampler in the body becomes `optimumTextures[]`: a sampler can only be a + function argument, so sampling calls (`texture`, `texelFetch`, `textureLod`, `textureGather`, + `textureGrad`, `textureSize`) and samplers handed to the shader's own functions (`getColorMapped`, + FXAA's texture chain, `sampleCatmullRom`) are one rule. A parameter, local or struct member of the same + name shadows the global in its scope and is left alone. (A `sampler2D` local initialised from the array + would have spared the body rewrite, but GLSL allows samplers only as uniforms and function parameters.) + A sampler array, a type set 1 has no array for, or more than 32 slots fails the link. + - **Loose uniforms.** Frame members as before (`FrameGlobals.TryPlace`); every other one is the program + record at set 2 binding 3 (scalar layout, explicit offsets). `LocationOf` keeps its three ranges. + - **Named uniform blocks** become `layout(std140, set = 2, binding = N) readonly buffer`: `Animation` and + `AnimationPrev` at bindings 1 and 2, any other at 4..7 in declaration order, more fails the link. std140 + is forced because a storage buffer defaults to std430, and std140 is what the client's UBO uploads + already stride to. The draw snapshots the client UBO into the uniform ring (which gains STORAGE usage and + aligns to both offset limits) and names the snapshot's offset in set 2. + - **Storage blocks** move to `OPTIMUM_BINDING_FACE_DATA` whatever binding the shader stated: chunkopaque's + `binding = 3` is the record's under the shared layout. A second storage block takes the named-block range. + - **The draw** resolves each sampler: unit, bound texture (a feedback draw's ReadSelf copy in place of the + attachment; a transient rebind through `TextureManager.Get` to the physical texture), the unit's sampler + object over the texture's state, then the bindless slot of the declared kind keyed on + `DEPTH_READ_ONLY_OPTIMAL` when it samples the bound depth attachment with writes off, or the kind's + placeholder (slot 0) when the texture cannot sit behind the kind. Set 1 is bound once per recording, set 0 + only when its set or offset changed (never by a program switch alone), push constants only when the bytes + changed. `stats.counters` reports `push_constants`, `storage_set_binds`, `bindless_slots` and + `bindless_placeholders`. diff --git a/sources/shaders-vk/include/bindings.glsl b/sources/shaders-vk/include/bindings.glsl index be84b8e1..5972b579 100644 --- a/sources/shaders-vk/include/bindings.glsl +++ b/sources/shaders-vk/include/bindings.glsl @@ -8,7 +8,8 @@ // set 0 frame once per frame FrameGlobals UBO (dynamic offset) + frame textures // set 1 textures on create / retire bindless combined-image-sampler arrays, // PARTIALLY_BOUND | UPDATE_AFTER_BIND -// set 2 storage on create / retire FaceData and the animation buffers +// set 2 storage per draw FaceData, the animation buffers, the program record +// and named blocks // push per draw texture slot indices and per-draw scalars // // Indices into the set-1 arrays come from push constants and are uniform over a @@ -86,5 +87,9 @@ layout(set = OPTIMUM_SET_TEXTURES, binding = OPTIMUM_BINDING_TEXTURES_CUBE_SHADO // The program record (docs/vulkan-native-shaders.md section 4): a dynamic uniform // buffer with every non-frame uniform that is not in the push block. #define OPTIMUM_BINDING_PROGRAM_RECORD 3 +// Any other named block a rewritten (mod or GLSL 330) program declares, as a +// layout(std140) readonly storage buffer, in declaration order. More fails the link. +#define OPTIMUM_BINDING_NAMED_BLOCK_FIRST 4 +#define OPTIMUM_BINDING_NAMED_BLOCK_LAST 7 #endif From beaadc11f1207b5e759e2da96cdfaf88e6682085 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:37:42 +0200 Subject: [PATCH 165/226] fix(vulkan): bindless colour placeholders read opaque black, magenta only under poison mode Before the shared layout an unbound or unsuitable sampler read an opaque black placeholder, which is what OpenGL samples from an unbound texture. The bindless table's colour placeholders were magenta for every session, so the retarget would have shown magenta wherever the game leaves a sampler unbound. Poison mode keeps magenta, where an undefined read is meant to be loud. --- .../BindlessTextureTableTests.cs | 17 +++++++++++------ .../Core/BindlessTextureTable.cs | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs index 4b0ce284..f5cefbad 100644 --- a/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs +++ b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs @@ -266,6 +266,7 @@ public void CapacitiesAreTheConventionSizesAtTheFloorAndScaleDownBelowIt() private static readonly byte[] Green = { 0, 255, 0, 255 }; private static readonly byte[] Blue = { 0, 0, 255, 255 }; private static readonly byte[] Magenta = { 255, 0, 255, 255 }; + private static readonly byte[] OpaqueBlack = { 0, 0, 0, 255 }; private const string VertexSource = """ #version 450 @@ -601,7 +602,7 @@ public void ADeletedTexturesSlotHoldsThePlaceholderUntilReusedWhileItsGlIdServes } Assert.True(placeholderFrames >= 3, "the retired slot was freed after " + (6 - placeholderFrames) + " frames"); - Assert.Equal(Magenta, harness.Pixel(retiredTarget)); + Assert.Equal(OpaqueBlack, harness.Pixel(retiredTarget)); Assert.Equal(Green, harness.Pixel(liveTarget)); int blue = harness.Texture(Blue); @@ -653,12 +654,16 @@ public void AShadowSlotComparesAgainstTheStoredDepth() /// /// A texture asked for as a kind it cannot sit behind, or no texture at all, - /// resolves to slot 0 and allocates nothing; slot 0 samples the placeholder. + /// resolves to slot 0 and allocates nothing; slot 0 samples the placeholder: + /// opaque black, as OpenGL reads an unbound texture, and magenta only under + /// poison mode, where an undefined read is meant to be loud. /// - [SkippableFact] - public void AWrongKindRequestResolvesToThePlaceholderSlot() + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void AWrongKindRequestResolvesToThePlaceholderSlot(bool poison) { - Skip.IfNot(TryCreateHarness(false, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); + Skip.IfNot(TryCreateHarness(poison, out Harness? harness, out ShaderCompiler? compiler), "No usable Vulkan device or shaderc."); using (compiler) using (harness) { @@ -684,7 +689,7 @@ public void AWrongKindRequestResolvesToThePlaceholderSlot() harness.Draw(target, 0); device.Present(); - Assert.Equal(Magenta, harness.Pixel(target)); + Assert.Equal(poison ? Magenta : OpaqueBlack, harness.Pixel(target)); GpuTest.AssertClean(device); } } diff --git a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs index 07edad3e..5fe960c3 100644 --- a/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs +++ b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs @@ -15,8 +15,10 @@ namespace Optimum.Render.Vulkan.Core; /// layout (); decides /// which. Slot 0 of every array is that kind's placeholder, and every other slot /// holds the placeholder until it is allocated and again once it is freed, so a -/// stale or out-of-date index samples magenta (or a far-plane depth, or an -/// integer texel) instead of undefined memory. +/// stale or out-of-date index samples a defined value instead of undefined memory: +/// opaque black for colour kinds, as OpenGL reads an unbound texture (magenta under +/// poison mode, where an undefined read is meant to be loud), a far-plane depth for +/// shadow kinds, a fixed texel for integer kinds. /// /// Writes are queued and applied by in one /// vkUpdateDescriptorSets: at frame start (, which first @@ -28,7 +30,13 @@ namespace Optimum.Render.Vulkan.Core; /// internal sealed unsafe class BindlessTextureTable : IDisposable { - /// Placeholder colour for float and depth-less colour arrays: loud, as poison mode is. + /// + /// Placeholder colour for colour arrays: opaque black, what OpenGL samples from an unbound + /// texture and what the per-program placeholders read before the shared layout. + /// + private static readonly byte[] OpaqueBlack = { 0, 0, 0, 255 }; + + /// The colour placeholder under poison mode, where an undefined read is meant to be loud. private static readonly byte[] Magenta = { 255, 0, 255, 255 }; private readonly record struct PendingWrite(TextureKind Kind, uint Slot, ImageView View, Sampler Sampler, ImageLayout Layout); @@ -324,7 +332,7 @@ private DescriptorSet AllocateSet() /// private void CreatePlaceholders() { - fixed (byte* magenta = Magenta) + fixed (byte* magenta = _context.PoisonFreshResources ? Magenta : OpaqueBlack) { _placeholders[(int)TextureKind.Texture2D] = Colour(_textures.Create(1, 1, Format.R8G8B8A8Unorm), 1, magenta); // A single-layer texture gets a 2D view; an array view needs two layers. From b2ea63bb740dbe36c75651b36fed3cb247c6793a Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:40:39 +0200 Subject: [PATCH 166/226] wip(native-shaders): entities - entityanimated, shadowmapentityanimated, standard, instanced 4 programs, 37 variants (entityanimated 16 incl. USEOIT for Entityanimated_Oit, standard 16, instanced 4, shadowmapentityanimated 1), 74 SPIR-V modules. Decisions recorded in docs/vulkan-native-shaders.md section 9, family entities. Verified: NativeShaderParityTests 15/15 (spirv-val on PATH), full Optimum.Render.Vulkan.Tests 866/866, Optimum.Tests -c Release 1184 passed, 34 skipped, 0 failed. --- docs/vulkan-native-shaders.md | 33 ++++ sources/shaders-vk/entityanimated.frag | 179 ++++++++++++++++++ .../shaders-vk/entityanimated.interface.glsl | 66 +++++++ sources/shaders-vk/entityanimated.vert | 149 +++++++++++++++ sources/shaders-vk/instanced.frag | 79 ++++++++ sources/shaders-vk/instanced.interface.glsl | 29 +++ sources/shaders-vk/instanced.vert | 99 ++++++++++ .../shaders-vk/shadowmapentityanimated.frag | 17 ++ .../shadowmapentityanimated.interface.glsl | 16 ++ .../shaders-vk/shadowmapentityanimated.vert | 35 ++++ sources/shaders-vk/standard.frag | 158 ++++++++++++++++ sources/shaders-vk/standard.interface.glsl | 75 ++++++++ sources/shaders-vk/standard.vert | 138 ++++++++++++++ 13 files changed, 1073 insertions(+) create mode 100644 sources/shaders-vk/entityanimated.frag create mode 100644 sources/shaders-vk/entityanimated.interface.glsl create mode 100644 sources/shaders-vk/entityanimated.vert create mode 100644 sources/shaders-vk/instanced.frag create mode 100644 sources/shaders-vk/instanced.interface.glsl create mode 100644 sources/shaders-vk/instanced.vert create mode 100644 sources/shaders-vk/shadowmapentityanimated.frag create mode 100644 sources/shaders-vk/shadowmapentityanimated.interface.glsl create mode 100644 sources/shaders-vk/shadowmapentityanimated.vert create mode 100644 sources/shaders-vk/standard.frag create mode 100644 sources/shaders-vk/standard.interface.glsl create mode 100644 sources/shaders-vk/standard.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 22d93014..bbaa789d 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -438,3 +438,36 @@ Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage its differential GPU test in its stage. 7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. + +### Family: entities (`entityanimated`, `shadowmapentityanimated`, `standard`, `instanced`; 2026-09-15) + +Axes and variants: `entityanimated` ALLOWDEPTHOFFSET, GBUFFER, TAAMOTION, USEOIT (16; `Entityanimated_Oit` is +USEOIT=1); `standard` ALLOWDEPTHOFFSET, GBUFFER, GLOWSUB, TAAMOTION (16); `instanced` GBUFFER, TAAMOTION (4); +`shadowmapentityanimated` none (1). No oracle quirk (section 2) was met. Decisions: + +- **`#if defined(X)` on a per-registration define** (`ALLOWDEPTHOFFSET`, `GLOWSUB`) becomes `#if X == 1`; the + nested `#if ALLOWDEPTHOFFSET > 0` folds into it, since every variant defines the axis as 0 or 1. +- **Animation buffers:** `Animation`/`AnimationPrev` are `readonly buffer`s at `OPTIMUM_BINDING_ANIMATION`/ + `_PREV`, std140, keeping the instance names `ElementTransforms`/`PrevElementTransforms` and the member + `values`, declared as a runtime array `mat4 values[]`: MAXANIMATEDELEMENTS is not a native define, and the + index (`jointId`) is unchanged. +- **`AnimationPrev` exists only for TAAMOTION=1, USEOIT=0.** The GLSL 330 vertex stage declares it for every + TAAMOTION program, but the client creates the UBO only for the opaque program + (`ShaderProgramEntityanimated`, `!Oit && EffectiveTaa`), and the OIT fragment stage never reads `taaPrevClip`. + The OIT variant therefore writes `taaPrevClip = vec4(0.0)` and has no binding 2, so the runtime need not bind a + buffer the client never made. No pixel changes: nothing reads that varying under USEOIT=1. +- **Placement:** push and record are identical in every variant of a program (the oracle's names do not depend + on defines). + - `entityanimated`: push = `entityTex`, `addRenderFlags`, `extraGlow`, `taaHistoryValid`, `taaReactive`, + `entityId`, `glitchFlicker` (28 B). Record = everything else (568 B). + - `standard`: push = `tex`, `tex2dOverlay`, the nine integer flags and `taaReactive` (48 B). Record 628 B. + - `instanced`: push = `tex` (every per-object value is an instance attribute). Record 328 B, including + `windWaveCounter`: its vertex stage includes no `vertexwarp.glsl`, so it has no owner in this program. + - `shadowmapentityanimated`: push = `entityTex`, `modelViewMatrix` (per entity in the shadow pass), + `addRenderFlags` (72 B). Record = `projectionMatrix`. +- **Open for the runtime stage (not worked around here):** `EntityShapeRenderer` sets `windWaveIntensity` and + `waterWaveCounter` per entity (uniform-frequency map, section 3.2). In `entityanimated` both are frame members + (the program includes `vertexwarp.glsl`), so a native program reads the frame value unless the runtime treats + a per-draw write to an owned frame name specially. The GLSL 330 path honours the override. +- **`instanced.fsh`'s `in float normalShadeIntensity`** is written by no vertex stage and read by nothing. It + keeps a location of its own (11), and the optimised module drops it. diff --git a/sources/shaders-vk/entityanimated.frag b/sources/shaders-vk/entityanimated.frag new file mode 100644 index 00000000..172f0d79 --- /dev/null +++ b/sources/shaders-vk/entityanimated.frag @@ -0,0 +1,179 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of entityanimated.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// +// Axes: USEOIT (oit.glsl's six outputs instead of the opaque set), GBUFFER (SSAOLEVEL > 0), TAAMOTION and +// ALLOWDEPTHOFFSET (the first-person hands' gl_FragDepth write). SHADOWQUALITY, NORMALVIEW and SHINYEFFECT gate +// no declaration and are specialization-constant branches with the same expressions. +// +// The vertex stage includes fogandlight.vert.glsl and vertexwarp.glsl, so the names fogandlight.frag.glsl and +// this body read from those owners (flatFogDensity, viewDistance, fogSpheres, windWaveCounter, ...) are frame +// members here (section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "entityanimated.interface.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec4 color; +layout(location = 2) in vec4 rgbaFog; +layout(location = 3) in float fogAmount; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 4) in vec3 vertexPosition; +layout(location = 9) flat in int renderFlags; +layout(location = 11) in vec3 normal; +layout(location = 5) in vec4 worldPos; +layout(location = OPTIMUM_LOCATION_BLOCK_LIGHT) in vec3 blockLight; +layout(location = 7) in vec4 camPos; +layout(location = 6) in float damageEffect; +layout(location = 8) in float fragFrostAlpha; + +// Our include system is dumb and does not do conditional includes +// So we add a OIT preprocceor test to oit.fsh as well +#include "oit.glsl" + +#if USEOIT == 0 + layout(location = 0) out vec4 outColor; + layout(location = 1) out vec4 outGlow; + #if GBUFFER == 1 + layout(location = 12) in vec4 fragPosition; + layout(location = 13) in vec4 gnormal; + layout(location = 2) out vec4 outGNormal; + layout(location = 3) out vec4 outGPosition; + #endif +#endif + +// TAA motion vectors (Optimum P3); see chunkopaque.fsh for the contract. +// The alpha channel is this fragment's WINDOW depth, which for the first-person +// hand and item programs is gl_FragCoord.z + depthOffset, not gl_FragCoord.z - +// they write gl_FragDepth below, and the resolve compares what it finds here +// against the depth buffer. Writing the un-offset value would make the resolve +// reject every hand pixel and fall back to camera reprojection on the one class +// of geometry whose motion differs most from the camera's. +#if TAAMOTION == 1 +layout(location = 14) in vec4 taaPrevClip; +#if USEOIT == 0 +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#include "motion.glsl" +#endif +#endif + +#include "vertexflagbits.glsl" +#include "fogandlight.frag.glsl" +#include "noise3d.glsl" +#include "noise2d.glsl" +#include "underwatereffects.glsl" + +void main() { + float b = 1; + + if (damageEffect > 0) { + float f = cnoise2(floor(vec2(uv.x, uv.y) * 4096) / 4); + if (f < damageEffect - 1.3) discard; + b = min(1, f * 1.5 + 0.65 + (1-damageEffect)); + } + + vec4 texColor = texture(optimumTextures2D[entityTex], uv); + + // Declared before the branch that assigns it (contract section 5); every path overwrites the 0. + float intensity = 0.0; + if (OPTIMUM_SHADOWQUALITY > 0) { + intensity = 0.34 + (1 - shadowIntensity)/8.0; // this was 0.45, which makes shadow acne visible on blocks + } else { + intensity = 0.45; + } + + + //float seed = mod(entityId, 1000) / 5.0; - this is broken on NVIDIA cards O_O + int eidfloor = (entityId / 100) * 100; + float seed = (entityId - eidfloor) / 5.0; + + texColor = applyFrostEffect(fragFrostAlpha, texColor, normal, vertexPosition + vec3(seed)); + if (psychedelicStrength > Epsilon) texColor = applyPsychedelicEffect(texColor, vertexPosition, 0); + if (glitchStrength > Epsilon) texColor = applyRustEffect(texColor, normal, vertexPosition + vec3(seed), 0); + + texColor *= color; + texColor.rgb *= b; + +#if USEOIT == 1 + vec4 outColor; +#endif + + float murkiness=getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadowWithNormal(texColor, 0, normal, 1, intensity, worldPos.xyz); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadowWithNormal(texColor, fogAmount, normal, 1, intensity, worldPos.xyz); + } + + + if (glitchFlicker >0 && glitchEffectStrength > 0) { + float g = gnoise(vec3(gl_FragCoord.y / 2.0, gl_FragCoord.x / 2.0, windWaveCounter*30 + entityId * 3)); + outColor.a *= mix(1, clamp(0.7 + g / 2, 0, 1), glitchEffectStrength); + + float b = gnoise(vec3(0, 0, windWaveCounter*60 + entityId * 3)); + outColor.a *= mix(1, clamp(b * 10 + 2, 0, 1), glitchEffectStrength); + } + + if (OPTIMUM_NORMALVIEW == 0) { + if (outColor.a < alphaTest) discard; + } + + + + float glow = 0; + if (OPTIMUM_SHINYEFFECT > 0) { + outColor = mix(applyReflectiveEffect(outColor, glow, renderFlags, uv, normal, worldPos, camPos, vec3(1)), outColor, min(1, 2 * fogAmount)); + } + +#if USEOIT == 0 && GBUFFER == 1 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = vec4(gnormal.xyz, 0); +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + + + +#if USEOIT == 1 + OIT(outColor, glowLevel+glow); +#else + outGlow = vec4(glowLevel + glow, 0, 0, color.a); +#endif + + + +#if ALLOWDEPTHOFFSET == 1 + // This likely tanks performance in any other scenario so we do only only for the first person mode rendering. See also https://www.khronos.org/opengl/wiki/Early_Fragment_Test#Limitations + gl_FragDepth = gl_FragCoord.z + depthOffset; + + // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it + #if USEOIT == 0 && GBUFFER == 1 + outGPosition.w=1; + #endif + +#endif + + +#if TAAMOTION == 1 && USEOIT == 0 + // Opaque skinned entities are not reactive on their own; the C# side raises + // taaReactive to 1 for a draw whose per-entity history was unusable, where + // the vector above is camera-only and the history must not be trusted. + #if ALLOWDEPTHOFFSET == 1 + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, taaReactive, clamp(gl_FragCoord.z + depthOffset, 0.0, 1.0)); + #else + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, taaReactive, gl_FragCoord.z); + #endif +#endif +} diff --git a/sources/shaders-vk/entityanimated.interface.glsl b/sources/shaders-vk/entityanimated.interface.glsl new file mode 100644 index 00000000..c30bc22d --- /dev/null +++ b/sources/shaders-vk/entityanimated.interface.glsl @@ -0,0 +1,66 @@ +// Program interface of entityanimated (docs/vulkan-native-shaders.md section 4), shared by the opaque +// program, Entityanimated_Oit (USEOIT axis) and the first-person hands (ALLOWDEPTHOFFSET axis). +// +// Entity placement (section 4, about 280 B of DRAW data per entity): the push block holds the sampler slot, +// then the small per-entity integers and the TAA flags in declaration order (vertex stage first). The record +// holds the matrices, the colours and every remaining scalar: entityanimated.vsh's uniforms, the prev* +// uniforms of vertexwarp.glsl, then entityanimated.fsh's uniforms and underwatereffects.glsl's frameSize, each +// in declaration order. Names the TAAMOTION, USEOIT and ALLOWDEPTHOFFSET blocks declared are declared +// unconditionally: collectUniformNames reads the unpreprocessed text, so every variant has all of them. +// +// A block member cannot carry the GLSL 330 initializers (frostAlpha = 0, taaHistoryValid = 0, +// taaReactive = 0.0, alphaTest = 0.001, and vertexwarp's prev* defaults); the runtime seeds them +// (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, entityTex); + + int addRenderFlags; + int extraGlow; + int taaHistoryValid; + + float taaReactive; + int entityId; + int glitchFlicker; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec3 rgbaAmbientIn; + vec4 rgbaLightIn; + vec4 rgbaFogIn; + float fogMinIn; + float fogDensityIn; + vec4 renderColor; + float frostAlpha; + mat4 projectionMatrix; + mat4 viewMatrix; + mat4 modelMatrix; + int skipRenderJointId; + int skipRenderJointId2; + mat4 prevProjectionMatrix; + mat4 prevViewMatrix; + mat4 prevModelMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 taaRenderSize; + vec2 taaJitterPx; + float alphaTest; + float glitchEffectStrength; + float depthOffset; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/entityanimated.vert b/sources/shaders-vk/entityanimated.vert new file mode 100644 index 00000000..5fd8bbf3 --- /dev/null +++ b/sources/shaders-vk/entityanimated.vert @@ -0,0 +1,149 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of entityanimated.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// +// Axes: TAAMOTION (the previous-position varying and the AnimationPrev buffer), GBUFFER (SSAOLEVEL > 0: the +// G-buffer varyings) and USEOIT. The GLSL 330 vertex stage does not test USEOIT, but the client creates the +// AnimationPrev UBO only for the opaque program (ShaderProgramEntityanimated: `!Oit && EffectiveTaa`), and the +// OIT fragment stage never reads taaPrevClip. So the buffer and the previous-position reconstruction are +// compiled only for TAAMOTION == 1 && USEOIT == 0; the OIT variant writes a taaPrevClip nothing reads. +// +// The Animation blocks are storage buffers at set 2 (section 3) with the same members; the bone array is a +// runtime array because MAXANIMATEDELEMENTS is no longer a define. Its index (jointId) is unchanged. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "entityanimated.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in int flags; +layout(location = 4) in float damageEffectIn; +layout(location = 5) in int jointId; + +// UBO:Animation,0,4800 +layout(std140, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_ANIMATION) readonly buffer Animation +{ + mat4 values[]; +} ElementTransforms; + +#if TAAMOTION == 1 +#if USEOIT == 0 +// UBO:AnimationPrev,1,4800 +layout(std140, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_ANIMATION_PREV) readonly buffer AnimationPrev +{ + mat4 values[]; +} PrevElementTransforms; +#endif + +layout(location = 14) out vec4 taaPrevClip; +#endif + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec4 color; +layout(location = 2) out vec4 rgbaFog; +layout(location = 3) out float fogAmount; +layout(location = 4) out vec3 vertexPosition; +layout(location = 5) out vec4 worldPos; +layout(location = 6) out float damageEffect; +layout(location = 7) out vec4 camPos; +layout(location = 8) out float fragFrostAlpha; +layout(location = 9) flat out int renderFlags; + +layout(location = 10) out vec4 glPos; + +layout(location = 11) out vec3 normal; +#if GBUFFER == 1 +layout(location = 12) out vec4 fragPosition; +layout(location = 13) out vec4 gnormal; +#endif + + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" + +void main(void) +{ + damageEffect = damageEffectIn; + mat4 animModelMat = modelMatrix * ElementTransforms.values[jointId]; + worldPos = animModelMat * vec4(vertexPositionIn, 1.0); + + renderFlags = flags | addRenderFlags; + + if ((renderFlags & WindModeFruitMask) > 0) { + fragFrostAlpha = frostAlpha / 4; + renderFlags &= ~WindModeFruitMask; + } else fragFrostAlpha = frostAlpha; + + if ((renderFlags & WindModeWaterMask) > 0) { + worldPos = applyLiquidWarping(true, worldPos, 5); + } else { + worldPos = applyVertexWarping(renderFlags, worldPos); + } + worldPos = applyGlobalWarping(worldPos); + + +#if TAAMOTION == 1 +#if USEOIT == 0 + // Placed here, before the local `int renderFlags = extraGlow + flags;` below + // shadows the flat output: the warp branch has to see the same flags the + // current position was warped with, fruit-mask clearing included. + { + vec4 taaPrevWorld; + if (taaHistoryValid != 0) { + mat4 taaPrevAnimMat = prevModelMatrix * PrevElementTransforms.values[jointId]; + taaPrevWorld = taaPrevAnimMat * vec4(vertexPositionIn, 1.0); + WarpState taaPrev = previousWarpState(); + if ((renderFlags & WindModeWaterMask) > 0) { + taaPrevWorld = applyLiquidWarpingState(taaPrev, true, taaPrevWorld, 5); + } else { + taaPrevWorld = applyVertexWarpingState(taaPrev, renderFlags, taaPrevWorld); + } + taaPrevWorld = applyGlobalWarpingState(taaPrev, taaPrevWorld); + } else { + // Treat the surface as static in the world: its camera-relative position + // a frame ago differed by the camera's own movement only (accuracy rule 4). + taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0); + } + taaPrevClip = prevProjectionMatrix * (prevViewMatrix * taaPrevWorld); + } +#else + // Entityanimated_Oit: no AnimationPrev buffer, and its fragment stage never reads the varying. + taaPrevClip = vec4(0.0); +#endif +#endif + + vertexPosition = vertexPositionIn.xyz * 1.5; + + vec4 cameraPos = camPos = viewMatrix * worldPos; + + uv = uvIn; + int renderFlags = extraGlow + flags; + color = renderColor * colorIn * applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos); + rgbaFog = rgbaFogIn; + + // Distance fade out + color.a *= clamp(20 * (1.05 - length(worldPos.xz) / viewDistance) - 5, -1, 1); + + gl_Position = projectionMatrix * cameraPos; + calcShadowMapCoords(viewMatrix, worldPos); + + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + normal = unpackNormal(renderFlags); + normal = (animModelMat * vec4(normal.x, normal.y, normal.z, 0)).xyz; + + #if GBUFFER == 1 + fragPosition = cameraPos; + gnormal = viewMatrix * vec4(normal, 0); + #endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/instanced.frag b/sources/shaders-vk/instanced.frag new file mode 100644 index 00000000..fc192810 --- /dev/null +++ b/sources/shaders-vk/instanced.frag @@ -0,0 +1,79 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of instanced.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: GBUFFER (SSAOLEVEL > 0) and TAAMOTION. NORMALVIEW gates no declaration and is a +// specialization-constant branch. +// +// The vertex stage includes fogandlight.vert.glsl, so flatFogDensity, viewDistance and the fog spheres are frame +// members here (section 3, cross-stage owners); windWaveCounter has no owner in this program and is a record +// member. +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "instanced.interface.glsl" + +layout(location = 0) in vec4 color; +layout(location = 1) in vec2 uv; +layout(location = 2) in vec4 rgbaFog; +layout(location = 3) in float fogAmount; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 8) flat in int renderFlags; +layout(location = 4) in vec3 normal; +layout(location = 5) in vec4 worldPos; +// Never written by instanced.vsh and never read here (GLSL 330 links it as an unused input); the optimised +// module drops it. +layout(location = 11) in float normalShadeIntensity; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 6) in vec4 fragPosition; +layout(location = 7) in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + + +#if TAAMOTION == 1 +layout(location = 9) in vec4 taaPrevClip; +layout(location = 10) in float taaInstanceReactive; +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#include "motion.glsl" +#endif + +#include "fogandlight.frag.glsl" + +void main () { + outColor = texture(optimumTextures2D[tex], uv) * color; + if (outColor.a < alphaTest) discard; + + outColor = applyFogAndShadowWithNormal(outColor, fogAmount, normal, 1, 0.45, worldPos.xyz); + + //outColor = vec4((normal.x + 0.5) / 2, (normal.y + 0.5)/2, (normal.z+0.5)/2, 1); + + outGlow = vec4(glowLevel, 0, 0, outColor.a); + +#if GBUFFER == 1 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = gnormal; +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + +#if TAAMOTION == 1 + // Mechanical blocks are opaque and not reactive on their own; the C# side + // stamps reactive 1 per instance when its history was unusable, where the + // vector above is camera-only and the history must not be trusted. + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, taaInstanceReactive, gl_FragCoord.z); +#endif + +} diff --git a/sources/shaders-vk/instanced.interface.glsl b/sources/shaders-vk/instanced.interface.glsl new file mode 100644 index 00000000..7fcc8c62 --- /dev/null +++ b/sources/shaders-vk/instanced.interface.glsl @@ -0,0 +1,29 @@ +// Program interface of instanced (docs/vulkan-native-shaders.md section 4). Every per-object value travels as +// an instance attribute (locations 4-13), and the uniforms are set once per Use(), so the push block holds only +// the sampler slot and every uniform is a record member: instanced.vsh's, then instanced.fsh's, then +// fogandlight.frag.glsl's windWaveCounter (no vertexwarp owner in this program), each in declaration order. +// +// A block member cannot carry alphaTest's GLSL 330 initializer (0.1); the runtime seeds it (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogMinIn; + float fogDensityIn; + mat4 projectionMatrix; + mat4 modelViewMatrix; + mat4 prevProjectionMatrix; + mat4 prevModelViewMatrix; + vec3 cameraPosDelta; + + float alphaTest; + vec2 taaRenderSize; + vec2 taaJitterPx; + + float windWaveCounter; +}; diff --git a/sources/shaders-vk/instanced.vert b/sources/shaders-vk/instanced.vert new file mode 100644 index 00000000..e140f40c --- /dev/null +++ b/sources/shaders-vk/instanced.vert @@ -0,0 +1,99 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of instanced.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: TAAMOTION (the previous-transform instance attributes and varyings) and GBUFFER (SSAOLEVEL > 0). +// The per-instance attributes keep their GLSL 330 locations 4-13. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "instanced.interface.glsl" + +layout(location = 0) in vec3 vertexPosition; // Per vertex +layout(location = 1) in vec2 uvIn; // Per vertex +layout(location = 2) in vec4 rgbaBlockIn; // Per vertex (rgb = block light, a=sun light level) +layout(location = 3) in int renderFlagsIn; // Per vertex + +layout(location = 4) in vec4 rgbaLightIn; // Per instance +layout(location = 5) in mat4 transform; // Per instance + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION == 1 +layout(location = 9) in mat4 prevTransform; // Per instance: this device's transform last frame +layout(location = 13) in vec4 taaInstanceMeta; // Per instance: x = history usable, y = reactive +#endif + +layout(location = 0) out vec4 color; +layout(location = 1) out vec2 uv; +layout(location = 2) out vec4 rgbaFog; +layout(location = 3) out float fogAmount; +layout(location = 4) out vec3 normal; +layout(location = 5) out vec4 worldPos; + +#if GBUFFER == 1 +layout(location = 6) out vec4 fragPosition; +layout(location = 7) out vec4 gnormal; +#endif + +layout(location = 8) flat out int renderFlags; + +#if TAAMOTION == 1 +layout(location = 9) out vec4 taaPrevClip; +layout(location = 10) out float taaInstanceReactive; +#endif + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main() +{ + worldPos = transform * vec4(vertexPosition, 1.0); + vec4 cameraPos = modelViewMatrix * worldPos; + +#if TAAMOTION == 1 + // The same vertex, one frame ago. instanced.vsh applies no vertex warp and no + // w-offset, so the previous position is the previous instance transform run + // through the previous camera - nothing else has to be replayed. + { + vec4 taaPrevWorld; + if (taaInstanceMeta.x != 0.0) { + taaPrevWorld = prevTransform * vec4(vertexPosition, 1.0); + } else { + // Treat the block as static in the world: its camera-relative position + // a frame ago differed by the camera's own movement only (accuracy rule 4). + taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0); + } + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevWorld); + taaInstanceReactive = taaInstanceMeta.y; + } +#endif + + + calcShadowMapCoords(modelViewMatrix, worldPos); + + uv = uvIn; + color = applyLight(rgbaAmbientIn, rgbaLightIn * rgbaBlockIn, renderFlagsIn, cameraPos); + rgbaFog = rgbaFogIn; + + // Distance fade out + color.a = clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5, -1, 1); + gl_Position = projectionMatrix * cameraPos; + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + renderFlags = renderFlagsIn; + + normal = unpackNormal(renderFlagsIn); + normal = normalize((transform * vec4(normal.x, normal.y, normal.z, 0)).xyz); + + #if GBUFFER == 1 + fragPosition = cameraPos; + gnormal = modelViewMatrix * vec4(normal.xyz, 0); + #endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/shadowmapentityanimated.frag b/sources/shaders-vk/shadowmapentityanimated.frag new file mode 100644 index 00000000..17c79845 --- /dev/null +++ b/sources/shaders-vk/shadowmapentityanimated.frag @@ -0,0 +1,17 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of shadowmapentityanimated.fsh (vanilla asset, docs/vulkan-native-shaders.md). No variant axes. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "shadowmapentityanimated.interface.glsl" + +layout(location = 0) in vec2 uv; + +layout(location = 0) out vec4 outColor; + +void main () { + outColor = vec4(texture(optimumTextures2D[entityTex], uv)); + if (outColor.a < 0.01) discard; +} diff --git a/sources/shaders-vk/shadowmapentityanimated.interface.glsl b/sources/shaders-vk/shadowmapentityanimated.interface.glsl new file mode 100644 index 00000000..de27ce3d --- /dev/null +++ b/sources/shaders-vk/shadowmapentityanimated.interface.glsl @@ -0,0 +1,16 @@ +// Program interface of shadowmapentityanimated (docs/vulkan-native-shaders.md section 4). The shadow pass +// sets modelViewMatrix per entity (EntityShapeRenderer's isShadowPass branch) and addRenderFlags; with the +// sampler slot they fit the push block (72 B). projectionMatrix is set once per shadow map and goes to the +// record. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, entityTex); + + mat4 modelViewMatrix; + int addRenderFlags; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; +}; diff --git a/sources/shaders-vk/shadowmapentityanimated.vert b/sources/shaders-vk/shadowmapentityanimated.vert new file mode 100644 index 00000000..3f035d52 --- /dev/null +++ b/sources/shaders-vk/shadowmapentityanimated.vert @@ -0,0 +1,35 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of shadowmapentityanimated.vsh (vanilla asset, docs/vulkan-native-shaders.md). No variant axes. +// The Animation block is a storage buffer at set 2 (section 3) with the same member; the bone array is a +// runtime array because MAXANIMATEDELEMENTS is no longer a define. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "shadowmapentityanimated.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in int flags; +layout(location = 4) in float damageEffectIn; +layout(location = 5) in int jointId; + +// UBO:Animation,0,4800 +layout(std140, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_ANIMATION) readonly buffer Animation +{ + mat4 values[]; +} ElementTransforms; + +layout(location = 0) out vec2 uv; + +void main(void) +{ + vec4 cameraPos = modelViewMatrix * ElementTransforms.values[jointId] * vec4(vertexPositionIn, 1.0); + uv = uvIn; + gl_Position = projectionMatrix * cameraPos; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/standard.frag b/sources/shaders-vk/standard.frag new file mode 100644 index 00000000..ee71415f --- /dev/null +++ b/sources/shaders-vk/standard.frag @@ -0,0 +1,158 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of standard.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: GBUFFER (SSAOLEVEL > 0), TAAMOTION and ALLOWDEPTHOFFSET (`#if defined(ALLOWDEPTHOFFSET)` with +// `ALLOWDEPTHOFFSET > 0` inside becomes `#if ALLOWDEPTHOFFSET == 1`). BLOOM, NORMALVIEW and SHINYEFFECT gate +// no declaration and are specialization-constant branches with the same expressions; the G-buffer write the +// GLSL 330 source guards with a second `#if SSAOLEVEL > 0` is behind the GBUFFER axis, which is that test. +// +// The vertex stage includes fogandlight.vert.glsl and vertexwarp.glsl, so the names fogandlight.frag.glsl reads +// from those owners (flatFogDensity, viewDistance, fogSpheres, windWaveCounter, ...) are frame members here +// (section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "standard.interface.glsl" + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 9) in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +#if TAAMOTION == 1 +layout(location = 10) in vec4 taaPrevClip; +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#include "motion.glsl" +#endif + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec4 color; +layout(location = 2) in vec4 rgbaFog; +layout(location = 4) in float fogAmount; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 3) in vec4 rgbaGlow; +layout(location = 5) in vec4 camPos; +layout(location = 6) in vec4 worldPos; +layout(location = 8) in vec3 normal; +layout(location = 7) flat in int renderFlags; + + +#include "fogandlight.frag.glsl" +#include "noise2d.glsl" +#include "underwatereffects.glsl" + +void main() { + float b = 1; + + if (damageEffect > 0) { + float f = cnoise2(floor(vec2(uv.x, uv.y) * 4096) / 4); + if (f < damageEffect - 1.3) discard; + b = min(1, f * 1.5 + 0.65 + (1-damageEffect)); + } + + if (overlayOpacity > 0) { + vec2 uvOverlay = (uv - baseUvOrigin) * (baseTextureSize / overlayTextureSize); + + vec4 col1 = texture(optimumTextures2D[tex2dOverlay], uvOverlay); + vec4 col2 = texture(optimumTextures2D[tex], uv); + + float a1 = overlayOpacity * col1.a * min(1, col2.a * 100); + float a2 = col2.a * (1 - a1); + + outColor = vec4( + (a1 * col1.r + col2.r * a2) / (a1+a2), + (a1 * col1.b + col2.g * a2) / (a1+a2), + (a1 * col1.g + col2.b * a2) / (a1+a2), + a1 + a2 + ) * color; + + } else { + outColor = texture(optimumTextures2D[tex], uv) * color; + } + + if (OPTIMUM_BLOOM == 0) { + outColor.rgb *= 1 + glowLevel; + } + + if (tempGlowMode == 1) { + float f = (averageColor.r+averageColor.g+averageColor.b) / (rgbaGlow.r+rgbaGlow.g+rgbaGlow.b); + f=max(f,0.6); + // Use multiply so some texture is still visible, use 'f' to adjust to same brightness + outColor.rgb = mix(outColor.rgb, outColor.rgb * rgbaGlow.rgb / f, min(1.5, glowLevel*2)); + + } else { + outColor.rgb = mix(outColor.rgb, rgbaGlow.rgb, glowLevel * rgbaGlow.a); + } + + if (normalShaded > 0) { + float b = min(1, getBrightnessFromNormal(normal, 1, 0.45) + min(0.5, glowLevel)); + outColor *= vec4(b, b, b, 1); + } + + float murkiness=skyShaded > 0 ? getSkyMurkiness() : getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadow(outColor, 0); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadow(outColor, fogAmount); + } + + if (OPTIMUM_NORMALVIEW == 0) { + if (outColor.a < alphaTest) discard; + } + + float glow = 0; + if (OPTIMUM_SHINYEFFECT > 0) { + outColor = mix(applyReflectiveEffect(outColor, glow, renderFlags, uv, normal, worldPos, camPos, vec3(1)), outColor, min(1, 2 * fogAmount)); + glow = pow(max(0.0, dot(normal, lightPosition)), 6) / 8 * shadowIntensity * (1 - fogAmount); + } + +#if GBUFFER == 1 + if (applySsao > 0) { + outGPosition = vec4(camPos.xyz, fogAmount + glowLevel); + } else { + outGPosition = vec4(camPos.xyz, 1); + } + outGNormal = vec4(gnormal.xyz, ssaoAttn); + +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + + outColor.rgb *= b; + outGlow = vec4(glowLevel + glow, extraGodray - fogAmount, 0, outColor.a); + +#if ALLOWDEPTHOFFSET == 1 + // This likely tanks performance in any other scenario so we do only only for the first person mode rendering. See also https://www.khronos.org/opengl/wiki/Early_Fragment_Test#Limitations + gl_FragDepth = gl_FragCoord.z + depthOffset; + + // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it + #if GBUFFER == 1 + outGPosition.w=1; + #endif +#endif + +#if TAAMOTION == 1 + // Items and block-entity models are not reactive on their own; the C# side + // raises taaReactive to 1 for a draw whose per-object history was unusable, + // where the vector above is camera-only and the history must not be trusted. + #if ALLOWDEPTHOFFSET == 1 + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, taaReactive, clamp(gl_FragCoord.z + depthOffset, 0.0, 1.0)); + #else + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, taaReactive, gl_FragCoord.z); + #endif +#endif +} diff --git a/sources/shaders-vk/standard.interface.glsl b/sources/shaders-vk/standard.interface.glsl new file mode 100644 index 00000000..f8fd4ce9 --- /dev/null +++ b/sources/shaders-vk/standard.interface.glsl @@ -0,0 +1,75 @@ +// Program interface of standard (docs/vulkan-native-shaders.md section 4). Held items, dropped items and +// block-entity models: at most a few draws per Use(), so the push block holds the two sampler slots (tex, then +// tex2dOverlay, the GLSL 330 unit order) and the integer flags plus taaReactive, in declaration order (vertex +// stage first). The record holds everything else: standard.vsh's uniforms, vertexwarp.glsl's prev* uniforms, +// standard.fsh's uniforms and underwatereffects.glsl's frameSize, each in declaration order. Names inside the +// TAAMOTION and ALLOWDEPTHOFFSET blocks are declared unconditionally (collectUniformNames reads the +// unpreprocessed text). +// +// A block member cannot carry the GLSL 330 initializers (taaHistoryValid = 0, applySsao = 1, +// taaReactive = 0.0, extraGodray = 0, alphaTest = 0.001, ssaoAttn = 0, damageEffect = 0, and vertexwarp's +// prev* defaults); the runtime seeds them (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex); + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2dOverlay); + + int extraGlow; + int dontWarpVertices; + int fadeFromSpheresFog; + int addRenderFlags; + int taaHistoryValid; + + int applySsao; + int tempGlowMode; + int normalShaded; + int skyShaded; + float taaReactive; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaTint; + vec3 rgbaAmbientIn; + vec4 rgbaLightIn; + vec4 rgbaGlowIn; + vec4 rgbaFogIn; + float fogMinIn; + float fogDensityIn; + mat4 projectionMatrix; + mat4 viewMatrix; + mat4 modelMatrix; + float extraZOffset; + mat4 prevProjectionMatrix; + mat4 prevViewMatrix; + mat4 prevModelMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + float extraGodray; + float alphaTest; + float ssaoAttn; + float overlayOpacity; + vec2 overlayTextureSize; + vec2 baseTextureSize; + vec2 baseUvOrigin; + float damageEffect; + float depthOffset; + vec4 averageColor; + vec2 taaRenderSize; + vec2 taaJitterPx; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/standard.vert b/sources/shaders-vk/standard.vert new file mode 100644 index 00000000..0a80c0b9 --- /dev/null +++ b/sources/shaders-vk/standard.vert @@ -0,0 +1,138 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of standard.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: GLOWSUB (the glowSub vertex input; `#if defined(GLOWSUB)` becomes `#if GLOWSUB == 1`), GBUFFER +// (SSAOLEVEL > 0) and TAAMOTION. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "standard.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in int flags; +#if GLOWSUB == 1 +layout(location = 4) in float glowSub; +#endif + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec4 color; +layout(location = 2) out vec4 rgbaFog; +layout(location = 3) out vec4 rgbaGlow; +layout(location = 4) out float fogAmount; +layout(location = 5) out vec4 camPos; +layout(location = 6) out vec4 worldPos; +layout(location = 7) flat out int renderFlags; + +layout(location = 8) out vec3 normal; +#if GBUFFER == 1 +layout(location = 9) out vec4 gnormal; +#endif + +// TAA motion vectors (Optimum P3). TAAMOTION is stamped by +// ShaderRegistry.registerDefaultShaderCodePrefixes and is 1 only while TAA is +// on, so with TAA off this shader preprocesses back to vanilla. +#if TAAMOTION == 1 +layout(location = 10) out vec4 taaPrevClip; +#endif + + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" + +void main(void) +{ + worldPos = modelMatrix * vec4(vertexPositionIn, 1.0); + + if (dontWarpVertices == 0) { + worldPos = applyVertexWarping(flags | addRenderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + } + if (dontWarpVertices == 2) { + int windMode = ((flags | addRenderFlags) >> WindModePosition) & 0xF; + vec4 newPos = applyVertexWarping(flags | addRenderFlags, worldPos); + worldPos = mix(worldPos, newPos, 0.25); // Hardcoded intensity downscale of 4x + worldPos = applyGlobalWarping(worldPos); + } + +#if TAAMOTION == 1 + // The same vertex, one frame ago. The warp branch below has to be the caller's + // exact branch - a held item passes dontWarpVertices 2, a dropped item 0 - or + // the two positions differ by a warp the object never had. + { + vec4 taaPrevWorld; + if (taaHistoryValid != 0) { + taaPrevWorld = prevModelMatrix * vec4(vertexPositionIn, 1.0); + WarpState taaPrev = previousWarpState(); + if (dontWarpVertices == 0) { + taaPrevWorld = applyVertexWarpingState(taaPrev, flags | addRenderFlags, taaPrevWorld); + taaPrevWorld = applyGlobalWarpingState(taaPrev, taaPrevWorld); + } + if (dontWarpVertices == 2) { + vec4 taaNewPos = applyVertexWarpingState(taaPrev, flags | addRenderFlags, taaPrevWorld); + taaPrevWorld = mix(taaPrevWorld, taaNewPos, 0.25); // same hardcoded 4x downscale as above + taaPrevWorld = applyGlobalWarpingState(taaPrev, taaPrevWorld); + } + } else { + // Treat the surface as static in the world: its camera-relative position + // a frame ago differed by the camera's own movement only (accuracy rule 4). + taaPrevWorld = vec4(worldPos.xyz + cameraPosDelta, 1.0); + } + taaPrevClip = prevProjectionMatrix * (prevViewMatrix * taaPrevWorld); + // The z-fighting nudge applies to both positions or the pair disagrees by it. + taaPrevClip.w += extraZOffset; + } +#endif + + camPos = viewMatrix * worldPos; + + uv = uvIn; + + float gs = 0.0; +#if GLOWSUB == 1 + gs = glowSub; +#endif + + int glow = clamp(extraGlow + (flags & GlowLevelBitMask) - int(gs * 255), 0, 255); + + renderFlags = glow | (flags & ~GlowLevelBitMask); + rgbaGlow.rgb = rgbaGlowIn.rgb * max(vec3(0), (1 - vec3(3*gs))); + rgbaGlow.a = rgbaGlowIn.a; + + color = rgbaTint * applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, camPos) * colorIn; +#if GLOWSUB == 1 + color.rgb *= 1 - 0.5 * gs; + color.rgb = mix(color.rgb, rgbaGlow.rgb, max(0, glowLevel - gs) / 2); +#endif + + + if (fadeFromSpheresFog > 0) { + color.a *= clamp(1 - getSpheresFogAmount(vertexPositionIn * 10), 0, 1); + } + + // Distance fade out + color.a *= clamp(20 * (1.10 - length(worldPos.xz) / viewDistance) - 5, -1, 1); + + rgbaFog = rgbaFogIn; + gl_Position = projectionMatrix * camPos; + calcShadowMapCoords(viewMatrix, worldPos); + + fogAmount = getFogLevel(worldPos, fogMinIn, fogDensityIn); + + gl_Position.w += extraZOffset; + + normal = unpackNormal(flags); + normal = normalize((modelMatrix * vec4(normal.x, normal.y, normal.z, 0)).xyz); + + #if GBUFFER == 1 + gnormal = viewMatrix * vec4(normal, 0); + #endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From 3ddf3461b21f51efa46003381fc8ef84e83053c3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:41:35 +0200 Subject: [PATCH 167/226] wip(native-shaders): gui-overlays - 9 programs, parity 25/25, Optimum.Tests 1184 passed / 0 failed / 34 skipped --- docs/vulkan-native-shaders.md | 24 ++++ sources/shaders-vk/autocamera.frag | 20 ++++ sources/shaders-vk/autocamera.interface.glsl | 8 ++ sources/shaders-vk/autocamera.vert | 28 +++++ sources/shaders-vk/blockhighlights.frag | 32 +++++ .../shaders-vk/blockhighlights.interface.glsl | 16 +++ sources/shaders-vk/blockhighlights.vert | 37 ++++++ sources/shaders-vk/gui.frag | 110 ++++++++++++++++++ sources/shaders-vk/gui.interface.glsl | 40 +++++++ sources/shaders-vk/gui.vert | 76 ++++++++++++ sources/shaders-vk/guigear.frag | 43 +++++++ sources/shaders-vk/guigear.interface.glsl | 19 +++ sources/shaders-vk/guigear.vert | 26 +++++ sources/shaders-vk/guitopsoil.frag | 42 +++++++ sources/shaders-vk/guitopsoil.interface.glsl | 20 ++++ sources/shaders-vk/guitopsoil.vert | 35 ++++++ sources/shaders-vk/helditem.frag | 98 ++++++++++++++++ sources/shaders-vk/helditem.interface.glsl | 30 +++++ sources/shaders-vk/helditem.vert | 61 ++++++++++ sources/shaders-vk/lines.frag | 17 +++ sources/shaders-vk/lines.interface.glsl | 13 +++ sources/shaders-vk/lines.vert | 24 ++++ sources/shaders-vk/texture2texture.frag | 18 +++ .../shaders-vk/texture2texture.interface.glsl | 21 ++++ sources/shaders-vk/texture2texture.vert | 29 +++++ sources/shaders-vk/wireframe.frag | 20 ++++ sources/shaders-vk/wireframe.interface.glsl | 25 ++++ sources/shaders-vk/wireframe.vert | 42 +++++++ 28 files changed, 974 insertions(+) create mode 100644 sources/shaders-vk/autocamera.frag create mode 100644 sources/shaders-vk/autocamera.interface.glsl create mode 100644 sources/shaders-vk/autocamera.vert create mode 100644 sources/shaders-vk/blockhighlights.frag create mode 100644 sources/shaders-vk/blockhighlights.interface.glsl create mode 100644 sources/shaders-vk/blockhighlights.vert create mode 100644 sources/shaders-vk/gui.frag create mode 100644 sources/shaders-vk/gui.interface.glsl create mode 100644 sources/shaders-vk/gui.vert create mode 100644 sources/shaders-vk/guigear.frag create mode 100644 sources/shaders-vk/guigear.interface.glsl create mode 100644 sources/shaders-vk/guigear.vert create mode 100644 sources/shaders-vk/guitopsoil.frag create mode 100644 sources/shaders-vk/guitopsoil.interface.glsl create mode 100644 sources/shaders-vk/guitopsoil.vert create mode 100644 sources/shaders-vk/helditem.frag create mode 100644 sources/shaders-vk/helditem.interface.glsl create mode 100644 sources/shaders-vk/helditem.vert create mode 100644 sources/shaders-vk/lines.frag create mode 100644 sources/shaders-vk/lines.interface.glsl create mode 100644 sources/shaders-vk/lines.vert create mode 100644 sources/shaders-vk/texture2texture.frag create mode 100644 sources/shaders-vk/texture2texture.interface.glsl create mode 100644 sources/shaders-vk/texture2texture.vert create mode 100644 sources/shaders-vk/wireframe.frag create mode 100644 sources/shaders-vk/wireframe.interface.glsl create mode 100644 sources/shaders-vk/wireframe.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 22d93014..4c19dde5 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -438,3 +438,27 @@ Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage its differential GPU test in its stage. 7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. + +### Family: gui-overlays (2026-09-15) + +Programs: `gui`, `guigear`, `guitopsoil`, `helditem`, `lines`, `texture2texture`, `autocamera`, `blockhighlights`, +`wireframe`. `MinimalGui` (inline C# strings, never registered) and `optimum-map` (mod-registered inline strings) +stay on the rewriter. Decisions: +- **Animation as storage (`gui`):** the `Animation` UBO is `layout(std140, set = OPTIMUM_SET_STORAGE, binding = + OPTIMUM_BINDING_ANIMATION) readonly buffer Animation { mat4 values[]; } ElementTransforms;`. `MAXANIMATEDELEMENTS` + is a client setting (`maxAnimatedElements`) the offline build cannot know, so the array is a runtime array + rather than a fixed size; the vertex stage indexes it with `jointId` as before. +- **Placement:** `gui` and `guitopsoil` follow the section 4 GUI row (slots, `rgbaIn`, `extraGlow`, `applyColor`, + `noTexture`, and for `gui` `overlayOpacity`, in push; the matrices in the record). `guigear`, `helditem`, + `texture2texture` and `blockhighlights` put only their slots in push. `lines`, `autocamera` and `wireframe` + have no samplers and no push block: every uniform is a record member. +- **`helditem`'s `SSAOLEVEL > 0`** gates varyings and G-buffer outputs, so it is the `GBUFFER` axis; `BLOOM` and + `NORMALVIEW` are constant branches. +- **`blockhighlights` and USEOIT:** `oit.glsl` declares its outputs and `OIT()` under `#if USEOIT > 0`, which + makes USEOIT an axis of the program. The client always registers it with `Oit = true`, and its GLSL 330 stage + with USEOIT 0 does not compile (`OIT` undefined). The native body guards the call with `#if USEOIT == 1`, so the + unreachable USEOIT=0 variant compiles and writes nothing, matching that variant's (empty) GLSL 330 outputs. + The fragment stage defines `OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH` (cross-stage owner) and carries + `windWaveCounter` in the record (its owner `vertexwarp.vsh` is not included). +- **Unproduced fragment inputs** the GLSL 330 stages declare but nothing writes or reads (`guitopsoil`'s `color` + and `glowLevel`, `helditem`'s `n`) keep their declarations; the optimised modules drop them. diff --git a/sources/shaders-vk/autocamera.frag b/sources/shaders-vk/autocamera.frag new file mode 100644 index 00000000..dc0097b0 --- /dev/null +++ b/sources/shaders-vk/autocamera.frag @@ -0,0 +1,20 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of autocamera.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "autocamera.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec4 color; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; + +void main () { + outColor = color; + outGlow = vec4(glowLevel, 0, 0, color.a); +} diff --git a/sources/shaders-vk/autocamera.interface.glsl b/sources/shaders-vk/autocamera.interface.glsl new file mode 100644 index 00000000..10be779e --- /dev/null +++ b/sources/shaders-vk/autocamera.interface.glsl @@ -0,0 +1,8 @@ +// Program interface of autocamera (docs/vulkan-native-shaders.md section 4). No samplers and no scalars: the two +// matrices are the whole record, and there is no push block. The includes' uniforms are frame members +// (autocamera includes shadowcoords.vsh and fogandlight.vsh, their owners). +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; +}; diff --git a/sources/shaders-vk/autocamera.vert b/sources/shaders-vk/autocamera.vert new file mode 100644 index 00000000..4c9629b5 --- /dev/null +++ b/sources/shaders-vk/autocamera.vert @@ -0,0 +1,28 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of autocamera.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "autocamera.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec4 vertexColor; +layout(location = 2) in int renderFlags; + +layout(location = 0) out vec4 color; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main(void) +{ + vec4 cameraPos = modelViewMatrix * vec4(vertexPositionIn, 1.0); + color = applyLight(vec3(1), vec4(1), renderFlags, cameraPos) * vertexColor; + gl_Position = projectionMatrix * cameraPos; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/blockhighlights.frag b/sources/shaders-vk/blockhighlights.frag new file mode 100644 index 00000000..26ca35b9 --- /dev/null +++ b/sources/shaders-vk/blockhighlights.frag @@ -0,0 +1,32 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of blockhighlights.fsh (docs/vulkan-native-shaders.md). +// fogandlight.fsh reads flatFogDensity, flatFogStart, viewDistance and viewDistanceLod0, owned by fogandlight.vsh, +// which the vertex stage includes, so this stage activates that owner group itself (section 3). +// +// oit.fsh declares its outputs and OIT() under #if USEOIT > 0, which makes USEOIT an axis of this program. The +// client always registers blockhighlights with Oit = true (ShaderProgramBase.Oit's default), so USEOIT=0 is a +// variant no client produces; its GLSL 330 stage would not compile (OIT undefined). The call is therefore +// guarded by the axis, and that variant writes nothing, exactly the outputs its GLSL 330 declarations have. +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "blockhighlights.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec4 color; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 1) in vec4 rgbaFog; + + +#include "fogandlight.frag.glsl" +#include "oit.glsl" + +void main() +{ +#if USEOIT == 1 + OIT(color, glowLevel); +#endif +} diff --git a/sources/shaders-vk/blockhighlights.interface.glsl b/sources/shaders-vk/blockhighlights.interface.glsl new file mode 100644 index 00000000..372be2df --- /dev/null +++ b/sources/shaders-vk/blockhighlights.interface.glsl @@ -0,0 +1,16 @@ +// Program interface of blockhighlights (docs/vulkan-native-shaders.md section 4). The push block holds the +// sampler slot. The record holds the matrices and fogandlight.fsh's windWaveCounter, whose owner +// (vertexwarp.vsh) the program does not include; fogandlight.fsh's other program uniforms are frame members +// through fogandlight.vsh in the vertex stage. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, particleTex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; + + float windWaveCounter; +}; diff --git a/sources/shaders-vk/blockhighlights.vert b/sources/shaders-vk/blockhighlights.vert new file mode 100644 index 00000000..df51c763 --- /dev/null +++ b/sources/shaders-vk/blockhighlights.vert @@ -0,0 +1,37 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of blockhighlights.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "blockhighlights.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec4 vertexColor; + + +layout(location = 0) out vec4 color; +layout(location = 1) out vec4 rgbaFog; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main(void) +{ + vec4 cameraPos = modelViewMatrix * vec4(vertexPositionIn, 1.0); + + color = vertexColor; + gl_Position = projectionMatrix * cameraPos; + + // We are cheap. We pretend the highlights are closer to the camera to enforce it + // always being drawn on top + gl_Position.w += 0.0004; + + rgbaFog = vec4(0); + glowLevel = 0; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/gui.frag b/sources/shaders-vk/gui.frag new file mode 100644 index 00000000..bf69c5e4 --- /dev/null +++ b/sources/shaders-vk/gui.frag @@ -0,0 +1,110 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of gui.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "gui.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 2) in vec4 color; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 4) in vec2 clipPos; + + +layout(location = 0) out vec4 outColor; +layout(location = 3) in vec4 rgbaGlow; +layout(location = 5) in float damageEffectV; + +// Texture overlay "hack" +// We only have the base texture UV coordinates, which, for blocks and items in inventory is the block or item texture atlas, but none uv coords for a dedicated overlay texture +// So lets remove the base offset (baseUvOrigin) and rescale the coords (baseTextureSize / overlayTextureSize) to get useful UV coordinates for the overlay texture + + +layout(location = 7) in float normalShadeIntensity; +layout(location = 6) flat in vec3 normal; + + +#include "vertexflagbits.glsl" +#include "normalshading.glsl" +#include "noise2d.glsl" + +void main () { + float b = 1; + + float def = damageEffectV + damageEffect; + if (def > 0) { + float f = cnoise2(floor(vec2(uv.x, uv.y) * 4096) / 4); + if (f < def - 1.3) discard; + b = min(1, f * 1.5 + 0.65 + (1-def)); + } + + if (darkEdges > 0) { + float dx = 1.7 * abs(uv.x - 0.5) - 0.7; + float dy = 1.7 * abs(uv.y - 0.5) - 0.7; + float strength = clamp(max(dx,dy) * 0.85 + (1- (dx*dx + dy*dy)) * 0.15, 0, 0.5); + + outColor = vec4(0,0,0, strength); + return; + } + + if (noTexture > 0) { + outColor = color; + } else { + if (overlayOpacity > 0) { + vec2 uvOverlay = (uv - baseUvOrigin) * (baseTextureSize / overlayTextureSize); + + vec4 col1 = texture(optimumTextures2D[tex2dOverlay], uvOverlay); + vec4 col2 = texture(optimumTextures2D[tex2d], uv); + + float a1 = overlayOpacity * col1.a * min(1, col2.a * 100); + float a2 = col2.a * (1 - a1); + + outColor = vec4( + (a1 * col1.r + col2.r * a2) / (a1+a2), + (a1 * col1.b + col2.g * a2) / (a1+a2), + (a1 * col1.g + col2.b * a2) / (a1+a2), + a1 + a2 + ) * color; + + + } else { + outColor = texture(optimumTextures2D[tex2d], uv) * color; + } + } + + + if (tempGlowMode == 1) { + outColor.rgb += rgbaGlow.rgb * min(0.8, glowLevel + rgbaGlow.a); + } else { + outColor.rgb *= 1 + glowLevel; + } + + if (transparentCenter > 0) { + outColor.a *= clamp(pow(length(clipPos), 2) * 15, 0, 1); + } + + if (outColor.a <= alphaTest) discard; + + if (normalShaded > 0) { + float b = getBrightnessFromNormal(normal, normalShadeIntensity, 0.45) * 1.2; + outColor *= vec4(b, b, b, 1); + } + + if (sepiaLevel > 0) { + // Sepia + vec3 sepia = vec3( + (outColor.r * 0.393) + (outColor.g * 0.769) + (outColor.b * 0.189), + (outColor.r * 0.349) + (outColor.g * 0.686) + (outColor.b * 0.168), + (outColor.r * 0.272) + (outColor.g * 0.534) + (outColor.b * 0.131) + ); + + outColor.rgb = mix(outColor.rgb, sepia * 1.33, sepiaLevel); + } + + outColor.rgb *= b; + + //outColor.a=0.1; +} diff --git a/sources/shaders-vk/gui.interface.glsl b/sources/shaders-vk/gui.interface.glsl new file mode 100644 index 00000000..7caf2db4 --- /dev/null +++ b/sources/shaders-vk/gui.interface.glsl @@ -0,0 +1,40 @@ +// Program interface of gui (docs/vulkan-native-shaders.md section 4, GUI row). The push block holds the two +// sampler slots in gui.fsh's declaration order, then the per-element uniforms RenderAPIGame.RenderRectangle +// sets on every rectangle (rgbaIn, extraGlow, applyColor, noTexture, overlayOpacity). The record holds +// projectionMatrix, modelViewMatrix, modelMatrix and every other uniform: gui.vsh's in declaration order, +// then gui.fsh's, then normalshading.fsh's lightPosition (its owner fogandlight.fsh is not included). +// +// The GLSL 330 initializers (sepiaLevel = 0, damageEffect = 0) are seeded by the runtime (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2d); + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2dOverlay); + vec4 rgbaIn; + int extraGlow; + int applyColor; + float noTexture; + float overlayOpacity; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaGlowIn; + mat4 projectionMatrix; + mat4 modelViewMatrix; + mat4 modelMatrix; + int applyModelMat; + int applyAnimation; + + float alphaTest; + int darkEdges; + int tempGlowMode; + int transparentCenter; + int normalShaded; + float sepiaLevel; + float damageEffect; + vec2 overlayTextureSize; + vec2 baseTextureSize; + vec2 baseUvOrigin; + + vec3 lightPosition; +}; diff --git a/sources/shaders-vk/gui.vert b/sources/shaders-vk/gui.vert new file mode 100644 index 00000000..9f70d780 --- /dev/null +++ b/sources/shaders-vk/gui.vert @@ -0,0 +1,76 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of gui.vsh (docs/vulkan-native-shaders.md). +// The Animation UBO is the set 2 animation storage buffer. MAXANIMATEDELEMENTS is a client setting the +// offline build cannot know, so the array is unsized; jointId indexes it exactly as before. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "gui.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +// Bits 0-7: Glow level +// Bits 8-10: Z-Offset +// Bit 11: Wind waving yes/no +// Bit 12: Water waving yes/no +// Bit 13: low contrast mode +// Bit 14-26: x/y/z normals, 12 bits total. Each axis with 1 sign bit and 3 value bits +layout(location = 3) in int renderFlagsIn; +layout(location = 4) in float damageEffectIn; +layout(location = 5) in int jointId; + +layout(std140, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_ANIMATION) readonly buffer Animation +{ + mat4 values[]; +} ElementTransforms; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec2 uvOverlay; +layout(location = 2) out vec4 color; +layout(location = 3) out vec4 rgbaGlow; +layout(location = 4) out vec2 clipPos; +layout(location = 5) out float damageEffectV; + +layout(location = 6) flat out vec3 normal; +layout(location = 7) out float normalShadeIntensity; + +#include "vertexflagbits.glsl" +#include "fogandlight.vert.glsl" + +void main(void) +{ + damageEffectV = damageEffectIn; + uv = uvIn; + + int glow = min(255, extraGlow + (renderFlagsIn & GlowLevelBitMask)); + + glowLevel = glow / 255.0; + rgbaGlow = rgbaGlowIn; + + color = rgbaIn; + + if (applyColor == 1) color *= colorIn; + + if (applyAnimation > 0) { + mat4 animModelMat = modelViewMatrix * ElementTransforms.values[jointId]; + gl_Position = projectionMatrix * animModelMat * vec4(vertexPositionIn, 1.0); + } else { + gl_Position = projectionMatrix * modelViewMatrix * vec4(vertexPositionIn, 1.0); + } + + clipPos = gl_Position.xy; + + normal = unpackNormal(renderFlagsIn); + if (applyModelMat > 0) { + normal = (modelMatrix * vec4(normal, 0)).xyz; + normal = normalize(normal); + } + + normalShadeIntensity = 1; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/guigear.frag b/sources/shaders-vk/guigear.frag new file mode 100644 index 00000000..91c8eccf --- /dev/null +++ b/sources/shaders-vk/guigear.frag @@ -0,0 +1,43 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of guigear.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "guigear.interface.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec2 pos; + +layout(location = 0) out vec4 outColor; + +#include "noise3d.glsl" + +void main () { + outColor = texture(optimumTextures2D[tex2d], uv); + if (outColor.a <= 0.01) discard; + + vec3 tealCol = vec3(56/255.0, 232/255.0, 182/255.0); + vec3 tealDarkCol = vec3(80/255.0, 98/255.0, 93/255.0); + + float noise = abs(gnoise(vec3(pos.x/20, pos.y/20, gearCounter * 0.75))); + float height = gearHeight - 10; + + float relY = (pos.y - hotbarYPos + height * 0.685) / height; // No idea why the 0.68 and not 0.55 + + float ya = 1 - relY; + float yb = 0.5 + stabilityLevel/2 + noise/30.0; + + if (ya < yb) { + float b = 1.2f * (max(0.0, 0.4 - (outColor.r + outColor.g + outColor.b) / 3) + noise/5); + + outColor.rgb = mix(outColor.rgb, tealCol - b, clamp((yb - ya)*50, 0, 1)); + } + + outColor.rgb *= 1 - max(0.0, pos.y-shadeYPos)/15; + outColor.a *= 1;//0.5; + + //outColor.rgb = vec3(relY); + +} diff --git a/sources/shaders-vk/guigear.interface.glsl b/sources/shaders-vk/guigear.interface.glsl new file mode 100644 index 00000000..98d90dc0 --- /dev/null +++ b/sources/shaders-vk/guigear.interface.glsl @@ -0,0 +1,19 @@ +// Program interface of guigear (docs/vulkan-native-shaders.md section 4, GUI row): the sampler slot in the +// push block; the matrices and guigear.fsh's scalars in the record, each stage in declaration order. +// stabilityLevel's GLSL 330 initializer (0.5) is seeded by the runtime (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2d); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; + + float gearCounter; + float stabilityLevel; + float shadeYPos; + float hotbarYPos; + float gearHeight; +}; diff --git a/sources/shaders-vk/guigear.vert b/sources/shaders-vk/guigear.vert new file mode 100644 index 00000000..2a07f6ef --- /dev/null +++ b/sources/shaders-vk/guigear.vert @@ -0,0 +1,26 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of guigear.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "guigear.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; + + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec2 pos; + +void main(void) +{ + gl_Position = projectionMatrix * modelViewMatrix * vec4(vertexPositionIn, 1.0); + + uv = uvIn; + pos = (modelViewMatrix * vec4(vertexPositionIn, 1.0)).xy; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/guitopsoil.frag b/sources/shaders-vk/guitopsoil.frag new file mode 100644 index 00000000..2fe8a874 --- /dev/null +++ b/sources/shaders-vk/guitopsoil.frag @@ -0,0 +1,42 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of guitopsoil.fsh (docs/vulkan-native-shaders.md). +// color and glowLevel are declared by the GLSL 330 stage but written by no vertex stage and read by nothing; +// they keep their declarations (glowLevel at its shared varying location), and the optimised module drops them. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "guitopsoil.interface.glsl" +#include "varyings.glsl" + +layout(location = 2) in vec4 rgba; // Without biome tint +layout(location = 3) in vec4 rgba2; // With biome tint +layout(location = 0) in vec2 uv; +layout(location = 1) in vec2 uv2; +layout(location = 4) in vec4 color; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; + +layout(location = 0) out vec4 outColor; + +void main () { + + vec4 brownSoilColor = texture(optimumTextures2D[terrainTex], uv) * rgba; + vec4 grassColor; + + if (rgba.a < 0.01) { + // Bottom + outColor = brownSoilColor; + } else { + if (rgba2.a > 0.01) { + // Top + grassColor = texture(optimumTextures2D[terrainTex], uv2) * rgba2; + } else { + // Side + Overlay + grassColor = texture(optimumTextures2D[terrainTex], uv2 + vec2(blockTextureSize, 0)) * vec4(rgba2.rgb, 1); + } + + outColor = brownSoilColor * (1 - grassColor.a) + grassColor * grassColor.a; + } + outColor.a = 1; +} diff --git a/sources/shaders-vk/guitopsoil.interface.glsl b/sources/shaders-vk/guitopsoil.interface.glsl new file mode 100644 index 00000000..d25c6d24 --- /dev/null +++ b/sources/shaders-vk/guitopsoil.interface.glsl @@ -0,0 +1,20 @@ +// Program interface of guitopsoil (docs/vulkan-native-shaders.md section 4, GUI row): the sampler slot, then +// the per-element GUI uniforms (rgbaIn, extraGlow, applyColor, noTexture) in the push block; the matrices, +// blockTextureSize and alphaTest in the record, each stage in declaration order. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, terrainTex); + vec4 rgbaIn; + int extraGlow; + int applyColor; + float noTexture; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; + + float blockTextureSize; + float alphaTest; +}; diff --git a/sources/shaders-vk/guitopsoil.vert b/sources/shaders-vk/guitopsoil.vert new file mode 100644 index 00000000..6adea44b --- /dev/null +++ b/sources/shaders-vk/guitopsoil.vert @@ -0,0 +1,35 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of guitopsoil.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "guitopsoil.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec2 uv2; +layout(location = 2) out vec4 rgba; // Without biome tint +layout(location = 3) out vec4 rgba2; // With biome tint + + +void main(void) +{ + uv = uvIn; + uv2 = uvIn; + rgba = vec4(1); + rgba2 = vec4(1); + float glowLevel = extraGlow / 128.0; + + vec4 color = rgbaIn * (1 + glowLevel); + if (applyColor == 1) color *= colorIn; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(vertexPositionIn, 1.0); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/helditem.frag b/sources/shaders-vk/helditem.frag new file mode 100644 index 00000000..fd23e045 --- /dev/null +++ b/sources/shaders-vk/helditem.frag @@ -0,0 +1,98 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of helditem.fsh (docs/vulkan-native-shaders.md). +// SSAOLEVEL > 0 gates the G-buffer inputs and outputs, so it is the GBUFFER axis; BLOOM and NORMALVIEW are +// specialization-constant branches with the same expressions. n is declared by the GLSL 330 stage but written +// by no vertex stage and read by nothing; the optimised module drops it. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "helditem.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 4) in vec4 fragPosition; +layout(location = 5) in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + + +// Texture overlay "hack" +// We only have the base texture UV coordinates, which, for blocks and items in inventory is the block or item texture atlas, but none uv coords for a dedicated overlay texture +// So lets remove the base offset (baseUvOrigin) and rescale the coords (baseTextureSize / overlayTextureSize) to get useful UV coordinates for the overlay texture + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec4 color; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 6) in float n; +layout(location = 2) in vec3 normal; +layout(location = 3) in vec3 vertexPosition; + +#include "vertexflagbits.glsl" +#include "normalshading.glsl" +#include "noise2d.glsl" + +void main () { + float b = 1; + + if (damageEffect > 0) { + float f = cnoise2(floor(vec2(uv.x, uv.y) * 4096) / 4); + if (f < damageEffect - 1.3) discard; + b = min(1, f * 1.5 + 0.65 + (1-damageEffect)); + } + + if (overlayOpacity > 0) { + vec2 uvOverlay = (uv - baseUvOrigin) * (baseTextureSize / overlayTextureSize); + + vec4 col1 = texture(optimumTextures2D[tex2dOverlay], uvOverlay); + vec4 col2 = texture(optimumTextures2D[itemTex], uv); + + float a1 = overlayOpacity * col1.a * min(1, col2.a * 100); + float a2 = col2.a * (1 - a1); + + outColor = vec4( + (a1 * col1.r + col2.r * a2) / (a1+a2), + (a1 * col1.b + col2.g * a2) / (a1+a2), + (a1 * col1.g + col2.b * a2) / (a1+a2), + a1 + a2 + ) * color; + + } else { + outColor = texture(optimumTextures2D[itemTex], uv) * color; + } + + outColor.a = clamp(outColor.a, 0, 1); // No idea why, makes held torches glitchy without + + if (OPTIMUM_BLOOM == 0) { + outColor.rgb *= 1 + glowLevel; + } + + // Ensure held item always being in the front + gl_FragDepth = gl_FragCoord.z / 20; + + if (outColor.a < alphaTest) discard; + + if (normalShaded > 0) { + float b = min(1, getBrightnessFromNormal(normal, 1, 0.45) + glowLevel); + outColor *= vec4(b, b, b, 1); + } + +#if GBUFFER == 1 + // Doesn't work properly for some reason + //outGPosition = vec4(fragPosition.xyz, glowLevel); + outGPosition = vec4(1); + outGNormal = gnormal; +#endif + + if (OPTIMUM_NORMALVIEW > 0) { + outColor = vec4((normal.x + 1) / 2, (normal.y + 1)/2, (normal.z+1)/2, 1); + } + + outColor.rgb *= b; + + outGlow = vec4(glowLevel, 0, 0, outColor.a); +} diff --git a/sources/shaders-vk/helditem.interface.glsl b/sources/shaders-vk/helditem.interface.glsl new file mode 100644 index 00000000..b2a0c5fa --- /dev/null +++ b/sources/shaders-vk/helditem.interface.glsl @@ -0,0 +1,30 @@ +// Program interface of helditem (docs/vulkan-native-shaders.md section 4). At most a couple of draws per Use(), +// so the push block holds only the sampler slots, in helditem.fsh's declaration order. The record holds +// helditem.vsh's uniforms, then helditem.fsh's, then normalshading.fsh's lightPosition (its owner +// fogandlight.fsh is not included), each in declaration order. +// The GLSL 330 initializers (alphaTest = 0.001, damageEffect = 0) are seeded by the runtime (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, itemTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2dOverlay); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec3 rgbaAmbientIn; + vec4 rgbaLightIn; + vec4 rgbaGlowIn; + int extraGlow; + mat4 projectionMatrix; + mat4 modelViewMatrix; + + float alphaTest; + float overlayOpacity; + vec2 overlayTextureSize; + vec2 baseTextureSize; + vec2 baseUvOrigin; + int normalShaded; + float damageEffect; + + vec3 lightPosition; +}; diff --git a/sources/shaders-vk/helditem.vert b/sources/shaders-vk/helditem.vert new file mode 100644 index 00000000..4cf5075e --- /dev/null +++ b/sources/shaders-vk/helditem.vert @@ -0,0 +1,61 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of helditem.vsh (docs/vulkan-native-shaders.md). +// SSAOLEVEL > 0 gates the G-buffer varyings, so it is the GBUFFER axis (section 5). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "helditem.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 modelColor; +layout(location = 3) in int flags; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec4 color; + +layout(location = 2) out vec3 normal; +layout(location = 3) out vec3 vertexPosition; +#if GBUFFER == 1 +layout(location = 4) out vec4 fragPosition; +layout(location = 5) out vec4 gnormal; +#endif + + + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main(void) +{ + vec4 cameraPos = modelViewMatrix * vec4(vertexPositionIn, 1.0); + + int glow = min(255, extraGlow + (flags & GlowLevelBitMask)); + glowLevel = glow / 255.0; + + uv = uvIn; + color = applyLight( + rgbaAmbientIn, + rgbaLightIn, + glow, + cameraPos + ) * modelColor; + + color.rgb = mix(color.rgb, rgbaGlowIn.rgb, glow / 255.0 * rgbaGlowIn.a); + + gl_Position = projectionMatrix * cameraPos; + + normal = unpackNormal(flags); + normal = normalize((modelViewMatrix * vec4(normal.x, normal.y, normal.z, 0)).xyz); + + #if GBUFFER == 1 + fragPosition = cameraPos; + gnormal = vec4(normal, 0); + #endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/lines.frag b/sources/shaders-vk/lines.frag new file mode 100644 index 00000000..d64e922c --- /dev/null +++ b/sources/shaders-vk/lines.frag @@ -0,0 +1,17 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of lines.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "lines.interface.glsl" + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; + + +void main() { + outColor = color; + outGlow = vec4(glowLevel, 0, 0, outColor.a); +} diff --git a/sources/shaders-vk/lines.interface.glsl b/sources/shaders-vk/lines.interface.glsl new file mode 100644 index 00000000..8659f810 --- /dev/null +++ b/sources/shaders-vk/lines.interface.glsl @@ -0,0 +1,13 @@ +// Program interface of lines (docs/vulkan-native-shaders.md section 4). No samplers, and the two matrices alone +// exceed the push budget, so there is no push block: every uniform is a record member, lines.vsh's then +// lines.fsh's, each in declaration order. glowLevel's GLSL 330 initializer (1.0) is seeded by the runtime. +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float lineWidth; + mat4 projection; + mat4 view; + vec3 origin; + + vec4 color; + float glowLevel; +}; diff --git a/sources/shaders-vk/lines.vert b/sources/shaders-vk/lines.vert new file mode 100644 index 00000000..c9bad21c --- /dev/null +++ b/sources/shaders-vk/lines.vert @@ -0,0 +1,24 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of lines.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "lines.interface.glsl" + +layout(location = 0) in vec3 quadCoord; // Per vertex +layout(location = 1) in vec2 uvIn; // Per vertex + +layout(location = 2) in vec3 pointA; +layout(location = 3) in vec3 pointB; + +void main() { + vec3 dir = pointB - pointA; + vec3 q = quadCoord * vec3(lineWidth, 1, lineWidth) - vec3(lineWidth/2, 0, lineWidth/2); + float up = q.y; + gl_Position = projection * view * vec4(pointA + origin + vec3(1, dir.y, 1) * q + vec3(dir.x*up, 0, dir.z*up), 1); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/texture2texture.frag b/sources/shaders-vk/texture2texture.frag new file mode 100644 index 00000000..1eb6448a --- /dev/null +++ b/sources/shaders-vk/texture2texture.frag @@ -0,0 +1,18 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of texture2texture.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "texture2texture.interface.glsl" + +layout(location = 0) in vec2 uv; + +layout(location = 0) out vec4 outColor; + + +void main () { + outColor = texture(optimumTextures2D[tex2d], vec2(texu + uv.x * texw, texv + uv.y * texh)); + if (outColor.a <= alphaTest) discard; +} diff --git a/sources/shaders-vk/texture2texture.interface.glsl b/sources/shaders-vk/texture2texture.interface.glsl new file mode 100644 index 00000000..8e8d73b1 --- /dev/null +++ b/sources/shaders-vk/texture2texture.interface.glsl @@ -0,0 +1,21 @@ +// Program interface of texture2texture (docs/vulkan-native-shaders.md section 4). One draw per Use(), so the push +// block holds only the sampler slot and every other uniform is a record member: texture2texture.vsh's, then +// texture2texture.fsh's, each in declaration order. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex2d); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float xs; + float ys; + float width; + float height; + + float texu; + float texv; + float texw; + float texh; + float alphaTest; +}; diff --git a/sources/shaders-vk/texture2texture.vert b/sources/shaders-vk/texture2texture.vert new file mode 100644 index 00000000..fd499861 --- /dev/null +++ b/sources/shaders-vk/texture2texture.vert @@ -0,0 +1,29 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of texture2texture.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "texture2texture.interface.glsl" + +layout(location = 0) in vec3 pos; +layout(location = 1) in vec2 uvIn; + +layout(location = 0) out vec2 uv; + +void main(void) +{ + uv = uvIn; + + vec2 posTL = (pos.xy + 1) / 2; + + posTL.x = xs + posTL.x * width; + posTL.y = ys + posTL.y * height; + vec2 posOut = posTL * 2 - 1; + + gl_Position = vec4(posOut.x, posOut.y, 0, 1); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/wireframe.frag b/sources/shaders-vk/wireframe.frag new file mode 100644 index 00000000..d4a3a5d2 --- /dev/null +++ b/sources/shaders-vk/wireframe.frag @@ -0,0 +1,20 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of wireframe.fsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "wireframe.interface.glsl" +#include "varyings.glsl" + +layout(location = 0) in vec4 color; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; + +void main () { + outColor = color; + outGlow = vec4(glowLevel, 0, 0, color.a); +} diff --git a/sources/shaders-vk/wireframe.interface.glsl b/sources/shaders-vk/wireframe.interface.glsl new file mode 100644 index 00000000..c3411e36 --- /dev/null +++ b/sources/shaders-vk/wireframe.interface.glsl @@ -0,0 +1,25 @@ +// Program interface of wireframe (docs/vulkan-native-shaders.md section 4). No samplers, so there is no push block: +// the record holds wireframe.vsh's uniforms in declaration order, then vertexwarp.vsh's prev* uniforms in its +// header's order (their owner rule: they are program uniforms, not frame members). The prev* GLSL 330 +// initializers (some are 1) are seeded by the runtime (section 8). +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; + vec4 colorIn; + vec3 origin; + float extraGlow; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; +}; diff --git a/sources/shaders-vk/wireframe.vert b/sources/shaders-vk/wireframe.vert new file mode 100644 index 00000000..723b06fd --- /dev/null +++ b/sources/shaders-vk/wireframe.vert @@ -0,0 +1,42 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of wireframe.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "wireframe.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec4 vertexColor; +layout(location = 2) in int renderFlags; + +layout(location = 0) out vec4 color; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" + +void main(void) +{ + vec4 worldPos = applyVertexWarping(renderFlags, vec4(vertexPositionIn + origin, 1.0)); + worldPos = applyGlobalWarping(worldPos); + + vec4 cameraPos = modelViewMatrix * worldPos; + + color = max(vertexColor, vec4(0.001, 0.001, 0.001, 0)); + + + glowLevel = extraGlow; + color = applyLightWithoutPointLight(color, color, 0); + color.a = vertexColor.a; + gl_Position = projectionMatrix * cameraPos; + color *= colorIn; + + // Pretend the vertices are closer to the camera to enforce it always being drawn on top + gl_Position.w += 0.0014 + (renderFlags >> 8) * 0.00025; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From 8eb085d0937c205cbf7b548393e09ad183cb73f8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:41:47 +0200 Subject: [PATCH 168/226] wip(native-shaders): particles-sky - 9 programs (cloudvolumetric blocked by the harness frame-texture rule), parity green before the blocked program was removed --- docs/vulkan-native-shaders.md | 41 +++++ sources/shaders-vk/aurora.frag | 79 ++++++++++ sources/shaders-vk/aurora.interface.glsl | 30 ++++ sources/shaders-vk/aurora.vert | 55 +++++++ sources/shaders-vk/celestialobject.frag | 86 ++++++++++ .../shaders-vk/celestialobject.interface.glsl | 34 ++++ sources/shaders-vk/celestialobject.vert | 46 ++++++ sources/shaders-vk/cloudmap.frag | 147 ++++++++++++++++++ sources/shaders-vk/cloudmap.interface.glsl | 43 +++++ sources/shaders-vk/cloudmap.vert | 22 +++ sources/shaders-vk/decals.frag | 50 ++++++ sources/shaders-vk/decals.interface.glsl | 40 +++++ sources/shaders-vk/decals.vert | 101 ++++++++++++ sources/shaders-vk/nightsky.frag | 47 ++++++ sources/shaders-vk/nightsky.interface.glsl | 28 ++++ sources/shaders-vk/nightsky.vert | 28 ++++ sources/shaders-vk/particlescube.frag | 86 ++++++++++ .../shaders-vk/particlescube.interface.glsl | 38 +++++ sources/shaders-vk/particlescube.vert | 134 ++++++++++++++++ sources/shaders-vk/particlesquad.frag | 55 +++++++ .../shaders-vk/particlesquad.interface.glsl | 33 ++++ sources/shaders-vk/particlesquad.vert | 109 +++++++++++++ sources/shaders-vk/particlesquad2d.frag | 51 ++++++ .../shaders-vk/particlesquad2d.interface.glsl | 17 ++ sources/shaders-vk/particlesquad2d.vert | 36 +++++ sources/shaders-vk/sky.frag | 49 ++++++ sources/shaders-vk/sky.interface.glsl | 21 +++ sources/shaders-vk/sky.vert | 32 ++++ 28 files changed, 1538 insertions(+) create mode 100644 sources/shaders-vk/aurora.frag create mode 100644 sources/shaders-vk/aurora.interface.glsl create mode 100644 sources/shaders-vk/aurora.vert create mode 100644 sources/shaders-vk/celestialobject.frag create mode 100644 sources/shaders-vk/celestialobject.interface.glsl create mode 100644 sources/shaders-vk/celestialobject.vert create mode 100644 sources/shaders-vk/cloudmap.frag create mode 100644 sources/shaders-vk/cloudmap.interface.glsl create mode 100644 sources/shaders-vk/cloudmap.vert create mode 100644 sources/shaders-vk/decals.frag create mode 100644 sources/shaders-vk/decals.interface.glsl create mode 100644 sources/shaders-vk/decals.vert create mode 100644 sources/shaders-vk/nightsky.frag create mode 100644 sources/shaders-vk/nightsky.interface.glsl create mode 100644 sources/shaders-vk/nightsky.vert create mode 100644 sources/shaders-vk/particlescube.frag create mode 100644 sources/shaders-vk/particlescube.interface.glsl create mode 100644 sources/shaders-vk/particlescube.vert create mode 100644 sources/shaders-vk/particlesquad.frag create mode 100644 sources/shaders-vk/particlesquad.interface.glsl create mode 100644 sources/shaders-vk/particlesquad.vert create mode 100644 sources/shaders-vk/particlesquad2d.frag create mode 100644 sources/shaders-vk/particlesquad2d.interface.glsl create mode 100644 sources/shaders-vk/particlesquad2d.vert create mode 100644 sources/shaders-vk/sky.frag create mode 100644 sources/shaders-vk/sky.interface.glsl create mode 100644 sources/shaders-vk/sky.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 22d93014..fd30af72 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -438,3 +438,44 @@ Worked through on family 1 (`blit`, `final`, `luma`, 2026-09-15). A family stage its differential GPU test in its stage. 7. **Before committing:** the full `Optimum.Render.Vulkan.Tests` run (SYNC- only from `SyncValidationControlTests`) and `dotnet test Optimum.Tests -c Release`. + +### 9.1 Family 5: particles, decals, sky, clouds (2026-09-15) + +Ported: `particlescube`, `particlesquad`, `particlesquad2d`, `decals`, `sky`, `nightsky`, `celestialobject`, +`aurora`, `cloudmap`. Not ported: the unregistered `clouds` pair, and `cloudvolumetric` (below). + +- **USEOIT on programs that include `oit.glsl`.** `oit.glsl` gates its outputs and functions on `USEOIT`, so every + includer gets the axis and the builder compiles `USEOIT=0` too. `particlesquad`, `particlesquad2d` and `aurora` + are registered with `Oit = true` (the `ShaderProgramBase` default); only `USEOIT=1` is ever selected, and the + GLSL 330 bodies do not compile with `USEOIT 0`. The native bodies put every statement that names an + `oit.glsl` symbol under `#if USEOIT == 1`. The `USEOIT=0` variant has no fragment outputs, matching the + preprocessed GLSL 330 declarations, and exists only to compile. +- **GBUFFER as the motion location.** `decals` branches on the G-buffer only through `TAAMOTIONLOCATION`, so its + fragment stage declares `outMotion` at 4 under `#if GBUFFER == 1` and at 2 otherwise, which makes `GBUFFER` an + axis of the program (8 variants). `particlescube` does the same. +- **VEC3SCALE** is tested as `#if VEC3SCALE == 1` where `particlescube.vsh` has `#if defined(VEC3SCALE)`. +- **Motion writers.** `decals` writes `optimumWriteMotion(prevClip, taaRenderSize, taaJitterPx, 0.0, + gl_FragCoord.z)`; its behind-camera result `vec4(0, 0, 0, 0)` equals the GLSL 330 `vec4(0.0)`. `particlescube` + (the section 7 exception) writes `optimumWriteReactiveOnly(1.0)` behind the camera, which is the GLSL 330 + `vec4(0, 0, 1, 0)`, and `vec4(optimumMotionVector(...), 1.0, gl_FragCoord.z)` otherwise. +- **cloudmap's dither stub.** `cloudmap.fsh` defines `NoiseFromPixelPosition(a, b, c)` as `vec4(0.0)` before + `skycolor.fsh`, so its sky glow is undithered. Section 1's reading ("drops its own copy") would change pixels, + because `skycolor.glsl` would then call the real function. The port keeps the stub, placed after an explicit + `#include "dither.glsl"`: the function is defined (guarded, unused) and every call in `skycolor.glsl` still + expands to `vec4(0.0)`. `cloudmap.fsh`'s own `pointLightQuantity`, `pointLights`, `pointLightColors` and + `nightVisionStrength` are record members (no `fogandlight.vsh` owner). The arrays are sized + `FrameGlobals.MaxDynamicLights`, and `#if DYNLIGHTS` becomes an `OPTIMUM_DYNLIGHTS` branch. +- **Varyings a vertex stage never writes stay unwritten.** Examples: `aurora`'s `shadowCoordsFar/Near`, declared + unconditionally at the `varyings.glsl` locations; `celestialobject`'s `fragPosition`/`gnormal`; `nightsky`'s + `worldPosY`; `particlescube`'s fragment `uv`. +- **Placement.** Particles and the sky programs draw once per `Use()` or have no DRAW uniforms, so their push block + holds only sampler slots (`particlescube` and `sky` have none, so they have no push block). `decals` pushes both + slots plus `origin` and `modelViewMatrix` (84 B). +- **cloudvolumetric is blocked on the harness/contract.** `cloudvolumetric.fsh` declares + `uniform sampler2D liquidDepth` itself, without including `underwatereffects.fsh`. `bindings.glsl` declares the + set 0 `liquidDepth` globally, so a push slot of that name cannot compile. Sampling the frame texture compiles, + but section 2's name set counts a frame texture only through an included port's `optimum-frame-texture` + header, and `NativeShaderParityTests` fails with `only GLSL 330 [liquidDepth]`. Pulling in + `underwatereffects.glsl` to get the header would add its frame members to the name set. Unblocking needs a + contract decision: either count the set 0 textures a program's own GLSL 330 source declares, or have + `bindings.glsl` stop declaring frame textures a program does not own. diff --git a/sources/shaders-vk/aurora.frag b/sources/shaders-vk/aurora.frag new file mode 100644 index 00000000..62e931d9 --- /dev/null +++ b/sources/shaders-vk/aurora.frag @@ -0,0 +1,79 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of aurora.fsh (docs/vulkan-native-shaders.md). +// USEOIT is an axis because oit.fsh gates its outputs on it; the program is registered with Oit = true, and +// in the never-selected USEOIT=0 variant the writes to oit.fsh's outputs are compiled out (family 5 decision). +// The vertex stage includes fogandlight.vsh (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "aurora.interface.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec4 col; +layout(location = 2) in vec4 rgbaFog; +layout(location = 4) in vec4 vexPos; +layout(location = 5) in float xpos; +layout(location = 3) in float fogAmount; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 6) flat in int renderFlags; + + + +#include "fogandlight.frag.glsl" +#include "noise3d.glsl" +#include "oit.glsl" + +void main () { + vec4 outColor = vec4(1); + + outColor = applyFogAndShadow(outColor, fogAmount); + + if (outColor.a < alphaTest) discard; + + float rndr = min(0.6, cnoise(vec3(vexPos.x/500.0, -vexPos.z/600.0, auroraCounter))*2.3 - 1); + float rndg = cnoise(vec3(vexPos.x/600.0, vexPos.z/600.0, 1 - auroraCounter))/3; + float rndb = cnoise(vec3(vexPos.x/600.0, vexPos.z/600.0, 1 - auroraCounter))/5; + + outColor = vec4( + clamp(rndr, 0, 1), + clamp(0.5 + rndg - rndr, 0, 0.8), + clamp(0.5 + rndb, 0, 1), + max(0, 0.7 - uv.y * 0.7)/2 + ); + + float advx = xpos * 10; // was uv.x + + outColor.a *= + 1 + + 0.7 * cnoise(vec3(advx/6 + (vexPos.x)/120.0, uv.y/2 + (vexPos.z)/120.0, auroraCounter)) + + 0.5 * cnoise(vec3(advx/3 + (vexPos.x)/60.0, uv.y/1.5 + (vexPos.z)/60.0, auroraCounter)) + + 0.2 * cnoise(vec3(advx/1.5 + (vexPos.x)/30.0, uv.y + (vexPos.z)/30.0, auroraCounter)) + ; + + + //outColor.a *= 0.7; + + outColor.a *= clamp(9*uv.y - 1, 0, 1); + + //outColor.a=1; + + + outColor.a = clamp(outColor.a, 0, 1); + + outColor *= col; + + // Fade edges + // http://fooplot.com/#W3sidHlwZSI6MCwiZXEiOiJtaW4oMSxtaW4oMTAqeCwoMS14KSoxMCkpIiwiY29sb3IiOiIjMDAwMDAwIn0seyJ0eXBlIjoxMDAwLCJ3aW5kb3ciOlsiMCIsIjEiLCIwIiwiMiJdLCJzaXplIjpbNjQ5LDM5OV19XQ-- + float a = min((xpos - 0.1) * 20, (1 - xpos) * 20); + outColor.a *= min(1, a); + +#if USEOIT == 1 + OIT(clamp(outColor, vec4(0.0), vec4(1.0)), 0.0); + outGlow = vec4(1, extraGodray, 0, min(1, outColor.a * 6)); +#endif + +} diff --git a/sources/shaders-vk/aurora.interface.glsl b/sources/shaders-vk/aurora.interface.glsl new file mode 100644 index 00000000..afcc128d --- /dev/null +++ b/sources/shaders-vk/aurora.interface.glsl @@ -0,0 +1,30 @@ +// Program interface of aurora (docs/vulkan-native-shaders.md section 4). One draw per Use(): the push block +// holds only the sampler slot; every other uniform is a record member: aurora.vsh's, aurora.fsh's (its second +// auroraCounter is the same name), then fogandlight.fsh's windWaveCounter (its owner vertexwarp.vsh is not +// included). +// +// extraGodray = 0 and alphaTest = 0.001 are GLSL 330 initializers; the runtime seeds them (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 color; + vec4 rgbaTint; + vec3 rgbaAmbientIn; + vec4 rgbaLightIn; + vec4 rgbaBlockIn; + vec4 rgbaFogIn; + float fogMinIn; + float fogDensityIn; + mat4 projectionMatrix; + mat4 modelViewMatrix; + float auroraCounter; + + float extraGodray; + float alphaTest; + + float windWaveCounter; +}; diff --git a/sources/shaders-vk/aurora.vert b/sources/shaders-vk/aurora.vert new file mode 100644 index 00000000..c13ba250 --- /dev/null +++ b/sources/shaders-vk/aurora.vert @@ -0,0 +1,55 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of aurora.vsh (docs/vulkan-native-shaders.md). +// aurora.vsh declares shadowCoordsFar/Near itself under SHADOWQUALITY (it does not include shadowcoords.vsh) +// and never writes them. SHADOWQUALITY is a specialization constant, so they are declared unconditionally at +// the locations fogandlight.fsh reads, and stay unwritten as in GLSL 330. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "aurora.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in float xposIn; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec4 col; +layout(location = 2) out vec4 rgbaFog; +layout(location = 3) out float fogAmount; +layout(location = 4) out vec4 vexPos; +layout(location = 5) out float xpos; + +layout(location = 6) flat out int renderFlags; + +layout(location = OPTIMUM_LOCATION_SHADOW_COORDS_FAR) out vec4 shadowCoordsFar; +layout(location = OPTIMUM_LOCATION_SHADOW_COORDS_NEAR) out vec4 shadowCoordsNear; + +#include "vertexflagbits.glsl" +#include "fogandlight.vert.glsl" +#include "noise3d.glsl" + +void main(void) +{ + vexPos = vec4(vertexPositionIn, 1.0); + + vexPos.x += 100*cnoise(vec3((vexPos.x)/100.0, (vexPos.z)/500.0, auroraCounter/3)); + vexPos.z += 100*cnoise(vec3((vexPos.x)/120.0, (vexPos.z)/300.0, auroraCounter/3)); + + vec4 camPos = modelViewMatrix * vexPos; + + uv = uvIn; + xpos = xposIn; + col = color; + rgbaFog = rgbaFogIn; + + gl_Position = projectionMatrix * camPos; + + fogAmount = getFogLevel(vec4(vertexPositionIn, 1), fogMinIn, fogDensityIn); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/celestialobject.frag b/sources/shaders-vk/celestialobject.frag new file mode 100644 index 00000000..3bc3877d --- /dev/null +++ b/sources/shaders-vk/celestialobject.frag @@ -0,0 +1,86 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of celestialobject.fsh (docs/vulkan-native-shaders.md). Axis: GBUFFER (the G-buffer outputs). +// The vertex stage includes fogandlight.vsh (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "celestialobject.interface.glsl" + +layout(location = 2) in vec2 uv; +layout(location = 3) in vec4 color; +layout(location = 1) in vec4 rgbaFog; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 0) in vec3 vertexPosition; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 4) in vec4 fragPosition; +layout(location = 5) in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + + +#include "dither.glsl" +#include "fogandlight.frag.glsl" +#include "skycolor.glsl" +#include "underwatereffects.glsl" + +void main () { + vec4 texColor = applyFog(texture(optimumTextures2D[tex], uv) * color, 0); + + if (texColor.a < alphaTest) discard; + + // Now apply moon phase / sun lighting + float msa = int(moonSunAngle * 0.31830989 + 0.5) * 3.1415927; // the moonSunAngle rounded to either 0 or Math.Pi, no angles in between + float dotp = dot(sunPosition, moonPosition); + vec3 dirsun = normalize(sunPosition - moonPosition * dotp); + vec2 xyi = (ivec2(uv * 32) - 16) / 32.0; + float fact = (clamp((xyi.x * cos(msa) - xyi.y * sin(msa) * sign(dirsun.y)), -1, 1) + 1); + texColor.rgb *= clamp(7.1 - fact * 6.2 - dotp * 1.7, 0.0, texColor.a + 0.05); + + vec4 texGlow = vec4(glowLevel, extraGodray, 0, texColor.a); + + vec4 skyColor = vec4(1); + vec4 skyGlow = vec4(1); + float sealevelOffsetFactor = 0.06; + + getSkyColorAt(vertexPosition, sunPosition, sealevelOffsetFactor, clamp(dayLight, 0, 1), horizonFog, skyColor, skyGlow); + + vec3 skyPosNorm = normalize(vertexPosition.xyz); + float fogAmount =getFogAmountForSky(vertexPosition, skyPosNorm, sealevelOffsetFactor, horizonFog); + texColor = applyFog(texColor, fogAmount); + + outColor = texColor; + outColor.a = texColor.a; + + + if (weirdMathToMakeMoonLookNicer > 0) { + float b = pow((outColor.r+outColor.g+outColor.b) / 2.8, 1.4); + outColor.rgb *= b; + + float v = max(0.0, (1-texColor.a) * min(1, texColor.a*20)/3 - fogAmount * 0.5); + outGlow = vec4(v, v, 0, max(0.0, 4-7*dayLight)); + + outColor.rgb = max(outColor.rgb, skyColor.rgb); + } else { + + outGlow = max(texGlow, skyGlow); + + outColor.rgb = max(texColor.rgb, skyColor.rgb); + } + + float murkiness=getSkyMurkiness(); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + +#if GBUFFER == 1 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = vec4(gnormal.xyz, 0); +#endif + +} diff --git a/sources/shaders-vk/celestialobject.interface.glsl b/sources/shaders-vk/celestialobject.interface.glsl new file mode 100644 index 00000000..41a5560f --- /dev/null +++ b/sources/shaders-vk/celestialobject.interface.glsl @@ -0,0 +1,34 @@ +// Program interface of celestialobject (docs/vulkan-native-shaders.md section 4). SystemRenderSunMoon draws +// once per Use(), so the push block holds only the sampler slot; every other uniform is a record member: +// celestialobject.vsh's, celestialobject.fsh's, then fogandlight.fsh's windWaveCounter (its owner +// vertexwarp.vsh is not included) and underwatereffects.fsh's frameSize. +// +// extraGodray = 0 and alphaTest = 0.001 are GLSL 330 initializers; the runtime seeds them (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, tex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + mat4 projectionMatrix; + mat4 modelMatrix; + mat4 viewMatrix; + int extraGlow; + + float extraGodray; + float alphaTest; + float fogDensityIn; + float fogMinIn; + float horizonFog; + vec3 sunPosition; + vec3 moonPosition; + float moonSunAngle; + int weirdMathToMakeMoonLookNicer; + float dayLight; + + float windWaveCounter; + vec2 frameSize; +}; diff --git a/sources/shaders-vk/celestialobject.vert b/sources/shaders-vk/celestialobject.vert new file mode 100644 index 00000000..aefc8e75 --- /dev/null +++ b/sources/shaders-vk/celestialobject.vert @@ -0,0 +1,46 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of celestialobject.vsh (docs/vulkan-native-shaders.md). Axis: GBUFFER (fragPosition and +// gnormal, which GLSL 330 declares under SSAOLEVEL and never writes). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "celestialobject.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec2 uvIn; +layout(location = 2) in vec4 colorIn; +layout(location = 3) in int flags; + + +layout(location = 0) out vec3 vertexPosition; +layout(location = 1) out vec4 rgbaFog; +layout(location = 2) out vec2 uv; +layout(location = 3) out vec4 color; +#if GBUFFER == 1 +layout(location = 4) out vec4 fragPosition; +layout(location = 5) out vec4 gnormal; +#endif + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main(void) +{ + vec4 worldPos = modelMatrix * vec4(vertexPositionIn, 1.0); + vec4 cameraPos = viewMatrix * worldPos; + uv = uvIn; + glowLevel = (extraGlow + (flags & 0xff)) / 128.0; + color = colorIn; + color.a *= clamp(1 - getSpheresFogAmount(worldPos.xyz * 10), 0, 1); + + rgbaFog = rgbaFogIn; + gl_Position = projectionMatrix * cameraPos; + + vertexPosition = worldPos.xyz; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/cloudmap.frag b/sources/shaders-vk/cloudmap.frag new file mode 100644 index 00000000..2d852e02 --- /dev/null +++ b/sources/shaders-vk/cloudmap.frag @@ -0,0 +1,147 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of cloudmap.fsh (docs/vulkan-native-shaders.md). +// +// The dither stub: cloudmap.fsh defines NoiseFromPixelPosition(a, b, c) as vec4(0.0) before including +// skycolor.fsh, so the sky glow it samples carries no dither. skycolor.glsl includes dither.glsl, whose +// function definition that macro would rewrite, so dither.glsl is included first and the stub is defined +// after it: the function exists (unused), and every call skycolor.glsl makes still expands to vec4(0.0), +// exactly as in GLSL 330 (family 5 decision). +// +// The DYNLIGHTS preprocessor branches are OPTIMUM_DYNLIGHTS branches with the same code; the arrays they +// gated are declared unconditionally (contract section 5). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "cloudmap.interface.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec2 ndc; + +layout(location = 0) out vec4 tile; +layout(location = 1) out vec4 colour; + +#define rgbaFog rgbaFogIn +#include "fogandlight.frag.glsl" +#include "dither.glsl" +#define NoiseFromPixelPosition(a, b, c) vec4(0.0) +#include "skycolor.glsl" + +vec4 getPointLightRgbvl(vec3 worldPos) { + if (OPTIMUM_DYNLIGHTS == 0) { + return vec4(0); + } + + vec4 pointColSum = vec4(0); + float bPointBrightSum = 0; + + for (int i = 0; i < pointLightQuantity; i++) { + vec4 lightVec = -vec4(worldPos.x - pointLights[i].x, worldPos.y - pointLights[i].y, worldPos.z - pointLights[i].z, 1); + vec3 color = pointLightColors[i]; + if (color.r > 10) { + color /= 200; // This is a Lightning strike point light + } + + float dist = pow(1.35, length(lightVec) / 4.0); + float bright = (color.r + color.g + color.b); + float strength = min(bright/3, bright / dist); + + pointColSum.w = max(pointColSum.w, strength); + bPointBrightSum += strength; + + pointColSum.r += color.r * strength; + pointColSum.g += color.g * strength; + pointColSum.b += color.b * strength; + } + + if (bPointBrightSum > 0) { + pointColSum.rgb /= max(1, bPointBrightSum); + } + +// pointColSum.w /= max(1, glitchStrengthFL * 2); + + return pointColSum; +} + +float getFogLevel(vec4 worldPos, float fogMin, float fogDensity) { + float depth = length(worldPos.xyz); + float clampedDepth = min(250, depth); + float heightDiff = worldPos.y - flatFogStart; + float extraDistanceFog = max(-flatFogDensity * clampedDepth * (flatFogStart) / 60, 0); // div 60 was 160 before, at 160 thick flat fog looks broken when looking at trees + float distanceFog = 1 - 1 / exp(clampedDepth * fogDensity + extraDistanceFog); + + float flatFog = 1 - 1 / exp(heightDiff * flatFogDensity); + + float val = max(flatFog, distanceFog); + float nearnessToPlayer = clamp((8-depth)/8, 0, 0.9); + val = max(min(0.04, val), val - nearnessToPlayer); + + // Needs to be added after so that underwater fog still gets applied. + val += fogMin; + + return clamp(val, 0, 1); +} + +void main(){ + + const float cloudTileSize = 50.0; + + vec4 data1 = texelFetch(optimumTextures2D[mapData1], ivec2(uv * width), 0); + vec4 data2 = texelFetch(optimumTextures2D[mapData2], ivec2(uv * width), 0); + float thinCloudMode = data1.r; + float selfThickness = data1.g; + float cloudOpaqueness = data1.b; + float cloudBrightness = data1.a; + float undulatingModeness = data2.r; + + vec2 v = uv * width - width / 2.0; + vec3 tilePosition = vec3(mapOffset.xz + v * cloudTileSize, mapOffset.y).xzy; + vec3 viewSpace = vec3(viewMatrix * vec4(tilePosition, 1.0)); + + float undulate = gnoise(vec3((v + mapOffsetCentre) * vec2(0.5, 0.2), time * 0.15)) * undulatingModeness; + + float linearfade = abs(length((uv + mapOffset.xz/cloudTileSize/width) * 2.0 - 1.0)); + + float opaque = min(1.0, cloudOpaqueness * min(1.0, 10.0 * selfThickness)) * 2.0; + opaque += undulatingModeness * 4.0; + opaque *= alpha; + opaque *= smoothstep(0.95, 0.9, linearfade); + + float greyscale = smoothstep(0.0, 1.1, dayLight) + * (0.1 + cloudBrightness * 0.7) + * globalCloudBrightness + ; + greyscale *= mix(1, 0.4 + 0.5*(undulate + 0.5), undulatingModeness); + + float height = (500.0 - 500.0 * thinCloudMode) + * max(0.0, 1.0 - 3.0 * thinCloudMode) + * pow(selfThickness - 0.1, 2.0); + + float lo = (-12.5 - height * 0.05 - undulate * 25.0) / cloudTileSize; + float hi = ( 12.5 + height - undulate * 25.0) / cloudTileSize; + + colour = vec4(vec3(greyscale), 1.0); + + float sealevelOffsetFactor = 0.25; + float dayLight = 1; + float horizonFog = 0; + // Due to earth curvature the clouds are actually lower, so we do +100 to not have them dismissed during sunglow coloring + vec4 skyGlow = getSkyGlowAt(vec3(tilePosition.x, 100.0, tilePosition.z), sunPosition, sealevelOffsetFactor, clamp(dayLight, 0, 1), horizonFog, 0.7); + colour.rgb *= mix(vec3(1.0), 1.2 * skyGlow.rgb, skyGlow.a); + colour.rgb *= max(1, 0.9 + skyGlow.a/10); + + + float fogAmount = getFogLevel(vec4(tilePosition.x, 0.0, tilePosition.z, 1.0), fogMinIn, fogDensityIn); + float fogAmountf = clamp(fogAmount + clamp(1 - 4 * dayLight, -0.04, 1), 0, 1); + + colour.rgb = mix(colour.rgb, rgbaFogIn.rgb, fogAmountf); + colour.rgb += getPointLightRgbvl(viewSpace).rgb * 0.7; + colour.rgb += vec3(0.1, 0.5, 0.1) * nightVisionStrength; + + tile.r = opaque; + tile.g = 0.0; + tile.b = lo; + tile.a = hi; + +} diff --git a/sources/shaders-vk/cloudmap.interface.glsl b/sources/shaders-vk/cloudmap.interface.glsl new file mode 100644 index 00000000..ce7fe39e --- /dev/null +++ b/sources/shaders-vk/cloudmap.interface.glsl @@ -0,0 +1,43 @@ +// Program interface of cloudmap (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// Use(), so the push block holds only the sampler slots, in cloudmap.fsh's declaration order, and every +// other uniform is a record member: cloudmap.fsh's own, then the program uniforms of fogandlight.fsh and +// fogspheres.ash, whose frame owners (fogandlight.vsh, vertexwarp.vsh) this program does not include. +// +// pointLightQuantity, pointLights, pointLightColors and nightVisionStrength are cloudmap.fsh's own uniforms +// (it declares them itself), not frame members. The arrays are sized by FrameGlobals.MaxDynamicLights (100), +// as the frame block's copies are (section 5); pointLightQuantity bounds the loop. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, mapData1); + OPTIMUM_SAMPLER_SLOT(sampler2D, mapData2); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + float dayLight; + float globalCloudBrightness; + float time; + vec4 rgbaFogIn; + float fogMinIn; + float fogDensityIn; + vec3 sunPosition; + float nightVisionStrength; + float alpha; + + float width; + vec3 mapOffset; + vec2 mapOffsetCentre; + mat4 viewMatrix; + + int pointLightQuantity; + vec3 pointLights[100]; + vec3 pointLightColors[100]; + + float flatFogDensity; + float flatFogStart; + float viewDistance; + float viewDistanceLod0; + float windWaveCounter; + float fogSpheres[24]; + int fogSphereQuantity; +}; diff --git a/sources/shaders-vk/cloudmap.vert b/sources/shaders-vk/cloudmap.vert new file mode 100644 index 00000000..f8abe286 --- /dev/null +++ b/sources/shaders-vk/cloudmap.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of cloudmap.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "cloudmap.interface.glsl" + +layout(location = 0) in vec2 p; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec2 ndc; + +void main(){ + gl_Position = vec4(p, 0.0, 1.0); + uv = p * 0.5 + 0.5; + ndc = p; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/decals.frag b/sources/shaders-vk/decals.frag new file mode 100644 index 00000000..9abaee3f --- /dev/null +++ b/sources/shaders-vk/decals.frag @@ -0,0 +1,50 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of decals.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// The motion writer goes through include/motion.glsl with reactive 0 and writer depth gl_FragCoord.z; its +// behind-camera result vec4(0, 0, 0, 0) is the GLSL 330 vec4(0.0). GBUFFER is an axis only because it moves +// the motion attachment (TAAMOTIONLOCATION 4 with the G-buffer, 2 without). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "decals.interface.glsl" + +layout(location = 0) in vec2 decalUv; +layout(location = 1) in vec2 blockUv; +layout(location = 2) in vec2 decalUvSize; +layout(location = 4) in vec4 color; +layout(location = 3) in vec2 decalUvStart; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; + +#if TAAMOTION == 1 +layout(location = 5) in vec4 taaPrevClip; +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#endif + +#include "motion.glsl" + +void main() +{ + vec2 uv = vec2(decalUvStart.x + mod(decalUv.x, decalUvSize.x), decalUvStart.y + mod(decalUv.y, decalUvSize.y)); + + outColor = color * texture(optimumTextures2D[decalTexture], uv); + + + float blockAlpha = texture(optimumTextures2D[blockTexture], blockUv).a; + if (outColor.a < 0.01 || blockAlpha < 0.01) discard; + + outGlow = vec4(0, 0, 0, outColor.a); + +#if TAAMOTION == 1 + // b = 0: a decal overlays a static-or-swaying block surface and its vector is that surface's own. + // a = gl_FragCoord.z, the depth the decal itself writes. + outMotion = optimumWriteMotion(taaPrevClip, taaRenderSize, taaJitterPx, 0.0, gl_FragCoord.z); +#endif +} diff --git a/sources/shaders-vk/decals.interface.glsl b/sources/shaders-vk/decals.interface.glsl new file mode 100644 index 00000000..20020600 --- /dev/null +++ b/sources/shaders-vk/decals.interface.glsl @@ -0,0 +1,40 @@ +// Program interface of decals (docs/vulkan-native-shaders.md section 4). Decals are pooled like chunks: +// the push block holds the two sampler slots (decals.fsh order) and the DRAW uniforms origin and +// modelViewMatrix (84 B). The record holds the rest: decals.vsh's, vertexwarp.vsh's previous-frame +// mirrors, then decals.fsh's, each in declaration order. The TAA uniforms are declared in every variant. +// The prev* initializers are seeded by the runtime (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, decalTexture); + OPTIMUM_SAMPLER_SLOT(sampler2D, blockTexture); + vec3 origin; + mat4 modelViewMatrix; +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogDensityIn; + float fogMinIn; + mat4 projectionMatrix; + mat4 prevProjectionMatrix; + mat4 prevModelViewMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 taaRenderSize; + vec2 taaJitterPx; +}; diff --git a/sources/shaders-vk/decals.vert b/sources/shaders-vk/decals.vert new file mode 100644 index 00000000..638e1fb0 --- /dev/null +++ b/sources/shaders-vk/decals.vert @@ -0,0 +1,101 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of decals.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: USESSBO (attribute layout and the FaceData buffer), TAAMOTION (the previous clip position). +// sources/shaders/decals.fsh explains why decals write the motion attachment themselves. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "decals.interface.glsl" + + #if USESSBO == 1 +layout(location = 0) in vec4 rgbaLightIn; +layout(location = 1) in vec2 blockUvIn; +layout(location = 2) in vec2 decalUvSizeIn; +layout(location = 3) in vec2 decalUvStartIn; + #else +layout(location = 0) in vec3 vertexPos; +layout(location = 1) in vec2 decalUvIn; +// rgb = block light, a=sun light level +layout(location = 2) in vec4 rgbaLightIn; +layout(location = 3) in int renderFlagsIn; +layout(location = 4) in vec2 blockUvIn; +layout(location = 5) in vec2 decalUvSizeIn; +layout(location = 6) in vec2 decalUvStartIn; // Argh >.< + #endif + +layout(location = 0) out vec2 decalUv; +layout(location = 1) out vec2 blockUv; +layout(location = 2) out vec2 decalUvSize; +layout(location = 3) out vec2 decalUvStart; +layout(location = 4) out vec4 color; + +#if TAAMOTION == 1 +layout(location = 5) out vec4 taaPrevClip; +#endif + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" + + #if USESSBO == 1 +layout(std430, set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_FACE_DATA) readonly buffer faceDataBuf { FaceData faces[]; }; + #endif + + +void main () { + #if USESSBO == 1 + FaceData vdata = faces[gl_VertexIndex / 4]; + int vIndex = gl_VertexIndex & 0x03; + int renderFlagsIn = vdata.flags[vIndex]; + vec3 vertexPos = vdata.xyz + ((vIndex + 1) & 2) * vdata.xyzA + (vIndex & 2) * vdata.xyzB; + #endif + vec4 worldpos = vec4(vertexPos + origin, 1.0); + + worldpos = applyVertexWarping(renderFlagsIn, worldpos); + worldpos = applyGlobalWarping(worldpos); + + vec4 cameraPos = modelViewMatrix * worldpos; + + gl_Position = projectionMatrix * cameraPos; + + + color = applyLight(rgbaAmbientIn, rgbaLightIn, renderFlagsIn, cameraPos); + color = applyFog(worldpos, color, rgbaFogIn, fogMinIn, fogDensityIn); + color.a = 1; + + // We pretend the decal is closer to the camera to enforce it + // always being drawn on top + //gl_Position.w += 0.0012; - not enough for leaves :o + int zOffset = 1 + ((renderFlagsIn & ZOffsetBitMask) >> 8); + gl_Position.w += zOffset * 0.00025 / max(0.1, gl_Position.z * 0.05); + + #if USESSBO == 1 + decalUv = UnpackUv(vdata, vIndex, 0, 0); + #else + decalUv = decalUvIn; + #endif + blockUv = blockUvIn; + decalUvSize = decalUvSizeIn; + decalUvStart = decalUvStartIn; + +#if TAAMOTION == 1 + // The same vertex, one frame ago, through the same code path as the terrain under it, with the + // "pretend the decal is closer" w-offset replayed so it is not reported as motion. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = vec4(vertexPos + origin + cameraPosDelta, 1.0); + taaPrevPos = applyVertexWarpingState(taaPrev, renderFlagsIn, taaPrevPos); + taaPrevPos = applyGlobalWarpingState(taaPrev, taaPrevPos); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + + int taaPrevZOffset = 1 + ((renderFlagsIn & ZOffsetBitMask) >> 8); + taaPrevClip.w += taaPrevZOffset * 0.00025 / max(0.1, taaPrevClip.z * 0.05); + } +#endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/nightsky.frag b/sources/shaders-vk/nightsky.frag new file mode 100644 index 00000000..f903c5d9 --- /dev/null +++ b/sources/shaders-vk/nightsky.frag @@ -0,0 +1,47 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of nightsky.fsh (docs/vulkan-native-shaders.md). Axis: GBUFFER (the G-buffer outputs). +// outColor has no location in GLSL 330; it is location 0, the one GL and ProgramInterfaceLayout assign. +// worldPosY is read by nothing and written by no vertex stage, as in GLSL 330. +// The vertex stage includes fogandlight.vsh (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "nightsky.interface.glsl" + +layout(location = 0) in vec3 texCoords; +layout(location = 1) in float worldPosY; +layout(location = 2) in float nightVisionStrengthv; + + +layout(location = 0) out vec4 outColor; +#if GBUFFER == 1 +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + + +#include "dither.glsl" +#include "fogandlight.frag.glsl" +#include "underwatereffects.glsl" + +void main () { + vec4 skyCol = texture (optimumTexturesCube[ctex], texCoords) + NoiseFromPixelPosition(ivec2(gl_FragCoord.xy), ditherSeed, horizontalResolution); + skyCol -= 0.03f; + skyCol.rgb *= 2; + skyCol.a = max(0.0, 1 - 2*(dayLight - 0.05)); + + outColor = skyCol; + outColor.rgb += vec3(0.1, 0.5, 0.1) * nightVisionStrengthv; + + float murkiness=getSkyMurkiness(); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + +#if GBUFFER == 1 + outGPosition = vec4(0); + outGNormal = vec4(0); +#endif + +} diff --git a/sources/shaders-vk/nightsky.interface.glsl b/sources/shaders-vk/nightsky.interface.glsl new file mode 100644 index 00000000..bd77afc2 --- /dev/null +++ b/sources/shaders-vk/nightsky.interface.glsl @@ -0,0 +1,28 @@ +// Program interface of nightsky (docs/vulkan-native-shaders.md section 4). One draw per Use(): the push +// block holds only the cube map's slot; every other uniform is a record member: nightsky.vsh's, +// nightsky.fsh's (ditherSeed, horizontalResolution and playerToSealevelOffset are its own here, because +// skycolor.fsh, their frame owner, is not included), then fogandlight.fsh's windWaveCounter and +// underwatereffects.fsh's frameSize. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(samplerCube, ctex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelMatrix; + mat4 viewMatrix; + + vec4 rgbaFog; + int ditherSeed; + int horizontalResolution; + float dayLight; + float horizonFog; + float playerToSealevelOffset; + float fogDensityIn; + float fogMinIn; + + float windWaveCounter; + vec2 frameSize; +}; diff --git a/sources/shaders-vk/nightsky.vert b/sources/shaders-vk/nightsky.vert new file mode 100644 index 00000000..d225cbc4 --- /dev/null +++ b/sources/shaders-vk/nightsky.vert @@ -0,0 +1,28 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of nightsky.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "nightsky.interface.glsl" + +layout(location = 0) in vec3 vertexPosition; + +layout(location = 0) out vec3 texCoords; +layout(location = 2) out float nightVisionStrengthv; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main () { + texCoords = vertexPosition; + vec4 worldPos = modelMatrix * vec4(vertexPosition, 1.0); + nightVisionStrengthv = nightVisionStrength * 0.33; + + gl_Position = projectionMatrix * viewMatrix * worldPos; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/particlescube.frag b/sources/shaders-vk/particlescube.frag new file mode 100644 index 00000000..ad0f4422 --- /dev/null +++ b/sources/shaders-vk/particlescube.frag @@ -0,0 +1,86 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of particlescube.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// SHADOWQUALITY is a specialization-constant branch. The motion writer is the section 7 exception: behind the +// previous camera it keeps reactive 1 (optimumWriteReactiveOnly(1.0) is the GLSL 330 vec4(0, 0, 1, 0)), and +// otherwise it keeps its writer depth, calling optimumMotionVector directly. +// The vertex stage includes fogandlight.vsh and vertexwarp.vsh, which own the flatFogDensity, fogSpheres and +// windWaveCounter that fogandlight.fsh reads here (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "particlescube.interface.glsl" + +layout(location = 0) in vec4 color; +layout(location = 15) in vec2 uv; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 3) in float fogAmount; +layout(location = 1) in vec4 rgbaFog; +layout(location = 2) in vec3 normal; +layout(location = 4) in vec4 worldPos; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 6) in vec4 fragPosition; +layout(location = 7) in vec4 gnormal; +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +// TAAMOTIONLOCATION: 4 with the SSAO G-buffer, 2 without. +#if TAAMOTION == 1 +layout(location = 5) in vec4 taaPrevClip; +#if GBUFFER == 1 +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#endif + +#include "fogandlight.frag.glsl" +#include "underwatereffects.glsl" +#include "motion.glsl" + +void main() +{ + // Declared before the branch that assigns it (contract section 5); both paths overwrite the 0. + float intensity = 0.0; + if (OPTIMUM_SHADOWQUALITY > 0) { + intensity = 0.34 + (1 - shadowIntensity)/8.0; // this was 0.45, which makes shadow acne visible on blocks + } else { + intensity = 0.45; + } + + + + float murkiness = getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadowWithNormal(color, 0, normal, 1, intensity, worldPos.xyz); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadowWithNormal(color, fogAmount, normal, 1, intensity, worldPos.xyz); + } + + outGlow = vec4(glowLevel, 0, 0, outColor.a); + //outColor = vec4((normal.x + 1) / 2.0, (normal.y + 1) / 2.0, (normal.z + 1) / 2.0, 1); + +#if GBUFFER == 1 + outGPosition = vec4(fragPosition.xyz, fogAmount + glowLevel); + outGNormal = vec4(gnormal.xyz, outColor.a); +#endif + +#if TAAMOTION == 1 + // b = 1: a cube particle is always reactive. a = gl_FragCoord.z: cube particles write depth, so this is + // the writer depth the resolve accepts. Behind the previous camera rg and a are zero and b stays 1. + if (taaPrevClip.w <= 1e-6) { + outMotion = optimumWriteReactiveOnly(1.0); + } else { + outMotion = vec4(optimumMotionVector(taaPrevClip, taaRenderSize, taaJitterPx), 1.0, gl_FragCoord.z); + } +#endif +} diff --git a/sources/shaders-vk/particlescube.interface.glsl b/sources/shaders-vk/particlescube.interface.glsl new file mode 100644 index 00000000..67c2f40c --- /dev/null +++ b/sources/shaders-vk/particlescube.interface.glsl @@ -0,0 +1,38 @@ +// Program interface of particlescube (docs/vulkan-native-shaders.md section 4). Particles have no DRAW +// uniforms and this program samples nothing, so there is no push block; every uniform is a record member: +// particlescube.vsh's, vertexwarp.vsh's previous-frame mirrors, then particlescube.fsh's and +// underwatereffects.fsh's, each in declaration order. The TAA uniforms are declared in every variant +// because collectUniformNames sees them whatever TAAMOTION is. +// +// The prev* warp uniforms carry GLSL 330 initializers (prevWindWaveIntensity = 1, ...); the runtime seeds +// them from the GLSL 330 declarations (docs/vulkan-native-shaders.md section 8). +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogMinIn; + float fogDensityIn; + mat4 projectionMatrix; + mat4 modelViewMatrix; + mat4 prevProjectionMatrix; + mat4 prevModelViewMatrix; + vec3 cameraPosDelta; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 taaRenderSize; + vec2 taaJitterPx; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/particlescube.vert b/sources/shaders-vk/particlescube.vert new file mode 100644 index 00000000..451a8c04 --- /dev/null +++ b/sources/shaders-vk/particlescube.vert @@ -0,0 +1,134 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of particlescube.vsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// Axes: VEC3SCALE (the scale attribute's type), TAAMOTION (the previous clip position), GBUFFER (the +// G-buffer varyings). sources/shaders/particlescube.vsh explains the camera-only previous position. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "particlescube.interface.glsl" + +layout (location = 0) in vec3 vertexPosition; // Per vertex +layout (location = 1) in vec4 normalv; // Per vertex +layout (location = 2) in vec2 uv; // Per vertex +layout (location = 3) in int renderFlags; // Per instance + +layout (location = 4) in vec3 particlePosition; // Per instance (=per particle) +#if VEC3SCALE == 1 +layout (location = 5) in vec3 scale; // Per instance +#else +layout (location = 5) in float scale; // Per instance +#endif +layout (location = 6) in vec4 particleDir; // Per instance +layout (location = 7) in vec4 rgbaLightIn; // Per instance +layout (location = 8) in vec4 rgbaBlockIn; // Per instance + +layout(location = 0) out vec4 color; +layout(location = 1) out vec4 rgbaFog; +layout(location = 2) out vec3 normal; +layout(location = 3) out float fogAmount; +layout(location = 4) out vec4 worldPos; + +#if TAAMOTION == 1 +layout(location = 5) out vec4 taaPrevClip; +#endif +#if GBUFFER == 1 +layout(location = 6) out vec4 fragPosition; +layout(location = 7) out vec4 gnormal; +#endif + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" + +#define M_PI 3.1415926535897932384626433832795 + +mat4 rotation3d(vec3 axis, float angle) { + axis = normalize(axis); + float s = sin(angle); + float c = cos(angle); + float oc = 1.0 - c; + + return mat4( + oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0, + oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0, + oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0, + 0.0, 0.0, 0.0, 1.0 + ); +} + +float atan2(in float y, in float x) +{ + bool s = (abs(x) > abs(y)); + return mix(M_PI/2.0 - atan(x,y), atan(y,x), s); +} + + +#if TAAMOTION == 1 +// The position half of main() below, as a function of the warp state and the particle's position, so the +// same code is evaluated for this frame and for the previous one. +vec4 taaParticleWorldPos(WarpState st, vec3 taaParticlePosition) +{ + vec4 taaWorldPos; +#if VEC3SCALE == 1 + mat4 rotMat = rotation3d(vec3(0,1,0), atan2(particleDir.z, particleDir.x) + particleDir.w); + taaWorldPos = rotMat * (vec4(vertexPosition,1.0) * vec4(scale,1.0)) + vec4(taaParticlePosition, 1.0); + taaWorldPos.w=1; +#else + taaWorldPos = vec4(vertexPosition * scale + taaParticlePosition, 1.0); +#endif + + taaWorldPos = applyVertexWarpingState(st, renderFlags, taaWorldPos); + taaWorldPos = applyGlobalWarpingState(st, taaWorldPos); + return taaWorldPos; +} +#endif + + +void main() +{ +#if VEC3SCALE == 1 + mat4 rotMat = rotation3d(vec3(0,1,0), atan2(particleDir.z, particleDir.x) + particleDir.w); + worldPos = rotMat * (vec4(vertexPosition,1.0) * vec4(scale,1.0)) + vec4(particlePosition, 1.0); + worldPos.w=1; +#else + worldPos = vec4(vertexPosition * scale + particlePosition, 1.0); +#endif + + worldPos = applyVertexWarping(renderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + vec4 cameraPos = modelViewMatrix * worldPos; + + gl_Position = projectionMatrix * cameraPos; + + int flags = min(255, 2 * (renderFlags & 0xff)); // increase the glow on cube particles + color = applyLight(rgbaAmbientIn, rgbaLightIn, flags, cameraPos) * rgbaBlockIn; + + fogAmount = getFogLevel(vec4(particlePosition, 0), fogMinIn, fogDensityIn); + rgbaFog = rgbaFogIn; + normal = normalv.xyz; + + calcShadowMapCoords(modelViewMatrix, worldPos); + +#if GBUFFER == 1 + + fragPosition = cameraPos; + gnormal = modelViewMatrix * vec4(normal.xyz, 0.25); +#endif + +#if TAAMOTION == 1 + // The same vertex, one frame ago: the particle where it is now, moved by exactly the camera's own + // motion, the warp re-evaluated with the previous frame's counters, and the previous UNJITTERED + // projection with the previous CameraMatrixOrigin. + { + WarpState taaPrev = previousWarpState(); + vec4 taaPrevPos = taaParticleWorldPos(taaPrev, particlePosition + cameraPosDelta); + taaPrevClip = prevProjectionMatrix * (prevModelViewMatrix * taaPrevPos); + } +#endif + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/particlesquad.frag b/sources/shaders-vk/particlesquad.frag new file mode 100644 index 00000000..d564eb9c --- /dev/null +++ b/sources/shaders-vk/particlesquad.frag @@ -0,0 +1,55 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of particlesquad.fsh (docs/vulkan-native-shaders.md). +// USEOIT is an axis because oit.fsh gates its outputs on it. The program is registered with Oit = true, so +// only USEOIT=1 is ever selected; the USEOIT=0 variant exists because the builder compiles every axis value, +// and there the OIT call (which has nothing to write to) is compiled out (docs/vulkan-native-shaders.md +// section 9.1). +// The vertex stage includes fogandlight.vsh and vertexwarp.vsh (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#define OPTIMUM_FRAME_OWNER_VERTEXWARP_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "varyings.glsl" +#include "particlesquad.interface.glsl" + +layout(location = 0) in vec4 color; +layout(location = 5) in vec2 uv; +layout(location = 1) in vec4 rgbaFog; +layout(location = 2) in vec3 vexPos; +layout(location = 3) in float fogAmount; +layout(location = OPTIMUM_LOCATION_GLOW_LEVEL) in float glowLevel; +layout(location = 4) in float extraWeight; + + + +#include "fogandlight.frag.glsl" +#include "underwatereffects.glsl" +#include "oit.glsl" + +void main() +{ + vec4 outColor; + + float murkiness=getUnderwaterMurkiness(); + if (murkiness > 0) { + outColor = applyFogAndShadow(color, 0); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + } else { + outColor = applyFogAndShadow(color, fogAmount); + } + + vec2 uvdist = vec2( + max(max(0.0, 0.1 - uv.x), max(0.0, uv.x - 0.9)), + max(max(0.0, 0.1 - uv.y), max(0.0, uv.y - 0.9)) + ); + + outColor.a *= 1 - length(uvdist)*10; + +#if USEOIT == 1 + OIT(clamp(outColor, vec4(0.0), vec4(1.0)), glowLevel); +#endif + +} diff --git a/sources/shaders-vk/particlesquad.interface.glsl b/sources/shaders-vk/particlesquad.interface.glsl new file mode 100644 index 00000000..1d9b99db --- /dev/null +++ b/sources/shaders-vk/particlesquad.interface.glsl @@ -0,0 +1,33 @@ +// Program interface of particlesquad (docs/vulkan-native-shaders.md section 4). Particles have no DRAW +// uniforms, so the push block holds only the sampler slot; everything else is a record member: +// particlesquad.vsh's, vertexwarp.vsh's previous-frame mirrors, then underwatereffects.fsh's frameSize. +// The prev* initializers are seeded by the runtime (section 8). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, particleTex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + float fogMinIn; + float fogDensityIn; + mat4 projectionMatrix; + mat4 modelViewMatrix; + + float prevTimeCounter; + float prevWindWaveCounter; + float prevWindWaveCounterHighFreq; + float prevWaterWaveCounter; + float prevWindSpeed; + vec3 prevPlayerpos; + float prevGlobalWarpIntensity; + float prevGlitchWaviness; + float prevWindWaveIntensity; + float prevWaterWaveIntensity; + int prevPerceptionEffectId; + float prevPerceptionEffectIntensity; + + vec2 frameSize; +}; diff --git a/sources/shaders-vk/particlesquad.vert b/sources/shaders-vk/particlesquad.vert new file mode 100644 index 00000000..5658fe31 --- /dev/null +++ b/sources/shaders-vk/particlesquad.vert @@ -0,0 +1,109 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of particlesquad.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "particlesquad.interface.glsl" + +layout (location = 0) in vec3 vertexPosition; // Per vertex +layout (location = 1) in vec2 uvIn; // Per vertex +layout (location = 2) in vec4 baseColor; // Per vertex + +layout (location = 3) in int renderFlags; // Per instance +layout (location = 4) in vec3 particlePosition; // Per instance +layout (location = 5) in float scale; // Per instance +layout (location = 6) in vec4 particleDir; // Per instance +layout (location = 7) in vec4 rgbaLightIn; // Per instance +layout (location = 8) in vec4 rgbaBlockIn; // Per instance + +layout(location = 0) out vec4 color; +layout(location = 1) out vec4 rgbaFog; +layout(location = 2) out vec3 vexPos; +layout(location = 3) out float fogAmount; +layout(location = 4) out float extraWeight; +layout(location = 5) out vec2 uv; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" +#include "vertexwarp.glsl" + +mat4 rotationZ( in float angle ) { + return mat4( cos(angle), -sin(angle), 0, 0, + sin(angle), cos(angle), 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1); +} + + +void main() +{ + mat4 mvmat = modelViewMatrix; + // This makes all snow particles submerged :< + //vexPos = vertexPosition * scale - 0.125 * scale; + + vexPos = vertexPosition * scale; + + bool rainParticle = renderFlags < 0; //(renderFlags & (1<<31)) > 0; + + extraWeight = (renderFlags & (1<<9)) > 0 ? 1 : 10; + + uv = uvIn; + if (rainParticle) uv = vec2(0.5, 0.5); + + // 1. Translate the particle + mvmat[3] = mvmat * vec4(particlePosition, 1.0); + + if (rainParticle) { + vec3 u = particleDir.xyz; // your input vector + vec3 v = vec3(0.0, -1.0, 0.0); // your other input vector + + u = normalize(u); + + float zangle = acos( dot( u.xy, v.xy ) ); + mvmat = mvmat * rotationZ(zangle); + } + + + // 2. Billboard the particle + mvmat[0].xyz = vec3(1.0, 0.0, 0.0); + + if (!rainParticle) { + mvmat[1].xyz = vec3(0.0, 1.0, 0.0); + } + + mvmat[2].xyz = vec3(0.0, 0.0, 1.0); + + + + // 3. Lighting + vec4 worldPos = vec4(vexPos, 1.0); + if (rainParticle) { + worldPos.y = worldPos.y * 8 - 3; + worldPos.xz /= 3.5; + } + + + worldPos = applyVertexWarping(renderFlags, worldPos); + worldPos = applyGlobalWarping(worldPos); + + vec4 cameraPos = mvmat * worldPos; + color = baseColor * applyLight(rgbaAmbientIn, rgbaLightIn, renderFlags, cameraPos) * rgbaBlockIn; + color.a = rgbaBlockIn.a; + rgbaFog = rgbaFogIn; + + if (rainParticle) { + color.a = min(1, 1.05*color.a * (1.2 - clamp(1 - 7*vexPos.y, 0, 1))); + } + + calcShadowMapCoords(mvmat, vec4(worldPos.x + particlePosition.x, worldPos.y + particlePosition.y, worldPos.z + particlePosition.z, worldPos.w)); + + // 4. Done. + gl_Position = projectionMatrix * cameraPos; + fogAmount = getFogLevel(vec4(particlePosition, 0), fogMinIn, fogDensityIn); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/particlesquad2d.frag b/sources/shaders-vk/particlesquad2d.frag new file mode 100644 index 00000000..b9bef5e3 --- /dev/null +++ b/sources/shaders-vk/particlesquad2d.frag @@ -0,0 +1,51 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of particlesquad2d.fsh (docs/vulkan-native-shaders.md). +// USEOIT is an axis because oit.fsh gates its outputs on it; the program is registered with Oit = true, and +// in the never-selected USEOIT=0 variant the writes to oit.fsh's outputs are compiled out (family 5 decision). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "particlesquad2d.interface.glsl" + +layout(location = 0) in vec4 color; +layout(location = 1) in vec2 uv; +layout(location = 2) in float glowLevel; + + + +#include "oit.glsl" + +void main() +{ + vec4 outColor; + + if (heldItemMode > 0) { + // Ensure held item always being in the front + gl_FragDepth = gl_FragCoord.z / 20; + } else { + gl_FragDepth = gl_FragCoord.z; + } + + if (withTexture > 0) { + outColor = color * texture(optimumTextures2D[particleTex], uv); + } else { + outColor = color; + } + + if (outColor.a < 0.002) discard; + + +#if USEOIT == 1 + if (oitPass > 0) { + // Dunno why but with this modifier the torch particles look more similar when held versus placed + outColor.a*=1; + + OIT(outColor, glowLevel); + + } else { + OITreveal = outColor; + } +#endif +} diff --git a/sources/shaders-vk/particlesquad2d.interface.glsl b/sources/shaders-vk/particlesquad2d.interface.glsl new file mode 100644 index 00000000..17cb7782 --- /dev/null +++ b/sources/shaders-vk/particlesquad2d.interface.glsl @@ -0,0 +1,17 @@ +// Program interface of particlesquad2d (docs/vulkan-native-shaders.md section 4). Particles have no DRAW +// uniforms, so the push block holds only the sampler slot; everything else is a record member, +// particlesquad2d.vsh's then particlesquad2d.fsh's, each in declaration order. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, particleTex); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 projectionMatrix; + mat4 modelViewMatrix; + + int oitPass; + int withTexture; + int heldItemMode; +}; diff --git a/sources/shaders-vk/particlesquad2d.vert b/sources/shaders-vk/particlesquad2d.vert new file mode 100644 index 00000000..f45c7a58 --- /dev/null +++ b/sources/shaders-vk/particlesquad2d.vert @@ -0,0 +1,36 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of particlesquad2d.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "particlesquad2d.interface.glsl" + +layout (location = 0) in vec3 vertexPosition; // Per vertex +layout (location = 1) in vec2 uvIn; // Per vertex +layout (location = 2) in vec4 baseColor; // Per vertex + +layout (location = 3) in vec4 inColor; // Per instance +layout (location = 4) in vec3 particlePosition; // Per instance +layout (location = 5) in float scale; // Per instance +layout (location = 6) in float inGlow; // Per instance + +layout(location = 0) out vec4 color; +layout(location = 1) out vec2 uv; +layout(location = 2) out float glowLevel; + +void main() +{ + color = baseColor * inColor; + uv = uvIn; + glowLevel = inGlow; + + vec3 pos = vec3(vertexPosition.x * scale, vertexPosition.y * scale, vertexPosition.z); + + vec4 cameraPos = modelViewMatrix * vec4(pos + particlePosition, 1.0); + gl_Position = projectionMatrix * cameraPos; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders-vk/sky.frag b/sources/shaders-vk/sky.frag new file mode 100644 index 00000000..09cd6e67 --- /dev/null +++ b/sources/shaders-vk/sky.frag @@ -0,0 +1,49 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of sky.fsh (docs/vulkan-native-shaders.md). Axis: GBUFFER (the G-buffer outputs). +// The vertex stage includes fogandlight.vsh, which owns the flatFogDensity and fogSpheres that +// fogandlight.fsh and skycolor.fsh read here (contract section 3, cross-stage owners). +#define OPTIMUM_FRAME_OWNER_FOGANDLIGHT_VSH +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "sky.interface.glsl" + +layout(location = 0) in vec3 vertexPosition; +layout(location = 1) in vec4 rgbaFog; +layout(location = 2) in float nightVisionStrengthv; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER == 1 +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +#include "dither.glsl" +#include "fogandlight.frag.glsl" +#include "skycolor.glsl" +#include "underwatereffects.glsl" + +void main() +{ + outColor = vec4(1); + outGlow = vec4(1); + float sealevelOffsetFactor = 0.25; + getSkyColorAt(vertexPosition, sunPosition, sealevelOffsetFactor, clamp(dayLight, 0, 1), horizonFog, outColor, outGlow); + + if (psychedelicStrength > Epsilon) outColor = applyPsychedelicEffect(outColor, vertexPosition.xyz/2, 0); + + float murkiness = max(0.0, getSkyMurkiness() - 14*fogDensityIn); + outColor.rgb = applyUnderwaterEffects(outColor.rgb, murkiness); + + outColor.rgb += vec3(0.1, 0.5, 0.1) * nightVisionStrengthv; + outGlow.y *= clamp((dayLight - 0.05) * 2 - 50*murkiness, 0, 1); + +#if GBUFFER == 1 + outGPosition = vec4(0); + outGNormal = vec4(0); +#endif + +} diff --git a/sources/shaders-vk/sky.interface.glsl b/sources/shaders-vk/sky.interface.glsl new file mode 100644 index 00000000..4ffd5163 --- /dev/null +++ b/sources/shaders-vk/sky.interface.glsl @@ -0,0 +1,21 @@ +// Program interface of sky (docs/vulkan-native-shaders.md section 4). One draw per Use() and no sampler of +// its own (sky and glow are set 0 frame textures), so there is no push block; every uniform is a record +// member: sky.vsh's, sky.fsh's, then fogandlight.fsh's windWaveCounter (vertexwarp.vsh, its owner, is not +// included) and underwatereffects.fsh's frameSize. +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + vec4 rgbaFogIn; + vec3 rgbaAmbientIn; + mat4 projectionMatrix; + mat4 modelViewMatrix; + + float fogDensityIn; + float fogMinIn; + float dayLight; + float horizonFog; + vec3 playerPos; + vec3 sunPosition; + + float windWaveCounter; + vec2 frameSize; +}; diff --git a/sources/shaders-vk/sky.vert b/sources/shaders-vk/sky.vert new file mode 100644 index 00000000..ccd087e0 --- /dev/null +++ b/sources/shaders-vk/sky.vert @@ -0,0 +1,32 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of sky.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "sky.interface.glsl" + +layout(location = 0) in vec3 vertexPositionIn; +layout(location = 1) in vec4 vertexColor; + +layout(location = 0) out vec3 vertexPosition; +layout(location = 1) out vec4 rgbaFog; +layout(location = 2) out float nightVisionStrengthv; + +#include "vertexflagbits.glsl" +#include "shadowcoords.glsl" +#include "fogandlight.vert.glsl" + +void main() +{ + vertexPosition = vertexPositionIn; + rgbaFog = rgbaFogIn; + nightVisionStrengthv = nightVisionStrength; + vec4 cameraPos = modelViewMatrix * vec4(vertexPosition, 1.0); + + gl_Position = projectionMatrix * cameraPos; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From dc46b720e5f89fef9b5ad9e85b62ca6eb1500851 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:44:07 +0200 Subject: [PATCH 169/226] feat(native-shaders): parity oracle decisions and the transparentcompose port collectUniformNames has no sampler2DArray, so the client sees transparentcompose's OITaccumulation as a sampler2D named Array: the harness compares the declared sampler and records the alias the runtime must answer. A set-0 frame texture a program declares itself counts as the bindings.glsl declaration, as the rewriter places it. With both decisions transparentcompose is in the tree. Verified: NativeShaderParityTests 97/97 over all 48 native programs and variants, spirv-val on every module. --- .../NativeShaderParityTests.cs | 31 +++++++++ docs/vulkan-native-shaders.md | 9 +++ sources/shaders-vk/transparentcompose.frag | 65 +++++++++++++++++++ .../transparentcompose.interface.glsl | 14 ++++ sources/shaders-vk/transparentcompose.vert | 22 +++++++ 5 files changed, 141 insertions(+) create mode 100644 sources/shaders-vk/transparentcompose.frag create mode 100644 sources/shaders-vk/transparentcompose.interface.glsl create mode 100644 sources/shaders-vk/transparentcompose.vert diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs index ea4472d8..6142153f 100644 --- a/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs @@ -66,8 +66,18 @@ internal sealed class Oracle public readonly SortedDictionary> Names = new(StringComparer.Ordinal); /// textureLocations: a repeated sampler name is reassigned the current count, as the client does. public readonly Dictionary TextureLocations = new(StringComparer.Ordinal); + /// + /// The pattern has no sampler2DArray, so uniform sampler2DArray OITaccumulation matches as type + /// sampler2D named Array. The client registers that name and unit; the program's real sampler is the + /// declared one. Key: the name the client sees; value: the declared sampler2DArray name. The runtime answers + /// the client's name with the declared sampler's slot. + /// + public readonly Dictionary ArraySamplerAliases = new(StringComparer.Ordinal); } + private static readonly Regex DeclaredSampler2DArray = new(@"\G(\s|\r\n)uniform\s*sampler2DArray\s+(?[\d\w]+)", + RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); + internal static Oracle CollectOracle(IEnumerable stages) { var oracle = new Oracle(); @@ -82,6 +92,13 @@ internal static Oracle CollectOracle(IEnumerable stages) { string value = item.Groups["var"].Value; string type = item.Groups["type"].ToString(); + Match array = DeclaredSampler2DArray.Match(stage.Code, item.Index); + if (value == "Array" && array.Success) + { + oracle.ArraySamplerAliases["Array"] = array.Groups["var"].Value; + value = array.Groups["var"].Value; + type = "sampler2DArray"; + } if (!oracle.Names.TryGetValue(value, out SortedSet? types)) { oracle.Names[value] = types = new SortedSet(StringComparer.Ordinal); @@ -312,6 +329,20 @@ void Add(string name, string type, string source) } foreach (NativeMember member in variant.Record?.Members ?? new List()) Add(member.Name, member.Type, "the record"); foreach (NativeSampler sampler in variant.Samplers) Add(sampler.Name, sampler.GlslType, "a sampler slot"); + // A set-0 frame texture the program declares itself (cloudvolumetric's liquidDepth, without including + // underwatereffects) is the bindings.glsl declaration every native stage already has, as the rewriter + // treats a sampler whose name and type match SetConvention.FrameTextures. + foreach ((string name, SortedSet types) in oracle.Names) + { + if (native.ContainsKey(name)) continue; + foreach (string type in types) + { + if (SetConvention.FrameTextures.Any(binding => binding.Name == name && binding.GlslType == type)) + { + Add(name, type, "a set-0 frame texture the program declares itself"); + } + } + } var onlyNative = native.Keys.Except(oracle.Names.Keys).ToList(); var onlyGlsl330 = oracle.Names.Keys.Except(native.Keys).ToList(); diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 530a00ed..08182d77 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -137,6 +137,15 @@ records the decision here): family 6); - a sampler name seen twice is reassigned the current unit count. +**Oracle decisions (2026-09-15, after the family ports):** +- `uniform sampler2DArray ` (`transparentcompose`'s `OITaccumulation`): `collectUniformNames` has no + sampler2DArray, so the client registers the name `Array` with a sampler2D type at that texture unit. The + harness compares the declared name and type (`Oracle.ArraySamplerAliases` records the alias), and the runtime + answers `GetUniformLocation("Array")` and the unit bookkeeping with the declared sampler's slot. +- A set-0 frame texture a program declares itself without including the port that owns it (`cloudvolumetric`'s + `liquidDepth`) is the `bindings.glsl` declaration every native stage already has, and counts as present when its + name and type match `SetConvention.FrameTextures`, exactly as the rewriter places it. + ## 3. Descriptor use - **Set 0 (frame):** `frame.glsl` declares the FrameGlobals UBO at `OPTIMUM_BINDING_FRAME_GLOBALS` (scalar diff --git a/sources/shaders-vk/transparentcompose.frag b/sources/shaders-vk/transparentcompose.frag new file mode 100644 index 00000000..b7ff8c13 --- /dev/null +++ b/sources/shaders-vk/transparentcompose.frag @@ -0,0 +1,65 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of transparentcompose.fsh (the Optimum override in sources/shaders, docs/vulkan-native-shaders.md). +// GBUFFER and TAAMOTION are variant axes: they gate outputs. The motion attachment is written through +// optimumWriteReactiveOnly under the merge's additive (ONE, ONE) blend, so rg and a add zero and only b +// accumulates, exactly as the GLSL 330 override does. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "transparentcompose.interface.glsl" +#include "motion.glsl" + +layout(location = 0) in vec2 v_texcoord; + +layout(location = 0) out vec4 outColor; +layout(location = 1) out vec4 outGlow; +#if GBUFFER +layout(location = 2) out vec4 outGNormal; +layout(location = 3) out vec4 outGPosition; +#endif + +#if TAAMOTION == 1 +#if GBUFFER +layout(location = 4) out vec4 outMotion; +#else +layout(location = 2) out vec4 outMotion; +#endif +#endif + +#define OIT_BINS 3 + +vec3 unproject(vec4 a){ + return a.w < 0.0001 ? vec3(0.0) : a.xyz / a.w; +} + +void main(){ + + vec4 reveal = 1.0 - texelFetch(optimumTextures2D[OITreveal], ivec2(gl_FragCoord), 0); + float anet = 1.0 - texelFetch(optimumTextures2D[revealage], ivec2(gl_FragCoord), 0).r; + vec4 k = vec4(0.0); + float a = 1.0; + + for(int i = 0; i < OIT_BINS; i++){ + + vec4 bin = texelFetch(optimumTextures2DArray[OITaccumulation], ivec3(gl_FragCoord.xy, i), 0); + float anet_k = reveal[i]; + + k += vec4(unproject(bin) * anet_k, anet_k) * a; + a *= 1.0 - anet_k; + + } + + outColor = vec4(unproject(k), anet); + outGlow = texture(optimumTextures2D[inGlow], v_texcoord); + +#if TAAMOTION == 1 + // anet is the fraction of this pixel the transparent layer covers - the + // very alpha this pass is composited with. It is the reactive value: at 1 + // the pixel is entirely transparent content with no motion vector of its + // own, at 0 the pixel is untouched by it. + outMotion = optimumWriteReactiveOnly(clamp(anet, 0.0, 1.0)); +#endif + +} diff --git a/sources/shaders-vk/transparentcompose.interface.glsl b/sources/shaders-vk/transparentcompose.interface.glsl new file mode 100644 index 00000000..8fb5a163 --- /dev/null +++ b/sources/shaders-vk/transparentcompose.interface.glsl @@ -0,0 +1,14 @@ +// Program interface of transparentcompose (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one +// draw per Use(), so the push block holds only the sampler slots, in transparentcompose.fsh's declaration +// order, and there is no program record. +// +// OITaccumulation is the fifth slot, the unit collectUniformNames assigns to the name `Array` it misreads +// from `uniform sampler2DArray OITaccumulation` (docs/vulkan-native-shaders.md, "Family post"). +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, accumulation); + OPTIMUM_SAMPLER_SLOT(sampler2D, revealage); + OPTIMUM_SAMPLER_SLOT(sampler2D, inGlow); + OPTIMUM_SAMPLER_SLOT(sampler2D, OITreveal); + OPTIMUM_SAMPLER_SLOT(sampler2DArray, OITaccumulation); +}; diff --git a/sources/shaders-vk/transparentcompose.vert b/sources/shaders-vk/transparentcompose.vert new file mode 100644 index 00000000..c70a6e1f --- /dev/null +++ b/sources/shaders-vk/transparentcompose.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of transparentcompose.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "transparentcompose.interface.glsl" + +layout(location = 0) out vec2 v_texcoord; + +void main(void) +{ + // https://rauwendaal.net/2014/06/14/rendering-a-screen-covering-triangle-in-opengl/ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0, 1); + v_texcoord = vec2((x+1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From 6f1f2471a66ced87b04050cea904b34eefc0efc8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:48:17 +0200 Subject: [PATCH 170/226] wip(native-shaders): cloudvolumetric - NativeShaderParityTests 99/99 green Native port of vanilla cloudvolumetric.vsh plus the Optimum cloudvolumetric.fsh override. liquidDepth is sampled as the bindings.glsl set 0 frame texture (no push slot), per the section 2 oracle decision. USEOIT is the only axis (2 variants); oit.glsl statements are under #if USEOIT == 1. Push: depthTex, cloudMap, cloudCol; the rest is record. Verified: dotnet build VintageStory.slnx -c Release (0 errors); NativeShaderParityTests 99 passed, 0 failed, including both cloudvolumetric cases; shader-compiler --build emits cloudvolumetric USEOIT0/USEOIT1 vert and frag. Not run in game. --- docs/vulkan-native-shaders.md | 16 +- sources/shaders-vk/cloudvolumetric.frag | 223 ++++++++++++++++++ .../shaders-vk/cloudvolumetric.interface.glsl | 23 ++ sources/shaders-vk/cloudvolumetric.vert | 22 ++ 4 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 sources/shaders-vk/cloudvolumetric.frag create mode 100644 sources/shaders-vk/cloudvolumetric.interface.glsl create mode 100644 sources/shaders-vk/cloudvolumetric.vert diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 08182d77..c31d0ccc 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -637,7 +637,7 @@ stay on the rewriter. Decisions: ### 9.1 Family 5: particles, decals, sky, clouds (2026-09-15) Ported: `particlescube`, `particlesquad`, `particlesquad2d`, `decals`, `sky`, `nightsky`, `celestialobject`, -`aurora`, `cloudmap`. Not ported: the unregistered `clouds` pair, and `cloudvolumetric` (below). +`aurora`, `cloudmap`, `cloudvolumetric` (below). Not ported: the unregistered `clouds` pair. - **USEOIT on programs that include `oit.glsl`.** `oit.glsl` gates its outputs and functions on `USEOIT`, so every includer gets the axis and the builder compiles `USEOIT=0` too. `particlesquad`, `particlesquad2d` and `aurora` @@ -666,11 +666,9 @@ Ported: `particlescube`, `particlesquad`, `particlesquad2d`, `decals`, `sky`, `n - **Placement.** Particles and the sky programs draw once per `Use()` or have no DRAW uniforms, so their push block holds only sampler slots (`particlescube` and `sky` have none, so they have no push block). `decals` pushes both slots plus `origin` and `modelViewMatrix` (84 B). -- **cloudvolumetric is blocked on the harness/contract.** `cloudvolumetric.fsh` declares - `uniform sampler2D liquidDepth` itself, without including `underwatereffects.fsh`. `bindings.glsl` declares the - set 0 `liquidDepth` globally, so a push slot of that name cannot compile. Sampling the frame texture compiles, - but section 2's name set counts a frame texture only through an included port's `optimum-frame-texture` - header, and `NativeShaderParityTests` fails with `only GLSL 330 [liquidDepth]`. Pulling in - `underwatereffects.glsl` to get the header would add its frame members to the name set. Unblocking needs a - contract decision: either count the set 0 textures a program's own GLSL 330 source declares, or have - `bindings.glsl` stop declaring frame textures a program does not own. +- **cloudvolumetric** (the Optimum override of the `.fsh`, the vanilla `.vsh`) was unblocked by the section 2 + oracle decision. Its own `uniform sampler2D liquidDepth` is sampled as the `bindings.glsl` set 0 texture, with no + push slot. Push = `depthTex`, `cloudMap`, `cloudCol` (12 B); record = `iMvpMatrix`, `cloudMapWidth`, + `cloudOffset`, `frame`, `time`, `FrameWidth`, `PerceptionEffectIntensity`. USEOIT is its only axis (2 + variants), handled as above: `traverse`'s per-bin reveal loop and `main`'s output initialisation and + accumulation are under `#if USEOIT == 1`. diff --git a/sources/shaders-vk/cloudvolumetric.frag b/sources/shaders-vk/cloudvolumetric.frag new file mode 100644 index 00000000..d8cebf54 --- /dev/null +++ b/sources/shaders-vk/cloudvolumetric.frag @@ -0,0 +1,223 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of the Optimum override sources/shaders/cloudvolumetric.fsh (docs/vulkan-native-shaders.md). +// liquidDepth is the set 0 frame texture from bindings.glsl and is sampled directly (section 2, "Oracle +// decisions"). USEOIT is an axis because oit.glsl gates its outputs and functions on it; the program is +// registered with Oit = true, so only USEOIT=1 is ever selected. Every statement that names an oit.glsl symbol +// sits under #if USEOIT == 1, so the USEOIT=0 variant compiles and has no outputs, matching its preprocessed +// GLSL 330 declarations (family 5 decision). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "cloudvolumetric.interface.glsl" + +layout(location = 0) in vec2 uv; +layout(location = 1) in vec2 ndc; + +#include "dither.glsl" +#include "oit.glsl" + +vec3 hash(vec3 p){ + + // https://www.shadertoy.com/view/XlXcW4 + + // The MIT License + // Copyright 2017 Inigo Quilez + // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + const uint k = 1103515245U; + uvec3 x = floatBitsToUint(p); + x = ((x>>8U)^x.yzx)*k; + x = ((x>>8U)^x.yzx)*k; + x = ((x>>8U)^x.yzx)*k; + return vec3(x)/float(0xffffffffU); + +} + +float noise(vec3 p){ + vec3 f = smoothstep(0.0, 1.0, fract(p)); + vec3 x = floor(p); + return mix(mix(mix(hash(x + vec3(0, 0, 0)).x, + hash(x + vec3(1, 0, 0)).x, f.x), + mix(hash(x + vec3(0, 1, 0)).x, + hash(x + vec3(1, 1, 0)).x, f.x), f.y), + mix(mix(hash(x + vec3(0, 0, 1)).x, + hash(x + vec3(1, 0, 1)).x, f.x), + mix(hash(x + vec3(0, 1, 1)).x, + hash(x + vec3(1, 1, 1)).x, f.x), f.y), f.z); +} + +float octave(vec3 p){ + return (noise(p * 2.0) * 0.66 + noise(p * 6.0) * 0.33) * 2.0 - 1.0; +} + +mat2 rot(float n){ + return mat2(cos(n), -sin(n), sin(n), cos(n)); +} + +vec3 warp(vec3 d, float f){ + if(f < 0.0001) return d; + d.xz *= rot(octave(d * 2.0 + time * 0.05) * f); + d.xy *= rot(octave(d * 1.5 + time * 0.04) * f); + d.zy *= rot(octave(d * 1.5 - time * 0.04) * f); + return normalize(d); +} + +vec3 curve(vec3 d, float f){ + d.xy *= rot(d.x * f); + d.zy *= rot(d.z * f); + return normalize(d); +} + +vec3 unproject(vec4 x){ + return x.xyz / max(x.w, 0.0001); +} + +float volume(float o, float d, vec2 m, float t, float f){ + m = (m - o) / d; + return 1.0 - exp(-max(0.0, min(max(m.x, m.y), t) - max(0.0, min(m.x, m.y))) * f); +} + +vec2 intersect(float o, float d, vec2 m){ + m = (m - o) / d; + float near = min(m.x, m.y); + float far = max(m.x, m.y); + if(near > far || far < 0.0) return vec2(-1.0); + return vec2(max(0.0, near), max(0.0, far - max(0.0, near))); +} + +float halfsmooth(float x, float t){ + return x > t ? (x - t / 2.0) : (x * x * x * (1.0 - x * 0.5 / t) / t / t); +} + +vec4 traverse(vec3 o, vec3 d, float far, float T){ + + ivec2 p = ivec2(floor(o.xz)); + ivec2 istep = ivec2(sign(d.xz)); + vec2 tdelta, tmax; + tdelta.x = 1.0 / max(0.0001, abs(d.x)); + tdelta.y = 1.0 / max(0.0001, abs(d.z)); + tmax.x = (d.x > 0.0 ? floor(o.x) + 1.0 - o.x : o.x - floor(o.x)) * tdelta.x; + tmax.y = (d.z > 0.0 ? floor(o.z) + 1.0 - o.z : o.z - floor(o.z)) * tdelta.y; + float t = 0.0; + vec4 k = vec4(0.0); + + for(int i = 0; i < 200; i++){ + + vec4 map = texelFetch(optimumTextures2D[cloudMap], p, 0); + + if(map.r > 0.0){ + + vec4 col = texelFetch(optimumTextures2D[cloudCol], p, 0); + + float v = volume( + o.y + d.y * t, + d.y, + map.ba, + min(far, min(tmax.x, tmax.y)) - t, + map.r + ); + + if(v > 0.0){ + + k += (1.0 - k.a) * col * v; + +#if USEOIT == 1 + float bin = log(halfsmooth((T + t) * 50.0, 500.0) / OIT_BIN_SCALE + 1.0); + for(int i = 0; i < OIT_BINS; i++){ + float b = OITbellcurve(bin - float(i)); + if(i == (OIT_BINS-1) && bin > float(OIT_BINS-1)) b = 1.0; + OITreveal[i] *= 1.0 - col.a * v * b; + } +#endif + + } + + if(k.a > 0.99) break; + + } + + if(tmax.x < tmax.y){ + p.x += istep.x; + t = tmax.x; + tmax.x += tdelta.x; + }else{ + p.y += istep.y; + t = tmax.y; + tmax.y += tdelta.y; + } + + if(t > far) break; + + } + + return k; + +} + +void main(){ + + const float cloudTileSize = 50.0; + + vec3 origin = unproject(iMvpMatrix * vec4(ndc, -1.0, 1.0)); + vec3 direction = normalize(unproject(iMvpMatrix * vec4(ndc, 1.0, 1.0)) - origin); + vec3 world = unproject(iMvpMatrix * vec4(ndc, texelFetch(optimumTextures2D[depthTex], ivec2(gl_FragCoord), 0).r * 2.0 - 1.0, 1.0)); + vec3 liquid = unproject(iMvpMatrix * vec4(ndc, texelFetch(liquidDepth, ivec2(gl_FragCoord / 4.0), 0).r * 2.0 - 1.0, 1.0)); + + float far = min( + distance(origin, world), + distance(origin, liquid) + ); + + direction = curve(direction, 0.07); + direction = warp(direction, PerceptionEffectIntensity * 0.03); + + float height = max(0.0, cloudOffset.y - origin.y); + + origin.y -= cloudOffset.y; + + vec2 plane = intersect( + origin.y, + direction.y, + vec2(-12.5 - 500 * 0.1, 12.5 + 500.0) + ); + + float near = plane.x; + + if(near < 0.0 || far < near) discard; + + origin += direction * near; + origin.xz -= cloudOffset.xz; + origin /= cloudTileSize; + origin.xz += cloudMapWidth / 2.0; + + far -= near; + far = min(far, plane.y); + far = min(far, cloudMapWidth * cloudTileSize / 2.0 - near); + far /= cloudTileSize; + +#if USEOIT == 1 + outGlow = OITaccumulation0 = OITaccumulation1 = OITaccumulation2 = vec4(0.0); + OITreveal = outReveal = vec4(1.0); +#endif + + vec4 k = traverse(origin, direction, far, plane.x / cloudTileSize); + + if(k.a <= 0.0) discard; + + float s = FrameWidth / 240.0 / 11.0; + float n = NoiseFromPixelPosition(ivec2(gl_FragCoord.xy), frame + 256, FrameWidth).r * s; + + k = exp(log(k + 1.0) + n) - 1.0; + + if(k.a <= 0.0) discard; + +#if USEOIT == 1 + for(int i = 0; i < OIT_BINS; i++) + OITaccumulate(i, k * (1.0 - OITreveal[i])); + + outReveal = vec4(1.0 - k.a); + outGlow.a = k.a; +#endif +} diff --git a/sources/shaders-vk/cloudvolumetric.interface.glsl b/sources/shaders-vk/cloudvolumetric.interface.glsl new file mode 100644 index 00000000..30538c4a --- /dev/null +++ b/sources/shaders-vk/cloudvolumetric.interface.glsl @@ -0,0 +1,23 @@ +// Program interface of cloudvolumetric (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw +// per Use(), so the push block holds only the sampler slots, in cloudvolumetric.fsh's declaration order, and +// every other uniform is a record member in declaration order. +// +// liquidDepth, which cloudvolumetric.fsh declares itself between cloudCol and cloudMapWidth, is the set 0 frame +// texture bindings.glsl declares (section 2, "Oracle decisions"), so it has no slot here. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, depthTex); + OPTIMUM_SAMPLER_SLOT(sampler2D, cloudMap); + OPTIMUM_SAMPLER_SLOT(sampler2D, cloudCol); +}; + +layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram +{ + mat4 iMvpMatrix; + float cloudMapWidth; + vec3 cloudOffset; + int frame; + float time; + int FrameWidth; + float PerceptionEffectIntensity; +}; diff --git a/sources/shaders-vk/cloudvolumetric.vert b/sources/shaders-vk/cloudvolumetric.vert new file mode 100644 index 00000000..72fc1d0e --- /dev/null +++ b/sources/shaders-vk/cloudvolumetric.vert @@ -0,0 +1,22 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of cloudvolumetric.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "cloudvolumetric.interface.glsl" + +layout(location = 0) in vec2 p; + +layout(location = 0) out vec2 uv; +layout(location = 1) out vec2 ndc; + +void main(){ + gl_Position = vec4(p, 0.0, 1.0); + uv = p * 0.5 + 0.5; + ndc = p; + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} From f25a552e5015e189ec5be9c9185fa644692767c5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:50:04 +0200 Subject: [PATCH 171/226] docs: handoff status for decision 9, the native shaders and the AO work --- docs/vulkan-branch-progress.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index ff6c3d0c..0bf31eb0 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -1,6 +1,6 @@ # feat/vulkan-taa: handoff -Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-15 at 11195c5. +Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-15 at c6af6f9. - Plan of record: `docs/vulkan-native-plan.md` (decisions 1-9, phases, risks). - Research the designs follow: `docs/research/` (caching, descriptor model, bindless, XeGTAO, validation). @@ -234,12 +234,28 @@ Windows run above. validation message on the selected device). GPU suite 673/673, no `SYNC-`; both local devices (RTX 4070, UHD ADL-S) meet the floor. Open for the layout step: best practices' AMD check `KeepLayoutSmall` warns on that layout's 128-byte push-constant range; size the real push block from the uniform placement map, not the maximum. + **Status (2026-09-15, evening): done.** The bindless texture table (slot allocator keyed on the physical texture, + deferred free on the Frame timeline, per-kind placeholders: opaque black, magenta only under poison mode) and + `SharedPipelineLayout` are merged, and every program now links against the one layout: set 0 frame block and + frame textures, set 1 bindless arrays, set 2 FaceData, named uniform blocks as std140 storage buffers + (Animation 1, AnimationPrev 2, others 4-7) and the program record (dynamic UBO, binding 3), samplers as bindless + slots in push constants. Per-program layouts are gone. GPU suite 878/878 at the retarget merge. 4. **Rewriter retargeted** to the shared layout (samplers -> bindless indices, loose uniforms -> per-frame record addressed from push constants), then native GLSL 450 per program family (includes; post programs; GUI/lines; chunks; entities; particles/decals/sky/clouds; SSAO/godrays/bloom/colorgrade/OIT; Optimum programs), offline compiler tool + `shaders.manifest.json` + MSBuild target + packaging, runtime manifest load with the "N native, M rewritten, K failed" log line, specialization constants for quality defines, parity tests against the GLSL 330 sources. + **Status (2026-09-15, evening):** the rewriter half is done (item 3). Native GLSL 450: contract + `docs/vulkan-native-shaders.md`; shared includes, `frame.glsl`/`specialization.glsl` generators and + `motion.glsl`; the offline compiler (`tools/shader-compiler`, SPIR-V reflection, `shaders.manifest.json`, + MSBuild target, deploy and packaging beside the renderer DLL); the static parity harness + (`NativeShaderParityTests`, GLSL 330 oracle vs manifest); all 49 registered programs ported (MinimalGui and the + mod-registered optimum-map stay on the rewriter). GPU suite 968/968 at the family merges. In progress: the + runtime seam (manifest load in `LinkProgram`, per-program rewriter fallback, placement-table locations, + initializer seeding, the `Array` alias for sampler2DArray, native-vs-rewriter pixel tests). Open after it: the + settings-change reload through specialization constants, launcher scanner v2 (in progress), in-game check on + both backends. 5. **Phase 3b:** native render systems (post chain and TAA first, then chunks, entities, particles/decals/sky, GUI/text); remove `GlStateTracker`, GL id tables, texture units and uniform-by-location from the Vulkan path; decide the runtime rewriter's fate for mod shaders. @@ -258,6 +274,8 @@ Windows run above. 8. **GTAO with visibility bitmasks** (XeGTAO-derived; XeGTAO itself is archived since 2024-04-22, see `docs/research/xegtao-integration.md` section 0; the combined design is `docs/research/ambient-occlusion.md` section C; physically correct, default AO on Vulkan while TAA is active): compute pass kind in the frame graph, GLSL compute port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. + **Status (2026-09-15, evening):** the frame-graph compute pass kind is implemented on its stage branch; the AO + passes, class channel, composition and settings are in progress. 9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. 10. **Optimisation** (plan Phase 4): per-pass GPU timestamps, push-constant placement from the measured profile, transient aliasing on by default, DirectToSwapchain / transfer backend measured. Exit: Vulkan From 3e4f2da1e0a0ac6543e5cbfac49ba27bfb490f1c Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Tue, 15 Sep 2026 23:54:42 +0200 Subject: [PATCH 172/226] wip(launcher): scanner v2 - ShaderAssetOverride, PlatformInternals, schema 2 - ShaderAssetOverride findings and rewriterPrograms (program base names, or ["all"] for a shaderincludes override or a failed scan) for the Vulkan runtime to consume; known programs = built-in list plus the installed game's assets/game/shaders stems. - PlatformInternals indicator: Harmony plus ClientPlatformWindows or ShaderProgramBase (UTF-8 metadata or UTF-16 user string) disables Vulkan; openGlRequired/openGlRequiredBy in the report. RawOpenGL unchanged. - CurrentSchemaVersion 2; LoadReport refuses v1 and other versions. - VULKAN-BACKEND-PLAN.md section 9 and launcher notes updated. Verified: Optimum.Launcher.Tests 50/50 passed (14 new cases); Optimum.Tests -c Release 1184 passed, 34 skipped, 0 failed. No renderer change, no game run. --- .../ShaderCompatibilityScannerTests.cs | 226 ++++++++++++++++++ .../ShaderCompatibilityScanner.cs | 220 ++++++++++++++++- VULKAN-BACKEND-PLAN.md | 23 +- docs/vulkan-native-plan.md | 5 +- 4 files changed, 461 insertions(+), 13 deletions(-) diff --git a/Optimum.Launcher.Tests/ShaderCompatibilityScannerTests.cs b/Optimum.Launcher.Tests/ShaderCompatibilityScannerTests.cs index 33b65249..6a430b2e 100644 --- a/Optimum.Launcher.Tests/ShaderCompatibilityScannerTests.cs +++ b/Optimum.Launcher.Tests/ShaderCompatibilityScannerTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using Optimum.Launcher; using Vintagestory.API.Config; @@ -191,6 +192,231 @@ public void AnUnrelatedExternalShaderLeavesTaaAlone(string entryPath) Assert.DoesNotContain("Taa", report.DisabledFeatures); } + // ------------------------------------------------ v2: vanilla-shader overrides + + [Fact] + public void AModOverridingChunkopaqueMarksOnlyThatProgramForTheRewriter() + { + string dataPath = Path.Combine(_root, "data"); + WriteArchive(Path.Combine(dataPath, "Mods", "OpaquePack.zip"), + "assets/opaquepack/shaders/chunkopaque.fsh", + // A new program name is the mod's own: it has no native blob to bypass. + "assets/opaquepack/shaders/opaquepack-glow.fsh"); + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test"); + + Assert.Equal(["chunkopaque"], report.RewriterPrograms); + ShaderAssetOverride finding = Assert.Single(report.ShaderAssetOverrides); + // NormalizeShaderPath keeps only "shaders/..." for a domain other than game. + Assert.Equal("shaders/chunkopaque.fsh", finding.Asset); + Assert.Equal(["chunkopaque"], finding.Programs); + Assert.Single(finding.Owners); + Assert.False(report.OpenGlRequired); + } + + [Fact] + public void AShaderincludesOverrideMarksEveryProgram() + { + string dataPath = Path.Combine(_root, "data"); + WriteArchive(Path.Combine(dataPath, "Mods", "FogPack.zip"), + "assets/fogpack/shaderincludes/fogandlight.fsh", + "assets/fogpack/shaders/sky.fsh"); + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test"); + + Assert.Equal([ShaderCompatibilityScanner.AllPrograms], report.RewriterPrograms); + Assert.Contains(report.ShaderAssetOverrides, x => + x.Asset == "shaderincludes/fogandlight.fsh" || x.Asset.EndsWith("shaderincludes/fogandlight.fsh", StringComparison.Ordinal)); + Assert.Contains(report.ShaderAssetOverrides, x => x.Programs.SequenceEqual([ShaderCompatibilityScanner.AllPrograms])); + Assert.Contains(report.ShaderAssetOverrides, x => x.Programs.SequenceEqual(["sky"])); + Assert.False(report.OpenGlRequired); + } + + [Fact] + public void AProgramOnlyTheInstalledGameShipsIsStillAnOverride() + { + string gameDir = Path.Combine(_root, "game"); + Directory.CreateDirectory(Path.Combine(gameDir, "assets", "game", "shaders")); + File.WriteAllText(Path.Combine(gameDir, "assets", "game", "shaders", "futureprogram.vsh"), "void main() { }"); + string dataPath = Path.Combine(_root, "data"); + WriteArchive(Path.Combine(dataPath, "Mods", "FuturePack.zip"), "assets/futurepack/shaders/futureprogram.vsh"); + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, gameDir, "test"); + + Assert.Equal(["futureprogram"], report.RewriterPrograms); + } + + [Fact] + public void AFailedScanSendsEveryProgramToTheRewriterButDoesNotVetoVulkan() + { + ShaderCompatibilityReport report = ShaderCompatibilityScanner.CreateConservativeReport("test", "boom"); + + Assert.Equal([ShaderCompatibilityScanner.AllPrograms], report.RewriterPrograms); + Assert.False(report.OpenGlRequired); + } + + /// + /// Every native program is one a mod can override by file name; a program missing + /// from the scanner's list would link its SPIR-V over a mod's source whenever the + /// installed game directory is not readable. + /// + [Fact] + public void EveryNativeProgramIsAKnownOverridableProgram() + { + string shadersVk = Path.Combine(FindRepositoryRoot(), "sources", "shaders-vk"); + string[] native = Directory.EnumerateFiles(shadersVk, "*.frag") + .Select(Path.GetFileNameWithoutExtension) + .Cast() + .ToArray(); + + Assert.NotEmpty(native); + Assert.Empty(native.Except(ShaderCompatibilityScanner.KnownPrograms, StringComparer.OrdinalIgnoreCase)); + } + + // ------------------------------------------------ v2: PlatformInternals + + [Fact] + public void HarmonyPlusAClientPlatformWindowsNameRoutesToOpenGl() + { + string dataPath = Path.Combine(_root, "data"); + string modsPath = Path.Combine(dataPath, "Mods"); + Directory.CreateDirectory(modsPath); + File.WriteAllBytes(Path.Combine(modsPath, "PlatformPatch.dll"), + Encoding.UTF8.GetBytes("0Harmony HarmonyLib HarmonyPatch ClientPlatformWindows RenderMesh")); + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test"); + + ShaderModSource source = Assert.Single(report.Sources); + Assert.Contains("PlatformInternals", source.Indicators); + Assert.DoesNotContain("RawOpenGL", source.Indicators); + Assert.True(report.OpenGlRequired); + Assert.Contains("Vulkan", report.DisabledFeatures); + Assert.Equal([source.Id], report.OpenGlRequiredBy); + Assert.Contains(report.FeatureReasons["Vulkan"], reason => reason.Contains("Harmony", StringComparison.Ordinal)); + } + + [Fact] + public void AUtf16UserStringNamingShaderProgramBaseCountsAsAPlatformReference() + { + string dataPath = Path.Combine(_root, "data"); + string modsPath = Path.Combine(dataPath, "Mods"); + Directory.CreateDirectory(modsPath); + byte[] metadata = Encoding.UTF8.GetBytes("0Harmony AccessTools "); + // An odd prefix puts the user string at an odd offset, as the #US heap may. + byte[] userString = [0x2B, .. Encoding.Unicode.GetBytes("Vintagestory.Client.NoObf.ShaderProgramBase:Use")]; + File.WriteAllBytes(Path.Combine(modsPath, "ReflectionPatch.dll"), [.. metadata, .. userString]); + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test"); + + Assert.Contains("PlatformInternals", Assert.Single(report.Sources).Indicators); + Assert.True(report.OpenGlRequired); + } + + [Theory] + // A platform name without Harmony is a reflection read, not a patch. + // (RegisterRenderer keeps the assembly a listed source.) + [InlineData("ClientPlatformWindows ScreenshotHelper RegisterRenderer")] + // Harmony on gameplay code does not touch the graphics seam. + [InlineData("0Harmony HarmonyLib EntityBehaviorHealth OnDamage")] + public void PlatformNamesOrHarmonyAloneStayOnVulkan(string content) + { + string dataPath = Path.Combine(_root, "data"); + string modsPath = Path.Combine(dataPath, "Mods"); + Directory.CreateDirectory(modsPath); + File.WriteAllBytes(Path.Combine(modsPath, "Gameplay.dll"), Encoding.UTF8.GetBytes(content)); + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test"); + + Assert.DoesNotContain("PlatformInternals", Assert.Single(report.Sources).Indicators); + Assert.False(report.OpenGlRequired); + } + + [Fact] + public void ACleanModStaysOnVulkanWithNativePrograms() + { + string dataPath = Path.Combine(_root, "data"); + string archivePath = Path.Combine(dataPath, "Mods", "CleanMod.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + using (StreamWriter info = new(archive.CreateEntry("modinfo.json").Open())) + info.Write("{\"modid\":\"cleanmod\",\"name\":\"Clean Mod\",\"version\":\"1.0.0\"}"); + using (Stream dll = archive.CreateEntry("CleanMod.dll").Open()) + dll.Write(Encoding.UTF8.GetBytes("Vintagestory.API.Common ModSystem ICoreClientAPI BlockEntity")); + } + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test"); + + Assert.Equal("cleanmod", Assert.Single(report.Sources).Id); + Assert.False(report.OpenGlRequired); + Assert.Empty(report.OpenGlRequiredBy); + Assert.Empty(report.RewriterPrograms); + Assert.Empty(report.ShaderAssetOverrides); + Assert.DoesNotContain("Vulkan", report.DisabledFeatures); + } + + // ------------------------------------------------ v2: schema + + [Fact] + public void AVersion1ReportIsInvalidatedAndTheNextScanReplacesIt() + { + string dataPath = Path.Combine(_root, "data"); + string reportPath = Path.Combine(dataPath, ".optimum", ShaderCompatibilityScanner.ReportFileName); + Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); + File.WriteAllText(reportPath, + "{\"schemaVersion\":1,\"optimumVersion\":\"old\",\"scanFailed\":false,\"disabledFeatures\":[]}"); + + Assert.Equal(2, ShaderCompatibilityScanner.CurrentSchemaVersion); + Assert.Null(ShaderCompatibilityScanner.LoadReport(dataPath)); + + WriteArchive(Path.Combine(dataPath, "Mods", "OpaquePack.zip"), "assets/opaquepack/shaders/chunkopaque.vsh"); + ShaderCompatibilityScanner.SaveReport(dataPath, + ShaderCompatibilityScanner.Scan(dataPath, Path.Combine(_root, "game"), "test")); + + ShaderCompatibilityReport? loaded = ShaderCompatibilityScanner.LoadReport(dataPath); + Assert.NotNull(loaded); + Assert.Equal(ShaderCompatibilityScanner.CurrentSchemaVersion, loaded.SchemaVersion); + Assert.Equal(["chunkopaque"], loaded.RewriterPrograms); + Assert.Contains("\"rewriterPrograms\"", File.ReadAllText(reportPath), StringComparison.Ordinal); + } + + [Theory] + [InlineData("{\"optimumVersion\":\"old\"}")] + [InlineData("{\"schemaVersion\":\"2\"}")] + [InlineData("{\"schemaVersion\":3}")] + [InlineData("not json")] + public void AReportWithoutTheCurrentSchemaVersionIsNotLoaded(string content) + { + string dataPath = Path.Combine(_root, "data"); + string reportPath = Path.Combine(dataPath, ".optimum", ShaderCompatibilityScanner.ReportFileName); + Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); + File.WriteAllText(reportPath, content); + + Assert.Null(ShaderCompatibilityScanner.LoadReport(dataPath)); + } + + private static void WriteArchive(string archivePath, params string[] entries) + { + Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!); + using ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (string entry in entries) + { + using StreamWriter writer = new(archive.CreateEntry(entry).Open()); + writer.Write("void main() { }"); + } + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "VintageStory.slnx"))) return directory.FullName; + directory = directory.Parent; + } + throw new DirectoryNotFoundException("Repository root with VintageStory.slnx not found."); + } + private static void WriteHookAssembly(string path) { File.WriteAllBytes(path, Encoding.UTF8.GetBytes( diff --git a/Optimum.Launcher/ShaderCompatibilityScanner.cs b/Optimum.Launcher/ShaderCompatibilityScanner.cs index b6733836..0e649e32 100644 --- a/Optimum.Launcher/ShaderCompatibilityScanner.cs +++ b/Optimum.Launcher/ShaderCompatibilityScanner.cs @@ -16,9 +16,61 @@ namespace Optimum.Launcher; /// public static class ShaderCompatibilityScanner { - public const int CurrentSchemaVersion = 1; + /// + /// 2 (2026-09-15): adds , + /// , the PlatformInternals + /// indicator and . The launcher + /// rescans and overwrites the report on every start; + /// refuses any other version, so a v1 file is never read as if it carried the v2 + /// verdicts. + /// + public const int CurrentSchemaVersion = 2; public const string ReportFileName = "shader-compatibility.json"; + /// + /// The single entry holds + /// when every program has to take the rewriter: a shaderincludes override, or a + /// report the scanner could not complete. + /// + public const string AllPrograms = "all"; + + /// + /// Program base names the client registers from assets/game/shaders + /// (vanilla plus the Optimum overrides and stages that ship there). A mod file + /// assets/<domain>/shaders/<name>.vsh|.fsh|.gsh whose stem is one of + /// these replaces that program's source, so the Vulkan path must build it through + /// the rewriter from the mod's GLSL instead of linking the native SPIR-V. The scan + /// also adds every stem it finds in the installed game's own shaders directory. + /// + private static readonly string[] BuiltInPrograms = + [ + "aurora", "autocamera", "bilateralblur", "blit", "blockhighlights", "blur", "celestialobject", + "chunkliquid", "chunkliquiddepth", "chunkliquidmotion", "chunkopaque", "chunkshadowmap", "chunktopsoil", + "chunktransparent", "cloudmap", "clouds", "cloudvolumetric", "colorgrade", "debugdepthbuffer", "decals", + "entityanimated", "final", "findbright", "fsr-easu", "fsr-rcas", "godrays", "gui", "guigear", "guitopsoil", + "helditem", "instanced", "lines", "luma", "nightsky", "optimum-map", "particlescube", "particlesquad", + "particlesquad2d", "scene-ssao", "shadowmapentityanimated", "sky", "ssao", "standard", "taa-debug", + "taa-resolve", "taa-sharpen", "taa-skymotion", "texture2texture", "transparentcompose", "ui-compose", + "upscale-ssao", "wireframe", "woittest" + ]; + + /// Program base names the scanner treats as overridable without an installed game. + public static IReadOnlyList KnownPrograms => BuiltInPrograms; + + /// + /// Platform graphics types a Harmony patch can target. The Vulkan backend replaces + /// the platform (VulkanClientPlatform overrides the graphics members) and + /// links programs itself, so a prefix/postfix on one of these members either never + /// runs or runs against state the Vulkan path does not use. + /// + private static readonly string[] PlatformInternalTypes = + [ + "ClientPlatformWindows", "ShaderProgramBase" + ]; + + private static readonly byte[][] PlatformInternalTypesUtf16 = + [.. PlatformInternalTypes.Select(Encoding.Unicode.GetBytes)]; + private static readonly string[] ShaderFeatures = [ "GreedyMesh", "RenderScale", "GodRaysSampleCap", "EntityLightBatch", @@ -85,10 +137,64 @@ public static ShaderCompatibilityReport Scan(string dataPath, string gameDir, st MarkFailed(report, "Mods", ex); } - FinalizeReport(report); + FinalizeReport(report, InstalledPrograms(gameDir)); return report; } + /// + /// Reads a saved report, or null when there is none, it cannot be parsed, or it was + /// written under another schema version. A v1 report lacks the v2 verdicts + /// (rewriter programs, PlatformInternals routing), so it is invalidated rather than + /// migrated: its absence of a verdict must not read as "nothing found". + /// + public static ShaderCompatibilityReport? LoadReport(string dataPath) + { + string path = Path.Combine(dataPath, ".optimum", ReportFileName); + try + { + if (!File.Exists(path)) return null; + using FileStream stream = File.OpenRead(path); + using JsonDocument document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("schemaVersion", out JsonElement version) || + version.ValueKind != JsonValueKind.Number || + !version.TryGetInt32(out int schemaVersion) || + schemaVersion != CurrentSchemaVersion) + { + return null; + } + return document.RootElement.Deserialize(JsonOptions); + } + catch (Exception ex) when (IsRecoverable(ex) || ex is JsonException) + { + return null; + } + } + + private static HashSet InstalledPrograms(string gameDir) + { + var programs = new HashSet(BuiltInPrograms, StringComparer.OrdinalIgnoreCase); + try + { + string shaders = Path.Combine(gameDir, "assets", "game", "shaders"); + if (!Directory.Exists(shaders)) return programs; + foreach (string file in Directory.EnumerateFiles(shaders)) + { + if (IsStageExtension(Path.GetExtension(file))) + programs.Add(Path.GetFileNameWithoutExtension(file).ToLowerInvariant()); + } + } + catch (Exception ex) when (IsRecoverable(ex)) + { + // The built-in list still covers every program this build ships. + } + return programs; + } + + private static bool IsStageExtension(string extension) => + extension.Equals(".fsh", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".vsh", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".gsh", StringComparison.OrdinalIgnoreCase); + public static string SaveReport(string dataPath, ShaderCompatibilityReport report) { ArgumentNullException.ThrowIfNull(report); @@ -129,7 +235,7 @@ public static ShaderCompatibilityReport CreateConservativeReport(string optimumV report.DisabledFeatures.Add(feature); report.FeatureReasons[feature] = ["scanner failure: conservative fallback"]; } - FinalizeReport(report); + FinalizeReport(report, null); return report; } @@ -210,7 +316,7 @@ private static void ScanFile(string file, string relativePath, HashSet s byte[] bytes = File.ReadAllBytes(file); RecordContentHash(source, normalized, bytes); if (isModInfo) ReadModInfo(ReadTextBytes(bytes), source); - if (isAssembly) AddIndicators(ReadTextBytes(bytes), indicators); + if (isAssembly) AddIndicators(bytes, indicators); } private static void ScanArchive(string archivePath, HashSet shaders, HashSet indicators, ShaderModSource source, ShaderCompatibilityReport report) @@ -234,7 +340,7 @@ private static void ScanArchive(string archivePath, HashSet shaders, Has byte[] bytes = memory.ToArray(); RecordContentHash(source, relativePath, bytes); if (isModInfo) ReadModInfo(ReadTextBytes(bytes), source); - if (isAssembly) AddIndicators(ReadTextBytes(bytes), indicators); + if (isAssembly) AddIndicators(bytes, indicators); } } catch (Exception ex) when (IsRecoverable(ex)) @@ -260,17 +366,45 @@ private static void ReadModInfo(string json, ShaderModSource source) } } - private static void AddIndicators(string text, HashSet indicators) + private static void AddIndicators(byte[] bytes, HashSet indicators) { - string lower = text.ToLowerInvariant(); + string lower = ReadTextBytes(bytes).ToLowerInvariant(); foreach ((string token, string name) in IndicatorTokens) { if (lower.Contains(token, StringComparison.Ordinal)) indicators.Add(name); } + + // PlatformInternals: the assembly references Harmony and names a platform + // graphics type. Type and member references, and the type names serialized + // into [HarmonyPatch(typeof(...))] attribute blobs, are UTF-8 in metadata; + // AccessTools.Method("...ClientPlatformWindows:RenderMesh") is a user string, + // which the #US heap stores as UTF-16. Both forms count. The other indicators + // keep their UTF-8-only match so their verdicts do not move. + if (!indicators.Contains("Harmony")) return; + bool namesPlatform = + PlatformInternalTypes.Any(type => lower.Contains(type.ToLowerInvariant(), StringComparison.Ordinal)) || + PlatformInternalTypesUtf16.Any(pattern => bytes.AsSpan().IndexOf(pattern) >= 0); + if (namesPlatform) indicators.Add("PlatformInternals"); } - private static void FinalizeReport(ShaderCompatibilityReport report) + private static void FinalizeReport(ShaderCompatibilityReport report, HashSet? installedPrograms) { + AddShaderAssetOverrides(report, installedPrograms); + + // A Harmony patch on a platform graphics member is not honoured on Vulkan: + // VulkanClientPlatform overrides those members and links programs itself, + // so the patched GL body never runs. Like RawOpenGL this routes the session + // to OpenGL, and like it the decision is not a ShaderFeature, so a scan + // failure alone never forces it. + report.OpenGlRequiredBy = report.Sources + .Where(x => x.Indicators.Contains("RawOpenGL") || x.Indicators.Contains("PlatformInternals")) + .Select(x => x.Id) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToList(); + AddFeatureDecision(report, "Vulkan", report.Sources.Any(x => x.Indicators.Contains("PlatformInternals")), + "a mod Harmony-patches a platform graphics member (ClientPlatformWindows or ShaderProgramBase), which the Vulkan backend does not honour"); + foreach (List owners in report.ShaderOwners.Values) owners.Sort(StringComparer.OrdinalIgnoreCase); @@ -386,6 +520,9 @@ private static void FinalizeReport(ShaderCompatibilityReport report) { foreach (string feature in ShaderFeatures) AddFeatureDecision(report, feature, true, "scanner failure: conservative fallback"); + // A scan that did not finish cannot say which programs a mod replaced, + // so none of them may link the native SPIR-V over a mod's source. + report.RewriterPrograms = [AllPrograms]; } report.Fingerprint = ComputeFingerprint(report); @@ -430,6 +567,44 @@ private static bool HasExternalShaderInclude(ShaderCompatibilityReport report) path.Replace('\\', '/').Contains("shaderincludes/", StringComparison.OrdinalIgnoreCase)); } + /// + /// One finding per external shader asset that replaces a known program's stage + /// by file name, or any shaderincludes file. The union lands in + /// : sorted program base + /// names, or the single entry when an include was + /// overridden, because ShaderRegistry merges every include into the one dictionary + /// all programs compile against and an include has no program of its own. + /// A mod shader with a new name is not an override: it has no native blob and + /// takes the rewriter anyway. + /// + private static void AddShaderAssetOverrides(ShaderCompatibilityReport report, HashSet? installedPrograms) + { + var known = installedPrograms ?? new HashSet(BuiltInPrograms, StringComparer.OrdinalIgnoreCase); + var programs = new SortedSet(StringComparer.OrdinalIgnoreCase); + bool all = false; + report.ShaderAssetOverrides.Clear(); + foreach ((string asset, List owners) in report.ShaderOwners.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) + { + string normalized = asset.Replace('\\', '/'); + ShaderAssetOverride finding; + if (normalized.Contains("shaderincludes/", StringComparison.OrdinalIgnoreCase)) + { + all = true; + finding = new ShaderAssetOverride { Asset = asset, Programs = [AllPrograms] }; + } + else + { + string program = Path.GetFileNameWithoutExtension(normalized).ToLowerInvariant(); + if (!known.Contains(program)) continue; + programs.Add(program); + finding = new ShaderAssetOverride { Asset = asset, Programs = [program] }; + } + finding.Owners = [.. owners]; + report.ShaderAssetOverrides.Add(finding); + } + report.RewriterPrograms = all ? [AllPrograms] : [.. programs]; + } + private static bool IsShaderHookIndicator(string indicator) => indicator is "ShaderRegistry" or "LoadShader" or "ShaderProgram" or "Harmony" or "RenderHook"; @@ -616,6 +791,35 @@ public sealed class ShaderCompatibilityReport public List DisabledFeatures { get; set; } = []; public Dictionary> FeatureReasons { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List ScanErrors { get; set; } = []; + + /// External assets that replace a known program's stage or a shaderinclude. + public List ShaderAssetOverrides { get; set; } = []; + + /// + /// What the Vulkan runtime consumes: program base names that must be built through + /// the rewriter from the (mod) GLSL instead of the native SPIR-V, sorted; or the + /// single entry . Empty when + /// every program may link natively. + /// + public List RewriterPrograms { get; set; } = []; + + /// Sources that route the session to OpenGL (RawOpenGL or PlatformInternals). + public List OpenGlRequiredBy { get; set; } = []; + + /// True when the scan explicitly vetoes the Vulkan backend. + public bool OpenGlRequired => DisabledFeatures.Contains("Vulkan", StringComparer.OrdinalIgnoreCase); +} + +/// +/// ShaderAssetOverride finding: (normalized path, e.g. +/// assets/mymod/shaders/chunkopaque.fsh) replaces the listed programs' +/// source; is ["all"] for a shaderincludes file. +/// +public sealed class ShaderAssetOverride +{ + public string Asset { get; set; } = string.Empty; + public List Programs { get; set; } = []; + public List Owners { get; set; } = []; } public sealed class ShaderModSource diff --git a/VULKAN-BACKEND-PLAN.md b/VULKAN-BACKEND-PLAN.md index 41b8ba1e..fa7582db 100644 --- a/VULKAN-BACKEND-PLAN.md +++ b/VULKAN-BACKEND-PLAN.md @@ -1188,9 +1188,15 @@ is inside `ClientPlatformWindows` and becomes transplant targets with the branch `OpenTK.Graphics.ES30` and P/Invokes into `opengl32`/`libGL`. Results land in the existing `shader-compatibility.json` as a `glBoundMods` list; `OptimumConfig` exposes `IsShaderFeatureDisabled("Vulkan")` in the same style. - A second, advisory token scan flags Harmony mods that name - `ClientPlatformWindows`, `ShaderProgramBase` or `VAO`, because they may patch - internals the branch bypasses; those produce a warning, not a fallback. + A second token scan flags Harmony mods that name `ClientPlatformWindows` or + `ShaderProgramBase` (the `PlatformInternals` indicator); since scan v2 + (2026-09-15) that is a fallback to OpenGL, not a warning (section 9). +- Scan v2 (`CurrentSchemaVersion` 2) also reports `shaderAssetOverrides` and + `rewriterPrograms`, the list the Vulkan runtime consumes to choose the rewriter + over the native SPIR-V per program. `ShaderCompatibilityScanner.LoadReport` + refuses any other schema version, so a v1 report is invalidated, never read as + if it carried the v2 verdicts; the launcher rescans and overwrites it on every + start. ### Config and settings @@ -1229,12 +1235,21 @@ decompile means a new device operation. | `Renderer = opengl` | vanilla GL path; no Vulkan code loads | | Mod references `OpenTK.Graphics.OpenGL*` or P/Invokes GL | session forced to `opengl`; log + one-time notice naming the mod | | Mod uses only `IRenderAPI`/`IShaderAPI` and GLSL 330 | works on Vulkan; shader failures degrade per mod as today | -| Harmony mod patching platform internals | warning; runs on Vulkan; user can pin `opengl` | +| Harmony mod naming `ClientPlatformWindows` or `ShaderProgramBase` (`PlatformInternals`) | session forced to `opengl`: a Harmony patch on a platform graphics member is not honoured on Vulkan, because `VulkanClientPlatform` overrides those members and links programs itself, so the patched GL body never runs | +| Mod ships `assets//shaders/.vsh\|.fsh` for a vanilla or Optimum program | runs on Vulkan; that program only is built through the rewriter from the mod's GLSL instead of the native SPIR-V (`rewriterPrograms` lists it) | +| Mod ships any `assets//shaderincludes/*` file | runs on Vulkan; every program takes the rewriter (`rewriterPrograms` = `["all"]`), because every program compiles against the merged include dictionary | +| Mod ships a shader under a new program name | runs on Vulkan through the rewriter; not an override, nothing else changes | | Vulkan < 1.3, missing required feature, no presentable queue | `opengl` with reason | | Previous Vulkan session left a crash marker | one `opengl` session, marker cleared | | macOS | `opengl` | | Wayland/X11 | both via GLFW surfaces; Wayland tested explicitly (the code base already special-cases `IsWaylandSession`) | +Scan decisions are in `/.optimum/shader-compatibility.json` (schema 2): +`disabledFeatures` carries `Vulkan` for both OpenGL routes, with `featureReasons` +and `openGlRequiredBy` naming the mods; a failed scan never vetoes Vulkan but +sets `rewriterPrograms` to `["all"]`, since it cannot tell which programs a mod +replaced. + The GL SSBO gate for Arc (`ClientSystemStartup.cs:1076`) and the 4.3-fallback loop in `AttemptToOpenWindow` are GL-driver workarounds and are bypassed on Vulkan; `UseSSBOs` on Vulkan is true whenever `multiDrawIndirect` is present. diff --git a/docs/vulkan-native-plan.md b/docs/vulkan-native-plan.md index 0298aaa7..e4aa735d 100644 --- a/docs/vulkan-native-plan.md +++ b/docs/vulkan-native-plan.md @@ -417,7 +417,10 @@ records explicitly whether the contract gets a dated v1 addendum (provenance onl an overridden `shaderincludes/*` forces all programs); new `PlatformInternals` indicator (Harmony + `ClientPlatformWindows`/`ShaderProgramBase` strings) → `openGlRequired`; `RawOpenGL` unchanged; `CurrentSchemaVersion` 2. `VULKAN-BACKEND-PLAN.md` §9 states that a Harmony patch on -a platform graphics member is not honoured on Vulkan. +a platform graphics member is not honoured on Vulkan. *Delivered 2026-09-15 (wip/launcher-scanner-v2):* +the report carries `shaderAssetOverrides` (asset, programs, owners) and `rewriterPrograms` (sorted base names, +or `["all"]` for an include override or a failed scan) for the runtime to consume; `openGlRequired` is the +`Vulkan` entry in `disabledFeatures`, with `openGlRequiredBy`; `LoadReport` refuses v1 files. ### D. Mod policy (decision 2 made concrete) From a3a53f9e895e6fcacf97b845451b103d38d71f27 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:09:13 +0200 Subject: [PATCH 173/226] wip(ambient-occlusion): GTAO visibility-bitmask pass on the Vulkan compute pass kind Implements docs/research/ambient-occlusion.md section C on the Vulkan path. Compute shaders (sources/shaders-vk/gtao, embedded in the renderer assembly): - prefilter.comp: D32 depth linearised to five R32F levels with XeGTAO's weighted-average filter, one dispatch. Each invocation owns one 16x16 block and keeps the levels in invocation-local arrays instead of Workgroup memory, because reading Workgroup values across invocations is a data race in SPIR-V. - main.comp: XeGTAO slice/step scaffold (R1 steps, s^2 + minS, pixel snapping, working-depth levels, 2-bit edges); a 32-sector bitmask with round-criterion bits and cosine-CDF sector boundaries evaluated without acos. Uniform-bitmask and XeGTAO horizon integrations are specialization-constant variants. Thickness grows with distance and is randomised per sample, with a thin-class thickness read from gNormal.w. Also: slice weights by projected-normal length, no falloff inside the bitmask, the small-radius fade and vanilla's far fade, Hilbert LUT + R2 noise with 288 * (NoiseIndex % 64|61), the double-sided normal flip, ASSAO normal edges as an option, max(0.03, v), no floor, contrast or FinalValuePower. - denoise.comp: XeGTAO 3x3 edge-aware denoise with symmetric edges and the leak term, 1 pass with TAA, 2 without, 3 selectable. - Sky and hand-class pixels write visibility 1 and pass through the denoise untouched. - XeGTAO, Bevy and ASSAO/Godot MIT notices are in the headers. Renderer: AmbientOcclusion/GtaoRenderer records the three passes through RecordComputePass. GtaoSettings holds presets (low 1x2, medium 2x2, high 3x3, ultra 9x3), the C.13 variants as specialization constants and OPTIMUM_AO_* overrides, plus the 80-byte push block. GtaoProjection derives the GL-projection reconstruction constants, jitter included. HilbertLut is generated at startup, so no noise texture ships. VulkanDevice.TextureOf exposes texture shape to pass owners. Integration: - ClientPlatformAbstract.RenderOptimumAmbientOcclusion: neutral body returns 0, so OpenGL stays vanilla SSAO. VulkanClientPlatform overrides it. - RenderPostprocessingEffects runs vanilla SSAO only when the platform returned nothing. The platform texture is composed through ApplyOptimumSceneSsao before the TAA resolve, at render resolution and never on glow. - scene-ssao.fsh (OPTIMUMAO) texelFetches the visibility with no min-of-two-rows and applies vanilla's gPosition.w + 0.75 * (1 - revealage) attenuation. The albedo multi-bounce hook sits behind OPTIMUMAO_MULTIBOUNCE, which v1 never stamps; GtaoSettings refuses the tone without an albedo texture. - ShaderRegistry stamps OPTIMUMAO = EffectiveGtao && SSAOQuality > 0. - Class channel: hand-view draws (standard/entityanimated with ALLOWDEPTHOFFSET) write -1 into gNormal.w; chunkopaque's no-cull opaque pass (haxyFade: plants, grass, cross-quads) writes 1. All inside #if OPTIMUMAO > 0. - Setting: optimum.json AmbientOcclusion auto|vanilla|gtao (auto = GTAO on Vulkan while TAA is active) and AmbientOcclusionPreset (default medium). - OPTIMUM_AO_OUTPUTS=1 adds working term, edges, working-depth level 0 and output to the parity dump (slots 40-43) and beside every headless frame. - Patcher listings, ExpectedVirtuals and frame-graph reads updated. Verified: - dotnet test Optimum.Render.Vulkan.Tests: 756 passed, 0 failed, 0 skipped. Sync,best validation on; implicit layers disabled, only MESA_device_select inserted. - New AmbientOcclusionTests on synthetic D32/RGBA16F G-buffers: - open floor min visibility 0.984; - crease darker than open floor; - one-texel thin slab 0.3 blocks in front of a wall: bitmask 0.990 vs horizon 0.932; - near a crease, cosine > uniform (0.905 vs 0.849); - hand and sky exactly 1; - finite working depth, visibility within [0.03, 1]; - byte-identical across 6 presented frames with a fixed NoiseIndex; - view depth within 4.4e-6 relative of the analytic value under jitter; - every storage-format fallback compiles. - New SceneSsaoTests compose test; TAA resolve tests unchanged and green; corpus row taa-with-gtao translates OPTIMUMAO 1. - dotnet test Optimum.Tests -c Release: 1190 passed, 34 skipped, 0 failed. - extract-patches.sh: 157 patches. check-patches.sh: 93 applied, 64 cecil, 0 conflict; runtime 43 applied. - Not run in game or headless. --- Optimum.Patcher/Program.cs | 13 + .../AmbientOcclusionTests.cs | 642 ++++++++++++++++++ Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs | 107 +++ Optimum.Render.Vulkan.Tests/ShaderCorpus.cs | 12 + .../AmbientOcclusion/GtaoProjection.cs | 46 ++ .../AmbientOcclusion/GtaoRenderer.cs | 238 +++++++ .../AmbientOcclusion/GtaoSettings.cs | 260 +++++++ .../AmbientOcclusion/GtaoShaderSources.cs | 67 ++ .../AmbientOcclusion/HilbertLut.cs | 44 ++ .../Optimum.Render.Vulkan.csproj | 9 + .../VulkanClientPlatform.AmbientOcclusion.cs | 116 ++++ .../VulkanClientPlatform.FrameBuffers.cs | 2 + .../Platform/VulkanClientPlatform.Graph.cs | 15 +- .../Platform/VulkanClientPlatform.cs | 11 + Optimum.Render.Vulkan/VulkanDevice.cs | 3 + .../ambient-occlusion-coverage-tests.cs | 296 ++++++++ ...-platform-windows-vanilla-regions-tests.cs | 4 + Optimum.Tests/parity-dump-coverage-tests.cs | 7 +- .../ClientPlatformAbstract.cs.patch | 24 +- .../ClientPlatformWindows.cs.patch | 191 ++++-- .../ShaderRegistry.cs.patch | 12 +- .../Client/optimum-render-device.cs | 17 + .../VintagestoryApi/Config/OptimumConfig.cs | 56 ++ sources/shaders-vk/gtao/common.glsl | 197 ++++++ sources/shaders-vk/gtao/denoise.comp | 110 +++ sources/shaders-vk/gtao/main.comp | 325 +++++++++ sources/shaders-vk/gtao/prefilter.comp | 137 ++++ sources/shaders/chunkopaque.fsh | 9 + sources/shaders/entityanimated.fsh | 4 + sources/shaders/scene-ssao.fsh | 42 ++ sources/shaders/standard.fsh | 5 + 31 files changed, 2964 insertions(+), 57 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs create mode 100644 Optimum.Render.Vulkan/AmbientOcclusion/GtaoProjection.cs create mode 100644 Optimum.Render.Vulkan/AmbientOcclusion/GtaoRenderer.cs create mode 100644 Optimum.Render.Vulkan/AmbientOcclusion/GtaoSettings.cs create mode 100644 Optimum.Render.Vulkan/AmbientOcclusion/GtaoShaderSources.cs create mode 100644 Optimum.Render.Vulkan/AmbientOcclusion/HilbertLut.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs create mode 100644 Optimum.Tests/ambient-occlusion-coverage-tests.cs create mode 100644 sources/shaders-vk/gtao/common.glsl create mode 100644 sources/shaders-vk/gtao/denoise.comp create mode 100644 sources/shaders-vk/gtao/main.comp create mode 100644 sources/shaders-vk/gtao/prefilter.comp diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index bf8b761c..334fdf56 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -86,6 +86,9 @@ "RenderOptimumTaaSharpen", "OptimumFsrBlitActive", "DisableOptimumTaa", + // Optimum AO: the platform's own ambient occlusion (0 = vanilla SSAO) and its debug outputs. + "RenderOptimumAmbientOcclusion", + "OptimumAmbientOcclusionDebugTexture", // Headless render harness: the channel order ReadDefaultFramebuffer leaves // in the caller's buffer. B G R A on both backends: the OpenGL path reads // GL_BGRA and VulkanClientPlatform converts its R8G8B8A8 texels to match. @@ -347,6 +350,16 @@ // the flag Final reads so the AO is never applied twice. "optimumSsaoInScene", "ApplyOptimumSceneSsao", + // Optimum AO: this frame's GTAO visibility texture (0 = vanilla SSAO), composed through + // ApplyOptimumSceneSsao, and the slots of its opt-in debug outputs in the parity dump and + // the headless harness. + "optimumAmbientOcclusionTexture", + "OptimumAoWorkingSlot", + "OptimumAoEdgesSlot", + "OptimumAoDepthSlot", + "OptimumAoOutputSlot", + "OptimumAoOutputCount", + "OptimumHeadlessWriteAmbientOcclusion", // Phase 0 parity: the per-attachment dump (OPTIMUM_PARITY_DUMP) called from // window_RenderFrame, its in-world frame counter, slot names, the single // device-readback call site and the glGetTexImage body. diff --git a/Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs b/Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs new file mode 100644 index 00000000..df3f9063 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs @@ -0,0 +1,642 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using Optimum.Render.Vulkan.AmbientOcclusion; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The GTAO visibility-bitmask pass (docs/research/ambient-occlusion.md section C) on a device, +/// validation with sync and best practices on, against synthetic G-buffers drawn analytically: +/// a camera with a 90-degree square frustum looking down -z over a floor at y = -1, an optional +/// back wall, a one-texel slab and a hand-view rectangle. Depth goes through the real D32 +/// attachment, normals and the class channel through an RGBA16F one, exactly as Primary holds +/// them; the three compute passes run through as the platform does. +/// +/// The CPU facts at the bottom pin what the shaders and the C# side must agree on: the +/// reconstruction constants, the Hilbert table, the specialization ids and the push layout. +/// +public class AmbientOcclusionTests(ITestOutputHelper output) +{ + private const int Size = 64; + private const float Near = 0.1f; + private const float Far = 1000f; + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + // Ray per pixel in GL view space (tan = 1, so the ray is (ndc + jitter, -1)); the + // nearest analytic surface writes its GL depth, its GL view-space normal and class, and + // its view distance for the reconstruction check. + private const string SceneFragment = """ + #version 330 core + uniform int scene; + uniform float jitterX; + uniform float jitterY; + layout(location = 0) out vec4 outNormal; + layout(location = 1) out vec4 outViewDepth; + void main(void) + { + const float Near = 0.1; + const float Far = 1000.0; + vec2 ndc = gl_FragCoord.xy / 64.0 * 2.0 - 1.0; + vec3 ray = vec3(ndc.x + jitterX, ndc.y + jitterY, -1.0); + float t = 1e9; + vec3 n = vec3(0.0); + float surfaceClass = 0.0; + if (scene != 2 && ray.y < 0.0) + { + float floorT = -1.0 / ray.y; + if (floorT < t) { t = floorT; n = vec3(0.0, 1.0, 0.0); surfaceClass = 0.0; } + } + if (scene == 1 || scene == 3) + { + if (3.0 < t) { t = 3.0; n = vec3(0.0, 0.0, 1.0); surfaceClass = 0.0; } + } + if (scene == 2) + { + // A wall 4 blocks out and, 0.3 blocks in front of it, a rail one texel tall + // (a texel is 0.116 blocks at 3.7) flagged thin like foliage. + t = 4.0; n = vec3(0.0, 0.0, 1.0); surfaceClass = 0.0; + if (abs(ray.y * 3.7) < 0.058) { t = 3.7; surfaceClass = 1.0; } + } + if (scene == 3 && gl_FragCoord.x > 40.0 && gl_FragCoord.x < 56.0 && gl_FragCoord.y > 8.0 && gl_FragCoord.y < 24.0) + { + t = 0.5; n = vec3(0.0, 0.0, 1.0); surfaceClass = -1.0; + } + if (t > 1e8) + { + outNormal = vec4(0.0); + outViewDepth = vec4(0.0); + gl_FragDepth = 1.0; + return; + } + float a = -(Far + Near) / (Far - Near); + float b = -2.0 * Far * Near / (Far - Near); + float ndcZ = (a * -t + b) / t; + gl_FragDepth = ndcZ * 0.5 + 0.5; + outNormal = vec4(n, surfaceClass); + outViewDepth = vec4(t, 0.0, 0.0, 1.0); + } + """; + + private const string CopyFragment = """ + #version 330 core + uniform sampler2D ao; + layout(location = 0) out vec4 outColor; + void main(void) { outColor = vec4(texelFetch(ao, ivec2(gl_FragCoord.xy), 0).rrr, 1.0); } + """; + + private enum Scene + { + OpenFloor = 0, + Crease = 1, + Slab = 2, + Hand = 3, + } + + private static float[] Projection(float jitterX = 0f, float jitterY = 0f) + { + var m = new float[16]; + m[0] = 1f; + m[5] = 1f; + m[8] = jitterX; + m[9] = jitterY; + m[10] = -(Far + Near) / (Far - Near); + m[11] = -1f; + m[14] = -2f * Far * Near / (Far - Near); + return m; + } + + /// The synthetic Primary: D32 depth, RGBA16F gNormal, an R32F view distance. + private sealed class Rig : IDisposable + { + public readonly VulkanDevice Device; + public readonly GtaoRenderer Ao; + public readonly int Program; + public readonly int Framebuffer; + public readonly int Depth; + public readonly int Normal; + public readonly int ViewDepth; + + public Rig(VulkanDevice device) + { + Device = device; + Program = VulkanDeviceIntegrationTests.LinkProgram(device, FullscreenVertex, SceneFragment, "ao-scene"); + Depth = device.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + Normal = device.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, + IntPtr.Zero, false); + ViewDepth = device.CreateTexture2DRaw(Size, Size, 0x822E, IntPtr.Zero, 4); + Framebuffer = device.CreateFramebuffer(Size, Size); + device.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment0, Normal, 0); + device.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment1, ViewDepth, 0); + device.AttachTexture(Framebuffer, EnumFramebufferAttachment.DepthAttachment, Depth, 0); + device.SetDrawBuffers(Framebuffer, 3); + Assert.True(device.CheckFramebufferComplete(Framebuffer, out string status), status); + Ao = new GtaoRenderer(device); + } + + public void Draw(Scene scene, float jitterX = 0f, float jitterY = 0f) + { + Device.BindFramebuffer(Framebuffer); + Device.SetViewport(0, 0, Size, Size); + Device.SetCullFace(false); + Device.SetBlend(false, EnumBlendMode.Standard); + Device.SetDepthTest(true); + Device.SetDepthMask(true); + Device.SetDepthFunc(0x0207); // GL_ALWAYS + Device.UseProgram(Program); + Device.SetUniform(Program, Device.GetUniformLocation(Program, "scene"), (int)scene); + Device.SetUniform(Program, Device.GetUniformLocation(Program, "jitterX"), jitterX); + Device.SetUniform(Program, Device.GetUniformLocation(Program, "jitterY"), jitterY); + Device.DrawFullscreenTriangle(); + } + + public int Render(GtaoSettings settings, uint noiseIndex, float[]? projection = null) + { + int texture = Ao.Render(Depth, Normal, projection ?? Projection(), settings, noiseIndex); + Assert.True(texture != 0, Ao.LastError); + return texture; + } + + /// The first channel of an 8-bit storage texture, in [0, 1], row 0 at the bottom. + public float[] ReadUnorm(int texture) => ReadUnormFrom(Device.ReadBackLevel0ForTests(texture), texture); + + public float[] ReadUnormFrom(byte[] bytes, int texture) + { + int channels = Device.TextureOf(texture)!.Format switch + { + Format.R8Unorm => 1, + Format.R8G8Unorm => 2, + _ => 4, + }; + var values = new float[Size * Size]; + for (int i = 0; i < values.Length; i++) values[i] = bytes[i * channels] / 255f; + return values; + } + + public byte ByteAt(byte[] bytes, int texture, int x, int y) + { + int channels = Device.TextureOf(texture)!.Format switch + { + Format.R8Unorm => 1, + Format.R8G8Unorm => 2, + _ => 4, + }; + return bytes[(y * Size + x) * channels]; + } + + public void Dispose() => Ao.Dispose(); + } + + private static double Mean(float[] values, Func inside) + { + double sum = 0; + int count = 0; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + if (!inside(x, y)) continue; + sum += values[y * Size + x]; + count++; + } + Assert.True(count > 0); + return sum / count; + } + + private static GtaoSettings Settings(GtaoIntegration integration = GtaoIntegration.BitmaskCosine, + GtaoPreset preset = GtaoPreset.High) => + GtaoSettings.ForPreset(preset, temporal: true) with { Integration = integration }; + + [SkippableFact] + public void AnOpenPlaneIsUnoccludedAndACreaseIsDarker() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + + device!.BeginFrame(); + rig.Draw(Scene.OpenFloor); + float[] open = rig.ReadUnorm(rig.Render(Settings(), 0)); + device.Present(); + + device.BeginFrame(); + rig.Draw(Scene.Crease); + float[] crease = rig.ReadUnorm(rig.Render(Settings(), 0)); + device.Present(); + + // Floor rows 0..20 are 1.0 to 2.9 blocks away: open ground with nothing above it. + double openMin = 1.0; + for (int y = 0; y <= 20; y++) + for (int x = 0; x < Size; x++) + openMin = Math.Min(openMin, open[y * Size + x]); + output.WriteLine("open floor min visibility " + openMin.ToString("F4")); + Assert.True(openMin >= 0.98, "open floor min visibility " + openMin); + + // The wall meets the floor at row 21.3; the rows just below it sit in the crease. + double inCrease = Mean(crease, (_, y) => y is >= 17 and <= 20); + double awayFromCrease = Mean(crease, (_, y) => y is >= 0 and <= 4); + output.WriteLine("crease " + inCrease.ToString("F4") + ", away " + awayFromCrease.ToString("F4")); + Assert.True(inCrease < 0.9, "crease visibility " + inCrease); + Assert.True(inCrease < awayFromCrease - 0.05, "crease " + inCrease + " vs away " + awayFromCrease); + + rig.Dispose(); + GpuTest.AssertClean(device); + } + } + + [SkippableFact] + public void AOneTexelSlabOccludesLessWithTheBitmaskThanWithTheHorizonIntegral() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + // The wall rows within the effect radius (about 9 texels) of the rail, the rail excluded. + bool AroundTheSlab(int x, int y) => x is >= 8 and < 56 && y is >= 26 and <= 38 && y is < 31 or > 33; + + var means = new Dictionary(); + foreach (GtaoIntegration integration in new[] { GtaoIntegration.BitmaskCosine, GtaoIntegration.Horizon }) + { + device!.BeginFrame(); + rig.Draw(Scene.Slab); + means[integration] = Mean(rig.ReadUnorm(rig.Render(Settings(integration), 0)), AroundTheSlab); + device.Present(); + output.WriteLine(integration + " mean visibility around the slab " + means[integration].ToString("F4")); + } + + // The horizon integral treats the slab as infinitely thick; the bitmask lets light + // pass behind its thickness. + Assert.True(means[GtaoIntegration.Horizon] < 0.97, "the slab must visibly occlude the horizon variant"); + Assert.True(means[GtaoIntegration.BitmaskCosine] > means[GtaoIntegration.Horizon] + 0.02, + "bitmask " + means[GtaoIntegration.BitmaskCosine] + " vs horizon " + means[GtaoIntegration.Horizon]); + + rig.Dispose(); + GpuTest.AssertClean(device!); + } + } + + [SkippableFact] + public void CosineWeightingCountsGrazingOccludersLessThanUniformSectors() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + bool NearTheCrease(int _, int y) => y is >= 14 and <= 20; + + var means = new Dictionary(); + foreach (GtaoIntegration integration in new[] { GtaoIntegration.BitmaskCosine, GtaoIntegration.BitmaskUniform }) + { + device!.BeginFrame(); + rig.Draw(Scene.Crease); + means[integration] = Mean(rig.ReadUnorm(rig.Render(Settings(integration), 0)), NearTheCrease); + device.Present(); + output.WriteLine(integration + " mean visibility near the crease " + means[integration].ToString("F4")); + } + + // A crease occludes the floor from its horizon up; cosine weighting gives the + // near-horizon sectors less weight ((1 - cos a) / 2 against a / pi of the slice for + // an occluder up to elevation a), so the documented direction is brighter. + Assert.True(means[GtaoIntegration.BitmaskUniform] < 0.99); + Assert.True(means[GtaoIntegration.BitmaskCosine] > means[GtaoIntegration.BitmaskUniform], + "cosine " + means[GtaoIntegration.BitmaskCosine] + " vs uniform " + means[GtaoIntegration.BitmaskUniform]); + + rig.Dispose(); + GpuTest.AssertClean(device!); + } + } + + [SkippableFact] + public void TheHandClassAndTheSkyReceiveNoOcclusion() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + + device!.BeginFrame(); + rig.Draw(Scene.Hand); + int texture = rig.Render(Settings(), 0); + byte[] hand = device.ReadBackLevel0ForTests(texture); + byte[] handWorking = device.ReadBackLevel0ForTests(rig.Ao.WorkingTermTexture); + device.Present(); + + int handPixels = 0; + for (int y = 9; y <= 23; y++) + for (int x = 41; x <= 55; x++) + { + Assert.Equal(255, rig.ByteAt(hand, texture, x, y)); + Assert.Equal(170, rig.ByteAt(handWorking, rig.Ao.WorkingTermTexture, x, y)); // 1 / 1.5 + handPixels++; + } + // And the crease around the hand is still occluded: the pass did run. + Assert.True(Mean(rig.ReadUnormFrom(hand, texture), (x, y) => x < 36 && y is >= 17 and <= 20) < 0.95); + + device.BeginFrame(); + rig.Draw(Scene.OpenFloor); + texture = rig.Render(Settings(), 0); + byte[] sky = device.ReadBackLevel0ForTests(texture); + device.Present(); + for (int y = 32; y < Size; y++) + for (int x = 0; x < Size; x++) + Assert.Equal(255, rig.ByteAt(sky, texture, x, y)); + + output.WriteLine(handPixels + " hand pixels and " + (Size - 32) * Size + " sky pixels at visibility 1"); + rig.Dispose(); + GpuTest.AssertClean(device); + } + } + + [SkippableFact] + public void TheWorkingDepthIsFiniteAndTheVisibilityStaysInItsRange() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + foreach (GtaoIntegration integration in Enum.GetValues()) + { + device!.BeginFrame(); + rig.Draw(Scene.Hand); + float[] visibility = rig.ReadUnorm(rig.Render(Settings(integration), 3)); + for (uint level = 0; level < GtaoRenderer.DepthLevels; level++) + { + float[] depth = MemoryMarshal.Cast(device.ReadBackLevelForTests(rig.Ao.WorkingDepthTexture, level)).ToArray(); + Assert.Equal((Size >> (int)level) * (Size >> (int)level), depth.Length); + foreach (float z in depth) Assert.True(float.IsFinite(z) && z > 0f && z <= Far * 1.001f, "level " + level + " depth " + z); + } + // max(0.03, v) survives the UNORM packing; a NaN would have stored 0. + foreach (float v in visibility) Assert.InRange(v, 0.03f - 1f / 255f, 1f); + device.Present(); + } + rig.Dispose(); + GpuTest.AssertClean(device!); + } + } + + [SkippableFact] + public void TheResultIsStableAcrossPresentedFramesWithAFixedNoiseIndex() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + const int frames = 6; + int copy = VulkanDeviceIntegrationTests.LinkProgram(device!, FullscreenVertex, CopyFragment, "ao-copy"); + device!.SetSamplerUnit(copy, "ao", 0); + var colours = new int[frames]; + var targets = new int[frames]; + for (int i = 0; i < frames; i++) + { + colours[i] = device.CreateTexture2DRaw(Size, Size, 0x8058, IntPtr.Zero, 4); + targets[i] = device.CreateFramebuffer(Size, Size); + device.AttachTexture(targets[i], EnumFramebufferAttachment.ColorAttachment0, colours[i], 0); + device.SetDrawBuffers(targets[i], 1); + } + + // No readback in the loop: each frame draws the scene, runs the passes and copies + // the output into its own target, then presents. + for (int i = 0; i < frames; i++) + { + device.BeginFrame(); + rig.Draw(Scene.Crease); + int texture = rig.Render(Settings(), 11); + device.BindFramebuffer(targets[i]); + device.SetViewport(0, 0, Size, Size); + device.SetDepthTest(false); + device.UseProgram(copy); + device.BindTexture(0, texture); + device.DrawFullscreenTriangle(); + device.BindTexture(0, 0); + device.Present(); + } + + device.BeginFrame(); + byte[] first = device.ReadBackLevel0ForTests(colours[0]); + Assert.Contains(first.Where((_, i) => i % 4 == 0), b => b < 250); + for (int i = 1; i < frames; i++) Assert.Equal(first, device.ReadBackLevel0ForTests(colours[i])); + device.Present(); + + // The noise is live: another index moves the pre-denoise term. + device.BeginFrame(); + rig.Draw(Scene.Crease); + rig.Render(Settings(), 11); + byte[] still = device.ReadBackLevel0ForTests(rig.Ao.WorkingTermTexture); + rig.Render(Settings(), 12); + byte[] moved = device.ReadBackLevel0ForTests(rig.Ao.WorkingTermTexture); + device.Present(); + Assert.NotEqual(still, moved); + + rig.Dispose(); + GpuTest.AssertClean(device); + } + } + + [SkippableFact] + public void TheWorkingDepthReconstructsTheAnalyticViewDepthWithinATenthOfAPercent() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + var rig = new Rig(device!); + // A TAA-sized jitter, so the jitter columns of the projection are exercised too. + const float jitterX = 0.6f / Size, jitterY = -0.35f / Size; + + device!.BeginFrame(); + rig.Draw(Scene.Crease, jitterX, jitterY); + rig.Render(Settings(), 0, Projection(jitterX, jitterY)); + float[] working = MemoryMarshal.Cast(device.ReadBackLevelForTests(rig.Ao.WorkingDepthTexture, 0)).ToArray(); + float[] analytic = MemoryMarshal.Cast(device.ReadBackLevel0ForTests(rig.ViewDepth)).ToArray(); + device.Present(); + + int compared = 0; + double worst = 0; + for (int i = 0; i < analytic.Length; i++) + { + if (analytic[i] <= 0f || analytic[i] >= 100f) continue; + double error = Math.Abs(working[i] - analytic[i]) / analytic[i]; + worst = Math.Max(worst, error); + compared++; + } + output.WriteLine(compared + " pixels, worst relative view-depth error " + worst.ToString("E3")); + Assert.True(compared > Size * Size / 2); + Assert.True(worst < 1e-3, "worst relative error " + worst); + + rig.Dispose(); + GpuTest.AssertClean(device); + } + } + + [SkippableFact] + public void EveryStorageFormatFallbackCompilesWithMatchingQualifiers() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + // StorageFormats' candidate chains end in RGBA32F for the depth and RGBA8 for the terms. + foreach (Format depth in new[] { Format.R32Sfloat, Format.R32G32B32A32Sfloat }) + foreach (Format term in new[] { Format.R8Unorm, Format.R8G8Unorm, Format.R8G8B8A8Unorm }) + foreach (string file in new[] { "prefilter.comp", "main.comp", "denoise.comp" }) + { + string source = GtaoShaderSources.Build(file, depth, term); + int program = device!.CreateComputeProgram(source, file, Array.Empty(), GtaoSettings.PushConstantBytes); + Assert.True(program > 0, file + " " + depth + "/" + term + ": " + device.GetError()); + device.DeleteComputeProgram(program); + } + GpuTest.AssertClean(device!); + } + } + + // ------------------------------------------------------------------ device-free + + [Fact] + public void TheReconstructionConstantsInvertAJitteredGlProjection() + { + float[] m = Projection(0.6f / Size, -0.35f / Size); + GtaoProjection projection = GtaoProjection.From(m)!.Value; + var random = new Random(7); + for (int i = 0; i < 200; i++) + { + float z = 0.2f + (float)random.NextDouble() * 150f; + float x = ((float)random.NextDouble() * 2f - 1f) * z; + float y = ((float)random.NextDouble() * 2f - 1f) * z; + // GL: clip = P * (x, y, -z, 1) + float clipX = m[0] * x + m[8] * -z; + float clipY = m[5] * y + m[9] * -z; + float clipZ = m[10] * -z + m[14]; + float clipW = -(-z); + float u = (clipX / clipW + 1f) / 2f; + float v = (clipY / clipW + 1f) / 2f; + float depth = (clipZ / clipW + 1f) / 2f; + + float viewDepth = projection.ViewDepth(depth); + Assert.True(Math.Abs(viewDepth - z) / z < 1e-3, "depth " + viewDepth + " vs " + z); + (float rx, float ry, _) = projection.ViewPosition(u, v, z); + Assert.True(Math.Abs(rx - x) <= 1e-3 * z, "x " + rx + " vs " + x); + Assert.True(Math.Abs(ry - y) <= 1e-3 * z, "y " + ry + " vs " + y); + } + Assert.Null(GtaoProjection.From(new float[16])); + } + + [Fact] + public void TheHilbertTableIsAPermutationWithAdjacentNeighbours() + { + float[] table = HilbertLut.Build(); + Assert.Equal(4096, table.Length); + var positions = new (int X, int Y)[4096]; + var seen = new bool[4096]; + for (int y = 0; y < 64; y++) + for (int x = 0; x < 64; x++) + { + int index = (int)table[y * 64 + x]; + Assert.False(seen[index]); + seen[index] = true; + positions[index] = (x, y); + } + for (int i = 1; i < 4096; i++) + { + Assert.Equal(1, Math.Abs(positions[i].X - positions[i - 1].X) + Math.Abs(positions[i].Y - positions[i - 1].Y)); + } + } + + [Fact] + public void TheSpecializationIdsAndThePushBlockAgreeWithTheInclude() + { + string include = File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk", "gtao", "common.glsl")); + var ids = Regex.Matches(include, @"#define GTAO_SPEC_(\w+) (\d+)") + .ToDictionary(m => m.Groups[1].Value.Replace("_", ""), m => int.Parse(m.Groups[2].Value), StringComparer.OrdinalIgnoreCase); + foreach (var field in typeof(GtaoSpecialization).GetFields().Where(f => f.IsLiteral && f.Name != "Count")) + { + Assert.True(ids.TryGetValue(field.Name, out int id), field.Name + " missing from common.glsl"); + Assert.Equal((int)field.GetValue(null)!, id); + } + Assert.Equal(ids.Count, typeof(GtaoSpecialization).GetFields().Count(f => f.IsLiteral && f.Name != "Count")); + Assert.Equal(ids.Values.Max() + 1, GtaoSpecialization.Count); + + // Every member four bytes wide, in the order GtaoSettings.PushConstants writes them. + string block = include[include.IndexOf("uniform GtaoConstants", StringComparison.Ordinal)..]; + block = block[..block.IndexOf("} gtao;", StringComparison.Ordinal)]; + string[] members = Regex.Matches(block, @"^\s*(float|uint) (\w+);", RegexOptions.Multiline).Select(m => m.Groups[2].Value).ToArray(); + Assert.Equal(new[] + { + "depthUnpackMul", "depthUnpackAdd", "ndcToViewMulX", "ndcToViewMulY", "ndcToViewAddX", "ndcToViewAddY", + "effectRadius", "effectFalloffRange", "radiusMultiplier", "finalValuePower", "sampleDistributionPower", + "depthMipSamplingOffset", "thickness", "thicknessThin", "thicknessDistanceScale", "farFadeBias", "farFadeScale", + "noiseIndex", "denoiseBlurBeta", "reserved", + }, members); + Assert.Equal(GtaoSettings.PushConstantBytes, members.Length * 4); + + byte[] push = new GtaoSettings { EffectRadius = 0.9f }.PushConstants(GtaoProjection.From(Projection())!.Value, 42); + Assert.Equal(GtaoSettings.PushConstantBytes, push.Length); + Assert.Equal(0.9f, BitConverter.ToSingle(push, 24)); + Assert.Equal(42u, BitConverter.ToUInt32(push, 68)); + } + + [Fact] + public void PresetsVariantsAndTheAlbedoHookResolveAsDocumented() + { + Assert.Equal((1u, 2u, 1u), Counts(GtaoSettings.ForPreset(GtaoPreset.Low, true))); + Assert.Equal((2u, 2u, 1u), Counts(GtaoSettings.ForPreset(GtaoPreset.Medium, true))); + Assert.Equal((3u, 3u, 1u), Counts(GtaoSettings.ForPreset(GtaoPreset.High, true))); + Assert.Equal((9u, 3u, 2u), Counts(GtaoSettings.ForPreset(GtaoPreset.Ultra, true))); + Assert.Equal((2u, 2u, 2u), Counts(GtaoSettings.ForPreset(GtaoPreset.Medium, false))); + Assert.Equal(GtaoPreset.Medium, GtaoSettings.ParsePreset("nonsense")); + Assert.Equal(GtaoPreset.Ultra, GtaoSettings.ParsePreset(" Ultra ")); + + GtaoSettings defaults = GtaoSettings.ForPreset(GtaoPreset.Medium, true); + Assert.Equal(GtaoIntegration.BitmaskCosine, defaults.Integration); + Assert.Equal(GtaoThickness.Random, defaults.Thickness); + Assert.True(defaults.ClassChannel); + Assert.Equal(64u, defaults.NoiseCycle); + Assert.Equal(1.0f, defaults.FinalValuePower); // no FinalValuePower (C.11) + + var environment = new Dictionary + { + ["OPTIMUM_AO_INTEGRATION"] = "horizon", + ["OPTIMUM_AO_THICKNESS"] = "const", + ["OPTIMUM_AO_CLASS_CHANNEL"] = "0", + ["OPTIMUM_AO_NOISE_CYCLE"] = "61", + ["OPTIMUM_AO_DENOISE_PASSES"] = "3", + ["OPTIMUM_AO_NORMAL_EDGES"] = "1", + ["OPTIMUM_AO_TONE"] = "multibounce", + ["OPTIMUM_AO_FINAL_POWER"] = "2.2", + }; + GtaoSettings measured = defaults.WithEnvironment(name => environment.GetValueOrDefault(name)); + uint[] specialization = measured.MainSpecialization(); + Assert.Equal((uint)GtaoIntegration.Horizon, specialization[GtaoSpecialization.Integration]); + Assert.Equal((uint)GtaoThickness.Constant, specialization[GtaoSpecialization.Thickness]); + Assert.Equal(0u, specialization[GtaoSpecialization.ClassChannel]); + Assert.Equal(61u, specialization[GtaoSpecialization.NoiseCycle]); + Assert.Equal(1u, specialization[GtaoSpecialization.NormalEdges]); + Assert.Equal(3u, measured.DenoisePasses); + Assert.Equal(2.2f, measured.FinalValuePower); + Assert.Equal(defaults, defaults.WithEnvironment(_ => "garbage")); + + // Multi-bounce needs a real albedo; the lit scene colour is not one. + Assert.Equal(GtaoTone.Linear, measured.EffectiveTone(0, out string? refusal)); + Assert.NotNull(refusal); + Assert.Equal(GtaoTone.MultiBounce, measured.EffectiveTone(123, out refusal)); + Assert.Null(refusal); + Assert.Equal(0u, GtaoSettings.DenoiseSpecialization(false)[GtaoSpecialization.FinalApply]); + Assert.Equal(1u, GtaoSettings.DenoiseSpecialization(true)[GtaoSpecialization.FinalApply]); + + static (uint, uint, uint) Counts(GtaoSettings s) => (s.SliceCount, s.StepsPerSlice, s.DenoisePasses); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs b/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs index 33ba49c5..9e097445 100644 --- a/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs +++ b/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs @@ -104,4 +104,111 @@ public unsafe void OcclusionMultipliesOnlySceneColourBeforeTheResolve(int qualit GpuTest.AssertClean(seam); } } + + /// + /// The OPTIMUMAO variant (docs/research/ambient-occlusion.md C.9, C.11): the GTAO visibility is + /// fetched at render resolution with no min-of-two-rows, attenuated by vanilla's water, fog and + /// OIT term (gPosition.w + 0.75 * (1 - revealage)), and still multiplies only colour 0. + /// + [SkippableFact] + public unsafe void GtaoVisibilityIsComposedWithTheVanillaAttenuationAndNoRowMin() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device"); + using (device) + { + VulkanDevice seam = device!; + const int size = 8, frames = 4; + const float positionW = 0.25f, revealage = 0.6f; + var files = ShaderCorpus.LoadShaderFiles(); + int program = VulkanDeviceIntegrationTests.LinkProgram( + seam, + files["scene-ssao.vsh"], + files["scene-ssao.fsh"].Replace("#version 330 core", "#version 330 core\n#define SSAOLEVEL 2\n#define OPTIMUMAO 1"), + "scene-ssao-gtao"); + + var ao = new byte[size * size * 4]; + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + for (int c = 0; c < 4; c++) ao[(y * size + x) * 4 + c] = (byte)(y % 2 == 0 ? 64 : 192); + int visibility; + fixed (byte* data = ao) visibility = seam.CreateTexture2DRaw(size, size, 0x8058, (IntPtr)data, 4); + + int position = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int reveal = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4); + int inputs = seam.CreateFramebuffer(size, size); + seam.AttachTexture(inputs, EnumFramebufferAttachment.ColorAttachment0, position, 0); + seam.AttachTexture(inputs, EnumFramebufferAttachment.ColorAttachment1, reveal, 0); + seam.SetDrawBuffers(inputs, 3); + + var colors = new int[frames][]; + var targets = new int[frames]; + for (int i = 0; i < frames; i++) + { + targets[i] = seam.CreateFramebuffer(size, size); + colors[i] = new int[3]; + for (int slot = 0; slot < 3; slot++) + { + colors[i][slot] = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4); + seam.AttachTexture(targets[i], (EnumFramebufferAttachment)(36064 + slot), colors[i][slot], 0); + } + } + + seam.SetViewport(0, 0, size, size); + seam.SetCullFace(false); + seam.SetDepthTest(false); + seam.SetSamplerUnit(program, "ssaoScene", 0); + seam.SetSamplerUnit(program, "gPositionScene", 1); + seam.SetSamplerUnit(program, "revealageScene", 2); + seam.SetUniform(program, seam.GetUniformLocation(program, "invRenderHeight"), 1f / size); + seam.SetUniform(program, seam.GetUniformLocation(program, "optimumAoMode"), 1); + for (int i = 0; i < frames; i++) + { + seam.BeginFrame(); + seam.BindFramebuffer(inputs); + seam.ClearColor(0, 0f, 0f, 0f, positionW); + seam.ClearColor(1, revealage, 0f, 0f, 1f); + seam.BindFramebuffer(targets[i]); + seam.SetDrawBuffers(targets[i], 7); + seam.ClearColor(0, 0.75f, 0.75f, 0.75f, 1f); + seam.ClearColor(1, 0.25f, 0.25f, 0.25f, 1f); // glow: never touched + seam.ClearColor(2, 0.5f, 0.5f, 0.5f, 1f); + seam.SetDrawBuffers(targets[i], 1); + seam.SetBlend(true, EnumBlendMode.Multiply); + seam.UseProgram(program); + seam.BindTexture(0, visibility); + seam.BindTexture(1, position); + seam.BindTexture(2, reveal); + seam.DrawFullscreenTriangle(); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetDrawBuffers(targets[i], 7); + seam.Present(); + } + + double attenuate = positionW + (1.0 - Math.Round(revealage * 255) / 255.0) * 0.75; + seam.BeginFrame(); + for (int i = 0; i < frames; i++) + { + for (int slot = 0; slot < 3; slot++) + { + byte[] pixels = seam.ReadBackLevel0ForTests(colors[i][slot]); + for (int y = 0; y < size; y++) + for (int x = 0; x < size; x++) + { + double v = (y % 2 == 0 ? 64 : 192) / 255.0; + double factor = Math.Clamp(1.0 - (1.0 - v) * (1.0 - attenuate), 0.0, 1.0); + double expected = slot switch + { + 0 => Math.Round(0.75 * 255) * factor, + 1 => Math.Round(0.25 * 255), + _ => Math.Round(0.5 * 255), + }; + int offset = (y * size + x) * 4; + for (int c = 0; c < 3; c++) Assert.InRange((double)pixels[offset + c], expected - 1.1, expected + 1.1); + } + } + } + seam.Present(); + GpuTest.AssertClean(seam); + } + } } diff --git a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs index 61c26dff..269bd314 100644 --- a/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs +++ b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs @@ -193,6 +193,8 @@ public sealed class ShaderVariant public int TaaMotion; /// Primary colour attachment the motion texture occupies: 4 with the SSAO G-buffer, 2 without. public int TaaMotionLocation = 2; + /// ShaderRegistry's OPTIMUMAO: 1 while the Vulkan platform runs GTAO. + public int OptimumAo; /// /// Defines a caller put on the program itself before the engine's block, @@ -257,6 +259,14 @@ public static IEnumerable Variants() // the bodies of every vertexwarp function the motion writers replay for // the previous frame - so with TAA on it is a shipped combination that // no other row produced (the "everything-off" row carries TAAMOTION 0). + // The Vulkan AO row: TAA with the SSAO G-buffer and OPTIMUMAO 1, the combination that + // compiles in the class-channel writes (chunkopaque's no-cull flag, the hand-view class + // in standard and entityanimated) and scene-ssao's GTAO compose branch. + yield return new ShaderVariant + { + Name = "taa-with-gtao", + SsaoLevel = 2, DynLights = 4, TaaMotion = 1, TaaMotionLocation = 4, OptimumAo = 1, + }; yield return new ShaderVariant { Name = "taa-no-waving", @@ -289,6 +299,7 @@ public static string PrefixFor(EnumShaderType stage, ShaderVariant variant) lines.Add($"#define GREEDYMESH_GRAD 0"); lines.Add($"#define TAAMOTION {variant.TaaMotion}"); lines.Add($"#define TAAMOTIONLOCATION {variant.TaaMotionLocation}"); + lines.Add($"#define OPTIMUMAO {variant.OptimumAo}"); } else { @@ -306,6 +317,7 @@ public static string PrefixFor(EnumShaderType stage, ShaderVariant variant) lines.Add($"#define GREEDYMESH {variant.GreedyMesh}"); lines.Add($"#define TAAMOTION {variant.TaaMotion}"); lines.Add($"#define TAAMOTIONLOCATION {variant.TaaMotionLocation}"); + lines.Add($"#define OPTIMUMAO {variant.OptimumAo}"); } string prefix = string.Join("\r\n", lines) + "\r\n"; diff --git a/Optimum.Render.Vulkan/AmbientOcclusion/GtaoProjection.cs b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoProjection.cs new file mode 100644 index 00000000..3d8e7d9e --- /dev/null +++ b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoProjection.cs @@ -0,0 +1,46 @@ +using System; + +namespace Optimum.Render.Vulkan.AmbientOcclusion; + +/// +/// The reconstruction constants of the AO passes from a GL projection +/// (docs/research/xegtao-integration.md, integration plan step 1): view depth from the +/// [0, 1] depth buffer and view XY from the texel's UV, in the working frame x right, +/// y up, z forward (GL view space mirrored in z). The texel rows run bottom-up (GL order; +/// the device never flips), so unlike XeGTAO's D3D constants the Y terms keep their sign. +/// +/// The jitter columns of the projection (P[2][0], P[2][1]) are folded into the offset, +/// so a jittered G-buffer reconstructs exactly rather than within a sub-pixel. +/// +internal readonly record struct GtaoProjection( + float DepthUnpackMul, float DepthUnpackAdd, + float NdcToViewMulX, float NdcToViewMulY, + float NdcToViewAddX, float NdcToViewAddY) +{ + /// + /// From a column-major GL perspective matrix (m[10] = A, m[14] = B, + /// m[11] = -1); null for anything that is not a perspective projection. + /// + public static GtaoProjection? From(float[]? m) + { + if (m == null || m.Length < 16) return null; + if (MathF.Abs(m[11] + 1f) > 1e-4f || MathF.Abs(m[15]) > 1e-4f) return null; + if (m[0] == 0f || m[5] == 0f || m[14] == 0f) return null; + + float a = m[10]; + float b = m[14]; + float tanX = 1f / m[0]; + float tanY = 1f / m[5]; + return new GtaoProjection( + -b / 2f, (1f - a) / 2f, + 2f * tanX, 2f * tanY, + (m[8] - 1f) * tanX, (m[9] - 1f) * tanY); + } + + /// View depth (positive, forward) of a [0, 1] depth value; the shader's gtaoViewDepth. + public float ViewDepth(float screenDepth) => DepthUnpackMul / (DepthUnpackAdd - screenDepth); + + /// The working-frame position of a texel UV (row 0 at the bottom) at a view depth; gtaoViewPosition. + public (float X, float Y, float Z) ViewPosition(float u, float v, float viewDepth) => + ((NdcToViewMulX * u + NdcToViewAddX) * viewDepth, (NdcToViewMulY * v + NdcToViewAddY) * viewDepth, viewDepth); +} diff --git a/Optimum.Render.Vulkan/AmbientOcclusion/GtaoRenderer.cs b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoRenderer.cs new file mode 100644 index 00000000..49c64b6b --- /dev/null +++ b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoRenderer.cs @@ -0,0 +1,238 @@ +using System; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.AmbientOcclusion; + +/// +/// The AO pipeline on the device (docs/research/ambient-occlusion.md section C): prefilter +/// (Primary depth to five R32F levels), main (visibility term and packed edges) and the +/// edge-aware denoise, as frame-graph compute passes. Owns its targets, sized to the depth +/// it is given, and its programs, compiled for the storage formats the device chose. +/// +/// The output is the visibility at render resolution, unscaled from the 1.5 packing, with +/// sky and hand-view pixels exactly 1; the compose pass (scene-ssao, OPTIMUMAO) multiplies +/// it into Primary colour 0 before the TAA resolve and applies the water, fog and OIT +/// attenuation there, so this term stays a pure visibility for measurement. +/// +internal sealed class GtaoRenderer : IDisposable +{ + public const int DepthLevels = 5; + + /// The smallest depth the five-level chain covers. + public const int MinimumExtent = 16; + + private readonly VulkanDevice _device; + private int _hilbert; + private int _workingDepth; + private int _workingTerm; + private int _edges; + private int _output; + private int _scratchA; + private int _scratchB; + private uint _width; + private uint _height; + private int _prefilter; + private int _main; + private int _denoise; + private Format _programDepthFormat; + private Format _programTermFormat; + + public GtaoRenderer(VulkanDevice device) => _device = device; + + /// Why the last returned 0. + public string? LastError { get; private set; } + + /// True once the programs failed to compile: a device condition, not a frame's. + public bool ProgramsFailed { get; private set; } + + /// Working depth, five levels of view-space depth (the debug output "mip 0" is level 0). + public int WorkingDepthTexture => _workingDepth; + + /// The pre-denoise term, visibility / 1.5. + public int WorkingTermTexture => _workingTerm; + + /// The packed 2-bit edges. + public int EdgesTexture => _edges; + + /// The denoised visibility. + public int OutputTexture => _output; + + public int HilbertTexture => _hilbert; + + /// + /// Records the three passes into the open frame and returns , + /// or 0 with set when nothing could be recorded. + /// + /// Primary's D32 depth. + /// Primary colour 2 (gNormal): GL view-space normal, class in w. + /// The column-major GL projection the G-buffer was drawn with. + /// Preset, variants and uniforms. + /// The frame index while TAA accumulates, 0 otherwise. + public int Render(int depthTexture, int normalTexture, float[] projection, GtaoSettings settings, uint noiseIndex) + { + LastError = null; + GtaoProjection? reconstruction = GtaoProjection.From(projection); + if (reconstruction == null) return Fail("the projection is not a GL perspective matrix"); + + VulkanTexture? depth = _device.TextureOf(depthTexture); + VulkanTexture? normal = _device.TextureOf(normalTexture); + if (depth == null || normal == null) return Fail("depth or normal texture missing"); + if (depth.Width != normal.Width || depth.Height != normal.Height) return Fail("depth and normal sizes differ"); + if (depth.Width < MinimumExtent || depth.Height < MinimumExtent) return Fail("the target is smaller than 16x16"); + + if (!EnsureTargets(depth.Width, depth.Height)) return Fail("storage targets could not be created"); + if (!EnsurePrograms()) return 0; + + byte[] push = settings.PushConstants(reconstruction.Value, noiseIndex); + + uint blocksX = (depth.Width + 15) / 16; + uint blocksY = (depth.Height + 15) / 16; + var prefilterBindings = new ComputeBinding[1 + DepthLevels]; + prefilterBindings[0] = new ComputeBinding(0, depthTexture, ComputeAccess.Sampled); + for (uint level = 0; level < DepthLevels; level++) + { + prefilterBindings[1 + level] = new ComputeBinding(1 + level, _workingDepth, ComputeAccess.StorageWrite, BaseMip: level); + } + if (!_device.RecordComputePass(new ComputePassDeclaration + { + Name = "gtao-prefilter", + ProgramId = _prefilter, + Bindings = prefilterBindings, + Dispatches = new[] { ComputeDispatch.Explicit((blocksX + 7) / 8, (blocksY + 7) / 8, 1, push) }, + })) return Fail(_device.GetError()); + + if (!_device.RecordComputePass(new ComputePassDeclaration + { + Name = "gtao-main", + ProgramId = _main, + Specialization = settings.MainSpecialization(), + Bindings = new[] + { + new ComputeBinding(0, _workingDepth, ComputeAccess.Sampled, 0, DepthLevels), + new ComputeBinding(1, depthTexture, ComputeAccess.Sampled), + new ComputeBinding(2, normalTexture, ComputeAccess.Sampled), + new ComputeBinding(3, _hilbert, ComputeAccess.Sampled), + new ComputeBinding(4, _workingTerm, ComputeAccess.StorageWrite), + new ComputeBinding(5, _edges, ComputeAccess.StorageWrite), + }, + Dispatches = new[] { ComputeDispatch.Covering(4, push) }, + })) return Fail(_device.GetError()); + + uint passes = Math.Clamp(settings.DenoisePasses, 1, 3); + int source = _workingTerm; + for (uint pass = 0; pass < passes; pass++) + { + bool final = pass + 1 == passes; + int destination = final ? _output : pass % 2 == 0 ? _scratchA : _scratchB; + if (!_device.RecordComputePass(new ComputePassDeclaration + { + Name = final ? "gtao-denoise" : "gtao-denoise-pre", + ProgramId = _denoise, + Specialization = GtaoSettings.DenoiseSpecialization(final), + Bindings = new[] + { + new ComputeBinding(0, source, ComputeAccess.Sampled), + new ComputeBinding(1, _edges, ComputeAccess.Sampled), + new ComputeBinding(2, depthTexture, ComputeAccess.Sampled), + new ComputeBinding(3, normalTexture, ComputeAccess.Sampled), + new ComputeBinding(4, destination, ComputeAccess.StorageWrite), + }, + Dispatches = new[] { ComputeDispatch.Covering(4, push) }, + })) return Fail(_device.GetError()); + source = destination; + } + return _output; + } + + private int Fail(string reason) + { + LastError = string.IsNullOrEmpty(reason) ? "compute pass refused" : reason; + return 0; + } + + private unsafe bool EnsureTargets(uint width, uint height) + { + if (_hilbert == 0) + { + float[] table = HilbertLut.Build(); + fixed (float* data = table) + { + _hilbert = _device.CreateTexture2DRaw(HilbertLut.Width, HilbertLut.Width, 0x822E, (IntPtr)data, 4); + } + } + if (_output != 0 && _width == width && _height == height) return true; + + ReleaseTargets(); + _workingDepth = _device.CreateStorageTexture((int)width, (int)height, Format.R32Sfloat, DepthLevels); + _workingTerm = _device.CreateStorageTexture((int)width, (int)height, Format.R8Unorm); + _edges = _device.CreateStorageTexture((int)width, (int)height, Format.R8Unorm); + _scratchA = _device.CreateStorageTexture((int)width, (int)height, Format.R8Unorm); + _scratchB = _device.CreateStorageTexture((int)width, (int)height, Format.R8Unorm); + _output = _device.CreateStorageTexture((int)width, (int)height, Format.R8Unorm); + _width = width; + _height = height; + return _hilbert != 0 && _device.TextureOf(_workingDepth)?.MipLevels == DepthLevels; + } + + private bool EnsurePrograms() + { + Format depthFormat = _device.TextureOf(_workingDepth)!.Format; + Format termFormat = _device.TextureOf(_workingTerm)!.Format; + if (_prefilter != 0 && depthFormat == _programDepthFormat && termFormat == _programTermFormat) return true; + ReleasePrograms(); + + _prefilter = _device.CreateComputeProgram(GtaoShaderSources.Build("prefilter.comp", depthFormat, termFormat), + "gtao-prefilter", Slots(ComputeSlotKind.Sampled, 1, ComputeSlotKind.Storage, DepthLevels), + GtaoSettings.PushConstantBytes); + _main = _device.CreateComputeProgram(GtaoShaderSources.Build("main.comp", depthFormat, termFormat), + "gtao-main", Slots(ComputeSlotKind.Sampled, 4, ComputeSlotKind.Storage, 2), GtaoSettings.PushConstantBytes); + _denoise = _device.CreateComputeProgram(GtaoShaderSources.Build("denoise.comp", depthFormat, termFormat), + "gtao-denoise", Slots(ComputeSlotKind.Sampled, 4, ComputeSlotKind.Storage, 1), GtaoSettings.PushConstantBytes); + _programDepthFormat = depthFormat; + _programTermFormat = termFormat; + if (_prefilter != 0 && _main != 0 && _denoise != 0) return true; + + LastError = "AO compute shaders failed to compile: " + _device.GetError(); + ProgramsFailed = true; + ReleasePrograms(); + return false; + } + + /// Sampled bindings first, then storage bindings, numbered from 0. + private static ComputeSlot[] Slots(ComputeSlotKind first, int firstCount, ComputeSlotKind second, int secondCount) + { + var slots = new ComputeSlot[firstCount + secondCount]; + for (int i = 0; i < slots.Length; i++) slots[i] = new ComputeSlot((uint)i, i < firstCount ? first : second); + return slots; + } + + /// Frees the size-dependent targets (a framebuffer rebuild); the next render recreates them. + public void ReleaseTargets() + { + foreach (int texture in new[] { _workingDepth, _workingTerm, _edges, _scratchA, _scratchB, _output }) + { + if (texture != 0) _device.DeleteTexture(texture); + } + _workingDepth = _workingTerm = _edges = _scratchA = _scratchB = _output = 0; + _width = _height = 0; + } + + private void ReleasePrograms() + { + if (_prefilter != 0) _device.DeleteComputeProgram(_prefilter); + if (_main != 0) _device.DeleteComputeProgram(_main); + if (_denoise != 0) _device.DeleteComputeProgram(_denoise); + _prefilter = _main = _denoise = 0; + } + + public void Dispose() + { + ReleaseTargets(); + ReleasePrograms(); + if (_hilbert != 0) _device.DeleteTexture(_hilbert); + _hilbert = 0; + } +} diff --git a/Optimum.Render.Vulkan/AmbientOcclusion/GtaoSettings.cs b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoSettings.cs new file mode 100644 index 00000000..3ec347b1 --- /dev/null +++ b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoSettings.cs @@ -0,0 +1,260 @@ +using System; +using System.Globalization; + +namespace Optimum.Render.Vulkan.AmbientOcclusion; + +/// The per-slice integration (docs/research/ambient-occlusion.md C.3, C.13). +internal enum GtaoIntegration : uint +{ + /// 32-sector bitmask with cosine-CDF sector boundaries (C.4); the default. + BitmaskCosine = 0, + /// 32-sector bitmask with sectors uniform in angle (Therrien's original). + BitmaskUniform = 1, + /// XeGTAO's analytic horizon integral, unchanged. + Horizon = 2, +} + +/// The thickness model of the bitmask (C.5, C.13). WIDTH is not implemented. +internal enum GtaoThickness : uint +{ + Constant = 0, + /// Grows linearly with view distance. + Distance = 1, + /// Distance-scaled and randomised per sample; the default. + Random = 2, +} + +/// The quality presets of C.2 / C.12: slices x steps per side. +internal enum GtaoPreset +{ + /// 1x2 (4 fetches). + Low, + /// 2x2 (8 fetches); handheld candidate A at render resolution. + Medium, + /// 3x3 (18 fetches); the discrete preset. + High, + /// 9x3 (54 fetches), two denoise passes; screenshots. + Ultra, +} + +/// The compose tone (C.11): the albedo hook for a later multi-bounce term. +internal enum GtaoTone +{ + Linear, + /// GTAO 2016 eq. 10; refused unless an albedo texture is bound. + MultiBounce, +} + +/// Specialization constant ids; sources/shaders-vk/gtao/common.glsl is the source of truth. +internal static class GtaoSpecialization +{ + public const int Integration = 0; + public const int SliceCount = 1; + public const int StepsPerSlice = 2; + public const int Thickness = 3; + public const int ClassChannel = 4; + public const int NoiseCycle = 5; + public const int NormalEdges = 6; + public const int FinalApply = 7; + + /// One more than the highest id: the length of a specialization array. + public const int Count = 8; +} + +/// +/// Everything the AO passes are parameterised by: the preset's counts, the C.13 variants +/// (specialization constants) and the uniforms (push constants). Pure, so the packing +/// and the environment overrides are testable without a device. +/// +internal sealed record GtaoSettings +{ + public const int PushConstantBytes = 80; + + public uint SliceCount { get; init; } = 2; + public uint StepsPerSlice { get; init; } = 2; + public uint DenoisePasses { get; init; } = 1; + public GtaoIntegration Integration { get; init; } = GtaoIntegration.BitmaskCosine; + public GtaoThickness Thickness { get; init; } = GtaoThickness.Random; + public bool ClassChannel { get; init; } = true; + /// 64 (XeGTAO) or 61, coprime with the 8 and 32 jitter phases (C.7). + public uint NoiseCycle { get; init; } = 64; + public bool NormalEdges { get; init; } + public GtaoTone Tone { get; init; } = GtaoTone.Linear; + + /// Effect radius in blocks (C.2: start 0.75, tuned in D). + public float EffectRadius { get; init; } = 0.75f; + public float RadiusMultiplier { get; init; } = 1.457f; + public float FalloffRange { get; init; } = 0.615f; + /// Measurement only (C.11): 1.0 is the physically meaningful value. + public float FinalValuePower { get; init; } = 1.0f; + public float SampleDistributionPower { get; init; } = 2.0f; + public float DepthMipSamplingOffset { get; init; } = 3.30f; + /// Solid surfaces, blocks (C.5: a fence post is 0.125-0.25, a block 1). + public float ThicknessSolid { get; init; } = 0.5f; + /// The thin class, blocks (C.5). + public float ThicknessThin { get; init; } = 0.05f; + /// Thickness growth per block of view distance; a starting value for D. + public float ThicknessDistanceScale { get; init; } = 1f / 64f; + /// Vanilla ssao.fsh's fade: clamp(1.2 - z / 250, 0, 1). + public float FarFadeBias { get; init; } = 1.2f; + public float FarFadeDistance { get; init; } = 250f; + public float DenoiseBlurBeta { get; init; } = 1.2f; + + /// + /// The preset's counts. With a temporal accumulator one denoise pass (XeGTAO v1.21, + /// Bevy); without it two (C.7/C.8); Ultra always two (the screenshot row of C.12). + /// + public static GtaoSettings ForPreset(GtaoPreset preset, bool temporal) + { + (uint slices, uint steps) = preset switch + { + GtaoPreset.Low => (1u, 2u), + GtaoPreset.Medium => (2u, 2u), + GtaoPreset.High => (3u, 3u), + _ => (9u, 3u), + }; + uint passes = preset == GtaoPreset.Ultra || !temporal ? 2u : 1u; + return new GtaoSettings { SliceCount = slices, StepsPerSlice = steps, DenoisePasses = passes }; + } + + /// The persisted preset name ("low", "medium", "high", "ultra"); anything else is Medium. + public static GtaoPreset ParsePreset(string? name) => (name ?? "").Trim().ToLowerInvariant() switch + { + "low" => GtaoPreset.Low, + "high" => GtaoPreset.High, + "ultra" => GtaoPreset.Ultra, + _ => GtaoPreset.Medium, + }; + + /// + /// The C.13 measurement variants from the environment (never persisted): + /// OPTIMUM_AO_INTEGRATION (bitmask-cos | bitmask-uniform | horizon), + /// OPTIMUM_AO_THICKNESS (const | dist | random), OPTIMUM_AO_CLASS_CHANNEL (0 | 1), + /// OPTIMUM_AO_NOISE_CYCLE (64 | 61), OPTIMUM_AO_DENOISE_PASSES (1 | 2 | 3), + /// OPTIMUM_AO_NORMAL_EDGES (0 | 1), OPTIMUM_AO_TONE (linear | multibounce), + /// OPTIMUM_AO_FINAL_POWER and OPTIMUM_AO_RADIUS. Unrecognised values keep the setting. + /// + public GtaoSettings WithEnvironment(Func variable) + { + GtaoSettings result = this; + switch (variable("OPTIMUM_AO_INTEGRATION")?.Trim().ToLowerInvariant()) + { + case "bitmask-cos": result = result with { Integration = GtaoIntegration.BitmaskCosine }; break; + case "bitmask-uniform": result = result with { Integration = GtaoIntegration.BitmaskUniform }; break; + case "horizon": result = result with { Integration = GtaoIntegration.Horizon }; break; + } + switch (variable("OPTIMUM_AO_THICKNESS")?.Trim().ToLowerInvariant()) + { + case "const": result = result with { Thickness = GtaoThickness.Constant }; break; + case "dist": result = result with { Thickness = GtaoThickness.Distance }; break; + case "random": result = result with { Thickness = GtaoThickness.Random }; break; + } + switch (variable("OPTIMUM_AO_CLASS_CHANNEL")?.Trim()) + { + case "0": result = result with { ClassChannel = false }; break; + case "1": result = result with { ClassChannel = true }; break; + } + switch (variable("OPTIMUM_AO_NOISE_CYCLE")?.Trim()) + { + case "64": result = result with { NoiseCycle = 64 }; break; + case "61": result = result with { NoiseCycle = 61 }; break; + } + switch (variable("OPTIMUM_AO_DENOISE_PASSES")?.Trim()) + { + case "1": result = result with { DenoisePasses = 1 }; break; + case "2": result = result with { DenoisePasses = 2 }; break; + case "3": result = result with { DenoisePasses = 3 }; break; + } + switch (variable("OPTIMUM_AO_NORMAL_EDGES")?.Trim()) + { + case "0": result = result with { NormalEdges = false }; break; + case "1": result = result with { NormalEdges = true }; break; + } + switch (variable("OPTIMUM_AO_TONE")?.Trim().ToLowerInvariant()) + { + case "linear": result = result with { Tone = GtaoTone.Linear }; break; + case "multibounce": result = result with { Tone = GtaoTone.MultiBounce }; break; + } + if (TryParsePositive(variable("OPTIMUM_AO_FINAL_POWER"), out float power)) result = result with { FinalValuePower = power }; + if (TryParsePositive(variable("OPTIMUM_AO_RADIUS"), out float radius)) result = result with { EffectRadius = radius }; + return result; + } + + private static bool TryParsePositive(string? text, out float value) => + float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && value > 0f && float.IsFinite(value); + + /// + /// The tone the compose pass may use: needs a real + /// albedo (the scene colour here is lit LDR radiance, C.11), so without an albedo + /// texture it is refused and says why. + /// + public GtaoTone EffectiveTone(int albedoTexture, out string? refusal) + { + refusal = null; + if (Tone != GtaoTone.MultiBounce || albedoTexture != 0) return Tone; + refusal = "multi-bounce AO needs an albedo texture; the scene colour is lit radiance, so the linear tone is used"; + return GtaoTone.Linear; + } + + /// The main pass's specialization values, indexed by id. + public uint[] MainSpecialization() + { + var values = new uint[GtaoSpecialization.Count]; + values[GtaoSpecialization.Integration] = (uint)Integration; + values[GtaoSpecialization.SliceCount] = Math.Max(1, SliceCount); + values[GtaoSpecialization.StepsPerSlice] = Math.Max(1, StepsPerSlice); + values[GtaoSpecialization.Thickness] = (uint)Thickness; + values[GtaoSpecialization.ClassChannel] = ClassChannel ? 1u : 0u; + values[GtaoSpecialization.NoiseCycle] = Math.Max(1, NoiseCycle); + values[GtaoSpecialization.NormalEdges] = NormalEdges ? 1u : 0u; + values[GtaoSpecialization.FinalApply] = 1u; + return values; + } + + /// A denoise pass's specialization values: only FINAL_APPLY varies. + public static uint[] DenoiseSpecialization(bool finalApply) + { + var values = new uint[GtaoSpecialization.Count]; + values[GtaoSpecialization.FinalApply] = finalApply ? 1u : 0u; + return values; + } + + /// The 80-byte push block of common.glsl, in declaration order. + public byte[] PushConstants(GtaoProjection projection, uint noiseIndex) + { + var bytes = new byte[PushConstantBytes]; + int offset = 0; + void Float(float value) + { + BitConverter.TryWriteBytes(bytes.AsSpan(offset, 4), value); + offset += 4; + } + void UInt(uint value) + { + BitConverter.TryWriteBytes(bytes.AsSpan(offset, 4), value); + offset += 4; + } + + Float(projection.DepthUnpackMul); + Float(projection.DepthUnpackAdd); + Float(projection.NdcToViewMulX); + Float(projection.NdcToViewMulY); + Float(projection.NdcToViewAddX); + Float(projection.NdcToViewAddY); + Float(EffectRadius); + Float(FalloffRange); + Float(RadiusMultiplier); + Float(FinalValuePower); + Float(SampleDistributionPower); + Float(DepthMipSamplingOffset); + Float(ThicknessSolid); + Float(ThicknessThin); + Float(ThicknessDistanceScale); + Float(FarFadeBias); + Float(1f / FarFadeDistance); + UInt(noiseIndex); + Float(DenoiseBlurBeta); + UInt(0); + return bytes; + } +} diff --git a/Optimum.Render.Vulkan/AmbientOcclusion/GtaoShaderSources.cs b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoShaderSources.cs new file mode 100644 index 00000000..95aa4087 --- /dev/null +++ b/Optimum.Render.Vulkan/AmbientOcclusion/GtaoShaderSources.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.AmbientOcclusion; + +/// +/// The AO compute shaders, sources/shaders-vk/gtao/*.comp, embedded in the renderer +/// assembly (so deploy and the packagers carry them with the DLL). Expands their +/// #include "x.glsl" lines from the same directory and defines the storage format +/// qualifiers of the formats the device chose right after #version. +/// +internal static class GtaoShaderSources +{ + public const string ResourcePrefix = "shaders-vk/gtao/"; + + private static readonly ConcurrentDictionary Raw = new(StringComparer.Ordinal); + private static readonly Regex IncludeLine = new("^[ \\t]*#include[ \\t]+\"([^\"]+)\"[ \\t]*$", + RegexOptions.Multiline | RegexOptions.CultureInvariant); + + /// A resource of the gtao directory, as committed. + public static string Read(string fileName) => Raw.GetOrAdd(fileName, name => + { + Assembly assembly = typeof(GtaoShaderSources).Assembly; + using Stream? stream = assembly.GetManifestResourceStream(ResourcePrefix + name); + if (stream == null) throw new FileNotFoundException("embedded AO shader missing: " + ResourcePrefix + name); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + }); + + /// + /// The compilable source of : includes expanded (each file + /// once) and GTAO_DEPTH_FORMAT / GTAO_TERM_FORMAT defined. + /// + public static string Build(string fileName, Format depthFormat, Format termFormat) + { + string source = Expand(Read(fileName), new System.Collections.Generic.HashSet(StringComparer.Ordinal)); + int versionEnd = source.IndexOf('\n', source.IndexOf("#version", StringComparison.Ordinal)) + 1; + string defines = "#define GTAO_DEPTH_FORMAT " + Qualifier(depthFormat) + "\n" + + "#define GTAO_TERM_FORMAT " + Qualifier(termFormat) + "\n"; + return source.Insert(versionEnd, defines); + } + + private static string Expand(string source, System.Collections.Generic.HashSet included) => + IncludeLine.Replace(source, match => + { + string name = match.Groups[1].Value; + return included.Add(name) ? Expand(Read(name), included) : ""; + }); + + /// The storage image format qualifier of a format a storage texture can have. + public static string Qualifier(Format format) => format switch + { + Format.R8Unorm => "r8", + Format.R8G8Unorm => "rg8", + Format.R8G8B8A8Unorm => "rgba8", + Format.R16Sfloat => "r16f", + Format.R32Sfloat => "r32f", + Format.R16G16B16A16Sfloat => "rgba16f", + Format.R32G32B32A32Sfloat => "rgba32f", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "no storage qualifier for this format"), + }; +} diff --git a/Optimum.Render.Vulkan/AmbientOcclusion/HilbertLut.cs b/Optimum.Render.Vulkan/AmbientOcclusion/HilbertLut.cs new file mode 100644 index 00000000..15ea1700 --- /dev/null +++ b/Optimum.Render.Vulkan/AmbientOcclusion/HilbertLut.cs @@ -0,0 +1,44 @@ +namespace Optimum.Render.Vulkan.AmbientOcclusion; + +/// +/// The 64x64 Hilbert index table the AO noise starts from (docs/research/ambient-occlusion.md +/// C.7): generated at startup, never shipped. Neighbouring texels get neighbouring indices, +/// so the R2 sequence the index drives is low-discrepancy across the 3x3 denoise +/// footprint. XeGTAO's HilbertIndex (vaGTAO.hlsl, MIT, Intel), level 6. +/// +internal static class HilbertLut +{ + public const int Width = 64; + + /// The curve index of texel (, ), 0..4095. + public static uint Index(uint x, uint y) + { + uint index = 0; + for (uint level = Width / 2; level > 0; level /= 2) + { + uint regionX = (x & level) > 0 ? 1u : 0u; + uint regionY = (y & level) > 0 ? 1u : 0u; + index += level * level * ((3u * regionX) ^ regionY); + if (regionY == 0) + { + if (regionX == 1) + { + x = Width - 1 - x; + y = Width - 1 - y; + } + (x, y) = (y, x); + } + } + return index; + } + + /// The table in row order as floats, for an R32F texture: every index is exact in a float. + public static float[] Build() + { + var table = new float[Width * Width]; + for (uint y = 0; y < Width; y++) + for (uint x = 0; x < Width; x++) + table[y * Width + x] = Index(x, y); + return table; + } +} diff --git a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj index fecb39c8..703918f8 100644 --- a/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -90,4 +90,13 @@ + + + + shaders-vk/gtao/%(Filename)%(Extension) + + + diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs new file mode 100644 index 00000000..a471cb7a --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs @@ -0,0 +1,116 @@ +using System; +using Optimum.Render.Vulkan.AmbientOcclusion; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Optimum AO (docs/research/ambient-occlusion.md section C): the GTAO visibility-bitmask pass +// on the device. The base's RenderPostprocessingEffects asks for it where vanilla SSAO would run +// and composes the returned texture through ApplyOptimumSceneSsao before the TAA resolve. +public partial class VulkanClientPlatform +{ + private GtaoRenderer? ambientOcclusion; + + /// This frame's output, or 0 when GTAO did not run (the debug outputs and the compose pass's reads key off it). + private int ambientOcclusionOutput; + + /// The output texture whose sampler state was last set up. + private int ambientOcclusionSampledTexture; + + /// Set when the passes cannot run on this device; vanilla SSAO then runs for the session. + private string? ambientOcclusionFailure; + + private bool ambientOcclusionToneRefusalLogged; + + private (string Preset, bool Temporal, GtaoSettings Settings)? ambientOcclusionSettingsCache; + + /// + /// Runs GTAO when the live shaders were built for it (OPTIMUMAO, stamped from + /// and the SSAO G-buffer's condition) and returns + /// the denoised visibility; 0 hands the frame to vanilla SSAO. + /// + public override int RenderOptimumAmbientOcclusion(float[] projectMatrix) + { + ambientOcclusionOutput = 0; + if (device == null || projectMatrix == null || ambientOcclusionFailure != null) return 0; + if (!OptimumConfig.AmbientOcclusionShadersUseGtao) return 0; + FrameBufferRef? primary = FrameBuffers is { Count: > 0 } buffers ? buffers[0] : null; + if (primary?.ColorTextureIds == null || primary.ColorTextureIds.Length < 4 || primary.DepthTextureId == 0) return 0; + + // The noise advances with the temporal clock only while TAA accumulates (C.7). + bool temporal = OptimumConfig.EffectiveTaa && TaaTargetsReady; + GtaoSettings settings = AmbientOcclusionSettings(temporal); + if (!ambientOcclusionToneRefusalLogged && settings.EffectiveTone(0, out string? refusal) != settings.Tone) + { + ambientOcclusionToneRefusalLogged = true; + Logger.Warning("[Optimum] AO: " + refusal); + } + uint noiseIndex = temporal ? (uint)(OptimumTemporal.Frame.FrameIndex & 0xFFFFFFFFL) : 0u; + + ambientOcclusion ??= new GtaoRenderer(device); + int output = ambientOcclusion.Render(primary.DepthTextureId, primary.ColorTextureIds[2], projectMatrix, settings, noiseIndex); + if (output == 0) + { + if (ambientOcclusion.ProgramsFailed) + { + ambientOcclusionFailure = ambientOcclusion.LastError ?? "unknown"; + Logger.Error("[Optimum] AO: GTAO is unavailable on this device, vanilla SSAO runs instead: " + ambientOcclusionFailure); + } + return 0; + } + if (output != ambientOcclusionSampledTexture) + { + // Composed with texelFetch at the same resolution; nearest and clamp keep any sampling exact. + SetupOptimumTextureSampler(output, 9728, 33071); + ambientOcclusionSampledTexture = output; + } + ambientOcclusionOutput = output; + return output; + } + + /// The debug outputs of this frame in the base's index order (working term, edges, depth level 0, output). + public override int OptimumAmbientOcclusionDebugTexture(int index) + { + if (ambientOcclusionOutput == 0 || ambientOcclusion == null) return 0; + return index switch + { + 0 => ambientOcclusion.WorkingTermTexture, + 1 => ambientOcclusion.EdgesTexture, + 2 => ambientOcclusion.WorkingDepthTexture, + 3 => ambientOcclusion.OutputTexture, + _ => 0, + }; + } + + /// The preset with the measurement overrides from the environment, rebuilt only when the preset or TAA changes. + private GtaoSettings AmbientOcclusionSettings(bool temporal) + { + string preset = OptimumConfig.AmbientOcclusionPreset ?? ""; + if (ambientOcclusionSettingsCache is { } cached && cached.Preset == preset && cached.Temporal == temporal) + { + return cached.Settings; + } + GtaoSettings settings = GtaoSettings.ForPreset(GtaoSettings.ParsePreset(preset), temporal) + .WithEnvironment(Environment.GetEnvironmentVariable); + ambientOcclusionSettingsCache = (preset, temporal, settings); + return settings; + } + + /// The size-dependent targets go with the framebuffers; the next frame recreates them. + private void ReleaseAmbientOcclusionTargets() + { + ambientOcclusion?.ReleaseTargets(); + ambientOcclusionOutput = 0; + ambientOcclusionSampledTexture = 0; + } + + private void ReleaseAmbientOcclusion() + { + ambientOcclusion?.Dispose(); + ambientOcclusion = null; + ambientOcclusionOutput = 0; + ambientOcclusionSampledTexture = 0; + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index 7ec7c6f3..f78df8be 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -491,6 +491,8 @@ public override void DisposeFrameBuffer(FrameBufferRef frameBuffer, bool dispose /// public override void DisposeFrameBuffers(List buffers) { + // The AO targets are sized to Primary and go with it. + ReleaseAmbientOcclusionTargets(); HashSet deletedTextures = new HashSet(); for (int k = 0; k < buffers.Count; k++) { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index 65c0aee1..ad940e9c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -107,7 +107,20 @@ private void DeclareFinalCompositionPass() // The AO multiply before the TAA resolve shares the colour-0 mask, but // samples only the blurred AO and preserves every other Primary attachment. var reads = new List(); - AddColour(reads, SsaoBlurVerticalIndex, 0); + if (ambientOcclusionOutput != 0) + { + // GTAO: the visibility texture, and the attenuation inputs the OPTIMUMAO compose reads. + reads.Add(ambientOcclusionOutput); + } + else + { + AddColour(reads, SsaoBlurVerticalIndex, 0); + } + if (Vintagestory.API.Config.OptimumConfig.AmbientOcclusionShadersUseGtao) + { + AddColour(reads, PrimaryIndex, 3); + AddColour(reads, TransparentIndex, 1); + } device.DeclarePass(new PassDeclaration { Name = "SceneSsao/0", diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index f3d8ae2a..338dee4f 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -103,6 +103,9 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "ProbeThickLineSupport", Array.Empty()), new(true, "OnWindowSizeChanged", new[] { "Int32", "Int32" }), new(true, "ReadTextureForParity", new[] { "Int32" }), + // Optimum AO: the platform's own ambient occlusion and its debug outputs. + new(true, "RenderOptimumAmbientOcclusion", new[] { "Single[]" }), + new(true, "OptimumAmbientOcclusionDebugTexture", new[] { "Int32" }), // Phase 1A step 5: the leaf operations the render systems outside the platform issued. new(true, "SetDepthRange", new[] { "Single", "Single" }), new(true, "ClearDefaultDepth", new[] { "Single" }), @@ -322,6 +325,14 @@ public override void ShutdownGraphics() // The bridge goes first: nothing may reach a device that is being torn down. OptimumForkGraphics.Active = null; try + { + ReleaseAmbientOcclusion(); + } + catch (Exception) + { + // Released with the device below either way. + } + try { device?.Dispose(); } diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 785a0023..d57f887c 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -951,6 +951,9 @@ internal void DeleteComputeProgram(int programId) internal int CreateStorageTexture(int width, int height, Format format, int mipLevels = 1) => _textures.CreateStorage((uint)Math.Max(1, width), (uint)Math.Max(1, height), format, (uint)Math.Max(1, mipLevels)); + /// A live texture's description (size, levels, chosen format, usage) for compute pass owners; null when it does not exist. + internal VulkanTexture? TextureOf(int textureId) => _textures.Get(textureId); + private Graph.ComputeImageInfo? ComputeImageInfoOf(int textureId) => _textures.Get(textureId) is { } texture ? new Graph.ComputeImageInfo(texture.Width, texture.Height, texture.MipLevels, texture.Cube ? 6u : texture.Layers) diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs new file mode 100644 index 00000000..ee800584 --- /dev/null +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Vintagestory.API.Config; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Source coverage for the Vulkan ambient occlusion (docs/research/ambient-occlusion.md section C +/// and the section E decisions): the three-way setting and its backend rule, the post-chain seam +/// that lets the platform replace vanilla SSAO and composes before the TAA resolve, the shader +/// prefix and the class-channel writes that compile in only under it, the opt-in debug outputs, +/// the patcher listings and the licence notices of the ported compute shaders. +/// +/// Text assertions prove the wiring exists; Optimum.Render.Vulkan.Tests/AmbientOcclusionTests +/// and SceneSsaoTests prove the numbers. +/// +public class AmbientOcclusionCoverageTests +{ + // ------------------------------------------------------------------ the setting + + [Fact] + public void TheSettingDefaultsToAutoMediumAndPersistsThroughOptimumJson() + { + string config = Read("VintagestoryApi/Config/OptimumConfig.cs"); + Assert.Contains("public static string AmbientOcclusion = \"auto\";", config); + Assert.Contains("public static string AmbientOcclusionPreset = \"medium\";", config); + Assert.Contains("public string AmbientOcclusion { get; set; } = \"auto\";", config); + Assert.Contains("public string AmbientOcclusionPreset { get; set; } = \"medium\";", config); + Assert.Contains("AmbientOcclusion = AmbientOcclusion,", config); + Assert.Contains("AmbientOcclusionPreset = AmbientOcclusionPreset,", config); + Assert.Contains("(nameof(OptimumConfigData.AmbientOcclusion), AmbientOcclusion)", config); + Assert.Contains("(nameof(OptimumConfigData.AmbientOcclusionPreset), AmbientOcclusionPreset)", config); + // An unrecognised value degrades to the default instead of failing the file. + Assert.Contains("AmbientOcclusion = requestedAo is \"vanilla\" or \"gtao\" ? requestedAo : \"auto\";", config); + Assert.Contains("AmbientOcclusionPreset = requestedAoPreset is \"low\" or \"high\" or \"ultra\" ? requestedAoPreset : \"medium\";", config); + } + + [Fact] + public void AutoIsGtaoOnVulkanWithTaaVanillaOtherwiseAndOpenGlIgnoresTheSetting() + { + string saved = OptimumConfig.AmbientOcclusion; + try + { + OptimumConfig.AmbientOcclusion = "auto"; + Assert.True(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: true)); + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: false)); + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: false, taaActive: true)); + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: false, taaActive: false)); + + OptimumConfig.AmbientOcclusion = "vanilla"; + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: true)); + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: false)); + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: false, taaActive: true)); + + OptimumConfig.AmbientOcclusion = "gtao"; + Assert.True(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: true)); + Assert.True(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: false)); + // OpenGL stays vanilla SSAO whatever is asked for. + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: false, taaActive: true)); + Assert.False(OptimumConfig.GtaoSelected(vulkanBackend: false, taaActive: false)); + + OptimumConfig.AmbientOcclusion = "GTAO"; + Assert.True(OptimumConfig.GtaoSelected(vulkanBackend: true, taaActive: false)); + } + finally + { + OptimumConfig.AmbientOcclusion = saved; + } + + // The effective form reads the backend that actually started and the TAA actually in effect. + Assert.Contains("public static bool EffectiveGtao => GtaoSelected(OptimumRender.IsVulkan, EffectiveTaa);", + Read("VintagestoryApi/Config/OptimumConfig.cs")); + } + + // ------------------------------------------------------------------ the post chain + + [Fact] + public void ThePlatformReplacesVanillaSsaoAndComposesBeforeTheResolve() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + string post = Between(platform, "public override void RenderPostprocessingEffects", "public override void ClearSsaoTarget"); + + int reset = post.IndexOf("optimumAmbientOcclusionTexture = 0;", StringComparison.Ordinal); + int ask = post.IndexOf("optimumAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix);", StringComparison.Ordinal); + int vanillaGuard = post.IndexOf("if (optimumAmbientOcclusionTexture == 0 && RenderSSAO && projectMatrix != null)", StringComparison.Ordinal); + int ssao = post.IndexOf("ssao.Use();", StringComparison.Ordinal); + int gtaoGuard = post.IndexOf("if (optimumAmbientOcclusionTexture != 0)", StringComparison.Ordinal); + int compose = post.IndexOf("ApplyOptimumSceneSsao();", gtaoGuard, StringComparison.Ordinal); + int resolve = post.IndexOf("RenderOptimumTaaResolve();", StringComparison.Ordinal); + Assert.True(reset >= 0 && reset < ask, "the texture is cleared before the platform is asked"); + Assert.True(ask < vanillaGuard && vanillaGuard < ssao, "vanilla SSAO runs only when the platform returned nothing"); + Assert.True(ssao < gtaoGuard && gtaoGuard < compose && compose < resolve, + "the platform's AO is composed after the vanilla block and before the resolve"); + Assert.Contains("if (RenderSSAO && projectMatrix != null)\n\t\t{\n\t\t\toptimumAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix);", + post.Replace("\r\n", "\n")); + + // The neutral body is the OpenGL path: 0, vanilla SSAO. ClientPlatformWindows does not override it. + string abstractPlatform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + Assert.Contains("public virtual int RenderOptimumAmbientOcclusion(float[] projectMatrix)\n\t{\n\t\treturn 0;\n\t}", + abstractPlatform.Replace("\r\n", "\n")); + Assert.Contains("public virtual int OptimumAmbientOcclusionDebugTexture(int index)\n\t{\n\t\treturn 0;\n\t}", + abstractPlatform.Replace("\r\n", "\n")); + Assert.DoesNotContain("override int RenderOptimumAmbientOcclusion", platform); + + // The compose binds the platform's texture, and the attenuation inputs only for the OPTIMUMAO shaders. + string apply = Between(platform, "private void ApplyOptimumSceneSsao()", "public override void RenderFinalComposition"); + Assert.Contains("composite.BindTexture2D(\"ssaoScene\", optimumAmbientOcclusionTexture, 0);", apply); + Assert.Contains("composite.BindTexture2D(\"ssaoScene\", frameBuffers[14].ColorTextureIds[0], 0);", apply); + Assert.Contains("if (OptimumConfig.AmbientOcclusionShadersUseGtao)", apply); + Assert.Contains("composite.BindTexture2D(\"gPositionScene\", frameBuffers[0].ColorTextureIds[3], 1);", apply); + Assert.Contains("composite.BindTexture2D(\"revealageScene\", frameBuffers[1].ColorTextureIds[1], 2);", apply); + Assert.Contains("optimumSsaoInScene = true;", apply); + + // Never on glow: the pass keeps colour 0 alone and reads the AO and attenuation inputs. + string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); + string declaration = Between(graph, "private void DeclareFinalCompositionPass()", "private int FrameBufferIndexOf"); + Assert.Contains("ColorSlots = 1u", declaration); + Assert.Contains("reads.Add(ambientOcclusionOutput);", declaration); + Assert.Contains("AddColour(reads, PrimaryIndex, 3);", declaration); + Assert.Contains("AddColour(reads, TransparentIndex, 1);", declaration); + + // The Vulkan platform runs GTAO only for shaders built with it, never a half-res min hack. + string vulkan = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs"); + Assert.Contains("public override int RenderOptimumAmbientOcclusion(float[] projectMatrix)", vulkan); + Assert.Contains("if (!OptimumConfig.AmbientOcclusionShadersUseGtao) return 0;", vulkan); + Assert.Contains("OptimumTemporal.Frame.FrameIndex", vulkan); + Assert.Contains("bool temporal = OptimumConfig.EffectiveTaa && TaaTargetsReady;", vulkan); + } + + [Fact] + public void TheShaderPrefixStampsOptimumAoFromTheBackendAndTheGBuffer() + { + string registry = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + string prefixes = Between(registry, "private static void registerDefaultShaderCodePrefixes", "private static string HandleIncludes"); + Assert.Contains("bool optimumAo = OptimumConfig.EffectiveGtao && ClientSettings.SSAOQuality > 0;", prefixes); + Assert.Contains("OptimumConfig.AmbientOcclusionShadersUseGtao = optimumAo;", prefixes); + Assert.Contains("taaDefines = taaDefines + \"#define OPTIMUMAO \" + (optimumAo ? 1 : 0) + \"\\r\\n\";", prefixes); + Assert.True(prefixes.IndexOf("#define OPTIMUMAO", StringComparison.Ordinal) < + prefixes.IndexOf("taaFrag.PrefixCode = taaFrag.PrefixCode + taaDefines;", StringComparison.Ordinal), + "the define rides on the TAA defines both stages receive"); + } + + // ------------------------------------------------------------------ the class channel + + [Theory] + [InlineData("sources/shaders/chunkopaque.fsh", "outGNormal.w = 1.0;", 2, new[] { "SSAOLEVEL > 0", "OPTIMUMAO > 0" })] + [InlineData("sources/shaders/standard.fsh", "outGNormal.w = -1.0;", 1, new[] { "ALLOWDEPTHOFFSET > 0", "SSAOLEVEL > 0", "OPTIMUMAO > 0" })] + [InlineData("sources/shaders/entityanimated.fsh", "outGNormal.w = -1.0;", 1, new[] { "ALLOWDEPTHOFFSET > 0", "USEOIT==0 && SSAOLEVEL > 0", "OPTIMUMAO > 0" })] + public void ClassChannelWritesCompileInOnlyUnderTheirGuards(string path, string write, int expected, string[] guards) + { + string shader = Read(path); + int found = 0; + var stack = new List(); + foreach (string raw in shader.Replace("\r\n", "\n").Split('\n')) + { + string line = raw.Trim(); + if (line.StartsWith("#if", StringComparison.Ordinal)) stack.Add(line); + else if (line.StartsWith("#endif", StringComparison.Ordinal)) stack.RemoveAt(stack.Count - 1); + else if (line.StartsWith("#else", StringComparison.Ordinal) || line.StartsWith("#elif", StringComparison.Ordinal)) + stack[^1] = "#else of " + stack[^1]; + else if (line.Contains(write, StringComparison.Ordinal)) + { + found++; + foreach (string guard in guards) + { + Assert.True(stack.Any(s => s.StartsWith("#if", StringComparison.Ordinal) && s.Contains(guard, StringComparison.Ordinal)), + path + ": '" + write + "' is not inside #if " + guard + " (open: " + string.Join(" | ", stack) + ")"); + } + } + } + Assert.Equal(expected, found); + // Every OPTIMUMAO block is additive: nothing vanilla sits in an #else of it. + Assert.DoesNotContain("#else", Regex.Matches(shader, @"#if OPTIMUMAO > 0[\s\S]*?#endif").Select(m => m.Value).FirstOrDefault() ?? ""); + } + + [Fact] + public void ThePlantFlagIsTheNoCullOpaquePassAndTheComposeDropsTheRowMin() + { + string chunk = Read("sources/shaders/chunkopaque.fsh"); + Assert.Equal(2, Regex.Matches(chunk, @"if \(haxyFade > 0\) outGNormal\.w = 1\.0;").Count); + // ChunkRenderer sets HaxyFade = 1 exactly for the OpaqueNoCull pool (plants, grass, cross-quads). + string renderer = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + string opaque = Between(renderer, "public void RenderOpaque(float dt)", "ScreenManager.FrameProfiler.Mark(\"rend3D-ret-opnc\");"); + Assert.Matches(new Regex(@"chunkopaque\.HaxyFade = 1;\s*for \(int l = 0; l < textureIds\.Length; l\+\+\)\s*\{[^}]*poolsByRenderPass\[1\]"), opaque); + + string compose = Read("sources/shaders/scene-ssao.fsh").Replace("\r\n", "\n"); + Assert.Contains("#if OPTIMUMAO > 0\n if (optimumAoMode == 1)", compose); + Assert.Contains("ao = texelFetch(ssaoScene, texel, 0).r;", compose); + Assert.Contains("max(0.0, 1.0 - texelFetch(revealageScene, texel, 0).r) * 0.75", compose); + Assert.Contains("ao = 1.0 - (1.0 - ao) * (1.0 - attenuate);", compose); + // The albedo hook exists, compiled out in the first version. + Assert.Contains("#if OPTIMUMAO_MULTIBOUNCE > 0", compose); + Assert.Contains("uniform sampler2D aoAlbedo;", compose); + Assert.DoesNotContain("OPTIMUMAO_MULTIBOUNCE", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs")); + } + + // ------------------------------------------------------------------ outputs, patcher, licences + + [Fact] + public void TheDebugOutputsAreOptInInTheParityDumpAndTheHeadlessHarness() + { + string device = Read("VintagestoryApi/Client/optimum-render-device.cs"); + Assert.Contains("public static readonly bool AmbientOcclusionOutputs = ResolveAmbientOcclusionOutputs();", device); + Assert.Contains("Environment.GetEnvironmentVariable(\"OPTIMUM_AO_OUTPUTS\")", device); + + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + string dump = Between(platform, "private void OptimumRunParityDump()", "private string OptimumParitySlotName(int slot)"); + Assert.Contains("if (OptimumParityDump.AmbientOcclusionOutputs)", dump); + Assert.Contains("OptimumAmbientOcclusionDebugTexture(aoIndex)", dump); + string names = Between(platform, "private string OptimumParitySlotName(int slot)", "private int OptimumParityDumpAttachment"); + foreach (string name in new[] { "OptimumAoWorking", "OptimumAoEdges", "OptimumAoDepthMip0", "OptimumAoOutput" }) + Assert.Contains("return \"" + name + "\";", names); + string capture = Between(platform, "private void OptimumHeadlessCaptureFrame(long worldFrame)", "private void OptimumRunParityDump()"); + Assert.Contains("OptimumHeadlessWriteAmbientOcclusion(worldFrame);", capture); + // Through the dump's single readback and writer call site, into the frame directory. + Assert.Contains("OptimumParityDumpAttachment(OptimumHeadless.FrameDirectory, aoSlot, OptimumParitySlotName(aoSlot), attachment, aoTexture);", capture); + + string vulkan = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs"); + string debug = Between(vulkan, "public override int OptimumAmbientOcclusionDebugTexture(int index)", "private GtaoSettings AmbientOcclusionSettings"); + Assert.True(debug.IndexOf("WorkingTermTexture", StringComparison.Ordinal) < debug.IndexOf("EdgesTexture", StringComparison.Ordinal) && + debug.IndexOf("EdgesTexture", StringComparison.Ordinal) < debug.IndexOf("WorkingDepthTexture", StringComparison.Ordinal) && + debug.IndexOf("WorkingDepthTexture", StringComparison.Ordinal) < debug.IndexOf("OutputTexture", StringComparison.Ordinal), + "the Vulkan override answers in the slot order the dump names"); + } + + [Fact] + public void EveryChangedMemberIsListedForTheCecilTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + foreach (string member in new[] + { + "RenderOptimumAmbientOcclusion", "OptimumAmbientOcclusionDebugTexture", "optimumAmbientOcclusionTexture", + "OptimumAoWorkingSlot", "OptimumAoEdgesSlot", "OptimumAoDepthSlot", "OptimumAoOutputSlot", "OptimumAoOutputCount", + "OptimumHeadlessWriteAmbientOcclusion", + // Already transplanted members whose bodies changed. + "ApplyOptimumSceneSsao", "OptimumRunParityDump", "OptimumParitySlotName", "OptimumHeadlessCaptureFrame", + }) + { + Assert.Contains("\"" + member + "\"", patcher); + } + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"RenderPostprocessingEffects\", 1)", patcher); + Assert.Contains("new(\"Vintagestory.Client.NoObf.ShaderRegistry\", \"registerDefaultShaderCodePrefixes\", 2)", patcher); + + string expected = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); + Assert.Contains("new(true, \"RenderOptimumAmbientOcclusion\", new[] { \"Single[]\" }),", expected); + Assert.Contains("new(true, \"OptimumAmbientOcclusionDebugTexture\", new[] { \"Int32\" }),", expected); + } + + [Fact] + public void TheComputeShadersCarryTheirLicenceNoticesAndShipInsideTheRenderer() + { + const string permission = "Permission is hereby granted, free of charge, to any person obtaining a copy"; + foreach (string file in new[] { "common.glsl", "prefilter.comp", "main.comp", "denoise.comp" }) + { + string source = Read("sources/shaders-vk/gtao/" + file); + Assert.Contains("Copyright (C) 2016-2021, Intel Corporation", source); + Assert.Contains("https://github.com/GameTechDev/XeGTAO", source); + Assert.Contains(permission, source); + } + string main = Read("sources/shaders-vk/gtao/main.comp"); + Assert.Contains("https://github.com/bevyengine/bevy", main); + Assert.Contains("Copyright (c) 2016, Intel Corporation", main); // ASSAO's normal-based edges, via Godot + Assert.Contains("clayjohn: convert to Vulkan and Godot", main); + + string project = Read("Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj"); + Assert.Contains("", project); + Assert.Contains("shaders-vk/gtao/%(Filename)%(Extension)", project); + // No shipped noise texture in the first version (E.2.2): the Hilbert table is generated. + Assert.Empty(Directory.EnumerateFiles(Path.Combine(Root(), "sources", "shaders-vk", "gtao")) + .Where(f => !f.EndsWith(".comp", StringComparison.Ordinal) && !f.EndsWith(".glsl", StringComparison.Ordinal))); + } + + // ------------------------------------------------------------------ helpers + + private static string Between(string text, string start, string end) + { + int from = text.IndexOf(start, StringComparison.Ordinal); + Assert.True(from >= 0, "missing: " + start); + int to = text.IndexOf(end, from, StringComparison.Ordinal); + Assert.True(to > from, "missing after " + start + ": " + end); + return text[from..to]; + } + + private static string Root() + { + string root = Directory.GetCurrentDirectory(); + while (!Directory.Exists(Path.Combine(root, "Optimum.Patcher"))) root = Directory.GetParent(root)!.FullName; + return root; + } + + private static string Read(string path) => File.ReadAllText(Path.Combine(Root(), path)); +} diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index a896b945..44115ce3 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -59,6 +59,10 @@ public class ClientPlatformWindowsVanillaRegionsTests "optimumTaaDisabled", "optimumTaaResolvedThisFrame", "optimumTaaShaderReloadPending", "optimumTaaTargetsReady", "taaResolvedColorTexture", "taaResolvedGlowTexture", "optimumSsaoInScene", "ApplyOptimumSceneSsao", + // Optimum AO: the platform's visibility texture composed through ApplyOptimumSceneSsao, + // and the slots and headless writer of its opt-in debug outputs. + "optimumAmbientOcclusionTexture", "OptimumAoWorkingSlot", "OptimumAoEdgesSlot", "OptimumAoDepthSlot", + "OptimumAoOutputSlot", "OptimumAoOutputCount", "OptimumHeadlessWriteAmbientOcclusion", // Headless render harness: the per-frame hook, its own in-world frame counter, // the chat-command script dispatch, the presented-frame readback and the clean // close from the render thread. diff --git a/Optimum.Tests/parity-dump-coverage-tests.cs b/Optimum.Tests/parity-dump-coverage-tests.cs index 29f38c88..4ced5fd7 100644 --- a/Optimum.Tests/parity-dump-coverage-tests.cs +++ b/Optimum.Tests/parity-dump-coverage-tests.cs @@ -55,7 +55,12 @@ public void DumpedSlotListMatchesTheFramebuffersBothSetupsCreate() int slot = int.TryParse(match.Groups[1].Value, out int literal) ? literal : constants[match.Groups[1].Value]; named.Add(slot, match.Groups[2].Value); } - Assert.Equal(glSlots, new SortedSet(named.Keys)); + // Optimum AO: the compute-only outputs are named but held by no framebuffer + // (ambient-occlusion-coverage-tests pins them). + var aoSlots = new HashSet(new[] { "OptimumAoWorkingSlot", "OptimumAoEdgesSlot", "OptimumAoDepthSlot", "OptimumAoOutputSlot" }.Select(c => constants[c])); + Assert.Equal(4, aoSlots.Count(named.ContainsKey)); + Assert.DoesNotContain(glSlots, aoSlots.Contains); + Assert.Equal(glSlots, new SortedSet(named.Keys.Where(slot => !aoSlots.Contains(slot)))); // Vanilla slots are named exactly as EnumFrameBuffer names them. Dictionary enumValues = EnumValues(); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 0162b0e2..82bfc3d8 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..7420dff 100644 +index d6eb844..0d7c6f9 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,433 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,453 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -113,6 +113,26 @@ index d6eb844..7420dff 100644 + return resolvedScene; + } + ++ /// ++ /// Optimum AO (docs/research/ambient-occlusion.md): records the platform's own ambient ++ /// occlusion for this frame and returns the visibility texture the scene composes; 0 means ++ /// vanilla SSAO runs. The neutral body, and so the OpenGL path, always returns 0. ++ /// ++ public virtual int RenderOptimumAmbientOcclusion(float[] projectMatrix) ++ { ++ return 0; ++ } ++ ++ /// ++ /// Optimum AO: this frame's debug outputs for the parity dump and the headless harness - ++ /// 0 the pre-denoise working term, 1 the packed edges, 2 working-depth level 0, 3 the ++ /// denoised output; 0 when the platform ran no AO of its own. ++ /// ++ public virtual int OptimumAmbientOcclusionDebugTexture(int index) ++ { ++ return 0; ++ } ++ + public virtual bool OptimumFsrBlitActive() + { + return false; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 830d78b6..8cbe7111 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..4359768 100644 +index 6edf0c9..e4a4415 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -115,7 +115,7 @@ index 6edf0c9..4359768 100644 private Logger logger; private int doResize; -@@ -93,10 +182,115 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +182,127 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -141,6 +141,18 @@ index 6edf0c9..4359768 100644 + // the unsharpened resolve. + private const int OptimumTaaSharpenIndex = 21; + ++ // Optimum AO: the parity-dump and headless slot numbers of the compute-only AO outputs, ++ // in OptimumAmbientOcclusionDebugTexture's index order. No framebuffer holds them. ++ private const int OptimumAoWorkingSlot = 40; ++ ++ private const int OptimumAoEdgesSlot = 41; ++ ++ private const int OptimumAoDepthSlot = 42; ++ ++ private const int OptimumAoOutputSlot = 43; ++ ++ private const int OptimumAoOutputCount = 4; ++ + private const int OptimumGlR32f = 0x822E; + + /// @@ -231,7 +243,7 @@ index 6edf0c9..4359768 100644 private bool serverRunning; private bool gamepause; -@@ -109,10 +303,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -109,10 +315,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private bool RenderFXAA; @@ -250,7 +262,7 @@ index 6edf0c9..4359768 100644 private int ShadowMapQuality; private float ssaaLevel; -@@ -200,11 +402,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -200,11 +414,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return audio.MasterSoundLevel; } @@ -267,7 +279,7 @@ index 6edf0c9..4359768 100644 public override AssetManager AssetManager => assetManager; -@@ -256,10 +462,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +474,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -291,7 +303,7 @@ index 6edf0c9..4359768 100644 get { return serverRunning; -@@ -278,34 +497,54 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,34 +509,54 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -354,7 +366,7 @@ index 6edf0c9..4359768 100644 public override bool GlDebugMode { get -@@ -379,10 +618,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -379,10 +630,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public void StartAudio() { if (audio == null) @@ -374,7 +386,7 @@ index 6edf0c9..4359768 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +726,148 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +738,148 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -529,7 +541,7 @@ index 6edf0c9..4359768 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +887,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +899,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -542,7 +554,7 @@ index 6edf0c9..4359768 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1058,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1070,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -587,7 +599,7 @@ index 6edf0c9..4359768 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1170,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1182,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -607,7 +619,7 @@ index 6edf0c9..4359768 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1406,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1418,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -620,7 +632,7 @@ index 6edf0c9..4359768 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,150 +1533,900 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,146 +1545,943 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -861,13 +873,10 @@ index 6edf0c9..4359768 100644 - GL.DrawBuffers(3, array5); - ClearFrameBuffer(EnumFrameBuffer.Transparent); - CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); -- if (SetupSSAO) + return; + } + if (Vintagestory.API.Config.OptimumParityDump.Enabled && !optimumParityDumpDone) - { -- _ = ClientSettings.SSAOQuality; -- float num3 = 0.5f; ++ { + return; + } + if (!OptimumHeadless.CaptureEnabled && !Vintagestory.API.Config.OptimumParityDump.Enabled) @@ -963,6 +972,10 @@ index 6edf0c9..4359768 100644 + { + optimumHeadlessFramesWritten = optimumHeadlessFramesWritten + 1; + } ++ if (OptimumParityDump.AmbientOcclusionOutputs) ++ { ++ OptimumHeadlessWriteAmbientOcclusion(worldFrame); ++ } + } + catch (Exception error) + { @@ -971,6 +984,27 @@ index 6edf0c9..4359768 100644 + } + + /// ++ /// Optimum AO (headless harness): the AO debug outputs of a captured frame, written beside ++ /// the frame file under the parity dump's file-name format with the frame number as the ++ /// attachment name (OPTIMUM_AO_OUTPUTS opts in). ++ /// ++ private void OptimumHeadlessWriteAmbientOcclusion(long worldFrame) ++ { ++ string attachment = "frame" + worldFrame.ToString("D6", System.Globalization.CultureInfo.InvariantCulture); ++ for (int aoIndex = 0; aoIndex < OptimumAoOutputCount; aoIndex++) ++ { ++ int aoTexture = OptimumAmbientOcclusionDebugTexture(aoIndex); ++ if (aoTexture == 0) ++ { ++ continue; ++ } ++ int aoSlot = OptimumAoWorkingSlot + aoIndex; ++ // Through the dump's single readback and writer, into the frame directory. ++ OptimumParityDumpAttachment(OptimumHeadless.FrameDirectory, aoSlot, OptimumParitySlotName(aoSlot), attachment, aoTexture); ++ } ++ } ++ ++ /// + /// Optimum: the per-attachment parity dump (). + /// Called from window_RenderFrame only when OPTIMUM_PARITY_DUMP is set; dumps + /// every attachment of every framebuffer slot once, on in-world frame @@ -1032,6 +1066,20 @@ index 6edf0c9..4359768 100644 + attachments += OptimumParityDumpAttachment(directory, slot, slotName, "depth", depthTexture); + } + } ++ // Optimum AO: the compute-only AO outputs, opt-in (OPTIMUM_AO_OUTPUTS). ++ if (OptimumParityDump.AmbientOcclusionOutputs) ++ { ++ for (int aoIndex = 0; aoIndex < OptimumAoOutputCount; aoIndex++) ++ { ++ int aoTexture = OptimumAmbientOcclusionDebugTexture(aoIndex); ++ if (aoTexture == 0 || !dumped.Add(aoTexture)) ++ { ++ continue; ++ } ++ int aoSlot = OptimumAoWorkingSlot + aoIndex; ++ attachments += OptimumParityDumpAttachment(directory, aoSlot, OptimumParitySlotName(aoSlot), "color0", aoTexture); ++ } ++ } + logger.Notification("[Optimum] parity dump: " + attachments + " attachments -> " + directory); + } + @@ -1083,6 +1131,14 @@ index 6edf0c9..4359768 100644 + return "OptimumTaaHistoryB"; + case OptimumTaaSharpenIndex: + return "OptimumTaaSharpen"; ++ case OptimumAoWorkingSlot: ++ return "OptimumAoWorking"; ++ case OptimumAoEdgesSlot: ++ return "OptimumAoEdges"; ++ case OptimumAoDepthSlot: ++ return "OptimumAoDepthMip0"; ++ case OptimumAoOutputSlot: ++ return "OptimumAoOutput"; + default: + return "Slot" + slot; + } @@ -1635,16 +1691,12 @@ index 6edf0c9..4359768 100644 + GL.DrawBuffers(3, array5); + ClearFrameBuffer(EnumFrameBuffer.Transparent); + CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); -+ if (SetupSSAO) -+ { -+ _ = ClientSettings.SSAOQuality; -+ float num3 = 0.5f; + if (SetupSSAO) + { + _ = ClientSettings.SSAOQuality; + float num3 = 0.5f; FrameBufferRef obj = new FrameBufferRef - { - FboId = GL.GenFramebuffer(), - Width = (int)((float)num * num3), - Height = (int)((float)num2 * num3) -@@ -1436,10 +2569,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2628,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1720,7 +1772,7 @@ index 6edf0c9..4359768 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2746,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2805,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1735,7 +1787,7 @@ index 6edf0c9..4359768 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2769,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2828,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1749,7 +1801,7 @@ index 6edf0c9..4359768 100644 + /// member for it) attachments. + /// + private FrameBufferRef CreateOptimumHistoryTargetGl(int width, int height) - { ++ { + FrameBufferRef target = new FrameBufferRef + { + FboId = GL.GenFramebuffer(), @@ -1801,7 +1853,7 @@ index 6edf0c9..4359768 100644 + } + + public virtual void DisposeFrameBuffers(List buffers) -+ { + { + // Mono.Cecil transplant. + // SetupOptimumFrameBuffers shares one depth texture between Primary and + // Transparent, so the same handle appears in more than one FrameBufferRef. @@ -1830,7 +1882,7 @@ index 6edf0c9..4359768 100644 } } } -@@ -1591,11 +2863,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2922,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1849,7 +1901,7 @@ index 6edf0c9..4359768 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2898,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2957,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1936,7 +1988,7 @@ index 6edf0c9..4359768 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3007,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3066,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2022,7 +2074,7 @@ index 6edf0c9..4359768 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3087,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3146,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2108,7 +2160,7 @@ index 6edf0c9..4359768 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3169,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3228,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2369,7 +2421,7 @@ index 6edf0c9..4359768 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3433,95 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3492,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2390,8 +2442,16 @@ index 6edf0c9..4359768 100644 + // bypasses the history and carries the raw camera jitter onto the whole + // frame, worst on fine distant detail. + optimumSsaoInScene = false; ++ // Optimum AO (docs/research/ambient-occlusion.md): the platform's own AO replaces the ++ // vanilla SSAO pass when it returns a texture - the Vulkan platform with GTAO selected. ++ // The neutral body returns 0, so the OpenGL path always runs vanilla SSAO. ++ optimumAmbientOcclusionTexture = 0; + if (RenderSSAO && projectMatrix != null) + { ++ optimumAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix); ++ } ++ if (optimumAmbientOcclusionTexture == 0 && RenderSSAO && projectMatrix != null) ++ { + GlToggleBlend(on: false); + LoadFrameBuffer(EnumFrameBuffer.SSAO); + ClearSsaoTarget(); @@ -2445,6 +2505,12 @@ index 6edf0c9..4359768 100644 + ApplyOptimumSceneSsao(); + } + } ++ // Optimum AO: always composed into the scene before the resolve, at render resolution ++ // and never onto glow; the multiply also marks it applied, so Final never repeats it. ++ if (optimumAmbientOcclusionTexture != 0) ++ { ++ ApplyOptimumSceneSsao(); ++ } + // Optimum TAA: resolve first, so bloom, god rays and the final input read + // the temporally stable image instead of the jittered one. + RenderOptimumTaaResolve(); @@ -2469,7 +2535,7 @@ index 6edf0c9..4359768 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3533,108 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3606,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2596,6 +2662,12 @@ index 6edf0c9..4359768 100644 + /// + private bool optimumSsaoInScene; + ++ /// ++ /// Optimum AO: the visibility texture RenderOptimumAmbientOcclusion returned this frame, or 0 ++ /// when vanilla SSAO runs. The CLR default (0) is the vanilla state. ++ /// ++ private int optimumAmbientOcclusionTexture; ++ + /// Optimum TAA: multiply the jittered AO into Primary colour 0 before the resolve, touching nothing else. + private void ApplyOptimumSceneSsao() + { @@ -2610,7 +2682,24 @@ index 6edf0c9..4359768 100644 + // never samples Primary, so there is no attachment feedback loop. + GlToggleBlend(on: true, EnumBlendMode.Multiply); + composite.Use(); -+ composite.BindTexture2D("ssaoScene", frameBuffers[14].ColorTextureIds[0], 0); ++ if (optimumAmbientOcclusionTexture != 0) ++ { ++ // Optimum AO: the platform's visibility term at render resolution; the water, fog ++ // and OIT attenuation vanilla SSAO applies in its own pass is applied by the shader. ++ composite.BindTexture2D("ssaoScene", optimumAmbientOcclusionTexture, 0); ++ } ++ else ++ { ++ composite.BindTexture2D("ssaoScene", frameBuffers[14].ColorTextureIds[0], 0); ++ } ++ if (OptimumConfig.AmbientOcclusionShadersUseGtao) ++ { ++ // The OPTIMUMAO variant of scene-ssao declares the attenuation inputs; both exist ++ // whenever the SSAO G-buffer does, and are bound in either mode. ++ composite.BindTexture2D("gPositionScene", frameBuffers[0].ColorTextureIds[3], 1); ++ composite.BindTexture2D("revealageScene", frameBuffers[1].ColorTextureIds[1], 2); ++ composite.Uniform("optimumAoMode", (optimumAmbientOcclusionTexture != 0) ? 1 : 0); ++ } + composite.Uniform("invRenderHeight", 1f / (float)frameBuffers[0].Height); + RenderFullscreenTriangle(screenQuad); + composite.Stop(); @@ -2625,7 +2714,7 @@ index 6edf0c9..4359768 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3644,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3740,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2660,7 +2749,7 @@ index 6edf0c9..4359768 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3683,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3779,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2717,7 +2806,7 @@ index 6edf0c9..4359768 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3738,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3834,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3203,7 +3292,7 @@ index 6edf0c9..4359768 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4374,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4470,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3243,7 +3332,7 @@ index 6edf0c9..4359768 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4772,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4868,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3296,7 +3385,7 @@ index 6edf0c9..4359768 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4867,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4963,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3341,7 +3430,7 @@ index 6edf0c9..4359768 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +4904,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5000,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3362,7 +3451,7 @@ index 6edf0c9..4359768 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +4923,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5019,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3383,7 +3472,7 @@ index 6edf0c9..4359768 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +4942,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5038,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3404,7 +3493,7 @@ index 6edf0c9..4359768 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +4961,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5057,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3425,7 +3514,7 @@ index 6edf0c9..4359768 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +4984,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5080,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3446,7 +3535,7 @@ index 6edf0c9..4359768 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5546,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5642,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3470,7 +3559,7 @@ index 6edf0c9..4359768 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +5905,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6001,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index ecf70d55..8bd03e8c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index 4a24e75..b2f4276 100644 +index 4a24e75..c22cb64 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -238,7 +238,7 @@ index 4a24e75..b2f4276 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +497,48 @@ public class ShaderRegistry +@@ -333,10 +497,56 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; @@ -278,6 +278,14 @@ index 4a24e75..b2f4276 100644 + OptimumEntityMotion.Enabled = taaMotion; + int taaMotionLocation = ((ClientSettings.SSAOQuality > 0) ? 4 : 2); + string taaDefines = "#define TAAMOTION " + (taaMotion ? 1 : 0) + "\r\n#define TAAMOTIONLOCATION " + taaMotionLocation + "\r\n"; ++ // Optimum AO (docs/research/ambient-occlusion.md): the class channel writes (the hand view, ++ // the no-cull plants) and scene-ssao's GTAO compose branch compile in only while the Vulkan ++ // platform runs GTAO, so OpenGL and every other configuration preprocess exactly as before. ++ // SSAOQuality > 0 is the G-buffer's own condition, the one SSAOLEVEL is stamped from. The ++ // flag tells the platform what the live shaders were built for. ++ bool optimumAo = OptimumConfig.EffectiveGtao && ClientSettings.SSAOQuality > 0; ++ OptimumConfig.AmbientOcclusionShadersUseGtao = optimumAo; ++ taaDefines = taaDefines + "#define OPTIMUMAO " + (optimumAo ? 1 : 0) + "\r\n"; + Shader taaFrag = program.FragmentShader; + taaFrag.PrefixCode = taaFrag.PrefixCode + taaDefines; + Shader taaVert = program.VertexShader; diff --git a/sources/VintagestoryApi/Client/optimum-render-device.cs b/sources/VintagestoryApi/Client/optimum-render-device.cs index d96e190a..0482c092 100644 --- a/sources/VintagestoryApi/Client/optimum-render-device.cs +++ b/sources/VintagestoryApi/Client/optimum-render-device.cs @@ -62,6 +62,23 @@ public static class OptimumParityDump /// The in-world frame to dump, counted from 0. public static readonly long Frame = ResolveFrame(); + /// + /// True when OPTIMUM_AO_OUTPUTS opts in to the ambient occlusion debug outputs + /// (docs/research/ambient-occlusion.md C.13, section D): the pre-denoise working term, the + /// packed edges, working-depth level 0 and the denoised output are written by this dump + /// (slots 40-43) and beside every frame the headless harness captures. Compute-only + /// textures, so no framebuffer slot holds them and the dump asks the platform for them. + /// + public static readonly bool AmbientOcclusionOutputs = ResolveAmbientOcclusionOutputs(); + + private static bool ResolveAmbientOcclusionOutputs() + { + string value = Environment.GetEnvironmentVariable("OPTIMUM_AO_OUTPUTS"); + if (string.IsNullOrWhiteSpace(value)) return false; + value = value.Trim(); + return value != "0" && !value.Equals("false", StringComparison.OrdinalIgnoreCase); + } + /// /// The one file-name format both backends use: /// <slotIndex>-<slotName>-<color<i>|depth>-<format>.<ext>. diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index 335da811..d8436cd0 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -437,6 +437,51 @@ public static class OptimumConfig /// public static bool TaaJitterDev = false; + /// + /// Which ambient occlusion runs: "auto", "vanilla" or "gtao" + /// (docs/research/ambient-occlusion.md, section E). + /// + /// "auto" (the default) is the GTAO visibility-bitmask pass on the Vulkan backend + /// whenever TAA is active, and vanilla SSAO otherwise. "gtao" asks for it on Vulkan + /// without TAA too (a measurement configuration: two denoise passes, a still noise + /// index). "vanilla" keeps vanilla SSAO. The OpenGL backend ignores the setting and + /// always runs vanilla SSAO. Vanilla's own SSAO quality setting still switches AO off + /// entirely at 0 on both backends: the G-buffer both passes read exists only above 0. + /// + /// A string, like : an unrecognised value degrades to "auto" + /// rather than failing the whole file. + /// + public static string AmbientOcclusion = "auto"; + + /// + /// The GTAO quality preset: "low" (1x2), "medium" (2x2), "high" (3x3) or "ultra" (9x3, + /// two denoise passes). Medium is the render-resolution handheld candidate; the handheld + /// default is decided by the section D measurements. + /// + public static string AmbientOcclusionPreset = "medium"; + + /// + /// Whether GTAO is selected for a backend: never on OpenGL; on Vulkan with "gtao", or + /// with "auto" while TAA is active. + /// + public static bool GtaoSelected(bool vulkanBackend, bool taaActive) + { + if (!vulkanBackend) return false; + if (string.Equals(AmbientOcclusion, "gtao", StringComparison.OrdinalIgnoreCase)) return true; + return taaActive && string.Equals(AmbientOcclusion, "auto", StringComparison.OrdinalIgnoreCase); + } + + /// GTAO for the backend actually running and the TAA state actually in effect. + public static bool EffectiveGtao => GtaoSelected(OptimumRender.IsVulkan, EffectiveTaa); + + /// + /// Stamped by ShaderRegistry when it builds the shader prefixes: true when the shaders were + /// compiled with #define OPTIMUMAO 1 (the class channel writes and the GTAO compose + /// branch). The platform runs GTAO only while this holds, so the pass and the shaders + /// that feed and compose it can never disagree between two shader reloads. + /// + public static bool AmbientOcclusionShadersUseGtao { get; set; } + /// /// Which renderer the client runs: "opengl", "vulkan", or "auto". /// @@ -769,6 +814,8 @@ public static int ResolveWorldgenWorkerCount( (nameof(OptimumConfigData.TaaMipBias), TaaMipBias.ToString("F2")), (nameof(OptimumConfigData.TaaDebugView), TaaDebugView.ToString()), (nameof(OptimumConfigData.TaaJitterDev), TaaJitterDev.ToString()), + (nameof(OptimumConfigData.AmbientOcclusion), AmbientOcclusion), + (nameof(OptimumConfigData.AmbientOcclusionPreset), AmbientOcclusionPreset), (nameof(OptimumConfigData.MapPageCache), MapPageCacheEnabled.ToString()), (nameof(OptimumConfigData.MapPageCacheMaxLayers), MapPageCacheMaxLayers.ToString()), (nameof(OptimumConfigData.MapPageCacheBc7), MapPageCacheBc7.ToString()), @@ -872,6 +919,11 @@ public static void Load() TaaMipBias = Math.Clamp(data.TaaMipBias, -2f, 1f); TaaDebugView = Math.Max(0, data.TaaDebugView); TaaJitterDev = data.TaaJitterDev; + // Unrecognised values degrade to the defaults rather than failing the file. + string requestedAo = data.AmbientOcclusion?.Trim().ToLowerInvariant() ?? ""; + AmbientOcclusion = requestedAo is "vanilla" or "gtao" ? requestedAo : "auto"; + string requestedAoPreset = data.AmbientOcclusionPreset?.Trim().ToLowerInvariant() ?? ""; + AmbientOcclusionPreset = requestedAoPreset is "low" or "high" or "ultra" ? requestedAoPreset : "medium"; MapPageCacheEnabled = data.MapPageCache; MapPageCacheMaxLayers = Math.Clamp(data.MapPageCacheMaxLayers, 16, 512); MapPageCacheBc7 = data.MapPageCacheBc7; @@ -946,6 +998,8 @@ public static void Save() TaaMipBias = TaaMipBias, TaaDebugView = TaaDebugView, TaaJitterDev = TaaJitterDev, + AmbientOcclusion = AmbientOcclusion, + AmbientOcclusionPreset = AmbientOcclusionPreset, MapPageCache = MapPageCacheEnabled, MapPageCacheMaxLayers = MapPageCacheMaxLayers, MapPageCacheBc7 = MapPageCacheBc7, @@ -1028,6 +1082,8 @@ internal sealed class OptimumConfigData public float TaaMipBias { get; set; } = -0.5f; public int TaaDebugView { get; set; } = 0; public bool TaaJitterDev { get; set; } = false; + public string AmbientOcclusion { get; set; } = "auto"; + public string AmbientOcclusionPreset { get; set; } = "medium"; public bool MapPageCache { get; set; } = true; public int MapPageCacheMaxLayers { get; set; } = 128; public bool MapPageCacheBc7 { get; set; } = true; diff --git a/sources/shaders-vk/gtao/common.glsl b/sources/shaders-vk/gtao/common.glsl new file mode 100644 index 00000000..d1051907 --- /dev/null +++ b/sources/shaders-vk/gtao/common.glsl @@ -0,0 +1,197 @@ +// Shared declarations of Optimum's ambient occlusion compute passes +// (docs/research/ambient-occlusion.md, section C): the push constant block, the +// specialization constant ids, depth and position reconstruction, the edge +// packing and the Hilbert + R2 noise. +// +// Ported from XeGTAO (XeGTAO.hlsli, XeGTAO.h, vaGTAO.hlsl): +// +// Copyright (C) 2016-2021, Intel Corporation +// SPDX-License-Identifier: MIT +// https://github.com/GameTechDev/XeGTAO +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// These passes bind only their own pass set (set 0 of a compute program) and touch +// none of the shared sets of include/bindings.glsl. +// +// The loader (AmbientOcclusion/GtaoShaderSources.cs) expands the #include lines and +// defines GTAO_DEPTH_FORMAT and GTAO_TERM_FORMAT after #version: the storage format +// qualifiers of the formats the device actually chose (r32f or rgba32f, r8 or rgba8), +// so a fallback format never disagrees with its qualifier. + +#ifndef OPTIMUM_GTAO_COMMON_GLSL +#define OPTIMUM_GTAO_COMMON_GLSL + +#define GTAO_PI 3.1415926535897932384626433832795 +#define GTAO_PI_HALF 1.5707963267948966192313216916398 + +// Working depth levels: mip 0 at render resolution and four halvings (C.1). +#define GTAO_DEPTH_MIP_LEVELS 5.0 + +// The pre-denoise term is stored as visibility / 1.5: "raw, pre-denoised occlusion +// term can overshoot 1 but will later average out to 1" (XeGTAO.h). The last denoise +// pass scales it back. +#define GTAO_OCCLUSION_TERM_SCALE 1.5 + +// Specialization constant ids; AmbientOcclusion/GtaoSettings.cs (GtaoSpecialization) +// mirrors them and a test keeps the two in agreement. +#define GTAO_SPEC_INTEGRATION 0 +#define GTAO_SPEC_SLICE_COUNT 1 +#define GTAO_SPEC_STEPS_PER_SLICE 2 +#define GTAO_SPEC_THICKNESS 3 +#define GTAO_SPEC_CLASS_CHANNEL 4 +#define GTAO_SPEC_NOISE_CYCLE 5 +#define GTAO_SPEC_NORMAL_EDGES 6 +#define GTAO_SPEC_FINAL_APPLY 7 + +// INTEGRATION (C.3, C.13). +#define GTAO_INTEGRATION_BITMASK_COS 0u +#define GTAO_INTEGRATION_BITMASK_UNIFORM 1u +#define GTAO_INTEGRATION_HORIZON 2u + +// THICKNESS (C.5, C.13). WIDTH is not implemented (see the design's C.5). +#define GTAO_THICKNESS_CONST 0u +#define GTAO_THICKNESS_DIST 1u +#define GTAO_THICKNESS_RANDOM 2u + +// 32 sectors in one uint (C.3). +#define GTAO_SECTORS 32u + +// 80 bytes, every member 4 bytes wide so the offsets are the declaration order +// times four (GtaoSettings.PushConstants packs them in the same order). +layout(push_constant) uniform GtaoConstants +{ + float depthUnpackMul; // 0: viewZ = depthUnpackMul / (depthUnpackAdd - depth) + float depthUnpackAdd; // 4 + float ndcToViewMulX; // 8: view.xy = (mul * uv + add) * viewZ, uv row 0 at the bottom (GL order) + float ndcToViewMulY; // 12 + float ndcToViewAddX; // 16 + float ndcToViewAddY; // 20 + float effectRadius; // 24: blocks + float effectFalloffRange; // 28 + float radiusMultiplier; // 32 + float finalValuePower; // 36: measurement only, 1.0 (C.11) + float sampleDistributionPower; // 40 + float depthMipSamplingOffset; // 44 + float thickness; // 48: blocks, solid surfaces (C.5) + float thicknessThin; // 52: blocks, the thin class + float thicknessDistanceScale; // 56: per block of view distance + float farFadeBias; // 60: fade = clamp(bias - viewZ * scale, 0, 1) (C.6) + float farFadeScale; // 64 + uint noiseIndex; // 68: FrameIndex while TAA runs, else 0 (C.7) + float denoiseBlurBeta; // 72 + uint reserved; // 76 +} gtao; + +float gtaoViewDepth(float screenDepth) +{ + // GL depth [0, 1] of a GL projection: DepthUnpackConsts = (-B/2, (1-A)/2), A = P[2][2], B = P[3][2]. + float depth = gtao.depthUnpackMul / (gtao.depthUnpackAdd - screenDepth); + return clamp(depth, 0.0, 3.402823466e+38); +} + +vec3 gtaoViewPosition(vec2 screenPos, float viewspaceDepth) +{ + // The working frame: x right, y up, z forward (GL view space mirrored in z). + return vec3((vec2(gtao.ndcToViewMulX, gtao.ndcToViewMulY) * screenPos + + vec2(gtao.ndcToViewAddX, gtao.ndcToViewAddY)) * viewspaceDepth, viewspaceDepth); +} + +// [Drobot2014a] Low Level Optimizations for GCN (XeGTAO_FastSqrt). +float gtaoFastSqrt(float x) +{ + return intBitsToFloat(0x1fbd1df5 + (floatBitsToInt(x) >> 1)); +} + +// Input [-1, 1], output [0, PI] (XeGTAO_FastACos); the input is clamped so a +// dot product a hair above 1 cannot feed a negative number to gtaoFastSqrt. +float gtaoFastACos(float inX) +{ + const float pi = 3.141593; + const float halfPi = 1.570796; + float clamped = clamp(inX, -1.0, 1.0); + float x = abs(clamped); + float res = -0.156583 * x + halfPi; + res *= gtaoFastSqrt(1.0 - x); + return clamped >= 0.0 ? res : pi - res; +} + +// XeGTAO_CalculateEdges: 1 = no edge, 0 = full edge, relative to the centre depth. +vec4 gtaoCalculateEdges(float centerZ, float leftZ, float rightZ, float topZ, float bottomZ) +{ + vec4 edgesLRTB = vec4(leftZ, rightZ, topZ, bottomZ) - centerZ; + float slopeLR = (edgesLRTB.y - edgesLRTB.x) * 0.5; + float slopeTB = (edgesLRTB.w - edgesLRTB.z) * 0.5; + vec4 edgesLRTBSlopeAdjusted = edgesLRTB + vec4(slopeLR, -slopeLR, slopeTB, -slopeTB); + edgesLRTB = min(abs(edgesLRTB), abs(edgesLRTBSlopeAdjusted)); + return clamp(1.25 - edgesLRTB / (centerZ * 0.011), 0.0, 1.0); +} + +// Two bits per edge (XeGTAO_PackEdges / XeGTAO_UnpackEdges). +float gtaoPackEdges(vec4 edgesLRTB) +{ + edgesLRTB = floor(clamp(edgesLRTB, 0.0, 1.0) * 2.9 + 0.5); + return dot(edgesLRTB, vec4(64.0 / 255.0, 16.0 / 255.0, 4.0 / 255.0, 1.0 / 255.0)); +} + +vec4 gtaoUnpackEdges(float packedValue) +{ + uint packedBits = uint(packedValue * 255.5); + return clamp(vec4(float((packedBits >> 6) & 3u), float((packedBits >> 4) & 3u), + float((packedBits >> 2) & 3u), float(packedBits & 3u)) / 3.0, 0.0, 1.0); +} + +// XeGTAO_DepthMIPFilter: the weighted average that keeps the nearer depths (C.1). +float gtaoDepthMipFilter(float depth0, float depth1, float depth2, float depth3) +{ + float maxDepth = max(max(depth0, depth1), max(depth2, depth3)); + const float depthRangeScaleFactor = 0.75; // "found empirically :)" + float effectRadius = depthRangeScaleFactor * gtao.effectRadius * gtao.radiusMultiplier; + float falloffRange = gtao.effectFalloffRange * effectRadius; + float falloffFrom = effectRadius * (1.0 - gtao.effectFalloffRange); + float falloffMul = -1.0 / falloffRange; + float falloffAdd = falloffFrom / falloffRange + 1.0; + float weight0 = clamp((maxDepth - depth0) * falloffMul + falloffAdd, 0.0, 1.0); + float weight1 = clamp((maxDepth - depth1) * falloffMul + falloffAdd, 0.0, 1.0); + float weight2 = clamp((maxDepth - depth2) * falloffMul + falloffAdd, 0.0, 1.0); + float weight3 = clamp((maxDepth - depth3) * falloffMul + falloffAdd, 0.0, 1.0); + float weightSum = weight0 + weight1 + weight2 + weight3; + return (weight0 * depth0 + weight1 * depth1 + weight2 * depth2 + weight3 * depth3) / weightSum; +} + +// Hilbert index + 288 * (NoiseIndex % cycle) driving R2 (vaGTAO.hlsl SpatioTemporalNoise). +// The R2 step is evaluated in 32-bit fixed point, fract(0.5 + index * alpha) exactly, +// instead of in float where index * alpha loses the fraction's low bits. +vec2 gtaoNoise(uint hilbertIndex, uint noiseIndex, uint cycle) +{ + uint index = hilbertIndex + 288u * (noiseIndex % max(cycle, 1u)); + // 0.75487766624669276005 and 0.5698402909980532659114 times 2^32, rounded. + uvec2 fixedPoint = uvec2(2147483648u) + uvec2(index) * uvec2(3242174889u, 2447445414u); + return vec2(fixedPoint >> 8) / 16777216.0; +} + +// The class channel (gNormal.w, C.5): > 0 thin (leaves, plants, grass, cross-quads, +// translucent particles), < 0 the hand view, 0 solid. +bool gtaoIsHand(float surfaceClass) { return surfaceClass < 0.0; } +bool gtaoIsThin(float surfaceClass) { return surfaceClass > 0.0; } + +// Sky: depth at the far plane, as the TAA resolve tests it. +bool gtaoIsSky(float screenDepth) { return screenDepth >= 0.999999; } + +#endif diff --git a/sources/shaders-vk/gtao/denoise.comp b/sources/shaders-vk/gtao/denoise.comp new file mode 100644 index 00000000..5f605e7b --- /dev/null +++ b/sources/shaders-vk/gtao/denoise.comp @@ -0,0 +1,110 @@ +#version 450 +// Optimum ambient occlusion, pass 3 of 3: the edge-aware 3x3 denoise +// (docs/research/ambient-occlusion.md C.8). +// +// One pass with TAA; the final pass scales the term back from the 1.5 packing. +// Ported from XeGTAO's XeGTAO_Denoise (centre weight DenoiseBlurBeta on the final pass +// and beta / 5 before it, symmetric 2-bit edges, diagonal weights and the leak term): +// +// Copyright (C) 2016-2021, Intel Corporation +// SPDX-License-Identifier: MIT +// https://github.com/GameTechDev/XeGTAO +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// XeGTAO gathers two horizontal pixels per invocation through textureGather; this +// port reads one pixel per invocation with texelFetch (Bevy's spatial_denoise.wesl +// shape). Sky and hand-view pixels pass visibility 1 through untouched (C.9), so the +// hand class never receives AO and the sky is never darkened. + +layout(local_size_x = 8, local_size_y = 8) in; + +#include "common.glsl" + +layout(constant_id = GTAO_SPEC_FINAL_APPLY) const uint FINAL_APPLY = 1u; + +layout(binding = 0) uniform sampler2D sourceTerm; +layout(binding = 1) uniform sampler2D sourceEdges; +layout(binding = 2) uniform sampler2D sourceDepth; +layout(binding = 3) uniform sampler2D gNormal; +layout(binding = 4, GTAO_TERM_FORMAT) uniform writeonly image2D outTerm; + +ivec2 imageExtent; + +float termAt(ivec2 texel) +{ + return texelFetch(sourceTerm, clamp(texel, ivec2(0), imageExtent - 1), 0).r; +} + +vec4 edgesAt(ivec2 texel) +{ + return gtaoUnpackEdges(texelFetch(sourceEdges, clamp(texel, ivec2(0), imageExtent - 1), 0).r); +} + +void main() +{ + imageExtent = imageSize(outTerm); + ivec2 pix = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(pix, imageExtent))) return; + + float scale = FINAL_APPLY != 0u ? GTAO_OCCLUSION_TERM_SCALE : 1.0; + if (gtaoIsSky(texelFetch(sourceDepth, pix, 0).r) || gtaoIsHand(texelFetch(gNormal, pix, 0).w)) + { + imageStore(outTerm, pix, vec4(scale / GTAO_OCCLUSION_TERM_SCALE)); + return; + } + + float blurAmount = FINAL_APPLY != 0u ? gtao.denoiseBlurBeta : gtao.denoiseBlurBeta / 5.0; + const float diagWeight = 0.85 * 0.5; + + // L, R, T, B are -x, +x, +y, -y: the same convention the main pass's edges use. + vec4 edgesL = edgesAt(pix + ivec2(-1, 0)); + vec4 edgesR = edgesAt(pix + ivec2(1, 0)); + vec4 edgesT = edgesAt(pix + ivec2(0, 1)); + vec4 edgesB = edgesAt(pix + ivec2(0, -1)); + vec4 edgesC = edgesAt(pix); + + // Edges are not perfectly symmetric; this enforces it ("Works real nice with TAA"). + edgesC *= vec4(edgesL.y, edgesR.x, edgesT.w, edgesB.z); + + // Some leaking from neighbours when 3 or 4 edges are set, against spatial and temporal aliasing. + const float leakThreshold = 2.5; + const float leakStrength = 0.5; + float edginess = (clamp(4.0 - leakThreshold - dot(edgesC, vec4(1.0)), 0.0, 1.0) / (4.0 - leakThreshold)) * leakStrength; + edgesC = clamp(edgesC + edginess, 0.0, 1.0); + + float weightTL = diagWeight * (edgesC.x * edgesL.z + edgesC.z * edgesT.x); + float weightTR = diagWeight * (edgesC.z * edgesT.y + edgesC.y * edgesR.z); + float weightBL = diagWeight * (edgesC.w * edgesB.x + edgesC.x * edgesL.w); + float weightBR = diagWeight * (edgesC.y * edgesR.w + edgesC.w * edgesB.y); + + float sumWeight = blurAmount; + float sum = termAt(pix) * sumWeight; + sum += termAt(pix + ivec2(-1, 0)) * edgesC.x; sumWeight += edgesC.x; + sum += termAt(pix + ivec2(1, 0)) * edgesC.y; sumWeight += edgesC.y; + sum += termAt(pix + ivec2(0, 1)) * edgesC.z; sumWeight += edgesC.z; + sum += termAt(pix + ivec2(0, -1)) * edgesC.w; sumWeight += edgesC.w; + sum += termAt(pix + ivec2(-1, 1)) * weightTL; sumWeight += weightTL; + sum += termAt(pix + ivec2(1, 1)) * weightTR; sumWeight += weightTR; + sum += termAt(pix + ivec2(-1, -1)) * weightBL; sumWeight += weightBL; + sum += termAt(pix + ivec2(1, -1)) * weightBR; sumWeight += weightBR; + + float denoised = sum / sumWeight; + imageStore(outTerm, pix, vec4(clamp(denoised * scale, 0.0, 1.0))); +} diff --git a/sources/shaders-vk/gtao/main.comp b/sources/shaders-vk/gtao/main.comp new file mode 100644 index 00000000..71a6ae4b --- /dev/null +++ b/sources/shaders-vk/gtao/main.comp @@ -0,0 +1,325 @@ +#version 450 +// Optimum ambient occlusion, pass 2 of 3: the visibility term and the packed edges +// (docs/research/ambient-occlusion.md C.2-C.10). +// +// The slice and step scaffold (R1 steps, s^2 + minS, pixel snapping, the working depth +// levels, the edges and the noise) and the HORIZON_GTAO integration are ported from +// XeGTAO's XeGTAO_MainPass: +// +// Copyright (C) 2016-2021, Intel Corporation +// SPDX-License-Identifier: MIT +// https://github.com/GameTechDev/XeGTAO +// +// The visibility bitmask follows Bevy's ssao.wesl (Therrien, Levesque, Gilet 2023, +// Algorithm 1): +// +// Copyright (c) 2020 Carter Anderson +// SPDX-License-Identifier: MIT OR Apache-2.0 +// https://github.com/bevyengine/bevy +// +// The optional normal-based edge factor is Intel ASSAO's, as carried by Godot: +// +// Copyright (c) 2016, Intel Corporation +// 2020-12-05: clayjohn: convert to Vulkan and Godot +// +// All three under the MIT licence: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// What differs from those sources, and why (section C of the design): +// - 32 sectors (the paper's banding threshold), not Bevy's "sectors = samples per slice". +// - Bits set with the round criterion (a sector counts when its centre is covered), not ceil. +// - BITMASK_COS distributes the sector boundaries by the cosine CDF, (1 + sin(phi)) / 2 on +// the normal-centred slice, so plain bit counting is cosine-weighted (C.4). Evaluated +// from the horizon cosines without acos: sin(phi) = sin(theta) cos(n) - cos(theta) sin(n). +// - Slice visibilities are averaged with the projected-normal length as the weight (GTAO +// eq. 8), so grazing slices count less and an unoccluded surface is exactly 1. +// - Thickness grows with view distance and is randomised per sample (C.5), with a thin +// thickness for samples on the thin class of gNormal.w. +// - No falloff inside the bitmask (C.6); XeGTAO's small-screen-radius fade and a far fade. +// - Sky and the hand view write visibility 1 and skip the loop (C.9). +// - No FinalValuePower by default (1.0, C.11) and no floor or contrast: the term is the +// visibility, clamped to max(0.03, v). + +layout(local_size_x = 8, local_size_y = 8) in; + +#include "common.glsl" + +layout(constant_id = GTAO_SPEC_INTEGRATION) const uint INTEGRATION = 0u; +layout(constant_id = GTAO_SPEC_SLICE_COUNT) const uint SLICE_COUNT = 2u; +layout(constant_id = GTAO_SPEC_STEPS_PER_SLICE) const uint STEPS_PER_SLICE = 2u; +layout(constant_id = GTAO_SPEC_THICKNESS) const uint THICKNESS = 2u; +layout(constant_id = GTAO_SPEC_CLASS_CHANNEL) const uint CLASS_CHANNEL = 1u; +layout(constant_id = GTAO_SPEC_NOISE_CYCLE) const uint NOISE_CYCLE = 64u; +layout(constant_id = GTAO_SPEC_NORMAL_EDGES) const uint NORMAL_EDGES = 0u; + +layout(binding = 0) uniform sampler2D workingDepth; // five levels, nearest, nearest mip +layout(binding = 1) uniform sampler2D sourceDepth; // Primary depth: the sky test +layout(binding = 2) uniform sampler2D gNormal; // Primary colour 2: GL view-space normal, class in w +layout(binding = 3) uniform sampler2D hilbertLut; // 64x64, the index in r +layout(binding = 4, GTAO_TERM_FORMAT) uniform writeonly image2D outWorkingTerm; +layout(binding = 5, GTAO_TERM_FORMAT) uniform writeonly image2D outEdges; + +ivec2 imageExtent; + +float workingDepthAt(ivec2 texel) +{ + return texelFetch(workingDepth, clamp(texel, ivec2(0), imageExtent - 1), 0).r; +} + +vec3 normalAt(ivec2 texel) +{ + vec3 n = texelFetch(gNormal, clamp(texel, ivec2(0), imageExtent - 1), 0).xyz; + float len = length(n); + return len > 1e-6 ? n / len : vec3(0.0, 0.0, 1.0); +} + +// Where a horizon direction falls on the normal-centred slice, in [0, 1] sector units. +// side is +1 for samples along the slice direction and -1 against it; horizonCos is +// the cosine of the direction's angle to the view vector; (sinN, cosN) the signed +// projected-normal angle. A direction past the hemisphere edge clamps to that side's end. +float sectorCoordinate(float horizonCos, float side, float sinN, float cosN) +{ + float sinTheta = side * sqrt(max(0.0, 1.0 - horizonCos * horizonCos)); + float sinPhi = sinTheta * cosN - horizonCos * sinN; + float cosPhi = horizonCos * cosN + sinTheta * sinN; + if (cosPhi < 0.0) return side > 0.0 ? 1.0 : 0.0; + if (INTEGRATION == GTAO_INTEGRATION_BITMASK_COS) return 0.5 + 0.5 * sinPhi; + return 0.5 + atan(sinPhi, cosPhi) / GTAO_PI; +} + +// The sectors whose centres lie in [low, high]. +uint sectorBits(float low, float high) +{ + uint start = uint(floor(clamp(low, 0.0, 1.0) * float(GTAO_SECTORS) + 0.5)); + uint end = uint(floor(clamp(high, 0.0, 1.0) * float(GTAO_SECTORS) + 0.5)); + if (end <= start) return 0u; + uint count = end - start; + uint mask = count >= GTAO_SECTORS ? 0xFFFFFFFFu : ((1u << count) - 1u); + return mask << start; +} + +void main() +{ + imageExtent = imageSize(outWorkingTerm); + ivec2 pix = ivec2(gl_GlobalInvocationID.xy); + if (any(greaterThanEqual(pix, imageExtent))) return; + + vec2 pixelSize = 1.0 / vec2(imageExtent); + vec2 normalizedScreenPos = (vec2(pix) + 0.5) * pixelSize; + float surfaceClass = texelFetch(gNormal, pix, 0).w; + + if (gtaoIsSky(texelFetch(sourceDepth, pix, 0).r) || gtaoIsHand(surfaceClass)) + { + // Nothing to occlude (sky), or a projection the world reconstruction does not + // describe (the hand view). Edges fully cut, so the denoise never mixes them in. + imageStore(outWorkingTerm, pix, vec4(1.0 / GTAO_OCCLUSION_TERM_SCALE)); + imageStore(outEdges, pix, vec4(0.0)); + return; + } + + float viewspaceZ = workingDepthAt(pix); + float pixLZ = workingDepthAt(pix + ivec2(-1, 0)); + float pixRZ = workingDepthAt(pix + ivec2(1, 0)); + float pixTZ = workingDepthAt(pix + ivec2(0, 1)); + float pixBZ = workingDepthAt(pix + ivec2(0, -1)); + + vec4 edgesLRTB = gtaoCalculateEdges(viewspaceZ, pixLZ, pixRZ, pixTZ, pixBZ); + vec3 glNormal = normalAt(pix); + if (NORMAL_EDGES != 0u) + { + // ASSAO's SSAO_NORMAL_BASED_EDGES_DOT_THRESHOLD 0.5; neighbours one texel away at full resolution. + edgesLRTB *= clamp(vec4(dot(glNormal, normalAt(pix + ivec2(-1, 0))), dot(glNormal, normalAt(pix + ivec2(1, 0))), + dot(glNormal, normalAt(pix + ivec2(0, 1))), dot(glNormal, normalAt(pix + ivec2(0, -1)))) + 0.5, + 0.0, 1.0); + } + imageStore(outEdges, pix, vec4(gtaoPackEdges(edgesLRTB))); + + // Move the centre slightly towards the camera against depth imprecision (fp32 working depth). + viewspaceZ *= 0.99999; + + vec3 pixCenterPos = gtaoViewPosition(normalizedScreenPos, viewspaceZ); + vec3 viewVec = normalize(-pixCenterPos); + + // GL view space has z toward the viewer; the working frame has z forward (C.10). + vec3 viewspaceNormal = glNormal * vec3(1.0, 1.0, -1.0); + // Double-sided foliage stores the face's normal whichever side is seen (C.9). + if (dot(viewspaceNormal, viewVec) < 0.0) viewspaceNormal = -viewspaceNormal; + + float effectRadius = gtao.effectRadius * gtao.radiusMultiplier; + float falloffRange = gtao.effectFalloffRange * effectRadius; + float falloffFrom = effectRadius * (1.0 - gtao.effectFalloffRange); + float falloffMul = -1.0 / falloffRange; + float falloffAdd = falloffFrom / falloffRange + 1.0; + + uint hilbertIndex = uint(texelFetch(hilbertLut, pix % 64, 0).r + 0.5); + vec2 localNoise = gtaoNoise(hilbertIndex, gtao.noiseIndex, NOISE_CYCLE); + float noiseSlice = localNoise.x; + float noiseSample = localNoise.y; + + const float pixelTooCloseThreshold = 1.3; + float pixelViewspaceSizeX = viewspaceZ * gtao.ndcToViewMulX * pixelSize.x; + float screenspaceRadius = effectRadius / pixelViewspaceSizeX; + + float sliceCount = float(SLICE_COUNT); + float stepsPerSlice = float(STEPS_PER_SLICE); + + // Fade out for small screen radii (XeGTAO); divided by the slice count below as XeGTAO does. + float smallRadiusFade = clamp((10.0 - screenspaceRadius) / 100.0, 0.0, 1.0) * 0.5; + float minS = pixelTooCloseThreshold / screenspaceRadius; + + float thicknessScale = 1.0; + if (THICKNESS != GTAO_THICKNESS_CONST) thicknessScale += gtao.thicknessDistanceScale * viewspaceZ; + + float horizonVisibility = 0.0; + float bitmaskVisibility = 0.0; + float bitmaskWeight = 0.0; + + for (uint sliceIndex = 0u; sliceIndex < SLICE_COUNT; sliceIndex++) + { + float slice = float(sliceIndex); + float sliceK = (slice + noiseSlice) / sliceCount; + float phi = sliceK * GTAO_PI; + float cosPhi = cos(phi); + float sinPhi = sin(phi); + // Texel rows run up (GL order), the same way view y does, so the screen + // direction and the view direction share their sign. + vec2 omega = vec2(cosPhi, sinPhi) * screenspaceRadius; + vec3 directionVec = vec3(cosPhi, sinPhi, 0.0); + vec3 orthoDirectionVec = directionVec - dot(directionVec, viewVec) * viewVec; + vec3 axisVec = normalize(cross(orthoDirectionVec, viewVec)); + vec3 projectedNormalVec = viewspaceNormal - axisVec * dot(viewspaceNormal, axisVec); + float signNorm = dot(orthoDirectionVec, projectedNormalVec) < 0.0 ? -1.0 : 1.0; + float projectedNormalVecLength = max(length(projectedNormalVec), 1e-6); + float cosNorm = clamp(dot(projectedNormalVec, viewVec) / projectedNormalVecLength, 0.0, 1.0); + + if (INTEGRATION == GTAO_INTEGRATION_HORIZON) + { + float n = signNorm * gtaoFastACos(cosNorm); + float lowHorizonCos0 = cos(n + GTAO_PI_HALF); + float lowHorizonCos1 = cos(n - GTAO_PI_HALF); + float horizonCos0 = lowHorizonCos0; + float horizonCos1 = lowHorizonCos1; + + for (uint stepIndex = 0u; stepIndex < STEPS_PER_SLICE; stepIndex++) + { + float stepBaseNoise = (slice + float(stepIndex) * stepsPerSlice) * 0.6180339887498948482; + float stepNoise = fract(noiseSample + stepBaseNoise); + float s = (float(stepIndex) + stepNoise) / stepsPerSlice; + s = pow(s, gtao.sampleDistributionPower); + s += minS; + vec2 sampleOffset = s * omega; + float mipLevel = clamp(log2(length(sampleOffset)) - gtao.depthMipSamplingOffset, 0.0, GTAO_DEPTH_MIP_LEVELS); + sampleOffset = floor(sampleOffset + 0.5) * pixelSize; + + vec2 sampleScreenPos0 = normalizedScreenPos + sampleOffset; + vec3 samplePos0 = gtaoViewPosition(sampleScreenPos0, textureLod(workingDepth, sampleScreenPos0, mipLevel).r); + vec2 sampleScreenPos1 = normalizedScreenPos - sampleOffset; + vec3 samplePos1 = gtaoViewPosition(sampleScreenPos1, textureLod(workingDepth, sampleScreenPos1, mipLevel).r); + + vec3 sampleDelta0 = samplePos0 - pixCenterPos; + vec3 sampleDelta1 = samplePos1 - pixCenterPos; + float sampleDist0 = length(sampleDelta0); + float sampleDist1 = length(sampleDelta1); + vec3 sampleHorizonVec0 = sampleDelta0 / sampleDist0; + vec3 sampleHorizonVec1 = sampleDelta1 / sampleDist1; + + float weight0 = clamp(sampleDist0 * falloffMul + falloffAdd, 0.0, 1.0); + float weight1 = clamp(sampleDist1 * falloffMul + falloffAdd, 0.0, 1.0); + float shc0 = mix(lowHorizonCos0, dot(sampleHorizonVec0, viewVec), weight0); + float shc1 = mix(lowHorizonCos1, dot(sampleHorizonVec1, viewVec), weight1); + horizonCos0 = max(horizonCos0, shc0); + horizonCos1 = max(horizonCos1, shc1); + } + + // XeGTAO's slope fudge, kept only on this path for parity with its numbers (C.11). + projectedNormalVecLength = mix(projectedNormalVecLength, 1.0, 0.05); + float h0 = -gtaoFastACos(horizonCos1); + float h1 = gtaoFastACos(horizonCos0); + float iarc0 = (cosNorm + 2.0 * h0 * sin(n) - cos(2.0 * h0 - n)) / 4.0; + float iarc1 = (cosNorm + 2.0 * h1 * sin(n) - cos(2.0 * h1 - n)) / 4.0; + horizonVisibility += projectedNormalVecLength * (iarc0 + iarc1); + } + else + { + float cosN = cosNorm; + float sinN = signNorm * sqrt(max(0.0, 1.0 - cosNorm * cosNorm)); + uint occluded = 0u; + + for (uint stepIndex = 0u; stepIndex < STEPS_PER_SLICE; stepIndex++) + { + float stepBaseNoise = (slice + float(stepIndex) * stepsPerSlice) * 0.6180339887498948482; + float stepNoise = fract(noiseSample + stepBaseNoise); + float s = (float(stepIndex) + stepNoise) / stepsPerSlice; + s = pow(s, gtao.sampleDistributionPower); + s += minS; + vec2 sampleOffset = s * omega; + float mipLevel = clamp(log2(length(sampleOffset)) - gtao.depthMipSamplingOffset, 0.0, GTAO_DEPTH_MIP_LEVELS); + vec2 texelOffset = floor(sampleOffset + 0.5); + sampleOffset = texelOffset * pixelSize; + + // Randomised thickness (Bottosson): the binary mask would pop as a gap + // opens; a dithered thickness converges to a soft edge through TAA. + float sampleThicknessScale = thicknessScale; + if (THICKNESS == GTAO_THICKNESS_RANDOM) sampleThicknessScale *= 0.5 + fract(stepNoise * 7.0); + + for (int sideIndex = 0; sideIndex < 2; sideIndex++) + { + float side = sideIndex == 0 ? 1.0 : -1.0; + vec2 sampleScreenPos = normalizedScreenPos + side * sampleOffset; + float sampleZ = textureLod(workingDepth, sampleScreenPos, mipLevel).r; + vec3 sampleDelta = gtaoViewPosition(sampleScreenPos, sampleZ) - pixCenterPos; + + float sampleThickness = gtao.thickness; + if (CLASS_CHANNEL != 0u) + { + ivec2 sampleTexel = clamp(pix + ivec2(side * texelOffset), ivec2(0), imageExtent - 1); + if (gtaoIsThin(texelFetch(gNormal, sampleTexel, 0).w)) sampleThickness = gtao.thicknessThin; + } + sampleThickness *= sampleThicknessScale; + + vec3 sampleDeltaBack = sampleDelta - viewVec * sampleThickness; + float frontCos = dot(normalize(sampleDelta), viewVec); + float backCos = dot(normalize(sampleDeltaBack), viewVec); + float front = sectorCoordinate(frontCos, side, sinN, cosN); + float back = sectorCoordinate(backCos, side, sinN, cosN); + occluded |= sectorBits(min(front, back), max(front, back)); + } + } + + float sliceVisibility = 1.0 - float(bitCount(occluded)) / float(GTAO_SECTORS); + bitmaskVisibility += projectedNormalVecLength * sliceVisibility; + bitmaskWeight += projectedNormalVecLength; + } + } + + float visibility = INTEGRATION == GTAO_INTEGRATION_HORIZON + ? (smallRadiusFade + horizonVisibility) / sliceCount + : smallRadiusFade / sliceCount + bitmaskVisibility / max(bitmaskWeight, 1e-6); + + visibility = pow(max(visibility, 0.0), gtao.finalValuePower); + // A visible pixel cannot have zero visibility; it also guards the packing (XeGTAO). + visibility = max(0.03, visibility); + + // Distant sub-pixel geometry is not darkened (vanilla ssao.fsh's fade numbers, C.6). + float farFade = clamp(gtao.farFadeBias - viewspaceZ * gtao.farFadeScale, 0.0, 1.0); + visibility = mix(1.0, visibility, farFade); + + imageStore(outWorkingTerm, pix, vec4(clamp(visibility / GTAO_OCCLUSION_TERM_SCALE, 0.0, 1.0))); +} diff --git a/sources/shaders-vk/gtao/prefilter.comp b/sources/shaders-vk/gtao/prefilter.comp new file mode 100644 index 00000000..a98b6c7e --- /dev/null +++ b/sources/shaders-vk/gtao/prefilter.comp @@ -0,0 +1,137 @@ +#version 450 +// Optimum ambient occlusion, pass 1 of 3: the working depth (docs/research/ambient-occlusion.md C.1). +// +// Linearises the D32 depth of Primary into view-space depth (R32F; an fp16 position +// has 0.125-0.5 block steps at 200-500 blocks, fp32 does not) and builds five levels +// with XeGTAO's weighted-average filter, in one dispatch. +// +// Ported from XeGTAO's XeGTAO_PrefilterDepths16x16: +// +// Copyright (C) 2016-2021, Intel Corporation +// SPDX-License-Identifier: MIT +// https://github.com/GameTechDev/XeGTAO +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Bevy's preprocess_depth.wesl (MIT OR Apache-2.0) keeps the same one-dispatch shape. +// +// Porting note. XeGTAO shares the level-1..3 values between the 64 invocations of a +// work group (groupshared memory plus GroupMemoryBarrierWithGroupSync). In SPIR-V, +// Workgroup memory has no defined ordering between invocations (OpControlBarrier is +// not a synchronisation point a Vulkan driver has to honour), so reading a value +// another invocation writes is a data race. Here one invocation owns one 16x16 block of +// level 0 (one texel of level 4) and keeps the levels in invocation-local arrays: the +// same filter, the same single dispatch, and the result is deterministic. + +layout(local_size_x = 8, local_size_y = 8) in; + +#include "common.glsl" + +layout(binding = 0) uniform sampler2D sourceDepth; +layout(binding = 1, GTAO_DEPTH_FORMAT) uniform writeonly image2D outDepth0; +layout(binding = 2, GTAO_DEPTH_FORMAT) uniform writeonly image2D outDepth1; +layout(binding = 3, GTAO_DEPTH_FORMAT) uniform writeonly image2D outDepth2; +layout(binding = 4, GTAO_DEPTH_FORMAT) uniform writeonly image2D outDepth3; +layout(binding = 5, GTAO_DEPTH_FORMAT) uniform writeonly image2D outDepth4; + +ivec2 sourceSize; + +float viewDepthAt(ivec2 texel) +{ + return gtaoViewDepth(texelFetch(sourceDepth, clamp(texel, ivec2(0), sourceSize - 1), 0).r); +} + +void main() +{ + sourceSize = textureSize(sourceDepth, 0); + ivec2 block = ivec2(gl_GlobalInvocationID.xy); + ivec2 base = block * 16; + if (any(greaterThanEqual(base, sourceSize))) return; + + ivec2 size0 = imageSize(outDepth0); + ivec2 size1 = imageSize(outDepth1); + ivec2 size2 = imageSize(outDepth2); + ivec2 size3 = imageSize(outDepth3); + ivec2 size4 = imageSize(outDepth4); + + float level1[64]; + float level2[16]; + float level3[4]; + + // Levels 0 and 1: the XeGTAO per-invocation work, 64 times. + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + ivec2 texel1 = base / 2 + ivec2(x, y); + ivec2 texel0 = texel1 * 2; + float depth0 = viewDepthAt(texel0); + float depth1 = viewDepthAt(texel0 + ivec2(1, 0)); + float depth2 = viewDepthAt(texel0 + ivec2(0, 1)); + float depth3 = viewDepthAt(texel0 + ivec2(1, 1)); + if (all(lessThan(texel0 + ivec2(1, 1), size0))) + { + imageStore(outDepth0, texel0, vec4(depth0)); + imageStore(outDepth0, texel0 + ivec2(1, 0), vec4(depth1)); + imageStore(outDepth0, texel0 + ivec2(0, 1), vec4(depth2)); + imageStore(outDepth0, texel0 + ivec2(1, 1), vec4(depth3)); + } + else + { + // An odd-sized edge: store only the texels inside level 0. + if (all(lessThan(texel0, size0))) imageStore(outDepth0, texel0, vec4(depth0)); + if (all(lessThan(texel0 + ivec2(1, 0), size0))) imageStore(outDepth0, texel0 + ivec2(1, 0), vec4(depth1)); + if (all(lessThan(texel0 + ivec2(0, 1), size0))) imageStore(outDepth0, texel0 + ivec2(0, 1), vec4(depth2)); + } + float filtered = gtaoDepthMipFilter(depth0, depth1, depth2, depth3); + level1[y * 8 + x] = filtered; + if (all(lessThan(texel1, size1))) imageStore(outDepth1, texel1, vec4(filtered)); + } + } + + // Level 2. + for (int y = 0; y < 4; y++) + { + for (int x = 0; x < 4; x++) + { + int i = (y * 2) * 8 + x * 2; + float filtered = gtaoDepthMipFilter(level1[i], level1[i + 1], level1[i + 8], level1[i + 9]); + level2[y * 4 + x] = filtered; + ivec2 texel2 = base / 4 + ivec2(x, y); + if (all(lessThan(texel2, size2))) imageStore(outDepth2, texel2, vec4(filtered)); + } + } + + // Level 3. + for (int y = 0; y < 2; y++) + { + for (int x = 0; x < 2; x++) + { + int i = (y * 2) * 4 + x * 2; + float filtered = gtaoDepthMipFilter(level2[i], level2[i + 1], level2[i + 4], level2[i + 5]); + level3[y * 2 + x] = filtered; + ivec2 texel3 = base / 8 + ivec2(x, y); + if (all(lessThan(texel3, size3))) imageStore(outDepth3, texel3, vec4(filtered)); + } + } + + // Level 4. + float filtered4 = gtaoDepthMipFilter(level3[0], level3[1], level3[2], level3[3]); + if (all(lessThan(block, size4))) imageStore(outDepth4, block, vec4(filtered4)); +} diff --git a/sources/shaders/chunkopaque.fsh b/sources/shaders/chunkopaque.fsh index 3d1f29ad..a4327cd3 100644 --- a/sources/shaders/chunkopaque.fsh +++ b/sources/shaders/chunkopaque.fsh @@ -148,6 +148,11 @@ void main() #if SSAOLEVEL > 0 outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); outGNormal = gnormal; +#if OPTIMUMAO > 0 + // Optimum AO class channel (docs/research/ambient-occlusion.md C.5): plants, grass and + // cross-quad blocks draw in the no-cull opaque pass (haxyFade), and are thin like leaves. + if (haxyFade > 0) outGNormal.w = 1.0; +#endif #endif #if NORMALVIEW > 0 @@ -211,6 +216,10 @@ void main() #if SSAOLEVEL > 0 outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); outGNormal = gnormal; +#if OPTIMUMAO > 0 + // Optimum AO class channel (C.5): the no-cull opaque pass (plants, grass, cross-quads) is thin. + if (haxyFade > 0) outGNormal.w = 1.0; +#endif #endif #if NORMALVIEW > 0 diff --git a/sources/shaders/entityanimated.fsh b/sources/shaders/entityanimated.fsh index c2229778..811122c6 100644 --- a/sources/shaders/entityanimated.fsh +++ b/sources/shaders/entityanimated.fsh @@ -166,6 +166,10 @@ void main() { // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it #if USEOIT==0 && SSAOLEVEL > 0 outGPosition.w=1; + #if OPTIMUMAO > 0 + // Optimum AO class channel (C.5, C.9): the first-person hand writes the hand class. + outGNormal.w = -1.0; + #endif #endif #endif diff --git a/sources/shaders/scene-ssao.fsh b/sources/shaders/scene-ssao.fsh index 6c68d64f..549140be 100644 --- a/sources/shaders/scene-ssao.fsh +++ b/sources/shaders/scene-ssao.fsh @@ -1,14 +1,56 @@ #version 330 core uniform sampler2D ssaoScene; uniform float invRenderHeight; +#if OPTIMUMAO > 0 +// Optimum AO (docs/research/ambient-occlusion.md C.9, C.11): with GTAO the AO texture is the +// pure visibility at render resolution, so the water, fog and OIT attenuation vanilla SSAO +// applies inside its own pass is applied here instead. +uniform sampler2D gPositionScene; +uniform sampler2D revealageScene; +// 1: ssaoScene is the GTAO visibility; 0: vanilla SSAO's blurred result (a frame GTAO stood down). +uniform int optimumAoMode; +#endif +#if OPTIMUMAO_MULTIBOUNCE > 0 +// The albedo hook (C.11): GTAO 2016 eq. 10 needs the surface albedo, which the lit LDR scene +// colour is not. Never stamped in the first version; the platform refuses the tone without +// an albedo texture (GtaoSettings.EffectiveTone). +uniform sampler2D aoAlbedo; +float optimumMultiBounce(float visibility, vec3 albedo) +{ + vec3 a = 2.0404 * albedo - 0.3324; + vec3 b = -4.7951 * albedo + 0.6417; + vec3 c = 2.7552 * albedo + 0.6903; + vec3 bounced = max(vec3(visibility), ((visibility * a + b) * visibility + c) * visibility); + // The Multiply blend carries one factor; a coloured term needs a colour multiply blend. + return dot(bounced, vec3(0.2126, 0.7152, 0.0722)); +} +#endif in vec2 texCoord; layout(location = 0) out vec4 outColor; void main() { float ao = texture(ssaoScene, texCoord).r; +#if OPTIMUMAO > 0 + if (optimumAoMode == 1) + { + // Same resolution as Primary: nearest texel, no upsample and no min-of-two-rows. + ivec2 texel = ivec2(gl_FragCoord.xy); + ao = texelFetch(ssaoScene, texel, 0).r; + #if OPTIMUMAO_MULTIBOUNCE > 0 + ao = optimumMultiBounce(ao, texelFetch(aoAlbedo, texel, 0).rgb); + #endif + // vanilla ssao.fsh: attenuate = gPosition.w + 0.75 * (1 - revealage), occ = 1 - (1 - ao) * (1 - attenuate) + float attenuate = texelFetch(gPositionScene, texel, 0).w + + max(0.0, 1.0 - texelFetch(revealageScene, texel, 0).r) * 0.75; + ao = 1.0 - (1.0 - ao) * (1.0 - attenuate); + } + else +#endif + { #if SSAOLEVEL > 1 ao = min(ao, texture(ssaoScene, texCoord - vec2(0.0, invRenderHeight)).r); #endif + } // EnumBlendMode.Multiply: dstRGB * (1 - srcAlpha). RGB is not read. outColor = vec4(0.0, 0.0, 0.0, 1.0 - clamp(ao, 0.0, 1.0)); } diff --git a/sources/shaders/standard.fsh b/sources/shaders/standard.fsh index a12f2e94..c9cee816 100644 --- a/sources/shaders/standard.fsh +++ b/sources/shaders/standard.fsh @@ -167,6 +167,11 @@ void main() { // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it #if SSAOLEVEL > 0 outGPosition.w=1; + #if OPTIMUMAO > 0 + // Optimum AO class channel (C.5, C.9): the hand view has its own projection; the AO pass + // leaves these pixels at visibility 1 and treats them as solid when sampled. + outGNormal.w = -1.0; + #endif #endif #endif #endif From 482d819dc17abdcf505ecbeff7c3e6fbad36724d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:13:24 +0200 Subject: [PATCH 174/226] wip(native-shaders): runtime seam - manifest lookup at LinkProgram, 3 native programs (blit, final, luma), per-program rewriter fallback Loads shaders.manifest.json once at device start (schema and toolchain checked; OPTIMUM_VK_SHADER_SOURCE builds a source tree through NativeShaderBuilder; OPTIMUM_VK_NATIVE_SHADERS=0 forces the rewriter). LinkProgram keys the program by PassName and its prefix defines, verifies SPIR-V hashes lazily, builds the placement table (frame, record, push and sampler locations), seeds GLSL 330 initializers and passes specialization constants to every pipeline stage. A native program that cannot be served links through the rewriter and counts as failed. Logs [Optimum] shaders: N native, M rewritten, K failed and publishes stats.shaders. Verified: Optimum.Render.Vulkan.Tests 890/890 with sync,best validation and implicit layers disabled, no SYNC- messages; native vs rewriter pixels for blit, final and luma bitwise equal under five FXAA/BLOOM/SSAO/godrays combinations; corrupted-hash fallback and forced-rewriter tests clean; Optimum.Tests 1184 passed, 34 skipped. Not run in game. --- .../NativeShaderRuntimeTests.cs | 583 ++++++++++++++++++ .../PacingStatsTests.cs | 4 +- Optimum.Render.Vulkan/Core/PipelineCache.cs | 33 + .../Core/ShaderProgramResources.cs | 59 +- Optimum.Render.Vulkan/Core/VulkanStats.cs | 23 +- .../Shaders/NativeShaderLibrary.cs | 496 +++++++++++++++ .../Shaders/ProgramInterfaceLayout.Native.cs | 241 ++++++++ .../Shaders/ProgramInterfaceLayout.cs | 2 +- .../Shaders/ShaderTranslator.cs | 10 + Optimum.Render.Vulkan/VulkanDevice.cs | 174 +++++- docs/vulkan-native-shaders.md | 58 ++ 11 files changed, 1662 insertions(+), 21 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs create mode 100644 Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs create mode 100644 Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.Native.cs diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs new file mode 100644 index 00000000..bad9e0df --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs @@ -0,0 +1,583 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Xunit; +using Xunit.Abstractions; +using static Optimum.Render.Vulkan.Tests.GpuTest; +using TestProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using TestShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The native shader runtime seam (docs/vulkan-native-shaders.md section 8): the manifest looked up at +/// VulkanDevice.LinkProgram by pass name and variant key, programs built from its SPIR-V with the +/// shared layout, specialization constants from the prefix, GLSL 330 initializers seeded, and a per-program +/// fall back to the rewriter. +/// +public sealed class NativeShaderRuntimeTests +{ + private const int Size = 16; + private static readonly string[] FamilyOne = { "blit", "final", "luma" }; + + private readonly ITestOutputHelper _output; + + public NativeShaderRuntimeTests(ITestOutputHelper output) => _output = output; + + // ------------------------------------------------------------------ variant keys + + private static NativeProgram AllAxes() => new() { Name = "every-axis", Axes = NativeShaderBuilder.VariantAxes.ToList() }; + + private static Dictionary DefinesOf(ShaderCorpus.ShaderVariant variant) => + NativeShaderLibrary.ParseDefines(new[] + { + ShaderCorpus.PrefixFor(EnumShaderType.VertexShader, variant), + ShaderCorpus.PrefixFor(EnumShaderType.FragmentShader, variant), + }); + + /// Every corpus variant keys as its settings say: GBUFFER from SSAOLEVEL > 0, the rest from their defines. + [Fact] + public void TheVariantKeyOfEveryCorpusVariantFollowsItsSettings() + { + foreach (ShaderCorpus.ShaderVariant variant in ShaderCorpus.Variants()) + { + string? key = NativeShaderLibrary.VariantKeyFor(AllAxes(), DefinesOf(variant), out string error); + Assert.True(key != null, variant.Name + ": " + error); + + var expected = new Dictionary + { + ["ALLOWDEPTHOFFSET"] = 0, + ["GBUFFER"] = variant.SsaoLevel > 0 ? 1 : 0, + ["GLOWSUB"] = 0, + ["GREEDYMESH"] = variant.GreedyMesh, + ["TAAMOTION"] = variant.TaaMotion, + ["USEOIT"] = variant.UseOit, + ["USESSBO"] = variant.UseSsbo, + ["VEC3SCALE"] = 0, + }; + Assert.Equal(NativeShaderManifest.VariantKey(expected.Keys, expected), key); + } + } + + /// + /// The runtime key is the inverse of the parity harness's mapping from a key back to ShaderRegistry's prefix: + /// every combination of every axis, on every harness base, comes back as the key it started from. + /// + [Fact] + public void EveryAxisCombinationRoundTripsThroughThePrefixOnEveryParityBase() + { + string[] axes = NativeShaderBuilder.VariantAxes; + foreach (string baseName in new[] { "everything-off", "everything-on", "taa-with-ssao" }) + { + for (int combination = 0; combination < 1 << axes.Length; combination++) + { + var values = new Dictionary(StringComparer.Ordinal); + for (int a = 0; a < axes.Length; a++) values[axes[a]] = (combination >> a) & 1; + string key = NativeShaderManifest.VariantKey(axes, values); + + ShaderCorpus.ShaderVariant variant = NativeShaderParityTests.VariantFor(baseName, values); + Assert.Equal(key, NativeShaderLibrary.VariantKeyFor(AllAxes(), DefinesOf(variant), out _)); + } + } + } + + /// Every variant the tree's manifest holds is the one the runtime asks for under the prefix that variant stands for. + [SkippableFact] + public void EveryManifestVariantKeyIsTheKeyItsPrefixProduces() + { + NativeShaderBuildResult build = RequireTreeBuild(); + int checkedKeys = 0; + foreach (NativeProgram program in build.Manifest.Programs) + { + foreach (NativeVariant variant in program.Variants) + { + foreach (string baseName in new[] { "everything-off", "everything-on", "taa-with-ssao" }) + { + ShaderCorpus.ShaderVariant corpus = NativeShaderParityTests.VariantFor(baseName, NativeShaderParityTests.ParseKey(variant.Key)); + string? key = NativeShaderLibrary.VariantKeyFor(program, DefinesOf(corpus), out string error); + Assert.True(key == variant.Key, program.Name + " [" + variant.Key + "] on " + baseName + ": got '" + key + "' " + error); + checkedKeys++; + } + } + } + Assert.True(checkedKeys > 0); + } + + [Fact] + public void AnAxisValueOutsideZeroAndOneHasNoKey() + { + var defines = NativeShaderLibrary.ParseDefines(new[] { "#define TAAMOTION 2\r\n" }); + Assert.Null(NativeShaderLibrary.VariantKeyFor(new NativeProgram { Axes = { "TAAMOTION" } }, defines, out string error)); + Assert.Contains("TAAMOTION", error); + } + + /// Each specialization constant takes the value ShaderRegistry stamps into the define it replaces. + [Fact] + public void SpecializationConstantsTakeTheirDefinesValues() + { + var variant = new NativeVariant(); + foreach (SpecializationConvention.Constant constant in SpecializationConvention.Constants) + { + variant.SpecializationConstants.Add(new NativeSpecConstant { Id = (int)constant.Id, Name = constant.Name, Type = constant.GlslType }); + } + + foreach (ShaderCorpus.ShaderVariant corpus in ShaderCorpus.Variants()) + { + Assert.True(NativeShaderLibrary.TryBuildSpecialization(variant, DefinesOf(corpus), out NativeSpecialization specialization, out string error), error); + var expected = new Dictionary + { + ["OPTIMUM_FXAA"] = corpus.Fxaa, ["OPTIMUM_SSAOLEVEL"] = corpus.SsaoLevel, ["OPTIMUM_NORMALVIEW"] = corpus.NormalView, + ["OPTIMUM_BLOOM"] = corpus.Bloom, ["OPTIMUM_GODRAYS"] = corpus.GodRays, ["OPTIMUM_FOAMEFFECT"] = corpus.FoamEffect, + ["OPTIMUM_SHINYEFFECT"] = corpus.ShinyEffect, ["OPTIMUM_SHADOWQUALITY"] = corpus.ShadowQuality, + ["OPTIMUM_WAVINGSTUFF"] = corpus.WavingStuff, ["OPTIMUM_MINBRIGHT"] = corpus.MinBright, + ["OPTIMUM_GREEDYMESH_GRAD"] = 0, ["OPTIMUM_DYNLIGHTS"] = corpus.DynLights, + }; + foreach (NativeSpecialization.Entry entry in specialization.Entries) + { + SpecializationConvention.Constant constant = SpecializationConvention.Constants.Single(c => c.Id == entry.Id); + Assert.Equal(4u, entry.Size); + double actual = constant.GlslType == "float" + ? BitConverter.ToSingle(specialization.Data, (int)entry.Offset) + : BitConverter.ToInt32(specialization.Data, (int)entry.Offset); + Assert.True(Math.Abs(expected[constant.Name] - actual) < 1e-6, corpus.Name + " " + constant.Name + ": " + actual); + } + Assert.Equal(SpecializationConvention.Constants.Length, specialization.Entries.Length); + } + } + + // ------------------------------------------------------------------ loading + + [Fact] + public void TheDisableVariableWinsOverEveryOtherSource() + { + Assert.Equal(NativeShaderLibrary.Mode.Off, NativeShaderLibrary.Resolve(null, "/dir", "0", "/src", "/bin").Mode); + Assert.Equal(NativeShaderLibrary.Mode.Off, NativeShaderLibrary.Resolve(false, "/dir", null, "/src", "/bin").Mode); + Assert.Equal((NativeShaderLibrary.Mode.Directory, "/dir"), Pick(NativeShaderLibrary.Resolve(null, "/dir", "1", "/src", "/bin"))); + Assert.Equal((NativeShaderLibrary.Mode.Source, "/src"), Pick(NativeShaderLibrary.Resolve(null, null, null, "/src", "/bin"))); + Assert.Equal((NativeShaderLibrary.Mode.Directory, Path.Combine("/bin", "shaders-vk")), + Pick(NativeShaderLibrary.Resolve(true, null, null, null, "/bin"))); + + static (NativeShaderLibrary.Mode, string?) Pick((NativeShaderLibrary.Mode Mode, string? Path, string Reason) r) => (r.Mode, r.Path); + } + + [Fact] + public void AManifestThatCannotBeTrustedIsRejectedWithOneReason() + { + string directory = TemporaryDirectory(); + try + { + Assert.Null(NativeShaderLibrary.Load(directory, "tool", out string missing)); + Assert.Contains("no manifest", missing); + + var manifest = new NativeShaderManifest { Toolchain = "tool" }; + string path = Path.Combine(directory, NativeShaderManifest.FileName); + + File.WriteAllText(path, manifest.ToJson().Replace("\"schemaVersion\": 1", "\"schemaVersion\": 2")); + Assert.Null(NativeShaderLibrary.Load(directory, "tool", out string schema)); + Assert.Contains("schema version 2", schema); + + File.WriteAllText(path, "{ not json"); + Assert.Null(NativeShaderLibrary.Load(directory, "tool", out string malformed)); + Assert.Contains("rejected", malformed); + + File.WriteAllText(path, manifest.ToJson()); + Assert.Null(NativeShaderLibrary.Load(directory, "another tool", out string toolchain)); + Assert.Contains("toolchain", toolchain); + + Assert.NotNull(NativeShaderLibrary.Load(directory, "tool", out string accepted)); + Assert.Equal("", accepted); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// A SPIR-V file whose bytes are not the manifest's is refused, and stays refused. + [SkippableFact] + public void ASpirvFileWhoseHashDisagreesIsRefused() + { + NativeShaderBuildResult build = RequireFamilyOneBuild(); + string root = TemporaryDirectory(); + try + { + NativeShaderBuilder.Write(build, root); + string directory = Path.Combine(root, NativeShaderManifest.DirectoryName); + NativeShaderLibrary library = NativeShaderLibrary.Load(directory, build.Manifest.Toolchain, out string reason)!; + Assert.True(library != null, reason); + + NativeStage stage = library!.Manifest.Find("luma", "")!.Stages.Single(s => s.Stage == "fragment"); + CorruptOneByte(Path.Combine(directory, stage.Spirv)); + Assert.False(library.TryGetSpirv(stage, out _, out string error)); + Assert.Contains("sha256", error); + + NativeStage intact = library.Manifest.Find("blit", "")!.Stages.Single(s => s.Stage == "fragment"); + Assert.True(library.TryGetSpirv(intact, out byte[] bytes, out string intactError), intactError); + Assert.NotEmpty(bytes); + } + finally + { + Directory.Delete(root, true); + } + } + + [Fact] + public void TheStatsLineCarriesTheLoadCounts() => + Assert.Equal("stats.shaders native=3 rewritten=41 failed=1", + Core.VulkanStats.FormatShadersLine(3, 41, 1)); + + // ------------------------------------------------------------------ GPU + + /// A settings combination: the defines the post programs branch on. + private sealed record Settings(int Fxaa, int Bloom, int SsaoLevel, int GodRays) + { + public ShaderCorpus.ShaderVariant ToVariant() => new() + { + Name = ToString(), Fxaa = Fxaa, Bloom = Bloom, SsaoLevel = SsaoLevel, GodRays = GodRays, + TaaMotionLocation = SsaoLevel > 0 ? 4 : 2, + }; + } + + private static readonly Settings[] Combinations = + { + new(0, 0, 0, 0), + new(1, 1, 2, 1), + new(0, 1, 1, 0), + new(1, 0, 0, 2), + new(0, 0, 2, 1), + }; + + /// + /// blit, final and luma, linked natively and through the rewriter on one device, render the same pixels + /// for fixed inputs under several settings: exact for blit and luma, within 1/255 for final. final's + /// minlight/maxlight/minsat/maxsat and extraGamma are never set, so both paths rely on the seeded initializers + /// (a zero maxlight divides by zero in ColorGrade). + /// + [SkippableFact] + public void NativeAndRewrittenFamilyOneProgramsRenderTheSamePixels() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + NativeShaderBuildResult build = RequireFamilyOneBuild(); + string root = TemporaryDirectory(); + try + { + NativeShaderBuilder.Write(build, root); + Skip.IfNot(TryCreateDevice(_output, Path.Combine(root, NativeShaderManifest.DirectoryName), null, out VulkanDevice? device), + "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + _output.WriteLine("native shaders: " + seam.NativeShaderStatus); + int[] inputs = InputTextures(seam); + int pairs = 0; + + foreach (Settings settings in Combinations) + { + foreach (string name in FamilyOne) + { + int native = LinkGlsl330(seam, name, name, settings.ToVariant()); + int rewritten = LinkGlsl330(seam, name, name + "-rewriter", settings.ToVariant()); + Assert.True(seam.IsNativeProgram(native), name + " did not link natively: " + seam.GetError()); + Assert.False(seam.IsNativeProgram(rewritten)); + + if (name == "final") + { + byte[] record = seam.ProgramRecordForTests(native)!; + Assert.Equal(1f, BitConverter.ToSingle(record, seam.GetUniformLocation(native, "maxlight"))); + Assert.Equal(1f, BitConverter.ToSingle(record, seam.GetUniformLocation(native, "maxsat"))); + Assert.Equal(1f, BitConverter.ToSingle(record, seam.GetUniformLocation(native, "extraGamma"))); + Assert.Equal(0f, BitConverter.ToSingle(record, seam.GetUniformLocation(native, "minlight"))); + } + + byte[] nativePixels = Render(seam, native, name, inputs); + byte[] rewrittenPixels = Render(seam, rewritten, name, inputs); + int worst = WorstChannelDifference(nativePixels, rewrittenPixels); + _output.WriteLine(settings + " " + name + ": worst channel difference " + worst); + if (name == "final") + { + Assert.True(worst <= 1, settings + " final differs by " + worst + "/255"); + Assert.Contains(nativePixels, b => b != 0); + } + else + { + Assert.Equal(rewrittenPixels, nativePixels); + } + + seam.DeleteProgram(native); + seam.DeleteProgram(rewritten); + pairs++; + } + } + + Assert.Equal(Combinations.Length * FamilyOne.Length, pairs); + Assert.Equal((pairs, pairs, 0), seam.ShaderLinkCounts); + AssertClean(seam); + } + } + finally + { + Directory.Delete(root, true); + } + } + + /// A variant whose SPIR-V fails its hash links through the rewriter, counts as failed, and still draws. + [SkippableFact] + public void ACorruptedSpirvFileFallsBackToTheRewriterAndCountsAsFailed() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + NativeShaderBuildResult build = RequireFamilyOneBuild(); + string root = TemporaryDirectory(); + try + { + NativeShaderBuilder.Write(build, root); + string directory = Path.Combine(root, NativeShaderManifest.DirectoryName); + CorruptOneByte(Path.Combine(directory, build.Manifest.Find("final", "")!.Stages.Single(s => s.Stage == "fragment").Spirv)); + + Skip.IfNot(TryCreateDevice(_output, directory, null, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + Settings settings = Combinations[1]; + int final = LinkGlsl330(seam, "final", "final", settings.ToVariant()); + int blit = LinkGlsl330(seam, "blit", "blit", settings.ToVariant()); + + Assert.False(seam.IsNativeProgram(final)); + Assert.True(seam.IsNativeProgram(blit)); + Assert.Equal((1, 0, 1), seam.ShaderLinkCounts); + + int[] inputs = InputTextures(seam); + Assert.Contains(Render(seam, final, "final", inputs), b => b != 0); + AssertClean(seam); + } + } + finally + { + Directory.Delete(root, true); + } + } + + /// OPTIMUM_VK_NATIVE_SHADERS=0 (the device setting it maps to) links every program through the rewriter. + [SkippableFact] + public void TurningNativeShadersOffLinksEveryProgramThroughTheRewriter() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + NativeShaderBuildResult build = RequireFamilyOneBuild(); + string root = TemporaryDirectory(); + try + { + NativeShaderBuilder.Write(build, root); + Assert.Equal(NativeShaderLibrary.Mode.Off, + NativeShaderLibrary.Resolve(null, Path.Combine(root, NativeShaderManifest.DirectoryName), "0", null, null).Mode); + + Skip.IfNot(TryCreateDevice(_output, Path.Combine(root, NativeShaderManifest.DirectoryName), false, out VulkanDevice? device), + "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + Assert.StartsWith("off", seam.NativeShaderStatus); + foreach (string name in FamilyOne) + { + Assert.False(seam.IsNativeProgram(LinkGlsl330(seam, name, name, Combinations[0].ToVariant()))); + } + Assert.Equal((0, FamilyOne.Length, 0), seam.ShaderLinkCounts); + AssertClean(seam); + } + } + finally + { + Directory.Delete(root, true); + } + } + + // ------------------------------------------------------------------ helpers + + private static readonly Lazy<(NativeShaderBuildResult? Result, string Reason)> FamilyOneBuild = new(() => BuildTree(FamilyOne)); + private static readonly Lazy<(NativeShaderBuildResult? Result, string Reason)> TreeBuild = new(() => BuildTree(null)); + + private static string SourceDirectory => Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + + private static (NativeShaderBuildResult? Result, string Reason) BuildTree(string[]? programs) + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return (null, reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + if (programs == null) return (builder.Build(SourceDirectory), ""); + + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + foreach (string program in programs) + { + NativeShaderBuildResult one = builder.Build(SourceDirectory, program); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + } + return (merged, ""); + } + } + + private static NativeShaderBuildResult RequireFamilyOneBuild() => Require(FamilyOneBuild.Value); + + private static NativeShaderBuildResult RequireTreeBuild() => Require(TreeBuild.Value); + + private static NativeShaderBuildResult Require((NativeShaderBuildResult? Result, string Reason) built) + { + Skip.If(built.Result == null, built.Reason); + Assert.True(built.Result!.Success, string.Join("\n", built.Result.Errors)); + return built.Result; + } + + private static string TemporaryDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), "optimum-native-runtime-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return directory; + } + + private static void CorruptOneByte(string path) + { + byte[] bytes = File.ReadAllBytes(path); + bytes[bytes.Length / 2] ^= 0x5A; + File.WriteAllBytes(path, bytes); + } + + private static bool TryCreateDevice(ITestOutputHelper output, string manifestDirectory, bool? enabled, out VulkanDevice? device) + { + VulkanDevice created = NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = enabled; + if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) + { + device = created; + return true; + } + output.WriteLine("Vulkan unavailable: " + failureReason); + created.Dispose(); + device = null; + return false; + } + + /// Links the GLSL 330 program as ShaderRegistry would, under . + private static int LinkGlsl330(VulkanDevice seam, string program, string passName, ShaderCorpus.ShaderVariant variant) + { + List stages = ShaderCorpus.BuildProgram(program, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant); + var linked = new TestProgram { PassName = passName }; + foreach (ShaderStageSource stage in stages) + { + var shader = new TestShader { Type = stage.Stage, Code = stage.Code, PrefixCode = stage.PrefixCode }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + return id; + } + + private static unsafe int Texture(VulkanDevice seam, Func pixel) + { + var pixels = new byte[Size * Size * 4]; + for (int y = 0; y < Size; y++) + { + for (int x = 0; x < Size; x++) + { + (byte r, byte g, byte b) = pixel(x, y); + int i = (y * Size + x) * 4; + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = 255; + } + } + fixed (byte* data = pixels) + { + return seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)data, false); + } + } + + /// Five distinct inputs, one per final sampler, with edges for FXAA to find. + private static int[] InputTextures(VulkanDevice seam) => new[] + { + Texture(seam, (x, y) => ((byte)(x * 16), (byte)(y * 16), (byte)(((x ^ y) & 1) * 200 + 20))), + Texture(seam, (x, y) => ((byte)((x + y) % 3 * 90), 0, 0)), + Texture(seam, (x, y) => (60, (byte)(x * 8 + 30), 120)), + Texture(seam, (x, y) => ((byte)(y * 10), (byte)(y * 5), (byte)(x * 3))), + Texture(seam, (x, y) => ((byte)(255 - x * 12), (byte)(200 - y * 6), 0)), + }; + + private static readonly string[] FinalSamplers = { "primaryScene", "glowParts", "bloomParts", "godrayParts", "ssaoScene" }; + + private static byte[] Render(VulkanDevice seam, int program, string name, int[] inputs) + { + int target = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(framebuffer, 1); + + if (name == "final") + { + for (int i = 0; i < FinalSamplers.Length; i++) seam.SetSamplerUnit(program, FinalSamplers[i], i); + SetFloats(seam, program, "invFrameSizeIn", 1f / Size, 1f / Size); + SetFloats(seam, program, "sunPosScreenIn", 0.4f, 0.6f, 0.1f); + SetFloats(seam, program, "sunPos3dIn", 0.3f, 0.5f, 0.2f); + SetFloats(seam, program, "playerViewVector", 0.1f, 0.2f, 0.9f); + seam.SetUniform(program, seam.GetUniformLocation(program, "optimumSsaoInScene"), 0); + SetFloats(seam, program, "gammaLevel", 1.1f); + SetFloats(seam, program, "brightnessLevel", 0.95f); + SetFloats(seam, program, "contrastLevel", 0.05f); + SetFloats(seam, program, "sepiaLevel", 0.1f); + SetFloats(seam, program, "ambientBloomLevel", 0.4f); + SetFloats(seam, program, "damageVignetting", 0.3f); + SetFloats(seam, program, "damageVignettingSide", 0.2f); + SetFloats(seam, program, "frostVignetting", 0.25f); + SetFloats(seam, program, "windWaveCounter", 3f); + SetFloats(seam, program, "glitchEffectStrength", 0.35f); + } + else + { + seam.SetSamplerUnit(program, "scene", 0); + } + + seam.BeginFrame(); + for (int i = 0; i < inputs.Length; i++) seam.BindTexture(i, inputs[i]); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + seam.Present(); + + byte[] pixels = seam.ReadBackLevel0ForTests(target); + seam.DeleteFramebuffer(framebuffer); + seam.DeleteTexture(target); + return pixels; + } + + private static void SetFloats(VulkanDevice seam, int program, string name, params float[] values) + { + int location = seam.GetUniformLocation(program, name); + Assert.True(location >= 0, name + " has no location"); + switch (values.Length) + { + case 1: seam.SetUniform(program, location, values[0]); break; + case 2: seam.SetUniform(program, location, values[0], values[1]); break; + case 3: seam.SetUniform(program, location, values[0], values[1], values[2]); break; + default: throw new ArgumentException(name); + } + } + + private static int WorstChannelDifference(byte[] a, byte[] b) + { + Assert.Equal(a.Length, b.Length); + int worst = 0; + for (int i = 0; i < a.Length; i++) worst = Math.Max(worst, Math.Abs(a[i] - b[i])); + return worst; + } +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index ccd5272e..fa0780f6 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -162,7 +162,9 @@ public void SampleIsTheOriginalLineFollowedByTheTokenLines() Assert.NotNull(sample); string[] lines = sample!.Split('\n'); - Assert.Equal(7, lines.Length); + Assert.Equal(8, lines.Length); + // Native shader runtime seam: the latest shader load's native, rewritten and failed programs. + Assert.StartsWith("stats.shaders native=", lines[7]); // Caching follow-ups: pipelines compiled blocking/async/prewarmed, skipped draws, cache bytes, saves. Assert.StartsWith("stats.pipelines compiled_sync=", lines[6]); // Phase 2 step 4: transient and aliased MiB, the Transient pool's heap peak, ReadSelf copies. diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index 0f8b32ba..1f6b5eb4 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -1,3 +1,4 @@ +using Optimum.Render.Vulkan.Shaders; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -812,6 +813,31 @@ private Result CreatePipeline(PipelineRequest request, Silk.NET.Vulkan.PipelineC Vk api = _context.Api; byte* entryPoint = (byte*)SilkMarshal.StringToPtr("main"); + // A native program's settings are specialization constants (docs/vulkan-native-shaders.md + // section 5); every stage gets the same map, and an id a module does not declare is ignored. + NativeSpecialization? specialization = request.Program.Specialization; + SpecializationInfo* specializationInfo = null; + if (specialization != null && specialization.Entries.Length > 0) + { + nuint entryBytes = (nuint)(sizeof(SpecializationMapEntry) * specialization.Entries.Length); + var map = (SpecializationMapEntry*)System.Runtime.InteropServices.NativeMemory.Alloc(entryBytes); + for (int i = 0; i < specialization.Entries.Length; i++) + { + NativeSpecialization.Entry entry = specialization.Entries[i]; + map[i] = new SpecializationMapEntry { ConstantID = entry.Id, Offset = entry.Offset, Size = entry.Size }; + } + var data = (byte*)System.Runtime.InteropServices.NativeMemory.Alloc((nuint)Math.Max(specialization.Data.Length, 1)); + specialization.Data.AsSpan().CopyTo(new Span(data, specialization.Data.Length)); + specializationInfo = (SpecializationInfo*)System.Runtime.InteropServices.NativeMemory.Alloc((nuint)sizeof(SpecializationInfo)); + *specializationInfo = new SpecializationInfo + { + MapEntryCount = (uint)specialization.Entries.Length, + PMapEntries = map, + DataSize = (nuint)specialization.Data.Length, + PData = data, + }; + } + var stages = new List(); foreach (KeyValuePair module in request.Program.Modules) { @@ -826,6 +852,7 @@ private Result CreatePipeline(PipelineRequest request, Silk.NET.Vulkan.PipelineC }, Module = module.Value, PName = entryPoint, + PSpecializationInfo = specializationInfo, }); } @@ -991,6 +1018,12 @@ private Result CreatePipeline(PipelineRequest request, Silk.NET.Vulkan.PipelineC finally { SilkMarshal.Free((nint)entryPoint); + if (specializationInfo != null) + { + System.Runtime.InteropServices.NativeMemory.Free(specializationInfo->PMapEntries); + System.Runtime.InteropServices.NativeMemory.Free(specializationInfo->PData); + System.Runtime.InteropServices.NativeMemory.Free(specializationInfo); + } } } diff --git a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs index 631b2b16..cc5877af 100644 --- a/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -45,6 +45,18 @@ internal sealed unsafe class ShaderProgramResources : IDisposable /// CPU mirror of the program record. public byte[] UniformShadow { get; } + /// + /// CPU mirror of a native program's push block when it holds members besides sampler slots; null + /// otherwise. A draw copies it into the device's push shadow before the slots are resolved over it. + /// + public byte[]? PushShadow { get; } + + /// The specialization constants every pipeline of this program is created with; null for a rewritten program. + public NativeSpecialization? Specialization { get; } + + /// Whether the program was linked from the native manifest. + public bool IsNative { get; } + /// Bumped by every write that changes the shadow. public uint UniformVersion { get; private set; } = 1; @@ -70,12 +82,15 @@ public ShaderProgramResources( ProgramId = programId; Interface = translated.Layout; UniformShadow = translated.Layout.CreateShadowBuffer(); + PushShadow = translated.Layout.CreatePushShadow(); + Specialization = translated.Specialization; + IsNative = translated.IsNative; foreach (KeyValuePair stage in translated.Spirv) { Modules[stage.Key] = CreateModule(stage.Value); } - SourceHash = HashSpirv(translated.Spirv); + SourceHash = HashSpirv(translated.Spirv, translated.Specialization); // Sampler uniforms default to the unit matching their declaration order, // which is the order the game's own texture-location bookkeeping assigns. @@ -93,13 +108,15 @@ public ShaderProgramResources( } /// - /// A hash of every stage's SPIR-V, in stage order. The SPIR-V was compiled from the - /// rewritten source with its defines resolved, so two programs with this hash build the - /// same pipelines for the same state - what the pipeline-key log matches on across launches. + /// A hash of every stage's SPIR-V, in stage order, and of a native program's specialization + /// data. The rewriter's SPIR-V has its defines resolved; a native module's settings are its + /// specialization constants, so they are part of the program's identity. Two programs with + /// this hash build the same pipelines for the same state - what the pipeline-key log matches + /// on across launches. /// public UInt128 SourceHash { get; } - private static UInt128 HashSpirv(Dictionary spirv) + private static UInt128 HashSpirv(Dictionary spirv, NativeSpecialization? specialization) { using var hash = System.Security.Cryptography.IncrementalHash.CreateHash( System.Security.Cryptography.HashAlgorithmName.SHA256); @@ -113,6 +130,16 @@ private static UInt128 HashSpirv(Dictionary spirv) hash.AppendData(header); hash.AppendData(spirv[stage]); } + if (specialization != null) + { + foreach (NativeSpecialization.Entry entry in specialization.Entries) + { + BitConverter.TryWriteBytes(header, entry.Id); + BitConverter.TryWriteBytes(header[4..], entry.Offset); + hash.AppendData(header); + } + hash.AppendData(specialization.Data); + } Span digest = stackalloc byte[32]; hash.GetHashAndReset(digest); return new UInt128(BitConverter.ToUInt64(digest[..8]), BitConverter.ToUInt64(digest.Slice(8, 8))); @@ -153,6 +180,15 @@ private ShaderModule CreateModule(byte[] spirv) /// public const int FrameLocationBase = 1 << 28; + /// + /// Where a native program's push-member locations start: the member's offset in the push + /// block plus this. Above any record offset and below . + /// + public const int PushLocationBase = 1 << 27; + + /// Whether a location handed out by is a push-block member. + public static bool IsPushLocation(int location) => location >= PushLocationBase && location < FrameLocationBase; + /// Whether a location handed out by names a sampler. public static bool IsSamplerLocation(int location) => location <= FirstSamplerLocation; @@ -180,6 +216,11 @@ public int LocationOf(string name) return FrameLocationBase + frameMember.Offset; } + if (Interface.PushMembersByName.TryGetValue(name, out UniformMember? pushMember)) + { + return PushLocationBase + pushMember.Offset; + } + if (Interface.MembersByName.TryGetValue(name, out UniformMember? member)) { return member.Offset; @@ -222,6 +263,14 @@ public void SetUniform(int offset, ReadOnlySpan data) UniformVersion++; } + /// Writes raw bytes at a push location previously handed out by . + public void SetPushUniform(int location, ReadOnlySpan data) + { + int offset = location - PushLocationBase; + if (PushShadow == null || offset < 0 || offset + data.Length > PushShadow.Length) return; + data.CopyTo(PushShadow.AsSpan(offset, data.Length)); + } + /// Whether the shadow's current contents already sit in 's ring. public bool HasSnapshotFor(uint frame) => SnapshotFrame == frame && SnapshotVersion == UniformVersion; diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 0e30df10..ccb85f3f 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -496,9 +496,30 @@ public static Result WaitDeviceIdle(Vk api, Device device) AliasedLeases: Interlocked.Exchange(ref _aliasedLeases, 0), ReadSelfCopies: Interlocked.Exchange(ref _readSelfCopies, 0), ReadSelfPool: Interlocked.Read(ref _readSelfPool))) + "\n" + - FormatPipelinesLine(TakePipelineSample()); + FormatPipelinesLine(TakePipelineSample()) + "\n" + + FormatShadersLine(Interlocked.Read(ref _shadersNative), Interlocked.Read(ref _shadersRewritten), + Interlocked.Read(ref _shadersFailed)); } + private static long _shadersNative; + private static long _shadersRewritten; + private static long _shadersFailed; + + /// The counts of the latest shader load (docs/vulkan-native-shaders.md section 8); the device reports them once per load. + public static void NoteShaderLoad(long native, long rewritten, long failed) + { + Interlocked.Exchange(ref _shadersNative, native); + Interlocked.Exchange(ref _shadersRewritten, rewritten); + Interlocked.Exchange(ref _shadersFailed, failed); + } + + /// + /// stats.shaders: the latest shader load's programs linked from the native manifest, through the + /// rewriter (no native program), and native programs that failed and fell back to the rewriter. + /// + public static string FormatShadersLine(long native, long rewritten, long failed) => + string.Format(CultureInfo.InvariantCulture, "stats.shaders native={0} rewritten={1} failed={2}", native, rewritten, failed); + private static double Mib(ulong bytes) => bytes / (1024.0 * 1024.0); /// diff --git a/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs b/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs new file mode 100644 index 00000000..f239def7 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs @@ -0,0 +1,496 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// The specialization constants of one native program, as a pipeline's stages take them. +internal sealed class NativeSpecialization +{ + /// One constant: its id, and where its 4 bytes sit in . + public readonly record struct Entry(uint Id, uint Offset, uint Size); + + public Entry[] Entries = Array.Empty(); + public byte[] Data = Array.Empty(); +} + +/// What the GLSL 330 text of a program says that the manifest does not: sampler units and initializers. +internal sealed class GlslUniformOracle +{ + /// + /// ShaderProgram.collectUniformNames (build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgram.cs:57-68), + /// pattern and options verbatim. + /// + private static readonly Regex CollectUniformNames = new( + "(\\s|\\r\\n)uniform\\s*(?float|int|ivec2|ivec3|ivec4|vec2|vec3|vec4|sampler2DShadow|sampler2D|samplerCube|mat3|mat4x3|mat4)\\s*(\\[[\\d\\w]+\\])?\\s*(?[\\d\\w]+)", + RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled); + + private static readonly Regex Initializer = new( + @"\buniform\s+\w+\s+(?\w+)\s*=\s*(?[^;]+);", RegexOptions.Compiled); + + private static readonly Regex BlockComment = new(@"/\*.*?\*/", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex LineComment = new(@"//[^\n]*", RegexOptions.Compiled); + + private readonly List<(string Name, string Type, int Unit)> _samplers = new(); + private readonly Dictionary _initializers = new(StringComparer.Ordinal); + + /// The include-expanded GLSL 330 stage texts in the order the client scans them (vertex, fragment). + public GlslUniformOracle(IEnumerable stageCodes) + { + var units = new Dictionary(StringComparer.Ordinal); + var order = new List<(string Name, string Type)>(); + foreach (string code in stageCodes) + { + foreach (Match match in CollectUniformNames.Matches(code)) + { + string type = match.Groups["type"].Value; + if (!type.Contains("sampler", StringComparison.Ordinal)) continue; + string name = match.Groups["var"].Value; + // textureLocations[value] = textureLocations.Count: a name seen again takes the current count. + units[name] = units.Count; + order.Add((name, type)); + } + + string stripped = LineComment.Replace(BlockComment.Replace(code, " "), " "); + foreach (Match match in Initializer.Matches(stripped)) + { + _initializers.TryAdd(match.Groups["name"].Value, match.Groups["value"].Value.Trim()); + } + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach ((string name, string type) in order) + { + if (seen.Add(name)) _samplers.Add((name, type, units[name])); + } + } + + /// Each sampler name once, with the unit the client assigns it, in unit order. + public IEnumerable<(string Name, string Type, int Unit)> SamplersByUnit() + { + var sorted = new List<(string Name, string Type, int Unit)>(_samplers); + sorted.Sort((a, b) => a.Unit.CompareTo(b.Unit)); + return sorted; + } + + /// The initializer a GLSL 330 declaration of carries, or null. + public string? InitializerOf(string name) => _initializers.TryGetValue(name, out string? value) ? value : null; +} + +/// +/// The native shaders at runtime (docs/vulkan-native-shaders.md section 8): the manifest and SPIR-V +/// beside Optimum.Render.Vulkan.dll, or a source tree compiled at device start, looked up per +/// program at VulkanDevice.LinkProgram. +/// +/// Loaded once. SPIR-V is read and its SHA-256 checked the first time a variant is linked; a file that +/// fails the check fails that variant for the life of the library, and the program links through the +/// rewriter. +/// +internal sealed class NativeShaderLibrary +{ + /// 0 forces the rewriter for every program (A/B runs). + public const string EnabledVariable = "OPTIMUM_VK_NATIVE_SHADERS"; + + /// A sources/shaders-vk tree to compile at device start instead of the shipped manifest. + public const string SourceVariable = "OPTIMUM_VK_SHADER_SOURCE"; + + public enum Mode { Off, Directory, Source } + + /// How a link request came out. + public enum Outcome + { + /// No native program of that name (or a geometry stage): the rewriter, as before. + Miss, + /// Linked from the manifest. + Native, + /// A native program exists but could not be used: the rewriter, counted as failed. + Failed, + } + + public NativeShaderManifest Manifest { get; } + + /// Where the manifest came from, for the log. + public string Origin { get; } + + private readonly string? _directory; + private readonly IReadOnlyDictionary? _files; + private readonly Dictionary _verified = new(StringComparer.Ordinal); + private readonly Dictionary _rejected = new(StringComparer.Ordinal); + private readonly object _lock = new(); + + private NativeShaderLibrary(NativeShaderManifest manifest, string origin, string? directory, IReadOnlyDictionary? files) + { + Manifest = manifest; + Origin = origin; + _directory = directory; + _files = files; + } + + // ------------------------------------------------------------------ loading + + /// + /// Decides where native shaders come from. Off wins (the environment's 0 or the device setting); + /// then an explicit directory (tests), then , then shaders-vk beside + /// the renderer assembly. + /// + public static (Mode Mode, string? Path, string Reason) Resolve( + bool? enabledSetting, string? directorySetting, string? enabledVariable, string? sourceVariable, string? assemblyDirectory) + { + if (enabledSetting == false) return (Mode.Off, null, "native shaders off by device setting"); + if (enabledVariable?.Trim() == "0") return (Mode.Off, null, EnabledVariable + "=0"); + if (!string.IsNullOrEmpty(directorySetting)) return (Mode.Directory, directorySetting, ""); + if (!string.IsNullOrWhiteSpace(sourceVariable)) return (Mode.Source, sourceVariable, ""); + if (string.IsNullOrEmpty(assemblyDirectory)) return (Mode.Off, null, "renderer assembly location unknown"); + return (Mode.Directory, System.IO.Path.Combine(assemblyDirectory, NativeShaderManifest.DirectoryName), ""); + } + + /// + /// Reads shaders.manifest.json from . Null, with the one reason, when it is + /// missing, malformed, of another schema version, or built by another toolchain than . + /// + public static NativeShaderLibrary? Load(string directory, string toolchain, out string reason) + { + string path = System.IO.Path.Combine(directory, NativeShaderManifest.FileName); + if (!File.Exists(path)) + { + reason = "no manifest at " + path; + return null; + } + + NativeShaderManifest manifest; + try + { + manifest = NativeShaderManifest.Load(path); + } + catch (Exception error) when (error is InvalidDataException or IOException or UnauthorizedAccessException + or InvalidOperationException or FormatException or System.Text.Json.JsonException) + { + reason = "manifest " + path + " rejected: " + error.Message; + return null; + } + + if (manifest.Toolchain != toolchain) + { + reason = "manifest " + path + " was built by toolchain '" + manifest.Toolchain + "', this renderer compiles with '" + toolchain + "'"; + return null; + } + + reason = ""; + return new NativeShaderLibrary(manifest, path, directory, null); + } + + /// + /// Compiles a source tree through , the offline tool's library. Programs + /// that built are usable even when others failed; then names the failures. + /// + public static NativeShaderLibrary? BuildFromSource(string sourceDirectory, ShaderCompiler compiler, out string reason) + { + if (!Directory.Exists(sourceDirectory)) + { + reason = "source tree " + sourceDirectory + " not found"; + return null; + } + + NativeShaderBuildResult result; + try + { + result = new NativeShaderBuilder(compiler).Build(sourceDirectory); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidDataException) + { + reason = "source tree " + sourceDirectory + " did not build: " + error.Message; + return null; + } + + reason = result.Success + ? "" + : result.Errors.Count + " build error(s) in " + sourceDirectory + ", first: " + result.Errors[0]; + return new NativeShaderLibrary(result.Manifest, "source " + sourceDirectory, null, result.Files); + } + + /// A library over an in-memory build, for tests. + internal static NativeShaderLibrary FromBuild(NativeShaderBuildResult result) => + new(result.Manifest, "in-memory build", null, result.Files); + + /// The verified bytes of one stage's SPIR-V; false with the reason when unreadable or its hash disagrees. + public bool TryGetSpirv(NativeStage stage, out byte[] spirv, out string error) + { + lock (_lock) + { + if (_verified.TryGetValue(stage.Spirv, out spirv!)) + { + error = ""; + return true; + } + if (_rejected.TryGetValue(stage.Spirv, out error!)) + { + spirv = Array.Empty(); + return false; + } + + byte[]? bytes = null; + if (_files != null) + { + if (!_files.TryGetValue(stage.Spirv, out bytes)) error = stage.Spirv + " is not in the build"; + } + else + { + string path = System.IO.Path.Combine(_directory!, stage.Spirv); + try + { + bytes = File.ReadAllBytes(path); + } + catch (Exception readError) when (readError is IOException or UnauthorizedAccessException) + { + error = stage.Spirv + " unreadable: " + readError.Message; + } + } + + if (bytes != null) + { + string actual = Convert.ToHexStringLower(SHA256.HashData(bytes)); + if (actual == stage.Sha256 && bytes.Length % 4 == 0) + { + _verified[stage.Spirv] = bytes; + spirv = bytes; + error = ""; + return true; + } + error = stage.Spirv + " sha256 " + actual + ", manifest says " + stage.Sha256; + } + + _rejected[stage.Spirv] = error; + spirv = Array.Empty(); + return false; + } + } + + // ------------------------------------------------------------------ defines + + private static readonly Regex Define = new(@"^[ \t]*#[ \t]*define[ \t]+(\w+)(?:[ \t]+([^\r\n]*?))?[ \t]*\r?$", RegexOptions.Multiline | RegexOptions.Compiled); + + /// + /// The #defines of a program's prefixes (ShaderRegistry.registerDefaultShaderCodePrefixes plus the + /// program's own), stages merged in the order given. A define without a value maps to the empty string. + /// + public static Dictionary ParseDefines(IEnumerable prefixes) + { + var defines = new Dictionary(StringComparer.Ordinal); + foreach (string prefix in prefixes) + { + foreach (Match match in Define.Matches(prefix ?? "")) + { + defines[match.Groups[1].Value] = match.Groups[2].Value.Trim(); + } + } + return defines; + } + + /// The per-registration axes the GLSL 330 sources test with defined(): present is 1. + private static readonly HashSet PresenceAxes = new(StringComparer.Ordinal) { "ALLOWDEPTHOFFSET", "GLOWSUB", "VEC3SCALE" }; + + /// + /// The value of each axis of as the manifest keys it (contract section 5): + /// GBUFFER is SSAOLEVEL > 0, the per-registration axes are 1 when defined, every other axis is + /// its define's value. An absent define is 0, as in an #if. False for a value that is not 0 or 1. + /// + public static bool TryAxisValues(IEnumerable axes, IReadOnlyDictionary defines, + out Dictionary values, out string error) + { + values = new Dictionary(StringComparer.Ordinal); + foreach (string axis in axes) + { + int value; + if (PresenceAxes.Contains(axis)) + { + value = defines.ContainsKey(axis) ? 1 : 0; + } + else if (axis == "GBUFFER") + { + if (!TryIntDefine(defines, "SSAOLEVEL", out int ssao, out error)) return false; + value = ssao > 0 ? 1 : 0; + } + else + { + if (!TryIntDefine(defines, axis, out value, out error)) return false; + if (value is not (0 or 1)) + { + error = "axis " + axis + " is " + value + ", the manifest has 0 and 1"; + return false; + } + } + values[axis] = value; + } + error = ""; + return true; + } + + private static bool TryIntDefine(IReadOnlyDictionary defines, string name, out int value, out string error) + { + error = ""; + if (!defines.TryGetValue(name, out string? text)) + { + value = 0; + return true; + } + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) return true; + error = "#define " + name + " '" + text + "' is not an integer"; + return false; + } + + /// The manifest variant key of for a program's defines; null with the reason when it has none. + public static string? VariantKeyFor(NativeProgram program, IReadOnlyDictionary defines, out string error) + { + if (!TryAxisValues(program.Axes, defines, out Dictionary values, out error)) return null; + return NativeShaderManifest.VariantKey(program.Axes, values); + } + + /// + /// The specialization data for a variant: every constant the manifest lists, valued from the define + /// maps it to (the same setting ShaderRegistry stamps into the prefix), + /// 0 when the prefix does not define it. + /// + public static bool TryBuildSpecialization(NativeVariant variant, IReadOnlyDictionary defines, + out NativeSpecialization specialization, out string error) + { + var entries = new List(); + var data = new List(); + foreach (NativeSpecConstant constant in variant.SpecializationConstants) + { + SpecializationConvention.Constant? convention = null; + foreach (SpecializationConvention.Constant candidate in SpecializationConvention.Constants) + { + if (candidate.Id == (uint)constant.Id) convention = candidate; + } + if (convention == null || convention.Value.Name != constant.Name) + { + specialization = new NativeSpecialization(); + error = "specialization constant " + constant.Id + " '" + constant.Name + "' is not in the convention"; + return false; + } + + defines.TryGetValue(convention.Value.Define, out string? text); + byte[] bytes; + switch (constant.Type) + { + case "float": + float f = 0; + if (text != null && !float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out f)) + { + specialization = new NativeSpecialization(); + error = "#define " + convention.Value.Define + " '" + text + "' is not a float"; + return false; + } + bytes = BitConverter.GetBytes(f); + break; + case "int" or "uint" or "bool": + int i = 0; + if (text != null && !int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out i)) + { + specialization = new NativeSpecialization(); + error = "#define " + convention.Value.Define + " '" + text + "' is not an integer"; + return false; + } + bytes = BitConverter.GetBytes(constant.Type == "bool" ? (i != 0 ? 1 : 0) : i); + break; + default: + specialization = new NativeSpecialization(); + error = "specialization constant '" + constant.Name + "' has type " + constant.Type; + return false; + } + entries.Add(new NativeSpecialization.Entry((uint)constant.Id, (uint)data.Count, (uint)bytes.Length)); + data.AddRange(bytes); + } + + specialization = new NativeSpecialization { Entries = entries.ToArray(), Data = data.ToArray() }; + error = ""; + return true; + } + + // ------------------------------------------------------------------ linking + + /// + /// Looks up and, on a hit, builds the program from the manifest: SPIR-V per stage, + /// the layout, the specialization. are the GLSL 330 stages the program carries, for + /// the defines, the sampler units and the initializers. + /// + public Outcome TryLink(string passName, IReadOnlyList stages, + out TranslatedProgram? program, out string detail) + { + program = null; + detail = ""; + + ShaderStageSource? vertex = null, fragment = null; + foreach (ShaderStageSource stage in stages) + { + if (stage.Stage == EnumShaderType.VertexShader) vertex = stage; + else if (stage.Stage == EnumShaderType.FragmentShader) fragment = stage; + else return Outcome.Miss; + } + + NativeProgram? native = Manifest.FindProgram(passName); + if (native == null) return Outcome.Miss; + + if (vertex == null || fragment == null) + { + detail = "the GLSL 330 program lacks a vertex or fragment stage"; + return Outcome.Failed; + } + + Dictionary defines = ParseDefines(new[] { vertex.PrefixCode, fragment.PrefixCode }); + string? key = VariantKeyFor(native, defines, out string keyError); + if (key == null) + { + detail = keyError; + return Outcome.Failed; + } + detail = key; + + NativeVariant? variant = native.Variants.Find(v => v.Key == key); + if (variant == null) + { + detail = "no variant '" + key + "'"; + return Outcome.Failed; + } + + var translated = new TranslatedProgram { IsNative = true }; + foreach ((string stageName, EnumShaderType type) in new[] + { ("vertex", EnumShaderType.VertexShader), ("fragment", EnumShaderType.FragmentShader) }) + { + NativeStage? stage = variant.Stages.Find(s => s.Stage == stageName); + if (stage == null) + { + detail = "[" + key + "] has no " + stageName + " stage"; + return Outcome.Failed; + } + if (!TryGetSpirv(stage, out byte[] spirv, out string spirvError)) + { + detail = "[" + key + "] " + spirvError; + return Outcome.Failed; + } + translated.Spirv[type] = spirv; + } + + if (!TryBuildSpecialization(variant, defines, out NativeSpecialization specialization, out string specError)) + { + detail = "[" + key + "] " + specError; + return Outcome.Failed; + } + translated.Specialization = specialization; + + var oracle = new GlslUniformOracle(new[] { vertex.Code, fragment.Code }); + translated.Layout = ProgramInterfaceLayout.FromNative(variant, oracle); + if (translated.Layout.HasErrors) + { + detail = "[" + key + "] " + string.Join("; ", translated.Layout.Errors); + return Outcome.Failed; + } + + program = translated; + return Outcome.Native; + } +} diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.Native.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.Native.cs new file mode 100644 index 00000000..685ff92a --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.Native.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; + +namespace Optimum.Render.Vulkan.Shaders; + +/// +/// The layout of a program linked from the native manifest (docs/vulkan-native-shaders.md section 8). +/// +/// The draw path reads the same whichever way a program was +/// linked, so a native program is described in the rewriter's terms: frame members through the owner +/// rule (the manifest's frameMembers), record members at their reflected offsets, sampler slots +/// at their push offsets, and set 0 frame textures under their game names. The one thing the rewriter +/// never produces is a push member that is not a sampler slot (a DRAW uniform, section 4); those live in +/// and are written through the push location range. +/// +internal sealed partial class ProgramInterfaceLayout +{ + /// Push-block members that are not sampler slots, in block order. Empty for a rewritten program. + public List PushMembers { get; } = new(); + public Dictionary PushMembersByName { get; } = new(StringComparer.Ordinal); + + /// + /// The per-program push shadow a native program's non-slot push members persist in, seeded with the + /// GLSL 330 initializers; null when the push block holds only sampler slots (every rewritten program), + /// because the draw rewrites every slot anyway. + /// + public byte[]? CreatePushShadow() + { + if (PushMembers.Count == 0) return null; + var buffer = new byte[PushConstantSize]; + foreach (UniformMember member in PushMembers) + { + if (member.Initializer != null) WriteInitializer(buffer, member); + } + return buffer; + } + + /// + /// Builds the layout of one manifest variant. is what the GLSL 330 source the + /// program still carries says: sampler units in collectUniformNames order and the declarations' + /// initializers, neither of which the manifest records. + /// + public static ProgramInterfaceLayout FromNative(NativeVariant variant, GlslUniformOracle oracle) + { + var layout = new ProgramInterfaceLayout(); + + foreach (string name in variant.FrameMembers) + { + if (!FrameGlobals.TryGetMember(name, out UniformMember frame)) + { + layout.Errors.Add("frame member '" + name + "' is not in FrameGlobals"); + continue; + } + layout.FrameMemberDeclaredLengths[name] = frame.ArrayLength; + } + + if (variant.Record != null) + { + foreach (NativeMember member in variant.Record.Members) + { + UniformMember? built = MemberOf(layout, member, oracle, "record"); + if (built == null) continue; + layout.Members.Add(built); + layout.MembersByName[built.Name] = built; + } + layout.BlockSize = variant.Record.Size; + } + + var slots = new Dictionary(StringComparer.Ordinal); + foreach (NativeSampler sampler in variant.Samplers) slots[sampler.Name] = sampler; + + if (variant.Push != null) + { + if (variant.Push.Size > SetConvention.PushConstantBytes) + { + layout.Errors.Add("push block of " + variant.Push.Size + " B is past the " + SetConvention.PushConstantBytes + " B the shared layout holds"); + } + foreach (NativeMember member in variant.Push.Members) + { + if (slots.ContainsKey(member.Name)) continue; + UniformMember? built = MemberOf(layout, member, oracle, "push"); + if (built == null) continue; + layout.PushMembers.Add(built); + layout.PushMembersByName[built.Name] = built; + } + layout.PushConstantSize = variant.Push.Size; + } + + AddNativeSamplers(layout, variant, slots, oracle); + + foreach (NativeStorageBinding binding in variant.StorageBindings) + { + if (!binding.Used) continue; + if (binding.Set != SetConvention.StorageSet) + { + layout.Errors.Add("storage binding '" + binding.Name + "' is in set " + binding.Set); + continue; + } + // The client names its animation UBOs "Animation" and "AnimationPrev"; the device feeds a block + // binding from the UBO bound under the block's name, so the convention's binding decides the name. + switch (binding.Binding) + { + case SetConvention.FaceDataBinding: + layout.StorageBlocks.Add(new BlockBinding { BlockName = binding.Name, Binding = binding.Binding }); + break; + case SetConvention.AnimationBinding: + layout.UniformBlocks.Add(new BlockBinding { BlockName = "Animation", Binding = binding.Binding }); + break; + case SetConvention.AnimationPrevBinding: + layout.UniformBlocks.Add(new BlockBinding { BlockName = "AnimationPrev", Binding = binding.Binding }); + break; + default: + layout.Errors.Add("storage binding '" + binding.Name + "' at binding " + binding.Binding + + " has no native feed (the named-block range is the rewriter's)"); + break; + } + } + + foreach (NativeInterfaceVariable input in variant.VertexInputs) + { + if (input.ArrayLength != 0 || !GlslType.TryParse(input.Type, out GlslType type) || type.IsMatrix || type.IsOpaque) continue; + layout.VertexInputLocations[input.Name] = input.Location; + RecordNativeVertexInput(layout, new VertexInputSlot(input.Name, input.Location, type)); + } + + foreach (NativeInterfaceVariable output in variant.FragmentOutputs) + { + layout.FragmentOutputLocations[output.Name] = output.Location; + } + for (int bit = 0; bit < 32; bit++) + { + if ((variant.WrittenOutputs & (1u << bit)) != 0) layout.WrittenFragmentOutputs.Add(bit); + } + + return layout; + } + + private static void RecordNativeVertexInput(ProgramInterfaceLayout layout, VertexInputSlot slot) + { + foreach (VertexInputSlot existing in layout.VertexInputs) + { + if (existing.Location == slot.Location) return; + } + layout.VertexInputs.Add(slot); + } + + private static UniformMember? MemberOf(ProgramInterfaceLayout layout, NativeMember member, GlslUniformOracle oracle, string block) + { + if (!GlslType.TryParse(member.Type, out GlslType type)) + { + layout.Errors.Add(block + " member '" + member.Name + "' has type '" + member.Type + "', which the device does not model"); + return null; + } + if (member.ArrayLength < 0) + { + layout.Errors.Add(block + " member '" + member.Name + "' is a runtime array"); + return null; + } + return new UniformMember + { + Name = member.Name, + Type = type, + ArrayLength = member.ArrayLength, + Offset = member.Offset, + Size = member.Size, + Initializer = oracle.InitializerOf(member.Name), + }; + } + + /// + /// The sampler list in the texture-unit order the client assigns (collectUniformNames over the + /// GLSL 330 text): a name that is a set 0 frame texture reads that binding, a manifest slot takes its + /// push offset, and a name the oracle sees but neither side declares (its Array quirk) still + /// consumes its unit. A slot the oracle cannot see (a type outside its list) follows, in push order. + /// + private static void AddNativeSamplers( + ProgramInterfaceLayout layout, NativeVariant variant, Dictionary slots, GlslUniformOracle oracle) + { + var placed = new HashSet(StringComparer.Ordinal); + int nextUnit = 0; + foreach ((string name, string typeName, int unit) in oracle.SamplersByUnit()) + { + nextUnit = Math.Max(nextUnit, unit + 1); + if (!placed.Add(name)) continue; + + if (slots.TryGetValue(name, out NativeSampler? slot)) + { + AddSlot(layout, slot, unit); + continue; + } + foreach (SetConvention.Binding frame in SetConvention.FrameTextures) + { + if (string.Equals(frame.Name, name, StringComparison.Ordinal) && + string.Equals(frame.GlslType, typeName, StringComparison.Ordinal)) + { + AddFrameTexture(layout, name, typeName, frame.Value, unit); + break; + } + } + } + + foreach (NativeSampler slot in variant.Samplers) + { + if (placed.Add(slot.Name)) AddSlot(layout, slot, nextUnit++); + } + foreach (NativeFrameTexture texture in variant.FrameTextures) + { + if (placed.Add(texture.Name)) AddFrameTexture(layout, texture.Name, texture.GlslType, texture.Binding, nextUnit++); + } + + layout.Samplers.Sort((a, b) => a.Order.CompareTo(b.Order)); + } + + private static void AddSlot(ProgramInterfaceLayout layout, NativeSampler slot, int unit) + { + if (!BindlessKinds.TryFromGlslType(slot.GlslType, out TextureKind kind)) + { + layout.Errors.Add("sampler '" + slot.Name + "' has type '" + slot.GlslType + "', for which set 1 has no bindless array"); + return; + } + var binding = new SamplerBinding + { + Name = slot.Name, + TypeName = slot.GlslType, + Order = unit, + Kind = kind, + PushOffset = slot.PushOffset, + }; + layout.Samplers.Add(binding); + layout.SamplersByName[binding.Name] = binding; + } + + private static void AddFrameTexture(ProgramInterfaceLayout layout, string name, string typeName, int frameBinding, int unit) + { + var binding = new SamplerBinding { Name = name, TypeName = typeName, Order = unit, FrameBinding = frameBinding }; + layout.Samplers.Add(binding); + layout.SamplersByName[name] = binding; + layout.UsesFrameTextures = true; + } +} diff --git a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs index 18c5e0d4..8d28fd34 100644 --- a/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -83,7 +83,7 @@ internal sealed class BlockBinding /// This is why a stage cannot be compiled to SPIR-V alone, and why /// CompileShader only stages work that LinkProgram finishes. /// -internal sealed class ProgramInterfaceLayout +internal sealed partial class ProgramInterfaceLayout { public const string BlockTypeName = "OptimumUniforms"; public const string PushBlockTypeName = "OptimumDraw"; diff --git a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs index 30ef3b63..b09c4b7e 100644 --- a/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs +++ b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs @@ -24,6 +24,16 @@ internal sealed class TranslatedProgram public Dictionary RewrittenSource { get; } = new(); public List Errors { get; } = new(); public bool Success => Errors.Count == 0; + + /// + /// The specialization constants a native program's pipelines are created with + /// (docs/vulkan-native-shaders.md section 5); null for a rewritten program, whose + /// defines were resolved by the preprocessor. + /// + public NativeSpecialization? Specialization; + + /// Whether the program was linked from the manifest's SPIR-V rather than through the rewriter. + public bool IsNative; } /// diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index fa3d14ab..19cd2cc4 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -46,6 +46,11 @@ public sealed unsafe class VulkanDevice : IDisposable private FrameRing _frames = null!; private ShaderCompiler _shaderCompiler = null!; + /// The native shaders loaded at device start; null when they are off (docs/vulkan-native-shaders.md section 8). + private NativeShaderLibrary? _nativeShaders; + private int _nativeLinks, _rewrittenLinks, _failedNativeLinks; + private int _nativeLinksReported, _rewrittenLinksReported, _failedNativeLinksReported; + /// /// Where compiled SPIR-V and the driver's pipeline cache are kept between launches, /// set before . Null keeps nothing, which is what tests get; @@ -54,6 +59,28 @@ public sealed unsafe class VulkanDevice : IDisposable /// public string? ShaderCacheDirectory { get; set; } + /// + /// False forces the rewriter for every program, as OPTIMUM_VK_NATIVE_SHADERS=0 does; null follows the + /// environment. Read at . + /// + public bool? NativeShadersEnabled { get; set; } + + /// + /// The directory holding shaders.manifest.json, in place of shaders-vk beside the renderer + /// assembly (and of OPTIMUM_VK_SHADER_SOURCE). Read at . For tests. + /// + internal string? NativeShaderDirectory { get; set; } + + /// What made of the native shaders: the origin and program count, or why they are off. + internal string NativeShaderStatus { get; private set; } = "not loaded"; + + /// Programs linked from the manifest, through the rewriter, and native programs that fell back, since the device came up. + internal (int Native, int Rewritten, int Failed) ShaderLinkCounts => (_nativeLinks, _rewrittenLinks, _failedNativeLinks); + + /// Whether a linked program came from the native manifest. + internal bool IsNativeProgram(int programId) => + _programs.TryGetValue(programId, out ShaderProgramResources? program) && program.IsNative; + /// The pipeline cache and key-log files and their saves; null when there is no cache root. private PipelineCachePersistence? _pipelinePersistence; @@ -504,6 +531,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa { BinaryCache = cacheRoot == null ? null : new ShaderBinaryCache(System.IO.Path.Combine(cacheRoot, "spirv")), }; + LoadNativeShaders(); MirrorValidationMessage(cacheRoot == null ? "--- shader cache off" : "--- shader cache " + cacheRoot + "; pipeline cache " + @@ -737,6 +765,7 @@ public void BeginFrame() (frameStart - _lastFrameStart) * 1000.0 / System.Diagnostics.Stopwatch.Frequency); } _lastFrameStart = frameStart; + ReportShaderLoad(); // The safe point for pipelines the background worker finished, and for the // opportunistic pipeline cache save (a timestamp check; the save runs on a worker). @@ -1147,33 +1176,77 @@ public int LinkProgram(IShaderProgram program) return 0; } - // The include files the registry assembled the program from decide which - // of its uniforms read the shared frame block. A program built any other - // way - a mod's, a test's - keeps every uniform to itself. - TranslatedProgram translated = ShaderTranslator.Translate(stages, _shaderCompiler, null, - (program as Vintagestory.Client.NoObf.ShaderProgramBase)?.includes); - if (!translated.Success) + // The seam (docs/vulkan-native-shaders.md section 8): a program the manifest has links from its + // SPIR-V; one it has not links through the rewriter; one it has but cannot serve (a bad hash, an + // unreadable define, a module the driver refuses) links through the rewriter and counts as failed. + string passName = program.PassName ?? ""; + ShaderProgramResources? resources = null; + TranslatedProgram? native = null; + bool nativeFailed = false; + string nativeDetail = ""; + if (_nativeShaders != null) { - foreach (string error in translated.Errors) + NativeShaderLibrary.Outcome outcome = _nativeShaders.TryLink(passName, stages, out native, out nativeDetail); + nativeFailed = outcome == NativeShaderLibrary.Outcome.Failed; + if (outcome != NativeShaderLibrary.Outcome.Native) native = null; + } + + int programId = 0; + if (native != null) + { + programId = _nextProgramId++; + try { - AddDiagnostic($"{program.PassName}: {error}"); + resources = new ShaderProgramResources(_context, programId, native, _sharedLayout!.Layout); + } + catch (InvalidOperationException error) + { + nativeFailed = true; + nativeDetail += ": " + error.Message; } - return 0; } + if (nativeFailed) ReportNativeFailure(passName, nativeDetail); + + TranslatedProgram translated; + if (resources != null) + { + translated = native!; + _nativeLinks++; + } + else + { + if (nativeFailed) _failedNativeLinks++; + else _rewrittenLinks++; - int programId = _nextProgramId++; + // The include files the registry assembled the program from decide which + // of its uniforms read the shared frame block. A program built any other + // way - a mod's, a test's - keeps every uniform to itself. + translated = ShaderTranslator.Translate(stages, _shaderCompiler, null, + (program as Vintagestory.Client.NoObf.ShaderProgramBase)?.includes); + if (!translated.Success) + { + foreach (string error in translated.Errors) + { + AddDiagnostic($"{program.PassName}: {error}"); + } + return 0; + } + + if (programId == 0) programId = _nextProgramId++; + } if (RenderTrace.Enabled) { RenderTrace.DumpProgramSources(program.PassName, translated); RenderTrace.Write("program " + programId + " '" + program.PassName + "' uniformBlockBytes=" + - translated.Layout.BlockSize + " pushBytes=" + translated.Layout.PushConstantSize); + translated.Layout.BlockSize + " pushBytes=" + translated.Layout.PushConstantSize + + (translated.IsNative ? " native [" + nativeDetail + "]" : "")); foreach (UniformMember member in translated.Layout.Members) { RenderTrace.Write(" uniform " + member.Name + " offset=" + member.Offset + " type=" + member.Type + " count=" + member.ArrayLength); } } - var resources = new ShaderProgramResources(_context, programId, translated, _sharedLayout!.Layout); + resources ??= new ShaderProgramResources(_context, programId, translated, _sharedLayout!.Layout); _programs[programId] = resources; _programNames[programId] = program.PassName ?? ""; // Pipelines an earlier launch used with this exact program start compiling now. @@ -1185,6 +1258,73 @@ public int LinkProgram(IShaderProgram program) return programId; } + /// + /// Loads the native shaders once, at device start: the manifest beside the renderer assembly, the directory a + /// test named, or the source tree OPTIMUM_VK_SHADER_SOURCE names. One log line says what came of it. + /// + private void LoadNativeShaders() + { + string? assemblyDirectory = null; + try + { + assemblyDirectory = System.IO.Path.GetDirectoryName(typeof(VulkanDevice).Assembly.Location); + } + catch (Exception error) when (error is ArgumentException or System.IO.PathTooLongException) + { + // An assembly loaded from bytes has no location; the resolution below reports it. + } + + (NativeShaderLibrary.Mode mode, string? path, string reason) = NativeShaderLibrary.Resolve( + NativeShadersEnabled, NativeShaderDirectory, + Environment.GetEnvironmentVariable(NativeShaderLibrary.EnabledVariable), + Environment.GetEnvironmentVariable(NativeShaderLibrary.SourceVariable), + assemblyDirectory); + + _nativeShaders = mode switch + { + NativeShaderLibrary.Mode.Directory => NativeShaderLibrary.Load(path!, _shaderCompiler.Identity, out reason), + NativeShaderLibrary.Mode.Source => NativeShaderLibrary.BuildFromSource(path!, _shaderCompiler, out reason), + _ => null, + }; + + NativeShaderStatus = _nativeShaders == null + ? "off: " + reason + : _nativeShaders.Manifest.Programs.Count + " programs from " + _nativeShaders.Origin + + (reason.Length > 0 ? "; " + reason : ""); + LogShaderLine("[Optimum] shaders: native " + NativeShaderStatus); + } + + private void ReportNativeFailure(string passName, string detail) + { + string line = "[Optimum] shaders: native '" + passName + "' failed, linked through the rewriter: " + detail; + LogShaderLine(line); + if (RenderTrace.Enabled) RenderTrace.Write(line); + } + + /// + /// The line after a shader load: the programs linked since the last report. ShaderRegistry links every program + /// of a load or reload in one synchronous call, so the first frame after links is the end of that load. + /// + private void ReportShaderLoad() + { + int native = _nativeLinks - _nativeLinksReported; + int rewritten = _rewrittenLinks - _rewrittenLinksReported; + int failed = _failedNativeLinks - _failedNativeLinksReported; + if (native + rewritten + failed == 0) return; + + _nativeLinksReported = _nativeLinks; + _rewrittenLinksReported = _rewrittenLinks; + _failedNativeLinksReported = _failedNativeLinks; + LogShaderLine("[Optimum] shaders: " + native + " native, " + rewritten + " rewritten, " + failed + " failed"); + VulkanStats.NoteShaderLoad(native, rewritten, failed); + } + + private static void LogShaderLine(string line) + { + Console.WriteLine(line); + MirrorValidationMessage("--- " + line); + } + private void AddStage(List stages, IShader? shader, EnumShaderType stage, string passName) { if (shader == null || !_stagedStages.TryGetValue(shader, out StagedStage? staged)) return; @@ -1232,7 +1372,9 @@ private void Write(int programId, int location, ReadOnlySpan data) if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) { - program.SetUniform(location, data); + // A native program's push member (a DRAW uniform): kept per program, pushed per draw. + if (ShaderProgramResources.IsPushLocation(location)) program.SetPushUniform(location, data); + else program.SetUniform(location, data); } } @@ -1253,6 +1395,10 @@ private void WriteFrameGlobal(int offset, ReadOnlySpan data) _frameGlobalsVersion++; } + /// A copy of a linked program's record shadow, initializers included; null for an unknown program. For tests. + internal byte[]? ProgramRecordForTests(int programId) => + _programs.TryGetValue(programId, out ShaderProgramResources? program) ? (byte[])program.UniformShadow.Clone() : null; + /// A copy of the shared frame block's current bytes. For tests. internal byte[] FrameGlobalsForTests => (byte[])_frameGlobals.Clone(); @@ -2582,6 +2728,8 @@ private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources SharedPipelineLayout shared = _sharedLayout!; SyncBoundDescriptors(commandBuffer); + // A native push block's members persist per program; the slots are resolved over them. + if (program.PushShadow != null) program.PushShadow.CopyTo(_pushShadow, 0); ResolveSamplers(program); // Set 0: the frame block and the fixed frame textures. diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 20b2b063..db69648e 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -406,6 +406,52 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg - **Environment overrides:** - `OPTIMUM_VK_NATIVE_SHADERS=0` forces the rewriter for A/B runs. - `OPTIMUM_VK_SHADER_SOURCE=` compiles the source tree at runtime for the development loop. +- **Delivered (2026-09-16, the runtime seam):** `Shaders/NativeShaderLibrary.cs`, + `Shaders/ProgramInterfaceLayout.Native.cs`, the `LinkProgram` branch, `NativeShaderRuntimeTests`. + - **Loading, once at `Initialize`** (`NativeShaderLibrary.Resolve`, in this order): off when + `OPTIMUM_VK_NATIVE_SHADERS=0` or the device's `NativeShadersEnabled` is false; the device's + `NativeShaderDirectory` (tests); `OPTIMUM_VK_SHADER_SOURCE`, compiled through `NativeShaderBuilder.Build` + (programs that built are used, the build errors are named in the load line); else `shaders-vk/` beside + `Optimum.Render.Vulkan.dll`. A missing or malformed manifest, another schema version, or a `toolchain` other + than the device compiler's `ShaderCompiler.Identity` (it names the Silk shaderc package, so it is the same + on every platform) turns native shaders off with one line: `[Optimum] shaders: native off: `. A + loaded one logs `[Optimum] shaders: native programs from `. + - **SPIR-V is verified lazily:** read and SHA-256-checked the first time a variant links; a mismatch or an + unreadable file fails that variant for the life of the device. + - **Variant key** (`NativeShaderLibrary.VariantKeyFor`): the defines of both stages' prefixes, vertex then + fragment. `GBUFFER` is `SSAOLEVEL > 0`; `ALLOWDEPTHOFFSET`, `GLOWSUB`, `VEC3SCALE` are 1 when defined (the + GLSL 330 sources test them with `defined()`); every other axis is its define's value, 0 when absent, and a + value other than 0 or 1 fails the program. Pinned against `ShaderCorpus.Variants()` and as the inverse of + the parity harness's `VariantFor` for every combination of every axis and every manifest key. + - **Outcomes:** no program of that name, or a geometry stage, is a **miss** (rewritten). A program the manifest + has but cannot serve (no variant for the key, a stage missing, a failed hash, an unreadable spec value, a + layout error, `vkCreateShaderModule` refusing the module) is **failed**: it links through the rewriter, logs + `[Optimum] shaders: native '' failed, linked through the rewriter: ` and counts as failed, not + rewritten. `N + M + K` is every program linked. + - **Counts:** ShaderRegistry links a whole load or reload in one synchronous call, so the first `BeginFrame` + after links reports the programs linked since the last report, logs the counts line and publishes the same + numbers as `stats.shaders native= rewritten= failed=`. + - **Placement table** (`ProgramInterfaceLayout.FromNative`), so the draw path reads one layout type: + - frame members from `frameMembers` (the owner rule was applied by the builder), located at + `FrameLocationBase + offset`; + - record members at their reflected offsets, `BlockSize` the record's size; + - push members that are not sampler slots (DRAW uniforms, section 4) in a fourth, disjoint range, + `PushLocationBase` (`1 << 27`) + offset, below the frame range and above any record offset. Their values + persist in a per-program push shadow that `BindDescriptors` copies into the device's push bytes before the + slots are resolved over them; + - samplers in the unit order the client assigns: `collectUniformNames`' pattern run over the GLSL 330 text the + program still carries. A name that is a `SetConvention.FrameTextures` entry reads set 0; a manifest slot + takes its `pushOffset`; a slot the oracle cannot see (a type outside its list) follows in push order; + - `storageBindings` a shipped module uses: FaceData to the storage block, bindings 1 and 2 to the client's + `Animation`/`AnimationPrev` UBOs; the named-block range is the rewriter's and fails a native program; + - `vertexInputs` for GL's constant attribute defaults, `writtenOutputs` for the colour write masks. + - **Specialization constants** (`NativeShaderLibrary.TryBuildSpecialization`): every constant the variant lists, + valued from the define `SpecializationConvention` maps it to in the same prefix (the setting ShaderRegistry + stamps), 0 when absent, 4 bytes each (`int`, `float`, `bool` as VkBool32). `GraphicsPipelineCache.CreatePipeline` + hands the one map to every stage. The in-memory `PipelineKey` already names the program id, whose constants + are fixed at link (a settings change still reloads every program), and `ShaderProgramResources.SourceHash` + hashes the specialization data with the SPIR-V, so the persistent pipeline-key log's content id changes with + the settings too. - **Mod shaders:** they stay on the rewriter, retargeted to the same shared layout (handoff item 4, first half, delivered 2026-09-15). `ShaderProgramResources` owns no set or pipeline layout; every pipeline is built against `SharedPipelineLayout`. The rewriter (`ProgramInterfaceLayout.Build`, `ShaderRewriter`) emits: @@ -442,6 +488,18 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg divides by zero in `ColorGrade`). On a hit the runtime seeds the push shadow and the record from the GLSL 330 declarations' initializers, which it holds at the seam, the way `ProgramInterfaceLayout.WriteInitializer` seeds the rewriter's block. The manifest does not carry them (found by the family 1 pilot, 2026-09-15). + - **Delivered (2026-09-16):** `GlslUniformOracle` reads `uniform = ;` from the include-expanded + GLSL 330 stage texts the program still carries (comments stripped, vertex stage first, the first declaration of a + name wins) and `ProgramInterfaceLayout.WriteInitializer` writes it into the record shadow at the member's + reflected offset, and into the push shadow for a push member. Only scalar literals are honoured, as for the + rewriter. The preprocessor is not run: an initializer inside an inactive `#if` would still be seeded, which no + shipped shader has. + - **Pinned** by `NativeShaderRuntimeTests.NativeAndRewrittenFamilyOneProgramsRenderTheSamePixels`: blit, final and + luma link natively and through the rewriter on one device and render fixed inputs under five FXAA/BLOOM/ + SSAOLEVEL/GODRAYS combinations; blit and luma match exactly, final within 1/255 per channel, with + `minlight`/`maxlight`/`minsat`/`maxsat`/`extraGamma` never set and the native record checked for the seeded + values. `ACorruptedSpirvFileFallsBackToTheRewriterAndCountsAsFailed` and + `TurningNativeShadersOffLinksEveryProgramThroughTheRewriter` pin the fallbacks; all three run validation clean. ## 9. Adding a family From bf62791a91bbf7b3bd3192148d2b20991f0dd0c3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:24:43 +0200 Subject: [PATCH 175/226] wip(mod-api): mod pass and motion-writer API hosted on the Vulkan frame graph Contracts (Optimum.Api.Contracts, data holders, no lib types): EnumOptimumPass, EnumOptimumAttachment, OptimumPassDecl, OptimumMotionWriterDecl, OptimumPassContract validation, OptimumModPasses per-mod registry cleared on LeaveWorld, ICoreClientAPI extensions. VulkanClientPlatform.ModPasses runs declared passes at the end of their stage bracket with declared slots/reads and OpenSampling|AllowSplit, opens the motion window for motion writers, installs the renderer writer hooks. OpenGL reads none of it. Fixture mod in Optimum.Render.Vulkan.Tests; docs/vulkan-mod-support.md. Verified: Optimum.Render.Vulkan.Tests 973/973 (sync,best validation, implicit layers off, only MESA_device_select inserted); Optimum.Tests 1203 passed, 34 skipped, 0 failed; extract-patches and check-patches clean (93 applied, 64 cecil, 0 conflict). --- .gitignore | 2 + .../Fixtures/ClientApiStub.cs | 53 ++ .../ModPassFixture/ModPassFixtureSystem.cs | 60 ++ .../Fixtures/ModPassFixture/modinfo.json | 9 + .../ModPassHostingTests.cs | 365 ++++++++++ .../VulkanClientPlatform.ModPasses.cs | 291 ++++++++ .../Platform/VulkanClientPlatform.Stages.cs | 3 + .../Platform/VulkanClientPlatform.cs | 3 + Optimum.Tests/mod-pass-api-coverage-tests.cs | 256 +++++++ docs/vulkan-mod-support.md | 241 +++++++ .../optimum-api-contracts.csproj | 1 + .../Client/Render/OptimumModPasses.cs | 635 ++++++++++++++++++ .../VintagestoryApi/VintagestoryAPI.csproj | 1 + 13 files changed, 1920 insertions(+) create mode 100644 Optimum.Render.Vulkan.Tests/Fixtures/ClientApiStub.cs create mode 100644 Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs create mode 100644 Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/modinfo.json create mode 100644 Optimum.Render.Vulkan.Tests/ModPassHostingTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs create mode 100644 Optimum.Tests/mod-pass-api-coverage-tests.cs create mode 100644 docs/vulkan-mod-support.md create mode 100644 sources/VintagestoryApi/Client/Render/OptimumModPasses.cs diff --git a/.gitignore b/.gitignore index 47745590..1be658f2 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,8 @@ docs/* !docs/research/ # ...and the native shader interface contract every program family is written against. !docs/vulkan-native-shaders.md +# ...and the modder documentation for Vulkan-native mod support (passes, motion writers, shaders). +!docs/vulkan-mod-support.md build-linux.sh build-macos.sh build-windows.ps1 diff --git a/Optimum.Render.Vulkan.Tests/Fixtures/ClientApiStub.cs b/Optimum.Render.Vulkan.Tests/Fixtures/ClientApiStub.cs new file mode 100644 index 00000000..f450d20d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/Fixtures/ClientApiStub.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Tests.Fixtures; + +/// +/// A client API with only the members mod registration touches: Event and its +/// LeaveWorld event. Everything else returns its default. +/// +public class ClientApiStub : DispatchProxy +{ + public readonly List LeaveWorldHandlers = new(); + + private IClientEventAPI? events; + + public static (ICoreClientAPI Api, ClientApiStub Stub) Create() + { + ICoreClientAPI api = Create(); + var stub = (ClientApiStub)(object)api; + IClientEventAPI events = Create(); + var eventStub = (ClientApiStub)(object)events; + stub.events = events; + eventStub.owner = stub; + return (api, stub); + } + + private ClientApiStub? owner; + + /// What leaving the world does to the subscribers. + public void LeaveWorld() + { + foreach (Action handler in LeaveWorldHandlers.ToArray()) handler(); + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod!.Name) + { + case "get_Event": + return events; + case "add_LeaveWorld": + (owner ?? this).LeaveWorldHandlers.Add((Action)args![0]!); + return null; + case "remove_LeaveWorld": + (owner ?? this).LeaveWorldHandlers.Remove((Action)args![0]!); + return null; + } + Type type = targetMethod.ReturnType; + return type.IsValueType && type != typeof(void) ? Activator.CreateInstance(type) : null; + } +} diff --git a/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs b/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs new file mode 100644 index 00000000..20405172 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs @@ -0,0 +1,60 @@ +using System; +using Vintagestory.API.Client; +using Vintagestory.API.Common; + +namespace Optimum.Render.Vulkan.Tests.Fixtures.ModPassFixture; + +/// +/// A fixture mod that follows docs/vulkan-mod-support.md step by step: one declared pass and one +/// motion writer, registered in StartClientSide through the client-API extensions and removed in +/// Dispose. The pass tints Primary's colour from its glow after the AfterOIT renderers and writes +/// motion for its draw, so it reads one attachment of its own target (which therefore leaves the +/// rendering scope), writes colour and depth, and opens the motion window. The writer is what a +/// RegisterRenderer renderer of the same mod would open around its own draws. +/// +/// What the draw does is supplied by the host (): in a shipped mod it would draw +/// through capi.Render with its own shader; the GPU test draws the same shape through the platform. +/// +public sealed class ModPassFixtureSystem : ModSystem +{ + public const string PassName = "glow-tint"; + public const string WriterName = "fixture-renderer"; + + public OptimumPassDecl? Pass { get; private set; } + + public OptimumMotionWriterDecl? Writer { get; private set; } + + /// The draw the pass performs; null draws nothing. + public Action? Drawer; + + /// The id registrations are stored under (the mod id, or this assembly's name when loaded outside the mod loader). + public string ModId => OptimumModRenderExtensions.OptimumModId(this); + + public override bool ShouldLoad(EnumAppSide forSide) => forSide == EnumAppSide.Client; + + public override void StartClientSide(ICoreClientAPI api) + { + Writer = new OptimumMotionWriterDecl { Name = WriterName, Mode = EnumOptimumMotionWrite.WithColor }; + if (!api.RegisterOptimumMotionWriter(this, Writer, out string reason)) + throw new InvalidOperationException(reason); + + Pass = new OptimumPassDecl + { + Name = PassName, + Slot = EnumOptimumPass.AfterOIT, + Reads = new[] { EnumOptimumAttachment.PrimaryGlow }, + Writes = new[] { EnumOptimumAttachment.PrimaryColor, EnumOptimumAttachment.PrimaryDepth }, + Draw = OnDraw, + MotionWriter = new OptimumMotionWriterDecl { Name = PassName, Mode = EnumOptimumMotionWrite.WithColor }, + }; + if (!api.RegisterOptimumPass(this, Pass, out reason)) + throw new InvalidOperationException(reason); + } + + private void OnDraw(OptimumPassDecl pass) => Drawer?.Invoke(pass); + + public override void Dispose() + { + OptimumModPasses.UnregisterMod(ModId); + } +} diff --git a/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/modinfo.json b/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/modinfo.json new file mode 100644 index 00000000..1bbc32a4 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/modinfo.json @@ -0,0 +1,9 @@ +{ + "type": "code", + "modid": "optimummodpassfixture", + "name": "Optimum mod pass fixture", + "description": "Declares one Vulkan frame-graph pass and one motion writer, following docs/vulkan-mod-support.md.", + "version": "1.0.0", + "side": "Client", + "dependencies": { "game": "" } +} diff --git a/Optimum.Render.Vulkan.Tests/ModPassHostingTests.cs b/Optimum.Render.Vulkan.Tests/ModPassHostingTests.cs new file mode 100644 index 00000000..3b510b7d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ModPassHostingTests.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Graph; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Tests.Fixtures; +using Optimum.Render.Vulkan.Tests.Fixtures.ModPassFixture; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// The mod pass registry and the motion hooks are process statics: these tests run alone. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ModPassCollection +{ + public const string Name = "Optimum mod passes (process statics)"; +} + +/// +/// Vulkan-native plan, Phase 5: mod-declared passes and motion writers. The fixture mod +/// (Fixtures/ModPassFixture) registers one AfterOIT pass that samples Primary's glow, writes +/// Primary's colour and depth and is a motion writer, plus one renderer motion writer. On Vulkan +/// the platform runs the pass at the end of the AfterOIT bracket and nowhere else, with glow out of +/// the scope and shader-readable, colour, depth and motion attached, and the motion window open +/// around the draw; the result reads back after a multi-frame run with zero validation messages. +/// On OpenGL nothing of it runs. +/// +[Collection(ModPassCollection.Name)] +public class ModPassHostingTests +{ + private readonly ITestOutputHelper _output; + + public ModPassHostingTests(ITestOutputHelper output) => _output = output; + + private const int Size = 8; + private static readonly float[] GlowClear = { 0.8f, 0.4f, 0.2f, 1f }; + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + private const string TintFragment = """ + #version 330 core + uniform sampler2D glowTex; + layout(location = 0) out vec4 outColor; + layout(location = 2) out vec4 outMotion; + void main(void) + { + vec4 glow = texelFetch(glowTex, ivec2(gl_FragCoord.xy), 0); + outColor = vec4(glow.rgb * 0.5, 1.0); + outMotion = vec4(1.5, -2.5, 0.25, gl_FragCoord.z); + } + """; + + private static ClientMain HeadlessGame(ClientPlatformAbstract platform) + { + ScreenManager.FrameProfiler ??= new FrameProfilerUtil(static (string _) => { }); + var game = (ClientMain)RuntimeHelpers.GetUninitializedObject(typeof(ClientMain)); + game.Platform = platform; + return game; + } + + private sealed class DrawRecord + { + public EnumRenderStage Stage; + public bool InStage; + public bool MotionWindow; + public string? PassName; + public ImageLayout Colour, Glow, Motion, Depth; + public long UndeclaredSplits; + } + + [SkippableFact] + public void TheFixturePassRunsAtItsSlotWithTheDeclaredAttachmentStatesAndNoValidationMessages() + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-mod-pass-test-" + Guid.NewGuid().ToString("N")); + var platform = new VulkanClientPlatform(null!) + { + DeviceFactory = GpuTest.NewDevice, + CrashMarkerDataPath = dataPath, + }; + (ICoreClientAPI api, ClientApiStub stub) = ClientApiStub.Create(); + var fixture = new ModPassFixtureSystem(); + bool taa = OptimumConfig.Taa; + try + { + bool installed = platform.InitializeGraphics(IntPtr.Zero, 0, 0, out string reason); + if (!installed) _output.WriteLine("Vulkan unavailable: " + reason); + Skip.IfNot(installed, "No usable Vulkan device."); + VulkanDevice seam = platform.GraphicsDevice!; + Assert.NotNull(OptimumModPasses.MotionBeginHook); + + OptimumConfig.Taa = true; + Assert.True(OptimumConfig.EffectiveTaa, "TAA is explicitly disabled by a launcher scan on this machine"); + FrameBufferRef primary = CreatePrimary(seam); + InstallFrameBuffers(platform, primary); + + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, TintFragment, "mod-pass-fixture"); + seam.SetSamplerUnit(program, "glowTex", 0); + + fixture.StartClientSide(api); + Assert.Single(OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)); + Assert.Single(stub.LeaveWorldHandlers); + + var draws = new List(); + FrameGraph graph = seam.FrameGraphForTests; + fixture.Drawer = _ => + { + seam.SetViewport(0, 0, Size, Size); + platform.GlEnableDepthTest(); + platform.GlDepthMask(true); + platform.GlDisableCullFace(); + platform.GlToggleBlend(false); + platform.UseShaderProgram(program); + seam.BindTexture(0, primary.ColorTextureIds[1]); + seam.DrawFullscreenTriangle(); + draws.Add(new DrawRecord + { + Stage = platform.CurrentRenderStage, + InStage = platform.InRenderStage, + MotionWindow = platform.OptimumMotionWriteActive, + PassName = platform.CurrentModPass, + Colour = seam.TextureLayoutForTests(primary.ColorTextureIds[0]), + Glow = seam.TextureLayoutForTests(primary.ColorTextureIds[1]), + Motion = seam.TextureLayoutForTests(primary.ColorTextureIds[2]), + Depth = seam.TextureLayoutForTests(primary.DepthTextureId), + UndeclaredSplits = graph.UndeclaredSplits, + }); + }; + + ClientMain game = HeadlessGame(platform); + const int frames = 4; + long declaredBefore = graph.DeclaredPasses; + OptimumTemporal.Frame.JitterActive = true; + for (int frame = 0; frame < frames; frame++) + { + platform.BeginFrame(); + platform.CurrentFrameBuffer = primary; + seam.SetDrawBuffers(primary.FboId, 0b111); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearColor(1, GlowClear[0], GlowClear[1], GlowClear[2], GlowClear[3]); + seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + seam.SetDrawBuffers(primary.FboId, 0b011); + + game.TriggerRenderStage(EnumRenderStage.Before, 0.016f); + game.TriggerRenderStage(EnumRenderStage.Opaque, 0.016f); + game.TriggerRenderStage(EnumRenderStage.OIT, 0.016f); + game.TriggerRenderStage(EnumRenderStage.AfterOIT, 0.016f); + Assert.Same(primary, platform.CurrentFrameBuffer); + Assert.False(platform.OptimumMotionWriteActive, "the window closes with the pass"); + + // A renderer's registered writer opens the window inside Opaque on Primary only. + platform.BeginRenderStage(EnumRenderStage.Opaque); + platform.CurrentFrameBuffer = primary; + Assert.False(OptimumModPasses.BeginMotionWriter(new OptimumMotionWriterDecl { Name = "unregistered" })); + Assert.True(OptimumModPasses.BeginMotionWriter(fixture.Writer!)); + Assert.True(platform.OptimumMotionWriteActive); + OptimumModPasses.EndMotionWriter(); + Assert.False(platform.OptimumMotionWriteActive); + platform.EndRenderStage(EnumRenderStage.Opaque); + + OptimumTemporal.Frame.JitterActive = false; + game.TriggerRenderStage(EnumRenderStage.AfterPostProcessing, 0.016f); + platform.BeginRenderStage(EnumRenderStage.AfterFinalComposition); + platform.CurrentFrameBuffer = primary; + Assert.False(OptimumModPasses.BeginMotionWriter(fixture.Writer!), "no motion window outside the temporal window"); + platform.EndRenderStage(EnumRenderStage.AfterFinalComposition); + game.TriggerRenderStage(EnumRenderStage.Ortho, 0.016f); + OptimumTemporal.Frame.JitterActive = true; + platform.EndFrame(); + } + OptimumTemporal.Frame.JitterActive = false; + + Assert.Equal(frames, draws.Count); + foreach (DrawRecord draw in draws) + { + Assert.Equal(EnumRenderStage.AfterOIT, draw.Stage); + Assert.True(draw.InStage); + Assert.True(draw.MotionWindow, "the pass is a motion writer"); + Assert.Equal("Mod/" + fixture.ModId + "/" + ModPassFixtureSystem.PassName + "/0", draw.PassName); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, draw.Colour); + Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, draw.Glow); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, draw.Motion); + Assert.Equal(ImageLayout.DepthAttachmentOptimal, draw.Depth); + Assert.Equal(0, draw.UndeclaredSplits); + } + Assert.Equal(frames, platform.ModPassesRun); + Assert.Equal(0, platform.ModPassesSkipped); + Assert.True(graph.DeclaredPasses > declaredBefore); + Assert.Equal(0, graph.UndeclaredSplits); + + seam.BeginFrame(); + byte[] colour = seam.ReadBackLevel0ForTests(primary.ColorTextureIds[0]); + byte[] glow = seam.ReadBackLevel0ForTests(primary.ColorTextureIds[1]); + byte[] motion = seam.ReadBackLevel0ForTests(primary.ColorTextureIds[2]); + byte[] depth = seam.ReadBackLevel0ForTests(primary.DepthTextureId); + seam.Present(); + + for (int p = 0; p < Size * Size; p++) + { + for (int c = 0; c < 3; c++) + { + double glowByte = Math.Round(GlowClear[c] * 255); + Assert.InRange((double)glow[p * 4 + c], glowByte - 0.5, glowByte + 0.5); + Assert.InRange((double)colour[p * 4 + c], glowByte * 0.5 - 1.1, glowByte * 0.5 + 1.1); + } + Assert.Equal(255, colour[p * 4 + 3]); + Assert.Equal(1.5f, (float)BitConverter.ToHalf(motion, p * 8)); + Assert.Equal(-2.5f, (float)BitConverter.ToHalf(motion, p * 8 + 2)); + Assert.Equal(0.25f, (float)BitConverter.ToHalf(motion, p * 8 + 4)); + } + float[] depths = MemoryMarshal.Cast(depth).ToArray(); + for (int p = 0; p < Size * Size; p++) + { + Assert.Equal(0.5f, depths[p]); + Assert.InRange((float)BitConverter.ToHalf(motion, p * 8 + 6), 0.4995f, 0.5005f); + } + + // Zero validation errors and zero synchronization messages (sync,best on). What remains + // are the device-wide best-practices advisories every GPU test's device reports + // (vendor memory-priority, D32 format, push-constant range); anything else fails. + GpuTest.AssertClean(seam); + foreach (string message in ValidationAssert.Snapshot(GpuTest.MessagesOf(seam))) + { + Assert.True(message.StartsWith("[warning] [BestPractices-", StringComparison.Ordinal), "validation message: " + message); + Assert.DoesNotContain("SYNC-", message); + } + + // Leaving the world unloads the mod: its registrations go with it. + stub.LeaveWorld(); + Assert.Empty(OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)); + Assert.Empty(stub.LeaveWorldHandlers); + } + finally + { + OptimumTemporal.Frame.JitterActive = false; + OptimumConfig.Taa = taa; + fixture.Dispose(); + platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + Assert.Null(OptimumModPasses.MotionBeginHook); + } + + [Fact] + public void TheOpenGlPlatformIgnoresRegistrations() + { + (ICoreClientAPI api, ClientApiStub _) = ClientApiStub.Create(); + var fixture = new ModPassFixtureSystem(); + int draws = 0; + fixture.Drawer = _ => draws++; + try + { + fixture.StartClientSide(api); + Assert.Single(OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)); + + var platform = new ClientPlatformWindows(null!); + ClientMain game = HeadlessGame(platform); + foreach (EnumRenderStage stage in (EnumRenderStage[])Enum.GetValues(typeof(EnumRenderStage))) + game.TriggerRenderStage(stage, 0.016f); + + Assert.Equal(0, draws); + Assert.Null(OptimumModPasses.MotionBeginHook); + Assert.False(OptimumModPasses.BeginMotionWriter(fixture.Writer!)); + OptimumModPasses.EndMotionWriter(); + Assert.False(platform.OptimumMotionWriteActive); + + // No member of the GL platform names the registry. + foreach (MethodInfo method in typeof(ClientPlatformWindows).GetMethods( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + foreach (ParameterInfo parameter in method.GetParameters()) + Assert.NotEqual(typeof(OptimumPassDecl), parameter.ParameterType); + } + } + finally + { + fixture.Dispose(); + } + Assert.Empty(OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)); + } + + [Fact] + public void AHeadlessVulkanPlatformWithoutADeviceRunsNoModPass() + { + (ICoreClientAPI api, ClientApiStub _) = ClientApiStub.Create(); + var fixture = new ModPassFixtureSystem(); + int draws = 0; + fixture.Drawer = _ => draws++; + try + { + fixture.StartClientSide(api); + var platform = new VulkanClientPlatform(null!); + ClientMain game = HeadlessGame(platform); + game.TriggerRenderStage(EnumRenderStage.AfterOIT, 0.016f); + Assert.Equal(0, draws); + Assert.Equal(0, platform.ModPassesRun); + } + finally + { + fixture.Dispose(); + } + } + + private static FrameBufferRef CreatePrimary(VulkanDevice seam) + { + var primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + DepthTextureId = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false), + }; + for (int slot = 0; slot < primary.ColorTextureIds.Length; slot++) + seam.AttachTexture(primary.FboId, (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + primary.ColorTextureIds[slot], 0); + seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + seam.SetDrawBuffers(primary.FboId, 0b011); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status); + return primary; + } + + /// Primary at slot 0 with its motion attachment at 2 (no SSAO G-buffer), TAA targets ready. + private static void InstallFrameBuffers(VulkanClientPlatform platform, FrameBufferRef primary) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = primary; + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + platform.SetOptimumMotionAttachmentIndex(2); + typeof(ClientPlatformWindows).GetField("optimumTaaTargetsReady", flags)!.SetValue(platform, true); + Assert.True(platform.TaaTargetsReady); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs new file mode 100644 index 00000000..5554491f --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 5: the mod pass API hosted on the frame graph. A mod registers an +// OptimumPassDecl (slot, reads and writes by well-known handle, draw callback, optional motion +// writer) through OptimumModPasses; at the end of each render stage's bracket, after the stage's +// RegisterRenderer renderers, the platform runs the passes declared for that slot: it binds the +// declared target, declares the pass with the declared colour slots and reads (mod-hosted, so +// OpenSampling and AllowSplit), opens the motion window when the pass is a motion writer, calls +// the draw, ends the pass and restores the target and pass context. A RegisterRenderer renderer +// opens a registered writer's window itself through OptimumModPasses.BeginMotionWriter, which +// reaches the hooks installed here. OpenGL (ClientPlatformWindows) reads none of it. +public partial class VulkanClientPlatform +{ + /// The flags of every mod-hosted pass (plan, section D and Phase 2 step 2). + internal const PassFlags ModPassFlags = PassFlags.OpenSampling | PassFlags.AllowSplit; + + /// Mod passes that ran their draw. + internal long ModPassesRun { get; private set; } + + /// Mod passes skipped because a written attachment does not exist this session. + internal long ModPassesSkipped { get; private set; } + + /// The pass whose draw is running, null outside one ("Mod/<mod>/<name>/<target>"). + internal string? CurrentModPass { get; private set; } + + private readonly Dictionary modPassPlans = new(ReferenceEqualityComparer.Instance); + private readonly HashSet modPassFailuresLogged = new(ReferenceEqualityComparer.Instance); + private List? modPassPlansFor; + private int modPassPlansMotionIndex = int.MinValue; + + /// A registration resolved against the current framebuffer set; rebuilt when the set changes. + private sealed class ModPassPlan + { + public FrameBufferRef? Target; + public bool Runnable; + public string SkipReason = ""; + public PassDeclaration Declaration = new(); + } + + private void InstallModPassHooks() + { + OptimumModPasses.MotionBeginHook = BeginModMotionWriter; + OptimumModPasses.MotionEndHook = EndModMotionWriter; + } + + private void RemoveModPassHooks() + { + if (OptimumModPasses.MotionBeginHook?.Target == this) OptimumModPasses.MotionBeginHook = null; + if (OptimumModPasses.MotionEndHook?.Target == this) OptimumModPasses.MotionEndHook = null; + modPassPlans.Clear(); + modPassPlansFor = null; + } + + /// A renderer's registered writer: the window opens only inside Opaque or AfterOIT. + private bool BeginModMotionWriter(OptimumMotionWriterDecl writer) + { + if (device == null || writer == null || !InRenderStage) return false; + if (!OptimumPassContract.IsMotionWindowSlot((EnumOptimumPass)(int)CurrentRenderStage)) return false; + return writer.Mode == EnumOptimumMotionWrite.MotionOnly ? BeginMotionOnlyWrite() : BeginMotionWrite(); + } + + private void EndModMotionWriter() + { + if (device == null) return; + EndMotionWrite(); + } + + /// Runs the passes declared for , in registration order. + internal void RunModPasses(EnumRenderStage stage) + { + if (device == null) return; + OptimumPassRegistration[] passes = OptimumModPasses.ForSlot((EnumOptimumPass)(int)stage); + if (passes.Length == 0) return; + + FrameBufferRef saved = CurrentFrameBuffer; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + foreach (OptimumPassRegistration registration in passes) + { + RunModPass(registration); + } + passContext = outer; + passContextFlags = outerFlags; + // Rebinding re-declares the stage's pass on the target the renderers left bound. + CurrentFrameBuffer = saved; + } + + private void RunModPass(OptimumPassRegistration registration) + { + OptimumPassDecl decl = registration.Decl; + ModPassPlan plan = PlanFor(registration); + if (!plan.Runnable) + { + ModPassesSkipped++; + if (modPassFailuresLogged.Add(decl)) + Logger?.Warning("[Optimum] mod pass '{0}' of {1} skipped: {2}", decl.Name, registration.ModId, plan.SkipReason); + return; + } + + passContext = "Mod/" + registration.ModId + "/" + decl.Name; + passContextFlags = ModPassFlags; + // The platform setter binds and declares the context's pass on the target; the declaration + // below narrows it to the declared slots and reads under the same name. + CurrentFrameBuffer = plan.Target!; + device.DeclarePass(plan.Declaration); + + bool motion = false; + CurrentModPass = plan.Declaration.Name; + try + { + if (decl.MotionWriter != null) + { + motion = decl.MotionWriter.Mode == EnumOptimumMotionWrite.MotionOnly ? BeginMotionOnlyWrite() : BeginMotionWrite(); + } + decl.Draw(decl); + ModPassesRun++; + } + catch (Exception error) + { + // A mod's draw must not take the frame down with it; the pass is reported once. + if (modPassFailuresLogged.Add(decl)) + Logger?.Error("[Optimum] mod pass '{0}' of {1} threw: {2}", decl.Name, registration.ModId, error); + } + finally + { + if (motion) EndMotionWrite(); + CurrentModPass = null; + device.EndPass(); + } + } + + private ModPassPlan PlanFor(OptimumPassRegistration registration) + { + List buffers = FrameBuffers; + if (!ReferenceEquals(buffers, modPassPlansFor) || modPassPlansMotionIndex != MotionAttachmentIndex) + { + modPassPlans.Clear(); + modPassFailuresLogged.Clear(); + modPassPlansFor = buffers; + modPassPlansMotionIndex = MotionAttachmentIndex; + } + if (!modPassPlans.TryGetValue(registration.Decl, out ModPassPlan? plan)) + { + plan = BuildModPassPlan(registration); + modPassPlans[registration.Decl] = plan; + } + return plan; + } + + /// Resolves the declared handles to the target, its colour slots and the read textures. + private ModPassPlan BuildModPassPlan(OptimumPassRegistration registration) + { + OptimumPassDecl decl = registration.Decl; + var plan = new ModPassPlan(); + EnumOptimumTarget target = OptimumPassContract.TargetOf(decl); + int targetIndex = target switch + { + EnumOptimumTarget.Primary => PrimaryIndex, + EnumOptimumTarget.Transparent => TransparentIndex, + _ => -1, + }; + + string targetName; + uint colorSlots = 0; + if (target == EnumOptimumTarget.Default) + { + plan.Target = null; + targetName = "Default"; + colorSlots = uint.MaxValue; + } + else + { + List buffers = FrameBuffers; + FrameBufferRef? buffer = buffers != null && targetIndex >= 0 && targetIndex < buffers.Count ? buffers[targetIndex] : null; + if (buffer == null) + { + plan.SkipReason = target + " does not exist"; + return plan; + } + plan.Target = buffer; + targetName = targetIndex.ToString(CultureInfo.InvariantCulture); + foreach (EnumOptimumAttachment write in decl.Writes) + { + if (write == EnumOptimumAttachment.PrimaryDepth) + { + if (buffer.DepthTextureId <= 0) + { + plan.SkipReason = "PrimaryDepth does not exist"; + return plan; + } + continue; + } + int slot = OptimumPassContract.ColorSlotOf(write); + if (slot < 0 || buffer.ColorTextureIds == null || slot >= buffer.ColorTextureIds.Length || + buffer.ColorTextureIds[slot] <= 0 || (target == EnumOptimumTarget.Primary && slot == MotionAttachmentIndex)) + { + plan.SkipReason = write + " does not exist this session"; + return plan; + } + colorSlots |= 1u << slot; + } + if (decl.MotionWriter != null) + { + // The window writes the motion attachment on top of the declared slots; without one + // the begin call refuses and the draw falls back to camera reprojection. + // Undeclared colour slots stay out of the scope even though the window's draw-buffer + // mask names them, so a pass can still sample them (the final composition's shape). + if (MotionAttachmentIndex > -1) colorSlots |= 1u << MotionAttachmentIndex; + } + } + + var reads = new List(); + foreach (EnumOptimumAttachment read in decl.Reads) + { + int id = TextureOf(read); + if (id > 0 && !reads.Contains(id)) reads.Add(id); + } + + plan.Declaration = new PassDeclaration + { + Name = "Mod/" + registration.ModId + "/" + decl.Name + "/" + targetName, + FramebufferId = target == EnumOptimumTarget.Default + ? PassDeclaration.DefaultFramebuffer + : plan.Target!.FboId, + ColorSlots = colorSlots, + Reads = reads.ToArray(), + Flags = ModPassFlags, + }; + plan.Runnable = true; + return plan; + } + + /// The texture behind a handle in the current framebuffer set, 0 when it does not exist. + internal int TextureOf(EnumOptimumAttachment attachment) + { + switch (attachment) + { + case EnumOptimumAttachment.PrimaryMotion: + return MotionAttachmentIndex >= 0 ? ColourOf(PrimaryIndex, MotionAttachmentIndex) : 0; + case EnumOptimumAttachment.PrimaryDepth: + return DepthOf(PrimaryIndex); + case EnumOptimumAttachment.LiquidDepth: + return DepthOf(LiquidDepthIndex); + case EnumOptimumAttachment.ShadowFarDepth: + return DepthOf(ShadowFarIndex); + case EnumOptimumAttachment.ShadowNearDepth: + return DepthOf(ShadowNearIndex); + case EnumOptimumAttachment.GodRays: + return ColourOf(GodRaysIndex, 0); + case EnumOptimumAttachment.BloomLowRes: + return ColourOf(BlurVerticalLowResIndex, 0); + case EnumOptimumAttachment.Luma: + return ColourOf(LumaIndex, 0); + case EnumOptimumAttachment.SsaoBlurred: + return ColourOf(SsaoBlurVerticalIndex, 0); + } + EnumOptimumTarget target = OptimumPassContract.TargetOf(attachment); + int slot = OptimumPassContract.ColorSlotOf(attachment); + if (slot < 0) return 0; + if (target == EnumOptimumTarget.Primary) + { + // Absent G-buffer: slot 2 is the motion attachment, which is not this handle. + if (slot == MotionAttachmentIndex) return 0; + return ColourOf(PrimaryIndex, slot); + } + return target == EnumOptimumTarget.Transparent ? ColourOf(TransparentIndex, slot) : 0; + } + + private int ColourOf(int index, int slot) + { + List buffers = FrameBuffers; + if (buffers == null || index < 0 || index >= buffers.Count) return 0; + FrameBufferRef buffer = buffers[index]; + if (buffer?.ColorTextureIds == null || slot < 0 || slot >= buffer.ColorTextureIds.Length) return 0; + return buffer.ColorTextureIds[slot]; + } + + private int DepthOf(int index) + { + List buffers = FrameBuffers; + if (buffers == null || index < 0 || index >= buffers.Count || buffers[index] == null) return 0; + return buffers[index].DepthTextureId; + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs index a9922c0e..5908cc0b 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs @@ -29,6 +29,9 @@ public override void BeginRenderStage(EnumRenderStage stage) public override void EndRenderStage(EnumRenderStage stage) { + // Phase 5: the passes mods declared for this slot run after the stage's renderers, + // still inside the stage (VulkanClientPlatform.ModPasses.cs). + RunModPasses(stage); InRenderStage = false; RenderStageListener?.OnEndRenderStage(stage); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index f3d8ae2a..a885fef4 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -293,6 +293,8 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig this.device = device; // Phase 2 step 2: the stage bracket drives the frame graph's pass declarations. RenderStageListener = new FrameGraphStageListener(this); + // Phase 5: registered mod motion writers reach this platform's motion window. + InstallModPassHooks(); OptimumRender.ActiveBackend = EnumRenderBackend.Vulkan; OptimumForkGraphics.Active = new VulkanForkGraphics(device); return true; @@ -321,6 +323,7 @@ public override void ShutdownGraphics() { // The bridge goes first: nothing may reach a device that is being torn down. OptimumForkGraphics.Active = null; + RemoveModPassHooks(); try { device?.Dispose(); diff --git a/Optimum.Tests/mod-pass-api-coverage-tests.cs b/Optimum.Tests/mod-pass-api-coverage-tests.cs new file mode 100644 index 00000000..32be3adf --- /dev/null +++ b/Optimum.Tests/mod-pass-api-coverage-tests.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 5: the mod pass and motion-writer API. The contract types are data +/// holders in Optimum.Api.Contracts (no lib types); registration validates against the contract, +/// copies the declaration, stores it per mod and drops it when the client leaves the world; only the +/// Vulkan platform reads it, from the end of the stage bracket, with the mod-hosted pass flags. The +/// GPU side (slot, attachment states, validation) is ModPassHostingTests in Optimum.Render.Vulkan.Tests. +/// +[Collection("OptimumModPasses")] +public class ModPassApiCoverageTests +{ + private const string ModId = "coverage-mod"; + + public class ClientApiStub : DispatchProxy + { + public readonly List Handlers = new(); + private IClientEventAPI? events; + private ClientApiStub? owner; + + public static (ICoreClientAPI Api, ClientApiStub Stub) Create() + { + ICoreClientAPI api = Create(); + var stub = (ClientApiStub)(object)api; + IClientEventAPI events = Create(); + ((ClientApiStub)(object)events).owner = stub; + stub.events = events; + return (api, stub); + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod!.Name) + { + case "get_Event": return events; + case "add_LeaveWorld": (owner ?? this).Handlers.Add((Action)args![0]!); return null; + case "remove_LeaveWorld": (owner ?? this).Handlers.Remove((Action)args![0]!); return null; + } + Type type = targetMethod.ReturnType; + return type.IsValueType && type != typeof(void) ? Activator.CreateInstance(type) : null; + } + } + + private static OptimumPassDecl ValidPass(string name = "tint") => new() + { + Name = name, + Slot = EnumOptimumPass.AfterOIT, + Reads = new[] { EnumOptimumAttachment.PrimaryGlow }, + Writes = new[] { EnumOptimumAttachment.PrimaryColor, EnumOptimumAttachment.PrimaryDepth }, + Draw = static _ => { }, + MotionWriter = new OptimumMotionWriterDecl { Name = name, Mode = EnumOptimumMotionWrite.WithColor }, + }; + + private static string Read(string path) => File.ReadAllText(PatchReader.FindRepositoryFile(path)); + + [Fact] + public void TheSlotsMirrorTheRenderStages() + { + foreach (EnumRenderStage stage in Enum.GetValues()) + Assert.Equal(stage.ToString(), ((EnumOptimumPass)(int)stage).ToString()); + Assert.Equal(Enum.GetValues().Length, Enum.GetValues().Length); + } + + [Fact] + public void TheContractIsDataHoldersWithNoLibTypes() + { + Assembly contracts = typeof(OptimumPassDecl).Assembly; + Assert.Equal("Optimum.Api.Contracts", contracts.GetName().Name); + foreach (AssemblyName reference in contracts.GetReferencedAssemblies()) + Assert.NotEqual("VintagestoryLib", reference.Name); + + foreach (FieldInfo field in typeof(OptimumPassDecl).GetFields(BindingFlags.Public | BindingFlags.Instance)) + Assert.False(field.IsInitOnly, field.Name + " is a plain settable field"); + Assert.Empty(typeof(OptimumPassDecl).GetProperties(BindingFlags.Public | BindingFlags.Instance)); + Assert.Empty(typeof(OptimumMotionWriterDecl).GetProperties(BindingFlags.Public | BindingFlags.Instance)); + } + + [Fact] + public void ContractFileIsWiredIntoContractsAndRemovedFromTheFork() + { + Assert.Contains(@"..\sources\VintagestoryApi\Client\Render\OptimumModPasses.cs", + Read("optimum-api-contracts/optimum-api-contracts.csproj")); + Assert.Contains(@"", Read("VintagestoryApi/VintagestoryAPI.csproj")); + Assert.Contains(@"", Read("sources/VintagestoryApi/VintagestoryAPI.csproj")); + Assert.True(File.Exists(PatchReader.FindRepositoryFile("sources/VintagestoryApi/Client/Render/OptimumModPasses.cs"))); + } + + [Theory] + [InlineData("motion-write", "PrimaryMotion directly")] + [InlineData("feedback", "feedback loop")] + [InlineData("two-targets", "more than one target")] + [InlineData("default-in-world", "only AfterBlit, Ortho and Done")] + [InlineData("primary-after-blit", "after the scene was blitted")] + [InlineData("motion-in-ortho", "motion window exists only in Opaque and AfterOIT")] + [InlineData("shadow", "shadow stages")] + [InlineData("read-only-write", "which is read only")] + [InlineData("no-draw", "no draw callback")] + [InlineData("nothing", "writes nothing")] + public void RegistrationEnforcesTheContract(string breach, string expected) + { + OptimumPassDecl decl = ValidPass(); + switch (breach) + { + case "motion-write": decl.Writes = new[] { EnumOptimumAttachment.PrimaryColor, EnumOptimumAttachment.PrimaryMotion }; break; + case "feedback": decl.Reads = new[] { EnumOptimumAttachment.PrimaryColor }; break; + case "two-targets": decl.Writes = new[] { EnumOptimumAttachment.PrimaryColor, EnumOptimumAttachment.TransparentAccumulation }; decl.MotionWriter = null; break; + case "default-in-world": decl.Writes = new[] { EnumOptimumAttachment.DefaultColor }; decl.Reads = Array.Empty(); decl.MotionWriter = null; break; + case "primary-after-blit": decl.Slot = EnumOptimumPass.AfterBlit; decl.MotionWriter = null; break; + case "motion-in-ortho": decl.Slot = EnumOptimumPass.AfterPostProcessing; break; + case "shadow": decl.Slot = EnumOptimumPass.ShadowNear; break; + case "read-only-write": decl.Writes = new[] { EnumOptimumAttachment.GodRays }; decl.MotionWriter = null; break; + case "no-draw": decl.Draw = null; break; + case "nothing": decl.Writes = Array.Empty(); decl.MotionWriter = null; break; + } + + (ICoreClientAPI api, ClientApiStub _) = ClientApiStub.Create(); + Assert.False(OptimumModPasses.Register(api, ModId, decl, out string reason)); + Assert.Contains(expected, reason); + Assert.Empty(OptimumModPasses.ForSlot(decl.Slot)); + } + + [Fact] + public void AValidDeclarationIsCopiedStoredPerModAndReplacedByName() + { + (ICoreClientAPI api, ClientApiStub stub) = ClientApiStub.Create(); + try + { + OptimumPassDecl decl = ValidPass(); + Assert.True(OptimumPassContract.Validate(decl, out string? reason), reason); + Assert.Equal(EnumOptimumTarget.Primary, OptimumPassContract.TargetOf(decl)); + Assert.True(OptimumModPasses.Register(api, ModId, decl, out reason), reason); + Assert.True(OptimumModPasses.Register(api, "other-mod", ValidPass("other"), out reason), reason); + + OptimumPassRegistration[] slot = OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT); + Assert.Equal(2, slot.Length); + Assert.Equal(ModId, slot[0].ModId); + Assert.NotSame(decl, slot[0].Decl); + decl.Writes[0] = EnumOptimumAttachment.PrimaryGlow; + Assert.Equal(EnumOptimumAttachment.PrimaryColor, OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)[0].Decl.Writes[0]); + Assert.Same(slot, OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)); + + long version = OptimumModPasses.Version; + Assert.True(OptimumModPasses.Register(api, ModId, ValidPass(), out reason), reason); + Assert.Equal(2, OptimumModPasses.PassCount); + Assert.True(OptimumModPasses.Version > version); + // One LeaveWorld subscription per mod, not per registration. + Assert.Equal(2, stub.Handlers.Count); + } + finally + { + OptimumModPasses.UnregisterMod(ModId); + OptimumModPasses.UnregisterMod("other-mod"); + } + Assert.Equal(0, OptimumModPasses.PassCount); + } + + [Fact] + public void LeavingTheWorldClearsWhatTheModRegistered() + { + (ICoreClientAPI api, ClientApiStub stub) = ClientApiStub.Create(); + var writer = new OptimumMotionWriterDecl { Name = "renderer" }; + Assert.True(OptimumModPasses.Register(api, ModId, ValidPass(), out _)); + Assert.True(OptimumModPasses.RegisterMotionWriter(api, ModId, writer, out _)); + Assert.True(OptimumModPasses.IsRegisteredWriter(writer)); + Assert.Single(stub.Handlers); + + foreach (Action handler in stub.Handlers.ToArray()) handler(); + + Assert.Empty(OptimumModPasses.ForSlot(EnumOptimumPass.AfterOIT)); + Assert.False(OptimumModPasses.IsRegisteredWriter(writer)); + Assert.Empty(stub.Handlers); + Assert.DoesNotContain(ModId, OptimumModPasses.RegisteredMods()); + } + + [Fact] + public void MotionWritersOpenOnlyThroughAnInstalledHookAndOnlyWhenRegistered() + { + (ICoreClientAPI api, ClientApiStub _) = ClientApiStub.Create(); + var writer = new OptimumMotionWriterDecl { Name = "renderer", Mode = EnumOptimumMotionWrite.MotionOnly }; + var calls = new List(); + try + { + Assert.True(OptimumModPasses.RegisterMotionWriter(api, ModId, writer, out _)); + Assert.False(OptimumModPasses.BeginMotionWriter(writer), "no hook: OpenGL ignores writers"); + OptimumModPasses.EndMotionWriter(); + + OptimumModPasses.MotionBeginHook = w => { calls.Add("begin " + w.Name); return true; }; + OptimumModPasses.MotionEndHook = () => calls.Add("end"); + Assert.False(OptimumModPasses.BeginMotionWriter(new OptimumMotionWriterDecl { Name = "stranger" })); + Assert.True(OptimumModPasses.BeginMotionWriter(writer)); + OptimumModPasses.EndMotionWriter(); + Assert.Equal(new[] { "begin renderer", "end" }, calls); + } + finally + { + OptimumModPasses.MotionBeginHook = null; + OptimumModPasses.MotionEndHook = null; + OptimumModPasses.UnregisterMod(ModId); + } + } + + [Fact] + public void TheModSystemEntryPointsFallBackToTheAssemblyName() + { + var system = new CoverageModSystem(); + Assert.Equal(typeof(CoverageModSystem).Assembly.GetName().Name, OptimumModRenderExtensions.OptimumModId(system)); + } + + private sealed class CoverageModSystem : Vintagestory.API.Common.ModSystem + { + } + + [Fact] + public void OnlyTheVulkanPlatformHostsModPassesFromTheStageBracket() + { + string stages = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs"); + int run = stages.IndexOf("RunModPasses(stage);", StringComparison.Ordinal); + Assert.True(run > 0, "EndRenderStage runs the slot's mod passes"); + Assert.True(run < stages.IndexOf("InRenderStage = false;", StringComparison.Ordinal), "mod passes run inside the stage"); + Assert.True(run < stages.IndexOf("RenderStageListener?.OnEndRenderStage(stage);", StringComparison.Ordinal), + "mod passes run before the stage's pass ends"); + + string host = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs"); + Assert.Contains("internal const PassFlags ModPassFlags = PassFlags.OpenSampling | PassFlags.AllowSplit;", host); + Assert.Contains("Flags = ModPassFlags,", host); + Assert.Contains("BeginMotionOnlyWrite() : BeginMotionWrite()", host); + Assert.Contains("if (motion) EndMotionWrite();", host); + Assert.Contains("device.EndPass();", host); + + string platform = VulkanPlatformSource.Read(); + Assert.Contains("InstallModPassHooks();", platform); + Assert.Contains("RemoveModPassHooks();", platform); + + string gl = VulkanPlatformSource.ReadClientPlatformWindows(); + Assert.DoesNotContain("OptimumModPasses", gl); + Assert.DoesNotContain("OptimumPassDecl", gl); + } + + [Fact] + public void TheModderDocumentationAndTheFixtureShip() + { + string doc = Read("docs/vulkan-mod-support.md"); + foreach (string term in new[] { "OptimumPassDecl", "EnumOptimumPass", "OptimumMotionWriterDecl", "TAAMOTIONLOCATION", + "BeginMotionWriter", "routes to OpenGL", "rewriter", "shaderincludes", "LeaveWorld" }) + Assert.Contains(term, doc); + Assert.Contains("!docs/vulkan-mod-support.md", Read(".gitignore")); + Assert.Contains("RegisterOptimumPass", Read("Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs")); + } +} diff --git a/docs/vulkan-mod-support.md b/docs/vulkan-mod-support.md new file mode 100644 index 00000000..e15cc6f4 --- /dev/null +++ b/docs/vulkan-mod-support.md @@ -0,0 +1,241 @@ +# Vulkan-native mod support + +This page is for mod authors. It covers what runs on Optimum's Vulkan renderer without changes, what +makes the launcher start a session on OpenGL instead, how shaders are handled, and the opt-in API +for declaring your own frame-graph passes and motion writers. A mod that ignores the API keeps +working. The API is for a mod that wants its drawing scheduled correctly on Vulkan and its geometry +stable under TAA. + +The fixture mod in `Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/` follows this page step by +step. `ModPassHostingTests` runs it on a real device. + +## 1. What works unchanged + +Everything you reach through the game API lands on the platform's graphics members. On Vulkan those +members are backed by the native renderer. None of this needs code changes: + +- **Render API:** `ICoreClientAPI.Render` (`IRenderAPI`): meshes (`UploadMesh`, `RenderMesh`, + multi-texture meshes), textures, render-to-texture (`FrameBufferRef`, `LoadFrameBuffer`), fixed + function state (`GlToggleBlend`, `GlEnableDepthTest`, scissor, cull), the GUI and 2D helpers, and + screenshots. +- **Shaders:** `ICoreClientAPI.Shader` (`IShaderAPI`): `NewShaderProgram`, `RegisterFileShaderProgram` + and uniforms, compiled from GLSL 330 through the rewriter (section 3). +- **Renderers:** anything registered with `Event.RegisterRenderer(renderer, stage)`. Every stage is + bracketed by the platform (`BeginRenderStage`/`EndRenderStage`), and inside a mod-hosted stage the + frame graph lets your renderer sample any render target and rebind targets freely. Every stage + other than `Before`, the shadow stages, `Opaque` and `OIT` uses `OpenSampling` and `AllowSplit`. +- **TAA fallback:** geometry drawn without a motion writer is still temporally resolved. The resolve + sees no valid motion vector for your pixels and reprojects them with the camera. That is exact for + static geometry and wrong but bounded for geometry that moves on its own. Section 5 removes the + ghosting. + +## 2. What routes to OpenGL + +The launcher scans installed mods before the session starts (scanner v2, schema 2; the result is +stored in `/.optimum/shader-compatibility.json`). A mod that does any of the following makes +the whole session start on OpenGL. The log line and a one-time notice name the mod: + +| Mod does | Why | +| --- | --- | +| References `OpenTK.Graphics.OpenGL*` or P/Invokes GL directly | There is no GL context on the Vulkan path, so every direct GL call would fail. | +| Harmony-patches `ClientPlatformWindows` or `ShaderProgramBase` (platform internals) | `VulkanClientPlatform` overrides those members and links programs itself, so a patched GL body never runs. | + +A failed scan never vetoes Vulkan. It sends every program through the rewriter instead, because it +cannot tell which programs a mod replaced. + +To stay on Vulkan, move direct GL calls to the render API. If you need something the API does not +offer, declare a pass (section 4) and draw through the API inside it. + +## 3. Shaders: native programs, overrides and the rewriter + +Vanilla and Optimum programs ship as precompiled SPIR-V ("native"). Mod shaders take the rewriter, +which compiles GLSL 330 to Vulkan GLSL against the same shared pipeline layout: + +| Mod ships | Behaviour on Vulkan | +| --- | --- | +| A shader under a new program name | Built through the rewriter. | +| `assets//shaders/.vsh\|.fsh` for a vanilla or Optimum program | That program alone is built from your GLSL through the rewriter. The native blob is bypassed for it only. | +| Any `assets//shaderincludes/*` file | Every program takes the rewriter, because every program compiles against the merged include dictionary. | + +What the rewriter needs from GLSL 330: + +- **Samplers:** + - A sampler named like a frame texture (the depth and shadow maps) reads that texture directly. + - Every other sampler becomes an index into the bindless tables. + - At most 32 sampler slots per program. Sampler arrays are not supported. +- **Loose uniforms** become members of a per-program record. A uniform initializer + (`uniform float x = 1;`) is honoured. +- **Named uniform blocks** become std140 storage buffers: `Animation` and `AnimationPrev`, plus up to + four others. A fifth block fails the link. +- **Failure:** a program that fails to translate degrades per mod, the same as a failed GLSL compile + on OpenGL. + +`OPTIMUM_VK_NATIVE_SHADERS=0` forces every program through the rewriter, for comparing the two paths. + +## 4. Declaring a pass + +A declared pass is a unit of drawing the frame graph schedules at a fixed slot. The platform: + +1. binds the declared target; +2. declares the pass with the attachments you write and the textures you read, so every barrier is + known when the pass opens; +3. opens the motion window if the pass is a motion writer; +4. calls your draw; +5. ends the pass and restores the target and pass that were active before. + +Passes run at the end of their slot's stage, after that stage's `RegisterRenderer` renderers, in +registration order. + +### Slots: `EnumOptimumPass` + +The values equal `EnumRenderStage`: `Before`, `Opaque`, `OIT`, `AfterOIT`, `AfterPostProcessing`, +`AfterBlit`, `Ortho`, `AfterFinalComposition`, `Done`. The shadow stages are refused, because their +targets are not mod handles. + +### Attachments: `EnumOptimumAttachment` + +| Handle | Target | Use | +| --- | --- | --- | +| `PrimaryColor`, `PrimaryGlow` | Primary colour 0, 1 | read or write | +| `PrimaryGBufferPosition`, `PrimaryGBufferNormal` | Primary colour 2, 3 (SSAO on) | read or write | +| `PrimaryMotion` | Primary motion attachment (TAA on) | read only; writing it takes a motion writer | +| `PrimaryDepth` | Primary depth (shared with Transparent) | read or write | +| `TransparentAccumulation`, `TransparentRevealage`, `TransparentGlow` | Transparent colour 0, 1, 2 | read or write | +| `LiquidDepth`, `ShadowFarDepth`, `ShadowNearDepth`, `GodRays`, `BloomLowRes`, `Luma`, `SsaoBlurred` | none | read only | +| `DefaultColor` | the window | write only | + +A read whose texture does not exist this session (SSAO or TAA off) is dropped. A pass that writes +one is skipped, and the skip is logged once. + +### Rules, checked at registration (`OptimumPassContract.Validate`) + +- The pass needs a `Name` (unique within your mod; registering it again replaces it) and a `Draw` + callback. +- **Writes:** + - All writes belong to one target: Primary, Transparent or the window. + - `PrimaryDepth` may accompany Primary or Transparent writes. + - The window is written only in `AfterBlit`, `Ortho` and `Done`, and Primary and Transparent only + before them. + - Read-only handles cannot be written. + - `PrimaryMotion` is never a declared write. Declare a `MotionWriter` instead. +- **Reads:** a pass cannot read what it writes, because that would be a feedback loop. A colour + attachment of your target that you do not write leaves the rendering scope, so you may sample it. +- **Motion writers:** only in `Opaque` and `AfterOIT`, on Primary. + +### Example + +```csharp +public class MyModSystem : ModSystem +{ + public override bool ShouldLoad(EnumAppSide side) => side == EnumAppSide.Client; + + public override void StartClientSide(ICoreClientAPI capi) + { + var pass = new OptimumPassDecl + { + Name = "glow-tint", + Slot = EnumOptimumPass.AfterOIT, + Reads = new[] { EnumOptimumAttachment.PrimaryGlow }, + Writes = new[] { EnumOptimumAttachment.PrimaryColor, EnumOptimumAttachment.PrimaryDepth }, + Draw = decl => DrawTint(capi), // draw through capi.Render + MotionWriter = new OptimumMotionWriterDecl { Name = "glow-tint" }, + }; + if (!capi.RegisterOptimumPass(this, pass, out string reason)) + capi.Logger.Warning("glow-tint not registered: " + reason); + } + + public override void Dispose() + { + OptimumModPasses.UnregisterMod(OptimumModRenderExtensions.OptimumModId(this)); + } +} +``` + +### Lifecycle + +- **Registration:** register from the main thread, normally in `StartClientSide`. Registration copies + the declaration, so edit and register again to change it. +- **Storage:** registrations are stored per mod, under the mod id (or the assembly name when there is + none). +- **Removal:** everything a mod registered is removed when the client leaves the world (`LeaveWorld`, + which is when client mods unload). `OptimumModPasses.UnregisterMod` and `Unregister` remove + registrations earlier. +- **Draw exceptions:** an exception thrown by your draw is logged once and the frame continues. + +## 5. Motion writers + +A motion writer tells the renderer that your draws write motion vectors, so TAA can keep their +history instead of ghosting or smearing them. The rules are those of the frozen temporal frame +contract (`docs/temporal-frame-contract.md`, section 3.2). Your shader writes one `vec4` into the +motion attachment: + +| Channel | Meaning | +| --- | --- | +| `rg` | `previousPixel - currentPixel`, in render-resolution pixels, both positions from **unjittered** projections, not normalised | +| `b` | reactive value in [0,1]: 0 for opaque, higher lowers the history weight (transparent or animated surfaces) | +| `a` | the window depth your draw puts in the depth buffer, **including any depth offset** (`gl_FragCoord.z` in the plain case) | + +Two further rules apply: + +- **No previous position.** A draw with no previous position (it just spawned, or its previous clip + `w` is at or below 1e-6) still writes `b` and writes zero into `rg` and `a`. +- **Validity.** The resolve trusts a pixel only when `a` matches the depth buffer within a half-float + tolerance. A stale or offset-less `a` falls back to camera reprojection. + +Both defines are stamped into every program, yours included: + +```glsl +#if TAAMOTION > 0 +layout(location = TAAMOTIONLOCATION) out vec4 outMotion; +#endif +... +#if TAAMOTION > 0 + vec2 renderSize = ...; // the render resolution + vec2 cur = (currentClip.xy / currentClip.w * 0.5 + 0.5) * renderSize; // unjittered + if (prevClip.w <= 1e-6) outMotion = vec4(0.0, 0.0, reactive, 0.0); + else outMotion = vec4((prevClip.xy / prevClip.w * 0.5 + 0.5) * renderSize - cur, reactive, gl_FragCoord.z); +#endif +``` + +The window is replace-blended and exists only inside the temporal window: `Opaque` and `AfterOIT`, +with Primary bound and TAA on. Anywhere else the begin call refuses and your pixels take the camera +fallback. That is also the right answer for the post-composition overlays. + +There are two ways to open the window: + +- **On a declared pass:** set `OptimumPassDecl.MotionWriter`. The platform opens the window around + `Draw`. `Mode = WithColor` keeps Primary's colour set and adds the motion attachment. + `Mode = MotionOnly` writes only the motion attachment, for a velocity pass over geometry that is + already shaded. +- **In a `RegisterRenderer` renderer:** register the writer once, then bracket the draws: + + ```csharp + writer = new OptimumMotionWriterDecl { Name = "my-renderer" }; + capi.RegisterOptimumMotionWriter(this, writer, out _); + ... + bool motion = OptimumModPasses.BeginMotionWriter(writer); // false: not open, do not End + try { /* draws */ } + finally { if (motion) OptimumModPasses.EndMotionWriter(); } + ``` + +## 6. OpenGL + +On OpenGL the whole API is inert: + +- registration validates and stores, but nothing reads the registry; +- declared passes never run; +- `BeginMotionWriter` returns false. + +A mod written against this page therefore needs no backend check. If your effect must also exist on +OpenGL, draw it from a `RegisterRenderer` renderer there. `OptimumRender.IsVulkan` tells you which +backend started. + +## 7. Diagnostics + +- **Render trace.** `OPTIMUM_RENDER_TRACE=` writes one `pass` line per opened pass. Your passes + are named `Mod///`. A `pass split` line naming one of them means your draw + rebound its target mid-pass. That is allowed, but it costs a second rendering scope. +- **Validation.** `OPTIMUM_VULKAN_VALIDATION=1` with `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` + reports undefined reads and synchronisation hazards. A hazard inside your pass is a real finding. +- **Logs.** `[Optimum] mod pass '' of skipped: ...` names a write whose texture does not + exist this session. `... threw: ...` is an exception from your draw. diff --git a/optimum-api-contracts/optimum-api-contracts.csproj b/optimum-api-contracts/optimum-api-contracts.csproj index 3086ede7..53e2a3c7 100644 --- a/optimum-api-contracts/optimum-api-contracts.csproj +++ b/optimum-api-contracts/optimum-api-contracts.csproj @@ -28,6 +28,7 @@ + diff --git a/sources/VintagestoryApi/Client/Render/OptimumModPasses.cs b/sources/VintagestoryApi/Client/Render/OptimumModPasses.cs new file mode 100644 index 00000000..8dbf0137 --- /dev/null +++ b/sources/VintagestoryApi/Client/Render/OptimumModPasses.cs @@ -0,0 +1,635 @@ +using System; +using System.Collections.Generic; +using Vintagestory.API.Common; + +namespace Vintagestory.API.Client +{ + /// + /// The pass slots a mod may attach a declared pass to (Vulkan-native plan, Phase 5). The values + /// equal 's, so a slot is the stage whose renderers it runs after: + /// the Vulkan platform runs every pass declared for a slot at the end of that stage's bracket, + /// after the renderers registered through RegisterRenderer. The shadow stages are listed + /// for completeness and refused by : their targets + /// are not mod handles. + /// + public enum EnumOptimumPass + { + Before = 0, + Opaque = 1, + OIT = 2, + AfterOIT = 3, + ShadowFar = 4, + ShadowFarDone = 5, + ShadowNear = 6, + ShadowNearDone = 7, + AfterPostProcessing = 8, + AfterBlit = 9, + Ortho = 10, + AfterFinalComposition = 11, + Done = 12, + } + + /// + /// Well-known attachment handles a declared pass reads or writes. The platform resolves each + /// to the texture behind it for the current framebuffer set; a handle whose texture does not + /// exist this session (the G-buffer with SSAO off, the motion attachment with TAA off) drops + /// out of the reads, and a pass that writes one is skipped for the frame. + /// + public enum EnumOptimumAttachment + { + None = 0, + /// Primary colour 0: the lit scene. + PrimaryColor = 1, + /// Primary colour 1: glow. + PrimaryGlow = 2, + /// Primary colour 2: SSAO G-buffer position (SSAO on only). + PrimaryGBufferPosition = 3, + /// Primary colour 3: SSAO G-buffer normal (SSAO on only). + PrimaryGBufferNormal = 4, + /// + /// The TAA motion attachment (TAA on only). Never a declared write: a pass writes it by + /// declaring an , which opens the motion window. + /// + PrimaryMotion = 5, + /// Primary's depth, shared with Transparent. + PrimaryDepth = 6, + /// Transparent colour 0: OIT accumulation (RGBA16F). + TransparentAccumulation = 7, + /// Transparent colour 1: OIT revealage (R16F). + TransparentRevealage = 8, + /// Transparent colour 2: OIT glow. + TransparentGlow = 9, + /// The quarter-resolution liquid depth prepass. Read only. + LiquidDepth = 10, + /// The far shadow map. Read only. + ShadowFarDepth = 11, + /// The near shadow map. Read only. + ShadowNearDepth = 12, + /// The god-rays target. Read only. + GodRays = 13, + /// The low-resolution bloom blur. Read only. + BloomLowRes = 14, + /// The luma target. Read only. + Luma = 15, + /// The blurred ambient occlusion (SSAO on only). Read only. + SsaoBlurred = 16, + /// The window's colour. Write only. + DefaultColor = 17, + } + + /// The render target a set of written attachments belongs to. + public enum EnumOptimumTarget + { + None = 0, + Primary = 1, + Transparent = 2, + Default = 3, + } + + /// Which motion window a motion writer opens. + public enum EnumOptimumMotionWrite + { + /// Primary's default colour set plus the motion attachment (the draw shades and writes motion). + WithColor = 0, + /// The motion attachment alone (a velocity-only pass over geometry already shaded). + MotionOnly = 1, + } + + /// The draw callback of a declared pass. Render thread only. + public delegate void OptimumPassDraw(OptimumPassDecl pass); + + /// + /// A mod renderer's declaration that it writes motion for its draws, so its geometry does not + /// ghost under TAA. The writer rules are those of docs/temporal-frame-contract.md 3.2: + /// + /// the shader writes vec4(mv, reactive, writerDepth) at + /// layout(location = TAAMOTIONLOCATION) under #if TAAMOTION > 0 (both defines + /// are stamped into every program); + /// rg is previousPixel - currentPixel in render pixels from unjittered + /// projections; b is the reactive value in [0,1]; a is the window depth the draw + /// puts in the depth buffer, including any depth offset; + /// a draw that has no previous position still writes b and zeroes rg and + /// a; + /// the window is replace-blended and exists only inside the temporal window, on Primary, + /// in and ; outside + /// it the begin call refuses and the pixel falls back to camera reprojection. + /// + /// OpenGL ignores the declaration: the begin call returns false there. + /// + public sealed class OptimumMotionWriterDecl + { + /// A name unique within the mod, for logs and traces. + public string Name = ""; + + public EnumOptimumMotionWrite Mode; + + internal OptimumMotionWriterDecl Clone() => new OptimumMotionWriterDecl { Name = Name, Mode = Mode }; + } + + /// + /// A pass a mod declares on the Vulkan frame graph: its slot, the attachments it reads and + /// writes by well-known handle, its draw callback and, optionally, the motion writer its draws + /// are. A plain data holder: registration copies it, so later edits change nothing until it is + /// registered again. OpenGL ignores it. + /// + public sealed class OptimumPassDecl + { + /// A name unique within the mod; registering the same name again replaces the pass. + public string Name = ""; + + public EnumOptimumPass Slot; + + /// Attachments the draw samples. Pre-transitioned to shader-readable at pass entry. + public EnumOptimumAttachment[] Reads = Array.Empty(); + + /// + /// Attachments the draw writes, all on one target (). + /// Colour attachments of that target that are not listed leave the rendering scope, so the + /// pass may sample them. is the only depth + /// write and goes with the Primary or Transparent target. + /// + public EnumOptimumAttachment[] Writes = Array.Empty(); + + public OptimumPassDraw Draw; + + /// Non-null: the platform opens this motion window around . + public OptimumMotionWriterDecl MotionWriter; + + internal OptimumPassDecl Clone() => new OptimumPassDecl + { + Name = Name, + Slot = Slot, + Reads = Reads == null ? Array.Empty() : (EnumOptimumAttachment[])Reads.Clone(), + Writes = Writes == null ? Array.Empty() : (EnumOptimumAttachment[])Writes.Clone(), + Draw = Draw, + MotionWriter = MotionWriter?.Clone(), + }; + } + + /// One registered pass: the owning mod and its (copied) declaration. + public sealed class OptimumPassRegistration + { + public readonly string ModId; + public readonly OptimumPassDecl Decl; + + internal OptimumPassRegistration(string modId, OptimumPassDecl decl) + { + ModId = modId; + Decl = decl; + } + } + + /// The rules a declaration must satisfy, shared by registration and the platform. + public static class OptimumPassContract + { + /// The target a handle belongs to as an attachment; None for read-only handles. + public static EnumOptimumTarget TargetOf(EnumOptimumAttachment attachment) + { + switch (attachment) + { + case EnumOptimumAttachment.PrimaryColor: + case EnumOptimumAttachment.PrimaryGlow: + case EnumOptimumAttachment.PrimaryGBufferPosition: + case EnumOptimumAttachment.PrimaryGBufferNormal: + case EnumOptimumAttachment.PrimaryMotion: + return EnumOptimumTarget.Primary; + case EnumOptimumAttachment.TransparentAccumulation: + case EnumOptimumAttachment.TransparentRevealage: + case EnumOptimumAttachment.TransparentGlow: + return EnumOptimumTarget.Transparent; + case EnumOptimumAttachment.DefaultColor: + return EnumOptimumTarget.Default; + default: + return EnumOptimumTarget.None; + } + } + + /// The colour slot of a target attachment, -1 for depth and read-only handles (motion: -1, it moves). + public static int ColorSlotOf(EnumOptimumAttachment attachment) + { + switch (attachment) + { + case EnumOptimumAttachment.PrimaryColor: + case EnumOptimumAttachment.TransparentAccumulation: + case EnumOptimumAttachment.DefaultColor: + return 0; + case EnumOptimumAttachment.PrimaryGlow: + case EnumOptimumAttachment.TransparentRevealage: + return 1; + case EnumOptimumAttachment.PrimaryGBufferPosition: + case EnumOptimumAttachment.TransparentGlow: + return 2; + case EnumOptimumAttachment.PrimaryGBufferNormal: + return 3; + default: + return -1; + } + } + + public static bool IsDepth(EnumOptimumAttachment attachment) => + attachment == EnumOptimumAttachment.PrimaryDepth || attachment == EnumOptimumAttachment.LiquidDepth || + attachment == EnumOptimumAttachment.ShadowFarDepth || attachment == EnumOptimumAttachment.ShadowNearDepth; + + /// The slots inside the temporal window where Primary is the drawn target. + public static bool IsMotionWindowSlot(EnumOptimumPass slot) => + slot == EnumOptimumPass.Opaque || slot == EnumOptimumPass.AfterOIT; + + /// The slots that run on the window after the scene was blitted. + public static bool IsDefaultTargetSlot(EnumOptimumPass slot) => + slot == EnumOptimumPass.AfterBlit || slot == EnumOptimumPass.Ortho || slot == EnumOptimumPass.Done; + + /// The target a declaration draws into; None when it names none or several. + public static EnumOptimumTarget TargetOf(OptimumPassDecl decl) + { + if (decl == null) return EnumOptimumTarget.None; + EnumOptimumTarget target = EnumOptimumTarget.None; + bool depth = false; + if (decl.Writes != null) + { + foreach (EnumOptimumAttachment write in decl.Writes) + { + if (write == EnumOptimumAttachment.PrimaryDepth) + { + depth = true; + continue; + } + EnumOptimumTarget of = TargetOf(write); + if (of == EnumOptimumTarget.None) return EnumOptimumTarget.None; + if (target != EnumOptimumTarget.None && target != of) return EnumOptimumTarget.None; + target = of; + } + } + if (target == EnumOptimumTarget.None && (depth || decl.MotionWriter != null)) target = EnumOptimumTarget.Primary; + return target; + } + + /// Checks a declaration against the contract; false with the first broken rule. + public static bool Validate(OptimumPassDecl decl, out string reason) + { + reason = null; + if (decl == null) { reason = "the declaration is null"; return false; } + if (string.IsNullOrWhiteSpace(decl.Name)) { reason = "a pass needs a name"; return false; } + if (decl.Draw == null) { reason = "pass '" + decl.Name + "' has no draw callback"; return false; } + if (!Enum.IsDefined(typeof(EnumOptimumPass), decl.Slot)) { reason = "pass '" + decl.Name + "' names no slot"; return false; } + if (decl.Slot == EnumOptimumPass.ShadowFar || decl.Slot == EnumOptimumPass.ShadowFarDone || + decl.Slot == EnumOptimumPass.ShadowNear || decl.Slot == EnumOptimumPass.ShadowNearDone) + { + reason = "pass '" + decl.Name + "': the shadow stages draw the shadow maps, which are not mod targets"; + return false; + } + + EnumOptimumAttachment[] writes = decl.Writes ?? Array.Empty(); + EnumOptimumAttachment[] reads = decl.Reads ?? Array.Empty(); + if (writes.Length == 0 && decl.MotionWriter == null) + { + reason = "pass '" + decl.Name + "' writes nothing"; + return false; + } + foreach (EnumOptimumAttachment write in writes) + { + if (write == EnumOptimumAttachment.None || !Enum.IsDefined(typeof(EnumOptimumAttachment), write)) + { + reason = "pass '" + decl.Name + "' writes an unknown attachment"; + return false; + } + if (write == EnumOptimumAttachment.PrimaryMotion) + { + reason = "pass '" + decl.Name + "' writes PrimaryMotion directly; declare a MotionWriter instead"; + return false; + } + if (write != EnumOptimumAttachment.PrimaryDepth && TargetOf(write) == EnumOptimumTarget.None) + { + reason = "pass '" + decl.Name + "' writes " + write + ", which is read only"; + return false; + } + } + foreach (EnumOptimumAttachment read in reads) + { + if (read == EnumOptimumAttachment.None || !Enum.IsDefined(typeof(EnumOptimumAttachment), read)) + { + reason = "pass '" + decl.Name + "' reads an unknown attachment"; + return false; + } + if (read == EnumOptimumAttachment.DefaultColor) + { + reason = "pass '" + decl.Name + "' reads DefaultColor, which is write only"; + return false; + } + if (Array.IndexOf(writes, read) >= 0) + { + reason = "pass '" + decl.Name + "' reads and writes " + read + " (a feedback loop)"; + return false; + } + } + + EnumOptimumTarget target = TargetOf(decl); + if (target == EnumOptimumTarget.None) + { + reason = "pass '" + decl.Name + "' writes attachments of more than one target"; + return false; + } + if (target == EnumOptimumTarget.Default) + { + if (Array.IndexOf(writes, EnumOptimumAttachment.PrimaryDepth) >= 0) + { + reason = "pass '" + decl.Name + "' writes PrimaryDepth on the default target"; + return false; + } + if (!IsDefaultTargetSlot(decl.Slot)) + { + reason = "pass '" + decl.Name + "' writes DefaultColor in " + decl.Slot + "; only AfterBlit, Ortho and Done draw on the window"; + return false; + } + } + else if (IsDefaultTargetSlot(decl.Slot)) + { + reason = "pass '" + decl.Name + "' writes " + target + " in " + decl.Slot + ", after the scene was blitted"; + return false; + } + + if (decl.MotionWriter != null && !ValidateMotionWriter(decl.MotionWriter, out reason, decl.Slot, target)) + { + reason = "pass '" + decl.Name + "': " + reason; + return false; + } + return true; + } + + /// + /// Checks a motion writer. With a slot and target (a writer on a declared pass) the window + /// rules are checked too; a renderer's writer is checked against them at begin time. + /// + public static bool ValidateMotionWriter(OptimumMotionWriterDecl writer, out string reason, + EnumOptimumPass? slot = null, EnumOptimumTarget target = EnumOptimumTarget.Primary) + { + reason = null; + if (writer == null) { reason = "the motion writer is null"; return false; } + if (string.IsNullOrWhiteSpace(writer.Name)) { reason = "a motion writer needs a name"; return false; } + if (!Enum.IsDefined(typeof(EnumOptimumMotionWrite), writer.Mode)) { reason = "motion writer '" + writer.Name + "' has an unknown mode"; return false; } + if (slot.HasValue && !IsMotionWindowSlot(slot.Value)) + { + reason = "motion writer '" + writer.Name + "' in " + slot.Value + ": the motion window exists only in Opaque and AfterOIT"; + return false; + } + if (target != EnumOptimumTarget.Primary) + { + reason = "motion writer '" + writer.Name + "' draws into " + target + "; the motion attachment is Primary's"; + return false; + } + return true; + } + } + + /// + /// The registry of mod-declared passes and motion writers (Vulkan-native plan, Phase 5). Stored + /// per mod; everything a mod registered is removed when the client leaves the world (mods unload + /// with it), or earlier through . The Vulkan platform reads + /// at each stage's end and installs the motion hooks; OpenGL reads nothing, + /// so on OpenGL registration succeeds and has no effect. + /// + /// Register from the main (render) thread, typically in StartClientSide. + /// + public static class OptimumModPasses + { + private sealed class ModEntry + { + public readonly string ModId; + public readonly List Passes = new List(); + public readonly List Writers = new List(); + public ICoreClientAPI Api; + + public ModEntry(string modId) => ModId = modId; + + public void OnLeaveWorld() => UnregisterMod(ModId); + } + + private static readonly object Gate = new object(); + private static readonly Dictionary Mods = new Dictionary(StringComparer.Ordinal); + private static readonly OptimumPassRegistration[][] Slots = new OptimumPassRegistration[13][]; + private static readonly OptimumPassRegistration[] Empty = Array.Empty(); + private static bool dirty = true; + private static long version; + + /// + /// Installed by the Vulkan platform while its graphics are up; null on OpenGL. Opens the + /// motion window for a registered writer and returns whether it opened. + /// + public static System.Func MotionBeginHook; + + /// Installed with : closes the window it opened. + public static Action MotionEndHook; + + /// Changes on every registration change. + public static long Version + { + get { lock (Gate) return version; } + } + + /// Registered passes over all mods. + public static int PassCount + { + get + { + lock (Gate) + { + int count = 0; + foreach (ModEntry entry in Mods.Values) count += entry.Passes.Count; + return count; + } + } + } + + /// + /// Registers (or replaces, by name) a pass for . False with the + /// broken rule when the declaration does not satisfy . + /// + public static bool Register(ICoreClientAPI capi, string modId, OptimumPassDecl decl, out string reason) + { + if (capi == null) throw new ArgumentNullException(nameof(capi)); + if (string.IsNullOrWhiteSpace(modId)) throw new ArgumentException("a mod id is required", nameof(modId)); + if (!OptimumPassContract.Validate(decl, out reason)) return false; + + OptimumPassDecl copy = decl.Clone(); + lock (Gate) + { + ModEntry entry = EntryFor(capi, modId); + for (int i = 0; i < entry.Passes.Count; i++) + { + if (entry.Passes[i].Decl.Name == copy.Name) + { + entry.Passes.RemoveAt(i); + break; + } + } + entry.Passes.Add(new OptimumPassRegistration(modId, copy)); + Changed(); + } + return true; + } + + /// + /// Registers a motion writer a RegisterRenderer renderer opens around its own draws + /// with . The same instance is what Begin takes. + /// + public static bool RegisterMotionWriter(ICoreClientAPI capi, string modId, OptimumMotionWriterDecl writer, out string reason) + { + if (capi == null) throw new ArgumentNullException(nameof(capi)); + if (string.IsNullOrWhiteSpace(modId)) throw new ArgumentException("a mod id is required", nameof(modId)); + if (!OptimumPassContract.ValidateMotionWriter(writer, out reason)) return false; + lock (Gate) + { + ModEntry entry = EntryFor(capi, modId); + if (!entry.Writers.Contains(writer)) entry.Writers.Add(writer); + Changed(); + } + return true; + } + + /// Removes one pass of a mod; false when it had none of that name. + public static bool Unregister(string modId, string passName) + { + lock (Gate) + { + if (modId == null || !Mods.TryGetValue(modId, out ModEntry entry)) return false; + for (int i = 0; i < entry.Passes.Count; i++) + { + if (entry.Passes[i].Decl.Name != passName) continue; + entry.Passes.RemoveAt(i); + Changed(); + return true; + } + return false; + } + } + + /// Removes everything a mod registered and detaches from its API's LeaveWorld. + public static void UnregisterMod(string modId) + { + ModEntry entry; + lock (Gate) + { + if (modId == null || !Mods.TryGetValue(modId, out entry)) return; + Mods.Remove(modId); + Changed(); + } + IClientEventAPI events = entry.Api?.Event; + if (events != null) events.LeaveWorld -= entry.OnLeaveWorld; + } + + /// The mods with at least one registration, for diagnostics. + public static string[] RegisteredMods() + { + lock (Gate) + { + var ids = new string[Mods.Count]; + Mods.Keys.CopyTo(ids, 0); + return ids; + } + } + + /// + /// The passes declared for a slot, in registration order (mods by first registration). + /// The array is shared and rebuilt only when a registration changes; never modify it. + /// + public static OptimumPassRegistration[] ForSlot(EnumOptimumPass slot) + { + int index = (int)slot; + if (index < 0 || index >= Slots.Length) return Empty; + lock (Gate) + { + if (dirty) Rebuild(); + return Slots[index]; + } + } + + /// Whether is registered for any mod. + public static bool IsRegisteredWriter(OptimumMotionWriterDecl writer) + { + if (writer == null) return false; + lock (Gate) + { + foreach (ModEntry entry in Mods.Values) + { + if (entry.Writers.Contains(writer)) return true; + } + return false; + } + } + + /// + /// Opens the motion window for a registered writer around a renderer's draws. False when the + /// window did not open (OpenGL, TAA off, outside Opaque/AfterOIT, Primary not bound, an + /// unregistered writer); then do not call . + /// + public static bool BeginMotionWriter(OptimumMotionWriterDecl writer) + { + System.Func hook = MotionBeginHook; + return hook != null && IsRegisteredWriter(writer) && hook(writer); + } + + /// Closes the window a true opened. + public static void EndMotionWriter() + { + Action hook = MotionEndHook; + if (hook != null) hook(); + } + + private static ModEntry EntryFor(ICoreClientAPI capi, string modId) + { + if (!Mods.TryGetValue(modId, out ModEntry entry)) + { + entry = new ModEntry(modId); + Mods.Add(modId, entry); + } + if (entry.Api == null) + { + entry.Api = capi; + IClientEventAPI events = capi.Event; + if (events != null) events.LeaveWorld += entry.OnLeaveWorld; + } + return entry; + } + + private static void Changed() + { + dirty = true; + version++; + } + + private static void Rebuild() + { + var lists = new List[Slots.Length]; + foreach (ModEntry entry in Mods.Values) + { + foreach (OptimumPassRegistration registration in entry.Passes) + { + int slot = (int)registration.Decl.Slot; + (lists[slot] ??= new List()).Add(registration); + } + } + for (int i = 0; i < Slots.Length; i++) Slots[i] = lists[i] == null ? Empty : lists[i].ToArray(); + dirty = false; + } + } + + /// The client-API entry points: capi.RegisterOptimumPass(this, decl) from a mod system. + public static class OptimumModRenderExtensions + { + /// The id registrations of a mod system are stored under: its mod id, else its assembly name. + public static string OptimumModId(ModSystem system) + { + if (system == null) throw new ArgumentNullException(nameof(system)); + string id = system.Mod?.Info?.ModID; + return string.IsNullOrWhiteSpace(id) ? system.GetType().Assembly.GetName().Name : id; + } + + public static bool RegisterOptimumPass(this ICoreClientAPI capi, ModSystem system, OptimumPassDecl decl, out string reason) => + OptimumModPasses.Register(capi, OptimumModId(system), decl, out reason); + + public static bool RegisterOptimumMotionWriter(this ICoreClientAPI capi, ModSystem system, OptimumMotionWriterDecl writer, out string reason) => + OptimumModPasses.RegisterMotionWriter(capi, OptimumModId(system), writer, out reason); + + public static void UnregisterOptimumPasses(this ICoreClientAPI capi, ModSystem system) => + OptimumModPasses.UnregisterMod(OptimumModId(system)); + } +} diff --git a/sources/VintagestoryApi/VintagestoryAPI.csproj b/sources/VintagestoryApi/VintagestoryAPI.csproj index e25adced..2369e614 100644 --- a/sources/VintagestoryApi/VintagestoryAPI.csproj +++ b/sources/VintagestoryApi/VintagestoryAPI.csproj @@ -53,6 +53,7 @@ + From fe965af1ed07b07c2a943272bd2f8e06bceeb996 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:31:20 +0200 Subject: [PATCH 176/226] docs(vulkan): Phase 3b native render systems design Native renderers replace the GL-shaped route system by system behind the existing and new platform seams: pipelines from manifest programs with fixed state, passes with explicit reads and writes, typed push/record writes by placement, direct bindless resolution, write-mask motion windows. The runtime rewriter stays as the mod-shader adapter. Order: device API with the post and TAA chain, then chunks, entities, particles/decals/sky and GUI in parallel, then removal of the emulation with no vanilla caller. Acceptance is pixel identity with the old route per system. --- .gitignore | 2 + docs/vulkan-native-render-systems.md | 135 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 docs/vulkan-native-render-systems.md diff --git a/.gitignore b/.gitignore index 1be658f2..57f3b149 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,8 @@ docs/* !docs/research/ # ...and the native shader interface contract every program family is written against. !docs/vulkan-native-shaders.md +# ...and the native render systems design (Phase 3b) the system stages follow. +!docs/vulkan-native-render-systems.md # ...and the modder documentation for Vulkan-native mod support (passes, motion writers, shaders). !docs/vulkan-mod-support.md build-linux.sh diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md new file mode 100644 index 00000000..9b05818d --- /dev/null +++ b/docs/vulkan-native-render-systems.md @@ -0,0 +1,135 @@ +# Vulkan-native render systems (Phase 3b) + +Design for handoff item 5. Every vanilla render system on the Vulkan path draws through a renderer that owns its +pipelines, its descriptor use on the set convention and its per-draw data, instead of reaching the device through +the GL-shaped platform virtuals. OpenGL keeps `ClientPlatformWindows` unchanged ("OFF is vanilla"). + +Inputs: +- plan decision 7 and Phase 3b (`docs/vulkan-native-plan.md`); +- `docs/research/vulkan-descriptor-model.md`, "Native architecture for this renderer"; +- the shader contract (`docs/vulkan-native-shaders.md`): all 49 vanilla programs are native, and the runtime seam + links them from the manifest; +- three read-only maps of the tree (2026-09-16): the post and TAA chain, the world render systems, and the + GL-emulation layer. Their findings are restated below where they decide something. + +## 1. What the maps established + +- **Post and TAA are not native yet.** Every post and TAA override in `Platform/VulkanClientPlatform.Graph.cs` calls + `base.()`. The OpenGL body in `ClientPlatformWindows` runs on Vulkan, and each call reaches the device + through a GL-shaped virtual: + - state toggles land in `GlStateTracker`, which the device resolves into a pipeline key on every draw; + - textures bind by unit; + - uniforms are set by name through a location; + - draw-buffer masks select attachments. + + The graph declarations around those calls take the union of every texture a pass might read (both TAA history + parities, raw, resolved and sharpened scene), because the platform cannot see which one the OpenGL body picked. +- **What already has the target shape:** + - the frame graph (`FrameGraph`, `PassRecorder`, `BarrierBatcher`, plans, promoted clears); + - handle-based textures, meshes and targets; + - the bindless resolve (`BindlessTextureTable.Resolve` with kind checks, depth-read-only layouts and feedback + copies); + - the upload shadows (frame block, record, push) with dirty snapshots into the uniform ring; + - the native programs and their placement tables. + + None of that is OpenGL emulation, and it all stays. +- **What is emulation and leaves the Vulkan path system by system:** + - `GlStateTracker`'s setters and the per-draw key resolve; + - the texture-unit tables (`_boundTextures`, `_unitSamplerOverrides`, `SamplerUnits`); + - uniform dispatch by name and location; + - `RenderTargetManager`'s draw-buffer-mask and single-bound-target model as the way a pass says what it + writes; + - the GL-enum translators; + - the platform overrides that only forward those calls (`.State.cs`, and the GL-shaped parts of + `.Shaders.cs`, `.FrameBuffers.cs` and `.Textures.cs`). + +## 2. Decisions + +1. **The runtime rewriter stays, permanently, as the mod-shader adapter.** Plan decision 2 promises that mods + rendering through the game API keep working. Those mods set state, bind units and set uniforms by name. The + GL-shaped adapter therefore stays reachable for mod renderers and for any vanilla system not yet moved, retargeted + to the shared layout as it already is. Phase 3b's exit is that no *vanilla* render system uses it. The adapter + shrinks to exactly what mod programs need. +2. **Seams are the existing virtuals, overridden without calling base.** + - Post and TAA: `RenderPostprocessingEffects`, `RenderOptimumTaaResolve`, `RenderOptimumTaaSharpen`, + `RenderOptimumSkyMotion`, `MergeTransparentRenderPass`, `RenderFinalComposition` and + `BlitPrimaryToDefault` are virtual on `ClientPlatformAbstract`. + - World systems: they get transplanted seams in the library where no virtual exists. For example + `ChunkRenderer`'s pass methods call a platform virtual whose OpenGL body is today's body. + - Every new seam follows the decision-5 rules: Cecil listing, `callvirt`, no injected field initializers, and no + early touch of vanilla static classes. +3. **A native system reads client state, never GL state.** + - It takes the values the OpenGL body computes: settings, ambient manager, temporal frame, framebuffer list, SSAO + kernel. Where a value is private to `ClientPlatformWindows`, the seam passes it as an argument or a + transplanted accessor exposes it; fields are never widened. + - It computes its uniform values exactly as the OpenGL body does, so both backends produce the same image. +4. **Device API for native systems (`NativePasses`, device side).** + - **Pipelines.** A native renderer asks for a pipeline by (program, variant defines, fixed state: blend per + attachment, depth test/write/compare, cull, topology, colour write masks, target formats). + - The pipeline comes from the manifest program and the pipeline cache, and is created at load or on first + use, never re-derived from tracked GL state. + - The key is `GlStateTracker.PipelineKey`'s shape without the tracker. + - **Passes.** A pass is declared with explicit writes (target handle, colour slots, depth) and explicit reads + (texture handles, the one texture the system chose this frame). + - Load/store and transient hints follow the existing plan machinery. + - Passes still render into the existing framebuffer objects created by `SetupDefaultFrameBuffers`, so target + ownership and resize handling do not move in this phase. + - The draw-buffer mask is not consulted for a native pass. + - **Draws.** A native draw writes the program's push block and record through typed setters by placement: the + placement table resolved once at pipeline creation, not by name per draw. It resolves sampled textures to + bindless slots directly from the texture handle and sampler state, then records the draw: fullscreen + triangle, mesh, multi-draw or instanced. + - Motion windows are write masks of the pass or pipeline (the colour-write tiers), not `SetDrawBuffers` calls. +5. **Order and parallelism.** + 1. The device API plus the post and TAA chain, one stage: Optimum owns the chain end to end, and the chain proves + the API. + 2. Then, in parallel on the stable API: chunks (all passes including shadow and liquid motion); entities (animated, + shadow, held items through `standard`, `instanced`); particles, decals, sky, night sky, celestial, aurora and + clouds; GUI and text (`gui`, `guigear`, `guitopsoil`, `helditem`, `lines`, `texture2texture`, block + highlights, wireframe, autocamera). + 3. Last, removal: the emulation with no vanilla caller left leaves the Vulkan path, and the adapter that mods need + stays, under tests that pin the mod path. +6. **Behavioural identity is the acceptance rule.** + - Each moved system gets GPU readback tests that render the same inputs through the old route (the OpenGL body on + the Vulkan device) and the native route, and compare pixels. They are bitwise where both paths are, within 1/255 + where filtering allows. + - The TAA resolve keeps its rule-11 invariants and their tests. + - The motion attachment stays identical, per the temporal contract. + - Validation stays clean with `sync,best`. + - Temporal claims need multi-frame tests, and the in-game check runs on both backends with the headless harness. +7. **FSR input (open item from the post and TAA map).** `BlitPrimaryToDefault` upsamples `Primary` colour 0. The + native chain keeps exactly that input and does not reinterpret "the scene" per stage. Any change is a separate, + measured decision. + +## 3. Stage 1 scope: device API plus post and TAA chain + +- **Device:** + - native pipeline creation from manifest programs with explicit fixed state; + - native pass declaration with explicit reads and writes; + - typed push and record writes by placement; + - direct bindless resolution by texture handle; + - native draw recording; + - motion write masks per pass; + - stats: native passes, native draws, pipelines. +- **Platform:** a `NativePostChain` on the Vulkan platform implementing the seven post and TAA virtuals plus + `ApplyOptimumSceneSsao`'s composite, with the frame order and the conditions of the OpenGL body. + 1. OIT merge. + 2. Sky motion. + 3. SSAO and blur, then the AO composite into the scene. + 4. TAA resolve and sharpen. + 5. The bloom chain. + 6. God rays. + 7. FXAA luma or blit. + 8. Final composition. + 9. Debug view, FSR (EASU and RCAS) or blit. + + Each pass reads the one physical texture the chain chose. The motion-writer hosting for mod passes and the AO + compute pass keep working at their slots. +- **Tests:** + - old-route versus native-route pixel comparison for every pass under the settings sweep (SSAO 0/1/2, bloom, god + rays, FXAA, TAA on/off, render scale below 1 with FSR, debug view); + - a multi-frame TAA accumulation test through the native chain; + - declared reads that no longer union candidates; + - validation clean; + - the full suites. +- **Not in stage 1:** world systems, GUI, and removal of the emulation layer. From 836c5f69a4351a3359deeffe970838e7c6149dc9 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:35:02 +0200 Subject: [PATCH 177/226] wip(native-shaders): seam follow-ups - toolchain check on compile options only, mod shader scan rewriterPrograms at LinkProgram, OITaccumulation contract correction - NativeShaderLibrary.Load compares the options part of ShaderCompiler.Identity; a differing shaderc library is accepted (per-stage sha256 pins the SPIR-V) and noted in the status line - OptimumConfig reads schema 2 rewriterPrograms (missing, unreadable, failed or v1 report counts as all); VulkanDevice.ShaderProgramOverriddenByMods sends named programs through the rewriter, counted as rewritten, logged once; OPTIMUM_VK_NATIVE_SHADERS=force ignores the scan - docs section 2: runtime binds OITaccumulation by its real name, the Array alias is oracle-only; section 8 documents both items Verified: Optimum.Render.Vulkan.Tests 984/984 (sync,best validation, implicit layers off), Optimum.Tests 1191 passed 34 skipped, Optimum.Launcher.Tests 50/50, extract-patches and check-patches clean --- .../NativeShaderRuntimeTests.cs | 109 +++++++++++++++++- .../Platform/VulkanClientPlatform.cs | 1 + .../Shaders/NativeShaderLibrary.cs | 40 ++++++- Optimum.Render.Vulkan/VulkanDevice.cs | 53 ++++++++- .../shader-compatibility-config-tests.cs | 78 +++++++++++++ docs/vulkan-native-shaders.md | 39 +++++-- .../VintagestoryApi/Config/OptimumConfig.cs | 86 ++++++++++++++ 7 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 Optimum.Tests/shader-compatibility-config-tests.cs diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs index bad9e0df..e590f3ce 100644 --- a/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs @@ -161,6 +161,13 @@ public void TheDisableVariableWinsOverEveryOtherSource() Assert.Equal((NativeShaderLibrary.Mode.Directory, Path.Combine("/bin", "shaders-vk")), Pick(NativeShaderLibrary.Resolve(true, null, null, null, "/bin"))); + Assert.Equal(NativeShaderLibrary.Mode.Directory, NativeShaderLibrary.Resolve(null, "/dir", "force", null, "/bin").Mode); + Assert.True(NativeShaderLibrary.IgnoresModScan("force")); + Assert.True(NativeShaderLibrary.IgnoresModScan(" FORCE ")); + Assert.False(NativeShaderLibrary.IgnoresModScan("0")); + Assert.False(NativeShaderLibrary.IgnoresModScan("1")); + Assert.False(NativeShaderLibrary.IgnoresModScan(null)); + static (NativeShaderLibrary.Mode, string?) Pick((NativeShaderLibrary.Mode Mode, string? Path, string Reason) r) => (r.Mode, r.Path); } @@ -197,6 +204,40 @@ public void AManifestThatCannotBeTrustedIsRejectedWithOneReason() } } + /// + /// A manifest built with the same compile options by another shaderc build (another platform, another package) + /// is accepted: the per-stage SHA-256 pins the SPIR-V. The differing library is the note for the status line. + /// Different options are still refused. + /// + [Fact] + public void OnlyTheCompileOptionsOfTheToolchainMustMatch() + { + string directory = TemporaryDirectory(); + try + { + string options = ShaderCompiler.OptionsIdentity; + var manifest = new NativeShaderManifest { Toolchain = options + ";shaderc-sha256:linux" }; + File.WriteAllText(Path.Combine(directory, NativeShaderManifest.FileName), manifest.ToJson()); + + Assert.NotNull(NativeShaderLibrary.Load(directory, options + ";shaderc-sha256:linux", out string same)); + Assert.Equal("", same); + + Assert.NotNull(NativeShaderLibrary.Load(directory, options + ";silk-shaderc-2.23.0.0", out string otherLibrary)); + Assert.Contains("shaderc-sha256:linux", otherLibrary); + Assert.Contains("silk-shaderc-2.23.0.0", otherLibrary); + + Assert.Null(NativeShaderLibrary.Load(directory, options.Replace("performance", "size") + ";shaderc-sha256:linux", out string otherOptions)); + Assert.Contains("toolchain", otherOptions); + + Assert.Equal(("a;b", "c"), NativeShaderLibrary.SplitToolchain("a;b;c")); + Assert.Equal(("tool", ""), NativeShaderLibrary.SplitToolchain("tool")); + } + finally + { + Directory.Delete(directory, true); + } + } + /// A SPIR-V file whose bytes are not the manifest's is refused, and stays refused. [SkippableFact] public void ASpirvFileWhoseHashDisagreesIsRefused() @@ -393,6 +434,69 @@ public void TurningNativeShadersOffLinksEveryProgramThroughTheRewriter() } } + /// + /// The launcher's mod shader scan at the seam: a program it names links through the rewriter and counts as + /// rewritten while another links natively; a scan of "all" sends every program to the rewriter and says so in + /// the status line; OPTIMUM_VK_NATIVE_SHADERS=force (the device setting it maps to) ignores the scan. + /// + [SkippableFact] + public void AProgramTheModScanNamesLinksThroughTheRewriterUnlessForced() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets."); + NativeShaderBuildResult build = RequireFamilyOneBuild(); + string root = TemporaryDirectory(); + try + { + NativeShaderBuilder.Write(build, root); + string directory = Path.Combine(root, NativeShaderManifest.DirectoryName); + ShaderCorpus.ShaderVariant variant = Combinations[1].ToVariant(); + Func blitOnly = name => string.Equals(name, "blit", StringComparison.OrdinalIgnoreCase); + + Skip.IfNot(TryCreateDevice(_output, directory, null, out VulkanDevice? device, blitOnly, false), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + Assert.DoesNotContain("rewriter-only", seam.NativeShaderStatus); + int blit = LinkGlsl330(seam, "blit", "blit", variant); + int luma = LinkGlsl330(seam, "luma", "luma", variant); + int blitAgain = LinkGlsl330(seam, "blit", "blit", variant); + Assert.False(seam.IsNativeProgram(blit)); + Assert.False(seam.IsNativeProgram(blitAgain)); + Assert.True(seam.IsNativeProgram(luma), seam.GetError()); + Assert.Equal((1, 2, 0), seam.ShaderLinkCounts); + Assert.Contains(Render(seam, blit, "blit", InputTextures(seam)), b => b != 0); + AssertClean(seam); + } + + Skip.IfNot(TryCreateDevice(_output, directory, null, out device, _ => true, false), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + Assert.Contains("rewriter-only", seam.NativeShaderStatus); + foreach (string name in FamilyOne) + { + Assert.False(seam.IsNativeProgram(LinkGlsl330(seam, name, name, variant))); + } + Assert.Equal((0, FamilyOne.Length, 0), seam.ShaderLinkCounts); + AssertClean(seam); + } + + Skip.IfNot(TryCreateDevice(_output, directory, null, out device, _ => true, true), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + Assert.Contains("scan ignored", seam.NativeShaderStatus); + Assert.True(seam.IsNativeProgram(LinkGlsl330(seam, "blit", "blit", variant)), seam.GetError()); + Assert.Equal((1, 0, 0), seam.ShaderLinkCounts); + AssertClean(seam); + } + } + finally + { + Directory.Delete(root, true); + } + } + // ------------------------------------------------------------------ helpers private static readonly Lazy<(NativeShaderBuildResult? Result, string Reason)> FamilyOneBuild = new(() => BuildTree(FamilyOne)); @@ -446,11 +550,14 @@ private static void CorruptOneByte(string path) File.WriteAllBytes(path, bytes); } - private static bool TryCreateDevice(ITestOutputHelper output, string manifestDirectory, bool? enabled, out VulkanDevice? device) + private static bool TryCreateDevice(ITestOutputHelper output, string manifestDirectory, bool? enabled, out VulkanDevice? device, + Func? modScan = null, bool? ignoreModScan = null) { VulkanDevice created = NewDevice(); created.NativeShaderDirectory = manifestDirectory; created.NativeShadersEnabled = enabled; + created.ShaderProgramOverriddenByMods = modScan; + created.IgnoreModShaderScan = ignoreModScan; if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason)) { device = created; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index f3d8ae2a..dc9e960f 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -161,6 +161,7 @@ public partial class VulkanClientPlatform : ClientPlatformWindows internal Func DeviceFactory = () => new VulkanDevice { ShaderCacheDirectory = System.IO.Path.Combine(GamePaths.Cache, "optimum-vulkan"), + ShaderProgramOverriddenByMods = Vintagestory.API.Config.OptimumConfig.IsShaderProgramOverriddenByMods, }; /// Test seam: where the crash marker goes; null means . diff --git a/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs b/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs index f239def7..4c6a7057 100644 --- a/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs +++ b/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs @@ -92,9 +92,19 @@ public GlslUniformOracle(IEnumerable stageCodes) /// internal sealed class NativeShaderLibrary { - /// 0 forces the rewriter for every program (A/B runs). + /// + /// 0 forces the rewriter for every program (A/B runs); links natively even + /// where the launcher's mod shader scan says a mod replaced the program (development runs without the launcher). + /// public const string EnabledVariable = "OPTIMUM_VK_NATIVE_SHADERS"; + /// The value that ignores the mod shader scan. + public const string ForceValue = "force"; + + /// Whether 's value asks to ignore the mod shader scan. + public static bool IgnoresModScan(string? enabledVariable) => + string.Equals(enabledVariable?.Trim(), ForceValue, StringComparison.OrdinalIgnoreCase); + /// A sources/shaders-vk tree to compile at device start instead of the shipped manifest. public const string SourceVariable = "OPTIMUM_VK_SHADER_SOURCE"; @@ -150,7 +160,12 @@ public static (Mode Mode, string? Path, string Reason) Resolve( /// /// Reads shaders.manifest.json from . Null, with the one reason, when it is - /// missing, malformed, of another schema version, or built by another toolchain than . + /// missing, malformed, of another schema version, or built with other compile options than . + /// + /// A toolchain is ShaderCompiler.Identity: the options, then after the last ; the shaderc library + /// (its SHA-256, or the Silk package). Only the options must match: a manifest built on another platform or with + /// another shaderc build is still accepted, because every stage's SHA-256 pins the SPIR-V bytes themselves. The + /// differing library is then the non-empty of a loaded library, for the status line. /// public static NativeShaderLibrary? Load(string directory, string toolchain, out string reason) { @@ -173,16 +188,31 @@ public static (Mode Mode, string? Path, string Reason) Resolve( return null; } + reason = ""; if (manifest.Toolchain != toolchain) { - reason = "manifest " + path + " was built by toolchain '" + manifest.Toolchain + "', this renderer compiles with '" + toolchain + "'"; - return null; + (string builtOptions, string builtLibrary) = SplitToolchain(manifest.Toolchain); + (string ownOptions, string ownLibrary) = SplitToolchain(toolchain); + if (builtOptions != ownOptions) + { + reason = "manifest " + path + " was built by toolchain '" + manifest.Toolchain + "', this renderer compiles with '" + toolchain + "'"; + return null; + } + reason = "manifest built with shader library '" + builtLibrary + "', this renderer has '" + ownLibrary + + "' (same options; the SPIR-V is pinned by its per-stage sha256)"; } - reason = ""; return new NativeShaderLibrary(manifest, path, directory, null); } + /// A toolchain identity split at its last ;: the compile options and the shaderc library. + internal static (string Options, string Library) SplitToolchain(string? toolchain) + { + toolchain ??= ""; + int split = toolchain.LastIndexOf(';'); + return split < 0 ? (toolchain, "") : (toolchain[..split], toolchain[(split + 1)..]); + } + /// /// Compiles a source tree through , the offline tool's library. Programs /// that built are usable even when others failed; then names the failures. diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 19cd2cc4..9aad9537 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -71,6 +71,21 @@ public sealed unsafe class VulkanDevice : IDisposable /// internal string? NativeShaderDirectory { get; set; } + /// + /// The launcher's mod shader scan (OptimumConfig.IsShaderProgramOverriddenByMods): true for a pass name + /// whose GLSL a mod replaced, and for "all" when every program is rewriter-only. Such a program links + /// through the rewriter from the mod's source instead of the native SPIR-V. Null consults no scan (tests, a + /// device outside the client); OPTIMUM_VK_NATIVE_SHADERS=force ignores it. Read at . + /// + public Func? ShaderProgramOverriddenByMods { get; set; } + + /// True ignores as OPTIMUM_VK_NATIVE_SHADERS=force does; null follows the environment. For tests. + internal bool? IgnoreModShaderScan { get; set; } + + /// The scan consults: null when there is none or it is ignored. + private Func? _modShaderScan; + private readonly HashSet _modOverrideLogged = new(StringComparer.OrdinalIgnoreCase); + /// What made of the native shaders: the origin and program count, or why they are off. internal string NativeShaderStatus { get; private set; } = "not loaded"; @@ -1184,7 +1199,13 @@ public int LinkProgram(IShaderProgram program) TranslatedProgram? native = null; bool nativeFailed = false; string nativeDetail = ""; - if (_nativeShaders != null) + if (_nativeShaders != null && _modShaderScan != null && _modShaderScan(passName)) + { + // A mod replaced this program's GLSL (or the scan cannot rule it out): the native SPIR-V would draw + // vanilla over the mod, so the mod's source goes through the rewriter. Rewritten, not failed. + ReportModOverride(passName); + } + else if (_nativeShaders != null) { NativeShaderLibrary.Outcome outcome = _nativeShaders.TryLink(passName, stages, out native, out nativeDetail); nativeFailed = outcome == NativeShaderLibrary.Outcome.Failed; @@ -1287,13 +1308,43 @@ private void LoadNativeShaders() _ => null, }; + string enabledVariable = Environment.GetEnvironmentVariable(NativeShaderLibrary.EnabledVariable) ?? ""; + bool ignoreScan = IgnoreModShaderScan ?? NativeShaderLibrary.IgnoresModScan(enabledVariable); + _modShaderScan = ignoreScan ? null : ShaderProgramOverriddenByMods; + NativeShaderStatus = _nativeShaders == null ? "off: " + reason : _nativeShaders.Manifest.Programs.Count + " programs from " + _nativeShaders.Origin + (reason.Length > 0 ? "; " + reason : ""); + if (_nativeShaders != null && ignoreScan && ShaderProgramOverriddenByMods != null) + { + NativeShaderStatus += "; mod shader scan ignored (" + NativeShaderLibrary.EnabledVariable + "=" + NativeShaderLibrary.ForceValue + ")"; + } + else if (_nativeShaders != null && _modShaderScan != null && _modShaderScan(AllShaderProgramsEntry)) + { + NativeShaderStatus += "; the mod shader scan makes every program rewriter-only (no report, a failed scan, or a shaderincludes override)"; + } LogShaderLine("[Optimum] shaders: native " + NativeShaderStatus); } + /// The scan's entry for every program (OptimumConfig.AllShaderPrograms, the scanner's AllPrograms). + internal const string AllShaderProgramsEntry = "all"; + + /// Logs, once per program the manifest has, that the mod shader scan sent it to the rewriter. + private void ReportModOverride(string passName) + { + if (_nativeShaders?.Manifest.FindProgram(passName) == null) return; + bool all = _modShaderScan!(AllShaderProgramsEntry); + lock (_modOverrideLogged) + { + if (!_modOverrideLogged.Add(passName)) return; + } + string line = "[Optimum] shaders: native '" + passName + "' linked through the rewriter: " + + (all ? "the mod shader scan makes every program rewriter-only" : "a mod replaces its GLSL (launcher shader scan)"); + LogShaderLine(line); + if (RenderTrace.Enabled) RenderTrace.Write(line); + } + private void ReportNativeFailure(string passName, string detail) { string line = "[Optimum] shaders: native '" + passName + "' failed, linked through the rewriter: " + detail; diff --git a/Optimum.Tests/shader-compatibility-config-tests.cs b/Optimum.Tests/shader-compatibility-config-tests.cs new file mode 100644 index 00000000..fb33e16a --- /dev/null +++ b/Optimum.Tests/shader-compatibility-config-tests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using Vintagestory.API.Config; +using Xunit; + +namespace Optimum.Tests; + +/// +/// How OptimumConfig reads the launcher's shader compatibility report: schema 2's rewriterPrograms, the +/// programs a mod's GLSL replaced, which the Vulkan renderer links through the rewriter instead of the native +/// SPIR-V (docs/vulkan-native-shaders.md section 8). Anything that cannot say which programs are safe counts as +/// "all", the scanner's own conservative rule. +/// +/// The pure parse is tested rather than SetDataPath: loading a report sets the static scan state every +/// other OptimumConfig test reads in parallel (a failed scan disables the shader features). +/// +public sealed class ShaderCompatibilityConfigTests +{ + private static IReadOnlyCollection Parse(string? json) => OptimumConfig.ParseShaderRewriterPrograms(json); + + private static bool Overridden(IReadOnlyCollection programs, string name) => + OptimumConfig.IsShaderProgramOverriddenBy(programs, name); + + [Fact] + public void NamedProgramsAreOverriddenAndOthersAreNot() + { + var programs = Parse("""{ "schemaVersion": 2, "scanFailed": false, "rewriterPrograms": ["blit", " chunkopaque ", ""] }"""); + Assert.Equal(new[] { "blit", "chunkopaque" }, programs); + Assert.True(Overridden(programs, "blit")); + Assert.True(Overridden(programs, "ChunkOpaque")); + Assert.False(Overridden(programs, "final")); + Assert.False(Overridden(programs, OptimumConfig.AllShaderPrograms)); + Assert.False(Overridden(programs, "")); + } + + [Fact] + public void AnEmptyListLeavesEveryProgramNative() + { + var programs = Parse("""{ "schemaVersion": 2, "scanFailed": false, "rewriterPrograms": [] }"""); + Assert.Empty(programs); + Assert.False(Overridden(programs, "final")); + Assert.False(Overridden(programs, OptimumConfig.AllShaderPrograms)); + } + + [Fact] + public void TheAllEntryOverridesEveryProgram() => + AssertAll(Parse("""{ "schemaVersion": 2, "scanFailed": false, "rewriterPrograms": ["all"] }""")); + + [Fact] + public void AReportWithoutTheFieldCountsAsAll() => + AssertAll(Parse("""{ "schemaVersion": 2, "scanFailed": false, "disabledFeatures": [] }""")); + + [Fact] + public void AVersionOneReportCountsAsAll() + { + AssertAll(Parse("""{ "schemaVersion": 1, "scanFailed": false, "rewriterPrograms": [] }""")); + AssertAll(Parse("""{ "scanFailed": false, "disabledFeatures": [] }""")); + } + + [Fact] + public void AFailedScanCountsAsAll() => + AssertAll(Parse("""{ "schemaVersion": 2, "scanFailed": true, "rewriterPrograms": [] }""")); + + [Fact] + public void AMissingOrUnreadableReportCountsAsAll() + { + AssertAll(Parse(null)); + AssertAll(Parse("")); + AssertAll(Parse("{ not json")); + AssertAll(Parse("null")); + } + + private static void AssertAll(IReadOnlyCollection programs) + { + Assert.Equal(new[] { OptimumConfig.AllShaderPrograms }, programs); + Assert.True(Overridden(programs, "final")); + Assert.True(Overridden(programs, "blit")); + } +} diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 19b16731..37965393 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -139,9 +139,12 @@ records the decision here): **Oracle decisions (2026-09-15, after the family ports):** - `uniform sampler2DArray ` (`transparentcompose`'s `OITaccumulation`): `collectUniformNames` has no - sampler2DArray, so the client registers the name `Array` with a sampler2D type at that texture unit. The - harness compares the declared name and type (`Oracle.ArraySamplerAliases` records the alias), and the runtime - answers `GetUniformLocation("Array")` and the unit bookkeeping with the declared sampler's slot. + sampler2DArray, so its pattern reads the name `Array` with a sampler2D type at that texture unit. The harness + compares the declared name and type (`Oracle.ArraySamplerAliases` records the alias). The alias exists **only in + the static oracle**: the client binds the sampler by its real name + (`ShaderProgramTransparentcompose.OITaccumulation2D` calls `BindTexture2D("OITaccumulation", value, 4)`, + `SystemRenderOITLayers` calls `SetProgramSamplerUnit(..., "OITaccumulation", 7)`), so the runtime answers the + declared name and needs no `Array` alias (corrected 2026-09-16). - A set-0 frame texture a program declares itself without including the port that owns it (`cloudvolumetric`'s `liquidDepth`) is the `bindings.glsl` declaration every native stage already has, and counts as present when its name and type match `SetConvention.FrameTextures`, exactly as the rewriter places it. @@ -414,6 +417,8 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg - **Log line:** one line reports `[Optimum] shaders: N native, M rewritten, K failed`. - **Environment overrides:** - `OPTIMUM_VK_NATIVE_SHADERS=0` forces the rewriter for A/B runs. + - `OPTIMUM_VK_NATIVE_SHADERS=force` links natively even where the launcher's mod shader scan sends a program to + the rewriter (development runs without the launcher; see "Mod shader scan" below). - `OPTIMUM_VK_SHADER_SOURCE=` compiles the source tree at runtime for the development loop. - **Delivered (2026-09-16, the runtime seam):** `Shaders/NativeShaderLibrary.cs`, `Shaders/ProgramInterfaceLayout.Native.cs`, the `LinkProgram` branch, `NativeShaderRuntimeTests`. @@ -421,10 +426,30 @@ vec4 optimumWriteReactiveOnly(float reactive); // rg `OPTIMUM_VK_NATIVE_SHADERS=0` or the device's `NativeShadersEnabled` is false; the device's `NativeShaderDirectory` (tests); `OPTIMUM_VK_SHADER_SOURCE`, compiled through `NativeShaderBuilder.Build` (programs that built are used, the build errors are named in the load line); else `shaders-vk/` beside - `Optimum.Render.Vulkan.dll`. A missing or malformed manifest, another schema version, or a `toolchain` other - than the device compiler's `ShaderCompiler.Identity` (it names the Silk shaderc package, so it is the same - on every platform) turns native shaders off with one line: `[Optimum] shaders: native off: `. A - loaded one logs `[Optimum] shaders: native programs from `. + `Optimum.Render.Vulkan.dll`. A missing or malformed manifest, another schema version, or a `toolchain` whose + compile options differ from the device compiler's turns native shaders off with one line: + `[Optimum] shaders: native off: `. A loaded one logs `[Optimum] shaders: native programs from `. + - **Toolchain check (2026-09-16):** `ShaderCompiler.Identity` is `OptionsIdentity` + `;` + the shaderc library + identity (the SHA-256 of `shaderc_shared` beside the renderer, else the Silk package name), so it differs between + Linux, Windows and macOS and between shaderc builds. `NativeShaderLibrary.Load` compares only the options part + (before the last `;`): every stage's SHA-256 already pins the SPIR-V bytes, so a manifest built on another + platform or by another shaderc build is accepted, and the differing library is appended to the status line + (`...; manifest built with shader library '', this renderer has '' (same options; ...)`). Different + options are refused as before. Pinned by `NativeShaderRuntimeTests.OnlyTheCompileOptionsOfTheToolchainMustMatch` + and `AManifestThatCannotBeTrustedIsRejectedWithOneReason`. + - **Mod shader scan (2026-09-16):** the launcher's report (schema 2) lists in `rewriterPrograms` the program base + names whose GLSL a mod asset replaces, or the single entry `all` (a shaderincludes override, or a scan that + failed). `OptimumConfig.IsShaderProgramOverriddenByMods(passName)` is true when the list names the program + (case-insensitive) or holds `all`; a missing, unreadable, failed or v1 report, or one without the field, counts + as `all`, the scanner's own conservative rule. The platform hands that query to the device + (`VulkanDevice.ShaderProgramOverriddenByMods`; null, as in tests, consults no scan). `LinkProgram` asks it + before the manifest lookup: an overridden program links through the rewriter from the mod's source, counts as + **rewritten** (not failed), and a program the manifest has logs once: + `[Optimum] shaders: native '' linked through the rewriter: a mod replaces its GLSL (launcher shader scan)`. + When the scan answers `all`, the load line says `the mod shader scan makes every program rewriter-only`; + under `OPTIMUM_VK_NATIVE_SHADERS=force` it says `mod shader scan ignored` and the scan is not consulted. Pinned by + `Optimum.Tests/shader-compatibility-config-tests.cs` (names, `all`, missing field, v1, failed, missing and + malformed report) and `NativeShaderRuntimeTests.AProgramTheModScanNamesLinksThroughTheRewriterUnlessForced`. - **SPIR-V is verified lazily:** read and SHA-256-checked the first time a variant links; a mismatch or an unreadable file fails that variant for the life of the device. - **Variant key** (`NativeShaderLibrary.VariantKeyFor`): the defines of both stages' prefixes, vertex then diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index 335da811..5cf1b6f4 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -483,6 +483,8 @@ public static class OptimumConfig // outside OptimumConfigData so a mod cannot make a runtime fallback // persistent by changing its shader files. private static readonly HashSet _shaderCompatibilityDisabledFeatures = new(StringComparer.OrdinalIgnoreCase); + // "all" until a schema 2 report says otherwise: no report read yet is no report. + private static readonly HashSet _shaderCompatibilityRewriterPrograms = new(StringComparer.OrdinalIgnoreCase) { AllShaderPrograms }; private static bool _shaderCompatibilityScanFailed; private static bool _greedyMeshVertexShaderReady; private static bool _greedyMeshFragmentShaderReady; @@ -587,6 +589,24 @@ public static bool IsShaderFeatureDisabled(string feature) => public static bool IsFeatureExplicitlyDisabled(string feature) => _shaderCompatibilityDisabledFeatures.Contains(feature); + /// + /// The entry of the scan's rewriterPrograms (report schema 2) that stands for every program: + /// a shaderincludes override, or a scan that could not finish. + /// + public const string AllShaderPrograms = "all"; + + /// + /// Whether the launcher's scan found a mod replacing 's GLSL, so the Vulkan + /// renderer must build it through the rewriter from that source instead of linking the native SPIR-V + /// (docs/vulkan-native-shaders.md section 8). True when rewriterPrograms names the program + /// (case-insensitive, the scanner lowercases base names) or holds . + /// A missing, unreadable, failed or pre-schema-2 report counts as , + /// the scanner's own conservative rule. Asking for itself answers + /// whether every program is rewriter-only. + /// + public static bool IsShaderProgramOverriddenByMods(string passName) => + IsShaderProgramOverriddenBy(_shaderCompatibilityRewriterPrograms, passName); + public static void SetGreedyMeshShaderAbi(bool vertexShaderReady, bool fragmentShaderReady) { _greedyMeshVertexShaderReady = vertexShaderReady; @@ -604,6 +624,7 @@ private static void LoadShaderCompatibilityReport() _shaderCompatibilityDisabledFeatures.Clear(); _shaderCompatibilityScanFailed = true; _shaderCompatibilityFingerprint = null; + SetRewriterProgramsToAll(); ResetShaderCompatibilityAfterReload(); if (_dataPath == null) return; @@ -628,6 +649,9 @@ private static void LoadShaderCompatibilityReport() } } + _shaderCompatibilityRewriterPrograms.Clear(); + foreach (string program in RewriterProgramsOf(report)) _shaderCompatibilityRewriterPrograms.Add(program); + _shaderCompatibilityScanFailed = report.ScanFailed; _shaderCompatibilityFingerprint = report.Fingerprint; } @@ -636,7 +660,67 @@ private static void LoadShaderCompatibilityReport() _shaderCompatibilityDisabledFeatures.Clear(); _shaderCompatibilityScanFailed = true; _shaderCompatibilityFingerprint = null; + SetRewriterProgramsToAll(); + } + } + + /// + /// The rewriterPrograms of a shader compatibility report's JSON, as + /// reads them: the named programs, or the single for a null, unreadable, pre-schema-2 + /// or failed report and for one without the field. Pure; the loaded state is not touched. + /// + public static IReadOnlyCollection ParseShaderRewriterPrograms(string? reportJson) + { + if (string.IsNullOrWhiteSpace(reportJson)) return new[] { AllShaderPrograms }; + try + { + var report = JsonSerializer.Deserialize(reportJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + return RewriterProgramsOf(report); } + catch (Exception) + { + return new[] { AllShaderPrograms }; + } + } + + /// Whether (see ) send to the rewriter. + public static bool IsShaderProgramOverriddenBy(IEnumerable rewriterPrograms, string passName) + { + foreach (string program in rewriterPrograms) + { + if (string.Equals(program, AllShaderPrograms, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrEmpty(passName) && string.Equals(program, passName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + return false; + } + + // Schema 2 carries the programs a mod's GLSL replaced. A v1 report, a report without the list, or a + // failed scan cannot say which programs are safe, so all of them stay rewriter-only. + private static List RewriterProgramsOf(ShaderCompatibilityState? report) + { + var programs = new List(); + if (report == null || report.SchemaVersion < 2 || report.RewriterPrograms == null || report.ScanFailed) + { + programs.Add(AllShaderPrograms); + return programs; + } + foreach (string? program in report.RewriterPrograms) + { + if (!string.IsNullOrWhiteSpace(program)) programs.Add(program.Trim()); + } + return programs; + } + + private static void SetRewriterProgramsToAll() + { + _shaderCompatibilityRewriterPrograms.Clear(); + _shaderCompatibilityRewriterPrograms.Add(AllShaderPrograms); } private static string? _configPath; @@ -984,6 +1068,8 @@ public static void Save() private sealed class ShaderCompatibilityState { + public int SchemaVersion { get; set; } + public List? RewriterPrograms { get; set; } public bool ScanFailed { get; set; } public string? Fingerprint { get; set; } public List? DisabledFeatures { get; set; } From b3f36c6b2c89b6ce44a23e8831e00793101f2340 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:45:51 +0200 Subject: [PATCH 178/226] wip(native-shaders): AO port - OPTIMUMAO as specialization constant 12, class-channel writes in chunkopaque/entityanimated/standard, scene-ssao GTAO compose branch with attenuation OPTIMUMAO_MULTIBOUNCE is a native constant fixed to 0 in scene-ssao.frag; aoAlbedo declared unconditionally. Verified: parity/specialization/runtime/AO filter 130/130; Optimum.Render.Vulkan.Tests 1026/1026 (no SYNC- output); Optimum.Tests -c Release 1221 passed, 34 skipped, 0 failed. --- .../NativeShaderRuntimeTests.cs | 1 + .../Shaders/SpecializationConvention.cs | 3 ++ docs/vulkan-native-shaders.md | 13 ++++++- sources/shaders-vk/chunkopaque.frag | 9 +++++ sources/shaders-vk/entityanimated.frag | 4 ++ .../shaders-vk/include/specialization.glsl | 3 ++ sources/shaders-vk/scene-ssao.frag | 38 ++++++++++++++++++- sources/shaders-vk/scene-ssao.interface.glsl | 7 +++- sources/shaders-vk/standard.frag | 5 +++ 9 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs index e590f3ce..04fd12bc 100644 --- a/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs @@ -135,6 +135,7 @@ public void SpecializationConstantsTakeTheirDefinesValues() ["OPTIMUM_SHINYEFFECT"] = corpus.ShinyEffect, ["OPTIMUM_SHADOWQUALITY"] = corpus.ShadowQuality, ["OPTIMUM_WAVINGSTUFF"] = corpus.WavingStuff, ["OPTIMUM_MINBRIGHT"] = corpus.MinBright, ["OPTIMUM_GREEDYMESH_GRAD"] = 0, ["OPTIMUM_DYNLIGHTS"] = corpus.DynLights, + ["OPTIMUM_OPTIMUMAO"] = corpus.OptimumAo, }; foreach (NativeSpecialization.Entry entry in specialization.Entries) { diff --git a/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs b/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs index bc9f3dbd..b171346a 100644 --- a/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs +++ b/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs @@ -42,5 +42,8 @@ internal static class SpecializationConvention // value still selects fogandlight.vsh's no-point-light path, which also skips the night // vision, MINBRIGHT and contrast terms. Keeping that path needs the value. new(11, "OPTIMUM_DYNLIGHTS", "int", "0", "DYNLIGHTS"), + // Optimum AO (docs/research/ambient-occlusion.md C.5, C.11): gates the class-channel writes and + // scene-ssao's GTAO compose branch, never an output or a varying. + new(12, "OPTIMUM_OPTIMUMAO", "int", "0", "OPTIMUMAO"), }; } diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 37965393..e4fca01b 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -268,10 +268,21 @@ The prefix is `ShaderRegistry.registerDefaultShaderCodePrefixes`, `ShaderRegistr path (`applyLightWithoutPointLight`), which also skips the night-vision, `MINBRIGHT` and 1.05-contrast terms, so a loop bound alone would change pixels with dynamic lights set to 0. - `MAXANIMATEDELEMENTS` is fixed. + - `OPTIMUMAO` (Optimum AO, stamped 0 or 1 since the GTAO merge) gates only uniform and sampler declarations + and code (the class-channel writes into `gNormal.w` in `chunkopaque`, `entityanimated`, `standard`, and + `scene-ssao`'s GTAO compose branch), never an output or varying, so it is the constant `OPTIMUM_OPTIMUMAO` + (added 2026-09-16). `scene-ssao`'s `#if OPTIMUMAO > 0 if (optimumAoMode == 1) {...} else #endif {...}` becomes + `if (OPTIMUM_OPTIMUMAO > 0 && optimumAoMode == 1) {...} else {...}`, the same control flow. + - `OPTIMUMAO_MULTIBOUNCE` is never stamped by `ShaderRegistry` (the albedo hook of + `docs/research/ambient-occlusion.md` C.11, off in the first version), so it is neither a constant nor an + axis: `scene-ssao.frag` declares a plain `const int OPTIMUM_AO_MULTIBOUNCE = 0` and branches on it, which + compiles the multibounce code out. `optimumMultiBounce` and the `aoAlbedo` sampler slot are declared + unconditionally, as the oracle sees them; the optimiser drops both. When the albedo hook ships it becomes a + real constant with a stamped define. - **Ids** (`specialization.glsl`, mirrored in `Shaders/SpecializationConvention.cs`): 0 `OPTIMUM_FXAA`, 1 `OPTIMUM_SSAOLEVEL`, 2 `OPTIMUM_NORMALVIEW`, 3 `OPTIMUM_BLOOM`, 4 `OPTIMUM_GODRAYS`, 5 `OPTIMUM_FOAMEFFECT`, 6 `OPTIMUM_SHINYEFFECT`, 7 `OPTIMUM_SHADOWQUALITY`, 8 `OPTIMUM_WAVINGSTUFF`, 9 `OPTIMUM_MINBRIGHT` (float), - 10 `OPTIMUM_GREEDYMESH_GRAD`, 11 `OPTIMUM_DYNLIGHTS`. Every other constant is `int`. + 10 `OPTIMUM_GREEDYMESH_GRAD`, 11 `OPTIMUM_DYNLIGHTS`, 12 `OPTIMUM_OPTIMUMAO`. Every other constant is `int`. - Defaults are 0, the value an undefined macro has in `#if` and the fallback `fogandlight.vsh` defines; the runtime specializes every constant. - `SpecializationConventionTests` checks the include against the C# table, the ids and types in the diff --git a/sources/shaders-vk/chunkopaque.frag b/sources/shaders-vk/chunkopaque.frag index dacabfe2..1a6a0425 100644 --- a/sources/shaders-vk/chunkopaque.frag +++ b/sources/shaders-vk/chunkopaque.frag @@ -144,6 +144,11 @@ void main() #if GBUFFER == 1 outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); outGNormal = gnormal; + if (OPTIMUM_OPTIMUMAO > 0) { + // Optimum AO class channel (docs/research/ambient-occlusion.md C.5): plants, grass and + // cross-quad blocks draw in the no-cull opaque pass (haxyFade), and are thin like leaves. + if (haxyFade > 0) outGNormal.w = 1.0; + } #endif if (OPTIMUM_NORMALVIEW > 0) { @@ -207,6 +212,10 @@ void main() #if GBUFFER == 1 outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); outGNormal = gnormal; + if (OPTIMUM_OPTIMUMAO > 0) { + // Optimum AO class channel (C.5): the no-cull opaque pass (plants, grass, cross-quads) is thin. + if (haxyFade > 0) outGNormal.w = 1.0; + } #endif if (OPTIMUM_NORMALVIEW > 0) { diff --git a/sources/shaders-vk/entityanimated.frag b/sources/shaders-vk/entityanimated.frag index 172f0d79..443138c5 100644 --- a/sources/shaders-vk/entityanimated.frag +++ b/sources/shaders-vk/entityanimated.frag @@ -161,6 +161,10 @@ void main() { // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it #if USEOIT == 0 && GBUFFER == 1 outGPosition.w=1; + if (OPTIMUM_OPTIMUMAO > 0) { + // Optimum AO class channel (C.5, C.9): the first-person hand writes the hand class. + outGNormal.w = -1.0; + } #endif #endif diff --git a/sources/shaders-vk/include/specialization.glsl b/sources/shaders-vk/include/specialization.glsl index a3bc5b51..165ec5e6 100644 --- a/sources/shaders-vk/include/specialization.glsl +++ b/sources/shaders-vk/include/specialization.glsl @@ -29,5 +29,8 @@ layout(constant_id = 10) const int OPTIMUM_GREEDYMESH_GRAD = 0; // pointLightQuantity bounds the loop). Its zero value still selects fogandlight's // no-point-light path, so the value stays a constant. layout(constant_id = 11) const int OPTIMUM_DYNLIGHTS = 0; +// Optimum AO: the class-channel writes and scene-ssao's GTAO compose branch. Gates no +// output or varying, so it is a constant, not an axis. +layout(constant_id = 12) const int OPTIMUM_OPTIMUMAO = 0; #endif diff --git a/sources/shaders-vk/scene-ssao.frag b/sources/shaders-vk/scene-ssao.frag index 013557e2..9d5eac6f 100644 --- a/sources/shaders-vk/scene-ssao.frag +++ b/sources/shaders-vk/scene-ssao.frag @@ -1,21 +1,55 @@ #version 450 #extension GL_EXT_scalar_block_layout : require #extension GL_GOOGLE_include_directive : require -// Native port of scene-ssao.fsh (docs/vulkan-native-shaders.md). The SSAOLEVEL > 1 preprocessor branch is a -// specialization-constant branch with the same expression; it gates no declaration, so no variant axes. +// Native port of scene-ssao.fsh (docs/vulkan-native-shaders.md). The SSAOLEVEL > 1 and OPTIMUMAO > 0 preprocessor +// branches are specialization-constant branches with the same expressions; they gate no output, so no variant +// axes. OPTIMUMAO_MULTIBOUNCE is never stamped by ShaderRegistry: it is a native constant fixed to 0 here, so the +// multibounce code compiles out while aoAlbedo stays declared as the oracle sees it (section 5). #include "bindings.glsl" #include "frame.glsl" #include "specialization.glsl" #include "scene-ssao.interface.glsl" +const int OPTIMUM_AO_MULTIBOUNCE = 0; + +// The albedo hook (C.11): GTAO 2016 eq. 10 needs the surface albedo, which the lit LDR scene +// colour is not. Never stamped in the first version; the platform refuses the tone without +// an albedo texture (GtaoSettings.EffectiveTone). +float optimumMultiBounce(float visibility, vec3 albedo) +{ + vec3 a = 2.0404 * albedo - 0.3324; + vec3 b = -4.7951 * albedo + 0.6417; + vec3 c = 2.7552 * albedo + 0.6903; + vec3 bounced = max(vec3(visibility), ((visibility * a + b) * visibility + c) * visibility); + // The Multiply blend carries one factor; a coloured term needs a colour multiply blend. + return dot(bounced, vec3(0.2126, 0.7152, 0.0722)); +} + layout(location = 0) in vec2 texCoord; layout(location = 0) out vec4 outColor; void main() { float ao = texture(optimumTextures2D[ssaoScene], texCoord).r; + // GLSL 330: `#if OPTIMUMAO > 0 if (optimumAoMode == 1) { ... } else #endif { ... }`; the same control flow. + if (OPTIMUM_OPTIMUMAO > 0 && optimumAoMode == 1) + { + // Same resolution as Primary: nearest texel, no upsample and no min-of-two-rows. + ivec2 texel = ivec2(gl_FragCoord.xy); + ao = texelFetch(optimumTextures2D[ssaoScene], texel, 0).r; + if (OPTIMUM_AO_MULTIBOUNCE > 0) { + ao = optimumMultiBounce(ao, texelFetch(optimumTextures2D[aoAlbedo], texel, 0).rgb); + } + // vanilla ssao.fsh: attenuate = gPosition.w + 0.75 * (1 - revealage), occ = 1 - (1 - ao) * (1 - attenuate) + float attenuate = texelFetch(optimumTextures2D[gPositionScene], texel, 0).w + + max(0.0, 1.0 - texelFetch(optimumTextures2D[revealageScene], texel, 0).r) * 0.75; + ao = 1.0 - (1.0 - ao) * (1.0 - attenuate); + } + else + { if (OPTIMUM_SSAOLEVEL > 1) { ao = min(ao, texture(optimumTextures2D[ssaoScene], texCoord - vec2(0.0, invRenderHeight)).r); } + } // EnumBlendMode.Multiply: dstRGB * (1 - srcAlpha). RGB is not read. outColor = vec4(0.0, 0.0, 0.0, 1.0 - clamp(ao, 0.0, 1.0)); } diff --git a/sources/shaders-vk/scene-ssao.interface.glsl b/sources/shaders-vk/scene-ssao.interface.glsl index cd6a52b1..15d0fec4 100644 --- a/sources/shaders-vk/scene-ssao.interface.glsl +++ b/sources/shaders-vk/scene-ssao.interface.glsl @@ -1,11 +1,16 @@ // Program interface of scene-ssao (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per -// Use(), so the push block holds only the sampler slot and every other uniform is a record member. +// Use(), so the push block holds only the sampler slots (GLSL 330 declaration order) and every other uniform is +// a record member. layout(push_constant, scalar) uniform OptimumDraw { OPTIMUM_SAMPLER_SLOT(sampler2D, ssaoScene); + OPTIMUM_SAMPLER_SLOT(sampler2D, gPositionScene); + OPTIMUM_SAMPLER_SLOT(sampler2D, revealageScene); + OPTIMUM_SAMPLER_SLOT(sampler2D, aoAlbedo); }; layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scalar) uniform OptimumProgram { float invRenderHeight; + int optimumAoMode; }; diff --git a/sources/shaders-vk/standard.frag b/sources/shaders-vk/standard.frag index ee71415f..7123b493 100644 --- a/sources/shaders-vk/standard.frag +++ b/sources/shaders-vk/standard.frag @@ -142,6 +142,11 @@ void main() { // A bit hacky: We use ALLOWDEPTHOFFSET for the first person rendering. SSAO seems to break on it, so we disable it #if GBUFFER == 1 outGPosition.w=1; + if (OPTIMUM_OPTIMUMAO > 0) { + // Optimum AO class channel (C.5, C.9): the hand view has its own projection; the AO pass + // leaves these pixels at visibility 1 and treats them as solid when sampled. + outGNormal.w = -1.0; + } #endif #endif From 8aa6d9333bbadb596ef08a7f4f848b1482661ae0 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 00:53:13 +0200 Subject: [PATCH 179/226] docs: in-game headless verification of the native shaders and AO on both backends --- docs/vulkan-branch-progress.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 0bf31eb0..d9120935 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -1,6 +1,6 @@ # feat/vulkan-taa: handoff -Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-15 at c6af6f9. +Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-16 at 14f0779. - Plan of record: `docs/vulkan-native-plan.md` (decisions 1-9, phases, risks). - Research the designs follow: `docs/research/` (caching, descriptor model, bindless, XeGTAO, validation). @@ -203,6 +203,26 @@ Windows run above. Windows implicit layers disabled, and record the driver version and `vulkaninfo --summary` (present modes, image counts) next to it. +### Test state (Linux notebook, 2026-09-16, at 14f0779): the native shaders in the real client + +Headless captures of `serene cave world` (`scripts/dev/headless-capture.sh`, 5 frames from in-world frame 300, +window never mapped), implicit layers off, `sync,best` validation to a file. The dev client was deployed with +`make deploy INSTALL_DIR=/nonexistent-...` so the user's own install was not touched. + +- **Vulkan, native shaders forced** (`OPTIMUM_VK_NATIVE_SHADERS=force`): `[Optimum] shaders: 63 native, 4 rewritten, + 0 failed` (the 4 have no manifest entry: the inline `MinimalGui`, the mod-registered `optimum-map` and two + registration variants; nothing fell back with a reason). **0 validation errors, 0 `SYNC-` messages.** GTAO ran + (up to 177 compute passes per stats sample). +- **Vulkan, default**: the dev client has no launcher scan, so the conservative rule put every program on the + rewriter (`0 native, 50 rewritten`) - as designed. Same run: 0 validation errors, 0 `SYNC-`, GTAO active. +- **OpenGL**: ran unchanged, twice. +- **Pixels (`scripts/dev/ssim.py`)**: two OpenGL launches of the same save differ by **SSIM 0.9757** (mean abs 1.73) + - the same-session noise floor, since a launch differs in world time, weather and entities. Native vs rewriter on + Vulkan is **0.9770** (1.58), *inside* that floor: the 49 native programs introduce no measurable pixel difference. + Vulkan vs OpenGL is 0.969 and native vs OpenGL 0.964, both around the floor and not a per-pass comparison. +- Not yet measured: a deterministic comparison with the scene stilled (`--commands`, `--fixed-dt`), the AO + measurement plan of `docs/research/ambient-occlusion.md` section D, and pacing numbers. + ### Next, in order 1. **Fix the present-after-write hazard.** The present has to wait on a semaphore signalled by the submit From 0e7d19a5d55ae8b9b06656bcfc24ecda0bf31671 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 01:06:42 +0200 Subject: [PATCH 180/226] feat(ao): ambient occlusion master switch in the Optimum options tab A switch to turn AO off and back on in game, so what AO contributes can be judged by flipping it while standing still instead of by comparing two launches. OptimumConfig.AmbientOcclusionEnabled (default on, persisted in optimum.json) gates RenderSSAO in window_RenderFrame, which is the single condition both AO paths hang off: vanilla SSAO and RenderOptimumAmbientOcclusion are inside it, so one switch stops GTAO and vanilla SSAO alike on both backends. It is orthogonal to the AO mode (auto, vanilla, gtao): SetupSSAO keeps the SSAO G-buffer and its frame buffers, and SSAOLEVEL and OPTIMUMAO are still stamped from the mode and the vanilla SSAO quality. So the switch needs no shader reload, no frame buffer rebuild and no temporal reset, takes effect on the next frame, and whichever AO the mode selects comes back exactly as it was. The row interval of the tab shrinks to 27px (23px in the greedy-mesh build) to fit the new last row inside the fixed 740px dialog. Verified: dotnet build 0 errors; extract-patches and check-patches clean (157 patches, 93 applied, 64 cecil, 0 conflict); Optimum.Tests 1225 passed. --- Optimum.Patcher/Program.cs | 1 + .../ambient-occlusion-coverage-tests.cs | 56 +++++++++++++++ Optimum.Tests/taa-settings-coverage-tests.cs | 17 ++--- docs/vulkan-branch-progress.md | 10 ++- .../ClientPlatformWindows.cs.patch | 69 ++++++++++--------- .../GuiCompositeSettings.cs.patch | 56 +++++++++------ .../VintagestoryApi/Config/OptimumConfig.cs | 17 +++++ sources/lang/en.json | 2 + 8 files changed, 167 insertions(+), 61 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 334fdf56..c8403156 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -478,6 +478,7 @@ "onOptimumTaaChanged", "onOptimumTaaSharpnessChanged", "onOptimumTaaMipBiasChanged", + "onOptimumAmbientOcclusionChanged", #if OPTIMUM_GREEDY_MESH "onOptimumGreedyMeshChanged", "onOptimumGreedySpanChanged", diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index ee800584..e29d3e31 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -274,6 +274,62 @@ public void TheComputeShadersCarryTheirLicenceNoticesAndShipInsideTheRenderer() .Where(f => !f.EndsWith(".comp", StringComparison.Ordinal) && !f.EndsWith(".glsl", StringComparison.Ordinal))); } + // ------------------------------------------------------------------ the master switch + + [Fact] + public void TheMasterSwitchDefaultsToOnAndPersistsThroughOptimumJson() + { + string config = Read("VintagestoryApi/Config/OptimumConfig.cs"); + Assert.Contains("public static bool AmbientOcclusionEnabled = true;", config); + Assert.Contains("public bool AmbientOcclusionEnabled { get; set; } = true;", config); + Assert.Contains("AmbientOcclusionEnabled = data.AmbientOcclusionEnabled;", config); + Assert.Contains("AmbientOcclusionEnabled = AmbientOcclusionEnabled,", config); + Assert.Contains("(nameof(OptimumConfigData.AmbientOcclusionEnabled), AmbientOcclusionEnabled.ToString())", config); + } + + [Fact] + public void TheMasterSwitchGatesBothAoPathsThroughRenderSsao() + { + // Vanilla SSAO and RenderOptimumAmbientOcclusion hang off the same condition, so gating + // RenderSSAO switches off AO on both backends and in both modes. + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains( + "RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects && OptimumConfig.AmbientOcclusionEnabled;", + platform); + // SetupSSAO is deliberately not gated: the G-buffer and its frame buffers stay, which is + // what lets the switch flip without a rebuild. + Assert.Contains("SetupSSAO = ClientSettings.SSAOQuality > 0;", platform); + Assert.DoesNotContain("SetupSSAO = ClientSettings.SSAOQuality > 0 && OptimumConfig.AmbientOcclusionEnabled", platform); + } + + [Fact] + public void TheMasterSwitchIsAnOptimumTabSwitchWiredToTheConfig() + { + string gui = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + Assert.Contains("Lang.Get(\"optimum-ao\")", gui); + Assert.Contains("Lang.Get(\"optimum-ao-tooltip\")", gui); + Assert.Contains("AddSwitch(onOptimumAmbientOcclusionChanged", gui); + Assert.Contains("\"optAo\")", gui); + Assert.Contains("composer.GetSwitch(\"optAo\").SetValue(Vintagestory.API.Config.OptimumConfig.AmbientOcclusionEnabled);", gui); + + string handler = Between(gui, "private void onOptimumAmbientOcclusionChanged(bool on)", "\n\t}"); + Assert.Contains("OptimumConfig.AmbientOcclusionEnabled = on;", handler); + Assert.Contains("OptimumConfig.Save();", handler); + // The point of the switch is the live A/B: no reload, no rebuild, no temporal reset. + Assert.DoesNotContain("ReloadShaders", handler); + Assert.DoesNotContain("RebuildFrameBuffers", handler); + Assert.DoesNotContain("RequestReset", handler); + } + + [Fact] + public void TheMasterSwitchHasItsLangEntriesAndItsPatcherListing() + { + string lang = Read("sources/lang/en.json"); + Assert.Contains("\"optimum-ao\":", lang); + Assert.Contains("\"optimum-ao-tooltip\":", lang); + Assert.Contains("\"onOptimumAmbientOcclusionChanged\"", Read("Optimum.Patcher/Program.cs")); + } + // ------------------------------------------------------------------ helpers private static string Between(string text, string start, string end) diff --git a/Optimum.Tests/taa-settings-coverage-tests.cs b/Optimum.Tests/taa-settings-coverage-tests.cs index c8df0359..19503804 100644 --- a/Optimum.Tests/taa-settings-coverage-tests.cs +++ b/Optimum.Tests/taa-settings-coverage-tests.cs @@ -153,19 +153,20 @@ public void TheRowsFitTheFixedMainMenuDialog() "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); // ComposerHeader lays the main-menu dialog out at a fixed 740px, and the - // tab starts at y0 = 87. The stock build now ends at row 21 (TAA mip - // bias) and the feature-flag build at row 25, so the row interval has to - // shrink in the latter or the last rows fall off the dialog. - Assert.Contains("double rowH = 28.0;", gui); - Assert.Contains("double rowH = 24.0;", gui); + // tab starts at y0 = 87. The stock build now ends at row 22 (the ambient + // occlusion switch) and the feature-flag build at row 26, so the row + // interval has to shrink or the last rows fall off the dialog. + Assert.Contains("double rowH = 27.0;", gui); + Assert.Contains("double rowH = 23.0;", gui); Assert.Contains("rowH * 19", gui); // TAA toggle Assert.Contains("rowH * 20", gui); // sharpness Assert.Contains("rowH * 21", gui); // mip bias - Assert.Contains("rowH * 25", gui); // greedy far distance, shifted down + Assert.Contains("rowH * 22", gui); // ambient occlusion, last stock row + Assert.Contains("rowH * 26", gui); // greedy far distance, shifted down - Assert.True(87.0 + 28.0 * 21 <= 740.0); - Assert.True(87.0 + 24.0 * 25 <= 740.0); + Assert.True(87.0 + 27.0 * 22 <= 740.0); + Assert.True(87.0 + 23.0 * 26 <= 740.0); } [Fact] diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index d9120935..ff3e7308 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -294,8 +294,14 @@ window never mapped), implicit layers off, `sync,best` validation to a file. The 8. **GTAO with visibility bitmasks** (XeGTAO-derived; XeGTAO itself is archived since 2024-04-22, see `docs/research/xegtao-integration.md` section 0; the combined design is `docs/research/ambient-occlusion.md` section C; physically correct, default AO on Vulkan while TAA is active): compute pass kind in the frame graph, GLSL compute port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. - **Status (2026-09-15, evening):** the frame-graph compute pass kind is implemented on its stage branch; the AO - passes, class channel, composition and settings are in progress. + **Status (2026-09-16):** landed on the branch - the frame-graph compute pass kind, the GTAO passes, the + class channel, the composition before the resolve and the settings, with the native port carrying + OPTIMUMAO as specialization constant 12. The Optimum options tab now also carries an ambient occlusion + master switch (`OptimumConfig.AmbientOcclusionEnabled`, default on, `optAo`): it gates `RenderSSAO` only, + so both AO paths stop together, the SSAO G-buffer and the stamped shader defines stay untouched, and it + flips live with no shader reload, no frame buffer rebuild and no temporal reset - the in-game A/B for + judging what AO contributes. Still open: the section D measurements and the deterministic stilled-scene + comparison. 9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. 10. **Optimisation** (plan Phase 4): per-pass GPU timestamps, push-constant placement from the measured profile, transient aliasing on by default, DirectToSwapchain / transfer backend measured. Exit: Vulkan diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 8cbe7111..02cd6d7e 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..e4a4415 100644 +index 6edf0c9..17c7ff6 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -386,7 +386,7 @@ index 6edf0c9..e4a4415 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +738,148 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +738,154 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -497,7 +497,14 @@ index 6edf0c9..e4a4415 100644 RenderBloom = ClientSettings.Bloom && base.DoPostProcessingEffects; RenderGodRays = ClientSettings.GodRayQuality > 0 && base.DoPostProcessingEffects; RenderFXAA = ClientSettings.FXAA && base.DoPostProcessingEffects; - RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects; +- RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects; ++ // Optimum AO: the in-game master switch (Optimum options tab). It gates the ++ // passes only - SetupSSAO below keeps the G-buffer and its frame buffers, and ++ // the shader defines are stamped from the AO mode - so it flips live with no ++ // shader reload and no frame buffer rebuild, and whichever AO the mode selects ++ // comes back exactly as it was. Both AO paths hang off RenderSSAO: vanilla SSAO ++ // and RenderOptimumAmbientOcclusion are inside the same condition. ++ RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects && OptimumConfig.AmbientOcclusionEnabled; SetupSSAO = ClientSettings.SSAOQuality > 0; ShadowMapQuality = ClientSettings.ShadowMapQuality; ShaderProgramBase.shadowmapQuality = ShadowMapQuality; @@ -541,7 +548,7 @@ index 6edf0c9..e4a4415 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +899,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +905,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -554,7 +561,7 @@ index 6edf0c9..e4a4415 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1070,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1076,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -599,7 +606,7 @@ index 6edf0c9..e4a4415 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1182,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1188,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -619,7 +626,7 @@ index 6edf0c9..e4a4415 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1418,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1424,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -632,7 +639,7 @@ index 6edf0c9..e4a4415 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,146 +1545,943 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,146 +1551,943 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -1696,7 +1703,7 @@ index 6edf0c9..e4a4415 100644 _ = ClientSettings.SSAOQuality; float num3 = 0.5f; FrameBufferRef obj = new FrameBufferRef -@@ -1436,10 +2628,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2634,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1772,7 +1779,7 @@ index 6edf0c9..e4a4415 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2805,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2811,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1787,7 +1794,7 @@ index 6edf0c9..e4a4415 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2828,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2834,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1882,7 +1889,7 @@ index 6edf0c9..e4a4415 100644 } } } -@@ -1591,11 +2922,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2928,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1901,7 +1908,7 @@ index 6edf0c9..e4a4415 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2957,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2963,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1988,7 +1995,7 @@ index 6edf0c9..e4a4415 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3066,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3072,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2074,7 +2081,7 @@ index 6edf0c9..e4a4415 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3146,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3152,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2160,7 +2167,7 @@ index 6edf0c9..e4a4415 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3228,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3234,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2421,7 +2428,7 @@ index 6edf0c9..e4a4415 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3492,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3498,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2535,7 +2542,7 @@ index 6edf0c9..e4a4415 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3606,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3612,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2714,7 +2721,7 @@ index 6edf0c9..e4a4415 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3740,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3746,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2749,7 +2756,7 @@ index 6edf0c9..e4a4415 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3779,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3785,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2806,7 +2813,7 @@ index 6edf0c9..e4a4415 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3834,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3840,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3292,7 +3299,7 @@ index 6edf0c9..e4a4415 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4470,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4476,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3332,7 +3339,7 @@ index 6edf0c9..e4a4415 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4868,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4874,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3385,7 +3392,7 @@ index 6edf0c9..e4a4415 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4963,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4969,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3430,7 +3437,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5000,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5006,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3451,7 +3458,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5019,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5025,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3472,7 +3479,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5038,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5044,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3493,7 +3500,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5057,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5063,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3514,7 +3521,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5080,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5086,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3535,7 +3542,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5642,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5648,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3559,7 +3566,7 @@ index 6edf0c9..e4a4415 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6001,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6007,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch index 7570d1a0..7b5aed64 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs b/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs -index 4d08f84..5a760a7 100644 +index 4d08f84..0d1c206 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs @@ -46,10 +46,12 @@ public class GuiCompositeSettings : GuiComposite @@ -99,7 +99,7 @@ index 4d08f84..5a760a7 100644 internal bool OpenSettingsMenu() { OnGraphicsOptions(on: true); -@@ -1740,10 +1750,232 @@ public class GuiCompositeSettings : GuiComposite +@@ -1740,10 +1750,248 @@ public class GuiCompositeSettings : GuiComposite composer.GetSwitch("debugVaoDisposeSwitch").SetValue(RuntimeEnv.DebugVAODispose); composer.GetSwitch("debugSoundDisposeSwitch").SetValue(RuntimeEnv.DebugSoundDispose); composer.GetSwitch("fasterStartupSwitch").SetValue(ClientSettings.OffThreadMipMapCreation); @@ -107,14 +107,14 @@ index 4d08f84..5a760a7 100644 + private void OnOptimumOptions(bool on) + { -+ // The stock build uses 22 rows inside the fixed 740px dialog: the 28px -+ // interval places the last row (TAA mip bias) at 675px. The feature-flag ++ // The stock build uses 23 rows inside the fixed 740px dialog: the 27px ++ // interval places the last row (ambient occlusion) at 681px. The feature-flag + // build stacks the four greedy-mesh rows on top of the three TAA ones, -+ // and 26 rows only fit at a 24px interval (last row at 687px). ++ // and 27 rows only fit at a 23px interval (last row at 685px). +#if OPTIMUM_GREEDY_MESH -+ double rowH = 24.0; ++ double rowH = 23.0; +#else -+ double rowH = 28.0; ++ double rowH = 27.0; +#endif + double y0 = 87.0; + composer = ComposerHeader("gamesettings-optimumoptions", "optimum") @@ -181,19 +181,22 @@ index 4d08f84..5a760a7 100644 + .AddStaticText(Lang.Get("optimum-taamipbias"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 21, 400, 30)) + .AddHoverText(Lang.Get("optimum-taamipbias-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 21, 400, 25)) + .AddSlider(onOptimumTaaMipBiasChanged, ElementBounds.Fixed(450, y0 + rowH * 21 + 2, 200, 20), "optTaaMipBias") ++ .AddStaticText(Lang.Get("optimum-ao"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 22, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-ao-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 22, 400, 25)) ++ .AddSwitch(onOptimumAmbientOcclusionChanged, ElementBounds.Fixed(450, y0 + rowH * 22 - 3, 200, 20), "optAo") +#if OPTIMUM_GREEDY_MESH -+ .AddStaticText(Lang.Get("optimum-greedymesh"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 22, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedymesh-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 22, 400, 25)) -+ .AddSwitch(onOptimumGreedyMeshChanged, ElementBounds.Fixed(450, y0 + rowH * 22 - 3, 200, 20), "optGreedyMesh") -+ .AddStaticText(Lang.Get("optimum-greedyspan"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 23, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedyspan-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 23, 400, 25)) -+ .AddSlider(onOptimumGreedySpanChanged, ElementBounds.Fixed(450, y0 + rowH * 23 + 2, 200, 20), "optGreedySpan") -+ .AddStaticText(Lang.Get("optimum-greedylighttol"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 24, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedylighttol-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 24, 400, 25)) -+ .AddSlider(onOptimumGreedyLightTolChanged, ElementBounds.Fixed(450, y0 + rowH * 24 + 2, 200, 20), "optGreedyLightTol") -+ .AddStaticText(Lang.Get("optimum-greedyfardist"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 25, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedyfardist-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 25, 400, 25)) -+ .AddSlider(onOptimumGreedyFarDistChanged, ElementBounds.Fixed(450, y0 + rowH * 25 + 2, 200, 20), "optGreedyFarDist") ++ .AddStaticText(Lang.Get("optimum-greedymesh"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 23, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedymesh-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 23, 400, 25)) ++ .AddSwitch(onOptimumGreedyMeshChanged, ElementBounds.Fixed(450, y0 + rowH * 23 - 3, 200, 20), "optGreedyMesh") ++ .AddStaticText(Lang.Get("optimum-greedyspan"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 24, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedyspan-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 24, 400, 25)) ++ .AddSlider(onOptimumGreedySpanChanged, ElementBounds.Fixed(450, y0 + rowH * 24 + 2, 200, 20), "optGreedySpan") ++ .AddStaticText(Lang.Get("optimum-greedylighttol"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 25, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedylighttol-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 25, 400, 25)) ++ .AddSlider(onOptimumGreedyLightTolChanged, ElementBounds.Fixed(450, y0 + rowH * 25 + 2, 200, 20), "optGreedyLightTol") ++ .AddStaticText(Lang.Get("optimum-greedyfardist"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 26, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedyfardist-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 26, 400, 25)) ++ .AddSlider(onOptimumGreedyFarDistChanged, ElementBounds.Fixed(450, y0 + rowH * 26 + 2, 200, 20), "optGreedyFarDist") +#endif + .EndChildElements() + .Compose(); @@ -224,6 +227,7 @@ index 4d08f84..5a760a7 100644 + // are carried in hundredths so the row still has usable granularity. + composer.GetSlider("optTaaSharpness").SetValues((int)Math.Round(Vintagestory.API.Config.OptimumConfig.TaaSharpness * 100f), 0, 100, 5, "%"); + composer.GetSlider("optTaaMipBias").SetValues((int)Math.Round(Vintagestory.API.Config.OptimumConfig.TaaMipBias * 100f), -100, 0, 5, "/100"); ++ composer.GetSwitch("optAo").SetValue(Vintagestory.API.Config.OptimumConfig.AmbientOcclusionEnabled); +#if OPTIMUM_GREEDY_MESH + composer.GetSwitch("optGreedyMesh").SetValue(Vintagestory.API.Config.OptimumConfig.EffectiveGreedyMesh); + composer.GetSlider("optGreedySpan").SetValues(Vintagestory.API.Config.OptimumConfig.GreedyMeshMaxMergeWidth, 1, 8, 1, " blocks"); @@ -314,6 +318,18 @@ index 4d08f84..5a760a7 100644 + Vintagestory.API.Config.OptimumConfig.Save(); + return true; + } ++ ++ // Optimum AO: the master switch gates the passes only - RenderSSAO is ++ // recomputed every frame in window_RenderFrame, the SSAO G-buffer stays set ++ // up and the shader defines are stamped from the AO mode, not from this. So ++ // it takes effect on the next frame with no shader reload, no frame buffer ++ // rebuild and no temporal reset, which is what makes it usable as an A/B ++ // comparison while standing still. ++ private void onOptimumAmbientOcclusionChanged(bool on) ++ { ++ Vintagestory.API.Config.OptimumConfig.AmbientOcclusionEnabled = on; ++ Vintagestory.API.Config.OptimumConfig.Save(); ++ } + private void onOptimumEntityLightBatchChanged(bool on) { Vintagestory.API.Config.OptimumConfig.EntityLightBatchEnabled = on; Vintagestory.API.Config.OptimumConfig.Save(); } + private void onOptimumEntityShaderCacheChanged(bool on) { Vintagestory.API.Config.OptimumConfig.EntityShaderStateCacheEnabled = on; Vintagestory.API.Config.OptimumConfig.Save(); } +#if OPTIMUM_GREEDY_MESH @@ -332,7 +348,7 @@ index 4d08f84..5a760a7 100644 ClientSettings.StartupErrorDialog = on; } -@@ -1818,6 +2050,74 @@ public class GuiCompositeSettings : GuiComposite +@@ -1818,6 +2066,74 @@ public class GuiCompositeSettings : GuiComposite { ClientSettings.DeveloperMode = true; OnDeveloperOptions(on: true); diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index 2af82a6e..716d4f14 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -460,6 +460,19 @@ public static class OptimumConfig /// public static string AmbientOcclusionPreset = "medium"; + /// + /// The master switch for ambient occlusion, in the Optimum options tab: false skips both + /// the vanilla SSAO pass and the GTAO pass for the frame and changes nothing else in the + /// post chain. + /// + /// Orthogonal to , which decides which AO runs while this is + /// on. The shader defines (SSAOLEVEL, OPTIMUMAO) are stamped from the mode and the vanilla + /// SSAO quality, never from this, so flipping it needs no shader reload and no frame buffer + /// rebuild and the selected AO comes back exactly as it was - which is what makes it an A/B + /// comparison rather than a settings change. + /// + public static bool AmbientOcclusionEnabled = true; + /// /// Whether GTAO is selected for a backend: never on OpenGL; on Vulkan with "gtao", or /// with "auto" while TAA is active. @@ -900,6 +913,7 @@ public static int ResolveWorldgenWorkerCount( (nameof(OptimumConfigData.TaaJitterDev), TaaJitterDev.ToString()), (nameof(OptimumConfigData.AmbientOcclusion), AmbientOcclusion), (nameof(OptimumConfigData.AmbientOcclusionPreset), AmbientOcclusionPreset), + (nameof(OptimumConfigData.AmbientOcclusionEnabled), AmbientOcclusionEnabled.ToString()), (nameof(OptimumConfigData.MapPageCache), MapPageCacheEnabled.ToString()), (nameof(OptimumConfigData.MapPageCacheMaxLayers), MapPageCacheMaxLayers.ToString()), (nameof(OptimumConfigData.MapPageCacheBc7), MapPageCacheBc7.ToString()), @@ -1008,6 +1022,7 @@ public static void Load() AmbientOcclusion = requestedAo is "vanilla" or "gtao" ? requestedAo : "auto"; string requestedAoPreset = data.AmbientOcclusionPreset?.Trim().ToLowerInvariant() ?? ""; AmbientOcclusionPreset = requestedAoPreset is "low" or "high" or "ultra" ? requestedAoPreset : "medium"; + AmbientOcclusionEnabled = data.AmbientOcclusionEnabled; MapPageCacheEnabled = data.MapPageCache; MapPageCacheMaxLayers = Math.Clamp(data.MapPageCacheMaxLayers, 16, 512); MapPageCacheBc7 = data.MapPageCacheBc7; @@ -1084,6 +1099,7 @@ public static void Save() TaaJitterDev = TaaJitterDev, AmbientOcclusion = AmbientOcclusion, AmbientOcclusionPreset = AmbientOcclusionPreset, + AmbientOcclusionEnabled = AmbientOcclusionEnabled, MapPageCache = MapPageCacheEnabled, MapPageCacheMaxLayers = MapPageCacheMaxLayers, MapPageCacheBc7 = MapPageCacheBc7, @@ -1170,6 +1186,7 @@ internal sealed class OptimumConfigData public bool TaaJitterDev { get; set; } = false; public string AmbientOcclusion { get; set; } = "auto"; public string AmbientOcclusionPreset { get; set; } = "medium"; + public bool AmbientOcclusionEnabled { get; set; } = true; public bool MapPageCache { get; set; } = true; public int MapPageCacheMaxLayers { get; set; } = 128; public bool MapPageCacheBc7 { get; set; } = true; diff --git a/sources/lang/en.json b/sources/lang/en.json index 7f260659..c02cfa86 100644 --- a/sources/lang/en.json +++ b/sources/lang/en.json @@ -57,5 +57,7 @@ "optimum-taasharpness-tooltip": "How strongly the resolved image is sharpened after TAA, in percent. 0 turns the sharpen pass off entirely. Kept separate from the render-scale (FSR) sharpening so the two never apply at full strength together. Applies immediately.", "optimum-taamipbias": "TAA Texture Sharpness", "optimum-taamipbias-tooltip": "Mip-map bias, in hundredths of a mip level, applied while TAA is on: jitter gives the resolve sub-pixel samples, so textures can be selected sharper than an unjittered frame would allow. -50 is the default; 0 keeps vanilla mip selection; more negative is sharper but can reintroduce texture shimmer. Applies immediately.", + "optimum-ao": "Ambient Occlusion", + "optimum-ao-tooltip": "Master switch for ambient occlusion - the contact shading in corners, under overhangs and where blocks meet. Off skips both the vanilla SSAO pass and Optimum's GTAO and changes nothing else, so flipping it while standing still shows exactly what AO contributes. Applies on the next frame: no shader reload, no restart. Which AO runs while it is on is the ambient occlusion mode in optimum.json (auto, vanilla or gtao); the vanilla SSAO quality setting at 0 still switches it off on both paths.", "optimum-godrayscap-tooltip": "Limits the god-rays post-process to 100 texture samples. Off keeps the vanilla 180-sample path." } From dc05748100d4639009f0a59987260a9bbd746a1b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 01:17:10 +0200 Subject: [PATCH 181/226] fix(ao): AO off no longer darkens the frame; add the black and white AO debug view Switching AO off darkened the whole image. The scene shaders stay compiled with SSAOLEVEL > 0, so final.fsh multiplies the frame by ssaoScene unless optimumSsaoInScene says otherwise, and with the passes gated off nothing rendered into that target: the multiply used whatever it held. The flag means "AO is not Final's to apply", which is exactly true when AO is off, so it is now set for that case as well - the one state vanilla never produced, since RenderSSAO and the SSAOLEVEL the shaders carry always agreed there. Also adds the AO debug view (AmbientOcclusionDebugView, optAoDebug, default off): the final composition writes the AO term alone as greyscale, white fully lit and dark fully occluded, before colour grading and vignetting. It reads whichever AO ran - the platform's GTAO visibility texture when it produced one this frame, the vanilla blurred SSAO target otherwise - so the same view compares the two. The branch is identical in both shader twins (final.fsh and the native final.frag/final.interface.glsl, one new record member), and it is inert while AO is off, where neither target was written. Verified: dotnet build 0 errors; extract-patches and check-patches clean (157 patches, 0 conflict); Optimum.Tests 1229 passed; the native shader parity suite 99 passed with the new uniform in both twins and in the manifest. --- Optimum.Patcher/Program.cs | 1 + .../ambient-occlusion-coverage-tests.cs | 71 +++++++++++++++++++ Optimum.Tests/scene-ssao-coverage-tests.cs | 5 +- Optimum.Tests/taa-settings-coverage-tests.cs | 15 ++-- docs/vulkan-branch-progress.md | 9 ++- .../ClientPlatformWindows.cs.patch | 42 ++++++----- .../GuiCompositeSettings.cs.patch | 49 ++++++++----- .../VintagestoryApi/Config/OptimumConfig.cs | 13 ++++ sources/lang/en.json | 2 + sources/shaders-vk/final.frag | 10 +++ sources/shaders-vk/final.interface.glsl | 1 + sources/shaders/final.fsh | 11 +++ 12 files changed, 185 insertions(+), 44 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index c8403156..0b9c4436 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -479,6 +479,7 @@ "onOptimumTaaSharpnessChanged", "onOptimumTaaMipBiasChanged", "onOptimumAmbientOcclusionChanged", + "onOptimumAmbientOcclusionDebugChanged", #if OPTIMUM_GREEDY_MESH "onOptimumGreedyMeshChanged", "onOptimumGreedySpanChanged", diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index e29d3e31..360f1e56 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -330,6 +330,77 @@ public void TheMasterSwitchHasItsLangEntriesAndItsPatcherListing() Assert.Contains("\"onOptimumAmbientOcclusionChanged\"", Read("Optimum.Patcher/Program.cs")); } + // ------------------------------------------------------------------ the debug view + + [Fact] + public void SwitchingAoOffAlsoStopsFinalFromMultiplyingByTheStaleSsaoTarget() + { + // The regression this pins: the scene shaders are still compiled with SSAOLEVEL > 0, so + // final.fsh multiplies by ssaoScene unless optimumSsaoInScene says otherwise. With AO off + // nothing renders into that target, so an unguarded multiply darkens the whole image. + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains( + "final.Uniform(\"optimumSsaoInScene\", (optimumSsaoInScene || !RenderSSAO) ? 1 : 0);", + platform); + } + + [Fact] + public void TheDebugViewShowsWhicheverAoRanAndOnlyWhileAoIsOn() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains("bool optimumAoDebugView = OptimumConfig.AmbientOcclusionDebugView && RenderSSAO;", platform); + // GTAO's own visibility texture when it produced one, the vanilla blurred target otherwise. + Assert.Contains("optimumAoDebugView && optimumAmbientOcclusionTexture != 0", platform); + Assert.Contains("final.Uniform(\"optimumAoDebug\", optimumAoDebugView ? 1 : 0);", platform); + + string config = Read("VintagestoryApi/Config/OptimumConfig.cs"); + Assert.Contains("public static bool AmbientOcclusionDebugView = false;", config); + Assert.Contains("public bool AmbientOcclusionDebugView { get; set; } = false;", config); + Assert.Contains("AmbientOcclusionDebugView = data.AmbientOcclusionDebugView;", config); + Assert.Contains("AmbientOcclusionDebugView = AmbientOcclusionDebugView,", config); + } + + [Fact] + public void TheDebugBranchIsTheSameInBothShaderTwins() + { + // The native program and the GLSL 330 override must agree on the uniform set (the parity + // harness) and on what the branch does, or the two backends show different pictures. + string gl = Read("sources/shaders/final.fsh"); + Assert.Contains("uniform int optimumAoDebug;", gl); + Assert.Contains("float aoDebugTerm = texture(ssaoScene, texCoord).r;", gl); + Assert.Contains("outColor = vec4(vec3(aoDebugTerm), 1.0);", gl); + + string vk = Read("sources/shaders-vk/final.frag"); + Assert.Contains("float aoDebugTerm = texture(optimumTextures2D[ssaoScene], texCoord).r;", vk); + Assert.Contains("outColor = vec4(vec3(aoDebugTerm), 1.0);", vk); + Assert.Contains("int optimumAoDebug;", Read("sources/shaders-vk/final.interface.glsl")); + + // Before colour grading and vignetting in both: the view is the AO term, nothing else. + Assert.True(gl.IndexOf("optimumAoDebug != 0", StringComparison.Ordinal) + < gl.IndexOf("vec4 gradedColor = ColorGrade(color);", StringComparison.Ordinal)); + Assert.True(vk.IndexOf("optimumAoDebug != 0", StringComparison.Ordinal) + < vk.IndexOf("vec4 gradedColor = ColorGrade(color);", StringComparison.Ordinal)); + } + + [Fact] + public void TheDebugViewIsAnOptimumTabSwitchWithItsLangEntriesAndPatcherListing() + { + string gui = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + Assert.Contains("Lang.Get(\"optimum-aodebug\")", gui); + Assert.Contains("AddSwitch(onOptimumAmbientOcclusionDebugChanged", gui); + Assert.Contains("composer.GetSwitch(\"optAoDebug\").SetValue(Vintagestory.API.Config.OptimumConfig.AmbientOcclusionDebugView);", gui); + + string handler = Between(gui, "private void onOptimumAmbientOcclusionDebugChanged(bool on)", "\n\t}"); + Assert.Contains("OptimumConfig.AmbientOcclusionDebugView = on;", handler); + Assert.DoesNotContain("ReloadShaders", handler); + Assert.DoesNotContain("RebuildFrameBuffers", handler); + + string lang = Read("sources/lang/en.json"); + Assert.Contains("\"optimum-aodebug\":", lang); + Assert.Contains("\"optimum-aodebug-tooltip\":", lang); + Assert.Contains("\"onOptimumAmbientOcclusionDebugChanged\"", Read("Optimum.Patcher/Program.cs")); + } + // ------------------------------------------------------------------ helpers private static string Between(string text, string start, string end) diff --git a/Optimum.Tests/scene-ssao-coverage-tests.cs b/Optimum.Tests/scene-ssao-coverage-tests.cs index 3a77b038..24354b20 100644 --- a/Optimum.Tests/scene-ssao-coverage-tests.cs +++ b/Optimum.Tests/scene-ssao-coverage-tests.cs @@ -30,7 +30,10 @@ public void JitteredAoIsComposedBeforeTheResolveAndIsNotAppliedTwice() Assert.Equal(1, Count(post, "ssao.Use();")); Assert.Contains("if (OptimumTaaRequested && TaaTargetsReady)", post); - Assert.Contains("final.Uniform(\"optimumSsaoInScene\", optimumSsaoInScene ? 1 : 0);", platform); + // The flag means "AO is not Final's to apply": set when the AO was already multiplied + // into the scene before the resolve, and also when AO is switched off entirely, where + // nothing rendered into the SSAO target and multiplying by it would darken the frame. + Assert.Contains("final.Uniform(\"optimumSsaoInScene\", (optimumSsaoInScene || !RenderSSAO) ? 1 : 0);", platform); Assert.Contains("optimumSsaoInScene = true;", platform); Assert.Contains("if (optimumSsaoInScene == 0)", Read("sources/shaders/final.fsh")); Assert.Contains("uniform int optimumSsaoInScene;", Read("sources/shaders/final.fsh")); diff --git a/Optimum.Tests/taa-settings-coverage-tests.cs b/Optimum.Tests/taa-settings-coverage-tests.cs index 19503804..07b92fb6 100644 --- a/Optimum.Tests/taa-settings-coverage-tests.cs +++ b/Optimum.Tests/taa-settings-coverage-tests.cs @@ -153,20 +153,21 @@ public void TheRowsFitTheFixedMainMenuDialog() "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); // ComposerHeader lays the main-menu dialog out at a fixed 740px, and the - // tab starts at y0 = 87. The stock build now ends at row 22 (the ambient - // occlusion switch) and the feature-flag build at row 26, so the row - // interval has to shrink or the last rows fall off the dialog. + // tab starts at y0 = 87. The stock build now ends at row 23 (the AO debug + // view) and the feature-flag build at row 27, so the row interval has to + // shrink or the last rows fall off the dialog. Assert.Contains("double rowH = 27.0;", gui); Assert.Contains("double rowH = 23.0;", gui); Assert.Contains("rowH * 19", gui); // TAA toggle Assert.Contains("rowH * 20", gui); // sharpness Assert.Contains("rowH * 21", gui); // mip bias - Assert.Contains("rowH * 22", gui); // ambient occlusion, last stock row - Assert.Contains("rowH * 26", gui); // greedy far distance, shifted down + Assert.Contains("rowH * 22", gui); // ambient occlusion + Assert.Contains("rowH * 23", gui); // AO debug view, last stock row + Assert.Contains("rowH * 27", gui); // greedy far distance, shifted down - Assert.True(87.0 + 27.0 * 22 <= 740.0); - Assert.True(87.0 + 23.0 * 26 <= 740.0); + Assert.True(87.0 + 27.0 * 23 <= 740.0); + Assert.True(87.0 + 23.0 * 27 <= 740.0); } [Fact] diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index ff3e7308..c5202fbf 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -300,8 +300,13 @@ window never mapped), implicit layers off, `sync,best` validation to a file. The master switch (`OptimumConfig.AmbientOcclusionEnabled`, default on, `optAo`): it gates `RenderSSAO` only, so both AO paths stop together, the SSAO G-buffer and the stamped shader defines stay untouched, and it flips live with no shader reload, no frame buffer rebuild and no temporal reset - the in-game A/B for - judging what AO contributes. Still open: the section D measurements and the deterministic stilled-scene - comparison. + judging what AO contributes. Switching it off also sets `optimumSsaoInScene`: the scene shaders stay + compiled with `SSAOLEVEL > 0`, so `final.fsh` would otherwise multiply by an SSAO target nothing wrote + that frame and darken the whole image (found in game, 2026-09-16). Beside it, `AmbientOcclusionDebugView` + (`optAoDebug`) writes the AO term alone as greyscale in the final composition, sourced from the GTAO + output when it ran and the vanilla blurred target otherwise - the same branch in both shader twins + (`final.fsh`, `final.frag`), before colour grading. Still open: the section D measurements and the + deterministic stilled-scene comparison. 9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. 10. **Optimisation** (plan Phase 4): per-pass GPU timestamps, push-constant placement from the measured profile, transient aliasing on by default, DirectToSwapchain / transfer backend measured. Exit: Vulkan diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 02cd6d7e..3e31c680 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..17c7ff6 100644 +index 6edf0c9..49bfdac 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -2721,7 +2721,7 @@ index 6edf0c9..17c7ff6 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3746,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3746,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2744,19 +2744,29 @@ index 6edf0c9..17c7ff6 100644 + final.GlowParts2D = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1]; final.GodrayParts2D = godrayParts2D; final.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; ++ // Optimum AO: the debug view shows whichever AO actually ran this frame - the ++ // platform's own visibility texture when it produced one, the vanilla blurred ++ // SSAO target otherwise. Never while AO is off: nothing filled either target. ++ bool optimumAoDebugView = OptimumConfig.AmbientOcclusionDebugView && RenderSSAO; if (RenderSSAO) { - final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; +- final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; ++ final.SsaoScene2D = ((optimumAoDebugView && optimumAmbientOcclusionTexture != 0) ? optimumAmbientOcclusionTexture : frameBuffers[14].ColorTextureIds[0]); } + // Optimum TAA: written every frame, never conditionally - a declared uniform + // left unset reads back as whatever the Vulkan uniform ring last held. -+ final.Uniform("optimumSsaoInScene", optimumSsaoInScene ? 1 : 0); ++ // Optimum AO: the flag means "AO is not Final's to apply". With AO switched off ++ // nothing rendered into the SSAO target this frame, so multiplying by it would ++ // darken the whole image by whatever the target happens to hold - the flag is ++ // set for that case too, which is the one state vanilla never produced. ++ final.Uniform("optimumSsaoInScene", (optimumSsaoInScene || !RenderSSAO) ? 1 : 0); ++ final.Uniform("optimumAoDebug", optimumAoDebugView ? 1 : 0); final.Uniform("invFrameSizeIn", 1f / ((float)((NativeWindow)window).ClientSize.X * ssaaLevel), 1f / ((float)((NativeWindow)window).ClientSize.Y * ssaaLevel)); final.GammaLevel = ClientSettings.GammaLevel; final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3785,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3794,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2813,7 +2823,7 @@ index 6edf0c9..17c7ff6 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3840,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3849,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3299,7 +3309,7 @@ index 6edf0c9..17c7ff6 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4476,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4485,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3339,7 +3349,7 @@ index 6edf0c9..17c7ff6 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4874,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4883,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3392,7 +3402,7 @@ index 6edf0c9..17c7ff6 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4969,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4978,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3437,7 +3447,7 @@ index 6edf0c9..17c7ff6 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5006,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5015,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3458,7 +3468,7 @@ index 6edf0c9..17c7ff6 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5025,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5034,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3479,7 +3489,7 @@ index 6edf0c9..17c7ff6 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5044,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5053,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3500,7 +3510,7 @@ index 6edf0c9..17c7ff6 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5063,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5072,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3521,7 +3531,7 @@ index 6edf0c9..17c7ff6 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5086,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5095,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3542,7 +3552,7 @@ index 6edf0c9..17c7ff6 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5648,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5657,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3566,7 +3576,7 @@ index 6edf0c9..17c7ff6 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6007,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6016,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch index 7b5aed64..7673a146 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs b/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs -index 4d08f84..0d1c206 100644 +index 4d08f84..e3cad5a 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs @@ -46,10 +46,12 @@ public class GuiCompositeSettings : GuiComposite @@ -99,7 +99,7 @@ index 4d08f84..0d1c206 100644 internal bool OpenSettingsMenu() { OnGraphicsOptions(on: true); -@@ -1740,10 +1750,248 @@ public class GuiCompositeSettings : GuiComposite +@@ -1740,10 +1750,261 @@ public class GuiCompositeSettings : GuiComposite composer.GetSwitch("debugVaoDisposeSwitch").SetValue(RuntimeEnv.DebugVAODispose); composer.GetSwitch("debugSoundDisposeSwitch").SetValue(RuntimeEnv.DebugSoundDispose); composer.GetSwitch("fasterStartupSwitch").SetValue(ClientSettings.OffThreadMipMapCreation); @@ -107,10 +107,10 @@ index 4d08f84..0d1c206 100644 + private void OnOptimumOptions(bool on) + { -+ // The stock build uses 23 rows inside the fixed 740px dialog: the 27px -+ // interval places the last row (ambient occlusion) at 681px. The feature-flag ++ // The stock build uses 24 rows inside the fixed 740px dialog: the 27px ++ // interval places the last row (the AO debug view) at 708px. The feature-flag + // build stacks the four greedy-mesh rows on top of the three TAA ones, -+ // and 27 rows only fit at a 23px interval (last row at 685px). ++ // and 28 rows only fit at a 23px interval (last row at 708px). +#if OPTIMUM_GREEDY_MESH + double rowH = 23.0; +#else @@ -184,19 +184,22 @@ index 4d08f84..0d1c206 100644 + .AddStaticText(Lang.Get("optimum-ao"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 22, 400, 30)) + .AddHoverText(Lang.Get("optimum-ao-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 22, 400, 25)) + .AddSwitch(onOptimumAmbientOcclusionChanged, ElementBounds.Fixed(450, y0 + rowH * 22 - 3, 200, 20), "optAo") ++ .AddStaticText(Lang.Get("optimum-aodebug"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 23, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-aodebug-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 23, 400, 25)) ++ .AddSwitch(onOptimumAmbientOcclusionDebugChanged, ElementBounds.Fixed(450, y0 + rowH * 23 - 3, 200, 20), "optAoDebug") +#if OPTIMUM_GREEDY_MESH -+ .AddStaticText(Lang.Get("optimum-greedymesh"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 23, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedymesh-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 23, 400, 25)) -+ .AddSwitch(onOptimumGreedyMeshChanged, ElementBounds.Fixed(450, y0 + rowH * 23 - 3, 200, 20), "optGreedyMesh") -+ .AddStaticText(Lang.Get("optimum-greedyspan"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 24, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedyspan-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 24, 400, 25)) -+ .AddSlider(onOptimumGreedySpanChanged, ElementBounds.Fixed(450, y0 + rowH * 24 + 2, 200, 20), "optGreedySpan") -+ .AddStaticText(Lang.Get("optimum-greedylighttol"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 25, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedylighttol-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 25, 400, 25)) -+ .AddSlider(onOptimumGreedyLightTolChanged, ElementBounds.Fixed(450, y0 + rowH * 25 + 2, 200, 20), "optGreedyLightTol") -+ .AddStaticText(Lang.Get("optimum-greedyfardist"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 26, 400, 30)) -+ .AddHoverText(Lang.Get("optimum-greedyfardist-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 26, 400, 25)) -+ .AddSlider(onOptimumGreedyFarDistChanged, ElementBounds.Fixed(450, y0 + rowH * 26 + 2, 200, 20), "optGreedyFarDist") ++ .AddStaticText(Lang.Get("optimum-greedymesh"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 24, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedymesh-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 24, 400, 25)) ++ .AddSwitch(onOptimumGreedyMeshChanged, ElementBounds.Fixed(450, y0 + rowH * 24 - 3, 200, 20), "optGreedyMesh") ++ .AddStaticText(Lang.Get("optimum-greedyspan"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 25, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedyspan-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 25, 400, 25)) ++ .AddSlider(onOptimumGreedySpanChanged, ElementBounds.Fixed(450, y0 + rowH * 25 + 2, 200, 20), "optGreedySpan") ++ .AddStaticText(Lang.Get("optimum-greedylighttol"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 26, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedylighttol-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 26, 400, 25)) ++ .AddSlider(onOptimumGreedyLightTolChanged, ElementBounds.Fixed(450, y0 + rowH * 26 + 2, 200, 20), "optGreedyLightTol") ++ .AddStaticText(Lang.Get("optimum-greedyfardist"), CairoFont.WhiteSmallishText(), ElementBounds.Fixed(0, y0 + rowH * 27, 400, 30)) ++ .AddHoverText(Lang.Get("optimum-greedyfardist-tooltip"), CairoFont.WhiteSmallText(), 250, ElementBounds.Fixed(0, y0 + rowH * 27, 400, 25)) ++ .AddSlider(onOptimumGreedyFarDistChanged, ElementBounds.Fixed(450, y0 + rowH * 27 + 2, 200, 20), "optGreedyFarDist") +#endif + .EndChildElements() + .Compose(); @@ -228,6 +231,7 @@ index 4d08f84..0d1c206 100644 + composer.GetSlider("optTaaSharpness").SetValues((int)Math.Round(Vintagestory.API.Config.OptimumConfig.TaaSharpness * 100f), 0, 100, 5, "%"); + composer.GetSlider("optTaaMipBias").SetValues((int)Math.Round(Vintagestory.API.Config.OptimumConfig.TaaMipBias * 100f), -100, 0, 5, "/100"); + composer.GetSwitch("optAo").SetValue(Vintagestory.API.Config.OptimumConfig.AmbientOcclusionEnabled); ++ composer.GetSwitch("optAoDebug").SetValue(Vintagestory.API.Config.OptimumConfig.AmbientOcclusionDebugView); +#if OPTIMUM_GREEDY_MESH + composer.GetSwitch("optGreedyMesh").SetValue(Vintagestory.API.Config.OptimumConfig.EffectiveGreedyMesh); + composer.GetSlider("optGreedySpan").SetValues(Vintagestory.API.Config.OptimumConfig.GreedyMeshMaxMergeWidth, 1, 8, 1, " blocks"); @@ -330,6 +334,15 @@ index 4d08f84..0d1c206 100644 + Vintagestory.API.Config.OptimumConfig.AmbientOcclusionEnabled = on; + Vintagestory.API.Config.OptimumConfig.Save(); + } ++ ++ // Optimum AO: a view, not a render setting - the final composition reads it as one ++ // uniform per frame and writes the AO term as greyscale instead of the graded scene. ++ // Nothing to reload, and nothing to see while AO itself is off. ++ private void onOptimumAmbientOcclusionDebugChanged(bool on) ++ { ++ Vintagestory.API.Config.OptimumConfig.AmbientOcclusionDebugView = on; ++ Vintagestory.API.Config.OptimumConfig.Save(); ++ } + private void onOptimumEntityLightBatchChanged(bool on) { Vintagestory.API.Config.OptimumConfig.EntityLightBatchEnabled = on; Vintagestory.API.Config.OptimumConfig.Save(); } + private void onOptimumEntityShaderCacheChanged(bool on) { Vintagestory.API.Config.OptimumConfig.EntityShaderStateCacheEnabled = on; Vintagestory.API.Config.OptimumConfig.Save(); } +#if OPTIMUM_GREEDY_MESH @@ -348,7 +361,7 @@ index 4d08f84..0d1c206 100644 ClientSettings.StartupErrorDialog = on; } -@@ -1818,6 +2066,74 @@ public class GuiCompositeSettings : GuiComposite +@@ -1818,6 +2079,74 @@ public class GuiCompositeSettings : GuiComposite { ClientSettings.DeveloperMode = true; OnDeveloperOptions(on: true); diff --git a/sources/VintagestoryApi/Config/OptimumConfig.cs b/sources/VintagestoryApi/Config/OptimumConfig.cs index 716d4f14..1f7ad259 100644 --- a/sources/VintagestoryApi/Config/OptimumConfig.cs +++ b/sources/VintagestoryApi/Config/OptimumConfig.cs @@ -473,6 +473,15 @@ public static class OptimumConfig /// public static bool AmbientOcclusionEnabled = true; + /// + /// The ambient occlusion debug view (Optimum options tab): the final composition writes the + /// AO term alone as greyscale instead of the graded scene - white fully lit, dark fully + /// occluded. It shows whichever AO ran this frame, the platform's own visibility texture or + /// the vanilla blurred SSAO target, and does nothing while AO is off, because neither target + /// was written then. A view, not a render setting: one uniform per frame, no reload. + /// + public static bool AmbientOcclusionDebugView = false; + /// /// Whether GTAO is selected for a backend: never on OpenGL; on Vulkan with "gtao", or /// with "auto" while TAA is active. @@ -914,6 +923,7 @@ public static int ResolveWorldgenWorkerCount( (nameof(OptimumConfigData.AmbientOcclusion), AmbientOcclusion), (nameof(OptimumConfigData.AmbientOcclusionPreset), AmbientOcclusionPreset), (nameof(OptimumConfigData.AmbientOcclusionEnabled), AmbientOcclusionEnabled.ToString()), + (nameof(OptimumConfigData.AmbientOcclusionDebugView), AmbientOcclusionDebugView.ToString()), (nameof(OptimumConfigData.MapPageCache), MapPageCacheEnabled.ToString()), (nameof(OptimumConfigData.MapPageCacheMaxLayers), MapPageCacheMaxLayers.ToString()), (nameof(OptimumConfigData.MapPageCacheBc7), MapPageCacheBc7.ToString()), @@ -1023,6 +1033,7 @@ public static void Load() string requestedAoPreset = data.AmbientOcclusionPreset?.Trim().ToLowerInvariant() ?? ""; AmbientOcclusionPreset = requestedAoPreset is "low" or "high" or "ultra" ? requestedAoPreset : "medium"; AmbientOcclusionEnabled = data.AmbientOcclusionEnabled; + AmbientOcclusionDebugView = data.AmbientOcclusionDebugView; MapPageCacheEnabled = data.MapPageCache; MapPageCacheMaxLayers = Math.Clamp(data.MapPageCacheMaxLayers, 16, 512); MapPageCacheBc7 = data.MapPageCacheBc7; @@ -1100,6 +1111,7 @@ public static void Save() AmbientOcclusion = AmbientOcclusion, AmbientOcclusionPreset = AmbientOcclusionPreset, AmbientOcclusionEnabled = AmbientOcclusionEnabled, + AmbientOcclusionDebugView = AmbientOcclusionDebugView, MapPageCache = MapPageCacheEnabled, MapPageCacheMaxLayers = MapPageCacheMaxLayers, MapPageCacheBc7 = MapPageCacheBc7, @@ -1187,6 +1199,7 @@ internal sealed class OptimumConfigData public string AmbientOcclusion { get; set; } = "auto"; public string AmbientOcclusionPreset { get; set; } = "medium"; public bool AmbientOcclusionEnabled { get; set; } = true; + public bool AmbientOcclusionDebugView { get; set; } = false; public bool MapPageCache { get; set; } = true; public int MapPageCacheMaxLayers { get; set; } = 128; public bool MapPageCacheBc7 { get; set; } = true; diff --git a/sources/lang/en.json b/sources/lang/en.json index c02cfa86..a2ff3dc3 100644 --- a/sources/lang/en.json +++ b/sources/lang/en.json @@ -59,5 +59,7 @@ "optimum-taamipbias-tooltip": "Mip-map bias, in hundredths of a mip level, applied while TAA is on: jitter gives the resolve sub-pixel samples, so textures can be selected sharper than an unjittered frame would allow. -50 is the default; 0 keeps vanilla mip selection; more negative is sharper but can reintroduce texture shimmer. Applies immediately.", "optimum-ao": "Ambient Occlusion", "optimum-ao-tooltip": "Master switch for ambient occlusion - the contact shading in corners, under overhangs and where blocks meet. Off skips both the vanilla SSAO pass and Optimum's GTAO and changes nothing else, so flipping it while standing still shows exactly what AO contributes. Applies on the next frame: no shader reload, no restart. Which AO runs while it is on is the ambient occlusion mode in optimum.json (auto, vanilla or gtao); the vanilla SSAO quality setting at 0 still switches it off on both paths.", + "optimum-aodebug": "AO Debug View (B/W)", + "optimum-aodebug-tooltip": "Show the ambient occlusion term by itself, as a black and white image: white is fully lit, dark is fully occluded, and nothing else - no textures, no lighting, no colour grading. Shows whichever AO is running, Optimum's GTAO or the vanilla SSAO, which makes it the way to compare the two and to see where the occlusion actually lands. Does nothing while ambient occlusion is switched off. Applies immediately.", "optimum-godrayscap-tooltip": "Limits the god-rays post-process to 100 texture samples. Off keeps the vanilla 180-sample path." } diff --git a/sources/shaders-vk/final.frag b/sources/shaders-vk/final.frag index 3efdbb9c..238d2978 100644 --- a/sources/shaders-vk/final.frag +++ b/sources/shaders-vk/final.frag @@ -109,6 +109,16 @@ void main(void) color.a=1; } + // Optimum AO debug view: the ambient occlusion term alone, as greyscale, before colour + // grading and vignetting. ssaoScene holds whichever AO ran this frame (the vanilla blurred + // SSAO target, or the platform's own visibility texture), so white is fully lit and dark is + // fully occluded - the picture of what AO contributes, with nothing else in it. + if (optimumAoDebug != 0) { + float aoDebugTerm = texture(optimumTextures2D[ssaoScene], texCoord).r; + outColor = vec4(vec3(aoDebugTerm), 1.0); + return; + } + vec4 gradedColor = ColorGrade(color); outColor = mix(color, gradedColor, gradedColor.a); diff --git a/sources/shaders-vk/final.interface.glsl b/sources/shaders-vk/final.interface.glsl index c28f5d38..9aa2b67e 100644 --- a/sources/shaders-vk/final.interface.glsl +++ b/sources/shaders-vk/final.interface.glsl @@ -22,6 +22,7 @@ layout(set = OPTIMUM_SET_STORAGE, binding = OPTIMUM_BINDING_PROGRAM_RECORD, scal vec3 playerViewVector; int optimumSsaoInScene; + int optimumAoDebug; float gammaLevel; float brightnessLevel; diff --git a/sources/shaders/final.fsh b/sources/shaders/final.fsh index af156db0..f0182078 100644 --- a/sources/shaders/final.fsh +++ b/sources/shaders/final.fsh @@ -7,6 +7,7 @@ uniform sampler2D bloomParts; uniform sampler2D godrayParts; uniform sampler2D ssaoScene; uniform int optimumSsaoInScene; +uniform int optimumAoDebug; uniform float gammaLevel; uniform float brightnessLevel; @@ -122,6 +123,16 @@ void main(void) color.a=1; #endif + // Optimum AO debug view: the ambient occlusion term alone, as greyscale, before colour + // grading and vignetting. ssaoScene holds whichever AO ran this frame (the vanilla blurred + // SSAO target, or the platform's own visibility texture), so white is fully lit and dark is + // fully occluded - the picture of what AO contributes, with nothing else in it. + if (optimumAoDebug != 0) { + float aoDebugTerm = texture(ssaoScene, texCoord).r; + outColor = vec4(vec3(aoDebugTerm), 1.0); + return; + } + vec4 gradedColor = ColorGrade(color); outColor = mix(color, gradedColor, gradedColor.a); From e5397c2cfccd07e665b9d8232c11cfbf3ee5abe8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 01:20:09 +0200 Subject: [PATCH 182/226] wip(native-systems): device API and native blit - 3 branches, 1-2 native passes, 4 native pipelines, 7 GPU tests Device API (VulkanDevice.Native.cs): native pipelines by program plus fixed state through the pipeline cache without GlStateTracker (negative blend ids keep the key space disjoint), native passes with an explicit target, colour slots and read handles, typed writes by placement resolved once per pipeline, sampled textures resolved to bindless slots from handle plus sampler state, and a native fullscreen draw that shares the descriptor and dynamic-state emission with the emulated path. Stats: native_passes, native_draws; emulation calls are counted, and calls inside a native pass must stay 0. Blit: VulkanClientPlatform.NativeBlit.cs runs BlitPrimaryToDefault natively without base - TAA debug view, FSR (EASU into the FSR target reading Primary colour 0, RCAS into Default) and the plain blit, with the OpenGL body's conditions and uniform values, one declared pass per written target. The window size becomes a transplanted seam (OptimumWindowClientSize) both routes read, and the headless device now creates the offscreen default target. Verified: 7 new NativeBlitTests pass (plain blit and 4 debug views bitwise equal to the old route, FSR within 1/255, 0 emulation calls inside native passes, validation clean); Optimum.Render.Vulkan.Tests 1031 passed with only the 2 known base failures (NativeShaderParityTests scene-ssao, SpecializationConventionTests); Optimum.Tests 1222 passed; extract-patches and check-patches clean. --- Optimum.Patcher/Program.cs | 3 + .../NativeBlitTests.cs | 456 +++++++++++ .../PacingStatsTests.cs | 11 +- .../Core/RenderTargetManager.cs | 29 + Optimum.Render.Vulkan/Core/VulkanStats.cs | 35 +- .../Platform/VulkanClientPlatform.Graph.cs | 11 + .../VulkanClientPlatform.NativeBlit.cs | 259 +++++++ Optimum.Render.Vulkan/VulkanDevice.Native.cs | 711 ++++++++++++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 112 ++- ...-platform-windows-vanilla-regions-tests.cs | 3 +- Optimum.Tests/fsr-pipeline-coverage-tests.cs | 54 ++ docs/taa-acceptance.md | 5 +- .../ClientPlatformWindows.cs.patch | 65 +- 13 files changed, 1694 insertions(+), 60 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeBlitTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs create mode 100644 Optimum.Render.Vulkan/VulkanDevice.Native.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 334fdf56..b5ef52b6 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -241,6 +241,9 @@ "_optimumFocusLostStopwatch", "optimumFsrDisabled", "DisableOptimumFsr", + // Phase 3b: the window's client size as a seam, so the native blit and the OpenGL body + // read the same value (docs/vulkan-native-render-systems.md, decision 3). + "OptimumWindowClientSize", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). diff --git a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs new file mode 100644 index 00000000..37cf0dff --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using OpenTK.Mathematics; +using OpenTK.Windowing.Desktop; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +// The device integration tests' stand-ins for the client's shader types, under names that do +// not read as a device construction to the GPU suite's helper-bypass check. +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The blit to the Default target, drawn twice on one Vulkan device: through the OpenGL body +/// (the route every other vanilla system still takes) and through the native device API +/// (docs/vulkan-native-render-systems.md, decision 4). The three branches - TAA debug view, +/// FSR (EASU into the FSR target, RCAS into Default) and the plain blit - have to produce the +/// same pixels, and the native one must not touch the GL state tracker, a texture unit or a +/// draw-buffer mask while its passes are open. +/// +public class NativeBlitTests(ITestOutputHelper output) +{ + private const int WindowSize = 16; + + /// Render resolution below the window: what makes the FSR branch an upsample. + private const int RenderSize = 10; + + private static readonly string[] Programs = { "blit", "fsr-easu", "fsr-rcas", "taa-debug" }; + + /// The Vulkan platform with FSR under the test's control, so no client setting is read. + private sealed class BlitPlatform : VulkanClientPlatform + { + public BlitPlatform() : base(null!) + { + } + + public bool FsrActive; + + public override bool OptimumFsrBlitActive() => FsrActive; + + /// No window is opened here, so both routes take the blit's size from this seam. + public override Size2i OptimumWindowClientSize() => + new(NativeBlitTests.WindowSize, NativeBlitTests.WindowSize); + } + + // ------------------------------------------------------------------ the tests + + /// + /// The plain blit: same pixels, one declared pass, one native draw, and nothing reaching + /// the emulation layer while the pass is open. + /// + [SkippableFact] + public unsafe void ThePlainBlitMatchesTheOpenGlBodyAndUsesNoEmulation() + { + using Session session = Open(); + + byte[] emulated = RunFrame(session, native: false, debugView: 0, fsr: false); + + long drawsBefore = session.Seam.NativeDrawsForTests; + long passesBefore = session.Seam.NativePassesForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: false); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + Assert.Equal(emulated, nativeRoute); + + GpuTest.AssertClean(session.Seam); + } + + /// Every TAA debug view draws what the OpenGL body draws, with the same mode and render size. + [SkippableTheory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public unsafe void ADebugViewMatchesTheOpenGlBody(int mode) + { + using Session session = Open(); + + byte[] emulated = RunFrame(session, native: false, debugView: mode, fsr: false); + + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] nativeRoute = RunFrame(session, native: true, debugView: mode, fsr: false); + + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + Assert.Equal(emulated, nativeRoute); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// FSR at a render scale below 1: EASU upsamples Primary colour 0 into the FSR target and + /// RCAS sharpens it into Default - two written targets, two declared passes, and pixels + /// the OpenGL body's two draws agree with. + /// + [SkippableFact] + public unsafe void FsrMatchesTheOpenGlBodyWithOnePassPerWrittenTarget() + { + using Session session = Open(); + + byte[] emulated = RunFrame(session, native: false, debugView: 0, fsr: true); + + long drawsBefore = session.Seam.NativeDrawsForTests; + long passesBefore = session.Seam.NativePassesForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: true); + + Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + int worst = WorstChannelDifference(emulated, nativeRoute); + output.WriteLine("FSR worst channel difference: " + worst); + Assert.True(worst <= 1, "FSR differs from the OpenGL body by " + worst + "/255"); + + GpuTest.AssertClean(session.Seam); + } + + /// The OpenGL body on this device is the emulation layer, and the native route is not. + [SkippableFact] + public unsafe void TheOpenGlBodyDrawsThroughTheEmulationLayerAndTheNativeRouteDoesNot() + { + using Session session = Open(); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long emulatedBefore = session.Seam.EmulationCallsForTests; + RunFrame(session, native: false, debugView: 0, fsr: false); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + RunFrame(session, native: true, debugView: 0, fsr: false); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + GpuTest.AssertClean(session.Seam); + } + + // ---------------------------------------------------------------------- driving + + private unsafe byte[] RunFrame(Session session, bool native, int debugView, bool fsr) + { + VulkanDevice seam = session.Seam; + session.Platform.NativeBlitEnabled = native; + session.Platform.FsrActive = fsr; + OptimumConfig.TaaDebugView = debugView; + + session.Platform.BeginFrame(); + + // The motion attachment and the depth the debug views read, written as the frame's + // own clears so both routes see the identical inputs. + seam.BindFramebuffer(session.Primary.FboId); + seam.SetDrawBuffers(session.Primary.FboId, 0b111); + seam.ClearColor(2, 3f, -5f, 0.25f, 0.5f); + seam.ClearDepth(0.5f); + seam.SetDrawBuffers(session.Primary.FboId, 0b011); + + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.125f, 0.75f, 0.375f, 1f); + + // The state ScreenManager.Render leaves the frame in at the blit: blending back on + // after the final composition, no depth test, no culling, viewport on the window. + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetViewport(0, 0, WindowSize, WindowSize); + + session.Platform.BlitPrimaryToDefault(); + + var pixels = new byte[WindowSize * WindowSize * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, WindowSize, WindowSize, (IntPtr)destination); + } + session.Platform.EndFrame(); + return pixels; + } + + private static int WorstChannelDifference(byte[] a, byte[] b) + { + Assert.Equal(a.Length, b.Length); + int worst = 0; + for (int i = 0; i < a.Length; i++) worst = Math.Max(worst, Math.Abs(a[i] - b[i])); + return worst; + } + + // ---------------------------------------------------------------------- session + + private Session Open() + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + + Session? session = Session.TryOpen(output, manifest); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// + /// The platform, its device, the frame buffers the blit indexes and the shader programs + /// it uses, installed the way the client installs them and put back afterwards. + /// + private sealed class Session : IDisposable + { + public BlitPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Primary { get; private set; } = null!; + + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + private ShaderProgramBlit? blitBefore; + private ShaderProgram? easuBefore; + private ShaderProgram? rcasBefore; + private ShaderProgram? debugBefore; + private int debugViewBefore; + + public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-blit-" + Guid.NewGuid().ToString("N")); + var platform = new BlitPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, WindowSize, WindowSize, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + blitBefore = ShaderPrograms.Blit, + easuBefore = ShaderPrograms.FsrEasu, + rcasBefore = ShaderPrograms.FsrRcas, + debugBefore = ShaderPrograms.TaaDebug, + debugViewBefore = OptimumConfig.TaaDebugView, + }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.Primary = CreatePrimary(seam); + FrameBufferRef fsr = CreateFsrTarget(seam); + InstallFrameBuffers(platform, session.Primary, fsr); + + var blit = new ShaderProgramBlit { PassName = "blit" }; + Link(seam, blit, "blit", Array.Empty()); + var easu = new ShaderProgram { PassName = "fsr-easu" }; + Link(seam, easu, "fsr-easu", new[] { "inputTexelSize" }); + var rcas = new ShaderProgram { PassName = "fsr-rcas" }; + Link(seam, rcas, "fsr-rcas", new[] { "inputTexelSize" }); + var debug = new ShaderProgram { PassName = "taa-debug" }; + Link(seam, debug, "taa-debug", new[] { "mode", "renderSize" }); + + ShaderPrograms.Blit = blit; + ShaderPrograms.FsrEasu = easu; + ShaderPrograms.FsrRcas = rcas; + ShaderPrograms.TaaDebug = debug; + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + ShaderPrograms.Blit = blitBefore!; + ShaderPrograms.FsrEasu = easuBefore!; + ShaderPrograms.FsrRcas = rcasBefore!; + ShaderPrograms.TaaDebug = debugBefore!; + OptimumConfig.TaaDebugView = debugViewBefore; + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// Links one vanilla program as ShaderRegistry does and fills the locations its body looks up. + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant()); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + /// Primary at the render resolution: scene, glow and the motion attachment at slot 2, plus depth. + private static unsafe FrameBufferRef CreatePrimary(VulkanDevice seam) + { + var scene = new byte[RenderSize * RenderSize * 4]; + for (int y = 0; y < RenderSize; y++) + { + for (int x = 0; x < RenderSize; x++) + { + int i = (y * RenderSize + x) * 4; + scene[i] = (byte)(20 + x * 23); + scene[i + 1] = (byte)(40 + y * 17); + scene[i + 2] = (byte)(((x ^ y) & 1) * 180 + 30); + scene[i + 3] = 255; + } + } + + int color0; + fixed (byte* pixels = scene) + { + color0 = seam.CreateTexture2D(RenderSize, RenderSize, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + + var primary = new FrameBufferRef + { + Width = RenderSize, + Height = RenderSize, + FboId = seam.CreateFramebuffer(RenderSize, RenderSize), + ColorTextureIds = new[] + { + color0, + seam.CreateTexture2D(RenderSize, RenderSize, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + seam.CreateTexture2D(RenderSize, RenderSize, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + DepthTextureId = seam.CreateTexture2D(RenderSize, RenderSize, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false), + }; + for (int slot = 0; slot < primary.ColorTextureIds.Length; slot++) + { + seam.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + primary.ColorTextureIds[slot], 0); + } + seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + seam.SetDrawBuffers(primary.FboId, 0b011); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status); + return primary; + } + + /// The FSR intermediate at window resolution, as SetupDefaultFrameBuffers builds it. + private static FrameBufferRef CreateFsrTarget(VulkanDevice seam) + { + var target = new FrameBufferRef + { + Width = WindowSize, + Height = WindowSize, + FboId = seam.CreateFramebuffer(WindowSize, WindowSize), + ColorTextureIds = new[] + { + seam.CreateTexture2D(WindowSize, WindowSize, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + }; + seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + seam.SetDrawBuffers(target.FboId, 1); + return target; + } + + /// Primary at slot 0 with its motion attachment at 2, the FSR target at 18, and a window to size the blit by. + private static void InstallFrameBuffers(BlitPlatform platform, FrameBufferRef primary, FrameBufferRef fsr) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = primary; + list[18] = fsr; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + platform.SetOptimumMotionAttachmentIndex(2); + typeof(ClientPlatformWindows).GetField("optimumTaaTargetsReady", flags)!.SetValue(platform, true); + + // No window: OptimumWindowClientSize is the seam both routes size the blit by, + // and NativeWindow.ClientSize is GLFW-backed, so a stub could not answer it. + } + } + + // ---------------------------------------------------------------- native shaders + + /// The manifest of the four programs the blit uses, built once for the whole class. + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + foreach (string program in Programs) + { + NativeShaderBuildResult one = builder.Build(source, program); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + } + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), "optimum-native-blit-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index b535123b..9dccc2e8 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -135,9 +135,10 @@ public void NewStatsLinesCarryStableKeyValueTokens() "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 barrier_commands=9 barriers_per_frame=2.0 " + "mask_restarts=10 feedback_splits=11 passes=12 plan_hits=13 plan_misses=14 in_pass_clears=15 " + "promoted_clears=16 standalone_clears=17 pass_splits=18 push_constants=19 storage_set_binds=20 " + - "bindless_slots=21 bindless_placeholders=22 compute_passes=23 dispatches=24", + "bindless_slots=21 bindless_placeholders=22 compute_passes=23 dispatches=24 " + + "native_passes=25 native_draws=26", VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 20, 21, 22, 23, 24))); + 19, 20, 21, 22, 23, 24, 25, 26))); Assert.Equal( "stats.transients transient_mib=1.5 aliased_mib=0.5 heap_peak_mib=64.0 leases=3 aliased_leases=1 " + @@ -372,8 +373,10 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() Assert.Contains("_frames.Timeline.WaitForTransfer(transferValue, WaitSite.Readback);", readBack); Assert.DoesNotContain("WaitSite.UploadSubmit", device); - // The per-draw dynamic-state count matches the commands actually recorded. - string dynamicState = Body(device, "private void ApplyDynamicState("); + // The per-draw dynamic-state count matches the commands actually recorded. The + // emission is shared: the emulated draw resolves the values from the state tracker, + // a native draw from its pipeline's fixed state, and both record them here. + string dynamicState = Body(device, "private void EmitDynamicState("); Assert.Equal(VulkanStats.DynamicStateCommandsPerDraw, Count(dynamicState, "api.CmdSet")); // Phase 1B step 6: dirty-masked, so the count is what was emitted, not a constant. Assert.Contains("DynamicStateDirty dirty = _dynamicState.Update(serial, values);", dynamicState); diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index d0ae4b85..3ad81a2a 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -746,6 +746,35 @@ public int FormatsIdOf(VulkanFramebuffer framebuffer) return framebuffer.FormatsId; } + /// + /// The attachment formats of the scope opens, without + /// interning them: what a native pipeline has to be built for, and what a native draw + /// checks its pipeline against. is the slot mask a pass + /// would leave out (bit i: slot i is not an attachment of the pass), so the formats can + /// be asked for before the pass is declared. + /// + public RenderTargetFormats ScopeFormats(VulkanFramebuffer framebuffer, uint exclusion = 0) + { + int count = 0; + for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + { + if (InScope(framebuffer, i) && ((exclusion >> i) & 1) == 0) count = i + 1; + } + + var colorFormats = new Format[count]; + for (int i = 0; i < count; i++) + { + bool inScope = InScope(framebuffer, i) && ((exclusion >> i) & 1) == 0; + VulkanTexture? texture = inScope ? _textures.Get(framebuffer.Color[i].TextureId) : null; + colorFormats[i] = texture?.Format ?? Format.Undefined; + } + + Format depthFormat = framebuffer.DepthTextureId > 0 + ? _textures.Get(framebuffer.DepthTextureId)?.Format ?? Format.Undefined + : Format.Undefined; + return new RenderTargetFormats(colorFormats, depthFormat); + } + /// Colour attachments of the scope the framebuffer opens (highest participating slot + 1). public int EnabledAttachmentCount(VulkanFramebuffer framebuffer) => HighestScopeAttachment(framebuffer) + 1; diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 62d0291b..7b85172b 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -351,6 +351,28 @@ public static void NoteBindlessSlotResolution() Interlocked.Increment(ref _intervalBindlessSlots); } + private static long _nativePasses; + private static long _nativeDraws; + private static long _intervalNativePasses; + private static long _intervalNativeDraws; + + /// A pass a native render system declared with explicit writes and reads (no draw-buffer mask). + public static void NoteNativePass() + { + Interlocked.Increment(ref _nativePasses); + Interlocked.Increment(ref _intervalNativePasses); + } + + /// A draw a native render system recorded through its own pipeline, without the GL state tracker. + public static void NoteNativeDraw() + { + Interlocked.Increment(ref _nativeDraws); + Interlocked.Increment(ref _intervalNativeDraws); + } + + public static long NativePasses => Interlocked.Read(ref _nativePasses); + public static long NativeDraws => Interlocked.Read(ref _nativeDraws); + /// A draw's frame texture (set 0) resolved to its placeholder: nothing suitable bound. public static void NoteSamplerPlaceholder() { @@ -486,7 +508,9 @@ public static Result WaitDeviceIdle(Vk api, Device device) BindlessSlots: Interlocked.Exchange(ref _intervalBindlessSlots, 0), BindlessPlaceholders: Interlocked.Exchange(ref _intervalBindlessPlaceholders, 0), ComputePasses: Interlocked.Exchange(ref _computePasses, 0), - Dispatches: Interlocked.Exchange(ref _dispatches, 0)); + Dispatches: Interlocked.Exchange(ref _dispatches, 0), + NativePasses: Interlocked.Exchange(ref _intervalNativePasses, 0), + NativeDraws: Interlocked.Exchange(ref _intervalNativeDraws, 0)); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -592,14 +616,15 @@ public static string FormatCountersLine(CounterSample counters) => "barrier_commands={8} barriers_per_frame={9:F1} mask_restarts={10} feedback_splits={11} " + "passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} " + "standalone_clears={17} pass_splits={18} push_constants={19} storage_set_binds={20} " + - "bindless_slots={21} bindless_placeholders={22} compute_passes={23} dispatches={24}", + "bindless_slots={21} bindless_placeholders={22} compute_passes={23} dispatches={24}" + + " native_passes={25} native_draws={26}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, counters.BarrierCommands, counters.Frames > 0 ? counters.Barriers / (double)counters.Frames : 0.0, counters.MaskRestarts, counters.FeedbackSplits, counters.Passes, counters.PlanHits, counters.PlanMisses, counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits, counters.PushConstantWrites, counters.StorageSetBinds, counters.BindlessSlots, counters.BindlessPlaceholders, - counters.ComputePasses, counters.Dispatches); + counters.ComputePasses, counters.Dispatches, counters.NativePasses, counters.NativeDraws); private static long _lastSample; @@ -698,7 +723,9 @@ internal readonly record struct CounterSample( long BindlessSlots = 0, long BindlessPlaceholders = 0, long ComputePasses = 0, - long Dispatches = 0); + long Dispatches = 0, + long NativePasses = 0, + long NativeDraws = 0); /// The values on the stats.transients line. internal readonly record struct TransientSample( diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index ad940e9c..7fd99d26 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -344,8 +344,19 @@ public override void RenderFinalComposition() SetPassContext("Frame", PassFlags.AllowSplit); } + /// + /// Phase 3b: the blit runs natively (VulkanClientPlatform.NativeBlit.cs) - its own + /// pipelines, one declared pass per written target, no GL-shaped call in between. The + /// GL body stays reachable through for the old-route + /// side of the parity tests. + /// public override void BlitPrimaryToDefault() { + if (NativeBlitEnabled && device != null) + { + RenderNativeBlit(); + return; + } SetPassContext("Blit", PassFlags.None); base.BlitPrimaryToDefault(); SetPassContext("Frame", PassFlags.AllowSplit); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs new file mode 100644 index 00000000..896c8eba --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), stage 1: the blit to +// the swapchain-equivalent Default target runs natively. The three branches are the GL body's +// (ClientPlatformWindows.BlitPrimaryToDefault): the TAA debug view, FSR (EASU into the FSR +// target, RCAS into Default) and the plain blit, with the same conditions, the same uniform +// values and the same inputs - EASU reads Primary colour 0 (decision 7). Each written target +// is one declared pass, and no draw between BeginNativePass and EndNativePass touches the GL +// state tracker, a texture unit or a draw-buffer mask. +public partial class VulkanClientPlatform +{ + /// + /// False runs the OpenGL body on the Vulkan device instead of the native chain: the old + /// route the parity tests compare the native one against. + /// + internal bool NativeBlitEnabled { get; set; } = true; + + /// + /// The base's OffscreenBuffer, which is private there. Tracked through the one virtual + /// that changes it, and starting where the base's own field does. + /// + private bool offscreenBufferActive = true; + + public override void ToggleOffscreenBuffer(bool enable) + { + offscreenBufferActive = enable; + base.ToggleOffscreenBuffer(enable); + } + + /// A native fullscreen program: its pipeline for the current target, and the placements its draws write through. + private sealed class NativeFullscreenPass + { + public NativeFullscreenPass(string passName, string[] uniforms, string[] samplers) + { + PassName = passName; + UniformNames = uniforms; + SamplerNames = samplers; + Uniforms = new NativeUniform[uniforms.Length]; + Samplers = new NativeSamplerSlot[samplers.Length]; + } + + public string PassName { get; } + public string[] UniformNames { get; } + public string[] SamplerNames { get; } + + /// Resolved once per pipeline, never by name per draw. + public NativeUniform[] Uniforms; + public NativeSamplerSlot[] Samplers; + + public NativePipeline? Pipeline; + public RenderTargetFormats? Formats; + public bool Reported; + + public void Adopt(NativePipeline pipeline, RenderTargetFormats formats) + { + Pipeline = pipeline; + Formats = formats; + for (int i = 0; i < UniformNames.Length; i++) Uniforms[i] = pipeline.Uniform(UniformNames[i]); + for (int i = 0; i < SamplerNames.Length; i++) Samplers[i] = pipeline.Sampler(SamplerNames[i]); + } + } + + private readonly NativeFullscreenPass nativeTaaDebug = + new("taa-debug", new[] { "mode", "renderSize" }, new[] { "motionTex", "depthTex", "sceneTex" }); + + private readonly NativeFullscreenPass nativeFsrEasu = + new("fsr-easu", new[] { "inputTexelSize" }, new[] { "inputScene" }); + + private readonly NativeFullscreenPass nativeFsrRcas = + new("fsr-rcas", new[] { "inputTexelSize" }, new[] { "inputScene" }); + + private readonly NativeFullscreenPass nativeBlit = + new("blit", Array.Empty(), new[] { "scene" }); + + /// Opaque fullscreen state: no blend, no depth, no culling, every channel written. + private static AttachmentBlend[] OpaqueColorZero() => new[] { AttachmentBlend.Default }; + + /// + /// The pipeline for one fullscreen program against one target, rebuilt only when the + /// program was relinked or the target's formats changed. + /// + private NativePipeline? NativePipelineFor(NativeFullscreenPass pass, ShaderProgramBase program, int framebufferId) + { + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, 1u); + if (formats == null) return null; + + if (pass.Pipeline != null && pass.Pipeline.ProgramId == program.ProgramId && + formats.Equals(pass.Formats) && device.IsNativePipelineLive(pass.Pipeline)) + { + return pass.Pipeline; + } + + NativePipeline? pipeline = device.RequestNativePipeline(new NativePipelineDescription + { + ProgramId = program.ProgramId, + PassName = pass.PassName, + Blend = OpaqueColorZero(), + DepthTest = false, + DepthWrite = false, + Cull = CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + Targets = formats, + }, out string error); + + if (pipeline == null) + { + if (!pass.Reported) + { + pass.Reported = true; + Logger.Warning("Optimum: no native pipeline for '{0}': {1}", pass.PassName, error); + } + pass.Pipeline = null; + return null; + } + + pass.Reported = false; + pass.Adopt(pipeline, formats); + return pipeline; + } + + /// The Default target, as a native pass names it. + private const int NativeDefaultTarget = PassDeclaration.DefaultFramebuffer; + + private bool BeginNativeBlitPass(string name, int framebufferId, int width, int height, int[] reads) => + device.BeginNativePass(new NativePassDescription + { + Name = name, + FramebufferId = framebufferId, + ColorSlots = 1u, + Reads = reads, + Flags = PassFlags.None, + ViewportWidth = width, + ViewportHeight = height, + }); + + /// + /// Leaves the GL-emulation state where the OpenGL body leaves it, so everything the + /// client draws after the blit (the ortho GUI pass) sees what it always saw: the Default + /// target bound, the viewport on the window, and blending back on where the body turned + /// it off. Outside every native pass. + /// + private void FinishNativeBlit(bool restoreBlend) + { + passContext = "Frame"; + passContextFlags = PassFlags.AllowSplit; + LoadFrameBuffer(EnumFrameBuffer.Default); + if (restoreBlend) GlToggleBlend(true); + } + + /// The native blit: the OpenGL body's three branches, drawn through the native device API. + private void RenderNativeBlit() + { + if (!offscreenBufferActive) return; + + List buffers = FrameBuffers; + FrameBufferRef primary = buffers[0]; + int scene2D = primary.ColorTextureIds[0]; + Size2i client = OptimumWindowClientSize(); + + // TAA debug views (P1): bypasses FSR and the blit entirely, exactly as the GL body does. + if (OptimumConfig.TaaDebugView != 0 && MotionAttachmentIndex >= 0) + { + ShaderProgram taaDebug = ShaderPrograms.TaaDebug; + if (taaDebug != null && !taaDebug.LoadError) + { + int motion = primary.ColorTextureIds[MotionAttachmentIndex]; + int depth = primary.DepthTextureId; + NativePipeline? pipeline = NativePipelineFor(nativeTaaDebug, taaDebug, NativeDefaultTarget); + if (pipeline != null && + BeginNativeBlitPass("Blit/Default", NativeDefaultTarget, client.Width, client.Height, + new[] { motion, depth, scene2D })) + { + device.WriteNative(pipeline, nativeTaaDebug.Uniforms[0], OptimumConfig.TaaDebugView); + device.WriteNative(pipeline, nativeTaaDebug.Uniforms[1], primary.Width, primary.Height); + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeTaaDebug.Samplers[0], motion), + new NativeTexture(nativeTaaDebug.Samplers[1], depth), + new NativeTexture(nativeTaaDebug.Samplers[2], scene2D), + }); + } + device.EndNativePass(); + FinishNativeBlit(restoreBlend: false); + return; + } + } + + // FSR: one shared condition with RenderOptimumTaaSharpen, which skips its own RCAS + // pass whenever this one runs. + if (OptimumFsrBlitActive()) + { + FrameBufferRef fsrTarget = buffers[OptimumFsrFramebufferIndex]; + try + { + ShaderProgram fsrEasu = ShaderPrograms.FsrEasu; + ShaderProgram fsrRcas = ShaderPrograms.FsrRcas; + int fsrColor = fsrTarget.ColorTextureIds[0]; + + // EASU upsamples Primary colour 0 into the FSR target (decision 7). + NativePipeline? easu = NativePipelineFor(nativeFsrEasu, fsrEasu, fsrTarget.FboId); + if (easu != null && + BeginNativeBlitPass("Blit/" + OptimumFsrFramebufferIndex, fsrTarget.FboId, + fsrTarget.Width, fsrTarget.Height, new[] { scene2D })) + { + device.WriteNative(easu, nativeFsrEasu.Uniforms[0], 1f / primary.Width, 1f / primary.Height); + device.DrawNativeFullscreen(easu, new[] + { + new NativeTexture(nativeFsrEasu.Samplers[0], scene2D), + }); + } + device.EndNativePass(); + + // RCAS sharpens the upsampled image into Default. + NativePipeline? rcas = NativePipelineFor(nativeFsrRcas, fsrRcas, NativeDefaultTarget); + if (rcas != null && + BeginNativeBlitPass("Blit/Default", NativeDefaultTarget, client.Width, client.Height, new[] { fsrColor })) + { + device.WriteNative(rcas, nativeFsrRcas.Uniforms[0], 1f / fsrTarget.Width, 1f / fsrTarget.Height); + device.DrawNativeFullscreen(rcas, new[] + { + new NativeTexture(nativeFsrRcas.Samplers[0], fsrColor), + }); + } + device.EndNativePass(); + FinishNativeBlit(restoreBlend: true); + return; + } + catch (Exception error) + { + device.EndNativePass(); + DisableOptimumFsr(error); + FinishNativeBlit(restoreBlend: true); + } + } + + ShaderProgramBlit blit = ShaderPrograms.Blit; + NativePipeline? plain = NativePipelineFor(nativeBlit, blit, NativeDefaultTarget); + if (plain != null && + BeginNativeBlitPass("Blit/Default", NativeDefaultTarget, client.Width, client.Height, new[] { scene2D })) + { + device.DrawNativeFullscreen(plain, new[] + { + new NativeTexture(nativeBlit.Samplers[0], scene2D), + }); + } + device.EndNativePass(); + FinishNativeBlit(restoreBlend: false); + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs new file mode 100644 index 00000000..8def4198 --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -0,0 +1,711 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan; + +/// Which block of a program's interface a native uniform lives in. +internal enum NativeUniformBlock : byte +{ + None = 0, + /// The program record at set 2, snapshotted into the uniform ring per draw. + Record = 1, + /// A DRAW uniform in the program's push block. + Push = 2, + /// A member of the shared frame block at set 0. + Frame = 3, +} + +/// +/// A uniform's placement in one native pipeline's program, resolved once when the pipeline +/// is created. A native renderer holds the value and writes through it, so no draw looks a +/// uniform up by name. +/// +internal readonly record struct NativeUniform(NativeUniformBlock Block, int Offset, int Size) +{ + public bool IsPresent => Block != NativeUniformBlock.None; +} + +/// +/// A sampler's placement: the push-block offset its bindless slot index is written to (or +/// the set 0 binding, for a fixed frame texture) and the array kind it indexes. Resolved +/// once with the pipeline. +/// +internal readonly record struct NativeSamplerSlot(int Index, int PushOffset, int FrameBinding, TextureKind Kind) +{ + public static NativeSamplerSlot None => new(-1, -1, -1, TextureKind.Texture2D); + + public bool IsPresent => Index >= 0; +} + +/// One sampled texture of a native draw: the slot, the texture handle and the sampler state to read it with (null: the texture's own). +internal readonly record struct NativeTexture(NativeSamplerSlot Sampler, int TextureId, SamplerState? Sampling = null); + +/// +/// The fixed state a native pipeline is built for (docs/vulkan-native-render-systems.md, +/// decision 4). It is 's shape stated outright instead of read +/// back out of : per-attachment blend and colour write mask, +/// depth test/write/compare, cull, topology and the target's formats. +/// +internal sealed class NativePipelineDescription +{ + /// The linked program: a manifest program when native shaders are on, its rewritten twin otherwise. + public int ProgramId; + + /// The pass name the program was linked under, checked against the device's; null skips the check. + public string? PassName; + + /// The variant the program must have been linked for, checked against the device's; null skips the check. + public string? VariantKey; + + /// Per colour attachment; is the colour write mask. Attachments past the array are not written. + public AttachmentBlend[] Blend = Array.Empty(); + + public bool DepthTest; + public bool DepthWrite; + public CompareOp DepthCompare = CompareOp.Less; + public CullModeFlags Cull = CullModeFlags.None; + public PrimitiveTopology Topology = PrimitiveTopology.TriangleList; + + /// The attachment formats of the target the pipeline renders into. + public RenderTargetFormats Targets = null!; +} + +/// +/// A pipeline a native render system owns: the program, its fixed state, the pipeline-cache +/// key built from them, and the placement tables the draws write through. +/// +internal sealed class NativePipeline +{ + private readonly Dictionary _uniforms = new(StringComparer.Ordinal); + private readonly Dictionary _samplers = new(StringComparer.Ordinal); + + internal NativePipeline(ShaderProgramResources program, NativePipelineDescription description, + PipelineKey key, GraphicsPipelineCache.PipelineRequest request, int dynamicBlendId) + { + Program = program; + Description = description; + Key = key; + Request = request; + DynamicBlendId = dynamicBlendId; + + ProgramInterfaceLayout layout = program.Interface; + foreach (UniformMember member in layout.Members) + { + _uniforms[member.Name] = new NativeUniform(NativeUniformBlock.Record, member.Offset, member.Size); + } + foreach (KeyValuePair push in layout.PushMembersByName) + { + _uniforms[push.Key] = new NativeUniform(NativeUniformBlock.Push, push.Value.Offset, push.Value.Size); + } + foreach (string name in layout.FrameMemberDeclaredLengths.Keys) + { + if (FrameGlobals.TryGetMember(name, out UniformMember frame)) + { + _uniforms[name] = new NativeUniform(NativeUniformBlock.Frame, frame.Offset, frame.Size); + } + } + for (int i = 0; i < layout.Samplers.Count; i++) + { + SamplerBinding sampler = layout.Samplers[i]; + TextureKind kind = sampler.Kind; + if (sampler.IsFrameTexture) BindlessKinds.TryFromGlslType(sampler.TypeName, out kind); + _samplers[sampler.Name] = new NativeSamplerSlot(i, sampler.PushOffset, sampler.FrameBinding, kind); + } + } + + internal ShaderProgramResources Program { get; } + + public int ProgramId => Program.ProgramId; + + public NativePipelineDescription Description { get; } + + internal PipelineKey Key { get; } + + internal GraphicsPipelineCache.PipelineRequest Request { get; } + + /// The interned blend set the dynamic-state cache compares on, with the mask tier's dynamic blend. + internal int DynamicBlendId { get; } + + /// The placement of a uniform, resolved once here rather than per draw. + public NativeUniform Uniform(string name) => + _uniforms.TryGetValue(name, out NativeUniform member) ? member : default; + + /// The placement of a sampler, resolved once here rather than per draw. + public NativeSamplerSlot Sampler(string name) => + _samplers.TryGetValue(name, out NativeSamplerSlot sampler) ? sampler : NativeSamplerSlot.None; +} + +/// +/// A native pass: an explicit target, the colour slots it writes, the textures it samples +/// and the viewport its draws use. No draw-buffer mask and no bound-target guessing. +/// +internal sealed class NativePassDescription +{ + public string Name = ""; + + /// A render target id, or for the default target. + public int FramebufferId = PassDeclaration.DefaultFramebuffer; + + /// Bit i: colour slot i is an attachment of the pass. + public uint ColorSlots = 1u; + + /// The textures the pass samples, made shader-readable at pass entry. + public int[] Reads = Array.Empty(); + + public uint TransientSlots; + public PassFlags Flags = PassFlags.None; + + public int ViewportX; + public int ViewportY; + + /// Negative: the full target. + public int ViewportWidth = -1; + public int ViewportHeight = -1; +} + +/// +/// The device API native render systems draw through (docs/vulkan-native-render-systems.md, +/// section 2 decision 4 and section 3). +/// +/// A native system asks for a pipeline by program and fixed state, declares a pass with its +/// target, its colour slots and the textures it reads, writes its uniforms by placement and +/// records draws. None of it consults , the texture-unit tables +/// or a draw-buffer mask; the emulation layer stays for mod renderers and for the vanilla +/// systems that have not moved yet. +/// +public sealed unsafe partial class VulkanDevice +{ + private readonly Interner _nativeBlends = new(); + private readonly Interner _nativeFormats = new(); + private readonly Dictionary _nativePipelines = new(); + + /// The manifest variant each native program was linked for, keyed by program id. + private readonly Dictionary _programVariants = new(); + + private NativePassDescription? _nativePass; + private VulkanFramebuffer? _nativeTarget; + private long _emulationCalls; + private long _emulationCallsInNativePasses; + private long _nativePasses; + private long _nativeDraws; + + private readonly record struct NativePipelineCacheKey( + int ProgramId, int FormatsId, int BlendId, bool DepthTest, bool DepthWrite, + CompareOp DepthCompare, CullModeFlags Cull, PrimitiveTopology Topology); + + /// Calls into the GL-emulation layer (state, units, uniforms by location, draws). Tests only. + internal long EmulationCallsForTests => _emulationCalls; + + /// Calls into the GL-emulation layer while a native pass was open: must stay 0. Tests only. + internal long EmulationCallsInNativePassesForTests => _emulationCallsInNativePasses; + + /// Native passes declared and native draws recorded. Tests only. + internal long NativePassesForTests => _nativePasses; + internal long NativeDrawsForTests => _nativeDraws; + + /// Distinct native pipelines this device holds. Tests only. + internal int NativePipelinesForTests => _nativePipelines.Count; + + /// Counts one entry into the GL-emulation layer, and whether it happened inside a native pass. + private void NoteEmulation() + { + _emulationCalls++; + if (_nativePass != null) _emulationCallsInNativePasses++; + } + + /// Set 1's placeholders are written shader-read-only and never used any other way: put them there once. + private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) + { + if (_bindlessPlaceholdersReadable || _bindless == null) return; + + for (int kind = 0; kind < BindlessKinds.Count; kind++) + { + VulkanTexture? placeholder = _textures.Get(_bindless.PlaceholderTextureId((TextureKind)kind)); + if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; + _targets.EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, placeholder, ResourceUsage.SampleFragment); + } + _bindlessPlaceholdersReadable = true; + } + + // ------------------------------------------------------------------ pipelines + + /// + /// The attachment formats of a target's colour slots, so a native system can state the + /// formats its pipeline is built for. Null for a target that does not exist. + /// + internal RenderTargetFormats? NativeTargetFormats(int framebufferId, uint colorSlots) + { + VulkanFramebuffer? target = _targets.Get(ResolveNativeFramebuffer(framebufferId)); + if (target == null) return null; + + uint exclusion = 0; + for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + { + if (((colorSlots >> i) & 1) == 0) exclusion |= 1u << i; + } + return _targets.ScopeFormats(target, exclusion); + } + + /// The manifest variant a program was linked for; "" for a program the rewriter linked. + internal string NativeVariantOf(int programId) => + _programVariants.TryGetValue(programId, out string? key) ? key : ""; + + private int ResolveNativeFramebuffer(int framebufferId) => + framebufferId == PassDeclaration.DefaultFramebuffer ? _defaultFramebuffer : framebufferId; + + /// + /// The pipeline for a program and a piece of fixed state, created through the pipeline + /// cache on first request and returned from this device's table afterwards. Null with a + /// reason when the program is not linked, was linked as something else, or the request + /// names no target formats. + /// + internal NativePipeline? RequestNativePipeline(NativePipelineDescription description, out string error) + { + error = ""; + if (!_programs.TryGetValue(description.ProgramId, out ShaderProgramResources? program)) + { + error = "program " + description.ProgramId + " is not linked"; + return null; + } + if (description.PassName != null && + (!_programNames.TryGetValue(description.ProgramId, out string? name) || + !string.Equals(name, description.PassName, StringComparison.Ordinal))) + { + error = "program " + description.ProgramId + " is not '" + description.PassName + "'"; + return null; + } + if (description.VariantKey != null && + !string.Equals(NativeVariantOf(description.ProgramId), description.VariantKey, StringComparison.Ordinal)) + { + error = "program '" + description.PassName + "' was linked for variant '" + + NativeVariantOf(description.ProgramId) + "', not '" + description.VariantKey + "'"; + return null; + } + if (description.Targets == null) + { + error = "the request names no target formats"; + return null; + } + + ColorWriteTier tier = _context.Capabilities.ColorWriteTier; + bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; + int count = description.Targets.ColorFormats.Length; + + // The blend set as the pipeline bakes it under the colour write tier, the way + // GlStateTracker.PipelineBlendFor does for an emulated draw - without the tracker. + var baked = new AttachmentBlend[Math.Max(count, 1)]; + for (int i = 0; i < baked.Length; i++) + { + AttachmentBlend blend = AttachmentBlend.Default; + if (i < description.Blend.Length) blend = description.Blend[i]; + else blend.WriteMask = 0; + + switch (tier) + { + case ColorWriteTier.DynamicMask: + if (dynamicBlend) blend = AttachmentBlend.Default; + blend.WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + break; + } + baked[i] = blend; + } + + int rawBlendId = _nativeBlends.Intern(new BlendSignature(NativeBlendSpan(description, count))); + int bakedBlendId = _nativeBlends.Intern(new BlendSignature(baked.AsSpan(0, Math.Max(count, 0)))); + int formatsId = _nativeFormats.Intern(description.Targets); + + var cacheKey = new NativePipelineCacheKey(description.ProgramId, formatsId, bakedBlendId, + description.DepthTest, description.DepthWrite, description.DepthCompare, + description.Cull, description.Topology); + if (_nativePipelines.TryGetValue(cacheKey, out NativePipeline? cached) && + ReferenceEquals(cached.Program, program)) + { + return cached; + } + + // A native draw generates its vertices, so the layout is the reserved empty one plus + // the constant attribute defaults GL promises for anything the program declares. + VertexLayoutDescription vertexLayout = _meshes.LayoutOf(MeshManager.EmptyLayoutId) + .WithDefaultsFor(program.Interface.VertexInputs); + + // The blend id is negative so a native key can never collide with an emulated one, + // whose ids come from the tracker's interners. + var key = new PipelineKey( + ProgramId: description.ProgramId, + VertexLayoutId: MeshManager.EmptyLayoutId, + TargetFormatsId: formatsId, + BlendId: -(bakedBlendId + 1), + PolygonMode: PolygonMode.Fill, + TopologyClass: GlEnums.TopologyClassOf(description.Topology)); + + var request = new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = vertexLayout, + Targets = description.Targets, + Blend = baked, + PolygonMode = PolygonMode.Fill, + Topology = description.Topology, + }; + + var pipeline = new NativePipeline(program, description, key, request, -(rawBlendId + 1)); + _nativePipelines[cacheKey] = pipeline; + + // Created here rather than at the first draw where it can be; an async cache queues + // the compile and the first draws are skipped until it is published, as they are on + // the emulated path. + _pipelines.TryGet(key, request, out _); + return pipeline; + } + + private static ReadOnlySpan NativeBlendSpan(NativePipelineDescription description, int count) + { + if (description.Blend.Length >= count) return description.Blend.AsSpan(0, Math.Max(count, 0)); + var padded = new AttachmentBlend[Math.Max(count, 0)]; + for (int i = 0; i < padded.Length; i++) + { + padded[i] = i < description.Blend.Length ? description.Blend[i] : AttachmentBlend.Default; + } + return padded; + } + + /// Whether a pipeline's program is still the linked one of that id (a shader reload replaces it). + internal bool IsNativePipelineLive(NativePipeline pipeline) => + _programs.TryGetValue(pipeline.ProgramId, out ShaderProgramResources? program) && + ReferenceEquals(program, pipeline.Program); + + private void ForgetNativePipelines(int programId) + { + if (_nativePipelines.Count == 0) return; + + var stale = new List(); + foreach (KeyValuePair entry in _nativePipelines) + { + if (entry.Key.ProgramId == programId) stale.Add(entry.Key); + } + foreach (NativePipelineCacheKey key in stale) _nativePipelines.Remove(key); + } + + // ---------------------------------------------------------------------- passes + + /// + /// Opens a native pass on an explicit target: its colour slots, the textures it samples + /// and the viewport its draws use. The draw-buffer mask is not consulted. + /// + internal bool BeginNativePass(NativePassDescription pass) + { + EndNativePass(); + if (!_frameActive) return false; + + int id = ResolveNativeFramebuffer(pass.FramebufferId); + VulkanFramebuffer? target = id > 0 ? _targets.Get(id) : null; + if (target == null) + { + if (RenderTrace.Enabled) RenderTrace.Write("native pass '" + pass.Name + "' skipped: no such target " + id); + return false; + } + + CommandBuffer commandBuffer = Commands; + _targets.DeclarePass(commandBuffer, new PassDeclaration + { + Name = pass.Name, + FramebufferId = id, + ColorSlots = pass.ColorSlots, + Reads = pass.Reads, + TransientSlots = pass.TransientSlots, + Flags = pass.Flags, + }, id); + if (!ReferenceEquals(_targets.Bound, target)) _targets.Bind(commandBuffer, id); + + _nativePass = pass; + _nativeTarget = target; + _nativePasses++; + VulkanStats.NoteNativePass(); + if (RenderTrace.Enabled) + { + RenderTrace.Write("native pass '" + pass.Name + "' target=" + id + " slots=" + pass.ColorSlots + + " reads=" + string.Join(",", pass.Reads)); + } + return true; + } + + /// Closes the native pass and its scope. + internal void EndNativePass() + { + if (_nativePass == null) return; + + _nativePass = null; + _nativeTarget = null; + if (!_frameActive) return; + + CommandBuffer commandBuffer = Commands; + _targets.EndPass(commandBuffer); + _targets.EndRendering(commandBuffer); + } + + // --------------------------------------------------------------------- uniforms + + /// Writes a uniform of a native pipeline's program at its resolved placement. + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, ReadOnlySpan data) + { + switch (uniform.Block) + { + case NativeUniformBlock.Record: + pipeline.Program.SetUniform(uniform.Offset, data); + break; + case NativeUniformBlock.Push: + pipeline.Program.SetPushUniform(ShaderProgramResources.PushLocationBase + uniform.Offset, data); + break; + case NativeUniformBlock.Frame: + WriteFrameGlobal(uniform.Offset, data); + break; + } + } + + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, float value) => + WriteNative(pipeline, uniform, new ReadOnlySpan(&value, sizeof(float))); + + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, int value) => + WriteNative(pipeline, uniform, new ReadOnlySpan(&value, sizeof(int))); + + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, float x, float y) + { + float* values = stackalloc float[2] { x, y }; + WriteNative(pipeline, uniform, new ReadOnlySpan(values, 2 * sizeof(float))); + } + + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, float x, float y, float z) + { + float* values = stackalloc float[3] { x, y, z }; + WriteNative(pipeline, uniform, new ReadOnlySpan(values, 3 * sizeof(float))); + } + + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, float x, float y, float z, float w) + { + float* values = stackalloc float[4] { x, y, z, w }; + WriteNative(pipeline, uniform, new ReadOnlySpan(values, 4 * sizeof(float))); + } + + // ------------------------------------------------------------------------ draws + + /// + /// Records the fullscreen triangle of a native pass: the pass's reads are made + /// shader-readable, the sampled textures resolve to bindless slots straight from their + /// handles and sampler state, and the pipeline's fixed state is what the draw runs with. + /// + internal bool DrawNativeFullscreen(NativePipeline pipeline, ReadOnlySpan textures) + { + if (!_frameActive || _nativePass == null || _nativeTarget == null) + { + if (RenderTrace.Enabled) RenderTrace.Write("native draw skipped: no open native pass"); + return false; + } + + NativePassDescription pass = _nativePass; + VulkanFramebuffer target = _nativeTarget; + if (!ReferenceEquals(_targets.Bound, target)) + { + AddDiagnostic("native pass '" + pass.Name + "' lost its target before its draw"); + return false; + } + + ShaderProgramResources program = pipeline.Program; + if (!IsNativePipelineLive(pipeline)) + { + AddDiagnostic("native pass '" + pass.Name + "' draws with program " + pipeline.ProgramId + + ", which has been relinked or deleted"); + return false; + } + + CommandBuffer commandBuffer = Commands; + ReleaseReadSelfCopies(); + EnsureBindlessPlaceholdersReadable(commandBuffer); + + // The pass's reads, put into the layout a shader read needs. A pass never samples + // its own attachment: that would be feedback, which a native system resolves by + // declaring two passes instead. + for (int i = 0; i < textures.Length; i++) + { + VulkanTexture? texture = _textures.Get(textures[i].TextureId); + if (texture == null) continue; + if (_targets.IsAttachmentOfBound(textures[i].TextureId) || _targets.IsBoundDepth(textures[i].TextureId)) + { + AddDiagnostic("native pass '" + pass.Name + "' samples texture " + textures[i].TextureId + + ", an attachment of its own target"); + return false; + } + _targets.FlushPendingClears(commandBuffer, texture); + if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) + { + _uploads.NoteUse(commandBuffer, texture); + continue; + } + _targets.EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.SampleFragment); + } + _barriers.Flush(commandBuffer); + + _targets.SetDepthReadOnly(false); + _targets.EnsureRendering(commandBuffer); + + RenderTargetFormats scope = _targets.ScopeFormats(target); + if (!scope.Equals(pipeline.Description.Targets)) + { + AddDiagnostic("native pass '" + pass.Name + "' has target formats its pipeline was not built for"); + return false; + } + + if (!_pipelines.TryGet(pipeline.Key, pipeline.Request, out Pipeline handle)) + { + if (RenderTrace.Enabled) + { + RenderTrace.Write("native draw skipped: pipeline for program " + pipeline.ProgramId + " still compiling"); + } + return false; + } + + Vk api = _context.Api; + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, handle); + + VertexLayoutDescription vertexLayout = pipeline.Request.VertexLayout; + if (vertexLayout.Bindings.Length > 0 && + vertexLayout.Bindings[^1].Binding == VertexLayoutDescription.DefaultAttributeBinding && + _defaultAttributes != null) + { + Silk.NET.Vulkan.Buffer defaults = _defaultAttributes.Handle; + ulong offset = 0; + api.CmdBindVertexBuffers(commandBuffer, + VertexLayoutDescription.DefaultAttributeBinding, 1, &defaults, &offset); + } + + // The program's push block, then this draw's slots over it. + int pushSize = program.Interface.PushConstantSize; + if (program.PushShadow != null) program.PushShadow.CopyTo(_pushShadow, 0); + else if (pushSize > 0) _pushShadow.AsSpan(0, pushSize).Clear(); + + for (int i = 0; i < textures.Length; i++) + { + NativeTexture sampled = textures[i]; + NativeSamplerSlot sampler = sampled.Sampler; + if (!sampler.IsPresent) continue; + + VulkanTexture? texture = _textures.Get(sampled.TextureId); + if (texture != null && !BindlessKinds.Suits(TextureShape.Of(texture), sampler.Kind)) + { + if (RenderTrace.Enabled) + { + RenderTrace.Write("native sampler " + sampler.Index + " on program " + program.ProgramId + + " has texture " + sampled.TextureId + " of format " + texture.Format + + " bound, which it cannot sample; using a placeholder"); + } + texture = null; + } + + SamplerState sampling = SamplerState.Default; + if (texture != null) + { + // MAX_LEVEL belongs to the texture, even when the caller overrides the filters. + sampling = sampled.Sampling is { } state + ? state with { MaxLevel = texture.State.MaxLevel } + : texture.State; + } + + if (sampler.FrameBinding >= 0) + { + SamplerBindingValue value = texture == null + ? default + : new SamplerBindingValue((uint)sampler.FrameBinding, texture.View, + _textures.Samplers.Get(BindlessKinds.EffectiveState(sampling, sampler.Kind)), texture.Id); + if (texture == null) VulkanStats.NoteSamplerPlaceholder(); + lock (_frameTextureLock) _frameTextureValues[FrameTextureIndex(sampler.FrameBinding)] = value; + continue; + } + + uint slot = _bindless!.Resolve(texture, sampler.Kind, sampling); + VulkanStats.NoteBindlessSlotResolution(); + BitConverter.TryWriteBytes(_pushShadow.AsSpan(sampler.PushOffset, ProgramInterfaceLayout.SlotBytes), slot); + } + + BindProgramSets(commandBuffer, program, 0); + EmitNativeDynamicState(commandBuffer, target, pass, pipeline); + + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.Fullscreen, program.ProgramId, target.Id, 0)); + if (RenderTrace.Enabled) + { + RenderTrace.Write("native fullscreen program=" + program.ProgramId + " pass='" + pass.Name + + "' target=" + target.Id); + } + api.CmdDraw(commandBuffer, 3, 1, 0, 0); + _nativeDraws++; + VulkanStats.NoteNativeDraw(); + return true; + } + + /// The dynamic state of a native draw: the pipeline's fixed state and the pass's viewport, never the tracker's. + private void EmitNativeDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target, + NativePassDescription pass, NativePipeline pipeline) + { + ColorWriteTier tier = _context.Capabilities.ColorWriteTier; + bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; + int colorStates = (int)Math.Min(_context.Capabilities.MaxColorAttachments, (uint)GlStateTracker.MaxColorAttachments); + NativePipelineDescription description = pipeline.Description; + + uint colorWrite = 0; + for (int i = 0; i < colorStates && tier != ColorWriteTier.PipelineKey; i++) + { + ColorComponentFlags mask = i < description.Blend.Length ? description.Blend[i].WriteMask : 0; + // An output the program never writes keeps the attachment's contents, as it does on GL. + if (!pipeline.Program.Interface.WrittenFragmentOutputs.Contains(i)) mask = 0; + if (tier == ColorWriteTier.DynamicEnable) + { + if (mask != 0) colorWrite |= 1u << i; + } + else + { + colorWrite |= (uint)mask << (i * 4); + } + } + + int width = pass.ViewportWidth >= 0 ? pass.ViewportWidth : (int)target.Width; + int height = pass.ViewportHeight >= 0 ? pass.ViewportHeight : (int)target.Height; + var values = new DynamicStateValues + { + Viewport = new Viewport(pass.ViewportX, pass.ViewportY, width, height, 0f, 1f), + Scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), + CullMode = description.Cull, + FrontFace = GlStateTracker.FrontFace, + Topology = description.Topology, + DepthTest = description.DepthTest, + DepthWrite = description.DepthWrite, + DepthCompare = description.DepthCompare, + StencilTest = false, + StencilFail = StencilOp.Keep, + StencilPass = StencilOp.Keep, + StencilDepthFail = StencilOp.Keep, + StencilCompare = CompareOp.Always, + StencilCompareMask = 0xFF, + StencilWriteMask = 0xFF, + StencilReference = 0, + LineWidth = 1.0f, + ColorWrite = colorWrite, + BlendStateId = dynamicBlend ? pipeline.DynamicBlendId : 0, + }; + + FrameSlot slot = _frames.Current; + ulong serial = slot.CommandBuffer.Handle == commandBuffer.Handle ? slot.RecordingSerial : 0; + + Span blendStates = stackalloc AttachmentBlend[dynamicBlend ? colorStates : 0]; + for (int i = 0; i < blendStates.Length; i++) + { + blendStates[i] = i < description.Blend.Length ? description.Blend[i] : AttachmentBlend.Default; + } + EmitDynamicState(commandBuffer, values, serial, tier, dynamicBlend, colorStates, blendStates); + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 4f46fbf4..76d9503e 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -24,7 +24,7 @@ namespace Optimum.Render.Vulkan; /// out integer ids, because the game's public API exposes raw GL names as fields /// that mods read and pass back. /// -public sealed unsafe class VulkanDevice : IDisposable +public sealed unsafe partial class VulkanDevice : IDisposable { private VulkanContext _context = null!; private UploadManager _uploads = null!; @@ -567,6 +567,12 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa CreateDefaultAttributeBuffer(); CreatePlaceholderUniformBuffer(); + // The default target is an ordinary offscreen one, so it exists headless too: the + // frame's last passes (the blit) write into it, and a headless run - the capture + // harness, a test driving the post chain - reads it back. Only presenting it needs + // a surface. + CreateDefaultFramebuffer((uint)width, (uint)height); + if (!headless) { if (!WindowSurface.TryCreate(_context, windowHandle, out SurfaceKHR surface, out string? surfaceError)) @@ -584,7 +590,6 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _swapchain = swapchain; _presentPath = new BlitPresentPath(_context, _textures, DefaultColorTexture); - CreateDefaultFramebuffer((uint)width, (uint)height); } failureReason = null!; @@ -780,6 +785,10 @@ private void AddDiagnostic(string message) public void BeginFrame() { + // A native pass never spans a frame boundary. + _nativePass = null; + _nativeTarget = null; + // CPU frame interval: start of one frame to the start of the next, so it // includes the Frame timeline pacing wait below and everything the client did. long frameStart = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -1307,21 +1316,41 @@ public void SetVSync(bool enabled) // ------------------------------------------------------------------ raw state - public void SetViewport(int x, int y, int width, int height) => _state.SetViewport(x, y, width, height); + public void SetViewport(int x, int y, int width, int height) + { + NoteEmulation(); + _state.SetViewport(x, y, width, height); + } public void SetScissor(int x, int y, int width, int height) => _state.SetScissor(x, y, width, height); public void SetScissorEnabled(bool enabled) => _state.SetScissorEnabled(enabled); public bool ScissorEnabled => _state.ScissorEnabled; - public void SetDepthTest(bool enabled) => _state.SetDepthTest(enabled); + public void SetDepthTest(bool enabled) + { + NoteEmulation(); + _state.SetDepthTest(enabled); + } public void SetDepthMask(bool enabled) => _state.SetDepthWrite(enabled); public void SetDepthFunc(int func) => _state.SetDepthFunc(func); - public void SetCullFace(bool enabled) => _state.SetCullEnabled(enabled); + public void SetCullFace(bool enabled) + { + NoteEmulation(); + _state.SetCullEnabled(enabled); + } public void SetCullFaceMode(bool back) => _state.SetCullBack(back); - public void SetBlend(bool enabled, EnumBlendMode mode) => _state.SetBlend(enabled, mode); + public void SetBlend(bool enabled, EnumBlendMode mode) + { + NoteEmulation(); + _state.SetBlend(enabled, mode); + } - public void SetBlendEnabled(bool enabled) => _state.SetBlendEnabled(enabled); + public void SetBlendEnabled(bool enabled) + { + NoteEmulation(); + _state.SetBlendEnabled(enabled); + } public void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) => _state.SetAttachmentBlendFunc(attachment, srcColor, dstColor, srcAlpha, dstAlpha); @@ -1470,6 +1499,9 @@ public int LinkProgram(IShaderProgram program) } resources ??= new ShaderProgramResources(_context, programId, translated, _sharedLayout!.Layout); _programs[programId] = resources; + // The variant a native program was linked for (TryLink reports the key as its detail), + // so a native pipeline request can state the variant it expects. + if (resources.IsNative) _programVariants[programId] = nativeDetail; _programNames[programId] = program.PassName ?? ""; // Pipelines an earlier launch used with this exact program start compiling now. int prewarming = _pipelines.PrewarmFor(resources); @@ -1603,10 +1635,15 @@ public void DeleteProgram(int programId) _programNames.Remove(programId); // No background compile may still be reading its modules or layout. _pipelines.CancelProgram(program); + ForgetNativePipelines(programId); _frames.DeferDeletion(program); } - public void UseProgram(int programId) => _state.SetProgram(programId); + public void UseProgram(int programId) + { + NoteEmulation(); + _state.SetProgram(programId); + } public int GetUniformLocation(int programId, string name) => _programs.TryGetValue(programId, out ShaderProgramResources? program) ? program.LocationOf(name) : -1; @@ -1615,6 +1652,7 @@ public int GetUniformLocation(int programId, string name) => private void Write(int programId, int location, ReadOnlySpan data) { + NoteEmulation(); // A member of the shared frame block: one shadow for every program. if (ShaderProgramResources.IsFrameLocation(location)) { @@ -1753,6 +1791,7 @@ internal List SamplerNamesOf(int programId) public void SetSamplerUnit(int programId, string samplerName, int unit) { + NoteEmulation(); if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) { program.SamplerUnits[samplerName] = unit; @@ -2090,6 +2129,7 @@ public int GetTextureParameter(int textureId, int parameterName) public void BindTexture(int unit, int textureId) { + NoteEmulation(); if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; _boundTextures[unit] = textureId; if (RenderTrace.Enabled) RenderTrace.Write("bind unit=" + unit + " texture=" + textureId); @@ -2141,6 +2181,7 @@ public void SetSamplerParameter(int samplerId, int parameterName, float value) public void BindSampler(int unit, int samplerId) { + NoteEmulation(); if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; _unitSamplerOverrides[unit] = _standaloneSamplers.ContainsKey(samplerId) ? samplerId : 0; @@ -2170,8 +2211,11 @@ public void AttachTexture(int framebufferId, EnumFramebufferAttachment attachmen _targets.Attach(framebufferId, index, textureId, (uint)layer); } - public void SetDrawBuffers(int framebufferId, int attachmentMask) => + public void SetDrawBuffers(int framebufferId, int attachmentMask) + { + NoteEmulation(); _targets.SetDrawBuffers(framebufferId, (uint)attachmentMask); + } public bool CheckFramebufferComplete(int framebufferId, out string status) { @@ -2190,11 +2234,13 @@ public bool CheckFramebufferComplete(int framebufferId, out string status) public void BindFramebuffer(int framebufferId) { + NoteEmulation(); if (_frameActive) _targets.Bind(Commands, framebufferId); } public void BindDefaultFramebuffer() { + NoteEmulation(); if (_frameActive) _targets.Bind(Commands, _defaultFramebuffer); } @@ -2544,6 +2590,7 @@ public void DrawFullscreenTriangle() /// private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer commandBuffer) { + NoteEmulation(); commandBuffer = default; if (!_frameActive) { @@ -2694,19 +2741,7 @@ private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgra ReleaseReadSelfCopies(); if (program.Interface.Samplers.Count == 0) return; - // Set 1's placeholders (and set 0's, which are the same textures) are written - // shader-read-only and never used any other way: put them there once. - if (!_bindlessPlaceholdersReadable && _bindless != null) - { - for (int kind = 0; kind < BindlessKinds.Count; kind++) - { - VulkanTexture? placeholder = _textures.Get(_bindless.PlaceholderTextureId((TextureKind)kind)); - if (placeholder == null || placeholder.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; - _targets.EndRendering(commandBuffer); - _textures.Require(_barriers, commandBuffer, placeholder, Graph.ResourceUsage.SampleFragment); - } - _bindlessPlaceholdersReadable = true; - } + EnsureBindlessPlaceholdersReadable(commandBuffer); for (int i = 0; i < program.Interface.Samplers.Count; i++) { @@ -2976,13 +3011,23 @@ private uint SnapshotFrameGlobals(ShaderProgramResources program) private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) { - Vk api = _context.Api; - SharedPipelineLayout shared = _sharedLayout!; - SyncBoundDescriptors(commandBuffer); // A native push block's members persist per program; the slots are resolved over them. if (program.PushShadow != null) program.PushShadow.CopyTo(_pushShadow, 0); ResolveSamplers(program); + BindProgramSets(commandBuffer, program, meshId); + } + + /// + /// Binds the three sets of the shared layout for a draw whose push shadow already holds + /// its sampler slots: the frame set, the texture set with the push block, and the storage + /// set. Shared by the GL-emulation path and the native one. + /// + private void BindProgramSets(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) + { + Vk api = _context.Api; + SharedPipelineLayout shared = _sharedLayout!; + SyncBoundDescriptors(commandBuffer); // Set 0: the frame block and the fixed frame textures. if (program.Interface.UsesFrameBlock || program.Interface.UsesFrameTextures) @@ -3282,6 +3327,21 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta // slot's current one is never trusted. FrameSlot slot = _frames.Current; ulong serial = slot.CommandBuffer.Handle == commandBuffer.Handle ? slot.RecordingSerial : 0; + Span blendStates = stackalloc AttachmentBlend[dynamicBlend ? colorStates : 0]; + for (int i = 0; i < blendStates.Length; i++) blendStates[i] = _state.BlendFor(i); + EmitDynamicState(commandBuffer, values, serial, tier, dynamicBlend, colorStates, blendStates); + } + + /// + /// Records the dynamic state a draw needs and the recording does not already hold. The + /// values come from the GL state tracker on the emulation path and from a native + /// pipeline's fixed state on the native one. + /// + private void EmitDynamicState(CommandBuffer commandBuffer, DynamicStateValues values, ulong serial, + ColorWriteTier tier, bool dynamicBlend, int colorStates, ReadOnlySpan blendStates) + { + Vk api = _context.Api; + uint colorWrite = values.ColorWrite; DynamicStateDirty dirty = _dynamicState.Update(serial, values); if (tier == ColorWriteTier.PipelineKey) dirty &= ~DynamicStateDirty.ColorWrite; if (!dynamicBlend) dirty &= ~DynamicStateDirty.ColorBlend; @@ -3312,7 +3372,7 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta ColorBlendEquationEXT* equations = stackalloc ColorBlendEquationEXT[colorStates]; for (int i = 0; i < colorStates; i++) { - AttachmentBlend blend = _state.BlendFor(i); + AttachmentBlend blend = i < blendStates.Length ? blendStates[i] : AttachmentBlend.Default; blendEnables[i] = blend.Enabled; equations[i] = new ColorBlendEquationEXT { diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index 44115ce3..6d98ffe6 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -43,7 +43,8 @@ public class ClientPlatformWindowsVanillaRegionsTests "OptimumRunPendingTaaShaderReload", "OptimumSpinIterations", "OptimumSpinTailMinProcessorCount", "OptimumSsaoKernel", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumTaaRequested", "OptimumTaaSharpenIndex", "OptimumTimeBeginPeriod", "OptimumTimeEndPeriod", - "OptimumUndershootPercent", "OptimumYieldThresholdMs", "ProbeThickLineSupport", + "OptimumUndershootPercent", "OptimumWindowClientSize", "OptimumYieldThresholdMs", + "ProbeThickLineSupport", "ReadDefaultFramebuffer", "ReadTextureForParity", "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "RestorePrimaryDrawBuffers", "RestoreWorldDrawBuffers", "SelectBackDrawBuffer", "SelectFsrDrawBuffer", "SetBlendEnabled", diff --git a/Optimum.Tests/fsr-pipeline-coverage-tests.cs b/Optimum.Tests/fsr-pipeline-coverage-tests.cs index d6241f1b..bc1d5fbb 100644 --- a/Optimum.Tests/fsr-pipeline-coverage-tests.cs +++ b/Optimum.Tests/fsr-pipeline-coverage-tests.cs @@ -63,6 +63,60 @@ public void BlitRunsEasuBeforeRcasAndKeepsVanillaFallback() Assert.Contains("DisableOptimumFsr(error)", platform); } + /// + /// Phase 3b: on Vulkan the blit runs natively - the same three branches, the same + /// conditions and uniform values, one declared pass per written target, and EASU still + /// reading Primary colour 0 (docs/vulkan-native-render-systems.md, decision 7). The + /// OpenGL body stays reachable for the parity tests' old route. + /// + [Fact] + public void TheVulkanBlitRunsNativelyWithOnePassPerWrittenTarget() + { + string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); + Assert.Contains("public override void BlitPrimaryToDefault()", graph); + Assert.Contains("if (NativeBlitEnabled && device != null)", graph); + Assert.Contains("RenderNativeBlit();", graph); + + string native = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs"); + Assert.Contains("if (OptimumConfig.TaaDebugView != 0 && MotionAttachmentIndex >= 0)", native); + Assert.Contains("if (taaDebug != null && !taaDebug.LoadError)", native); + Assert.Contains("if (OptimumFsrBlitActive())", native); + Assert.Contains("DisableOptimumFsr(error);", native); + // EASU upsamples Primary colour 0, and the two FSR targets are two declared passes. + Assert.Contains("new NativeTexture(nativeFsrEasu.Samplers[0], scene2D),", native); + Assert.Contains("BeginNativeBlitPass(\"Blit/\" + OptimumFsrFramebufferIndex, fsrTarget.FboId,", native); + Assert.Contains("BeginNativeBlitPass(\"Blit/Default\", NativeDefaultTarget, client.Width, client.Height, new[] { fsrColor })", native); + Assert.Contains("1f / primary.Width, 1f / primary.Height", native); + Assert.Contains("1f / fsrTarget.Width, 1f / fsrTarget.Height", native); + Assert.Contains("ShaderProgramBlit blit = ShaderPrograms.Blit;", native); + + // The device API the native blit draws through, and its stats. + string device = Read("Optimum.Render.Vulkan/VulkanDevice.Native.cs"); + Assert.Contains("internal NativePipeline? RequestNativePipeline(", device); + Assert.Contains("internal bool BeginNativePass(NativePassDescription pass)", device); + Assert.Contains("internal bool DrawNativeFullscreen(NativePipeline pipeline, ReadOnlySpan textures)", device); + Assert.Contains("VulkanStats.NoteNativePass();", device); + Assert.Contains("VulkanStats.NoteNativeDraw();", device); + + string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); + Assert.Contains("native_passes={25} native_draws={26}", stats); + + // The FSR failure path needs the base's own disable, so the transplant widens it. + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains("protected void DisableOptimumFsr(Exception error)", platform); + + // Both routes size the blit by one seam rather than casting the window themselves. + Assert.Contains("public virtual Size2i OptimumWindowClientSize()", platform); + Assert.Contains("GlViewport(0, 0, optimumClientSize.Width, optimumClientSize.Height);", platform); + Assert.Contains("Size2i client = OptimumWindowClientSize();", native); + // The FSR sharpen pass takes its viewport from LoadFrameBuffer(Default), so that site + // reads the seam too. + Assert.Contains("Size2i optimumDefaultSize = OptimumWindowClientSize();", platform); + Assert.Contains("\"OptimumWindowClientSize\"", Read("Optimum.Patcher/Program.cs")); + } + [Fact] public void TerrainBiasCoversTextureObjectsAndCustomSamplers() { diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 1c942c5e..89fba533 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -272,7 +272,7 @@ unchanged from earlier builds; the other six carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= push_constants= storage_set_binds= bindless_slots= bindless_placeholders= compute_passes= dispatches= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= push_constants= storage_set_binds= bindless_slots= bindless_placeholders= compute_passes= dispatches= native_passes= native_draws= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draws_skipped= pending= cache_bytes= saves= @@ -317,6 +317,9 @@ stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draw because nothing suitable was bound). Compute pass kind: `compute_passes` (compute passes recorded, their barriers flushed with no rendering scope open; not part of `passes`) and `dispatches` (vkCmdDispatch calls). + Native render systems (Phase 3b): `native_passes` (passes declared through the native device + API, with explicit writes and reads instead of a draw-buffer mask) and `native_draws` (draws + recorded through a native pipeline, without the GL state tracker or a texture unit). The colour write tier is on the device-up validation log line; `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. - `stats.transients` (Phase 2 step 4, `TransientAllocator` and `FeedbackCopyPool`): `transient_mib` diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 8cbe7111..0541b2c2 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..e4a4415 100644 +index 6edf0c9..9026010 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -1988,7 +1988,7 @@ index 6edf0c9..e4a4415 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3066,68 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3066,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2022,12 +2022,17 @@ index 6edf0c9..e4a4415 100644 break; } case EnumFrameBuffer.Default: ++ { CurrentFrameBufferKeepVw = null; - GL.Viewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); - GL.DrawBuffer((DrawBufferMode)1029); -+ GlViewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); ++ // Optimum (Phase 3b): through the window-size seam, like the blit - the FSR ++ // sharpen pass takes its viewport from exactly this call. ++ Size2i optimumDefaultSize = OptimumWindowClientSize(); ++ GlViewport(0, 0, optimumDefaultSize.Width, optimumDefaultSize.Height); + SelectBackDrawBuffer(); break; ++ } case EnumFrameBuffer.BlurHorizontalMedRes: case EnumFrameBuffer.BlurVerticalMedRes: - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X / 2f), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y / 2f)); @@ -2074,7 +2079,7 @@ index 6edf0c9..e4a4415 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3146,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3151,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2160,7 +2165,7 @@ index 6edf0c9..e4a4415 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3228,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3233,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2421,7 +2426,7 @@ index 6edf0c9..e4a4415 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3492,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3497,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2535,7 +2540,7 @@ index 6edf0c9..e4a4415 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3606,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3611,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2714,7 +2719,7 @@ index 6edf0c9..e4a4415 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3740,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3745,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2749,7 +2754,7 @@ index 6edf0c9..e4a4415 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3779,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3784,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2806,13 +2811,13 @@ index 6edf0c9..e4a4415 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3834,484 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3839,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } } -+ private void DisableOptimumFsr(Exception error) ++ protected void DisableOptimumFsr(Exception error) + { + if (!optimumFsrDisabled) + { @@ -3197,6 +3202,17 @@ index 6edf0c9..e4a4415 100644 + /// (see taa-skymotion.fsh), so a clear pixel still gets 0. + /// + internal const float OptimumCloudReactive = 1f; ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): the window's client size, which the ++ /// blit sizes the Default target's viewport by. A seam rather than a cast of the window, ++ /// so a native render system - and a test that drives one without a window - reads the ++ /// same value the OpenGL body does. ++ /// ++ public virtual Size2i OptimumWindowClientSize() ++ { ++ return new Size2i(((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); ++ } + public override void BlitPrimaryToDefault() { @@ -3205,6 +3221,7 @@ index 6edf0c9..e4a4415 100644 if (OffscreenBuffer) { int scene2D = frameBuffers[0].ColorTextureIds[0]; ++ Size2i optimumClientSize = OptimumWindowClientSize(); + // Optimum P1: TAA debug views. Bypasses the normal FSR/blit path + // entirely and draws a fullscreen triangle visualising the Primary + // motion attachment, depth buffer or scene colour. Only reached when @@ -3217,7 +3234,7 @@ index 6edf0c9..e4a4415 100644 + if (taaDebug != null && !taaDebug.LoadError) + { + LoadFrameBuffer(EnumFrameBuffer.Default); -+ GlViewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); ++ GlViewport(0, 0, optimumClientSize.Width, optimumClientSize.Height); + taaDebug.Use(); + taaDebug.BindTexture2D("motionTex", frameBuffers[0].ColorTextureIds[MotionAttachmentIndex], 0); + taaDebug.BindTexture2D("depthTex", frameBuffers[0].DepthTextureId, 1); @@ -3263,13 +3280,13 @@ index 6edf0c9..e4a4415 100644 + { + DisableOptimumFsr(error); + LoadFrameBuffer(EnumFrameBuffer.Default); -+ GlViewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); ++ GlViewport(0, 0, optimumClientSize.Width, optimumClientSize.Height); + GlToggleBlend(on: true); + } + } LoadFrameBuffer(EnumFrameBuffer.Default); - GL.Viewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); -+ GlViewport(0, 0, ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); ++ GlViewport(0, 0, optimumClientSize.Width, optimumClientSize.Height); ShaderProgramBlit blit = ShaderPrograms.Blit; blit.Use(); blit.Scene2D = scene2D; @@ -3292,7 +3309,7 @@ index 6edf0c9..e4a4415 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4470,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4487,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3332,7 +3349,7 @@ index 6edf0c9..e4a4415 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4868,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4885,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3385,7 +3402,7 @@ index 6edf0c9..e4a4415 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4963,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4980,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3430,7 +3447,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5000,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5017,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3451,7 +3468,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5019,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5036,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3472,7 +3489,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5038,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5055,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3493,7 +3510,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5057,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5074,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3514,7 +3531,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5080,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5097,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3535,7 +3552,7 @@ index 6edf0c9..e4a4415 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5642,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5659,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3559,7 +3576,7 @@ index 6edf0c9..e4a4415 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6001,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6018,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From 356c72ef31f1c012ad366e4ad829ff8d17be85d1 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 01:31:31 +0200 Subject: [PATCH 183/226] feat(ao): one dropdown for the AO choice - off, the game's SSAO, Optimum's GTAO, auto Replaces the on/off switch with a four-way choice, so the two AO implementations can be compared directly instead of only switched off: Off, Auto (GTAO while TAA is active), Vanilla SSAO (game), Optimum GTAO. Off still gates RenderSSAO only and takes effect on the next frame. Moving between the game's AO and ours changes what OPTIMUMAO stamps into the shaders (the class-channel writes and scene-ssao's compose branch), so that case, and only that case, reloads them - the handler compares EffectiveGtao before and after and reloads nothing otherwise. Also re-applies the AO gate and the final-composition AO binding onto the merged tree: the stage-1a merge resolved the generated ClientPlatformWindows patch in favour of the stage (only the hunk offsets conflicted), which dropped them, and extract regenerated the patch with both sides. Verified: build 0 errors; extract-patches and check-patches clean (157 patches, 0 conflict); Optimum.Tests 1230 passed. Two headless captures of the same scripted scene with the debug view on show the AO debug reads both sources: vanilla mean 191.3 (soft, half-res blurred) versus GTAO mean 184.7 (fine per-block contact shading). --- .../ClientPlatformWindows.cs.patch | 83 +++++++++++-------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 0541b2c2..e15fdaa0 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..9026010 100644 +index 6edf0c9..812ddd3 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -386,7 +386,7 @@ index 6edf0c9..9026010 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +738,148 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +738,154 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -497,7 +497,14 @@ index 6edf0c9..9026010 100644 RenderBloom = ClientSettings.Bloom && base.DoPostProcessingEffects; RenderGodRays = ClientSettings.GodRayQuality > 0 && base.DoPostProcessingEffects; RenderFXAA = ClientSettings.FXAA && base.DoPostProcessingEffects; - RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects; +- RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects; ++ // Optimum AO: the in-game master switch (Optimum options tab). It gates the ++ // passes only - SetupSSAO below keeps the G-buffer and its frame buffers, and ++ // the shader defines are stamped from the AO mode - so it flips live with no ++ // shader reload and no frame buffer rebuild, and whichever AO the mode selects ++ // comes back exactly as it was. Both AO paths hang off RenderSSAO: vanilla SSAO ++ // and RenderOptimumAmbientOcclusion are inside the same condition. ++ RenderSSAO = ClientSettings.SSAOQuality > 0 && base.DoPostProcessingEffects && OptimumConfig.AmbientOcclusionEnabled; SetupSSAO = ClientSettings.SSAOQuality > 0; ShadowMapQuality = ClientSettings.ShadowMapQuality; ShaderProgramBase.shadowmapQuality = ShadowMapQuality; @@ -541,7 +548,7 @@ index 6edf0c9..9026010 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +899,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +905,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -554,7 +561,7 @@ index 6edf0c9..9026010 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1070,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1076,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -599,7 +606,7 @@ index 6edf0c9..9026010 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1182,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1188,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -619,7 +626,7 @@ index 6edf0c9..9026010 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1418,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1424,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -632,7 +639,7 @@ index 6edf0c9..9026010 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,146 +1545,943 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,146 +1551,943 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -1696,7 +1703,7 @@ index 6edf0c9..9026010 100644 _ = ClientSettings.SSAOQuality; float num3 = 0.5f; FrameBufferRef obj = new FrameBufferRef -@@ -1436,10 +2628,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2634,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1772,7 +1779,7 @@ index 6edf0c9..9026010 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2805,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2811,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1787,7 +1794,7 @@ index 6edf0c9..9026010 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2828,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2834,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1882,7 +1889,7 @@ index 6edf0c9..9026010 100644 } } } -@@ -1591,11 +2922,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2928,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1901,7 +1908,7 @@ index 6edf0c9..9026010 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2957,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2963,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1988,7 +1995,7 @@ index 6edf0c9..9026010 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3066,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3072,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2079,7 +2086,7 @@ index 6edf0c9..9026010 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3151,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3157,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2165,7 +2172,7 @@ index 6edf0c9..9026010 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3233,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,22 +3239,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2426,7 +2433,7 @@ index 6edf0c9..9026010 100644 public override void RenderPostprocessingEffects(float[] projectMatrix) { //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3497,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1823,20 +3503,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_05c9: Unknown result type (might be due to invalid IL or missing references) if (!OffscreenBuffer) { @@ -2540,7 +2547,7 @@ index 6edf0c9..9026010 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3611,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3617,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2719,7 +2726,7 @@ index 6edf0c9..9026010 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3745,30 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3751,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2742,19 +2749,29 @@ index 6edf0c9..9026010 100644 + final.GlowParts2D = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1]; final.GodrayParts2D = godrayParts2D; final.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; ++ // Optimum AO: the debug view shows whichever AO actually ran this frame - the ++ // platform's own visibility texture when it produced one, the vanilla blurred ++ // SSAO target otherwise. Never while AO is off: nothing filled either target. ++ bool optimumAoDebugView = OptimumConfig.AmbientOcclusionDebugView && RenderSSAO; if (RenderSSAO) { - final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; +- final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; ++ final.SsaoScene2D = ((optimumAoDebugView && optimumAmbientOcclusionTexture != 0) ? optimumAmbientOcclusionTexture : frameBuffers[14].ColorTextureIds[0]); } + // Optimum TAA: written every frame, never conditionally - a declared uniform + // left unset reads back as whatever the Vulkan uniform ring last held. -+ final.Uniform("optimumSsaoInScene", optimumSsaoInScene ? 1 : 0); ++ // Optimum AO: the flag means "AO is not Final's to apply". With AO switched off ++ // nothing rendered into the SSAO target this frame, so multiplying by it would ++ // darken the whole image by whatever the target happens to hold - the flag is ++ // set for that case too, which is the one state vanilla never produced. ++ final.Uniform("optimumSsaoInScene", (optimumSsaoInScene || !RenderSSAO) ? 1 : 0); ++ final.Uniform("optimumAoDebug", optimumAoDebugView ? 1 : 0); final.Uniform("invFrameSizeIn", 1f / ((float)((NativeWindow)window).ClientSize.X * ssaaLevel), 1f / ((float)((NativeWindow)window).ClientSize.Y * ssaaLevel)); final.GammaLevel = ClientSettings.GammaLevel; final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3784,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3799,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2811,7 +2828,7 @@ index 6edf0c9..9026010 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3839,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3854,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3309,7 +3326,7 @@ index 6edf0c9..9026010 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4487,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4502,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3349,7 +3366,7 @@ index 6edf0c9..9026010 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4885,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4900,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3402,7 +3419,7 @@ index 6edf0c9..9026010 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4980,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +4995,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3447,7 +3464,7 @@ index 6edf0c9..9026010 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5017,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5032,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3468,7 +3485,7 @@ index 6edf0c9..9026010 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5036,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5051,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3489,7 +3506,7 @@ index 6edf0c9..9026010 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5055,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5070,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3510,7 +3527,7 @@ index 6edf0c9..9026010 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5074,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5089,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3531,7 +3548,7 @@ index 6edf0c9..9026010 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5097,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3552,7 +3569,7 @@ index 6edf0c9..9026010 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5659,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5674,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3576,7 +3593,7 @@ index 6edf0c9..9026010 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6018,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6033,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From cdd6dd0905c628af3646f90e19d2c056a21cdb61 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 01:54:53 +0200 Subject: [PATCH 184/226] docs: audited status of every plan item, and the DLSS/DLSS-FG foundations as explicit work Five read-only agents checked every item of the Vulkan-native plan against this tree, each required to cite a file, test or commit for anything marked done; a claim in the plan or in this handoff was not accepted as evidence. 44 done, 15 partial, 28 left, 1 blocked, 2 superseded. The plan is not complete: Phase 3b is one of nine chain passes native with no world system started, every Phase 4 numeric exit criterion is unmeasured, three Milestone 1 bullets are unmet, and Phases 6, the latency seams and the NGX/DLSS work are not on this branch at all. Also records the two foundations the owner asked to bring forward - frame marking from feat/latency and HUD separation from feat/dlss-g (not feat/dlss) - with their conflicts against the native blit that replaced the GL body of BlitPrimaryToDefault, and their staging. --- docs/vulkan-branch-progress.md | 176 +++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index c5202fbf..15e0dd23 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -115,6 +115,182 @@ compared before and after for every branch. ## 5. Status and to-do +### Plan status, audited 2026-09-16 + +Every item of `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md` checked against this tree by +five read-only agents, each required to cite a file, test or commit for anything marked done; a claim in the plan or +in this handoff was not accepted as evidence. The same marking, with per-item evidence, is at the top of the plan file. + +**44 done, 15 partial, 28 left, 1 blocked, 2 superseded. The plan is not complete.** + +Legend: `[x]` done, `[~]` partially done (what is left follows it), `[ ]` not started, `[!]` blocked externally, +`[-]` superseded by a later decision. + +**Step 0: branching; Phase 0: foundations; Phase 1A: platform substitution; Phase 1B: synchronisation foundation; constrai** + +- [x] Step 0: Branching: fix/taa-sky-direction and feat/vulkan-native from origin/main +- [x] Phase 0: Foundations: patcher capabilities, diagnostics, validation default, parity dump, acceptance doc skeleton +- [x] Phase 0 exit criteria: Phase 0 exit: builds/suites green, both dump paths run, GL-vs-GL noise floor, VK-vs-GL table, pacing baselines recorded +- [x] Phase 1A step 1: VulkanClientPlatform forwarding subclass; SetupOptimumFrameBuffers moved; ClientProgram.Start wiring; csproj donor reference +- [x] Phase 1A step 2: TAA members to the abstract class; 7 casts become virtual calls; 14 Optimum.Tests files re-pointed +- [x] Phase 1A step 3: Program/uniform/UBO virtuals; ShaderProgramBase.cs and UBO.cs revert to vanilla plus virtual calls; per-draw CPU measured +- [x] Phase 1A step 4: Remaining leaf sites moved; IOptimumGraphicsDevice/OptimumRender.Device/OptimumRenderBootstrap.Install deleted; ClientPlatformWindows branch-free +- [x] Phase 1A Tests: Phase 1A test list: GL.-grep source test, no-lambda test, fallback re-assigns ScreenManager.Platform, no device/cast remnants, PlatformSubstitutionTests, identical-pixel GPU tests +- [x] Phase 1A Exit: Phase 1A exit: identical screenshots per backend; forced-install-failure fallback exercised with the exact log line +- [x] Phase 1B step 1: FrameTimeline + RetireQueue; FrameRing on timelines; blocking waits = 1/frame +- [x] Phase 1B step 2: UploadManager + per-slot upload command buffer (backend A); ReadbackManager + SubmitPartial + QueryRing; SubmitAndWait/FlushFrame deleted +- [x] Phase 1B step 3: Readback in a frame: ReadbackManager.CopyToHost + SubmitPartial; only screenshot path waits +- [x] Phase 1B step 4: Swapchain/SwapchainRetirement/IPresentPath split submission; resize/alt-tab/minimise clean under sync,best; acquire wait stage never ALL_COMMANDS +- [x] Phase 1B step 5: VulkanAllocator pool classes + budget; static meshes off ReBAR; allocator policy tests; heap report +- [x] Phase 1B step 6: Per-slot indirect ring; descriptor arena; dirty-masked dynamic state; free GetError; CPU frame time drop measured, draw counters unchanged +- [~] Phase 1B Tests: GPU test list: AsyncTransferTests, PresentDecouplingTests, SwapchainRecreationVisualTests, ConcurrentDeviceAccessTests, ReadbackMidFrameTests, QueryRingTests, AllocatorPolicyTests; unit: IndirectRingWrapTests, TimelineLifetimeTests, PresentWaitStageTests, SwapchainRetirementTests +- [x] Phase 1B/1A Exit (combined, Phase 1 exit): Phase 1 exit: both renderers start; forced-install-failure fallback; sync,best 0 errors; blocking uploads 0; build/test counts recorded +- [x] Constraint: Cecil transplant rules: No cached lambdas / LINQ predicates / non-capturing lambdas / hidden-helper lowering in transplanted bodies +- [x] Constraint: Patcher capabilities (typesToUnseal/methodsToVirtualize/verifier): typesToUnseal clears TypeAttributes.Sealed; methodsToVirtualize sets Virtual|NewSlot|HideBySig; call-vs-callvirt verifier fails the patch on a stray call +- [x] Constraint: Hardware floor: Vulkan 1.3 + dynamicRendering, synchronization2, timelineSemaphore, scalarBlockLayout, independentBlend, multiDrawIndirect; optional tiers with fallback + env override + +**Phase 2: frame graph -> Milestone 1; Phase 3: native shaders** + +- [x] Phase 2 Step 1: ResourceStateTracker + BarrierBatcher drive the immediate path +- [x] Phase 2 Step 2: FrameGraph streaming recorder + PassRecorder, coexisting with the non-graph path +- [x] Phase 2 Step 3: Write-mask motion tiers, clear promotion, FramePlan load/store solving; TAA through the graph +- [~] Phase 2 Step 4: Transient aliasing implemented, default off, but not wired into the live per-frame graph path + - left: Wire FrameGraph/PassRecorder to call BindTransientForFrame per declared transient lifetime so aliasing can actually take effect outside tests. +- [x] Phase 2 invariants pinned by tests: Invariants pinned by tests +- [ ] Milestone 1 bullet: pacing-gate.sh passes: M1 bullet 1 - pacing-gate.sh passes against the OpenGL baseline +- [x] Milestone 1 bullet: blocking uploads/waits: M1 bullet 2 - blocking uploads 0, blocking waits 1/frame +- [x] Milestone 1 bullet: acquire ordering: M1 bullet 3 - acquire after render submit, wait stage TRANSFER/COLOR_ATTACHMENT_OUTPUT +- [x] Milestone 1 bullet: scopes and passes: M1 bullet 4 - ScopesOpened==PassCount; no transition inside a scope +- [x] Milestone 1 bullet: validation scripted session: M1 bullet 5 - sync,best validation, zero [error] over the scripted session +- [ ] Milestone 1 bullet: SSIM parity TAA off: M1 bullet 6 - per-attachment SSIM vs OpenGL, TAA off +- [x] Milestone 1 bullet: TAA still-frame stability: M1 bullet 7 - TAA on: luma-diff median within 0.3 of OpenGL, distant-leaf rejection <=1.5% +- [ ] Milestone 1 bullet: TAA acceptance rows re-pass: M1 bullet 8 - TAA acceptance rows A11,A13,A14,A15,A17,A18 re-pass +- [x] Milestone 1 bullet: in-game judgement: M1 bullet 9 - user judges it in game on both backends +- [-] Phase 3 set convention: Set convention: superseded by decision 9, implemented as a single shared layout +- [x] Phase 3 placement table: Uniform placement table +- [x] Phase 3 manifest: shaders.manifest.json schema and consistency +- [x] Phase 3 compiler tool: Offline shader compiler tool (--build/--verify/--single) +- [x] Phase 3 adapter layout: Mod-shader adapter retargeted to the shared layout in one change +- [x] Phase 3 seven worktree stages of native GLSL: Native GLSL ported in family stages +- [x] Phase 3 scanner v2: Launcher scanner v2 (ShaderAssetOverride, PlatformInternals, schema 2) +- [ ] Phase 3 temporal contract addendum-or-v2 decision: Temporal contract addendum-or-v2 decision for native shaders + - left: Add a dated entry to docs/temporal-frame-contract.md (or a version bump) stating whether the native-shader motion-writer port is a v1 addendum or a v2 change. +- [ ] Phase 3 ReloadShaders no longer recompiling on a settings change: ReloadShaders no longer recompiling on a settings change +- [~] Phase 3 Tests list: Phase 3 Tests list (parity, motion-writer shape, manifest, adapter-layout, differential, launcher fixtures, no legacy extensions) + - left: The plan specifically asks that 'the eight Taa*Motion*Tests gain native-vs-rewriter differential cases (motion attachment equal within 1 ULP of RGBA16F)'. Searched TaaMotionWriterTests.cs, TaaEntityMotionWriterTests.cs, TaaInstancedMotionWriterTests.cs, TaaStandardMotionWriterTests.cs, TaaLiquidMotionTests.cs, TaaSkyMotionTests.cs and found no native-vs-rewriter comparison in any of them - all sti +- [ ] Phase 3 Exit criteria: Phase 3 exit criteria (48 native/0 failed logged, SSIM>=0.99, validation clean, in-game settings sweep, vulkan-acceptance.md matrix, contract decision) + - left: Run and record the actual Phase 3 exit in docs/vulkan-acceptance.md: the native/rewritten/failed count from a real (non-headless-forced) launch, per-attachment SSIM, validation log, the full settings sweep on both backends, and the temporal-contract addendum-or-v2 decision. + +**Phase 3b (docs/vulkan-native-render-systems.md decisions 1-7, stage 1 nine-pass scope, parallel world-system stages, rem** + +- [x] Phase 3b decision 1: Runtime rewriter stays permanently as mod-shader adapter +- [~] Phase 3b decision 2: Seams are existing virtuals, overridden without calling base + - left: 6 of 7 post/TAA virtuals still call base; no world-system transplanted seams exist (ChunkRenderer, entities, particles, GUI unchanged). +- [~] Phase 3b decision 3: A native system reads client state, never GL state + - left: Rule only exercised by the blit; unverified for any world-render system since none has been ported. +- [x] Phase 3b decision 4: Device API for native systems (NativePasses) +- [~] Phase 3b decision 5: order and parallelism: Stage 1 (device API + post/TAA chain) then parallel world systems then removal + - left: Stage 1 itself incomplete (8 of 9 chain passes still on base); stage 2 (chunks, entities, particles/decals/sky/clouds, GUI/text) not started; stage 3 removal not started. +- [~] Phase 3b decision 6: Behavioural identity is the acceptance rule (old-route vs native-route GPU tests) + - left: Differential tests needed for the remaining 8 passes once each goes native; none exist because none is native. +- [x] Phase 3b decision 7: FSR input identity preserved (BlitPrimaryToDefault keeps reading Primary colour 0) +- [~] Phase 3b stage 1 scope: 9 chain passes: Which of the nine post/TAA chain passes are native today + - left: 8 of 9 passes (everything except the final blit) still run the OpenGL body via base.() and therefore still go through GlStateTracker, texture units and uniform-by-location. +- [ ] Phase 3b: world render systems still on the emulation layer: Every world render system still on the GL-emulation layer + - left: All world render systems (chunks, entities, particles, decals, sky/clouds, GUI/text) - stage 2 of decision 5 - are entirely unstarted. +- [ ] Phase 3b: GlStateTracker / texture-unit tables / uniform-by-location reachability: GlStateTracker, texture-unit tables and uniform-by-location still reachable from the Vulkan path + - left: Not reachable only from the native blit's own pipeline creation; reachable and load-bearing for every other pass and every world system. +- [x] Phase 4: disk pipeline cache: Disk pipeline cache with FAIL_ON_PIPELINE_COMPILE_REQUIRED, background compile worker +- [x] Phase 4: used-key manifest: Used-pipeline-key manifest for pre-warming +- [x] Phase 4: warm-up: Background warm-up from the manifest +- [ ] Phase 4: push-constant placement from a measured profile: Push-constant placement frozen from OPTIMUM_VULKAN_UNIFORM_PROFILE measurement + - left: Entire item: the env-driven measurement tool, the manifest field, and the placement logic reading it are all absent. +- [x] Phase 4: animation SSBO ring: Bone/animation data on a storage-buffer ring with dynamic offsets +- [ ] Phase 4: Use() include-block early-out: Skipping ShaderProgramBase.Use()'s frame-global include-block writes when unchanged + - left: Entire item unimplemented; Use() still writes all ~50 frame-global uniforms unconditionally every call. +- [ ] Phase 4: per-pass GPU timestamps: Per-pass GPU time table from timestamp queries + - left: No timestamp-query infrastructure exists; no per-pass ms table has been produced. +- [ ] Phase 4: transient aliasing default on: Transient aliasing switched on by default after clean validation on all targets + - left: Default flag flip to on, plus the required clean-validation-on-all-targets gate, have not happened. +- [-] Phase 4: bindless decision: Bindless set (originally set 2, later set 1) adopted based on measured descriptor-miss rate +- [ ] Phase 4: DirectToSwapchain: DirectToSwapchain present policy measured and kept only if it wins + - left: Entire item unimplemented and unmeasured. +- [ ] Phase 4: transfer backend B: Dedicated-transfer-queue backend (B) measured against backend A + - left: No ITransferBackend abstraction, no backend B implementation, no measurement exists. +- [ ] Phase 4: exit criteria: Phase 4 exit - Vulkan mean FPS >= OpenGL, p99 <= OpenGL, pipeline cache hit rate >= 95%, per-pass ms table within 10% of GPU frame time + - left: Every numeric exit criterion is unmeasured, or where a related number exists (Milestone 1 pacing) it fails the bar; the required 30-minute session and doubled perf-capture.sh runs have not been executed. + +**Phase 5: mod API and fork ports; Phase 6: upscaler and frame-generation seams; Latency seams section (L0 types, S1-S8, b** + +- [~] Phase 5: Mod API and fork ports + - left: No evidence the exit criterion 'VSEssentials/VSSurvivalMod/VSCreativeMod renderers checked against declared passes' was done: grep for OptimumPass/RegisterOptimumPass/MotionWriter across VSEssentials, VSSurvivalMod, VSCreativeMod (working trees) and their patches/ directories returns nothing; none of the 40+ existing fork renderer patches (CloudRendererVolumetric, MechNetworkRenderer, EntityShapeR +- [ ] Vendor orchestrator decision: Optimum builds its own multi-vendor orchestrator (not Streamline) + - left: Merge/port from feat/dlss (or feat/dlss-g) into feat/vulkan-taa, which per the branch-split decision (StratumServer PR #69) is deliberately deferred until after the native Vulkan backend and TAA land upstream. +- [ ] Vendor orchestrator decision: Slot coupling: vendor latency backend only when upscaler vendor matches GPU + - left: Same as the orchestrator item: exists on feat/dlss only, not yet ported/merged to feat/vulkan-taa. +- [ ] Vendor orchestrator decision: NVIDIA goes direct: Reflex via VK_NV_low_latency2, DLSS/DLSS-G via NGX P/Invoke (no Streamline) + - left: Not merged into feat/vulkan-taa; lives on feat/dlss/feat/latency per the documented branch split. +- [ ] Vendor orchestrator decision: Intel on Windows: D3D12 bridge present path with XeFG and XeLL + - left: Entirely unbuilt: needs the D3D12 bridge present path, shared-image/fence interop, and XeLL binding; also gated on 'Windows interop support on the Arc driver is unverified: spike before building on it' per the plan's own text, i.e. even the prerequisite spike has not happened. +- [ ] Latency seams: L0 types, S1-S8 seams, backends None/Native/NvLowLatency2/AmdAntiLag + - left: Zero of this exists on feat/vulkan-taa. It is real work but on a sibling branch not yet merged back; XeLL as a fifth backend is absent even there (see the Intel D3D12 bridge item). +- [ ] Latency seams: Acceptance numbers (section L) + - left: Not present on feat/vulkan-taa; would need porting docs/vulkan-acceptance.md's Latency section (and the underlying code) from feat/latency. +- [ ] NGX on native Linux: Spike result: NGX comes up through a native shim + - left: None of this is on feat/vulkan-taa; it would need to be merged/ported from feat/dlss once that branch returns as its own PR per the documented sequencing. +- [ ] DLSS SR evaluation: DLSS Super Resolution evaluates on the device + - left: Entirely absent from feat/vulkan-taa; the sequencing note in the committed docs/vulkan-native-plan.md on this branch says this returns as its own PR after the native backend, from feat/dlss-g. +- [ ] Phase 6: Upscaler and frame-generation seams + - left: All of it: SceneNoHud/Composited graph handles, IPresentPath-based PresentThread backend selection, FramesInFlight raised to 3, temporal contract bumped to v2 with the vendor surface, and a recorded FSR/XeSS quality-perf table - none present on feat/vulkan-taa; real progress toward some of these (SceneNoHud, two-present dlss-g plumbing) exists on feat/dlss-g only. + +**Roadmap items (HDR output, ray tracing, headless render harness, GTAO/XeGTAO 3 sub-steps), plan's Documentation-to-updat** + +- [ ] Roadmap: HDR output: HDR output + - left: Everything: float/10-bit scene colour format, tone mapper, VK_EXT_swapchain_colorspace/HDR10 or scRGB swapchain, DLSS IsHDR=1 switch, temporal-contract colour-space row (T2/T3, currently only reserved as a heading in docs/temporal-frame-contract.md section 8, with no content). +- [ ] Roadmap: ray tracing: Ray tracing + - left: Everything: BLAS-per-chunk-mesh / TLAS-over-loaded-chunks with per-frame refit, VK_KHR_acceleration_structure/ray_query device tier, RTAO as the first ray budget, then shadows/reflections, then a denoiser (DLSS Ray Reconstruction or a hand-written one). Sequenced last by design, not blocked by anything external yet. +- [~] Roadmap: headless render harness: Headless render harness that does not take the machine + - left: The doc's own admission (docs/vulkan-acceptance.md, headless section, 'What it does not cover'): 'No camera path is checked in yet - one has to be authored per scene with .cam p and .cam save.' The roadmap's acceptance bar - 'the shimmer class of bug (jitter, disocclusion, AO noise) shows up as a number from that sequence' via a checked-in deterministic camera path - has not been demonstrated; the +- [x] GTAO order-of-work step 1: GTAO step 1: composite AO into the scene at render resolution, before the resolve +- [x] GTAO order-of-work step 2: GTAO step 2: make the dither temporally varying +- [~] GTAO order-of-work step 3: GTAO step 3: port XeGTAO and judge it against the fixed SSAO + - left: All of section D's measurement plan: converged numerical reference, thin-foliage/halo numbers, temporal-stability numbers vs vanilla SSAO+TAA, per-pass GPU cost on Arc-class and RTX hardware, and the resulting handheld-preset decision. Until then GTAO has landed as a code path but has not been 'judged' by the plan's own definition. +- [~] Documentation to update: Documentation-to-update list (VULKAN-BACKEND-PLAN.md v2, acceptance/allowlist docs, contract addendum, CLAUDE.md, skills) + - left: Rewrite VULKAN-BACKEND-PLAN.md in place to v2 (or formally mark it superseded/archived and delete stale sections instead of leaving contradictory content live); record the Phase-3 temporal-contract addendum-or-v2 decision somewhere durable; add the shaders-vk source-of-truth row, check-shaders-vk build step and the missing env vars to CLAUDE.md; update the three named skills with the manifest/paci +- [~] Risks (ranked) mitigations: Risks section: are the 10 ranked mitigations actually in place + - left: Fill in docs/vulkan-acceptance.md section 6's vendor matrix with the numbers that already exist elsewhere (risk 6); record the Phase-3 temporal-contract decision (risk 8, shared with the Documentation item above). +- [!] Handoff item 1: Fix the present-after-write hazard + - left: Waiting on: the complete first-message text of one of the five failures, captured on the Windows GTX 1060 (or another Pascal/580-branch device) by running `dotnet test Optimum.Render.Vulkan.Tests --filter --logger "console;verbosity=detailed"` with the implicit Vulkan layers disabled, plus that machine's driver version and `vulkaninfo --summary` (present modes, image counts) rec +- [~] Handoff item 7: Caching follow-ups + - left: Two items explicitly still open, confirmed absent from the code: VK_KHR_pipeline_binary (grep for 'PipelineBinary'/'pipeline_binary' across Optimum.Render.Vulkan: zero hits) and a real-client warm-start check driven through the headless harness (no 'warm-start' or 'WarmStart' hit anywhere outside the two progress-doc lines that call it open). +- [ ] Handoff item 9: General refactor: split VulkanDevice.cs, restructure the project, remove GL-emulation leftovers + - left: Everything: splitting VulkanDevice.cs into smaller units, any project-layout restructuring, and removing GlStateTracker.cs plus its call sites. This is item 9 of 12 on the to-do list and item 5 (Phase 3b native render systems, a prerequisite for retiring GlStateTracker per the handoff's own text) is itself only one stage in (device API + native blit merged; chunks/entities/particles/GUI on native +- [~] Handoff item 11: Validation milestones 2-7 + - left: A real per-area CI split including a scheduled GPU-AV run; AMD/RADV coverage; a lavapipe CI lane; a written, evidenced sign-off against the Khronos checklist; debug object naming and command-buffer labels; Aftermath and a GFXReconstruct reference capture. +- [ ] Handoff item 12: Cleanup for review (last) + - left: The entire item: read VULKAN-BACKEND-PLAN.md fully and reconcile/retire it, review the named scripts and Core/RenderTargetManager.cs for tooling/workflow references, and (with the owner's OK per the branch's binding rule on history rewrites) squash or rewrite the ~23-27 worktree/merge-wave commit subjects before the upstream PR, plus the optional host-environment test fixes (numpy self-tests, Wind + +**Added 2026-09-16 at the owner's request: the DLSS / DLSS-FG foundations, brought forward** + +Foundations only - the vendor backends, NGX, two-presents-per-frame and the present-thread pacer stay out. Scoped +against `feat/latency` and `feat/dlss-g`; note it is `feat/dlss-g`, not `feat/dlss`, that carries the HUD work. + +- [ ] Foundation A, frame marking: L0 latency types, the pre-input `LatencySleep` lib seam, + `IDeviceRequirementContributor` and the pNext chain builder in `CreateDevice`, one frame id per frame, + `VkPresentIdKHR` chaining, markers around simulation / render submit / present, the `stats.latency` line. + Conflicts: `VulkanClientPlatform.Frame.cs` exists here already (add, do not replace); `FrameSlot.Submit` gains a + parameter and its three call sites move together, `UploadManager.SubmitStandalone` stays untagged. +- [ ] Foundation B, GUI separation: the `SceneNoHud` snapshot (slot 23) at the end of `RenderFinalComposition`, and + the UI target (slot 24) with a `ui-compose` pass composed back before the `Done` stage, so an upscaler or frame + generator evaluates a HUD-less image. Conflicts: the `feat/dlss-g` hooks sit in the lib GL body of + `BlitPrimaryToDefault`, which is overridden here and dispatches to `RenderNativeBlit()` - ported verbatim they are + dead code on Vulkan, so they must be re-implemented at that method's three exit points, with the lib patch kept for + the OpenGL path only. `ui-compose` registers through the native manifest / `LinkProgram` seam and needs + premultiplied-alpha blending. The UI compose GPU test must be new: the original only proved the GL-emulation path. +- Staging: L0 contracts (serial), then lib hook + device/markers + SceneNoHud + UI target in parallel worktrees, then + integrate, then in-game on both backends. Review only the `RenderNativeBlit()` re-implementation. + + ### Done | Commit | What | From 7a106eb1ee72e0584e07e8b4fade1182bac1e58b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 01:58:22 +0200 Subject: [PATCH 185/226] docs: scope the plan audit to this branch - DLSS, upscalers and frame generation are not owed here The plan predates the PR #69 split, so it still carries the whole programme: Phase 6, the latency seams and their vendor backends, the NGX spike, DLSS SR, the vendor orchestrator decisions, the Intel D3D12 bridge, HDR and ray tracing. Marking those as LEFT read as work outstanding on this branch. They are now marked [out] and named to the branch that owns them (feat/dlss, feat/dlss-g, feat/latency), per this document's own context section: this branch carries Vulkan and TAA only. In scope for this branch: 44 done, 15 partial, 17 left, 1 blocked, 2 superseded. Out of scope: 11 items. The two owner-requested foundations (frame marking, GUI separation) are listed separately with the scope decision called out as open: they are foundations, not vendor code, but landing them grows PR #69. --- docs/vulkan-branch-progress.md | 268 ++++++++++++++++----------------- 1 file changed, 130 insertions(+), 138 deletions(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 15e0dd23..b26545a6 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -115,181 +115,173 @@ compared before and after for every branch. ## 5. Status and to-do -### Plan status, audited 2026-09-16 - -Every item of `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md` checked against this tree by -five read-only agents, each required to cite a file, test or commit for anything marked done; a claim in the plan or -in this handoff was not accepted as evidence. The same marking, with per-item evidence, is at the top of the plan file. - -**44 done, 15 partial, 28 left, 1 blocked, 2 superseded. The plan is not complete.** - -Legend: `[x]` done, `[~]` partially done (what is left follows it), `[ ]` not started, `[!]` blocked externally, -`[-]` superseded by a later decision. - -**Step 0: branching; Phase 0: foundations; Phase 1A: platform substitution; Phase 1B: synchronisation foundation; constrai** - -- [x] Step 0: Branching: fix/taa-sky-direction and feat/vulkan-native from origin/main -- [x] Phase 0: Foundations: patcher capabilities, diagnostics, validation default, parity dump, acceptance doc skeleton -- [x] Phase 0 exit criteria: Phase 0 exit: builds/suites green, both dump paths run, GL-vs-GL noise floor, VK-vs-GL table, pacing baselines recorded -- [x] Phase 1A step 1: VulkanClientPlatform forwarding subclass; SetupOptimumFrameBuffers moved; ClientProgram.Start wiring; csproj donor reference -- [x] Phase 1A step 2: TAA members to the abstract class; 7 casts become virtual calls; 14 Optimum.Tests files re-pointed -- [x] Phase 1A step 3: Program/uniform/UBO virtuals; ShaderProgramBase.cs and UBO.cs revert to vanilla plus virtual calls; per-draw CPU measured -- [x] Phase 1A step 4: Remaining leaf sites moved; IOptimumGraphicsDevice/OptimumRender.Device/OptimumRenderBootstrap.Install deleted; ClientPlatformWindows branch-free -- [x] Phase 1A Tests: Phase 1A test list: GL.-grep source test, no-lambda test, fallback re-assigns ScreenManager.Platform, no device/cast remnants, PlatformSubstitutionTests, identical-pixel GPU tests -- [x] Phase 1A Exit: Phase 1A exit: identical screenshots per backend; forced-install-failure fallback exercised with the exact log line -- [x] Phase 1B step 1: FrameTimeline + RetireQueue; FrameRing on timelines; blocking waits = 1/frame -- [x] Phase 1B step 2: UploadManager + per-slot upload command buffer (backend A); ReadbackManager + SubmitPartial + QueryRing; SubmitAndWait/FlushFrame deleted -- [x] Phase 1B step 3: Readback in a frame: ReadbackManager.CopyToHost + SubmitPartial; only screenshot path waits -- [x] Phase 1B step 4: Swapchain/SwapchainRetirement/IPresentPath split submission; resize/alt-tab/minimise clean under sync,best; acquire wait stage never ALL_COMMANDS -- [x] Phase 1B step 5: VulkanAllocator pool classes + budget; static meshes off ReBAR; allocator policy tests; heap report -- [x] Phase 1B step 6: Per-slot indirect ring; descriptor arena; dirty-masked dynamic state; free GetError; CPU frame time drop measured, draw counters unchanged -- [~] Phase 1B Tests: GPU test list: AsyncTransferTests, PresentDecouplingTests, SwapchainRecreationVisualTests, ConcurrentDeviceAccessTests, ReadbackMidFrameTests, QueryRingTests, AllocatorPolicyTests; unit: IndirectRingWrapTests, TimelineLifetimeTests, PresentWaitStageTests, SwapchainRetirementTests -- [x] Phase 1B/1A Exit (combined, Phase 1 exit): Phase 1 exit: both renderers start; forced-install-failure fallback; sync,best 0 errors; blocking uploads 0; build/test counts recorded -- [x] Constraint: Cecil transplant rules: No cached lambdas / LINQ predicates / non-capturing lambdas / hidden-helper lowering in transplanted bodies -- [x] Constraint: Patcher capabilities (typesToUnseal/methodsToVirtualize/verifier): typesToUnseal clears TypeAttributes.Sealed; methodsToVirtualize sets Virtual|NewSlot|HideBySig; call-vs-callvirt verifier fails the patch on a stray call -- [x] Constraint: Hardware floor: Vulkan 1.3 + dynamicRendering, synchronization2, timelineSemaphore, scalarBlockLayout, independentBlend, multiDrawIndirect; optional tiers with fallback + env override - -**Phase 2: frame graph -> Milestone 1; Phase 3: native shaders** - -- [x] Phase 2 Step 1: ResourceStateTracker + BarrierBatcher drive the immediate path -- [x] Phase 2 Step 2: FrameGraph streaming recorder + PassRecorder, coexisting with the non-graph path -- [x] Phase 2 Step 3: Write-mask motion tiers, clear promotion, FramePlan load/store solving; TAA through the graph -- [~] Phase 2 Step 4: Transient aliasing implemented, default off, but not wired into the live per-frame graph path +### Plan status, audited 2026-09-16 (scoped to this branch) + +Every item of `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md` checked against this tree. +The plan predates the PR #69 split, so it also contains DLSS, upscaler, frame-generation, HDR and ray-tracing work: +those are marked `[out]` and are NOT owed on this branch. + +**In scope for this branch: 44 done, 15 partial, 17 left, 1 blocked, 2 superseded.** +**Out of scope for this branch: 11 items** - PR #69 carries the Vulkan backend and TAA only; DLSS, upscalers, +frame generation, the vendor latency backends, NGX, HDR and ray tracing live on `feat/dlss`, `feat/dlss-g` and +`feat/latency` and are NOT work owed here. They appear in the plan because the plan predates that split. + +Audit method: five read-only agents, each required to cite a file, test or commit for anything marked done; a claim +in the plan or in the handoff was not accepted as evidence. + +Legend: `[x]` done, `[~]` partly done (what is left follows it), `[ ]` not started, `[!]` blocked externally, +`[-]` superseded by a later decision, `[out]` out of scope for this branch. + +**Step 0: branching; Phase 0: foundations; Phase 1A: platform substitution; Phase 1B: synchronisation foundation; constrai + +- [x] DONE — **Step 0**: Branching: fix/taa-sky-direction and feat/vulkan-native from origin/main +- [x] DONE — **Phase 0**: Foundations: patcher capabilities, diagnostics, validation default, parity dump, acceptance doc skeleton +- [x] DONE — **Phase 0 exit criteria**: Phase 0 exit: builds/suites green, both dump paths run, GL-vs-GL noise floor, VK-vs-GL table, pacing baselines recorded +- [x] DONE — **Phase 1A step 1**: VulkanClientPlatform forwarding subclass; SetupOptimumFrameBuffers moved; ClientProgram.Start wiring; csproj donor reference +- [x] DONE — **Phase 1A step 2**: TAA members to the abstract class; 7 casts become virtual calls; 14 Optimum.Tests files re-pointed +- [x] DONE — **Phase 1A step 3**: Program/uniform/UBO virtuals; ShaderProgramBase.cs and UBO.cs revert to vanilla plus virtual calls; per-draw CPU measured +- [x] DONE — **Phase 1A step 4**: Remaining leaf sites moved; IOptimumGraphicsDevice/OptimumRender.Device/OptimumRenderBootstrap.Install deleted; ClientPlatformWindows branch-free +- [x] DONE — **Phase 1A Tests**: Phase 1A test list: GL.-grep source test, no-lambda test, fallback re-assigns ScreenManager.Platform, no device/cast remnants, PlatformSubstitutionTests, identical-pixel GPU tests +- [x] DONE — **Phase 1A Exit**: Phase 1A exit: identical screenshots per backend; forced-install-failure fallback exercised with the exact log line +- [x] DONE — **Phase 1B step 1**: FrameTimeline + RetireQueue; FrameRing on timelines; blocking waits = 1/frame +- [x] DONE — **Phase 1B step 2**: UploadManager + per-slot upload command buffer (backend A); ReadbackManager + SubmitPartial + QueryRing; SubmitAndWait/FlushFrame deleted +- [x] DONE — **Phase 1B step 3**: Readback in a frame: ReadbackManager.CopyToHost + SubmitPartial; only screenshot path waits +- [x] DONE — **Phase 1B step 4**: Swapchain/SwapchainRetirement/IPresentPath split submission; resize/alt-tab/minimise clean under sync,best; acquire wait stage never ALL_COMMANDS +- [x] DONE — **Phase 1B step 5**: VulkanAllocator pool classes + budget; static meshes off ReBAR; allocator policy tests; heap report +- [x] DONE — **Phase 1B step 6**: Per-slot indirect ring; descriptor arena; dirty-masked dynamic state; free GetError; CPU frame time drop measured, draw counters unchanged +- [~] PARTIAL — **Phase 1B Tests**: GPU test list: AsyncTransferTests, PresentDecouplingTests, SwapchainRecreationVisualTests, ConcurrentDeviceAccessTests, ReadbackMidFrameTests, QueryRingTests, AllocatorPolicyTests; unit: IndirectRingWrapTests, TimelineLifetimeTests, PresentWaitStageTests, SwapchainRetirementTests +- [x] DONE — **Phase 1B/1A Exit (combined, Phase 1 exit)**: Phase 1 exit: both renderers start; forced-install-failure fallback; sync,best 0 errors; blocking uploads 0; build/test counts recorded +- [x] DONE — **Constraint: Cecil transplant rules**: No cached lambdas / LINQ predicates / non-capturing lambdas / hidden-helper lowering in transplanted bodies +- [x] DONE — **Constraint: Patcher capabilities (typesToUnseal/methodsToVirtualize/verifier)**: typesToUnseal clears TypeAttributes.Sealed; methodsToVirtualize sets Virtual|NewSlot|HideBySig; call-vs-callvirt verifier fails the patch on a stray call +- [x] DONE — **Constraint: Hardware floor**: Vulkan 1.3 + dynamicRendering, synchronization2, timelineSemaphore, scalarBlockLayout, independentBlend, multiDrawIndirect; optional tiers with fallback + env override + +**Phase 2: frame graph -> Milestone 1; Phase 3: native shaders + +- [x] DONE — **Phase 2 Step 1**: ResourceStateTracker + BarrierBatcher drive the immediate path +- [x] DONE — **Phase 2 Step 2**: FrameGraph streaming recorder + PassRecorder, coexisting with the non-graph path +- [x] DONE — **Phase 2 Step 3**: Write-mask motion tiers, clear promotion, FramePlan load/store solving; TAA through the graph +- [~] PARTIAL — **Phase 2 Step 4**: Transient aliasing implemented, default off, but not wired into the live per-frame graph path - left: Wire FrameGraph/PassRecorder to call BindTransientForFrame per declared transient lifetime so aliasing can actually take effect outside tests. -- [x] Phase 2 invariants pinned by tests: Invariants pinned by tests -- [ ] Milestone 1 bullet: pacing-gate.sh passes: M1 bullet 1 - pacing-gate.sh passes against the OpenGL baseline -- [x] Milestone 1 bullet: blocking uploads/waits: M1 bullet 2 - blocking uploads 0, blocking waits 1/frame -- [x] Milestone 1 bullet: acquire ordering: M1 bullet 3 - acquire after render submit, wait stage TRANSFER/COLOR_ATTACHMENT_OUTPUT -- [x] Milestone 1 bullet: scopes and passes: M1 bullet 4 - ScopesOpened==PassCount; no transition inside a scope -- [x] Milestone 1 bullet: validation scripted session: M1 bullet 5 - sync,best validation, zero [error] over the scripted session -- [ ] Milestone 1 bullet: SSIM parity TAA off: M1 bullet 6 - per-attachment SSIM vs OpenGL, TAA off -- [x] Milestone 1 bullet: TAA still-frame stability: M1 bullet 7 - TAA on: luma-diff median within 0.3 of OpenGL, distant-leaf rejection <=1.5% -- [ ] Milestone 1 bullet: TAA acceptance rows re-pass: M1 bullet 8 - TAA acceptance rows A11,A13,A14,A15,A17,A18 re-pass -- [x] Milestone 1 bullet: in-game judgement: M1 bullet 9 - user judges it in game on both backends -- [-] Phase 3 set convention: Set convention: superseded by decision 9, implemented as a single shared layout -- [x] Phase 3 placement table: Uniform placement table -- [x] Phase 3 manifest: shaders.manifest.json schema and consistency -- [x] Phase 3 compiler tool: Offline shader compiler tool (--build/--verify/--single) -- [x] Phase 3 adapter layout: Mod-shader adapter retargeted to the shared layout in one change -- [x] Phase 3 seven worktree stages of native GLSL: Native GLSL ported in family stages -- [x] Phase 3 scanner v2: Launcher scanner v2 (ShaderAssetOverride, PlatformInternals, schema 2) -- [ ] Phase 3 temporal contract addendum-or-v2 decision: Temporal contract addendum-or-v2 decision for native shaders +- [x] DONE — **Phase 2 invariants pinned by tests**: Invariants pinned by tests +- [ ] LEFT — **Milestone 1 bullet: pacing-gate.sh passes**: M1 bullet 1 - pacing-gate.sh passes against the OpenGL baseline +- [x] DONE — **Milestone 1 bullet: blocking uploads/waits**: M1 bullet 2 - blocking uploads 0, blocking waits 1/frame +- [x] DONE — **Milestone 1 bullet: acquire ordering**: M1 bullet 3 - acquire after render submit, wait stage TRANSFER/COLOR_ATTACHMENT_OUTPUT +- [x] DONE — **Milestone 1 bullet: scopes and passes**: M1 bullet 4 - ScopesOpened==PassCount; no transition inside a scope +- [x] DONE — **Milestone 1 bullet: validation scripted session**: M1 bullet 5 - sync,best validation, zero [error] over the scripted session +- [ ] LEFT — **Milestone 1 bullet: SSIM parity TAA off**: M1 bullet 6 - per-attachment SSIM vs OpenGL, TAA off +- [x] DONE — **Milestone 1 bullet: TAA still-frame stability**: M1 bullet 7 - TAA on: luma-diff median within 0.3 of OpenGL, distant-leaf rejection <=1.5% +- [ ] LEFT — **Milestone 1 bullet: TAA acceptance rows re-pass**: M1 bullet 8 - TAA acceptance rows A11,A13,A14,A15,A17,A18 re-pass +- [x] DONE — **Milestone 1 bullet: in-game judgement**: M1 bullet 9 - user judges it in game on both backends +- [-] SUPERSEDED — **Phase 3 set convention**: Set convention: superseded by decision 9, implemented as a single shared layout +- [x] DONE — **Phase 3 placement table**: Uniform placement table +- [x] DONE — **Phase 3 manifest**: shaders.manifest.json schema and consistency +- [x] DONE — **Phase 3 compiler tool**: Offline shader compiler tool (--build/--verify/--single) +- [x] DONE — **Phase 3 adapter layout**: Mod-shader adapter retargeted to the shared layout in one change +- [x] DONE — **Phase 3 seven worktree stages of native GLSL**: Native GLSL ported in family stages +- [x] DONE — **Phase 3 scanner v2**: Launcher scanner v2 (ShaderAssetOverride, PlatformInternals, schema 2) +- [ ] LEFT — **Phase 3 temporal contract addendum-or-v2 decision**: Temporal contract addendum-or-v2 decision for native shaders - left: Add a dated entry to docs/temporal-frame-contract.md (or a version bump) stating whether the native-shader motion-writer port is a v1 addendum or a v2 change. -- [ ] Phase 3 ReloadShaders no longer recompiling on a settings change: ReloadShaders no longer recompiling on a settings change -- [~] Phase 3 Tests list: Phase 3 Tests list (parity, motion-writer shape, manifest, adapter-layout, differential, launcher fixtures, no legacy extensions) +- [ ] LEFT — **Phase 3 ReloadShaders no longer recompiling on a settings change**: ReloadShaders no longer recompiling on a settings change +- [~] PARTIAL — **Phase 3 Tests list**: Phase 3 Tests list (parity, motion-writer shape, manifest, adapter-layout, differential, launcher fixtures, no legacy extensions) - left: The plan specifically asks that 'the eight Taa*Motion*Tests gain native-vs-rewriter differential cases (motion attachment equal within 1 ULP of RGBA16F)'. Searched TaaMotionWriterTests.cs, TaaEntityMotionWriterTests.cs, TaaInstancedMotionWriterTests.cs, TaaStandardMotionWriterTests.cs, TaaLiquidMotionTests.cs, TaaSkyMotionTests.cs and found no native-vs-rewriter comparison in any of them - all sti -- [ ] Phase 3 Exit criteria: Phase 3 exit criteria (48 native/0 failed logged, SSIM>=0.99, validation clean, in-game settings sweep, vulkan-acceptance.md matrix, contract decision) +- [ ] LEFT — **Phase 3 Exit criteria**: Phase 3 exit criteria (48 native/0 failed logged, SSIM>=0.99, validation clean, in-game settings sweep, vulkan-acceptance.md matrix, contract decision) - left: Run and record the actual Phase 3 exit in docs/vulkan-acceptance.md: the native/rewritten/failed count from a real (non-headless-forced) launch, per-attachment SSIM, validation log, the full settings sweep on both backends, and the temporal-contract addendum-or-v2 decision. -**Phase 3b (docs/vulkan-native-render-systems.md decisions 1-7, stage 1 nine-pass scope, parallel world-system stages, rem** +**Phase 3b (docs/vulkan-native-render-systems.md decisions 1-7, stage 1 nine-pass scope, parallel world-system stages, rem -- [x] Phase 3b decision 1: Runtime rewriter stays permanently as mod-shader adapter -- [~] Phase 3b decision 2: Seams are existing virtuals, overridden without calling base +- [x] DONE — **Phase 3b decision 1**: Runtime rewriter stays permanently as mod-shader adapter +- [~] PARTIAL — **Phase 3b decision 2**: Seams are existing virtuals, overridden without calling base - left: 6 of 7 post/TAA virtuals still call base; no world-system transplanted seams exist (ChunkRenderer, entities, particles, GUI unchanged). -- [~] Phase 3b decision 3: A native system reads client state, never GL state +- [~] PARTIAL — **Phase 3b decision 3**: A native system reads client state, never GL state - left: Rule only exercised by the blit; unverified for any world-render system since none has been ported. -- [x] Phase 3b decision 4: Device API for native systems (NativePasses) -- [~] Phase 3b decision 5: order and parallelism: Stage 1 (device API + post/TAA chain) then parallel world systems then removal +- [x] DONE — **Phase 3b decision 4**: Device API for native systems (NativePasses) +- [~] PARTIAL — **Phase 3b decision 5: order and parallelism**: Stage 1 (device API + post/TAA chain) then parallel world systems then removal - left: Stage 1 itself incomplete (8 of 9 chain passes still on base); stage 2 (chunks, entities, particles/decals/sky/clouds, GUI/text) not started; stage 3 removal not started. -- [~] Phase 3b decision 6: Behavioural identity is the acceptance rule (old-route vs native-route GPU tests) +- [~] PARTIAL — **Phase 3b decision 6**: Behavioural identity is the acceptance rule (old-route vs native-route GPU tests) - left: Differential tests needed for the remaining 8 passes once each goes native; none exist because none is native. -- [x] Phase 3b decision 7: FSR input identity preserved (BlitPrimaryToDefault keeps reading Primary colour 0) -- [~] Phase 3b stage 1 scope: 9 chain passes: Which of the nine post/TAA chain passes are native today +- [x] DONE — **Phase 3b decision 7**: FSR input identity preserved (BlitPrimaryToDefault keeps reading Primary colour 0) +- [~] PARTIAL — **Phase 3b stage 1 scope: 9 chain passes**: Which of the nine post/TAA chain passes are native today - left: 8 of 9 passes (everything except the final blit) still run the OpenGL body via base.() and therefore still go through GlStateTracker, texture units and uniform-by-location. -- [ ] Phase 3b: world render systems still on the emulation layer: Every world render system still on the GL-emulation layer +- [ ] LEFT — **Phase 3b: world render systems still on the emulation layer**: Every world render system still on the GL-emulation layer - left: All world render systems (chunks, entities, particles, decals, sky/clouds, GUI/text) - stage 2 of decision 5 - are entirely unstarted. -- [ ] Phase 3b: GlStateTracker / texture-unit tables / uniform-by-location reachability: GlStateTracker, texture-unit tables and uniform-by-location still reachable from the Vulkan path +- [ ] LEFT — **Phase 3b: GlStateTracker / texture-unit tables / uniform-by-location reachability**: GlStateTracker, texture-unit tables and uniform-by-location still reachable from the Vulkan path - left: Not reachable only from the native blit's own pipeline creation; reachable and load-bearing for every other pass and every world system. -- [x] Phase 4: disk pipeline cache: Disk pipeline cache with FAIL_ON_PIPELINE_COMPILE_REQUIRED, background compile worker -- [x] Phase 4: used-key manifest: Used-pipeline-key manifest for pre-warming -- [x] Phase 4: warm-up: Background warm-up from the manifest -- [ ] Phase 4: push-constant placement from a measured profile: Push-constant placement frozen from OPTIMUM_VULKAN_UNIFORM_PROFILE measurement +- [x] DONE — **Phase 4: disk pipeline cache**: Disk pipeline cache with FAIL_ON_PIPELINE_COMPILE_REQUIRED, background compile worker +- [x] DONE — **Phase 4: used-key manifest**: Used-pipeline-key manifest for pre-warming +- [x] DONE — **Phase 4: warm-up**: Background warm-up from the manifest +- [ ] LEFT — **Phase 4: push-constant placement from a measured profile**: Push-constant placement frozen from OPTIMUM_VULKAN_UNIFORM_PROFILE measurement - left: Entire item: the env-driven measurement tool, the manifest field, and the placement logic reading it are all absent. -- [x] Phase 4: animation SSBO ring: Bone/animation data on a storage-buffer ring with dynamic offsets -- [ ] Phase 4: Use() include-block early-out: Skipping ShaderProgramBase.Use()'s frame-global include-block writes when unchanged +- [x] DONE — **Phase 4: animation SSBO ring**: Bone/animation data on a storage-buffer ring with dynamic offsets +- [ ] LEFT — **Phase 4: Use() include-block early-out**: Skipping ShaderProgramBase.Use()'s frame-global include-block writes when unchanged - left: Entire item unimplemented; Use() still writes all ~50 frame-global uniforms unconditionally every call. -- [ ] Phase 4: per-pass GPU timestamps: Per-pass GPU time table from timestamp queries +- [ ] LEFT — **Phase 4: per-pass GPU timestamps**: Per-pass GPU time table from timestamp queries - left: No timestamp-query infrastructure exists; no per-pass ms table has been produced. -- [ ] Phase 4: transient aliasing default on: Transient aliasing switched on by default after clean validation on all targets +- [ ] LEFT — **Phase 4: transient aliasing default on**: Transient aliasing switched on by default after clean validation on all targets - left: Default flag flip to on, plus the required clean-validation-on-all-targets gate, have not happened. -- [-] Phase 4: bindless decision: Bindless set (originally set 2, later set 1) adopted based on measured descriptor-miss rate -- [ ] Phase 4: DirectToSwapchain: DirectToSwapchain present policy measured and kept only if it wins +- [-] SUPERSEDED — **Phase 4: bindless decision**: Bindless set (originally set 2, later set 1) adopted based on measured descriptor-miss rate +- [ ] LEFT — **Phase 4: DirectToSwapchain**: DirectToSwapchain present policy measured and kept only if it wins - left: Entire item unimplemented and unmeasured. -- [ ] Phase 4: transfer backend B: Dedicated-transfer-queue backend (B) measured against backend A +- [ ] LEFT — **Phase 4: transfer backend B**: Dedicated-transfer-queue backend (B) measured against backend A - left: No ITransferBackend abstraction, no backend B implementation, no measurement exists. -- [ ] Phase 4: exit criteria: Phase 4 exit - Vulkan mean FPS >= OpenGL, p99 <= OpenGL, pipeline cache hit rate >= 95%, per-pass ms table within 10% of GPU frame time +- [ ] LEFT — **Phase 4: exit criteria**: Phase 4 exit - Vulkan mean FPS >= OpenGL, p99 <= OpenGL, pipeline cache hit rate >= 95%, per-pass ms table within 10% of GPU frame time - left: Every numeric exit criterion is unmeasured, or where a related number exists (Milestone 1 pacing) it fails the bar; the required 30-minute session and doubled perf-capture.sh runs have not been executed. -**Phase 5: mod API and fork ports; Phase 6: upscaler and frame-generation seams; Latency seams section (L0 types, S1-S8, b** +**Phase 5: mod API and fork ports; Phase 6: upscaler and frame-generation seams; Latency seams section (L0 types, S1-S8, b -- [~] Phase 5: Mod API and fork ports +- [~] PARTIAL — **Phase 5**: Mod API and fork ports - left: No evidence the exit criterion 'VSEssentials/VSSurvivalMod/VSCreativeMod renderers checked against declared passes' was done: grep for OptimumPass/RegisterOptimumPass/MotionWriter across VSEssentials, VSSurvivalMod, VSCreativeMod (working trees) and their patches/ directories returns nothing; none of the 40+ existing fork renderer patches (CloudRendererVolumetric, MechNetworkRenderer, EntityShapeR -- [ ] Vendor orchestrator decision: Optimum builds its own multi-vendor orchestrator (not Streamline) - - left: Merge/port from feat/dlss (or feat/dlss-g) into feat/vulkan-taa, which per the branch-split decision (StratumServer PR #69) is deliberately deferred until after the native Vulkan backend and TAA land upstream. -- [ ] Vendor orchestrator decision: Slot coupling: vendor latency backend only when upscaler vendor matches GPU - - left: Same as the orchestrator item: exists on feat/dlss only, not yet ported/merged to feat/vulkan-taa. -- [ ] Vendor orchestrator decision: NVIDIA goes direct: Reflex via VK_NV_low_latency2, DLSS/DLSS-G via NGX P/Invoke (no Streamline) - - left: Not merged into feat/vulkan-taa; lives on feat/dlss/feat/latency per the documented branch split. -- [ ] Vendor orchestrator decision: Intel on Windows: D3D12 bridge present path with XeFG and XeLL - - left: Entirely unbuilt: needs the D3D12 bridge present path, shared-image/fence interop, and XeLL binding; also gated on 'Windows interop support on the Arc driver is unverified: spike before building on it' per the plan's own text, i.e. even the prerequisite spike has not happened. -- [ ] Latency seams: L0 types, S1-S8 seams, backends None/Native/NvLowLatency2/AmdAntiLag - - left: Zero of this exists on feat/vulkan-taa. It is real work but on a sibling branch not yet merged back; XeLL as a fifth backend is absent even there (see the Intel D3D12 bridge item). -- [ ] Latency seams: Acceptance numbers (section L) - - left: Not present on feat/vulkan-taa; would need porting docs/vulkan-acceptance.md's Latency section (and the underlying code) from feat/latency. -- [ ] NGX on native Linux: Spike result: NGX comes up through a native shim - - left: None of this is on feat/vulkan-taa; it would need to be merged/ported from feat/dlss once that branch returns as its own PR per the documented sequencing. -- [ ] DLSS SR evaluation: DLSS Super Resolution evaluates on the device - - left: Entirely absent from feat/vulkan-taa; the sequencing note in the committed docs/vulkan-native-plan.md on this branch says this returns as its own PR after the native backend, from feat/dlss-g. -- [ ] Phase 6: Upscaler and frame-generation seams - - left: All of it: SceneNoHud/Composited graph handles, IPresentPath-based PresentThread backend selection, FramesInFlight raised to 3, temporal contract bumped to v2 with the vendor surface, and a recorded FSR/XeSS quality-perf table - none present on feat/vulkan-taa; real progress toward some of these (SceneNoHud, two-present dlss-g plumbing) exists on feat/dlss-g only. - -**Roadmap items (HDR output, ray tracing, headless render harness, GTAO/XeGTAO 3 sub-steps), plan's Documentation-to-updat** - -- [ ] Roadmap: HDR output: HDR output - - left: Everything: float/10-bit scene colour format, tone mapper, VK_EXT_swapchain_colorspace/HDR10 or scRGB swapchain, DLSS IsHDR=1 switch, temporal-contract colour-space row (T2/T3, currently only reserved as a heading in docs/temporal-frame-contract.md section 8, with no content). -- [ ] Roadmap: ray tracing: Ray tracing - - left: Everything: BLAS-per-chunk-mesh / TLAS-over-loaded-chunks with per-frame refit, VK_KHR_acceleration_structure/ray_query device tier, RTAO as the first ray budget, then shadows/reflections, then a denoiser (DLSS Ray Reconstruction or a hand-written one). Sequenced last by design, not blocked by anything external yet. -- [~] Roadmap: headless render harness: Headless render harness that does not take the machine +- [out] Vendor orchestrator decision: Optimum builds its own multi-vendor orchestrator (not Streamline) — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Vendor orchestrator decision: Slot coupling: vendor latency backend only when upscaler vendor matches GPU — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Vendor orchestrator decision: NVIDIA goes direct: Reflex via VK_NV_low_latency2, DLSS/DLSS-G via NGX P/Invoke (no Streamline) — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Vendor orchestrator decision: Intel on Windows: D3D12 bridge present path with XeFG and XeLL — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Latency seams: L0 types, S1-S8 seams, backends None/Native/NvLowLatency2/AmdAntiLag — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Latency seams: Acceptance numbers (section L) — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] NGX on native Linux: Spike result: NGX comes up through a native shim — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] DLSS SR evaluation: DLSS Super Resolution evaluates on the device — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Phase 6: Upscaler and frame-generation seams — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency + +**Roadmap items (HDR output, ray tracing, headless render harness, GTAO/XeGTAO 3 sub-steps), plan's Documentation-to-updat + +- [~] PARTIAL — **Roadmap: headless render harness**: Headless render harness that does not take the machine - left: The doc's own admission (docs/vulkan-acceptance.md, headless section, 'What it does not cover'): 'No camera path is checked in yet - one has to be authored per scene with .cam p and .cam save.' The roadmap's acceptance bar - 'the shimmer class of bug (jitter, disocclusion, AO noise) shows up as a number from that sequence' via a checked-in deterministic camera path - has not been demonstrated; the -- [x] GTAO order-of-work step 1: GTAO step 1: composite AO into the scene at render resolution, before the resolve -- [x] GTAO order-of-work step 2: GTAO step 2: make the dither temporally varying -- [~] GTAO order-of-work step 3: GTAO step 3: port XeGTAO and judge it against the fixed SSAO +- [x] DONE — **GTAO order-of-work step 1**: GTAO step 1: composite AO into the scene at render resolution, before the resolve +- [x] DONE — **GTAO order-of-work step 2**: GTAO step 2: make the dither temporally varying +- [~] PARTIAL — **GTAO order-of-work step 3**: GTAO step 3: port XeGTAO and judge it against the fixed SSAO - left: All of section D's measurement plan: converged numerical reference, thin-foliage/halo numbers, temporal-stability numbers vs vanilla SSAO+TAA, per-pass GPU cost on Arc-class and RTX hardware, and the resulting handheld-preset decision. Until then GTAO has landed as a code path but has not been 'judged' by the plan's own definition. -- [~] Documentation to update: Documentation-to-update list (VULKAN-BACKEND-PLAN.md v2, acceptance/allowlist docs, contract addendum, CLAUDE.md, skills) +- [~] PARTIAL — **Documentation to update**: Documentation-to-update list (VULKAN-BACKEND-PLAN.md v2, acceptance/allowlist docs, contract addendum, CLAUDE.md, skills) - left: Rewrite VULKAN-BACKEND-PLAN.md in place to v2 (or formally mark it superseded/archived and delete stale sections instead of leaving contradictory content live); record the Phase-3 temporal-contract addendum-or-v2 decision somewhere durable; add the shaders-vk source-of-truth row, check-shaders-vk build step and the missing env vars to CLAUDE.md; update the three named skills with the manifest/paci -- [~] Risks (ranked) mitigations: Risks section: are the 10 ranked mitigations actually in place +- [~] PARTIAL — **Risks (ranked) mitigations**: Risks section: are the 10 ranked mitigations actually in place - left: Fill in docs/vulkan-acceptance.md section 6's vendor matrix with the numbers that already exist elsewhere (risk 6); record the Phase-3 temporal-contract decision (risk 8, shared with the Documentation item above). -- [!] Handoff item 1: Fix the present-after-write hazard +- [!] BLOCKED — **Handoff item 1**: Fix the present-after-write hazard - left: Waiting on: the complete first-message text of one of the five failures, captured on the Windows GTX 1060 (or another Pascal/580-branch device) by running `dotnet test Optimum.Render.Vulkan.Tests --filter --logger "console;verbosity=detailed"` with the implicit Vulkan layers disabled, plus that machine's driver version and `vulkaninfo --summary` (present modes, image counts) rec -- [~] Handoff item 7: Caching follow-ups +- [~] PARTIAL — **Handoff item 7**: Caching follow-ups - left: Two items explicitly still open, confirmed absent from the code: VK_KHR_pipeline_binary (grep for 'PipelineBinary'/'pipeline_binary' across Optimum.Render.Vulkan: zero hits) and a real-client warm-start check driven through the headless harness (no 'warm-start' or 'WarmStart' hit anywhere outside the two progress-doc lines that call it open). -- [ ] Handoff item 9: General refactor: split VulkanDevice.cs, restructure the project, remove GL-emulation leftovers +- [ ] LEFT — **Handoff item 9**: General refactor: split VulkanDevice.cs, restructure the project, remove GL-emulation leftovers - left: Everything: splitting VulkanDevice.cs into smaller units, any project-layout restructuring, and removing GlStateTracker.cs plus its call sites. This is item 9 of 12 on the to-do list and item 5 (Phase 3b native render systems, a prerequisite for retiring GlStateTracker per the handoff's own text) is itself only one stage in (device API + native blit merged; chunks/entities/particles/GUI on native -- [~] Handoff item 11: Validation milestones 2-7 +- [~] PARTIAL — **Handoff item 11**: Validation milestones 2-7 - left: A real per-area CI split including a scheduled GPU-AV run; AMD/RADV coverage; a lavapipe CI lane; a written, evidenced sign-off against the Khronos checklist; debug object naming and command-buffer labels; Aftermath and a GFXReconstruct reference capture. -- [ ] Handoff item 12: Cleanup for review (last) +- [ ] LEFT — **Handoff item 12**: Cleanup for review (last) - left: The entire item: read VULKAN-BACKEND-PLAN.md fully and reconcile/retire it, review the named scripts and Core/RenderTargetManager.cs for tooling/workflow references, and (with the owner's OK per the branch's binding rule on history rewrites) squash or rewrite the ~23-27 worktree/merge-wave commit subjects before the upstream PR, plus the optional host-environment test fixes (numpy self-tests, Wind +- [out] Roadmap: HDR output: HDR output — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency +- [out] Roadmap: ray tracing: Ray tracing — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -**Added 2026-09-16 at the owner's request: the DLSS / DLSS-FG foundations, brought forward** +#### Owner-requested, 2026-09-16: the two foundations - SCOPE DECISION OPEN -Foundations only - the vendor backends, NGX, two-presents-per-frame and the present-thread pacer stay out. Scoped -against `feat/latency` and `feat/dlss-g`; note it is `feat/dlss-g`, not `feat/dlss`, that carries the HUD work. +The owner asked for frame marking and GUI separation to be prepared here. These are foundations, NOT vendor code: +no DLSS, no upscaler, no frame generation, no vendor latency backend is pulled in with them. They are listed apart +from the in-scope plan items because landing them grows PR #69, which the maintainer wants small and Vulkan-only. +Decide before starting: land them before PR #69, or after it on their own branch. -- [ ] Foundation A, frame marking: L0 latency types, the pre-input `LatencySleep` lib seam, +- [ ] Foundation A, frame marking (source `feat/latency`): L0 latency types, the pre-input `LatencySleep` lib seam, `IDeviceRequirementContributor` and the pNext chain builder in `CreateDevice`, one frame id per frame, `VkPresentIdKHR` chaining, markers around simulation / render submit / present, the `stats.latency` line. - Conflicts: `VulkanClientPlatform.Frame.cs` exists here already (add, do not replace); `FrameSlot.Submit` gains a - parameter and its three call sites move together, `UploadManager.SubmitStandalone` stays untagged. -- [ ] Foundation B, GUI separation: the `SceneNoHud` snapshot (slot 23) at the end of `RenderFinalComposition`, and - the UI target (slot 24) with a `ui-compose` pass composed back before the `Done` stage, so an upscaler or frame - generator evaluates a HUD-less image. Conflicts: the `feat/dlss-g` hooks sit in the lib GL body of - `BlitPrimaryToDefault`, which is overridden here and dispatches to `RenderNativeBlit()` - ported verbatim they are - dead code on Vulkan, so they must be re-implemented at that method's three exit points, with the lib patch kept for - the OpenGL path only. `ui-compose` registers through the native manifest / `LinkProgram` seam and needs - premultiplied-alpha blending. The UI compose GPU test must be new: the original only proved the GL-emulation path. -- Staging: L0 contracts (serial), then lib hook + device/markers + SceneNoHud + UI target in parallel worktrees, then - integrate, then in-game on both backends. Review only the `RenderNativeBlit()` re-implementation. - + Explicitly excluded: the NV, AMD and XeLL backends. +- [ ] Foundation B, GUI separation (source `feat/dlss-g`, not `feat/dlss`): the `SceneNoHud` snapshot (slot 23) at + the end of `RenderFinalComposition`, and the UI target (slot 24) with a `ui-compose` pass composed back before the + `Done` stage. Explicitly excluded: the upscaler subsystem its gate reads there, and two-presents-per-frame. + Conflict: the `feat/dlss-g` hooks live in the lib GL body of `BlitPrimaryToDefault`, which is overridden here and + dispatches to `RenderNativeBlit()` - ported verbatim they are dead code on Vulkan and must be re-implemented at + that method's three exit points, the lib patch kept for the OpenGL path only. ### Done From 0b9c6cc8c63b1b994388c3855937ea4789e7454e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 02:03:02 +0200 Subject: [PATCH 186/226] docs: frame marking and GUI separation are Vulkan foundation and in scope for this branch Frame identity with markers around simulation, submit and present, and the world frame separated from UI composition, are properties of the Vulkan backend's frame structure, not vendor work: they make pacing measurable and keep the HUD out of the scene image regardless of whether an upscaler exists. They were wrongly parked as a scope decision; they are in scope. Unchanged: the vendor layer that later sits on top of them - DLSS, XeSS, FSR, frame generation, the NV/AMD/XeLL latency backends and NGX - stays off this branch. In scope now: 44 done, 15 partial, 19 left, 1 blocked, 2 superseded. --- docs/vulkan-branch-progress.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index b26545a6..71c269b2 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -121,7 +121,7 @@ Every item of `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernigha The plan predates the PR #69 split, so it also contains DLSS, upscaler, frame-generation, HDR and ray-tracing work: those are marked `[out]` and are NOT owed on this branch. -**In scope for this branch: 44 done, 15 partial, 17 left, 1 blocked, 2 superseded.** +**In scope for this branch: 44 done, 15 partial, 19 left, 1 blocked, 2 superseded** (the 17 audited items plus the two foundations below).** **Out of scope for this branch: 11 items** - PR #69 carries the Vulkan backend and TAA only; DLSS, upscalers, frame generation, the vendor latency backends, NGX, HDR and ray tracing live on `feat/dlss`, `feat/dlss-g` and `feat/latency` and are NOT work owed here. They appear in the plan because the plan predates that split. @@ -265,12 +265,13 @@ Legend: `[x]` done, `[~]` partly done (what is left follows it), `[ ]` not start - [out] Roadmap: HDR output: HDR output — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency - [out] Roadmap: ray tracing: Ray tracing — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -#### Owner-requested, 2026-09-16: the two foundations - SCOPE DECISION OPEN +#### Vulkan foundation, in scope for this branch (owner's call, 2026-09-16) -The owner asked for frame marking and GUI separation to be prepared here. These are foundations, NOT vendor code: -no DLSS, no upscaler, no frame generation, no vendor latency backend is pulled in with them. They are listed apart -from the in-scope plan items because landing them grows PR #69, which the maintainer wants small and Vulkan-only. -Decide before starting: land them before PR #69, or after it on their own branch. +Frame structure is part of the Vulkan backend, not vendor work. A backend with an explicit frame graph needs one +identity per frame, with markers around simulation, submit and present, and it needs the world frame separated from +UI composition; both stand on their own whether or not an upscaler ever exists, and both are what make pacing +measurable and keep the HUD out of the scene image. They are IN SCOPE here. What stays off this branch is the vendor +layer that later sits on top of them: DLSS, XeSS, FSR, frame generation, the NV/AMD/XeLL latency backends and NGX. - [ ] Foundation A, frame marking (source `feat/latency`): L0 latency types, the pre-input `LatencySleep` lib seam, `IDeviceRequirementContributor` and the pNext chain builder in `CreateDevice`, one frame id per frame, From 3f97ecde680b27b3b230dd93490471008ad26b78 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 10:02:12 +0200 Subject: [PATCH 187/226] wip(native-post): the post chain as a chain, with the OIT merge and sky motion native Phase 3b stage 1b (docs/vulkan-native-render-systems.md, section 3): Optimum owns the post and TAA chain's order, and its first two passes draw through the native device API. The lib body is now one virtual per pass - OptimumPostAmbientOcclusion, the two post-scene/glow accessors, OptimumPostBloom, OptimumPostGodRays, OptimumPostLuma and OptimumPostFinish - lifted out of RenderPostprocessingEffects unchanged, so the OpenGL path runs the identical sequence it ran before ("OFF is vanilla") and a platform can replace one step at a time. OptimumBindKeepViewport is the bind-only seam a native pass restores the GL-shaped bound target with, and SystemRenderOITLayers exposes its two OIT targets by handle. Every new member is listed in Optimum.Patcher/Program.cs and in the vanilla-regions test. VulkanClientPlatform.NativePostChain.cs holds the chain: the ordered steps, a native helper for the OIT merge and for sky motion (RequestNativePipeline / BeginNativePass / WriteNative / DrawNativeFullscreen, the motion window as the pass's colour slots and the merge's additive blend as the pipeline's), and a legacy helper for every remaining step naming the stage that replaces it. RenderPostprocessingEffects' override never calls base; NativePostChainEnabled puts the whole chain, the blit included, back on the OpenGL body for the differential tests. Verified: dotnet build VintageStory.slnx -c Release (0 errors); dotnet test Optimum.Tests -c Release (1235 passed); dotnet test Optimum.Render.Vulkan.Tests (1038 passed, sync+best validation clean) with the implicit-layer disable set and vulkaninfo showing only VK_LAYER_MESA_device_select; extract-patches + check-patches (157 patches, 0 conflict). New GPU tests in NativePostChainTests: the merge matches the OpenGL body pixel for pixel with and without the motion window (scene, glow and motion attachment), sky motion matches it and leaves the image alone, neither native pass touches the emulation layer inside its pass, and four frames keep the declared step order with TAA still accumulating. --- Optimum.Patcher/Program.cs | 14 + .../NativePostChainTests.cs | 841 ++++++++++++++++++ .../Platform/VulkanClientPlatform.Graph.cs | 41 +- .../VulkanClientPlatform.NativePostChain.cs | 468 ++++++++++ Optimum.Render.Vulkan/VulkanDevice.Native.cs | 7 + .../ambient-occlusion-coverage-tests.cs | 15 +- ...-platform-windows-vanilla-regions-tests.cs | 4 + Optimum.Tests/fsr-pipeline-coverage-tests.cs | 2 +- .../native-post-chain-coverage-tests.cs | 265 ++++++ Optimum.Tests/scene-ssao-coverage-tests.cs | 12 +- Optimum.Tests/taa-pipeline-coverage-tests.cs | 23 +- .../ClientPlatformWindows.cs.patch | 215 +++-- .../SystemRenderOITLayers.cs.patch | 15 +- 13 files changed, 1823 insertions(+), 99 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativePostChainTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs create mode 100644 Optimum.Tests/native-post-chain-coverage-tests.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 92ac8266..8e29fe5a 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -244,6 +244,17 @@ // Phase 3b: the window's client size as a seam, so the native blit and the OpenGL body // read the same value (docs/vulkan-native-render-systems.md, decision 3). "OptimumWindowClientSize", + // Phase 3b: the post chain split into one virtual per pass, so a native chain + // (VulkanClientPlatform.NativePostChain) owns the order and replaces one step at a + // time, plus the keep-the-viewport bind a native pass restores the GL-shaped state with. + "OptimumPostAmbientOcclusion", + "OptimumPostSceneTexture", + "OptimumPostGlowTexture", + "OptimumPostBloom", + "OptimumPostGodRays", + "OptimumPostLuma", + "OptimumPostFinish", + "OptimumBindKeepViewport", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). @@ -450,6 +461,9 @@ { "optimumOitDisabled", "optimumOitFailureLogged", + // Phase 3b: the two OIT targets by handle, for the native OIT merge. + "OptimumOitRevealTexture", + "OptimumOitAccumTexture", "RestoreVanillaTransparentState", "DisableOptimumOit", }, diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs new file mode 100644 index 00000000..a5c98761 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -0,0 +1,841 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The post and TAA chain on the Vulkan platform (docs/vulkan-native-render-systems.md, stage 1). +/// Optimum owns the chain's order; its first two passes - the OIT merge and sky motion - draw +/// through the native device API, and the rest run the OpenGL body until their own stage moves +/// them. +/// +/// What is asserted here: +/// 1. the OIT merge draws the same pixels as the OpenGL body, with and without the motion +/// window, into the shaded image, the glow attachment and the motion attachment; +/// 2. sky motion writes the same motion attachment as the OpenGL body and leaves the shaded +/// image alone; +/// 3. neither native pass reaches the GL-emulation layer while its pass is open; +/// 4. over several frames the chain runs its steps in the declared order and the TAA resolve +/// keeps accumulating - the history parity alternates and the motion attachment the resolve +/// reads was written by the two passes that run before it. +/// +public class NativePostChainTests(ITestOutputHelper output) +{ + private const int Size = 16; + + private static readonly string[] Programs = { "transparentcompose", "taa-skymotion", "taa-resolve", "blit" }; + + /// The Vulkan platform without a window: the size seam answers for one. + private sealed class ChainPlatform : VulkanClientPlatform + { + public ChainPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(NativePostChainTests.Size, NativePostChainTests.Size); + + /// + /// No window is opened here, and the base's Primary case sizes its viewport from + /// NativeWindow.ClientSize, which is GLFW-backed. Primary is the render resolution, so + /// the full CurrentFrameBuffer setter is the same bind and the same viewport. + /// + public override void LoadFrameBuffer(EnumFrameBuffer framebuffer) + { + if (framebuffer == EnumFrameBuffer.Primary) + { + CurrentFrameBuffer = FrameBuffers[0]; + return; + } + base.LoadFrameBuffer(framebuffer); + } + } + + // ------------------------------------------------------------------ the tests + + /// + /// The OIT merge with the motion window open: the shaded image, the glow attachment and the + /// motion attachment all have to come out of the native pass exactly as the OpenGL body + /// leaves them, including the additive (ONE, ONE) blend that only touches the reactive + /// channel. + /// + [SkippableFact] + public void TheOitMergeMatchesTheOpenGlBodyWithTheMotionWindowOpen() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + + Frame emulated = RunMerge(session, native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + Frame nativeRoute = RunMerge(session, native: true); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(emulated.Glow, nativeRoute.Glow); + Assert.Equal(emulated.Motion, nativeRoute.Motion); + + // The merge really did add into the reactive channel, or the comparison above would + // pass on two routes that both wrote nothing. + Assert.True(Reactive(nativeRoute.Motion, Size / 2, Size / 2) > Reactive(session.MotionSeed, 0, 0), + "the merge did not accumulate coverage into the reactive channel"); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The same merge with TAA off: the motion window never opens, so the pass writes the two + /// world attachments and leaves the motion attachment exactly as it found it. + /// + [SkippableFact] + public void TheOitMergeMatchesTheOpenGlBodyWithoutTheMotionWindow() + { + using Session session = Open(); + session.EnableTaa(jitterActive: false); + + Frame emulated = RunMerge(session, native: false); + Frame nativeRoute = RunMerge(session, native: true); + + Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(emulated.Glow, nativeRoute.Glow); + Assert.Equal(emulated.Motion, nativeRoute.Motion); + Assert.Equal(session.MotionSeed, nativeRoute.Motion); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// Sky motion: the motion attachment the native pass writes is the OpenGL body's, and the + /// shaded image is untouched - the pass writes one colour slot and no depth. + /// + [SkippableFact] + public void SkyMotionMatchesTheOpenGlBody() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + + Frame emulated = RunSkyMotion(session, native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + Frame nativeRoute = RunSkyMotion(session, native: true); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + Assert.Equal(emulated.Motion, nativeRoute.Motion); + Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(session.SceneSeed, nativeRoute.Scene); + + // The pass covered the sky, or "the two routes agree" would be vacuous. + Assert.True(Reactive(nativeRoute.Motion, Size / 2, Size / 2) > 0.5f, + "sky motion wrote no reactive value, so the depth test rejected the whole target"); + + GpuTest.AssertClean(session.Seam); + } + + /// The OpenGL body on this device is the emulation layer; the native chain is not. + [SkippableFact] + public void TheOpenGlRouteDrawsThroughTheEmulationLayerAndTheNativeChainDoesNot() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long emulatedBefore = session.Seam.EmulationCallsForTests; + RunMerge(session, native: false); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + RunMerge(session, native: true); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// Several frames through the native chain: every frame runs the steps in the declared + /// order, and TAA keeps accumulating - the resolve runs each frame, the history parity + /// alternates so each frame reads the slot the last one wrote, and the motion attachment it + /// reads carries what the merge and sky motion wrote before it. + /// + /// The final composition is the one declared step not driven here: its body reads + /// NativeWindow.ClientSize directly, which a windowless test cannot answer. Its position in + /// the chain is pinned by the source coverage test (Optimum.Tests, native-post-chain). + /// + [SkippableFact] + public void TheChainKeepsItsOrderAcrossFramesAndTaaKeepsAccumulating() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + + VulkanDevice seam = session.Seam; + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = true; + + var expected = new List(); + foreach (VulkanClientPlatform.NativePostStep step in VulkanClientPlatform.NativePostChainOrder) + { + if (step != VulkanClientPlatform.NativePostStep.FinalComposition) expected.Add(step); + } + + var resolvedTextures = new List(); + const int frames = 4; + for (int frame = 0; frame < frames; frame++) + { + session.AdvanceTemporalFrame(); + var log = new List(); + platform.NativePostStepLog = log; + + platform.BeginFrame(); + session.SeedFrame(); + + platform.CurrentFrameBuffer = session.Primary; + platform.MergeTransparentRenderPass(); + platform.CurrentFrameBuffer = session.Primary; + platform.RenderOptimumSkyMotion(); + platform.RenderPostprocessingEffects(null); + platform.BlitPrimaryToDefault(); + + platform.NativePostStepLog = null; + Assert.Equal(expected, log); + Assert.True(platform.TaaResolvedThisFrame, "the resolve did not run on frame " + frame); + resolvedTextures.Add(ResolvedColorTexture(platform)); + + byte[] motion = session.ReadMotion(); + Assert.True(Reactive(motion, Size / 2, Size / 2) > 0.5f, + "frame " + frame + " reached the resolve with an empty motion attachment, " + + "so the merge and sky motion did not run before it"); + + platform.EndFrame(); + } + + // The resolve alternates its history slots, so every frame reads the one the previous + // frame wrote: that alternation is the accumulation. + int slotA = session.History(0).ColorTextureIds[0]; + int slotB = session.History(1).ColorTextureIds[0]; + for (int frame = 0; frame < frames; frame++) + { + Assert.Equal(frame % 2 == 0 ? slotA : slotB, resolvedTextures[frame]); + } + Assert.True(HistoryValid(platform), "the resolve left the history invalid"); + + GpuTest.AssertClean(seam); + } + + // ---------------------------------------------------------------------- driving + + private readonly record struct Frame(byte[] Scene, byte[] Glow, byte[] Motion); + + /// One OIT merge, on the route under test, from an identically seeded frame. + private Frame RunMerge(Session session, bool native) + { + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = native; + + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + + platform.MergeTransparentRenderPass(); + + var frame = new Frame(session.ReadScene(), session.ReadGlow(), session.ReadMotion()); + platform.EndFrame(); + return frame; + } + + /// One sky-motion pass, on the route under test, from an identically seeded frame. + private Frame RunSkyMotion(Session session, bool native) + { + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = native; + session.AdvanceTemporalFrame(); + + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + + bool drawn = platform.RenderOptimumSkyMotion(); + Assert.True(drawn, "the sky motion pass did not run"); + + var frame = new Frame(session.ReadScene(), session.ReadGlow(), session.ReadMotion()); + platform.EndFrame(); + return frame; + } + + /// The reactive channel of a decoded motion pixel (motion.b, in [0, 1]). + private static float Reactive(byte[] decoded, int x, int y) => decoded[(y * Size + x) * 4 + 2] / 255f; + + private static int ResolvedColorTexture(ClientPlatformWindows platform) => + (int)typeof(ClientPlatformWindows) + .GetField("taaResolvedColorTexture", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(platform)!; + + private static bool HistoryValid(ClientPlatformWindows platform) => + (bool)typeof(ClientPlatformWindows) + .GetField("_taaHistoryValid", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(platform)!; + + // ---------------------------------------------------------------------- session + + private Session Open() + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + Session? session = Session.TryOpen(output, manifest); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// + /// The platform, its device, the targets the chain indexes, the programs its passes use and + /// the client statics they read, all put back afterwards. + /// + private sealed class Session : IDisposable + { + public ChainPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Primary { get; private set; } = null!; + public FrameBufferRef Transparent { get; private set; } = null!; + + /// The motion attachment's seed, decoded the way decodes it. + public byte[] MotionSeed { get; private set; } = Array.Empty(); + + /// The shaded image's seed, as read back. + public byte[] SceneSeed { get; private set; } = Array.Empty(); + + private readonly List buffers = new(); + private int oitReveal; + private int oitAccumulation; + private int decodeProgram; + private int decodeTarget; + private int decodeFramebuffer; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + private ShaderProgramTransparentcompose? composeBefore; + private ShaderProgram? skyMotionBefore; + private ShaderProgram? resolveBefore; + private ShaderProgramBlit? blitBefore; + private bool taaBefore; + private float sharpnessBefore; + private object? oitRevealBefore; + private object? oitAccumBefore; + private DefaultShaderUniforms uniforms = new(); + + private const BindingFlags Hidden = BindingFlags.Instance | BindingFlags.NonPublic; + private const BindingFlags HiddenStatic = BindingFlags.Static | BindingFlags.NonPublic; + + public FrameBufferRef History(int parity) => buffers[parity == 0 ? 19 : 20]; + + public static Session? TryOpen(ITestOutputHelper output, string manifestDirectory) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-post-" + Guid.NewGuid().ToString("N")); + var platform = new ChainPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + composeBefore = ShaderPrograms.Transparentcompose, + skyMotionBefore = ShaderPrograms.TaaSkyMotion, + resolveBefore = ShaderPrograms.TaaResolve, + blitBefore = ShaderPrograms.Blit, + taaBefore = OptimumConfig.Taa, + sharpnessBefore = OptimumConfig.TaaSharpness, + }; + ScreenManager.Platform = platform; + ScreenManager.FrameProfiler ??= new FrameProfilerUtil(static (string _) => { }); + platform.ShaderUniforms = session.uniforms; + + session.BuildTargets(); + session.LinkPrograms(); + session.InstallState(); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + ShaderPrograms.Transparentcompose = composeBefore!; + ShaderPrograms.TaaSkyMotion = skyMotionBefore!; + ShaderPrograms.TaaResolve = resolveBefore!; + ShaderPrograms.Blit = blitBefore!; + OptimumConfig.Taa = taaBefore; + OptimumConfig.TaaSharpness = sharpnessBefore; + OptimumTemporal.Frame.JitterActive = false; + typeof(SystemRenderOITLayers).GetField("revealTextureId", HiddenStatic)!.SetValue(null, oitRevealBefore); + typeof(SystemRenderOITLayers).GetField("accumTextureId", HiddenStatic)!.SetValue(null, oitAccumBefore); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + // ------------------------------------------------------------ per-frame state + + /// + /// The state the AfterOIT stage leaves for the merge, and the inputs both routes read: + /// Primary cleared to the seeds, the Transparent target's OIT attachments cleared, the + /// world draw-buffer mask, and the OIT textures on the units the OIT renderer uses. + /// + public void SeedFrame() + { + VulkanDevice seam = Seam; + + seam.BindFramebuffer(Transparent.FboId); + seam.SetDrawBuffers(Transparent.FboId, 0b111001); + seam.ClearColor(0, 0.6f, 0.45f, 0.3f, 1f); + seam.ClearColor(3, 0.30f, 0.10f, 0.05f, 0.5f); + seam.ClearColor(4, 0.10f, 0.25f, 0.05f, 0.35f); + seam.ClearColor(5, 0.05f, 0.10f, 0.30f, 0.2f); + + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, 0b111); + seam.ClearColor(0, 0.25f, 0.5f, 0.75f, 1f); + seam.ClearColor(1, 0.125f, 0.25f, 0.375f, 1f); + seam.ClearColor(2, 0f, 0f, 0.125f, 0.5f); + seam.ClearDepth(1f); + // Primary's default colour set: two attachments without the SSAO G-buffer. + seam.SetDrawBuffers(Primary.FboId, 0b011); + + seam.SetViewport(0, 0, Size, Size); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetDepthTest(false); + seam.SetDepthMask(true); + seam.SetCullFace(false); + + // The units SystemRenderOITLayers points the merge's two OIT samplers at. + ShaderProgramTransparentcompose compose = ShaderPrograms.Transparentcompose; + seam.SetSamplerUnit(compose.ProgramId, "OITreveal", 6); + seam.SetSamplerUnit(compose.ProgramId, "OITaccumulation", 7); + seam.BindTexture(6, oitReveal); + seam.BindTexture(7, oitAccumulation); + } + + /// TAA on, with the jitter window open or closed, and no sharpen pass. + public void EnableTaa(bool jitterActive) + { + OptimumConfig.Taa = true; + OptimumConfig.TaaSharpness = 0f; + OptimumTemporalFrame frame = OptimumTemporal.Frame; + frame.JitterSequencePx.X = 0.25f; + frame.JitterSequencePx.Y = -0.375f; + frame.JitterActive = jitterActive; + AdvanceTemporalFrame(); + AdvanceTemporalFrame(); + frame.JitterActive = jitterActive; + } + + /// + /// One frame of the temporal contract: the camera and the projection captured, so the + /// sky-motion and resolve passes have a previous view to reproject through. + /// + public void AdvanceTemporalFrame() + { + OptimumTemporalFrame frame = OptimumTemporal.Frame; + bool jitter = frame.JitterActive; + frame.Advance(16f, Size, Size, 1f, 0.1f, 100f, 70f, uniforms); + double[] projection = Mat4d.Perspective(Mat4d.Create(), 70.0 * Math.PI / 180.0, 1.0, 0.1, 100.0); + double[] view = Mat4d.Identity(Mat4d.Create()); + frame.RecordProjection(EnumTemporalView.World, projection); + frame.CaptureCamera(view, view); + frame.JitterActive = jitter; + } + + // ---------------------------------------------------------------- readback + + public byte[] ReadScene() => ReadAttachmentZero(Primary.FboId); + + public byte[] ReadGlow() => Decode(Primary.ColorTextureIds[1], motion: false); + + public byte[] ReadMotion() => Decode(Primary.ColorTextureIds[2], motion: true); + + private unsafe byte[] ReadAttachmentZero(int framebufferId) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + Seam.BindFramebuffer(framebufferId); + Seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// + /// Any attachment through an RGBA8 copy, because the seam's readback is four bytes per + /// pixel from attachment 0. The motion mode encodes the vector into the two low + /// channels so a difference in it cannot hide behind a clamp. + /// + private unsafe byte[] Decode(int textureId, bool motion) + { + VulkanDevice seam = Seam; + seam.BindFramebuffer(decodeFramebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decodeProgram); + seam.SetSamplerUnit(decodeProgram, "source", 15); + seam.BindTexture(15, textureId); + SetInt(seam, decodeProgram, "motionMode", motion ? 1 : 0); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + return ReadAttachmentZero(decodeFramebuffer); + } + + private static void SetInt(VulkanDevice seam, int program, string name, int value) + { + int location = seam.GetUniformLocation(program, name); + if (location >= 0) seam.SetUniform(program, location, value); + } + + // ------------------------------------------------------------------- fixture + + private void BuildTargets() + { + VulkanDevice seam = Seam; + + Primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + Texture(EnumTextureInternalFormat.Rgba8), + Texture(EnumTextureInternalFormat.Rgba8), + Texture(EnumTextureInternalFormat.Rgba16f), + }, + DepthTextureId = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false), + }; + for (int slot = 0; slot < 3; slot++) + { + seam.AttachTexture(Primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + Primary.ColorTextureIds[slot], 0); + } + seam.AttachTexture(Primary.FboId, EnumFramebufferAttachment.DepthAttachment, Primary.DepthTextureId, 0); + Assert.True(seam.CheckFramebufferComplete(Primary.FboId, out string primaryStatus), primaryStatus); + + // The Transparent target as the client leaves it once the OIT renderer has replaced + // attachment 0 with its reveal target and attached the accumulation array's three + // layers at 3, 4 and 5. ColorTextureIds keeps the vanilla ids, which is what the + // merge binds as accumulation, revealage and in-glow. + oitReveal = Texture(EnumTextureInternalFormat.Rgba8); + oitAccumulation = seam.CreateTexture2DArray(Size, Size, 3, + EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba); + Transparent = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + Seeded(0.7f), + Seeded(0.35f), + Seeded(0.2f), + }, + }; + seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment0, oitReveal, 0); + seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment1, + Transparent.ColorTextureIds[1], 0); + seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment2, + Transparent.ColorTextureIds[2], 0); + seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment3, oitAccumulation, 0); + seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment4, oitAccumulation, 1); + seam.AttachTexture(Transparent.FboId, (EnumFramebufferAttachment)36069, oitAccumulation, 2); + Assert.True(seam.CheckFramebufferComplete(Transparent.FboId, out string status), status); + + for (int i = 0; i <= 24; i++) buffers.Add(null!); + buffers[0] = Primary; + buffers[1] = Transparent; + buffers[10] = SingleTarget(EnumTextureInternalFormat.Rgba8); + buffers[19] = HistoryTarget(); + buffers[20] = HistoryTarget(); + + decodeTarget = Texture(EnumTextureInternalFormat.Rgba8); + decodeFramebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(decodeFramebuffer, EnumFramebufferAttachment.ColorAttachment0, decodeTarget, 0); + seam.SetDrawBuffers(decodeFramebuffer, 0b1); + } + + private int Texture(EnumTextureInternalFormat format) => + Seam.CreateTexture2D(Size, Size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + /// A texture with a per-pixel pattern around , so a difference shows. + private unsafe int Seeded(float level) + { + var pixels = new byte[Size * Size * 4]; + for (int y = 0; y < Size; y++) + { + for (int x = 0; x < Size; x++) + { + int i = (y * Size + x) * 4; + pixels[i] = (byte)Math.Clamp(level * 255f + x * 3, 0, 255); + pixels[i + 1] = (byte)Math.Clamp(level * 255f + y * 5, 0, 255); + pixels[i + 2] = (byte)Math.Clamp(level * 255f + ((x ^ y) & 7) * 9, 0, 255); + pixels[i + 3] = (byte)Math.Clamp(level * 255f, 0, 255); + } + } + fixed (byte* data = pixels) + { + return Seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, (IntPtr)data, false); + } + } + + private FrameBufferRef SingleTarget(EnumTextureInternalFormat format) + { + var target = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = Seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] { Texture(format) }, + }; + Seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + Seam.SetDrawBuffers(target.FboId, 0b1); + return target; + } + + /// A TAA history slot: colour, aux and the linear-depth R32F, as the platform builds them. + private FrameBufferRef HistoryTarget() + { + var target = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = Seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + Texture(EnumTextureInternalFormat.Rgba16f), + Texture(EnumTextureInternalFormat.Rgba8), + Seam.CreateTexture2DRaw(Size, Size, 0x822E, IntPtr.Zero, 0), + }, + }; + for (int slot = 0; slot < 3; slot++) + { + Seam.AttachTexture(target.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + target.ColorTextureIds[slot], 0); + } + Seam.SetDrawBuffers(target.FboId, 0b111); + return target; + } + + private void LinkPrograms() + { + VulkanDevice seam = Seam; + ShaderCorpus.ShaderVariant variant = TaaVariant(); + + var compose = new ShaderProgramTransparentcompose { PassName = "transparentcompose" }; + Link(seam, compose, "transparentcompose", variant, Array.Empty()); + var skyMotion = new ShaderProgram { PassName = "taa-skymotion" }; + Link(seam, skyMotion, "taa-skymotion", variant, new[] + { + "taaRenderSize", "taaJitterPx", "taaInvViewProjJittered", "taaPrevViewProj", "taaCloudReactive", + }); + var resolve = new ShaderProgram { PassName = "taa-resolve" }; + Link(seam, resolve, "taa-resolve", variant, new[] + { + "renderSize", "jitterPx", "invViewProjJittered", "prevViewProj", "viewMatrix", + "cameraDelta", "resetHistory", "blendAlpha", "varianceGamma", + }); + var blit = new ShaderProgramBlit { PassName = "blit" }; + Link(seam, blit, "blit", variant, Array.Empty()); + + ShaderPrograms.Transparentcompose = compose; + ShaderPrograms.TaaSkyMotion = skyMotion; + ShaderPrograms.TaaResolve = resolve; + ShaderPrograms.Blit = blit; + + decodeProgram = LinkDecode(seam); + } + + /// TAA on without the SSAO G-buffer: the motion attachment at colour 2. + internal static ShaderCorpus.ShaderVariant TaaVariant() + { + foreach (ShaderCorpus.ShaderVariant candidate in ShaderCorpus.Variants()) + { + if (candidate.Name == "taa-no-ssao") return candidate; + } + throw new InvalidOperationException("the corpus has no taa-no-ssao variant"); + } + + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, + ShaderCorpus.ShaderVariant variant, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + /// The readback helper's own program: any attachment into RGBA8. + private static int LinkDecode(VulkanDevice seam) + { + const string vertex = @"#version 330 core +out vec2 uv; +void main(void) +{ + vec2 position = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + uv = position; + gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0); +} +"; + const string fragment = @"#version 330 core +uniform sampler2D source; +uniform int motionMode; +layout(location = 0) out vec4 outColor; +void main(void) +{ + vec4 texel = texelFetch(source, ivec2(gl_FragCoord.xy), 0); + if (motionMode != 0) { + outColor = vec4( + clamp(texel.r / 32.0 * 0.5 + 0.5, 0.0, 1.0), + clamp(texel.g / 32.0 * 0.5 + 0.5, 0.0, 1.0), + clamp(texel.b, 0.0, 1.0), + clamp(texel.a, 0.0, 1.0)); + return; + } + outColor = clamp(texel, 0.0, 1.0); +} +"; + var linked = new LinkedProgram { PassName = "native-post-decode" }; + var vertexShader = new LinkedShader { Type = EnumShaderType.VertexShader, Code = vertex, PrefixCode = "" }; + var fragmentShader = new LinkedShader { Type = EnumShaderType.FragmentShader, Code = fragment, PrefixCode = "" }; + Assert.True(seam.CompileShader(vertexShader), seam.GetError() ?? "decode vertex shader"); + Assert.True(seam.CompileShader(fragmentShader), seam.GetError() ?? "decode fragment shader"); + linked.VertexShader = vertexShader; + linked.FragmentShader = fragmentShader; + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "decode link failed"); + return id; + } + + /// The platform's frame buffers, motion attachment and TAA readiness, as the setup leaves them. + private void InstallState() + { + typeof(ClientPlatformWindows).GetField("frameBuffers", Hidden)!.SetValue(Platform, buffers); + typeof(ClientPlatformWindows).GetField("ssaaLevel", Hidden)!.SetValue(Platform, 1f); + Platform.SetOptimumMotionAttachmentIndex(2); + typeof(ClientPlatformWindows).GetField("optimumTaaTargetsReady", Hidden)!.SetValue(Platform, true); + + FieldInfo reveal = typeof(SystemRenderOITLayers).GetField("revealTextureId", HiddenStatic)!; + FieldInfo accumulation = typeof(SystemRenderOITLayers).GetField("accumTextureId", HiddenStatic)!; + oitRevealBefore = reveal.GetValue(null); + oitAccumBefore = accumulation.GetValue(null); + reveal.SetValue(null, oitReveal); + accumulation.SetValue(null, oitAccumulation); + + // The seeds the comparisons quote, read once through the same decode the tests use. + Platform.BeginFrame(); + SeedFrame(); + SceneSeed = ReadScene(); + MotionSeed = ReadMotion(); + Platform.EndFrame(); + } + } + + // ---------------------------------------------------------------- native shaders + + /// The manifest of the programs the chain's two native passes and the resolve use. + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + foreach (string program in Programs) + { + NativeShaderBuildResult one = builder.Build(source, program); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + } + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), "optimum-native-post-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index 7fd99d26..94274c22 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -295,23 +295,41 @@ private void AddDepth(List reads, int index) // ------------------------------------------------------------ post methods + /// + /// Phase 3b stage 1: the chain's first pass, drawn natively + /// (VulkanClientPlatform.NativePostChain.cs). puts the + /// whole chain back on the OpenGL body for the differential tests. + /// public override void MergeTransparentRenderPass() { - SetPassContext("MergeTransparent", PassFlags.None); - base.MergeTransparentRenderPass(); - SetPassContext("Frame", PassFlags.AllowSplit); + NotePostStep(NativePostStep.OitMerge); + if (UseNativePostChain) + { + NativeOitMerge(); + return; + } + LegacyOitMerge(); } + /// Phase 3b stage 1: the chain's second pass, drawn natively. public override bool RenderOptimumSkyMotion() { - SetPassContext("SkyMotion", PassFlags.None); - bool drawn = base.RenderOptimumSkyMotion(); - SetPassContext("Frame", PassFlags.AllowSplit); - return drawn; + NotePostStep(NativePostStep.SkyMotion); + return UseNativePostChain ? NativeSkyMotion() : LegacySkyMotion(); } + /// + /// Phase 3b stage 1: Optimum owns the post chain's order. The native route runs the steps + /// this method holds - AO, TAA resolve and sharpen, bloom, god rays, the Luma step and the + /// epilogue - and never calls base. + /// public override void RenderPostprocessingEffects(float[] projectMatrix) { + if (UseNativePostChain) + { + RunNativePostChain(projectMatrix); + return; + } SetPassContext("Post", PassFlags.None); base.RenderPostprocessingEffects(projectMatrix); SetPassContext("Frame", PassFlags.AllowSplit); @@ -337,11 +355,11 @@ public override int RenderOptimumTaaSharpen(int resolvedScene) return sharpened; } + /// Phase 3b stage 1: the chain's ninth pass. Stage 1g makes it native. public override void RenderFinalComposition() { - SetPassContext("FinalComposition", PassFlags.None); - base.RenderFinalComposition(); - SetPassContext("Frame", PassFlags.AllowSplit); + NotePostStep(NativePostStep.FinalComposition); + LegacyFinalComposition(); } /// @@ -352,7 +370,8 @@ public override void RenderFinalComposition() /// public override void BlitPrimaryToDefault() { - if (NativeBlitEnabled && device != null) + NotePostStep(NativePostStep.Blit); + if (NativeBlitEnabled && UseNativePostChain) { RenderNativeBlit(); return; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs new file mode 100644 index 00000000..bc2872d6 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -0,0 +1,468 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), stage 1: Optimum owns +// the post and TAA chain end to end. This file holds the chain - its ORDER, and one helper per +// pass with a stable signature. +// +// The order is section 3's and the OpenGL body's: OIT merge, sky motion, SSAO and blur then the +// AO composite, TAA resolve and sharpen, bloom, god rays, FXAA luma or blit, final composition, +// and last the blit/FSR/debug step that is already native (VulkanClientPlatform.NativeBlit.cs). +// RenderPostprocessingEffects' override runs the steps that live inside it and never calls base. +// +// Two helpers draw natively here - the OIT merge and sky motion - through RequestNativePipeline, +// BeginNativePass, WriteNative and DrawNativeFullscreen, exactly as the blit does. Every other +// helper is LEGACY: the same work through the GL-shaped platform calls, which after the split in +// ClientPlatformWindows is one lib virtual per pass, so the chain is complete and correct at +// every commit and a later stage replaces one helper at a time. Each legacy helper names the +// stage that will replace it. +public partial class VulkanClientPlatform +{ + /// The chain's passes, in the order the frame runs them (section 3). + internal enum NativePostStep + { + OitMerge = 0, + SkyMotion = 1, + SsaoAndAmbientOcclusion = 2, + TaaResolve = 3, + TaaSharpen = 4, + Bloom = 5, + GodRays = 6, + FxaaOrBlit = 7, + FinalComposition = 8, + Blit = 9, + } + + /// + /// The chain order, declared once. The steps the chain itself sequences are the ones inside + /// ; the others are separate virtuals the client + /// calls in this order, and every helper records its step, so a test can read the order back + /// off a real frame. + /// + internal static readonly NativePostStep[] NativePostChainOrder = + { + NativePostStep.OitMerge, + NativePostStep.SkyMotion, + NativePostStep.SsaoAndAmbientOcclusion, + NativePostStep.TaaResolve, + NativePostStep.TaaSharpen, + NativePostStep.Bloom, + NativePostStep.GodRays, + NativePostStep.FxaaOrBlit, + NativePostStep.FinalComposition, + NativePostStep.Blit, + }; + + /// + /// False runs the whole chain on the OpenGL body instead - every pass, the blit included: + /// the old route the differential tests compare the native one against. The blit keeps its + /// own switch for the tests written around it. + /// + internal bool NativePostChainEnabled { get; set; } = true; + + /// Test seam: when set, every chain step appends itself here as it runs. + internal List? NativePostStepLog { get; set; } + + private bool UseNativePostChain => NativePostChainEnabled && device != null; + + private void NotePostStep(NativePostStep step) => NativePostStepLog?.Add(step); + + // ------------------------------------------------------------------ the chain + + /// + /// The chain's own steps, in order, with no call to the base body: the AO step, the TAA + /// resolve and sharpen, bloom, god rays, the Luma step and the epilogue. The order and the + /// per-step conditions are the OpenGL body's + /// (ClientPlatformWindows.RenderPostprocessingEffects). + /// + private void RunNativePostChain(float[] projectMatrix) + { + if (!offscreenBufferActive) return; + + SetPassContext("Post", PassFlags.None); + NotePostStep(NativePostStep.SsaoAndAmbientOcclusion); + PostStepAmbientOcclusion(projectMatrix); + + NotePostStep(NativePostStep.TaaResolve); + PostStepTaaResolve(); + + int scene = OptimumPostSceneTexture(); + int glow = OptimumPostGlowTexture(); + NotePostStep(NativePostStep.TaaSharpen); + scene = PostStepTaaSharpen(scene); + + NotePostStep(NativePostStep.Bloom); + PostStepBloom(scene, glow); + NotePostStep(NativePostStep.GodRays); + PostStepGodRays(scene, glow); + NotePostStep(NativePostStep.FxaaOrBlit); + PostStepFxaaOrBlit(scene); + PostStepFinish(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + // ----------------------------------------------------------- native pass 1: OIT merge + + private readonly NativeFullscreenPass nativeOitMerge = new("transparentcompose", + Array.Empty(), + new[] { "accumulation", "revealage", "inGlow", "OITreveal", "OITaccumulation" }); + + /// + /// The OIT merge, drawn natively. The OpenGL body binds Primary without touching the + /// viewport, turns the depth test off and blending on with the global source-alpha mode, + /// opens the motion window when TAA is running, and composes the Transparent target's three + /// attachments together with the OIT reveal and accumulation targets. + /// + /// Here the pass states its target, its colour slots and its reads, and the blend is the + /// pipeline's: source-alpha on every slot, and additive (ONE, ONE) on the motion attachment + /// alone, which is what lets the merge add the transparent layer's coverage into the + /// reactive channel without touching the vector or the writer depth under it. The GL-shaped + /// state calls stay, outside the pass: they are what every stage AFTER the merge inherits, + /// exactly as on the OpenGL body. + /// + private void NativeOitMerge() + { + List buffers = FrameBuffers; + FrameBufferRef primary = buffers != null && buffers.Count > 0 ? buffers[0] : null!; + FrameBufferRef transparent = buffers != null && buffers.Count > 1 ? buffers[1] : null!; + ShaderProgramTransparentcompose compose = ShaderPrograms.Transparentcompose; + if (!offscreenBufferActive || primary == null || transparent == null || + transparent.ColorTextureIds == null || transparent.ColorTextureIds.Length < 3 || + compose == null || compose.LoadError || compose.Disposed) + { + LegacyOitMerge(); + return; + } + + OptimumBindKeepViewport(primary); + ApplyTransparentMergeBlendState(); + + bool motion = NativeMotionAttachmentWritable(primary); + uint slots = NativeWorldColorSlots(); + if (motion) slots |= 1u << MotionAttachmentIndex; + + NativePipeline? pipeline = NativePostPipeline(nativeOitMerge, compose, primary.FboId, slots, + NativeMergeBlend(slots, motion), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) + { + LegacyOitMerge(); + return; + } + + int accumulation = transparent.ColorTextureIds[0]; + int revealage = transparent.ColorTextureIds[1]; + int inGlow = transparent.ColorTextureIds[2]; + int oitReveal = SystemRenderOITLayers.OptimumOitRevealTexture; + int oitAccumulation = SystemRenderOITLayers.OptimumOitAccumTexture; + + var reads = new List { accumulation, revealage, inGlow }; + if (oitReveal > 0) reads.Add(oitReveal); + if (oitAccumulation > 0) reads.Add(oitAccumulation); + + if (BeginNativeKeepViewportPass("MergeTransparent/0", primary.FboId, slots, reads.ToArray())) + { + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeOitMerge.Samplers[0], accumulation), + new NativeTexture(nativeOitMerge.Samplers[1], revealage), + new NativeTexture(nativeOitMerge.Samplers[2], inGlow), + new NativeTexture(nativeOitMerge.Samplers[3], oitReveal), + new NativeTexture(nativeOitMerge.Samplers[4], oitAccumulation), + }); + } + device.EndNativePass(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + /// + /// The merge's blend: the global source-alpha mode on every slot the pass writes, with + /// FUNC_ADD and (ONE, ONE) on the motion attachment while the window is open + /// (ClientPlatformWindows.ApplyOptimumMotionAccumulateBlendState). + /// + private AttachmentBlend[] NativeMergeBlend(uint slots, bool motion) + { + var blend = new AttachmentBlend[NativeSlotCount(slots)]; + for (int i = 0; i < blend.Length; i++) + { + if (((slots >> i) & 1) == 0) + { + blend[i].WriteMask = 0; + continue; + } + AttachmentBlend attachment = AttachmentBlend.Default; + attachment.Enabled = true; + if (motion && i == MotionAttachmentIndex) + { + attachment.SrcColor = BlendFactor.One; + attachment.DstColor = BlendFactor.One; + attachment.SrcAlpha = BlendFactor.One; + attachment.DstAlpha = BlendFactor.One; + } + blend[i] = attachment; + } + return blend; + } + + // ---------------------------------------------------------- native pass 2: sky motion + + private readonly NativeFullscreenPass nativeSkyMotion = new("taa-skymotion", + new[] { "taaRenderSize", "taaJitterPx", "taaInvViewProjJittered", "taaPrevViewProj", "taaCloudReactive" }, + new[] { "transparentRevealTex" }); + + /// + /// The sky / volumetric-cloud motion and reactive pass, drawn natively: a fullscreen + /// triangle at window depth 1.0 with the depth test on, GL_LEQUAL and depth writes off, + /// writing the motion attachment and nothing else. The guards, the two matrices and the + /// uniform values are the OpenGL body's (ClientPlatformWindows.RenderOptimumSkyMotion); the + /// motion-only window is the pass's colour slot, not a draw-buffer mask. + /// + private bool NativeSkyMotion() + { + if (!OptimumConfig.EffectiveTaa) return false; + if (!TaaTargetsReady || MotionAttachmentIndex < 0) return false; + ShaderProgram skyMotion = ShaderPrograms.TaaSkyMotion; + if (skyMotion == null || skyMotion.LoadError || skyMotion.Disposed) return false; + List buffers = FrameBuffers; + if (buffers == null || buffers.Count <= 1) return false; + FrameBufferRef primary = buffers[0]; + FrameBufferRef transparent = buffers[1]; + if (primary == null || primary.Disposed || transparent == null || transparent.Disposed) return false; + if (transparent.ColorTextureIds == null || transparent.ColorTextureIds.Length < 2) return false; + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + if (!frame.WasViewCaptured(EnumTemporalView.World)) return false; + + // The same two matrices the resolve builds for its camera fallback: this frame's + // jittered view-projection inverted, and the previous frame's unjittered one. Built + // here rather than reused, exactly as the OpenGL body builds them. + float[] projection = frame.GetProjection(EnumTemporalView.World); + var jittered = new double[16]; + for (int i = 0; i < 16; i++) jittered[i] = projection[i]; + OptimumTemporalMath.ApplyProjectionJitter(jittered, frame.JitterPx.X, frame.JitterPx.Y, + primary.Width, primary.Height); + var projectionJittered = new float[16]; + for (int i = 0; i < 16; i++) projectionJittered[i] = (float)jittered[i]; + float[] viewProj = Mat4f.Mul(new float[16], projectionJittered, frame.CameraMatrixOrigin); + float[] invViewProj = Mat4f.Invert(new float[16], viewProj); + // A failed invert bails before any state is touched, as it does on the OpenGL body. + if (invViewProj == null) return false; + float[] prevViewProj = Mat4f.Mul(new float[16], + frame.GetPrevProjection(EnumTemporalView.World), frame.PrevCameraMatrixOrigin); + + bool opened = NativeMotionAttachmentWritable(primary); + if (opened) + { + uint slots = 1u << MotionAttachmentIndex; + int reveal = transparent.ColorTextureIds[1]; + NativePipeline? pipeline = NativePostPipeline(nativeSkyMotion, skyMotion, primary.FboId, slots, + NativeMotionOnlyBlend(slots), depthTest: true, depthWrite: false, CompareOp.LessOrEqual); + if (pipeline == null) + { + opened = false; + } + else + { + SetPassContext("SkyMotion", PassFlags.None); + if (BeginNativeKeepViewportPass("SkyMotion/0", primary.FboId, slots, new[] { reveal })) + { + device.WriteNative(pipeline, nativeSkyMotion.Uniforms[0], primary.Width, primary.Height); + device.WriteNative(pipeline, nativeSkyMotion.Uniforms[1], frame.JitterPx.X, frame.JitterPx.Y); + WriteNativeMatrix(pipeline, nativeSkyMotion.Uniforms[2], invViewProj); + WriteNativeMatrix(pipeline, nativeSkyMotion.Uniforms[3], prevViewProj); + device.WriteNative(pipeline, nativeSkyMotion.Uniforms[4], OptimumCloudReactive); + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeSkyMotion.Samplers[0], reveal), + }); + } + device.EndNativePass(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + } + + // Everything the OpenGL body's state block and its finally leave for the AfterOIT + // stages and the post chain: depth writes on, GL_LESS, blending on, culling on. The + // body normalises them whether or not the window opened, so this runs on both paths. + GlDepthFunc(EnumDepthFunction.Less); + GlDepthMask(flag: true); + GlToggleBlend(on: true); + GlEnableCullFace(); + + if (!opened) return false; + ScreenManager.FrameProfiler.Mark("rend3D-ret-skymv"); + return true; + } + + /// The motion attachment alone, unblended; every other slot masked out of the pass. + private AttachmentBlend[] NativeMotionOnlyBlend(uint slots) + { + var blend = new AttachmentBlend[NativeSlotCount(slots)]; + for (int i = 0; i < blend.Length; i++) + { + if (((slots >> i) & 1) == 0) blend[i].WriteMask = 0; + else blend[i] = AttachmentBlend.Default; + } + return blend; + } + + // ------------------------------------------------------------------ legacy helpers + + /// LEGACY - pass 1 on the OpenGL body. replaces it; kept as the old route. + private void LegacyOitMerge() + { + SetPassContext("MergeTransparent", PassFlags.None); + base.MergeTransparentRenderPass(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + /// LEGACY - pass 2 on the OpenGL body. replaces it; kept as the old route. + private bool LegacySkyMotion() + { + SetPassContext("SkyMotion", PassFlags.None); + bool drawn = base.RenderOptimumSkyMotion(); + SetPassContext("Frame", PassFlags.AllowSplit); + return drawn; + } + + /// + /// LEGACY - pass 3, the SSAO pass, its bilateral blur and the AO composite, through the lib + /// virtual that now holds the OpenGL body's inline code. Stage 1c makes it native. + /// + private void PostStepAmbientOcclusion(float[] projectMatrix) => OptimumPostAmbientOcclusion(projectMatrix); + + /// LEGACY - pass 4, the TAA resolve. Stage 1d makes it native. + private bool PostStepTaaResolve() => RenderOptimumTaaResolve(); + + /// LEGACY - pass 5, the TAA sharpen. Stage 1d makes it native. + private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene); + + /// LEGACY - pass 6, the bloom chain. Stage 1e makes it native. + private void PostStepBloom(int scene, int glow) => OptimumPostBloom(scene, glow); + + /// LEGACY - pass 7, god rays. Stage 1e makes it native. + private void PostStepGodRays(int scene, int glow) => OptimumPostGodRays(scene, glow); + + /// LEGACY - pass 8, the FXAA luma prepass or the pass-through blit into Luma. Stage 1f makes it native. + private void PostStepFxaaOrBlit(int scene) => OptimumPostLuma(scene); + + /// LEGACY - the chain's epilogue: blending back on, Primary bound again. Stage 1f makes it native. + private void PostStepFinish() => OptimumPostFinish(); + + /// LEGACY - pass 9, the final composition. Stage 1g makes it native. + private void LegacyFinalComposition() + { + SetPassContext("FinalComposition", PassFlags.None); + base.RenderFinalComposition(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + // ------------------------------------------------------------------ shared plumbing + + /// + /// The guards and + /// open their window under, minus + /// the draw-buffer mask a native pass does not use: a native pass says which slots it + /// writes, so the window is the pass's colour slots. + /// + private bool NativeMotionAttachmentWritable(FrameBufferRef primary) + { + if (OptimumMotionWriteActive) return false; + if (MotionAttachmentIndex < 0 || !TaaTargetsReady) return false; + if (!OptimumConfig.EffectiveTaa) return false; + if (!OptimumTemporal.Frame.JitterActive) return false; + // The same "Primary is the target being drawn into" invariant, so a native pass never + // writes the motion attachment from under another target. + return ReferenceEquals(CurrentFrameBuffer, primary); + } + + /// Primary's default colour set: four attachments with the SSAO G-buffer, two without. + private uint NativeWorldColorSlots() => OptimumRenderSsao ? 0b1111u : 0b11u; + + private static int NativeSlotCount(uint slots) + { + int count = 0; + for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + { + if (((slots >> i) & 1) != 0) count = i + 1; + } + return count; + } + + /// + /// A pass that keeps the viewport, the way the OpenGL body's bind-only setter does: both + /// native passes here draw into the viewport the world stage left. + /// + private bool BeginNativeKeepViewportPass(string name, int framebufferId, uint colorSlots, int[] reads) + { + Rect2D viewport = device.NativeCurrentViewport; + return device.BeginNativePass(new NativePassDescription + { + Name = name, + FramebufferId = framebufferId, + ColorSlots = colorSlots, + Reads = reads, + Flags = PassFlags.None, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + }); + } + + /// + /// The pipeline for one native pass of the chain, with the fixed state stated outright. The + /// device caches by (program, formats, blend, depth, cull, topology), so a pass whose blend + /// changes with the motion window simply gets the other pipeline, and the placements are + /// re-resolved whenever the pipeline object changes (a relink makes a new one). + /// + private NativePipeline? NativePostPipeline(NativeFullscreenPass pass, ShaderProgramBase program, + int framebufferId, uint colorSlots, AttachmentBlend[] blend, + bool depthTest, bool depthWrite, CompareOp depthCompare) + { + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, colorSlots); + if (formats == null) return null; + + NativePipeline? pipeline = device.RequestNativePipeline(new NativePipelineDescription + { + ProgramId = program.ProgramId, + PassName = pass.PassName, + Blend = blend, + DepthTest = depthTest, + DepthWrite = depthWrite, + DepthCompare = depthCompare, + Cull = CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + Targets = formats, + }, out string error); + + if (pipeline == null) + { + if (!pass.Reported) + { + pass.Reported = true; + Logger.Warning("Optimum: no native pipeline for '{0}': {1}", pass.PassName, error); + } + pass.Pipeline = null; + return null; + } + + pass.Reported = false; + if (!ReferenceEquals(pipeline, pass.Pipeline)) pass.Adopt(pipeline, formats); + return pipeline; + } + + /// A mat4 at its placement: sixteen floats, column-major, as the program declares it. + private void WriteNativeMatrix(NativePipeline pipeline, NativeUniform uniform, float[] values) => + device.WriteNative(pipeline, uniform, MemoryMarshal.AsBytes(new ReadOnlySpan(values))); +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 8def4198..898c2733 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -251,6 +251,13 @@ private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) return _targets.ScopeFormats(target, exclusion); } + /// + /// The viewport the GL-shaped state last set. A native pass that keeps the viewport - the + /// OIT merge and sky motion bind their target without touching it, as the OpenGL body's + /// bind-only setter does - states this as its own. + /// + internal Rect2D NativeCurrentViewport => _state.Viewport; + /// The manifest variant a program was linked for; "" for a program the rewriter linked. internal string NativeVariantOf(int programId) => _programVariants.TryGetValue(programId, out string? key) ? key : ""; diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index c1f277f6..6d115143 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -82,7 +82,12 @@ public void AutoIsGtaoOnVulkanWithTaaVanillaOtherwiseAndOpenGlIgnoresTheSetting( public void ThePlatformReplacesVanillaSsaoAndComposesBeforeTheResolve() { string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); - string post = Between(platform, "public override void RenderPostprocessingEffects", "public override void ClearSsaoTarget"); + // Phase 3b: the post body is one virtual per pass; the AO step is this one, and the + // chain that calls it before the resolve is RenderPostprocessingEffects. + string post = Between(platform, "public virtual void OptimumPostAmbientOcclusion", + "public virtual int OptimumPostSceneTexture"); + string chain = Between(platform, "public override void RenderPostprocessingEffects", + "public virtual void OptimumPostAmbientOcclusion"); int reset = post.IndexOf("optimumAmbientOcclusionTexture = 0;", StringComparison.Ordinal); int ask = post.IndexOf("optimumAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix);", StringComparison.Ordinal); @@ -90,11 +95,13 @@ public void ThePlatformReplacesVanillaSsaoAndComposesBeforeTheResolve() int ssao = post.IndexOf("ssao.Use();", StringComparison.Ordinal); int gtaoGuard = post.IndexOf("if (optimumAmbientOcclusionTexture != 0)", StringComparison.Ordinal); int compose = post.IndexOf("ApplyOptimumSceneSsao();", gtaoGuard, StringComparison.Ordinal); - int resolve = post.IndexOf("RenderOptimumTaaResolve();", StringComparison.Ordinal); + int aoStep = chain.IndexOf("OptimumPostAmbientOcclusion(projectMatrix);", StringComparison.Ordinal); + int resolve = chain.IndexOf("RenderOptimumTaaResolve();", StringComparison.Ordinal); Assert.True(reset >= 0 && reset < ask, "the texture is cleared before the platform is asked"); Assert.True(ask < vanillaGuard && vanillaGuard < ssao, "vanilla SSAO runs only when the platform returned nothing"); - Assert.True(ssao < gtaoGuard && gtaoGuard < compose && compose < resolve, - "the platform's AO is composed after the vanilla block and before the resolve"); + Assert.True(ssao < gtaoGuard && gtaoGuard < compose, + "the platform's AO is composed after the vanilla block"); + Assert.True(aoStep >= 0 && aoStep < resolve, "the AO step runs before the resolve"); Assert.Contains("if (RenderSSAO && projectMatrix != null)\n\t\t{\n\t\t\toptimumAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix);", post.Replace("\r\n", "\n")); diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index 6d98ffe6..accf9df5 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -45,6 +45,10 @@ public class ClientPlatformWindowsVanillaRegionsTests "OptimumTaaSharpenIndex", "OptimumTimeBeginPeriod", "OptimumTimeEndPeriod", "OptimumUndershootPercent", "OptimumWindowClientSize", "OptimumYieldThresholdMs", "ProbeThickLineSupport", + // Phase 3b: the post chain as one virtual per pass, and the keep-the-viewport bind. + "OptimumPostAmbientOcclusion", "OptimumPostSceneTexture", "OptimumPostGlowTexture", + "OptimumPostBloom", "OptimumPostGodRays", "OptimumPostLuma", "OptimumPostFinish", + "OptimumBindKeepViewport", "ReadDefaultFramebuffer", "ReadTextureForParity", "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "RestorePrimaryDrawBuffers", "RestoreWorldDrawBuffers", "SelectBackDrawBuffer", "SelectFsrDrawBuffer", "SetBlendEnabled", diff --git a/Optimum.Tests/fsr-pipeline-coverage-tests.cs b/Optimum.Tests/fsr-pipeline-coverage-tests.cs index bc1d5fbb..1d743836 100644 --- a/Optimum.Tests/fsr-pipeline-coverage-tests.cs +++ b/Optimum.Tests/fsr-pipeline-coverage-tests.cs @@ -74,7 +74,7 @@ public void TheVulkanBlitRunsNativelyWithOnePassPerWrittenTarget() { string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); Assert.Contains("public override void BlitPrimaryToDefault()", graph); - Assert.Contains("if (NativeBlitEnabled && device != null)", graph); + Assert.Contains("if (NativeBlitEnabled && UseNativePostChain)", graph); Assert.Contains("RenderNativeBlit();", graph); string native = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs"); diff --git a/Optimum.Tests/native-post-chain-coverage-tests.cs b/Optimum.Tests/native-post-chain-coverage-tests.cs new file mode 100644 index 00000000..cf07132a --- /dev/null +++ b/Optimum.Tests/native-post-chain-coverage-tests.cs @@ -0,0 +1,265 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The post and TAA chain as a chain (docs/vulkan-native-render-systems.md, stage 1): the OpenGL +/// body is one virtual per pass, Optimum's platform owns the order and never calls base for the +/// steps the chain holds, the first two passes draw natively, and every remaining step is a +/// legacy helper that names the stage which will replace it. +/// +public class NativePostChainCoverageTests +{ + private const string ChainFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs"; + + /// + /// The lib body is split into one virtual per pass, and RenderPostprocessingEffects is only + /// their order. The split is a lift, not a rewrite: each step keeps the body it had. + /// + [Fact] + public void TheOpenGlPostBodyIsOneVirtualPerPass() + { + string platform = Platform(); + + foreach (string member in new[] + { + "public virtual void OptimumPostAmbientOcclusion(float[] projectMatrix)", + "public virtual int OptimumPostSceneTexture()", + "public virtual int OptimumPostGlowTexture()", + "public virtual void OptimumPostBloom(int postSceneTexture, int postGlowTexture)", + "public virtual void OptimumPostGodRays(int postSceneTexture, int postGlowTexture)", + "public virtual void OptimumPostLuma(int postSceneTexture)", + "public virtual void OptimumPostFinish()", + "public virtual void OptimumBindKeepViewport(FrameBufferRef value)", + }) + { + Assert.Contains(member, platform); + } + + // The steps' bodies, still the OpenGL body's own code. + Assert.Contains("ShaderProgramSsao ssao = ShaderPrograms.Ssao;", platform); + Assert.Contains("ShaderProgramFindbright findbright = ShaderPrograms.Findbright;", platform); + Assert.Contains("godrays.SunPos3dIn = ShaderUniforms.LightPosition3D;", platform); + Assert.Contains("return TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0];", platform); + Assert.Contains("return TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1];", platform); + + // Every new lib member is a patcher target and an owned region. + string patcher = Read("Optimum.Patcher/Program.cs"); + string regions = Read("Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs"); + foreach (string member in new[] + { + "OptimumPostAmbientOcclusion", "OptimumPostSceneTexture", "OptimumPostGlowTexture", + "OptimumPostBloom", "OptimumPostGodRays", "OptimumPostLuma", "OptimumPostFinish", + "OptimumBindKeepViewport", + }) + { + Assert.Contains("\"" + member + "\"", patcher); + Assert.Contains("\"" + member + "\"", regions); + } + Assert.Contains("\"OptimumOitRevealTexture\"", patcher); + Assert.Contains("\"OptimumOitAccumTexture\"", patcher); + } + + /// The OpenGL body runs its steps in the order it always ran them. + [Fact] + public void TheOpenGlPostBodyKeepsItsOrder() + { + string platform = Platform(); + int start = platform.IndexOf("public override void RenderPostprocessingEffects(float[] projectMatrix)", + StringComparison.Ordinal); + Assert.True(start >= 0); + + int previous = start; + foreach (string step in new[] + { + "OptimumPostAmbientOcclusion(projectMatrix);", + "RenderOptimumTaaResolve();", + "int postSceneTexture = OptimumPostSceneTexture();", + "postSceneTexture = RenderOptimumTaaSharpen(postSceneTexture);", + "OptimumPostBloom(postSceneTexture, postGlowTexture);", + "OptimumPostGodRays(postSceneTexture, postGlowTexture);", + "OptimumPostLuma(postSceneTexture);", + "OptimumPostFinish();", + }) + { + int at = platform.IndexOf(step, previous, StringComparison.Ordinal); + Assert.True(at > previous, step + " is missing or out of order in RenderPostprocessingEffects"); + previous = at; + } + } + + /// + /// The Vulkan chain owns the order, in the doc's section 3 sequence, and its + /// RenderPostprocessingEffects override never calls base on the native route. + /// + [Fact] + public void TheVulkanChainOwnsTheOrderAndNeverCallsBase() + { + string chain = Read(ChainFile); + + int previous = chain.IndexOf("internal static readonly NativePostStep[] NativePostChainOrder", StringComparison.Ordinal); + Assert.True(previous >= 0); + foreach (string step in new[] + { + "NativePostStep.OitMerge,", "NativePostStep.SkyMotion,", + "NativePostStep.SsaoAndAmbientOcclusion,", "NativePostStep.TaaResolve,", + "NativePostStep.TaaSharpen,", "NativePostStep.Bloom,", "NativePostStep.GodRays,", + "NativePostStep.FxaaOrBlit,", "NativePostStep.FinalComposition,", "NativePostStep.Blit,", + }) + { + int at = chain.IndexOf(step, previous, StringComparison.Ordinal); + Assert.True(at > previous, step + " is missing or out of order in NativePostChainOrder"); + previous = at; + } + + // The chain's own steps, in the same order, inside the override's body. + previous = chain.IndexOf("private void RunNativePostChain(float[] projectMatrix)", StringComparison.Ordinal); + Assert.True(previous >= 0); + foreach (string step in new[] + { + "PostStepAmbientOcclusion(projectMatrix);", "PostStepTaaResolve();", + "scene = PostStepTaaSharpen(scene);", "PostStepBloom(scene, glow);", + "PostStepGodRays(scene, glow);", "PostStepFxaaOrBlit(scene);", "PostStepFinish();", + }) + { + int at = chain.IndexOf(step, previous, StringComparison.Ordinal); + Assert.True(at > previous, step + " is missing or out of order in the native chain"); + previous = at; + } + + string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); + Assert.Contains("if (UseNativePostChain)\n {\n RunNativePostChain(projectMatrix);\n return;\n }", graph.Replace("\r\n", "\n")); + Assert.Contains("NativeOitMerge();", graph); + Assert.Contains("return UseNativePostChain ? NativeSkyMotion() : LegacySkyMotion();", graph); + Assert.Contains("LegacyFinalComposition();", graph); + + // The test switch that puts the whole chain back on the OpenGL body. + Assert.Contains("internal bool NativePostChainEnabled { get; set; } = true;", chain); + Assert.Contains("private bool UseNativePostChain => NativePostChainEnabled && device != null;", chain); + Assert.Contains("if (NativeBlitEnabled && UseNativePostChain)", graph); + } + + /// + /// The two native passes draw through the device API - a pipeline with fixed state, a + /// declared pass with explicit reads and colour slots, and a native fullscreen draw - and + /// keep the OpenGL body's conditions, blend and uniform values. + /// + [Fact] + public void TheFirstTwoPassesDrawNatively() + { + string chain = Read(ChainFile); + + Assert.Contains("device.RequestNativePipeline(new NativePipelineDescription", chain); + Assert.Contains("device.BeginNativePass(new NativePassDescription", chain); + Assert.Contains("device.DrawNativeFullscreen(pipeline, new[]", chain); + Assert.Contains("device.EndNativePass();", chain); + + // The merge: the Transparent target's three attachments plus the OIT pair, the world + // colour set with the motion attachment added while the window is open, and the + // additive (ONE, ONE) blend on that attachment alone. + Assert.Contains("nativeOitMerge = new(\"transparentcompose\"", chain); + Assert.Contains("\"accumulation\", \"revealage\", \"inGlow\", \"OITreveal\", \"OITaccumulation\"", chain); + Assert.Contains("SystemRenderOITLayers.OptimumOitRevealTexture", chain); + Assert.Contains("SystemRenderOITLayers.OptimumOitAccumTexture", chain); + Assert.Contains("if (motion) slots |= 1u << MotionAttachmentIndex;", chain); + Assert.Contains("attachment.SrcColor = BlendFactor.One;", chain); + Assert.Contains("private uint NativeWorldColorSlots() => OptimumRenderSsao ? 0b1111u : 0b11u;", chain); + Assert.Contains("OptimumBindKeepViewport(primary);", chain); + Assert.Contains("ApplyTransparentMergeBlendState();", chain); + + // Sky motion: the same guards, the same two matrices, LEQUAL with depth writes off, and + // the motion attachment as the pass's only colour slot. + Assert.Contains("nativeSkyMotion = new(\"taa-skymotion\"", chain); + Assert.Contains("if (!frame.WasViewCaptured(EnumTemporalView.World)) return false;", chain); + Assert.Contains("OptimumTemporalMath.ApplyProjectionJitter(jittered, frame.JitterPx.X, frame.JitterPx.Y,", chain); + Assert.Contains("float[] invViewProj = Mat4f.Invert(new float[16], viewProj);", chain); + Assert.Contains("if (invViewProj == null) return false;", chain); + Assert.Contains("uint slots = 1u << MotionAttachmentIndex;", chain); + Assert.Contains("depthTest: true, depthWrite: false, CompareOp.LessOrEqual", chain); + Assert.Contains("device.WriteNative(pipeline, nativeSkyMotion.Uniforms[4], OptimumCloudReactive);", chain); + Assert.Contains("GlDepthFunc(EnumDepthFunction.Less);", chain); + Assert.Contains("GlEnableCullFace();", chain); + + // The motion window's guards, minus the draw-buffer mask a native pass does not use. + Assert.Contains("private bool NativeMotionAttachmentWritable(FrameBufferRef primary)", chain); + Assert.Contains("if (!OptimumTemporal.Frame.JitterActive) return false;", chain); + Assert.Contains("return ReferenceEquals(CurrentFrameBuffer, primary);", chain); + } + + /// + /// Every step that is not native yet has a legacy helper naming the stage that replaces it, + /// so the chain is complete at every commit and a later stage moves exactly one helper. + /// + [Fact] + public void EveryRemainingStepHasALegacyHelperNamingItsStage() + { + string chain = Read(ChainFile); + + foreach (string helper in new[] + { + "private void PostStepAmbientOcclusion(float[] projectMatrix) => OptimumPostAmbientOcclusion(projectMatrix);", + "private bool PostStepTaaResolve() => RenderOptimumTaaResolve();", + "private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene);", + "private void PostStepBloom(int scene, int glow) => OptimumPostBloom(scene, glow);", + "private void PostStepGodRays(int scene, int glow) => OptimumPostGodRays(scene, glow);", + "private void PostStepFxaaOrBlit(int scene) => OptimumPostLuma(scene);", + "private void PostStepFinish() => OptimumPostFinish();", + "private void LegacyFinalComposition()", + "private void LegacyOitMerge()", + "private bool LegacySkyMotion()", + }) + { + Assert.Contains(helper, chain); + } + + foreach (string stage in new[] { "Stage 1c makes it native", "Stage 1d makes it native", + "Stage 1e makes it native", "Stage 1f makes it native", "Stage 1g makes it native" }) + { + Assert.Contains(stage, chain); + } + // One per remaining pass: the seven steps above plus the merge, sky motion and the + // final composition, whose old routes stay reachable through the chain switch. + Assert.Equal(10, Count(chain, "LEGACY -")); + } + + private static string Platform() => ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + private static int Count(string text, string value) + { + int count = 0; + int offset = 0; + while ((offset = text.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/Optimum.Tests/scene-ssao-coverage-tests.cs b/Optimum.Tests/scene-ssao-coverage-tests.cs index 24354b20..9dc8ab96 100644 --- a/Optimum.Tests/scene-ssao-coverage-tests.cs +++ b/Optimum.Tests/scene-ssao-coverage-tests.cs @@ -16,17 +16,21 @@ public class SceneSsaoCoverageTests public void JitteredAoIsComposedBeforeTheResolveAndIsNotAppliedTwice() { string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); - int start = platform.IndexOf("public override void RenderPostprocessingEffects", StringComparison.Ordinal); + // Phase 3b: the AO step is its own virtual, and the chain calls it before the resolve. + int start = platform.IndexOf("public virtual void OptimumPostAmbientOcclusion", StringComparison.Ordinal); Assert.True(start > 0); - string post = platform[start..platform.IndexOf("public override void ClearSsaoTarget", start, StringComparison.Ordinal)]; + string post = platform[start..platform.IndexOf("public virtual int OptimumPostSceneTexture", start, StringComparison.Ordinal)]; + int chainStart = platform.IndexOf("public override void RenderPostprocessingEffects", StringComparison.Ordinal); + string chain = platform[chainStart..start]; int reset = post.IndexOf("optimumSsaoInScene = false;", StringComparison.Ordinal); int ssao = post.IndexOf("ssao.Use();", StringComparison.Ordinal); int apply = post.IndexOf("ApplyOptimumSceneSsao();", StringComparison.Ordinal); - int resolve = post.IndexOf("RenderOptimumTaaResolve();", StringComparison.Ordinal); + int aoStep = chain.IndexOf("OptimumPostAmbientOcclusion(projectMatrix);", StringComparison.Ordinal); + int resolve = chain.IndexOf("RenderOptimumTaaResolve();", StringComparison.Ordinal); Assert.True(reset >= 0 && reset < ssao, "the flag is cleared before the SSAO pass"); Assert.True(ssao < apply, "the AO is computed before it is composed"); - Assert.True(apply < resolve, "the AO is composed before the resolve"); + Assert.True(aoStep >= 0 && aoStep < resolve, "the AO is composed before the resolve"); Assert.Equal(1, Count(post, "ssao.Use();")); Assert.Contains("if (OptimumTaaRequested && TaaTargetsReady)", post); diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index 8503ac03..b27a3ca7 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -170,19 +170,26 @@ public void RenderPostprocessingEffectsResolvesTaaBeforeBloomAndReadsTheResolved // postSceneTexture/postGlowTexture are derived from the resolve result // right after the call, before the bloom block reads them. + // Phase 3b: the choice moved into its own virtual, which the chain calls right after + // the resolve and before the bloom step reads it. + Assert.Contains( + "return TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0];", + platform); + Assert.Contains( + "return TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1];", + platform); int postSceneDecl = platform.IndexOf( - "int postSceneTexture = TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0];", - resolveCall, - StringComparison.Ordinal); + "int postSceneTexture = OptimumPostSceneTexture();", resolveCall, StringComparison.Ordinal); int postGlowDecl = platform.IndexOf( - "int postGlowTexture = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1];", - resolveCall, - StringComparison.Ordinal); + "int postGlowTexture = OptimumPostGlowTexture();", resolveCall, StringComparison.Ordinal); Assert.True(postSceneDecl > resolveCall); Assert.True(postGlowDecl > postSceneDecl); - int bloomBlock = platform.IndexOf("if (RenderBloom)", postGlowDecl, StringComparison.Ordinal); - Assert.True(bloomBlock > postGlowDecl); + int bloomStep = platform.IndexOf( + "OptimumPostBloom(postSceneTexture, postGlowTexture);", postGlowDecl, StringComparison.Ordinal); + Assert.True(bloomStep > postGlowDecl); + int bloomBlock = platform.IndexOf("if (RenderBloom)", bloomStep, StringComparison.Ordinal); + Assert.True(bloomBlock > bloomStep); // Bloom's findbright pass reads the resolved colour+glow, not the raw // primary attachments. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index e15fdaa0..d56abc10 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..812ddd3 100644 +index 6edf0c9..c402ea4 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -303,7 +303,7 @@ index 6edf0c9..812ddd3 100644 get { return serverRunning; -@@ -278,34 +509,54 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,34 +509,65 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -360,13 +360,24 @@ index 6edf0c9..812ddd3 100644 + { + GL.BindFramebuffer((FramebufferTarget)36160, value?.FboId ?? 0); + } ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): binds a target the way the private ++ /// CurrentFrameBufferKeepVw setter does - bind only, the viewport stays - for a native ++ /// render system that owns its own pass but has to leave the GL-shaped bound target ++ /// exactly where the OpenGL body leaves it for the stages that follow. ++ /// ++ public virtual void OptimumBindKeepViewport(FrameBufferRef value) ++ { ++ CurrentFrameBufferKeepVw = value; ++ } + public override bool GlErrorChecking { get; set; } public override bool GlDebugMode { get -@@ -379,10 +630,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -379,10 +641,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public void StartAudio() { if (audio == null) @@ -386,7 +397,7 @@ index 6edf0c9..812ddd3 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +738,154 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +749,154 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -548,7 +559,7 @@ index 6edf0c9..812ddd3 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +905,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +916,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -561,7 +572,7 @@ index 6edf0c9..812ddd3 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1076,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1087,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -606,7 +617,7 @@ index 6edf0c9..812ddd3 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1188,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1199,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -626,7 +637,7 @@ index 6edf0c9..812ddd3 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1424,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1435,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -639,7 +650,7 @@ index 6edf0c9..812ddd3 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,146 +1551,943 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,143 +1562,940 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -877,9 +888,6 @@ index 6edf0c9..812ddd3 100644 - GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); - GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); - DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; -- GL.DrawBuffers(3, array5); -- ClearFrameBuffer(EnumFrameBuffer.Transparent); -- CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); + return; + } + if (Vintagestory.API.Config.OptimumParityDump.Enabled && !optimumParityDumpDone) @@ -1695,15 +1703,12 @@ index 6edf0c9..812ddd3 100644 + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); + DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; -+ GL.DrawBuffers(3, array5); -+ ClearFrameBuffer(EnumFrameBuffer.Transparent); -+ CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); + GL.DrawBuffers(3, array5); + ClearFrameBuffer(EnumFrameBuffer.Transparent); + CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); if (SetupSSAO) { - _ = ClientSettings.SSAOQuality; - float num3 = 0.5f; - FrameBufferRef obj = new FrameBufferRef -@@ -1436,10 +2634,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2645,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1779,7 +1784,7 @@ index 6edf0c9..812ddd3 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2811,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2822,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1794,7 +1799,7 @@ index 6edf0c9..812ddd3 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2834,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2845,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1889,7 +1894,7 @@ index 6edf0c9..812ddd3 100644 } } } -@@ -1591,11 +2928,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2939,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1908,7 +1913,7 @@ index 6edf0c9..812ddd3 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2963,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2974,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -1995,7 +2000,7 @@ index 6edf0c9..812ddd3 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3072,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3083,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2086,7 +2091,7 @@ index 6edf0c9..812ddd3 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3157,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3168,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2172,7 +2177,7 @@ index 6edf0c9..812ddd3 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,22 +3239,256 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,50 +3250,403 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2428,26 +2433,46 @@ index 6edf0c9..812ddd3 100644 + GlEnableDepthTest(); + LoadFrameBuffer(EnumFrameBuffer.Primary); + return target.ColorTextureIds[0]; - } - - public override void RenderPostprocessingEffects(float[] projectMatrix) - { - //IL_000f: Unknown result type (might be due to invalid IL or missing references) -@@ -1823,20 +3503,109 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - //IL_05c9: Unknown result type (might be due to invalid IL or missing references) - if (!OffscreenBuffer) - { - return; - } -- int x = ((NativeWindow)window).ClientSize.X; -- int y = ((NativeWindow)window).ClientSize.Y; ++ } ++ ++ public override void RenderPostprocessingEffects(float[] projectMatrix) ++ { + // Mono.Cecil transplant. -+ // The pass structure is API-neutral - it is framebuffer selection, a -+ // fullscreen triangle and uniforms, all of which are platform virtuals. -+ // The SSAO clear and the final blend enable are too (ClearSsaoTarget, -+ // SetBlendEnabled). -+ int x = ((NativeWindow)window).ClientSize.X; -+ int y = ((NativeWindow)window).ClientSize.Y; ++ // Optimum (Vulkan-native render systems, Phase 3b): the post chain's ORDER, and ++ // nothing else. Every step below is a virtual of its own, with the body this ++ // method always had - the steps were lifted out, not re-derived - so a platform ++ // that draws one of them natively (VulkanClientPlatform's NativePostChain) ++ // replaces that one step and inherits the rest, and the OpenGL path runs the ++ // identical sequence it ran before the split. ++ if (!OffscreenBuffer) ++ { ++ return; ++ } ++ OptimumPostAmbientOcclusion(projectMatrix); ++ // Optimum TAA: resolve first, so bloom, god rays and the final input read ++ // the temporally stable image instead of the jittered one. ++ RenderOptimumTaaResolve(); ++ int postSceneTexture = OptimumPostSceneTexture(); ++ int postGlowTexture = OptimumPostGlowTexture(); ++ // Optimum TAA (P5): sharpen the resolved colour once, before anything ++ // reads it, so bloom, god rays and the final composition all see the ++ // same image. Returns its input unchanged when the pass does not run. ++ postSceneTexture = RenderOptimumTaaSharpen(postSceneTexture); ++ OptimumPostBloom(postSceneTexture, postGlowTexture); ++ OptimumPostGodRays(postSceneTexture, postGlowTexture); ++ OptimumPostLuma(postSceneTexture); ++ OptimumPostFinish(); ++ } ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): the post chain's ambient-occlusion ++ /// step - the platform's own AO where it has one, otherwise the vanilla SSAO pass and its ++ /// bilateral blur, then the multiply into the scene before the TAA resolve reads it. The ++ /// body is the one held inline; the two fields ++ /// it resets are this step's own state. ++ /// ++ public virtual void OptimumPostAmbientOcclusion(float[] projectMatrix) ++ { + // Optimum TAA: AO is derived from the jittered G-buffer, so it is computed + // before the resolve and multiplied into the scene the resolve accumulates. + // Applied where vanilla applies it - in Final, after the resolve - the AO @@ -2464,6 +2489,8 @@ index 6edf0c9..812ddd3 100644 + } + if (optimumAmbientOcclusionTexture == 0 && RenderSSAO && projectMatrix != null) + { ++ int x = ((NativeWindow)window).ClientSize.X; ++ int y = ((NativeWindow)window).ClientSize.Y; + GlToggleBlend(on: false); + LoadFrameBuffer(EnumFrameBuffer.SSAO); + ClearSsaoTarget(); @@ -2523,17 +2550,47 @@ index 6edf0c9..812ddd3 100644 + { + ApplyOptimumSceneSsao(); + } -+ // Optimum TAA: resolve first, so bloom, god rays and the final input read -+ // the temporally stable image instead of the jittered one. -+ RenderOptimumTaaResolve(); -+ int postSceneTexture = TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0]; -+ int postGlowTexture = TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1]; -+ // Optimum TAA (P5): sharpen the resolved colour once, before anything -+ // reads it, so bloom, god rays and the final composition all see the -+ // same image. Returns its input unchanged when the pass does not run. -+ postSceneTexture = RenderOptimumTaaSharpen(postSceneTexture); + } + +- public override void RenderPostprocessingEffects(float[] projectMatrix) ++ /// ++ /// Optimum (Phase 3b): the scene texture the rest of the chain reads - the TAA resolve's ++ /// output where it ran this frame, the jittered Primary colour otherwise. ++ /// ++ public virtual int OptimumPostSceneTexture() ++ { ++ return TaaResolvedThisFrame ? taaResolvedColorTexture : frameBuffers[0].ColorTextureIds[0]; ++ } ++ ++ /// Optimum (Phase 3b): the glow texture the rest of the chain reads. Never sharpened. ++ public virtual int OptimumPostGlowTexture() ++ { ++ return TaaResolvedThisFrame ? taaResolvedGlowTexture : frameBuffers[0].ColorTextureIds[1]; ++ } ++ ++ /// Optimum (Phase 3b): the post chain's bloom step - find-bright and the two blur ping-pongs. ++ public virtual void OptimumPostBloom(int postSceneTexture, int postGlowTexture) + { +- //IL_000f: Unknown result type (might be due to invalid IL or missing references) +- //IL_0020: Unknown result type (might be due to invalid IL or missing references) +- //IL_0189: Unknown result type (might be due to invalid IL or missing references) +- //IL_01a8: Unknown result type (might be due to invalid IL or missing references) +- //IL_023b: Unknown result type (might be due to invalid IL or missing references) +- //IL_0254: Unknown result type (might be due to invalid IL or missing references) +- //IL_035c: Unknown result type (might be due to invalid IL or missing references) +- //IL_0375: Unknown result type (might be due to invalid IL or missing references) +- //IL_05b0: Unknown result type (might be due to invalid IL or missing references) +- //IL_05c9: Unknown result type (might be due to invalid IL or missing references) +- if (!OffscreenBuffer) +- { +- return; +- } +- int x = ((NativeWindow)window).ClientSize.X; +- int y = ((NativeWindow)window).ClientSize.Y; if (RenderBloom) { ++ int x = ((NativeWindow)window).ClientSize.X; ++ int y = ((NativeWindow)window).ClientSize.Y; GlToggleBlend(on: false); LoadFrameBuffer(EnumFrameBuffer.FindBright); ShaderProgramFindbright findbright = ShaderPrograms.Findbright; @@ -2547,7 +2604,7 @@ index 6edf0c9..812ddd3 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3617,131 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3658,151 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2570,8 +2627,15 @@ index 6edf0c9..812ddd3 100644 + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); GlToggleBlend(on: true); } ++ } ++ ++ /// Optimum (Phase 3b): the post chain's god-rays step. ++ public virtual void OptimumPostGodRays(int postSceneTexture, int postGlowTexture) ++ { if (RenderGodRays) { ++ int x = ((NativeWindow)window).ClientSize.X; ++ int y = ((NativeWindow)window).ClientSize.Y; LoadFrameBuffer(EnumFrameBuffer.GodRays); ShaderProgramGodrays godrays = ShaderPrograms.Godrays; godrays.Use(); @@ -2631,6 +2695,14 @@ index 6edf0c9..812ddd3 100644 + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); } - if (RenderFXAA) ++ } ++ ++ /// ++ /// Optimum (Phase 3b): the post chain's Luma step - the FXAA luma prepass over the raw ++ /// jittered scene, or the pass-through blit of the chain's scene when the resolve ran. ++ /// ++ public virtual void OptimumPostLuma(int postSceneTexture) ++ { + if (RenderFXAA && !TaaResolvedThisFrame) { LoadFrameBuffer(EnumFrameBuffer.Luma); @@ -2654,6 +2726,11 @@ index 6edf0c9..812ddd3 100644 blit.Stop(); } - GL.Enable((EnableCap)3042); ++ } ++ ++ /// Optimum (Phase 3b): the post chain's epilogue - blending back on, Primary bound again. ++ public virtual void OptimumPostFinish() ++ { + // Re-enabling blend only; the mode is whatever the last GlToggleBlend + // left, which is what glEnable(GL_BLEND) does here too. + SetBlendEnabled(true); @@ -2726,7 +2803,7 @@ index 6edf0c9..812ddd3 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3751,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3812,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2771,7 +2848,7 @@ index 6edf0c9..812ddd3 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3799,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3860,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2828,7 +2905,7 @@ index 6edf0c9..812ddd3 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3854,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3915,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3218,7 +3295,7 @@ index 6edf0c9..812ddd3 100644 + /// needs. Partial coverage interpolates towards it from the coverage itself + /// (see taa-skymotion.fsh), so a clear pixel still gets 0. + /// -+ internal const float OptimumCloudReactive = 1f; ++ public const float OptimumCloudReactive = 1f; + + /// + /// Optimum (Vulkan-native render systems, Phase 3b): the window's client size, which the @@ -3326,7 +3403,7 @@ index 6edf0c9..812ddd3 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4502,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4563,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3366,7 +3443,7 @@ index 6edf0c9..812ddd3 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4900,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4961,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3419,7 +3496,7 @@ index 6edf0c9..812ddd3 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +4995,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5056,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3464,7 +3541,7 @@ index 6edf0c9..812ddd3 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5032,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5093,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3485,7 +3562,7 @@ index 6edf0c9..812ddd3 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5051,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3506,7 +3583,7 @@ index 6edf0c9..812ddd3 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5070,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5131,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3527,7 +3604,7 @@ index 6edf0c9..812ddd3 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5089,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5150,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3548,7 +3625,7 @@ index 6edf0c9..812ddd3 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5173,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3569,7 +3646,7 @@ index 6edf0c9..812ddd3 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5674,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5735,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3593,7 +3670,7 @@ index 6edf0c9..812ddd3 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6033,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6094,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch index 005c37c4..7fa1ae6d 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs -index bba43f6..cda5b34 100644 +index bba43f6..18d1f36 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs @@ -18,85 +18,107 @@ public class SystemRenderOITLayers : ClientSystem @@ -189,7 +189,7 @@ index bba43f6..cda5b34 100644 public void Dispose() { } -@@ -122,19 +150,47 @@ public class SystemRenderOITLayers : ClientSystem +@@ -122,19 +150,58 @@ public class SystemRenderOITLayers : ClientSystem private static int accumTextureId; @@ -197,6 +197,17 @@ index bba43f6..cda5b34 100644 + private static bool optimumOitDisabled; + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): the OIT reveal target the merge ++ /// samples, for a native render system that binds textures by handle instead of by unit. ++ /// 0 when the OIT renderer is off or its targets are gone, which is the placeholder the ++ /// unit tables would have held. ++ /// ++ public static int OptimumOitRevealTexture => (optimumOitDisabled ? 0 : revealTextureId); ++ ++ /// Optimum (Phase 3b): the OIT accumulation array, same contract. ++ public static int OptimumOitAccumTexture => (optimumOitDisabled ? 0 : accumTextureId); ++ + private static bool optimumOitFailureLogged; + + private static void RestoreVanillaTransparentState() From 0381d4500b4ab0eb997e1f1b64c4f0216199b3e4 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 10:30:52 +0200 Subject: [PATCH 188/226] wip(native-post): the bloom chain, god rays, the Luma step and the final composition native Phase 3b stage 1e-1g (docs/vulkan-native-render-systems.md, section 3): four more of the post chain's nine passes draw through the native device API instead of the GL-shaped platform calls, leaving only the AO step and the TAA resolve/sharpen on the OpenGL body. VulkanClientPlatform.NativePostFinal.cs holds them, shaped exactly like the native blit: a pipeline per pass with its fixed state stated outright (per-attachment blend, depth test/write/compare, cull, topology, target formats), a pass declared with its explicit reads and colour slots, uniforms written by placement and sampled textures resolved straight to bindless slots. Bloom is five passes - find-bright plus the blur ladder - and reproduces the OpenGL body's single full-resolution frameSize, reused unchanged for the half- and quarter-resolution draws. God rays keeps LightPosition3D behind sunPos3dIn while the composition uses SunPosition3D, as the body does. The Luma step keeps the branch on RenderFXAA && !TaaResolvedThisFrame, where the FXAA prepass reads the raw jittered Primary colour and bypasses the resolved chain. The final composition is the attachment-subset pass: it declares every bound Primary slot except colour 1, so the glow it samples leaves the pass's scope and moves to the shader-read layout and back - one barrier each way, no feedback copy - while it writes colour 0. Every GL-shaped state call the body makes around these passes stays, outside the native passes, because it is what the rest of the frame inherits. Lib: six accessors (OptimumRenderBloom, OptimumRenderGodRays, OptimumRenderFxaa, OptimumSsaaLevel, OptimumAmbientOcclusionTexture, OptimumSsaoInScene) so a native pass reads client state and never GL state, and OptimumPostBloom, OptimumPostGodRays and RenderFinalComposition take the window size from the OptimumWindowClientSize seam the blit already reads it through - the same value on the OpenGL path, which stays vanilla. Every new member is listed in Optimum.Patcher/Program.cs and in the vanilla-regions test. Verified: dotnet build VintageStory.slnx -c Release (0 errors); dotnet test Optimum.Tests -c Release (1237 passed); dotnet test Optimum.Render.Vulkan.Tests (1051 passed, sync+best validation clean) with the implicit-layer disable set and vulkaninfo showing only VK_LAYER_MESA_device_select; extract-patches + check-patches (157 patches, 0 conflict). New GPU tests in NativePostChainTests: a twelve-row differential that runs the whole chain and the composition on both routes from identically seeded targets and compares the find-bright image, the low-resolution bloom result, the god-ray target, the Luma target, Primary colour 0 and the motion attachment bitwise, across bloom, god rays, FXAA, vanilla SSAO, TAA, the AO debug view, the GTAO AO mode and a render scale of 0.5; plus a test that the composition's read of Primary colour 1 takes no feedback copy, draws one native pass and leaves the glow attachment untouched. Source coverage in Optimum.Tests (native-post-chain) pins the native tail, the lib accessors and the window-size seam. --- Optimum.Patcher/Program.cs | 9 + .../NativePostChainTests.cs | 394 ++++++++++++++- .../Platform/VulkanClientPlatform.Graph.cs | 10 +- .../VulkanClientPlatform.NativePostChain.cs | 35 +- .../VulkanClientPlatform.NativePostFinal.cs | 469 ++++++++++++++++++ ...-platform-windows-vanilla-regions-tests.cs | 4 + .../native-post-chain-coverage-tests.cs | 127 ++++- .../ClientPlatformWindows.cs.patch | 139 ++++-- 8 files changed, 1089 insertions(+), 98 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostFinal.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 8e29fe5a..d548c9af 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -255,6 +255,15 @@ "OptimumPostLuma", "OptimumPostFinish", "OptimumBindKeepViewport", + // Phase 3b stage 1e-1g: the per-frame switches, the render scale and the two AO + // fields a native bloom, god-rays, Luma and final-composition pass reads as client + // state instead of GL state (decision 3). + "OptimumRenderBloom", + "OptimumRenderGodRays", + "OptimumRenderFxaa", + "OptimumSsaaLevel", + "OptimumAmbientOcclusionTexture", + "OptimumSsaoInScene", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs index a5c98761..66bd3b75 100644 --- a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -38,7 +38,11 @@ public class NativePostChainTests(ITestOutputHelper output) { private const int Size = 16; - private static readonly string[] Programs = { "transparentcompose", "taa-skymotion", "taa-resolve", "blit" }; + private static readonly string[] Programs = + { + "transparentcompose", "taa-skymotion", "taa-resolve", "blit", + "findbright", "blur", "godrays", "luma", "final", + }; /// The Vulkan platform without a window: the size seam answers for one. private sealed class ChainPlatform : VulkanClientPlatform @@ -47,12 +51,28 @@ public ChainPlatform() : base(null!) { } - public override Size2i OptimumWindowClientSize() => new(NativePostChainTests.Size, NativePostChainTests.Size); + /// + /// The window the size seam answers with. Render scale below 1 is this size divided by + /// the render targets' size, exactly as the client computes it: client 32 at ssaa 0.5 + /// gives the same 16-pixel targets as client 16 at ssaa 1. + /// + public Size2i ClientSize { get; set; } = new(NativePostChainTests.Size, NativePostChainTests.Size); + + /// + /// The god-rays pass takes its time uniform from here. Pinned so the two routes of a + /// differential run cannot be handed different values by the wall clock. + /// + public override long EllapsedMs => 4242; + + public override Size2i OptimumWindowClientSize() => ClientSize; /// - /// No window is opened here, and the base's Primary case sizes its viewport from - /// NativeWindow.ClientSize, which is GLFW-backed. Primary is the render resolution, so - /// the full CurrentFrameBuffer setter is the same bind and the same viewport. + /// No window is opened here, and the base's cases size their viewports from + /// NativeWindow.ClientSize, which is GLFW-backed. Every post target in this fixture is + /// built at exactly the size its case computes - Primary and Luma at the render + /// resolution, the bloom pair at half and at a quarter of it, god rays at half - so + /// binding the target and taking the viewport from it is the same bind and the same + /// viewport, for the OpenGL route and the native one alike. /// public override void LoadFrameBuffer(EnumFrameBuffer framebuffer) { @@ -61,6 +81,16 @@ public override void LoadFrameBuffer(EnumFrameBuffer framebuffer) CurrentFrameBuffer = FrameBuffers[0]; return; } + switch (framebuffer) + { + case EnumFrameBuffer.BlurHorizontalMedRes: + case EnumFrameBuffer.BlurVerticalMedRes: + case EnumFrameBuffer.BlurHorizontalLowRes: + case EnumFrameBuffer.BlurVerticalLowRes: + case EnumFrameBuffer.GodRays: + CurrentFrameBuffer = FrameBuffers[(int)framebuffer]; + return; + } base.LoadFrameBuffer(framebuffer); } } @@ -245,10 +275,165 @@ public void TheChainKeepsItsOrderAcrossFramesAndTaaKeepsAccumulating() GpuTest.AssertClean(seam); } + // ------------------------------------------------- the chain's tail, both routes + + /// + /// The settings that change the chain's tail. Each row is one run of the whole chain on both + /// routes: bloom, god rays, FXAA, vanilla SSAO, TAA, the render scale (client size over target + /// size), the AO debug view and which AO texture the frame produced. + /// + public static TheoryData TailSettings() + { + var data = new TheoryData(); + // name bloom rays fxaa ssao taa ssaa client debug gtao + data.Add("everything-off", false, false, false, false, false, 1f, Size, false, false); + data.Add("bloom", true, false, false, false, false, 1f, Size, false, false); + data.Add("god-rays", false, true, false, false, false, 1f, Size, false, false); + data.Add("fxaa", false, false, true, false, false, 1f, Size, false, false); + data.Add("bloom-rays-fxaa", true, true, true, false, false, 1f, Size, false, false); + data.Add("ssao", true, true, false, true, false, 1f, Size, false, false); + data.Add("ssao-debug-view", false, false, false, true, false, 1f, Size, true, false); + data.Add("ssao-gtao", false, false, false, true, false, 1f, Size, false, true); + data.Add("ssao-gtao-debug", false, false, false, true, false, 1f, Size, true, true); + data.Add("taa", true, true, true, false, true, 1f, Size, false, false); + data.Add("taa-ssao", true, true, true, true, true, 1f, Size, false, false); + data.Add("render-scale-half", true, true, true, true, false, 0.5f, Size * 2, false, false); + return data; + } + + /// + /// Behavioural identity (decision 6) for the four passes this stage made native: the bloom + /// chain, god rays, the Luma step and the final composition. The whole chain runs twice from + /// identically seeded targets - once on the OpenGL body, once natively - and every target the + /// tail writes has to come out the same, bitwise: the find-bright image, the low-resolution + /// bloom result the composition reads, the god-ray target, the Luma target and Primary colour + /// 0. The motion attachment is checked too, because the composition keeps it in its scope + /// while writing colour 0 and must not touch it. + /// + [SkippableTheory] + [MemberData(nameof(TailSettings))] + public void TheChainTailMatchesTheOpenGlBodyAcrossThePostSettings(string name, bool bloom, bool godRays, + bool fxaa, bool ssao, bool taa, float ssaa, int clientSize, bool debugView, bool gtao) + { + using Session session = Open(); + if (taa) session.EnableTaa(jitterActive: true); + else OptimumConfig.Taa = false; + OptimumConfig.AmbientOcclusionDebugView = debugView; + session.ApplyPostSettings(bloom, godRays, fxaa, ssao, ssaa, clientSize); + + int aoTexture = gtao ? session.SsaoBlurTexture : 0; + TailFrame emulated = RunTail(session, native: false, aoInScene: gtao, aoTexture: aoTexture); + + long passesBefore = session.Seam.NativePassesForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + long copiesBefore = session.Seam.ReadSelfCopiesForTests.Created; + TailFrame nativeRoute = RunTail(session, native: true, aoInScene: gtao, aoTexture: aoTexture); + + // The Luma step and the final composition always draw; bloom adds five passes and god + // rays one. No native pass reached the emulation layer, and the composition's self-read + // took no feedback copy - the declared attachment subset is what makes it safe. + long expectedPasses = 2 + (bloom ? 5 : 0) + (godRays ? 1 : 0); + Assert.Equal(expectedPasses, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + Assert.Equal(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created); + + Assert.Equal(emulated.FindBright, nativeRoute.FindBright); + Assert.Equal(emulated.BloomLow, nativeRoute.BloomLow); + Assert.Equal(emulated.GodRays, nativeRoute.GodRays); + Assert.Equal(emulated.Luma, nativeRoute.Luma); + Assert.Equal(emulated.Final, nativeRoute.Final); + Assert.Equal(emulated.Motion, nativeRoute.Motion); + + // The composition really wrote something, or "the two routes agree" would be vacuous. + Assert.NotEqual(session.SceneSeed, nativeRoute.Final); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The final composition samples Primary colour 1 as the glow on a frame with no TAA resolve, + /// while it writes Primary colour 0. The pass declares the attachment subset, so colour 1 is + /// moved to the shader-read layout for the pass and back afterwards - the frame takes no + /// feedback copy - and the glow attachment itself comes out of the pass unchanged. + /// + [SkippableFact] + public void TheFinalCompositionReadsPrimaryColourOneWithoutAFeedbackCopy() + { + using Session session = Open(); + OptimumConfig.Taa = false; + OptimumConfig.AmbientOcclusionDebugView = false; + session.ApplyPostSettings(bloom: false, godRays: false, fxaa: false, ssao: false, ssaa: 1f, + clientSize: Size); + + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = true; + session.ResetTaaHistory(); + + long copiesBefore = session.Seam.ReadSelfCopiesForTests.Created; + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + Assert.False(platform.TaaResolvedThisFrame); + + platform.RenderFinalComposition(); + + byte[] glow = session.ReadGlow(); + byte[] scene = session.ReadScene(); + platform.EndFrame(); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + Assert.Equal(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created); + Assert.NotEqual(session.SceneSeed, scene); + Assert.Equal(session.GlowSeed, glow); + + GpuTest.AssertClean(session.Seam); + } + // ---------------------------------------------------------------------- driving private readonly record struct Frame(byte[] Scene, byte[] Glow, byte[] Motion); + /// Everything the chain's tail writes, on one route. + private readonly record struct TailFrame(byte[] FindBright, byte[] BloomLow, byte[] GodRays, + byte[] Luma, byte[] Final, byte[] Motion); + + /// + /// One whole post chain plus the final composition, on the route under test, from identically + /// seeded targets. The two AO fields are set between the two calls because the chain's first + /// step - the AO step, which is still the OpenGL body on both routes - resets them, exactly as + /// a real frame's GTAO pass would then fill them in. + /// + private TailFrame RunTail(Session session, bool native, bool aoInScene, int aoTexture) + { + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = native; + session.ResetTaaHistory(); + + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + + platform.RenderPostprocessingEffects(null); + session.ApplyAmbientOcclusionState(aoInScene, aoTexture); + platform.RenderFinalComposition(); + + var frame = new TailFrame( + session.ReadPostTarget(4), + session.ReadPostTarget(8), + session.ReadPostTarget(7), + session.ReadPostTarget(10), + session.ReadScene(), + session.ReadMotion()); + platform.EndFrame(); + return frame; + } + /// One OIT merge, on the route under test, from an identically seeded frame. private Frame RunMerge(Session session, bool native) { @@ -328,20 +513,30 @@ private sealed class Session : IDisposable /// The shaded image's seed, as read back. public byte[] SceneSeed { get; private set; } = Array.Empty(); + /// The glow attachment's seed, decoded the way decodes it. + public byte[] GlowSeed { get; private set; } = Array.Empty(); + private readonly List buffers = new(); private int oitReveal; private int oitAccumulation; private int decodeProgram; private int decodeTarget; private int decodeFramebuffer; + private readonly Dictionary<(int Width, int Height), int> decodeFramebuffers = new(); private ClientPlatformAbstract? previousPlatform; private string dataPath = ""; private ShaderProgramTransparentcompose? composeBefore; private ShaderProgram? skyMotionBefore; private ShaderProgram? resolveBefore; private ShaderProgramBlit? blitBefore; + private ShaderProgramFindbright? findbrightBefore; + private ShaderProgramBlur? blurBefore; + private ShaderProgramGodrays? godraysBefore; + private ShaderProgramLuma? lumaBefore; + private ShaderProgramFinal? finalBefore; private bool taaBefore; private float sharpnessBefore; + private bool debugViewBefore; private object? oitRevealBefore; private object? oitAccumBefore; private DefaultShaderUniforms uniforms = new(); @@ -383,8 +578,14 @@ private sealed class Session : IDisposable skyMotionBefore = ShaderPrograms.TaaSkyMotion, resolveBefore = ShaderPrograms.TaaResolve, blitBefore = ShaderPrograms.Blit, + findbrightBefore = ShaderPrograms.Findbright, + blurBefore = ShaderPrograms.Blur, + godraysBefore = ShaderPrograms.Godrays, + lumaBefore = ShaderPrograms.Luma, + finalBefore = ShaderPrograms.Final, taaBefore = OptimumConfig.Taa, sharpnessBefore = OptimumConfig.TaaSharpness, + debugViewBefore = OptimumConfig.AmbientOcclusionDebugView, }; ScreenManager.Platform = platform; ScreenManager.FrameProfiler ??= new FrameProfilerUtil(static (string _) => { }); @@ -403,8 +604,14 @@ public void Dispose() ShaderPrograms.TaaSkyMotion = skyMotionBefore!; ShaderPrograms.TaaResolve = resolveBefore!; ShaderPrograms.Blit = blitBefore!; + ShaderPrograms.Findbright = findbrightBefore!; + ShaderPrograms.Blur = blurBefore!; + ShaderPrograms.Godrays = godraysBefore!; + ShaderPrograms.Luma = lumaBefore!; + ShaderPrograms.Final = finalBefore!; OptimumConfig.Taa = taaBefore; OptimumConfig.TaaSharpness = sharpnessBefore; + OptimumConfig.AmbientOcclusionDebugView = debugViewBefore; OptimumTemporal.Frame.JitterActive = false; typeof(SystemRenderOITLayers).GetField("revealTextureId", HiddenStatic)!.SetValue(null, oitRevealBefore); typeof(SystemRenderOITLayers).GetField("accumTextureId", HiddenStatic)!.SetValue(null, oitAccumBefore); @@ -437,6 +644,8 @@ public void SeedFrame() seam.ClearColor(4, 0.10f, 0.25f, 0.05f, 0.35f); seam.ClearColor(5, 0.05f, 0.10f, 0.30f, 0.2f); + SeedPostTargets(); + seam.BindFramebuffer(Primary.FboId); seam.SetDrawBuffers(Primary.FboId, 0b111); seam.ClearColor(0, 0.25f, 0.5f, 0.75f, 1f); @@ -460,6 +669,77 @@ public void SeedFrame() seam.BindTexture(7, oitAccumulation); } + /// The post chain's targets, indexed by their EnumFrameBuffer slot. + private static readonly int[] PostTargetIndices = { 2, 3, 4, 7, 8, 9, 10, 14 }; + + /// + /// Every post target seeded to its own constant, and the TAA history slots with it: the + /// final composition samples the bloom and god-ray targets whether or not their passes ran + /// this frame, so both routes have to start a run from identical contents. + /// + private void SeedPostTargets() + { + VulkanDevice seam = Seam; + for (int i = 0; i < PostTargetIndices.Length; i++) + { + FrameBufferRef target = buffers[PostTargetIndices[i]]; + if (target == null) continue; + float level = 0.08f + i * 0.09f; + seam.BindFramebuffer(target.FboId); + seam.SetDrawBuffers(target.FboId, 0b1); + seam.ClearColor(0, level, 1f - level, level * 0.5f, 1f); + } + + for (int parity = 0; parity < 2; parity++) + { + FrameBufferRef history = History(parity); + if (history == null) continue; + seam.BindFramebuffer(history.FboId); + seam.SetDrawBuffers(history.FboId, 0b111); + seam.ClearColor(0, 0.2f + parity * 0.1f, 0.3f, 0.4f, 1f); + seam.ClearColor(1, 0.1f, 0.2f + parity * 0.1f, 0.3f, 1f); + seam.ClearColor(2, 0.5f, 0f, 0f, 1f); + } + } + + /// + /// The TAA resolve's history slot selection put back to its starting state, so two + /// differential runs of the same chain resolve into the same slot from the same history. + /// + public void ResetTaaHistory() + { + typeof(ClientPlatformWindows).GetField("_taaFrameParity", Hidden)!.SetValue(Platform, 0); + typeof(ClientPlatformWindows).GetField("_taaHistoryValid", Hidden)!.SetValue(Platform, false); + } + + /// + /// The per-frame post switches window_RenderFrame computes, and the render scale: private + /// fields of ClientPlatformWindows, which is where the chain reads them from on both + /// routes (through OptimumRenderBloom and friends on the native one). + /// + public void ApplyPostSettings(bool bloom, bool godRays, bool fxaa, bool ssao, float ssaa, int clientSize) + { + SetField("RenderBloom", bloom); + SetField("RenderGodRays", godRays); + SetField("RenderFXAA", fxaa); + SetField("RenderSSAO", ssao); + SetField("ssaaLevel", ssaa); + Platform.ClientSize = new Size2i(clientSize, clientSize); + } + + /// The two AO fields the final composition reads, as the AO step would have left them. + public void ApplyAmbientOcclusionState(bool inScene, int platformTexture) + { + SetField("optimumSsaoInScene", inScene); + SetField("optimumAmbientOcclusionTexture", platformTexture); + } + + /// The blurred vanilla-SSAO target the final composition binds when AO is on. + public int SsaoBlurTexture => buffers[14].ColorTextureIds[0]; + + private void SetField(string name, object value) => + typeof(ClientPlatformWindows).GetField(name, Hidden)!.SetValue(Platform, value); + /// TAA on, with the jitter window open or closed, and no sharpen pass. public void EnableTaa(bool jitterActive) { @@ -492,44 +772,67 @@ public void AdvanceTemporalFrame() // ---------------------------------------------------------------- readback - public byte[] ReadScene() => ReadAttachmentZero(Primary.FboId); + public byte[] ReadScene() => ReadAttachmentZero(Primary.FboId, Size, Size); - public byte[] ReadGlow() => Decode(Primary.ColorTextureIds[1], motion: false); + public byte[] ReadGlow() => Decode(Primary.ColorTextureIds[1], Size, Size, motion: false); - public byte[] ReadMotion() => Decode(Primary.ColorTextureIds[2], motion: true); + public byte[] ReadMotion() => Decode(Primary.ColorTextureIds[2], Size, Size, motion: true); - private unsafe byte[] ReadAttachmentZero(int framebufferId) + /// One post target's colour 0, decoded at that target's own size. + public byte[] ReadPostTarget(int index) { - var pixels = new byte[Size * Size * 4]; + FrameBufferRef target = buffers[index]; + return Decode(target.ColorTextureIds[0], target.Width, target.Height, motion: false); + } + + private unsafe byte[] ReadAttachmentZero(int framebufferId, int width, int height) + { + var pixels = new byte[width * height * 4]; fixed (byte* destination = pixels) { Seam.BindFramebuffer(framebufferId); - Seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + Seam.ReadDefaultFramebuffer(0, 0, width, height, (IntPtr)destination); } return pixels; } /// - /// Any attachment through an RGBA8 copy, because the seam's readback is four bytes per - /// pixel from attachment 0. The motion mode encodes the vector into the two low - /// channels so a difference in it cannot hide behind a clamp. + /// Any attachment through an RGBA8 copy of its own size, because the seam's readback is + /// four bytes per pixel from attachment 0. The motion mode encodes the vector into the two + /// low channels so a difference in it cannot hide behind a clamp. /// - private unsafe byte[] Decode(int textureId, bool motion) + private byte[] Decode(int textureId, int width, int height, bool motion) { VulkanDevice seam = Seam; - seam.BindFramebuffer(decodeFramebuffer); + int framebuffer = DecodeFramebuffer(width, height); + seam.BindFramebuffer(framebuffer); seam.ClearColor(0, 0f, 0f, 0f, 1f); seam.UseProgram(decodeProgram); seam.SetSamplerUnit(decodeProgram, "source", 15); seam.BindTexture(15, textureId); SetInt(seam, decodeProgram, "motionMode", motion ? 1 : 0); - seam.SetViewport(0, 0, Size, Size); + seam.SetViewport(0, 0, width, height); seam.SetDepthTest(false); seam.SetDepthMask(false); seam.SetCullFace(false); seam.SetBlend(false, EnumBlendMode.Standard); seam.DrawFullscreenTriangle(); - return ReadAttachmentZero(decodeFramebuffer); + return ReadAttachmentZero(framebuffer, width, height); + } + + /// The RGBA8 decode target of one size, made once. + private int DecodeFramebuffer(int width, int height) + { + if (width == Size && height == Size) return decodeFramebuffer; + if (decodeFramebuffers.TryGetValue((width, height), out int existing)) return existing; + + int texture = Seam.CreateTexture2D(width, height, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int framebuffer = Seam.CreateFramebuffer(width, height); + Seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + Seam.SetDrawBuffers(framebuffer, 0b1); + decodeFramebuffers[(width, height)] = framebuffer; + return framebuffer; } private static void SetInt(VulkanDevice seam, int program, string name, int value) @@ -599,7 +902,17 @@ private void BuildTargets() for (int i = 0; i <= 24; i++) buffers.Add(null!); buffers[0] = Primary; buffers[1] = Transparent; - buffers[10] = SingleTarget(EnumTextureInternalFormat.Rgba8); + // The post chain's targets at the sizes and formats SetupDefaultFrameBuffers builds + // them at: the bloom ping-pongs at half and quarter resolution, find-bright, god rays + // and Luma at full, and the blurred vanilla-SSAO target the final composition binds. + buffers[2] = SingleTarget(Size / 2, Size / 2, EnumTextureInternalFormat.Rgba8); + buffers[3] = SingleTarget(Size / 2, Size / 2, EnumTextureInternalFormat.Rgba8); + buffers[4] = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba16f); + buffers[7] = SingleTarget(Size / 2, Size / 2, EnumTextureInternalFormat.Rgba16f); + buffers[8] = SingleTarget(Size / 4, Size / 4, EnumTextureInternalFormat.Rgba8); + buffers[9] = SingleTarget(Size / 4, Size / 4, EnumTextureInternalFormat.Rgba8); + buffers[10] = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba16f); + buffers[14] = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba8); buffers[19] = HistoryTarget(); buffers[20] = HistoryTarget(); @@ -634,14 +947,17 @@ private unsafe int Seeded(float level) } } - private FrameBufferRef SingleTarget(EnumTextureInternalFormat format) + private FrameBufferRef SingleTarget(int width, int height, EnumTextureInternalFormat format) { var target = new FrameBufferRef { - Width = Size, - Height = Size, - FboId = Seam.CreateFramebuffer(Size, Size), - ColorTextureIds = new[] { Texture(format) }, + Width = width, + Height = height, + FboId = Seam.CreateFramebuffer(width, height), + ColorTextureIds = new[] + { + Seam.CreateTexture2D(width, height, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, }; Seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); Seam.SetDrawBuffers(target.FboId, 0b1); @@ -694,10 +1010,39 @@ private void LinkPrograms() var blit = new ShaderProgramBlit { PassName = "blit" }; Link(seam, blit, "blit", variant, Array.Empty()); + // The chain's tail: find-bright, the blur ladder, god rays, the FXAA luma prepass and + // the final composition, with every uniform the OpenGL body sets on them registered + // so the old route can run too. + var findbright = new ShaderProgramFindbright { PassName = "findbright" }; + Link(seam, findbright, "findbright", variant, new[] { "ambientBloomLevel", "extraBloom" }); + var blur = new ShaderProgramBlur { PassName = "blur" }; + Link(seam, blur, "blur", variant, new[] { "frameSize", "isVertical" }); + var godrays = new ShaderProgramGodrays { PassName = "godrays" }; + Link(seam, godrays, "godrays", variant, new[] + { + "invFrameSizeIn", "maxGodRaySamples", "sunPosScreenIn", "sunPos3dIn", + "playerViewVector", "dusk", "iGlobalTimeIn", + }); + var luma = new ShaderProgramLuma { PassName = "luma" }; + Link(seam, luma, "luma", variant, Array.Empty()); + var final = new ShaderProgramFinal { PassName = "final" }; + Link(seam, final, "final", variant, new[] + { + "ambientBloomLevel", "optimumSsaoInScene", "optimumAoDebug", "invFrameSizeIn", + "gammaLevel", "extraGamma", "contrastLevel", "brightnessLevel", "sepiaLevel", + "windWaveCounter", "glitchEffectStrength", "sunPosScreenIn", "sunPos3dIn", + "playerViewVector", "damageVignetting", "damageVignettingSide", "frostVignetting", + }); + ShaderPrograms.Transparentcompose = compose; ShaderPrograms.TaaSkyMotion = skyMotion; ShaderPrograms.TaaResolve = resolve; ShaderPrograms.Blit = blit; + ShaderPrograms.Findbright = findbright; + ShaderPrograms.Blur = blur; + ShaderPrograms.Godrays = godrays; + ShaderPrograms.Luma = luma; + ShaderPrograms.Final = final; decodeProgram = LinkDecode(seam); } @@ -804,6 +1149,7 @@ private void InstallState() Platform.BeginFrame(); SeedFrame(); SceneSeed = ReadScene(); + GlowSeed = ReadGlow(); MotionSeed = ReadMotion(); Platform.EndFrame(); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index 94274c22..6bcd0763 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -355,10 +355,18 @@ public override int RenderOptimumTaaSharpen(int resolvedScene) return sharpened; } - /// Phase 3b stage 1: the chain's ninth pass. Stage 1g makes it native. + /// + /// Phase 3b stage 1: the chain's ninth pass, drawn natively - the attachment-subset pass that + /// writes Primary colour 0 while sampling Primary colour 1 (VulkanClientPlatform.NativePostFinal.cs). + /// public override void RenderFinalComposition() { NotePostStep(NativePostStep.FinalComposition); + if (UseNativePostChain) + { + NativeFinalComposition(); + return; + } LegacyFinalComposition(); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs index bc2872d6..4bf143ea 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -21,12 +21,13 @@ namespace Optimum.Render.Vulkan.Platform; // and last the blit/FSR/debug step that is already native (VulkanClientPlatform.NativeBlit.cs). // RenderPostprocessingEffects' override runs the steps that live inside it and never calls base. // -// Two helpers draw natively here - the OIT merge and sky motion - through RequestNativePipeline, +// Two helpers draw natively in this file - the OIT merge and sky motion - through RequestNativePipeline, // BeginNativePass, WriteNative and DrawNativeFullscreen, exactly as the blit does. Every other // helper is LEGACY: the same work through the GL-shaped platform calls, which after the split in // ClientPlatformWindows is one lib virtual per pass, so the chain is complete and correct at -// every commit and a later stage replaces one helper at a time. Each legacy helper names the -// stage that will replace it. +// every commit and a later stage replaces one helper at a time. The bloom chain, god rays, the +// Luma step and the final composition are native in VulkanClientPlatform.NativePostFinal.cs; +// their LEGACY helpers stay as the old route the differential tests compare against. public partial class VulkanClientPlatform { /// The chain's passes, in the order the frame runs them (section 3). @@ -347,19 +348,31 @@ private bool LegacySkyMotion() /// LEGACY - pass 5, the TAA sharpen. Stage 1d makes it native. private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene); - /// LEGACY - pass 6, the bloom chain. Stage 1e makes it native. - private void PostStepBloom(int scene, int glow) => OptimumPostBloom(scene, glow); + /// Pass 6, the bloom chain, drawn natively (VulkanClientPlatform.NativePostFinal.cs). + private void PostStepBloom(int scene, int glow) => NativeBloom(scene, glow); - /// LEGACY - pass 7, god rays. Stage 1e makes it native. - private void PostStepGodRays(int scene, int glow) => OptimumPostGodRays(scene, glow); + /// Pass 7, god rays, drawn natively. + private void PostStepGodRays(int scene, int glow) => NativeGodRays(scene, glow); - /// LEGACY - pass 8, the FXAA luma prepass or the pass-through blit into Luma. Stage 1f makes it native. - private void PostStepFxaaOrBlit(int scene) => OptimumPostLuma(scene); + /// Pass 8, the FXAA luma prepass or the pass-through blit into Luma, drawn natively. + private void PostStepFxaaOrBlit(int scene) => NativePostLuma(scene); - /// LEGACY - the chain's epilogue: blending back on, Primary bound again. Stage 1f makes it native. + /// + /// The chain's epilogue: blending back on and Primary bound again. State, not a draw - it is + /// the GL-shaped handoff every stage after the chain inherits, so it stays as it is. + /// private void PostStepFinish() => OptimumPostFinish(); - /// LEGACY - pass 9, the final composition. Stage 1g makes it native. + /// LEGACY - pass 6 on the OpenGL body. replaces it; kept as the old route. + private void LegacyBloom(int scene, int glow) => OptimumPostBloom(scene, glow); + + /// LEGACY - pass 7 on the OpenGL body. replaces it; kept as the old route. + private void LegacyGodRays(int scene, int glow) => OptimumPostGodRays(scene, glow); + + /// LEGACY - pass 8 on the OpenGL body. replaces it; kept as the old route. + private void LegacyPostLuma(int scene) => OptimumPostLuma(scene); + + /// LEGACY - pass 9 on the OpenGL body. replaces it; kept as the old route. private void LegacyFinalComposition() { SetPassContext("FinalComposition", PassFlags.None); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostFinal.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostFinal.cs new file mode 100644 index 00000000..c11849f1 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostFinal.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), stage 1: the tail of the +// post chain - the bloom chain, god rays, the FXAA luma step and the final composition - drawn +// through the native device API instead of the GL-shaped platform calls. +// +// Every pass here follows VulkanClientPlatform.NativeBlit.cs: a pipeline requested with its fixed +// state stated outright (per-attachment blend, depth test/write/compare, cull, topology, target +// formats), a pass declared with its explicit reads and colour slots, uniforms written by +// placement and sampled textures resolved straight to bindless slots. The values are the ones the +// OpenGL body computes, read from client state (decision 3): OptimumRenderBloom, +// OptimumRenderGodRays, OptimumRenderFxaa, OptimumSsaaLevel, OptimumRenderSsao, +// OptimumAmbientOcclusionTexture and OptimumSsaoInScene, plus ClientSettings and ShaderUniforms. +// +// The GL-shaped state calls the OpenGL body makes around these passes stay, outside every native +// pass: they are what the rest of the frame - the chain's epilogue, the GUI stage, a mod renderer +// - inherits, exactly as it does on the OpenGL body. +// +// Viewports: each of these targets is sized to the viewport its LoadFrameBuffer case sets (the +// FindBright and Luma cases set none and inherit the full render resolution, which is their own +// size), so every pass here draws into its whole target and the arithmetic cannot drift. +public partial class VulkanClientPlatform +{ + // ------------------------------------------------------------------ pass 6: bloom + + private readonly NativeFullscreenPass nativeFindBright = + new("findbright", new[] { "ambientBloomLevel", "extraBloom" }, new[] { "colorTex", "glowTex" }); + + // One instance per blur target: the program is the same, but a pipeline is per target + // formats, and each instance keeps its own resolved placements. + private readonly NativeFullscreenPass nativeBlurMedHorizontal = + new("blur", new[] { "frameSize", "isVertical" }, new[] { "inputTexture" }); + + private readonly NativeFullscreenPass nativeBlurMedVertical = + new("blur", new[] { "frameSize", "isVertical" }, new[] { "inputTexture" }); + + private readonly NativeFullscreenPass nativeBlurLowHorizontal = + new("blur", new[] { "frameSize", "isVertical" }, new[] { "inputTexture" }); + + private readonly NativeFullscreenPass nativeBlurLowVertical = + new("blur", new[] { "frameSize", "isVertical" }, new[] { "inputTexture" }); + + /// + /// The bloom chain, drawn natively: find-bright into the full-resolution target, then the + /// two blur ping-pongs at half and quarter resolution. The OpenGL body + /// (ClientPlatformWindows.OptimumPostBloom) turns blending off for the whole block, sets the + /// blur's frameSize exactly once - at the full resolution, before the half-resolution + /// pair, and never again for the quarter-resolution pair - and puts the viewport and + /// blending back at the end. All of that is reproduced here, the stale frameSize + /// included: it is what the vanilla image is made of, not a bug to fix. + /// + private void NativeBloom(int scene, int glow) + { + if (!OptimumRenderBloom) return; + + List buffers = FrameBuffers; + ShaderProgramFindbright findbright = ShaderPrograms.Findbright; + ShaderProgramBlur blur = ShaderPrograms.Blur; + FrameBufferRef? findBrightTarget = NativePostTarget(buffers, FindBrightIndex); + FrameBufferRef? medHorizontal = NativePostTarget(buffers, BlurHorizontalMedResIndex); + FrameBufferRef? medVertical = NativePostTarget(buffers, BlurVerticalMedResIndex); + FrameBufferRef? lowHorizontal = NativePostTarget(buffers, BlurHorizontalLowResIndex); + FrameBufferRef? lowVertical = NativePostTarget(buffers, BlurVerticalLowResIndex); + + if (!NativeProgramUsable(findbright) || !NativeProgramUsable(blur) || + findBrightTarget == null || medHorizontal == null || medVertical == null || + lowHorizontal == null || lowVertical == null) + { + LegacyBloom(scene, glow); + return; + } + + NativePipeline? bright = NativePipelineFor(nativeFindBright, findbright, findBrightTarget.FboId); + NativePipeline? medH = NativePipelineFor(nativeBlurMedHorizontal, blur, medHorizontal.FboId); + NativePipeline? medV = NativePipelineFor(nativeBlurMedVertical, blur, medVertical.FboId); + NativePipeline? lowH = NativePipelineFor(nativeBlurLowHorizontal, blur, lowHorizontal.FboId); + NativePipeline? lowV = NativePipelineFor(nativeBlurLowVertical, blur, lowVertical.FboId); + if (bright == null || medH == null || medV == null || lowH == null || lowV == null) + { + LegacyBloom(scene, glow); + return; + } + + Size2i client = OptimumWindowClientSize(); + float ssaa = OptimumSsaaLevel; + + // The block's blend state, left where the OpenGL body leaves it, outside the passes. + GlToggleBlend(on: false); + + if (BeginNativePostPass("Post/" + FindBrightIndex, findBrightTarget.FboId, new[] { scene, glow }, + transient: true)) + { + device.WriteNative(bright, nativeFindBright.Uniforms[0], NativeAmbientBloomLevel()); + device.WriteNative(bright, nativeFindBright.Uniforms[1], ShaderUniforms.ExtraBloom); + device.DrawNativeFullscreen(bright, new[] + { + new NativeTexture(nativeFindBright.Samplers[0], scene), + new NativeTexture(nativeFindBright.Samplers[1], glow), + }); + } + device.EndNativePass(); + + // frameSize is the full-resolution value the OpenGL body sets once here and reuses for + // all four blur draws, including the two that write a quarter-resolution target. + float blurWidth = client.Width * ssaa; + float blurHeight = client.Height * ssaa; + + NativeBlurStep(medH, nativeBlurMedHorizontal, "Post/" + BlurHorizontalMedResIndex, + medHorizontal.FboId, findBrightTarget.ColorTextureIds[0], vertical: 0, blurWidth, blurHeight); + NativeBlurStep(medV, nativeBlurMedVertical, "Post/" + BlurVerticalMedResIndex, + medVertical.FboId, medHorizontal.ColorTextureIds[0], vertical: 1, blurWidth, blurHeight); + NativeBlurStep(lowH, nativeBlurLowHorizontal, "Post/" + BlurHorizontalLowResIndex, + lowHorizontal.FboId, medVertical.ColorTextureIds[0], vertical: 0, blurWidth, blurHeight); + NativeBlurStep(lowV, nativeBlurLowVertical, "Post/" + BlurVerticalLowResIndex, + lowVertical.FboId, lowHorizontal.ColorTextureIds[0], vertical: 1, blurWidth, blurHeight); + + // What the rest of the frame inherits from this block on the OpenGL body. + GlViewport(0, 0, (int)(ssaa * client.Width), (int)(ssaa * client.Height)); + GlToggleBlend(on: true); + } + + private void NativeBlurStep(NativePipeline pipeline, NativeFullscreenPass pass, string name, + int framebufferId, int input, int vertical, float frameWidth, float frameHeight) + { + if (BeginNativePostPass(name, framebufferId, new[] { input }, transient: true)) + { + device.WriteNative(pipeline, pass.Uniforms[0], frameWidth, frameHeight); + device.WriteNative(pipeline, pass.Uniforms[1], vertical); + device.DrawNativeFullscreen(pipeline, new[] { new NativeTexture(pass.Samplers[0], input) }); + } + device.EndNativePass(); + } + + // --------------------------------------------------------------- pass 7: god rays + + private readonly NativeFullscreenPass nativeGodRays = new("godrays", + new[] + { + "invFrameSizeIn", "maxGodRaySamples", "sunPosScreenIn", "sunPos3dIn", + "playerViewVector", "dusk", "iGlobalTimeIn", + }, + new[] { "inputTexture", "glowParts" }); + + /// + /// God rays, drawn natively into the half-resolution target. The OpenGL body + /// (ClientPlatformWindows.OptimumPostGodRays) toggles no blend of its own, so the draw runs + /// with whatever the steps before it left - blending on in the source-alpha mode, which the + /// shader's outColor.a = 1 makes indistinguishable from blending off. The pipeline + /// states that mode outright rather than inheriting a tracked one, and the viewport reset the + /// body ends with stays, outside the pass. + /// + /// sunPos3dIn comes from ShaderUniforms.LightPosition3D here and from + /// SunPosition3D in the final composition: two different fields behind one uniform + /// name, as on the OpenGL body. + /// + private void NativeGodRays(int scene, int glow) + { + if (!OptimumRenderGodRays) return; + + List buffers = FrameBuffers; + ShaderProgramGodrays godrays = ShaderPrograms.Godrays; + FrameBufferRef? target = NativePostTarget(buffers, GodRaysIndex); + if (!NativeProgramUsable(godrays) || target == null) + { + LegacyGodRays(scene, glow); + return; + } + + NativePipeline? pipeline = NativePostPipeline(nativeGodRays, godrays, target.FboId, 1u, + NativeStandardBlend(), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) + { + LegacyGodRays(scene, glow); + return; + } + + Size2i client = OptimumWindowClientSize(); + float ssaa = OptimumSsaaLevel; + + if (BeginNativePostPass("Post/" + GodRaysIndex, target.FboId, new[] { scene, glow }, transient: false)) + { + // The input texel size is the full-resolution one, describing the texture the pass + // samples and not the half-resolution target it writes. + device.WriteNative(pipeline, nativeGodRays.Uniforms[0], + 1f / (client.Width * ssaa), 1f / (client.Height * ssaa)); + device.WriteNative(pipeline, nativeGodRays.Uniforms[1], OptimumConfig.GodRaysSampleLimit); + WriteNativeVec3(pipeline, nativeGodRays.Uniforms[2], ShaderUniforms.SunPositionScreen); + WriteNativeVec3(pipeline, nativeGodRays.Uniforms[3], ShaderUniforms.LightPosition3D); + WriteNativeVec3(pipeline, nativeGodRays.Uniforms[4], ShaderUniforms.PlayerViewVector); + device.WriteNative(pipeline, nativeGodRays.Uniforms[5], ShaderUniforms.Dusk); + device.WriteNative(pipeline, nativeGodRays.Uniforms[6], (float)EllapsedMs / 1000f); + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeGodRays.Samplers[0], scene), + new NativeTexture(nativeGodRays.Samplers[1], glow), + }); + } + device.EndNativePass(); + + GlViewport(0, 0, (int)(ssaa * client.Width), (int)(ssaa * client.Height)); + } + + // -------------------------------------------------------- pass 8: FXAA luma or blit + + private readonly NativeFullscreenPass nativeLuma = + new("luma", Array.Empty(), new[] { "scene" }); + + private readonly NativeFullscreenPass nativeLumaBlit = + new("blit", Array.Empty(), new[] { "scene" }); + + /// + /// The Luma step, drawn natively. The OpenGL body (ClientPlatformWindows.OptimumPostLuma) + /// picks the branch on RenderFXAA && !TaaResolvedThisFrame: the FXAA luma + /// prepass over the raw jittered Primary colour - bypassing the whole resolved chain - or a + /// pass-through blit of the chain's scene. Both go into the Luma target, and the Luma case of + /// LoadFrameBuffer turns blending off without putting it back; the chain's epilogue + /// does that. + /// + private void NativePostLuma(int scene) + { + List buffers = FrameBuffers; + FrameBufferRef? target = NativePostTarget(buffers, LumaIndex); + FrameBufferRef? primary = NativePostTarget(buffers, PrimaryIndex); + bool fxaa = OptimumRenderFxaa && !TaaResolvedThisFrame; + if (fxaa && (primary == null || primary.ColorTextureIds == null || primary.ColorTextureIds.Length < 1)) + { + LegacyPostLuma(scene); + return; + } + + ShaderProgramBase program = fxaa ? ShaderPrograms.Luma : ShaderPrograms.Blit; + NativeFullscreenPass pass = fxaa ? nativeLuma : nativeLumaBlit; + if (!NativeProgramUsable(program) || target == null) + { + LegacyPostLuma(scene); + return; + } + + NativePipeline? pipeline = NativePipelineFor(pass, program, target.FboId); + if (pipeline == null) + { + LegacyPostLuma(scene); + return; + } + + int source = fxaa ? primary!.ColorTextureIds[0] : scene; + + // The Luma case of LoadFrameBuffer turns blending off and leaves it off. + SetBlendEnabled(false); + if (BeginNativePostPass("Post/" + LumaIndex, target.FboId, new[] { source }, transient: false)) + { + device.DrawNativeFullscreen(pipeline, new[] { new NativeTexture(pass.Samplers[0], source) }); + } + device.EndNativePass(); + } + + // ------------------------------------------------------- pass 9: final composition + + private readonly NativeFullscreenPass nativeFinal = new("final", + new[] + { + "ambientBloomLevel", "optimumSsaoInScene", "optimumAoDebug", "invFrameSizeIn", + "gammaLevel", "extraGamma", "contrastLevel", "brightnessLevel", "sepiaLevel", + "windWaveCounter", "glitchEffectStrength", "sunPosScreenIn", "sunPos3dIn", + "playerViewVector", "damageVignetting", "damageVignettingSide", "frostVignetting", + }, + new[] { "primaryScene", "glowParts", "bloomParts", "godrayParts", "ssaoScene" }); + + /// + /// The final composition, drawn natively. This is the attachment-subset pass of the chain: it + /// writes Primary colour 0 while sampling Primary colour 1 as the glow on a frame the TAA + /// resolve did not run, so the pass declares every bound colour slot except 1 and slot 1 + /// leaves the scope for the pass - one barrier out to the shader-read layout and one back + /// when the next pass attaches it, and no feedback copy. + /// + /// The values are the OpenGL body's (ClientPlatformWindows.RenderFinalComposition), including + /// the two it writes unconditionally: optimumSsaoInScene is written on every frame - + /// a declared uniform left unset reads back as whatever the uniform ring last held - and the + /// bloom and god-ray inputs are sampled whether or not their passes ran this frame. + /// + private void NativeFinalComposition() + { + if (!offscreenBufferActive) return; + + List buffers = FrameBuffers; + ShaderProgramFinal final = ShaderPrograms.Final; + FrameBufferRef? primary = NativePostTarget(buffers, PrimaryIndex); + FrameBufferRef? luma = NativePostTarget(buffers, LumaIndex); + FrameBufferRef? bloom = NativePostTarget(buffers, BlurVerticalLowResIndex); + FrameBufferRef? godRays = NativePostTarget(buffers, GodRaysIndex); + if (!NativeProgramUsable(final) || primary == null || luma == null || bloom == null || godRays == null || + primary.ColorTextureIds == null || primary.ColorTextureIds.Length < 2) + { + LegacyFinalComposition(); + return; + } + + bool renderSsao = OptimumRenderSsao; + bool aoDebugView = OptimumConfig.AmbientOcclusionDebugView && renderSsao; + int aoTexture = OptimumAmbientOcclusionTexture; + FrameBufferRef? ssaoBlur = NativePostTarget(buffers, SsaoBlurVerticalIndex); + int ssaoScene = 0; + if (renderSsao) + { + ssaoScene = aoDebugView && aoTexture != 0 + ? aoTexture + : ssaoBlur?.ColorTextureIds != null && ssaoBlur.ColorTextureIds.Length > 0 + ? ssaoBlur.ColorTextureIds[0] + : 0; + } + + // Primary colour 1 stays out of the pass so the glow can be sampled from it. + const uint slots = ~(1u << 1); + + // The draw-buffer selection and the blend the OpenGL body sets around the pass: outside + // it, so everything after the composition inherits what it always did. + BeginFinalCompositionDrawBuffers(); + GlToggleBlend(on: true); + + NativePipeline? pipeline = NativePostPipeline(nativeFinal, final, primary.FboId, slots, + NativeFinalBlend(slots), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) + { + RestoreWorldDrawBuffers(renderSsao); + LegacyFinalComposition(); + return; + } + + int primaryScene = luma.ColorTextureIds[0]; + int glow = OptimumPostGlowTexture(); + int bloomParts = bloom.ColorTextureIds[0]; + int godrayParts = godRays.ColorTextureIds[0]; + + var reads = new List { primaryScene, glow, bloomParts, godrayParts }; + if (ssaoScene > 0 && !reads.Contains(ssaoScene)) reads.Add(ssaoScene); + + Size2i client = OptimumWindowClientSize(); + float ssaa = OptimumSsaaLevel; + + SetPassContext("FinalComposition", PassFlags.None); + if (device.BeginNativePass(new NativePassDescription + { + Name = "FinalComposition/0", + FramebufferId = primary.FboId, + ColorSlots = slots, + Reads = reads.ToArray(), + Flags = PassFlags.None, + })) + { + NativeUniform[] u = nativeFinal.Uniforms; + device.WriteNative(pipeline, u[0], NativeAmbientBloomLevel()); + device.WriteNative(pipeline, u[1], (OptimumSsaoInScene || !renderSsao) ? 1 : 0); + device.WriteNative(pipeline, u[2], aoDebugView ? 1 : 0); + device.WriteNative(pipeline, u[3], 1f / (client.Width * ssaa), 1f / (client.Height * ssaa)); + device.WriteNative(pipeline, u[4], ClientSettings.GammaLevel); + device.WriteNative(pipeline, u[5], ClientSettings.ExtraGammaLevel); + device.WriteNative(pipeline, u[6], ShaderUniforms.ExtraContrastLevel); + device.WriteNative(pipeline, u[7], ClientSettings.BrightnessLevel + + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f); + device.WriteNative(pipeline, u[8], ShaderUniforms.SepiaLevel + ShaderUniforms.ExtraSepia); + device.WriteNative(pipeline, u[9], ShaderUniforms.WindWaveCounter); + device.WriteNative(pipeline, u[10], ShaderUniforms.GlitchStrength); + if (OptimumRenderGodRays) + { + WriteNativeVec3(pipeline, u[11], ShaderUniforms.SunPositionScreen); + WriteNativeVec3(pipeline, u[12], ShaderUniforms.SunPosition3D); + WriteNativeVec3(pipeline, u[13], ShaderUniforms.PlayerViewVector); + } + device.WriteNative(pipeline, u[14], ShaderUniforms.DamageVignetting); + device.WriteNative(pipeline, u[15], ShaderUniforms.DamageVignettingSide); + device.WriteNative(pipeline, u[16], ShaderUniforms.FrostVignetting); + + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeFinal.Samplers[0], primaryScene), + new NativeTexture(nativeFinal.Samplers[1], glow), + new NativeTexture(nativeFinal.Samplers[2], bloomParts), + new NativeTexture(nativeFinal.Samplers[3], godrayParts), + new NativeTexture(nativeFinal.Samplers[4], ssaoScene), + }); + } + device.EndNativePass(); + + RestoreWorldDrawBuffers(renderSsao); + SetPassContext("Frame", PassFlags.AllowSplit); + } + + // ------------------------------------------------------------------ shared plumbing + + /// + /// The bloom level the find-bright pass and the final composition both take, bit for bit the + /// same expression on both (ClientPlatformWindows). + /// + private float NativeAmbientBloomLevel() => + ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + + ShaderUniforms.AmbientBloomLevelAdd[3]; + + /// A vec3 at its placement. + private void WriteNativeVec3(NativePipeline pipeline, NativeUniform uniform, Vec3f value) => + device.WriteNative(pipeline, uniform, value.X, value.Y, value.Z); + + /// A post target of the chain, or null when the frame buffers do not hold it. + private static FrameBufferRef? NativePostTarget(List buffers, int index) + { + if (buffers == null || index < 0 || index >= buffers.Count) return null; + FrameBufferRef target = buffers[index]; + if (target == null || target.Disposed || target.FboId == 0) return null; + if (target.ColorTextureIds == null || target.ColorTextureIds.Length == 0) return null; + return target; + } + + private static bool NativeProgramUsable(ShaderProgramBase? program) => + program != null && !program.LoadError && !program.Disposed && program.ProgramId > 0; + + /// + /// One colour-0 pass of the chain's tail, drawing into the whole target - which is the + /// viewport its LoadFrameBuffer case sets on the OpenGL body. + /// + private bool BeginNativePostPass(string name, int framebufferId, int[] reads, bool transient) => + device.BeginNativePass(new NativePassDescription + { + Name = name, + FramebufferId = framebufferId, + ColorSlots = 1u, + Reads = reads, + TransientSlots = transient ? 1u : 0u, + Flags = PassFlags.None, + }); + + /// Blending on in the source-alpha mode on colour 0, as GlToggleBlend(true) sets it. + private static AttachmentBlend[] NativeStandardBlend() + { + AttachmentBlend blend = AttachmentBlend.Default; + blend.Enabled = true; + return new[] { blend }; + } + + /// + /// The final composition's blend: the source-alpha mode on colour 0 and no write anywhere + /// else, so the G-buffer and motion attachments the pass keeps in its scope come out of it + /// exactly as they went in. + /// + private static AttachmentBlend[] NativeFinalBlend(uint slots) + { + var blend = new AttachmentBlend[NativeSlotCount(slots)]; + for (int i = 0; i < blend.Length; i++) + { + if (i == 0) + { + blend[0] = AttachmentBlend.Default; + blend[0].Enabled = true; + continue; + } + blend[i].WriteMask = 0; + } + return blend; + } +} diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index accf9df5..499ec95f 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -49,6 +49,10 @@ public class ClientPlatformWindowsVanillaRegionsTests "OptimumPostAmbientOcclusion", "OptimumPostSceneTexture", "OptimumPostGlowTexture", "OptimumPostBloom", "OptimumPostGodRays", "OptimumPostLuma", "OptimumPostFinish", "OptimumBindKeepViewport", + // Phase 3b stage 1e-1g: the client state a native bloom, god-rays, Luma and + // final-composition pass reads. + "OptimumRenderBloom", "OptimumRenderGodRays", "OptimumRenderFxaa", "OptimumSsaaLevel", + "OptimumAmbientOcclusionTexture", "OptimumSsaoInScene", "ReadDefaultFramebuffer", "ReadTextureForParity", "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "RestorePrimaryDrawBuffers", "RestoreWorldDrawBuffers", "SelectBackDrawBuffer", "SelectFsrDrawBuffer", "SetBlendEnabled", diff --git a/Optimum.Tests/native-post-chain-coverage-tests.cs b/Optimum.Tests/native-post-chain-coverage-tests.cs index cf07132a..49a4a517 100644 --- a/Optimum.Tests/native-post-chain-coverage-tests.cs +++ b/Optimum.Tests/native-post-chain-coverage-tests.cs @@ -13,6 +13,7 @@ namespace Optimum.Tests; public class NativePostChainCoverageTests { private const string ChainFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs"; + private const string TailFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostFinal.cs"; /// /// The lib body is split into one virtual per pass, and RenderPostprocessingEffects is only @@ -202,10 +203,13 @@ public void EveryRemainingStepHasALegacyHelperNamingItsStage() "private void PostStepAmbientOcclusion(float[] projectMatrix) => OptimumPostAmbientOcclusion(projectMatrix);", "private bool PostStepTaaResolve() => RenderOptimumTaaResolve();", "private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene);", - "private void PostStepBloom(int scene, int glow) => OptimumPostBloom(scene, glow);", - "private void PostStepGodRays(int scene, int glow) => OptimumPostGodRays(scene, glow);", - "private void PostStepFxaaOrBlit(int scene) => OptimumPostLuma(scene);", + "private void PostStepBloom(int scene, int glow) => NativeBloom(scene, glow);", + "private void PostStepGodRays(int scene, int glow) => NativeGodRays(scene, glow);", + "private void PostStepFxaaOrBlit(int scene) => NativePostLuma(scene);", "private void PostStepFinish() => OptimumPostFinish();", + "private void LegacyBloom(int scene, int glow) => OptimumPostBloom(scene, glow);", + "private void LegacyGodRays(int scene, int glow) => OptimumPostGodRays(scene, glow);", + "private void LegacyPostLuma(int scene) => OptimumPostLuma(scene);", "private void LegacyFinalComposition()", "private void LegacyOitMerge()", "private bool LegacySkyMotion()", @@ -214,14 +218,121 @@ public void EveryRemainingStepHasALegacyHelperNamingItsStage() Assert.Contains(helper, chain); } - foreach (string stage in new[] { "Stage 1c makes it native", "Stage 1d makes it native", - "Stage 1e makes it native", "Stage 1f makes it native", "Stage 1g makes it native" }) + foreach (string stage in new[] { "Stage 1c makes it native", "Stage 1d makes it native" }) { Assert.Contains(stage, chain); } - // One per remaining pass: the seven steps above plus the merge, sky motion and the - // final composition, whose old routes stay reachable through the chain switch. - Assert.Equal(10, Count(chain, "LEGACY -")); + // One old route per pass that is not native yet, plus one per native pass that keeps its + // OpenGL body reachable for the differential tests: the merge, sky motion, the AO step, + // the resolve, the sharpen, bloom, god rays, the Luma step and the final composition. + Assert.Equal(9, Count(chain, "LEGACY -")); + } + + /// + /// The chain's tail - bloom, god rays, the Luma step and the final composition - draws through + /// the device API, with the OpenGL body's conditions, inputs and uniform values, and the final + /// composition declares the attachment subset that lets it sample Primary colour 1 while it + /// writes Primary colour 0. + /// + [Fact] + public void TheChainTailDrawsNatively() + { + string tail = Read(TailFile); + + Assert.Contains("device.BeginNativePass(new NativePassDescription", tail); + Assert.Contains("device.DrawNativeFullscreen(pipeline, new[]", tail); + Assert.Contains("device.EndNativePass();", tail); + + // Bloom: find-bright then the two blur ping-pongs, the stale full-resolution frameSize + // the OpenGL body reuses for all four blur draws, and the block's blend and viewport + // handoff outside the passes. + Assert.Contains("private void NativeBloom(int scene, int glow)", tail); + Assert.Contains("if (!OptimumRenderBloom) return;", tail); + Assert.Contains("float blurWidth = client.Width * ssaa;", tail); + Assert.Contains("NativeBlurStep(lowV, nativeBlurLowVertical", tail); + Assert.Contains("GlToggleBlend(on: false);", tail); + Assert.Contains("GlToggleBlend(on: true);", tail); + + // God rays: the half-resolution target, the full-resolution input texel size, and + // LightPosition3D behind sunPos3dIn. + Assert.Contains("private void NativeGodRays(int scene, int glow)", tail); + Assert.Contains("if (!OptimumRenderGodRays) return;", tail); + Assert.Contains("ShaderUniforms.LightPosition3D", tail); + Assert.Contains("device.WriteNative(pipeline, nativeGodRays.Uniforms[1], OptimumConfig.GodRaysSampleLimit);", tail); + + // The Luma branch: the raw jittered Primary colour for the FXAA prepass, the chain's + // scene for the blit, and blending left off. + Assert.Contains("bool fxaa = OptimumRenderFxaa && !TaaResolvedThisFrame;", tail); + Assert.Contains("int source = fxaa ? primary!.ColorTextureIds[0] : scene;", tail); + Assert.Contains("SetBlendEnabled(false);", tail); + + // The final composition: the attachment subset, the unconditional uniform writes and the + // draw-buffer handoff around the pass. + Assert.Contains("private void NativeFinalComposition()", tail); + Assert.Contains("const uint slots = ~(1u << 1);", tail); + Assert.Contains("BeginFinalCompositionDrawBuffers();", tail); + Assert.Contains("RestoreWorldDrawBuffers(renderSsao);", tail); + Assert.Contains("device.WriteNative(pipeline, u[1], (OptimumSsaoInScene || !renderSsao) ? 1 : 0);", tail); + Assert.Contains("device.WriteNative(pipeline, u[2], aoDebugView ? 1 : 0);", tail); + Assert.Contains("ShaderUniforms.SunPosition3D", tail); + Assert.Contains("aoDebugView && aoTexture != 0", tail); + Assert.Contains("int glow = OptimumPostGlowTexture();", tail); + + string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); + Assert.Contains("NativeFinalComposition();", graph); + } + + /// + /// The client state the native tail reads instead of GL state (decision 3) is a lib accessor, + /// a patcher target and an owned region, and the two post steps that sized themselves from + /// NativeWindow.ClientSize now read the same window-size seam the blit does. + /// + [Fact] + public void TheNativeTailReadsClientStateThroughListedLibSeams() + { + string platform = Platform().Replace("\r\n", "\n"); + string patcher = Read("Optimum.Patcher/Program.cs"); + string regions = Read("Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs"); + + foreach (string member in new[] + { + "public bool OptimumRenderBloom => RenderBloom;", + "public bool OptimumRenderGodRays => RenderGodRays;", + "public bool OptimumRenderFxaa => RenderFXAA;", + "public float OptimumSsaaLevel => ssaaLevel;", + "public int OptimumAmbientOcclusionTexture => optimumAmbientOcclusionTexture;", + "public bool OptimumSsaoInScene => optimumSsaoInScene;", + }) + { + Assert.Contains(member, platform); + } + foreach (string member in new[] + { + "OptimumRenderBloom", "OptimumRenderGodRays", "OptimumRenderFxaa", "OptimumSsaaLevel", + "OptimumAmbientOcclusionTexture", "OptimumSsaoInScene", + }) + { + Assert.Contains("\"" + member + "\"", patcher); + Assert.Contains("\"" + member + "\"", regions); + } + + // The bloom, god-rays and final-composition bodies take the window size from the seam, so + // both routes compute the same value; nothing in them reads NativeWindow.ClientSize. + foreach (string body in new[] + { + "public virtual void OptimumPostBloom(int postSceneTexture, int postGlowTexture)", + "public virtual void OptimumPostGodRays(int postSceneTexture, int postGlowTexture)", + "public override void RenderFinalComposition()", + }) + { + int start = platform.IndexOf(body, StringComparison.Ordinal); + Assert.True(start >= 0, body + " is missing"); + int end = platform.IndexOf("\n\t}\n", start, StringComparison.Ordinal); + Assert.True(end > start); + string source = platform.Substring(start, end - start); + Assert.Contains("Size2i optimumClientSize = OptimumWindowClientSize();", source); + Assert.DoesNotContain("((NativeWindow)window).ClientSize", source); + } } private static string Platform() => ReadPatchedOrSource( diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index d56abc10..b4f4d5ef 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..c402ea4 100644 +index 6edf0c9..a4f1e3c 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -243,7 +243,7 @@ index 6edf0c9..c402ea4 100644 private bool serverRunning; private bool gamepause; -@@ -109,10 +315,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -109,10 +315,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private bool RenderFXAA; @@ -256,13 +256,29 @@ index 6edf0c9..c402ea4 100644 + /// bodies do. + /// + public bool OptimumRenderSsao => RenderSSAO; ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): the post chain's per-frame switches and ++ /// the render scale, as window_RenderFrame computed them. A native post pass reads client ++ /// state, never GL state (decision 3), and these are private fields the platform cannot see. ++ /// ++ public bool OptimumRenderBloom => RenderBloom; ++ ++ /// Optimum (Phase 3b): whether this frame draws god rays. ++ public bool OptimumRenderGodRays => RenderGodRays; ++ ++ /// Optimum (Phase 3b): whether this frame runs the FXAA luma prepass. ++ public bool OptimumRenderFxaa => RenderFXAA; ++ ++ /// Optimum (Phase 3b): the supersampling factor the render targets were built at. ++ public float OptimumSsaaLevel => ssaaLevel; + private bool SetupSSAO; private int ShadowMapQuality; private float ssaaLevel; -@@ -200,11 +414,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -200,11 +430,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return audio.MasterSoundLevel; } @@ -279,7 +295,7 @@ index 6edf0c9..c402ea4 100644 public override AssetManager AssetManager => assetManager; -@@ -256,10 +474,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +490,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -303,7 +319,7 @@ index 6edf0c9..c402ea4 100644 get { return serverRunning; -@@ -278,34 +509,65 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,34 +525,65 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -377,7 +393,7 @@ index 6edf0c9..c402ea4 100644 public override bool GlDebugMode { get -@@ -379,10 +641,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -379,10 +657,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public void StartAudio() { if (audio == null) @@ -397,7 +413,7 @@ index 6edf0c9..c402ea4 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +749,154 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +765,154 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -559,7 +575,7 @@ index 6edf0c9..c402ea4 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +916,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +932,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -572,7 +588,7 @@ index 6edf0c9..c402ea4 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1087,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1103,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -617,7 +633,7 @@ index 6edf0c9..c402ea4 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1199,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1215,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -637,7 +653,7 @@ index 6edf0c9..c402ea4 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1435,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1451,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -650,7 +666,7 @@ index 6edf0c9..c402ea4 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1150,143 +1562,940 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,139 +1578,936 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -884,10 +900,6 @@ index 6edf0c9..c402ea4 100644 - GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); -- DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; + return; + } + if (Vintagestory.API.Config.OptimumParityDump.Enabled && !optimumParityDumpDone) @@ -1699,16 +1711,12 @@ index 6edf0c9..c402ea4 100644 + GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); + GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, num, num2, 0, val, (PixelType)5121, (IntPtr)IntPtr.Zero); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); -+ DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36066, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[2], 0); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36096, (TextureTarget)3553, list[0].DepthTextureId, 0); + DrawBuffersEnum[] array5 = new DrawBuffersEnum[3] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2 }; GL.DrawBuffers(3, array5); - ClearFrameBuffer(EnumFrameBuffer.Transparent); - CheckFboStatus((FramebufferTarget)36160, EnumFrameBuffer.Transparent); - if (SetupSSAO) - { -@@ -1436,10 +2645,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2661,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1784,7 +1792,7 @@ index 6edf0c9..c402ea4 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2822,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2838,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1799,7 +1807,7 @@ index 6edf0c9..c402ea4 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2845,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2861,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1894,7 +1902,7 @@ index 6edf0c9..c402ea4 100644 } } } -@@ -1591,11 +2939,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +2955,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1913,7 +1921,7 @@ index 6edf0c9..c402ea4 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +2974,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +2990,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -2000,7 +2008,7 @@ index 6edf0c9..c402ea4 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3083,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3099,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2091,7 +2099,7 @@ index 6edf0c9..c402ea4 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3168,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3184,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2177,7 +2185,7 @@ index 6edf0c9..c402ea4 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,50 +3250,403 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,50 +3266,408 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2589,8 +2597,13 @@ index 6edf0c9..c402ea4 100644 - int y = ((NativeWindow)window).ClientSize.Y; if (RenderBloom) { -+ int x = ((NativeWindow)window).ClientSize.X; -+ int y = ((NativeWindow)window).ClientSize.Y; ++ // Optimum (Phase 3b): the window size through the seam the native blit already ++ // reads it through, so the OpenGL body and the native pass take the same value ++ // (docs/vulkan-native-render-systems.md, decision 3). The GL body of ++ // OptimumWindowClientSize is this exact ClientSize read. ++ Size2i optimumClientSize = OptimumWindowClientSize(); ++ int x = optimumClientSize.Width; ++ int y = optimumClientSize.Height; GlToggleBlend(on: false); LoadFrameBuffer(EnumFrameBuffer.FindBright); ShaderProgramFindbright findbright = ShaderPrograms.Findbright; @@ -2604,7 +2617,7 @@ index 6edf0c9..c402ea4 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3658,151 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3679,162 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2613,7 +2626,7 @@ index 6edf0c9..c402ea4 100644 - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X / 4f), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y / 4f)); + // Mono.Cecil transplant: GlViewport is the routed form of GL.Viewport + // and its GL body is the identical call. -+ GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X / 4f), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y / 4f)); ++ GlViewport(0, 0, (int)(ssaaLevel * (float)x / 4f), (int)(ssaaLevel * (float)y / 4f)); LoadFrameBuffer(EnumFrameBuffer.BlurHorizontalLowRes); blur.IsVertical = 0; blur.InputTexture2D = frameBuffers[3].ColorTextureIds[0]; @@ -2624,7 +2637,7 @@ index 6edf0c9..c402ea4 100644 RenderFullscreenTriangle(screenQuad); blur.Stop(); - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); -+ GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); ++ GlViewport(0, 0, (int)(ssaaLevel * (float)x), (int)(ssaaLevel * (float)y)); GlToggleBlend(on: true); } + } @@ -2634,8 +2647,13 @@ index 6edf0c9..c402ea4 100644 + { if (RenderGodRays) { -+ int x = ((NativeWindow)window).ClientSize.X; -+ int y = ((NativeWindow)window).ClientSize.Y; ++ // Optimum (Phase 3b): the window size through the seam the native blit already ++ // reads it through, so the OpenGL body and the native pass take the same value ++ // (docs/vulkan-native-render-systems.md, decision 3). The GL body of ++ // OptimumWindowClientSize is this exact ClientSize read. ++ Size2i optimumClientSize = OptimumWindowClientSize(); ++ int x = optimumClientSize.Width; ++ int y = optimumClientSize.Height; LoadFrameBuffer(EnumFrameBuffer.GodRays); ShaderProgramGodrays godrays = ShaderPrograms.Godrays; godrays.Use(); @@ -2692,7 +2710,7 @@ index 6edf0c9..c402ea4 100644 - bilateralblur.Stop(); - GlToggleBlend(on: true); - GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); -+ GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); ++ GlViewport(0, 0, (int)(ssaaLevel * (float)x), (int)(ssaaLevel * (float)y)); } - if (RenderFXAA) + } @@ -2757,6 +2775,12 @@ index 6edf0c9..c402ea4 100644 + /// + private int optimumAmbientOcclusionTexture; + ++ /// Optimum (Phase 3b): this frame's platform AO texture, for a native final composition. ++ public int OptimumAmbientOcclusionTexture => optimumAmbientOcclusionTexture; ++ ++ /// Optimum (Phase 3b): whether the AO multiply already ran, for a native final composition. ++ public bool OptimumSsaoInScene => optimumSsaoInScene; ++ + /// Optimum TAA: multiply the jittered AO into Primary colour 0 before the resolve, touching nothing else. + private void ApplyOptimumSceneSsao() + { @@ -2803,7 +2827,7 @@ index 6edf0c9..c402ea4 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3812,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,26 +3844,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2835,6 +2859,7 @@ index 6edf0c9..c402ea4 100644 - final.SsaoScene2D = frameBuffers[14].ColorTextureIds[0]; + final.SsaoScene2D = ((optimumAoDebugView && optimumAmbientOcclusionTexture != 0) ? optimumAmbientOcclusionTexture : frameBuffers[14].ColorTextureIds[0]); } +- final.Uniform("invFrameSizeIn", 1f / ((float)((NativeWindow)window).ClientSize.X * ssaaLevel), 1f / ((float)((NativeWindow)window).ClientSize.Y * ssaaLevel)); + // Optimum TAA: written every frame, never conditionally - a declared uniform + // left unset reads back as whatever the Vulkan uniform ring last held. + // Optimum AO: the flag means "AO is not Final's to apply". With AO switched off @@ -2843,12 +2868,18 @@ index 6edf0c9..c402ea4 100644 + // set for that case too, which is the one state vanilla never produced. + final.Uniform("optimumSsaoInScene", (optimumSsaoInScene || !RenderSSAO) ? 1 : 0); + final.Uniform("optimumAoDebug", optimumAoDebugView ? 1 : 0); - final.Uniform("invFrameSizeIn", 1f / ((float)((NativeWindow)window).ClientSize.X * ssaaLevel), 1f / ((float)((NativeWindow)window).ClientSize.Y * ssaaLevel)); ++ // Optimum (Phase 3b): the window size through the seam the native blit already ++ // reads it through, so the OpenGL body and the native pass take the same value ++ // (docs/vulkan-native-render-systems.md, decision 3). The GL body of ++ // OptimumWindowClientSize is this exact ClientSize read. ++ Size2i optimumClientSize = OptimumWindowClientSize(); ++ final.Uniform("invFrameSizeIn", 1f / ((float)optimumClientSize.Width * ssaaLevel), 1f / ((float)optimumClientSize.Height * ssaaLevel)); final.GammaLevel = ClientSettings.GammaLevel; final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3860,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + final.SepiaLevel = ShaderUniforms.SepiaLevel + ShaderUniforms.ExtraSepia; +@@ -1987,24 +3897,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2905,7 +2936,7 @@ index 6edf0c9..c402ea4 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3915,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3952,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3403,7 +3434,7 @@ index 6edf0c9..c402ea4 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4563,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4600,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3443,7 +3474,7 @@ index 6edf0c9..c402ea4 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4961,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4998,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3496,7 +3527,7 @@ index 6edf0c9..c402ea4 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +5056,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5093,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3541,7 +3572,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5093,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5130,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3562,7 +3593,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5149,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3583,7 +3614,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5131,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5168,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3604,7 +3635,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5150,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5187,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3625,7 +3656,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5173,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5210,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3646,7 +3677,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5735,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5772,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3670,7 +3701,7 @@ index 6edf0c9..c402ea4 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6094,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6131,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From dba8db54147f110465ff3a75d20f05b70c128536 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 10:35:16 +0200 Subject: [PATCH 189/226] wip(native-post): the TAA resolve and sharpen draw natively Phase 3b stage 1d (docs/vulkan-native-render-systems.md, section 3): passes 4 and 5 of the post chain - the TAA resolve and the TAA sharpen - draw through the native device API instead of the GL-shaped platform calls. The draw is a seam of its own. RenderOptimumTaaResolve and RenderOptimumTaaSharpen keep every decision they always made - the guards, the jittered and previous view-projections, the reset test, and afterwards taaResolvedColorTexture/GlowTexture, _taaHistoryValid, the _taaFrameParity flip and optimumTaaResolvedThisFrame - and hand the draw to OptimumTaaResolveDraw / OptimumTaaSharpenDraw, new virtuals on ClientPlatformAbstract whose ClientPlatformWindows bodies are the inline code lifted out unchanged. The temporal contract therefore cannot drift between the backends: only the draw changes route, and OpenGL runs the identical sequence it ran before. CLAUDE.md rule 11 is untouched - the 3x3 nearest-depth disocclusion and the luminance anti-flicker weighting live in taa-resolve.fsh, which both routes run as it stands. VulkanClientPlatform.NativePostChain.cs gains NativeTaaResolve and NativeTaaSharpen: RequestNativePipeline with the fixed state stated outright (unblended writes on the slots the pass owns, no depth test or write, no culling, triangle list, the target's formats), BeginNativePass with the history slot or the sharpen target, its colour slots and its declared reads, WriteNative by placement for the nine resolve uniforms and the sharpen's two, and DrawNativeFullscreen resolving the seven sampled textures straight to bindless slots. The GL-shaped restore the rest of the chain inherits - blending on, the depth test on, Primary bound - stays outside the pass, where the OpenGL body leaves it. Verified: dotnet build VintageStory.slnx -c Release (0 errors); dotnet test Optimum.Tests -c Release (1236 passed); dotnet test Optimum.Render.Vulkan.Tests (1045 passed, sync+best validation clean) with the implicit-layer disable set and VK_LOADER_DEBUG=layer showing only VK_LAYER_MESA_device_select inserted; extract-patches + check-patches (157 patches, 0 conflict). New GPU tests in NativePostChainTests: the resolve's three history attachments match the OpenGL body from a cold history and from a warm one, the sharpen target matches it at two strengths, both passes are one native pass and one native draw with no emulation call inside, the resolve draws nothing with TAA off, the sharpen is skipped on both routes at zero sharpness, and five frames with no readback between them land somewhere one frame does not and land where the OpenGL body lands. Source coverage in native-post-chain-coverage-tests pins the contract to the lib body and the two seams to the patcher, the owned regions and the platform's self-check. --- Optimum.Patcher/Program.cs | 9 + .../NativePostChainTests.cs | 398 +++++++++++++++++- .../Platform/VulkanClientPlatform.Graph.cs | 26 ++ .../VulkanClientPlatform.NativePostChain.cs | 181 +++++++- .../Platform/VulkanClientPlatform.cs | 4 + ...-platform-windows-vanilla-regions-tests.cs | 2 +- .../native-post-chain-coverage-tests.cs | 109 ++++- Optimum.Tests/taa-sharpen-coverage-tests.cs | 28 +- .../ClientPlatformAbstract.cs.patch | 24 +- .../ClientPlatformWindows.cs.patch | 84 ++-- 10 files changed, 818 insertions(+), 47 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 8e29fe5a..52c4f998 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -84,6 +84,10 @@ "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", + // Phase 3b stage 1d: the draw seams of the two TAA passes, so a native platform + // replaces the draw while the temporal contract stays in the lib body. + "OptimumTaaResolveDraw", + "OptimumTaaSharpenDraw", "OptimumFsrBlitActive", "DisableOptimumTaa", // Optimum AO: the platform's own ambient occlusion (0 = vanilla SSAO) and its debug outputs. @@ -255,6 +259,11 @@ "OptimumPostLuma", "OptimumPostFinish", "OptimumBindKeepViewport", + // Phase 3b stage 1d: the two TAA passes' draws, lifted out of their bodies so the + // native chain replaces the draw while the temporal contract - the reset decision, + // the resolved textures, the history validity and the parity flip - stays put. + "OptimumTaaResolveDraw", + "OptimumTaaSharpenDraw", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs index a5c98761..dec354a1 100644 --- a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -32,13 +32,30 @@ namespace Optimum.Render.Vulkan.Tests; /// 3. neither native pass reaches the GL-emulation layer while its pass is open; /// 4. over several frames the chain runs its steps in the declared order and the TAA resolve /// keeps accumulating - the history parity alternates and the motion attachment the resolve -/// reads was written by the two passes that run before it. +/// reads was written by the two passes that run before it; +/// 5. the TAA resolve and the TAA sharpen draw the same pixels natively as on the OpenGL body, +/// from a cold history and from a warm one, and the sharpen is skipped on both routes when +/// there is nothing to sharpen; +/// 6. several frames of the native resolve with no readback between them keep accumulating - +/// the result after five frames is not the result after one, and it is the OpenGL body's. /// public class NativePostChainTests(ITestOutputHelper output) { private const int Size = 16; - private static readonly string[] Programs = { "transparentcompose", "taa-skymotion", "taa-resolve", "blit" }; + private static readonly string[] Programs = + { "transparentcompose", "taa-skymotion", "taa-resolve", "taa-sharpen", "blit" }; + + /// + /// A jitter phase per loop step, pinned so two runs of the same length see the same sequence + /// whatever the global frame counter happens to be. The values are Halton-shaped: sub-pixel, + /// never zero, never repeating inside one run. + /// + private static readonly (float X, float Y)[] JitterPhases = + { + (0.25f, -0.375f), (-0.125f, 0.25f), (0.375f, 0.125f), + (-0.375f, -0.25f), (0.125f, 0.375f), (-0.25f, -0.125f), + }; /// The Vulkan platform without a window: the size seam answers for one. private sealed class ChainPlatform : VulkanClientPlatform @@ -245,10 +262,263 @@ public void TheChainKeepsItsOrderAcrossFramesAndTaaKeepsAccumulating() GpuTest.AssertClean(seam); } + /// + /// The TAA resolve, natively: all three attachments of the history slot it writes - the + /// resolved colour, the resolved glow and the linear depth - have to be the OpenGL body's, + /// from a cold history (the reset frame, which copies the scene through) and from a warm one + /// (the frame that actually blends history in). + /// + /// CLAUDE.md rule 11 is the shader's, and both routes run the same taa-resolve.fsh: what is + /// asserted here is that the native route feeds it the same seven textures and the same nine + /// uniform values, so the 3x3 nearest-depth disocclusion and the luminance anti-flicker + /// weighting see identical inputs and produce identical pixels. + /// + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void TheTaaResolveMatchesTheOpenGlBody(bool warmHistory) + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + session.PatternedScene = true; + + Resolved emulated = RunResolve(session, native: false, warmHistory); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + Resolved nativeRoute = RunResolve(session, native: true, warmHistory); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + Assert.Equal(emulated.Color, nativeRoute.Color); + Assert.Equal(emulated.Glow, nativeRoute.Glow); + Assert.Equal(emulated.Depth, nativeRoute.Depth); + + // The pass wrote a resolved image over the seed, or the comparison above would hold + // for two routes that both wrote nothing. + Assert.NotEqual(session.HistorySeedColor, nativeRoute.Color); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The TAA resolve with TAA off: the lib body returns before any draw, so the native route + /// declares no pass at all and the history is marked invalid on both routes. + /// + [SkippableFact] + public void TheTaaResolveDrawsNothingWithTaaOff() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + OptimumConfig.Taa = false; + + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = true; + long passesBefore = session.Seam.NativePassesForTests; + + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + Assert.False(platform.RenderOptimumTaaResolve()); + platform.EndFrame(); + + Assert.Equal(0, session.Seam.NativePassesForTests - passesBefore); + Assert.False(platform.TaaResolvedThisFrame); + Assert.False(HistoryValid(platform)); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The TAA sharpen, natively: the sharpen target's single attachment has to be the OpenGL + /// body's at every strength the setting can take, and the texture the pass hands on to the + /// rest of the chain has to be the sharpen target either way. + /// + [SkippableTheory] + [InlineData(1f)] + [InlineData(0.35f)] + public void TheTaaSharpenMatchesTheOpenGlBody(float sharpness) + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + session.PatternedScene = true; + OptimumConfig.TaaSharpness = sharpness; + + byte[] emulated = RunSharpen(session, native: false, out int emulatedTexture); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] nativeRoute = RunSharpen(session, native: true, out int nativeTexture); + + // One native pass for the resolve that has to run first, one for the sharpen. + Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + Assert.Equal(session.Sharpen.ColorTextureIds[0], emulatedTexture); + Assert.Equal(session.Sharpen.ColorTextureIds[0], nativeTexture); + Assert.Equal(emulated, nativeRoute); + Assert.NotEqual(session.SharpenSeed, nativeRoute); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The sharpen's conditions live in the lib body, so both routes skip it on exactly the same + /// frames: sharpness at zero hands the resolved texture straight on and draws nothing. + /// + [SkippableFact] + public void TheTaaSharpenIsSkippedOnBothRoutesWhenThereIsNothingToSharpen() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + OptimumConfig.TaaSharpness = 0f; + + ChainPlatform platform = session.Platform; + foreach (bool native in new[] { false, true }) + { + platform.NativePostChainEnabled = native; + long passesBefore = session.Seam.NativePassesForTests; + + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + Assert.True(platform.RenderOptimumTaaResolve()); + int resolved = platform.OptimumPostSceneTexture(); + Assert.Equal(resolved, platform.RenderOptimumTaaSharpen(resolved)); + platform.EndFrame(); + + // The resolve's pass on the native route, and nothing for the sharpen. + Assert.Equal(native ? 1 : 0, session.Seam.NativePassesForTests - passesBefore); + } + + GpuTest.AssertClean(session.Seam); + } + + /// + /// Several frames of the native resolve with nothing read back between them: the history is + /// still being accumulated, not replaced. Five frames do not land where one frame lands - + /// each frame reprojects the previous slot at its own sub-pixel jitter and blends it in - + /// and where they land is the OpenGL body's answer to the identical sequence. + /// + /// A single-frame readback cannot see this: it passed while the R32F-history and + /// masked-clear bugs were live (P2, 2026-09-10), which is why the loop below reads nothing. + /// + [SkippableFact] + public void TheNativeResolveKeepsAccumulatingHistoryAcrossFrames() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + session.PatternedScene = true; + + // The same last frame - the same jitter phase, the same scene, the same slot - reached + // two ways: cold, and after four frames of history. Pinning the phase is what makes the + // difference between them history and nothing else. + byte[] lastFrameAlone = RunResolveFrames(session, native: true, frames: 1, startPhase: 4); + byte[] fiveFrames = RunResolveFrames(session, native: true, frames: 5, startPhase: 0); + Assert.NotEqual(lastFrameAlone, fiveFrames); + + byte[] emulatedFive = RunResolveFrames(session, native: false, frames: 5, startPhase: 0); + Assert.Equal(emulatedFive, fiveFrames); + + GpuTest.AssertClean(session.Seam); + } + // ---------------------------------------------------------------------- driving private readonly record struct Frame(byte[] Scene, byte[] Glow, byte[] Motion); + /// The three attachments of the history slot a resolve wrote. + private readonly record struct Resolved(byte[] Color, byte[] Glow, byte[] Depth); + + /// + /// One TAA resolve on the route under test, from an identically seeded frame: the history + /// parity pinned to slot A, both history slots seeded, and the jitter pinned to phase 0 so + /// the two routes see the same sub-pixel offset. + /// + private Resolved RunResolve(Session session, bool native, bool warmHistory) + { + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = native; + session.AdvanceTemporalFrame(0); + SetParity(platform, 0); + SetHistoryValid(platform, warmHistory); + + platform.BeginFrame(); + session.SeedFrame(); + session.SeedHistory(); + platform.CurrentFrameBuffer = session.Primary; + + Assert.True(platform.RenderOptimumTaaResolve(), "the resolve did not run"); + Assert.Equal(1, Parity(platform)); + + var resolved = new Resolved(session.ReadHistoryColor(0), session.ReadHistoryGlow(0), + session.ReadHistoryDepth(0)); + platform.EndFrame(); + return resolved; + } + + /// One resolve and one sharpen on the route under test, from an identically seeded frame. + private byte[] RunSharpen(Session session, bool native, out int handedOn) + { + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = native; + session.AdvanceTemporalFrame(0); + SetParity(platform, 0); + SetHistoryValid(platform, true); + + platform.BeginFrame(); + session.SeedFrame(); + session.SeedHistory(); + session.SeedSharpen(); + platform.CurrentFrameBuffer = session.Primary; + + Assert.True(platform.RenderOptimumTaaResolve(), "the resolve did not run"); + handedOn = platform.RenderOptimumTaaSharpen(platform.OptimumPostSceneTexture()); + + byte[] sharpened = session.ReadSharpen(); + platform.EndFrame(); + return sharpened; + } + + /// + /// resolves on the route under test, with no readback inside the + /// loop - only the frame boundary between them - and the history read once at the end. The + /// run starts cold, so the first frame is the reset frame and every frame after it blends. + /// An odd frame count always ends on slot A, so two runs of different length are comparable. + /// + private byte[] RunResolveFrames(Session session, bool native, int frames, int startPhase) + { + Assert.True(frames % 2 == 1, "an even frame count would end on the other history slot"); + ChainPlatform platform = session.Platform; + platform.NativePostChainEnabled = native; + SetParity(platform, 0); + SetHistoryValid(platform, false); + + platform.BeginFrame(); + session.SeedHistory(); + platform.EndFrame(); + + for (int frame = 0; frame < frames; frame++) + { + session.AdvanceTemporalFrame(startPhase + frame); + platform.BeginFrame(); + session.SeedFrame(); + platform.CurrentFrameBuffer = session.Primary; + Assert.True(platform.RenderOptimumTaaResolve(), "the resolve did not run on frame " + frame); + platform.EndFrame(); + } + + platform.BeginFrame(); + byte[] history = session.ReadHistoryColor(0); + platform.EndFrame(); + return history; + } + /// One OIT merge, on the route under test, from an identically seeded frame. private Frame RunMerge(Session session, bool native) { @@ -293,6 +563,21 @@ private static int ResolvedColorTexture(ClientPlatformWindows platform) => .GetField("taaResolvedColorTexture", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(platform)!; + private static int Parity(ClientPlatformWindows platform) => + (int)ParityField.GetValue(platform)!; + + private static void SetParity(ClientPlatformWindows platform, int parity) => + ParityField.SetValue(platform, parity); + + private static void SetHistoryValid(ClientPlatformWindows platform, bool valid) => + HistoryValidField.SetValue(platform, valid); + + private static readonly FieldInfo ParityField = typeof(ClientPlatformWindows) + .GetField("_taaFrameParity", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly FieldInfo HistoryValidField = typeof(ClientPlatformWindows) + .GetField("_taaHistoryValid", BindingFlags.Instance | BindingFlags.NonPublic)!; + private static bool HistoryValid(ClientPlatformWindows platform) => (bool)typeof(ClientPlatformWindows) .GetField("_taaHistoryValid", BindingFlags.Instance | BindingFlags.NonPublic)! @@ -321,6 +606,19 @@ private sealed class Session : IDisposable public VulkanDevice Seam => Platform.GraphicsDevice!; public FrameBufferRef Primary { get; private set; } = null!; public FrameBufferRef Transparent { get; private set; } = null!; + public FrameBufferRef Sharpen { get; private set; } = null!; + + /// + /// True leaves Primary's colour 0 as the pattern it was created with instead of clearing + /// it flat: the TAA resolve's variance clip box collapses on a flat image, so a flat + /// scene would make every frame after the first return the current frame untouched and + /// hide whether history is being blended in at all. + /// + public bool PatternedScene { get; set; } + + /// The history slots' seed and the sharpen target's, as the readbacks decode them. + public byte[] HistorySeedColor { get; private set; } = Array.Empty(); + public byte[] SharpenSeed { get; private set; } = Array.Empty(); /// The motion attachment's seed, decoded the way decodes it. public byte[] MotionSeed { get; private set; } = Array.Empty(); @@ -331,6 +629,7 @@ private sealed class Session : IDisposable private readonly List buffers = new(); private int oitReveal; private int oitAccumulation; + private int scenePattern; private int decodeProgram; private int decodeTarget; private int decodeFramebuffer; @@ -339,6 +638,7 @@ private sealed class Session : IDisposable private ShaderProgramTransparentcompose? composeBefore; private ShaderProgram? skyMotionBefore; private ShaderProgram? resolveBefore; + private ShaderProgram? sharpenBefore; private ShaderProgramBlit? blitBefore; private bool taaBefore; private float sharpnessBefore; @@ -382,6 +682,7 @@ private sealed class Session : IDisposable composeBefore = ShaderPrograms.Transparentcompose, skyMotionBefore = ShaderPrograms.TaaSkyMotion, resolveBefore = ShaderPrograms.TaaResolve, + sharpenBefore = ShaderPrograms.TaaSharpen, blitBefore = ShaderPrograms.Blit, taaBefore = OptimumConfig.Taa, sharpnessBefore = OptimumConfig.TaaSharpness, @@ -402,6 +703,7 @@ public void Dispose() ShaderPrograms.Transparentcompose = composeBefore!; ShaderPrograms.TaaSkyMotion = skyMotionBefore!; ShaderPrograms.TaaResolve = resolveBefore!; + ShaderPrograms.TaaSharpen = sharpenBefore!; ShaderPrograms.Blit = blitBefore!; OptimumConfig.Taa = taaBefore; OptimumConfig.TaaSharpness = sharpnessBefore; @@ -439,10 +741,11 @@ public void SeedFrame() seam.BindFramebuffer(Primary.FboId); seam.SetDrawBuffers(Primary.FboId, 0b111); - seam.ClearColor(0, 0.25f, 0.5f, 0.75f, 1f); + if (!PatternedScene) seam.ClearColor(0, 0.25f, 0.5f, 0.75f, 1f); seam.ClearColor(1, 0.125f, 0.25f, 0.375f, 1f); seam.ClearColor(2, 0f, 0f, 0.125f, 0.5f); seam.ClearDepth(1f); + if (PatternedScene) DrawScenePattern(); // Primary's default colour set: two attachments without the SSAO G-buffer. seam.SetDrawBuffers(Primary.FboId, 0b011); @@ -460,6 +763,57 @@ public void SeedFrame() seam.BindTexture(7, oitAccumulation); } + /// + /// The scene pattern into Primary's colour 0, the same every frame: a clear can only + /// write a flat image, and a flat image collapses the resolve's variance clip box. + /// + private void DrawScenePattern() + { + VulkanDevice seam = Seam; + seam.SetDrawBuffers(Primary.FboId, 0b001); + seam.UseProgram(decodeProgram); + seam.SetSamplerUnit(decodeProgram, "source", 15); + seam.BindTexture(15, scenePattern); + SetInt(seam, decodeProgram, "motionMode", 0); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + } + + /// + /// Both history slots to a flat seed, so a resolve starts from known content on either + /// route. Inside a frame: a clear between frames is a no-op on this seam. + /// + public void SeedHistory() + { + VulkanDevice seam = Seam; + for (int parity = 0; parity < 2; parity++) + { + FrameBufferRef slot = History(parity); + seam.BindFramebuffer(slot.FboId); + seam.SetDrawBuffers(slot.FboId, 0b111); + seam.ClearColor(0, 0.9f, 0.1f, 0.4f, 1f); + seam.ClearColor(1, 0.4f, 0.9f, 0.1f, 1f); + seam.ClearColor(2, 12.5f, 0f, 0f, 1f); + } + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, 0b011); + } + + /// The sharpen target to a flat seed, so "the pass wrote something" is checkable. + public void SeedSharpen() + { + VulkanDevice seam = Seam; + seam.BindFramebuffer(Sharpen.FboId); + seam.SetDrawBuffers(Sharpen.FboId, 0b1); + seam.ClearColor(0, 0.05f, 0.95f, 0.55f, 1f); + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, 0b011); + } + /// TAA on, with the jitter window open or closed, and no sharpen pass. public void EnableTaa(bool jitterActive) { @@ -474,6 +828,22 @@ public void EnableTaa(bool jitterActive) frame.JitterActive = jitterActive; } + /// + /// One frame of the temporal contract with the jitter pinned to + /// of , so two runs of the same length are comparable whatever + /// the global frame counter is at. + /// + public void AdvanceTemporalFrame(int phase) + { + AdvanceTemporalFrame(); + OptimumTemporalFrame frame = OptimumTemporal.Frame; + (float x, float y) = JitterPhases[phase % JitterPhases.Length]; + frame.JitterSequencePx.X = x; + frame.JitterSequencePx.Y = y; + // The setter is what copies the sequence into the applied jitter. + frame.JitterActive = true; + } + /// /// One frame of the temporal contract: the camera and the projection captured, so the /// sky-motion and resolve passes have a previous view to reproject through. @@ -498,6 +868,18 @@ public void AdvanceTemporalFrame() public byte[] ReadMotion() => Decode(Primary.ColorTextureIds[2], motion: true); + public byte[] ReadHistoryColor(int parity) => Decode(History(parity).ColorTextureIds[0], motion: false); + + public byte[] ReadHistoryGlow(int parity) => Decode(History(parity).ColorTextureIds[1], motion: false); + + /// + /// The history's linear depth, through the motion decode: it is an R32F in view-space + /// metres, which the clamping colour decode would flatten to white everywhere. + /// + public byte[] ReadHistoryDepth(int parity) => Decode(History(parity).ColorTextureIds[2], motion: true); + + public byte[] ReadSharpen() => Decode(Sharpen.ColorTextureIds[0], motion: false); + private unsafe byte[] ReadAttachmentZero(int framebufferId) { var pixels = new byte[Size * Size * 4]; @@ -602,7 +984,10 @@ private void BuildTargets() buffers[10] = SingleTarget(EnumTextureInternalFormat.Rgba8); buffers[19] = HistoryTarget(); buffers[20] = HistoryTarget(); + Sharpen = SingleTarget(EnumTextureInternalFormat.Rgba16f); + buffers[21] = Sharpen; + scenePattern = Seeded(0.45f); decodeTarget = Texture(EnumTextureInternalFormat.Rgba8); decodeFramebuffer = seam.CreateFramebuffer(Size, Size); seam.AttachTexture(decodeFramebuffer, EnumFramebufferAttachment.ColorAttachment0, decodeTarget, 0); @@ -685,6 +1070,8 @@ private void LinkPrograms() { "taaRenderSize", "taaJitterPx", "taaInvViewProjJittered", "taaPrevViewProj", "taaCloudReactive", }); + var sharpen = new ShaderProgram { PassName = "taa-sharpen" }; + Link(seam, sharpen, "taa-sharpen", variant, new[] { "inputTexelSize", "sharpness" }); var resolve = new ShaderProgram { PassName = "taa-resolve" }; Link(seam, resolve, "taa-resolve", variant, new[] { @@ -697,6 +1084,7 @@ private void LinkPrograms() ShaderPrograms.Transparentcompose = compose; ShaderPrograms.TaaSkyMotion = skyMotion; ShaderPrograms.TaaResolve = resolve; + ShaderPrograms.TaaSharpen = sharpen; ShaderPrograms.Blit = blit; decodeProgram = LinkDecode(seam); @@ -803,8 +1191,12 @@ private void InstallState() // The seeds the comparisons quote, read once through the same decode the tests use. Platform.BeginFrame(); SeedFrame(); + SeedHistory(); + SeedSharpen(); SceneSeed = ReadScene(); MotionSeed = ReadMotion(); + HistorySeedColor = ReadHistoryColor(0); + SharpenSeed = ReadSharpen(); Platform.EndFrame(); } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index 94274c22..69b1920a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -355,6 +355,32 @@ public override int RenderOptimumTaaSharpen(int resolvedScene) return sharpened; } + /// + /// Phase 3b stage 1: the resolve's draw, natively. The lib body above keeps every temporal + /// decision and every field it writes afterwards, so only the draw changes route. + /// + public override void OptimumTaaResolveDraw(FrameBufferRef write, FrameBufferRef read, + float[] invViewProjJittered, float[] prevViewProj, bool reset) + { + if (UseNativePostChain) + { + NativeTaaResolve(write, read, invViewProjJittered, prevViewProj, reset); + return; + } + base.OptimumTaaResolveDraw(write, read, invViewProjJittered, prevViewProj, reset); + } + + /// Phase 3b stage 1: the sharpen's draw, natively. + public override void OptimumTaaSharpenDraw(FrameBufferRef target, int resolvedScene) + { + if (UseNativePostChain) + { + NativeTaaSharpen(target, resolvedScene); + return; + } + base.OptimumTaaSharpenDraw(target, resolvedScene); + } + /// Phase 3b stage 1: the chain's ninth pass. Stage 1g makes it native. public override void RenderFinalComposition() { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs index bc2872d6..0fa25a37 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -21,7 +21,7 @@ namespace Optimum.Render.Vulkan.Platform; // and last the blit/FSR/debug step that is already native (VulkanClientPlatform.NativeBlit.cs). // RenderPostprocessingEffects' override runs the steps that live inside it and never calls base. // -// Two helpers draw natively here - the OIT merge and sky motion - through RequestNativePipeline, +// Four helpers draw natively here - the OIT merge, sky motion and the two TAA passes - through RequestNativePipeline, // BeginNativePass, WriteNative and DrawNativeFullscreen, exactly as the blit does. Every other // helper is LEGACY: the same work through the GL-shaped platform calls, which after the split in // ClientPlatformWindows is one lib virtual per pass, so the chain is complete and correct at @@ -305,7 +305,14 @@ private bool NativeSkyMotion() } /// The motion attachment alone, unblended; every other slot masked out of the pass. - private AttachmentBlend[] NativeMotionOnlyBlend(uint slots) + private AttachmentBlend[] NativeMotionOnlyBlend(uint slots) => NativeOpaqueBlend(slots); + + /// + /// Unblended writes on every slot the pass owns, every other slot masked out of it: what a + /// fullscreen pass that owns its pixels asks for, and what the OpenGL body expresses as + /// blending off plus a draw-buffer mask. + /// + private AttachmentBlend[] NativeOpaqueBlend(uint slots) { var blend = new AttachmentBlend[NativeSlotCount(slots)]; for (int i = 0; i < blend.Length; i++) @@ -316,6 +323,151 @@ private AttachmentBlend[] NativeMotionOnlyBlend(uint slots) return blend; } + // ------------------------------------------------------- native passes 4 and 5: TAA + + private readonly NativeFullscreenPass nativeTaaResolve = new("taa-resolve", + new[] + { + "renderSize", "jitterPx", "invViewProjJittered", "prevViewProj", "viewMatrix", + "cameraDelta", "resetHistory", "blendAlpha", "varianceGamma", + }, + new[] { "sceneTex", "glowTex", "motionTex", "depthTex", "historyColor", "historyGlow", "historyDepth" }); + + private readonly NativeFullscreenPass nativeTaaSharpen = new("taa-sharpen", + new[] { "inputTexelSize", "sharpness" }, + new[] { "inputScene" }); + + /// + /// The TAA resolve's draw, drawn natively. Every decision stays in the lib body + /// (ClientPlatformWindows.RenderOptimumTaaResolve) - the guards, the jittered and previous + /// view-projections, the reset test, and afterwards the resolved textures, the history + /// validity and the parity flip - so the temporal contract is bit-identical whichever route + /// draws: this helper only replaces the draw. + /// + /// The history slot owns all three of its attachments (colour, glow and linear depth), so + /// the pass writes all three colour slots with no blending, no depth test and no depth + /// attachment, at the slot's own size. The seven inputs are the pass's declared reads and + /// resolve straight to bindless slots; the nine uniform values are the OpenGL body's, at + /// their placements. CLAUDE.md rule 11 lives in taa-resolve.fsh, which both routes run + /// unchanged - the 3x3 nearest-depth disocclusion and the luminance anti-flicker weighting + /// are the shader's, and nothing here touches them. + /// + private void NativeTaaResolve(FrameBufferRef write, FrameBufferRef read, + float[] invViewProjJittered, float[] prevViewProj, bool reset) + { + List buffers = FrameBuffers; + FrameBufferRef primary = buffers != null && buffers.Count > 0 ? buffers[0] : null!; + ShaderProgram resolve = ShaderPrograms.TaaResolve; + if (primary == null || primary.Disposed || primary.ColorTextureIds == null || + MotionAttachmentIndex < 0 || primary.ColorTextureIds.Length <= MotionAttachmentIndex || + write.ColorTextureIds == null || write.ColorTextureIds.Length < 3 || + read.ColorTextureIds == null || read.ColorTextureIds.Length < 3 || + resolve == null || resolve.LoadError || resolve.Disposed) + { + base.OptimumTaaResolveDraw(write, read, invViewProjJittered, prevViewProj, reset); + return; + } + + // Colour, glow and linear depth: the three attachments the history framebuffer was + // built with, all written by this one draw. + const uint slots = 0b111u; + NativePipeline? pipeline = NativePostPipeline(nativeTaaResolve, resolve, write.FboId, slots, + NativeOpaqueBlend(slots), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) + { + base.OptimumTaaResolveDraw(write, read, invViewProjJittered, prevViewProj, reset); + return; + } + + int scene = primary.ColorTextureIds[0]; + int glow = primary.ColorTextureIds[1]; + int motion = primary.ColorTextureIds[MotionAttachmentIndex]; + int depth = primary.DepthTextureId; + int historyColor = read.ColorTextureIds[0]; + int historyGlow = read.ColorTextureIds[1]; + int historyDepth = read.ColorTextureIds[2]; + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + if (BeginNativeTargetPass("TaaResolve/" + write.FboId, write.FboId, slots, + write.Width, write.Height, + new[] { scene, glow, motion, depth, historyColor, historyGlow, historyDepth })) + { + device.WriteNative(pipeline, nativeTaaResolve.Uniforms[0], write.Width, write.Height); + device.WriteNative(pipeline, nativeTaaResolve.Uniforms[1], frame.JitterPx.X, frame.JitterPx.Y); + WriteNativeMatrix(pipeline, nativeTaaResolve.Uniforms[2], invViewProjJittered); + WriteNativeMatrix(pipeline, nativeTaaResolve.Uniforms[3], prevViewProj); + WriteNativeMatrix(pipeline, nativeTaaResolve.Uniforms[4], frame.CameraMatrixOrigin); + device.WriteNative(pipeline, nativeTaaResolve.Uniforms[5], + frame.CameraPosDelta.X, frame.CameraPosDelta.Y, frame.CameraPosDelta.Z); + device.WriteNative(pipeline, nativeTaaResolve.Uniforms[6], reset ? 1 : 0); + device.WriteNative(pipeline, nativeTaaResolve.Uniforms[7], 0.1f); + device.WriteNative(pipeline, nativeTaaResolve.Uniforms[8], 1.25f); + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeTaaResolve.Samplers[0], scene), + new NativeTexture(nativeTaaResolve.Samplers[1], glow), + new NativeTexture(nativeTaaResolve.Samplers[2], motion), + new NativeTexture(nativeTaaResolve.Samplers[3], depth), + new NativeTexture(nativeTaaResolve.Samplers[4], historyColor), + new NativeTexture(nativeTaaResolve.Samplers[5], historyGlow), + new NativeTexture(nativeTaaResolve.Samplers[6], historyDepth), + }); + } + device.EndNativePass(); + FinishNativeTaaPass(); + } + + /// + /// The TAA sharpen's draw, drawn natively: one colour slot at the sharpen target's own + /// size, unblended, no depth. The conditions, the input texture and the texture the pass + /// hands on stay in the lib body (ClientPlatformWindows.RenderOptimumTaaSharpen). + /// + private void NativeTaaSharpen(FrameBufferRef target, int resolvedScene) + { + ShaderProgram sharpen = ShaderPrograms.TaaSharpen; + if (target.ColorTextureIds == null || target.ColorTextureIds.Length < 1 || target.Disposed || + sharpen == null || sharpen.LoadError || sharpen.Disposed) + { + base.OptimumTaaSharpenDraw(target, resolvedScene); + return; + } + + NativePipeline? pipeline = NativePostPipeline(nativeTaaSharpen, sharpen, target.FboId, 1u, + NativeOpaqueBlend(1u), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) + { + base.OptimumTaaSharpenDraw(target, resolvedScene); + return; + } + + if (BeginNativeTargetPass("TaaSharpen/" + target.FboId, target.FboId, 1u, + target.Width, target.Height, new[] { resolvedScene })) + { + device.WriteNative(pipeline, nativeTaaSharpen.Uniforms[0], 1f / target.Width, 1f / target.Height); + device.WriteNative(pipeline, nativeTaaSharpen.Uniforms[1], + GameMath.Clamp(OptimumConfig.TaaSharpness, 0f, 1f)); + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeTaaSharpen.Samplers[0], resolvedScene), + }); + } + device.EndNativePass(); + FinishNativeTaaPass(); + } + + /// + /// What both TAA draws hand back to the rest of the post chain, which is what the OpenGL + /// body's restore leaves: blending on in the standard mode, the depth test on and Primary + /// bound with its full-resolution viewport. Outside the native pass, on the GL-shaped + /// state, because that is what the steps after it read. + /// + private void FinishNativeTaaPass() + { + GlToggleBlend(on: true); + GlEnableDepthTest(); + LoadFrameBuffer(EnumFrameBuffer.Primary); + } + // ------------------------------------------------------------------ legacy helpers /// LEGACY - pass 1 on the OpenGL body. replaces it; kept as the old route. @@ -341,10 +493,14 @@ private bool LegacySkyMotion() /// private void PostStepAmbientOcclusion(float[] projectMatrix) => OptimumPostAmbientOcclusion(projectMatrix); - /// LEGACY - pass 4, the TAA resolve. Stage 1d makes it native. + /// + /// Pass 4, the TAA resolve. The lib body keeps the temporal contract - the guards, the + /// reset decision, the resolved textures and the history parity - and its draw seam is + /// on the native route. + /// private bool PostStepTaaResolve() => RenderOptimumTaaResolve(); - /// LEGACY - pass 5, the TAA sharpen. Stage 1d makes it native. + /// Pass 5, the TAA sharpen; its draw seam is . private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene); /// LEGACY - pass 6, the bloom chain. Stage 1e makes it native. @@ -420,6 +576,23 @@ private bool BeginNativeKeepViewportPass(string name, int framebufferId, uint co }); } + /// + /// A pass that owns its viewport, the way the OpenGL body's full CurrentFrameBuffer setter + /// does: bind the target and set the viewport to its own size. + /// + private bool BeginNativeTargetPass(string name, int framebufferId, uint colorSlots, + int width, int height, int[] reads) => + device.BeginNativePass(new NativePassDescription + { + Name = name, + FramebufferId = framebufferId, + ColorSlots = colorSlots, + Reads = reads, + Flags = PassFlags.None, + ViewportWidth = width, + ViewportHeight = height, + }); + /// /// The pipeline for one native pass of the chain, with the fixed state stated outright. The /// device caches by (program, formats, blend, depth, cull, topology), so a pass whose blend diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index adef0bf0..0805f946 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -139,6 +139,10 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderOptimumSkyMotion", Array.Empty()), new(true, "RenderOptimumTaaResolve", Array.Empty()), new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), + // Phase 3b stage 1: the two TAA passes' draw seams, which the native chain replaces. + new(true, "OptimumTaaResolveDraw", + new[] { "FrameBufferRef", "FrameBufferRef", "Single[]", "Single[]", "Boolean" }), + new(true, "OptimumTaaSharpenDraw", new[] { "FrameBufferRef", "Int32" }), }; /// diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index accf9df5..7b84afc0 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -48,7 +48,7 @@ public class ClientPlatformWindowsVanillaRegionsTests // Phase 3b: the post chain as one virtual per pass, and the keep-the-viewport bind. "OptimumPostAmbientOcclusion", "OptimumPostSceneTexture", "OptimumPostGlowTexture", "OptimumPostBloom", "OptimumPostGodRays", "OptimumPostLuma", "OptimumPostFinish", - "OptimumBindKeepViewport", + "OptimumBindKeepViewport", "OptimumTaaResolveDraw", "OptimumTaaSharpenDraw", "ReadDefaultFramebuffer", "ReadTextureForParity", "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "RestorePrimaryDrawBuffers", "RestoreWorldDrawBuffers", "SelectBackDrawBuffer", "SelectFsrDrawBuffer", "SetBlendEnabled", diff --git a/Optimum.Tests/native-post-chain-coverage-tests.cs b/Optimum.Tests/native-post-chain-coverage-tests.cs index cf07132a..e0d873c5 100644 --- a/Optimum.Tests/native-post-chain-coverage-tests.cs +++ b/Optimum.Tests/native-post-chain-coverage-tests.cs @@ -214,14 +214,104 @@ public void EveryRemainingStepHasALegacyHelperNamingItsStage() Assert.Contains(helper, chain); } - foreach (string stage in new[] { "Stage 1c makes it native", "Stage 1d makes it native", + foreach (string stage in new[] { "Stage 1c makes it native", "Stage 1e makes it native", "Stage 1f makes it native", "Stage 1g makes it native" }) { Assert.Contains(stage, chain); } - // One per remaining pass: the seven steps above plus the merge, sky motion and the + // Stage 1d is done: the TAA resolve and sharpen draw natively, so neither is a legacy + // helper any more. + Assert.DoesNotContain("Stage 1d makes it native", chain); + // One per remaining pass: the five steps above plus the merge, sky motion and the // final composition, whose old routes stay reachable through the chain switch. - Assert.Equal(10, Count(chain, "LEGACY -")); + Assert.Equal(8, Count(chain, "LEGACY -")); + } + + /// + /// Stage 1d: the TAA resolve and the TAA sharpen draw natively, through a draw seam that + /// leaves every temporal decision in the lib body. The contract is what this pins - the + /// reset test, the resolved textures, the history validity and the parity flip have to stay + /// where both backends run the same code, or the two routes can drift a frame apart. + /// + [Fact] + public void TheTwoTaaPassesDrawNativelyAndKeepTheirContractInTheLibBody() + { + string platform = Platform(); + string abstractPlatform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + // The seams exist on the abstract platform, so a platform can override them, and the + // Windows body is the OpenGL draw. + Assert.Contains("public virtual void OptimumTaaResolveDraw(FrameBufferRef write, FrameBufferRef read, float[] invViewProjJittered, float[] prevViewProj, bool reset)", abstractPlatform); + Assert.Contains("public virtual void OptimumTaaSharpenDraw(FrameBufferRef target, int resolvedScene)", abstractPlatform); + Assert.Contains("public override void OptimumTaaResolveDraw(FrameBufferRef write, FrameBufferRef read, float[] invViewProjJittered, float[] prevViewProj, bool reset)", platform); + Assert.Contains("public override void OptimumTaaSharpenDraw(FrameBufferRef target, int resolvedScene)", platform); + + // The temporal contract stays in the pass, on the far side of the seam. + string resolve = MethodBody(platform, "public override bool RenderOptimumTaaResolve()"); + Assert.Contains("bool reset = frame.Reset || !_taaHistoryValid || !frame.WasViewCaptured(EnumTemporalView.World) || invViewProj == null;", resolve); + Assert.Contains("OptimumTaaResolveDraw(write, read, invViewProj, prevViewProj, reset);", resolve); + foreach (string state in new[] + { + "taaResolvedColorTexture = write.ColorTextureIds[0];", + "taaResolvedGlowTexture = write.ColorTextureIds[1];", + "_taaHistoryValid = true;", + "_taaFrameParity ^= 1;", + "optimumTaaResolvedThisFrame = true;", + }) + { + Assert.Contains(state, resolve); + } + // And nothing of it leaked into the draw. + string draw = MethodBody(platform, + "public override void OptimumTaaResolveDraw(FrameBufferRef write, FrameBufferRef read, float[] invViewProjJittered, float[] prevViewProj, bool reset)"); + Assert.DoesNotContain("_taaFrameParity", draw); + Assert.DoesNotContain("_taaHistoryValid", draw); + Assert.DoesNotContain("optimumTaaResolvedThisFrame", draw); + + // The native route: a pipeline with stated fixed state, a pass with stated reads and + // colour slots, uniforms by placement, textures straight to bindless slots. + string chain = Read(ChainFile); + Assert.Contains("private void NativeTaaResolve(FrameBufferRef write, FrameBufferRef read,", chain); + Assert.Contains("private void NativeTaaSharpen(FrameBufferRef target, int resolvedScene)", chain); + Assert.Contains("nativeTaaResolve = new(\"taa-resolve\"", chain); + Assert.Contains("nativeTaaSharpen = new(\"taa-sharpen\"", chain); + // All three history attachments are the pass's colour slots. + Assert.Contains("const uint slots = 0b111u;", chain); + // The nine resolve uniforms and the seven textures the OpenGL body writes and binds. + foreach (string uniform in new[] + { + "renderSize", "jitterPx", "invViewProjJittered", "prevViewProj", "viewMatrix", + "cameraDelta", "resetHistory", "blendAlpha", "varianceGamma", + }) + { + Assert.Contains("\"" + uniform + "\"", chain); + } + foreach (string sampler in new[] + { + "sceneTex", "glowTex", "motionTex", "depthTex", + "historyColor", "historyGlow", "historyDepth", "inputScene", + }) + { + Assert.Contains("\"" + sampler + "\"", chain); + } + // The two literals the OpenGL body passes, unchanged. + Assert.Contains("nativeTaaResolve.Uniforms[7], 0.1f", chain); + Assert.Contains("nativeTaaResolve.Uniforms[8], 1.25f", chain); + // And the strength, clamped exactly as the OpenGL body clamps it. + Assert.Contains("GameMath.Clamp(OptimumConfig.TaaSharpness, 0f, 1f)", chain); + + // Registered everywhere a new lib member has to be. + string patcher = Read("Optimum.Patcher/Program.cs"); + string regions = Read("Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs"); + string selfCheck = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); + foreach (string member in new[] { "OptimumTaaResolveDraw", "OptimumTaaSharpenDraw" }) + { + Assert.Contains("\"" + member + "\"", patcher); + Assert.Contains("\"" + member + "\"", regions); + Assert.Contains("new(true, \"" + member + "\"", selfCheck); + } } private static string Platform() => ReadPatchedOrSource( @@ -240,6 +330,19 @@ private static int Count(string text, string value) return count; } + /// + /// The text from a method's signature to the start of the next member declaration at the + /// same indentation ("\n\t}" followed by a newline). + /// + private static string MethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "method not found: " + signature); + int end = source.IndexOf("\n\t}\n", start, StringComparison.Ordinal); + Assert.True(end > start, "method end not found: " + signature); + return source.Substring(start, end - start); + } + private static string ReadPatchedOrSource(string patchPath, string sourcePath) { string? resolvedPatch = TryFind(patchPath); diff --git a/Optimum.Tests/taa-sharpen-coverage-tests.cs b/Optimum.Tests/taa-sharpen-coverage-tests.cs index 6369270b..44f77484 100644 --- a/Optimum.Tests/taa-sharpen-coverage-tests.cs +++ b/Optimum.Tests/taa-sharpen-coverage-tests.cs @@ -162,17 +162,25 @@ public void SharpenSkipsWhenThereIsNothingToSharpenAndRestoresRenderState() Assert.Contains("if (!TaaResolvedThisFrame || OptimumConfig.TaaSharpness <= 0f)", body); Assert.Contains("if (sharpen == null || sharpen.LoadError || target == null)", body); + // The conditions stay in the pass; the draw is a seam of its own, so a platform that + // owns the pass natively replaces the draw and inherits every condition above it + // (docs/vulkan-native-render-systems.md, Phase 3b stage 1). + Assert.Contains("OptimumTaaSharpenDraw(target, resolvedScene);", body); + Assert.Contains("return target.ColorTextureIds[0];", body); + + string draw = MethodBody(platform, + "public override void OptimumTaaSharpenDraw(FrameBufferRef target, int resolvedScene)"); // Same set/restore discipline as the resolve (TAA-PLAN "Blend state"). - int blendOff = body.IndexOf("GlToggleBlend(on: false);", StringComparison.Ordinal); - int depthOff = body.IndexOf("GlDisableDepthTest();", StringComparison.Ordinal); - int draw = body.IndexOf("RenderFullscreenTriangle(screenQuad);", StringComparison.Ordinal); - int blendOn = body.IndexOf("GlToggleBlend(on: true);", StringComparison.Ordinal); - int depthOn = body.IndexOf("GlEnableDepthTest();", StringComparison.Ordinal); - int primary = body.IndexOf("LoadFrameBuffer(EnumFrameBuffer.Primary);", StringComparison.Ordinal); - Assert.True(blendOff >= 0 && depthOff > blendOff && draw > depthOff); - Assert.True(blendOn > draw && depthOn > blendOn && primary > depthOn); + int blendOff = draw.IndexOf("GlToggleBlend(on: false);", StringComparison.Ordinal); + int depthOff = draw.IndexOf("GlDisableDepthTest();", StringComparison.Ordinal); + int triangle = draw.IndexOf("RenderFullscreenTriangle(screenQuad);", StringComparison.Ordinal); + int blendOn = draw.IndexOf("GlToggleBlend(on: true);", StringComparison.Ordinal); + int depthOn = draw.IndexOf("GlEnableDepthTest();", StringComparison.Ordinal); + int primary = draw.IndexOf("LoadFrameBuffer(EnumFrameBuffer.Primary);", StringComparison.Ordinal); + Assert.True(blendOff >= 0 && depthOff > blendOff && triangle > depthOff); + Assert.True(blendOn > triangle && depthOn > blendOn && primary > depthOn); // The strength reaching the shader is the configured one, clamped. - Assert.Contains("sharpen.Uniform(\"sharpness\", GameMath.Clamp(OptimumConfig.TaaSharpness, 0f, 1f));", body); + Assert.Contains("sharpen.Uniform(\"sharpness\", GameMath.Clamp(OptimumConfig.TaaSharpness, 0f, 1f));", draw); } [Fact] @@ -190,7 +198,7 @@ public void NoDoubleSharpeningWhenTheFsrRcasBlitIsActive() string body = MethodBody(platform, "public override int RenderOptimumTaaSharpen(int resolvedScene)"); int guard = body.IndexOf("if (OptimumFsrBlitActive())", StringComparison.Ordinal); - int draw = body.IndexOf("RenderFullscreenTriangle(screenQuad);", StringComparison.Ordinal); + int draw = body.IndexOf("OptimumTaaSharpenDraw(target, resolvedScene);", StringComparison.Ordinal); Assert.True(guard >= 0 && guard < draw); // And the rule is written down where the next reader will look. Assert.Contains("two RCAS passes to the same pixels", platform); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 82bfc3d8..f9cc99d9 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..0d7c6f9 100644 +index d6eb844..5dc1182 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,453 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,473 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -114,6 +114,26 @@ index d6eb844..0d7c6f9 100644 + } + + /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): the TAA resolve's draw, as a seam of ++ /// its own. Every temporal decision stays in - the ++ /// guards, the jittered and previous view-projections, the reset test, the resolved ++ /// textures, the history validity and the parity flip - so a platform that owns this pass ++ /// natively replaces the draw and nothing else, and the contract cannot drift between the ++ /// two backends. The neutral body draws nothing. ++ /// ++ public virtual void OptimumTaaResolveDraw(FrameBufferRef write, FrameBufferRef read, float[] invViewProjJittered, float[] prevViewProj, bool reset) ++ { ++ } ++ ++ /// ++ /// Optimum (Phase 3b): the TAA sharpen's draw, as its own seam, on the same terms as ++ /// . The neutral body draws nothing. ++ /// ++ public virtual void OptimumTaaSharpenDraw(FrameBufferRef target, int resolvedScene) ++ { ++ } ++ ++ /// + /// Optimum AO (docs/research/ambient-occlusion.md): records the platform's own ambient + /// occlusion for this frame and returns the visibility texture the scene composes; 0 means + /// vanilla SSAO runs. The neutral body, and so the OpenGL path, always returns 0. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index d56abc10..46d84793 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..c402ea4 100644 +index 6edf0c9..15c9aea 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -2177,7 +2177,7 @@ index 6edf0c9..c402ea4 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,50 +3250,403 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,50 +3250,439 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2310,6 +2310,34 @@ index 6edf0c9..c402ea4 100644 + invViewProj = Mat4f.Identity(new float[16]); + } + ++ // Optimum (Vulkan-native render systems, Phase 3b): the draw is a seam of its ++ // own, so a platform that owns this pass natively replaces the draw and nothing ++ // else. Every decision above it - the guards, the two matrices, the reset - and ++ // every field below it - the resolved textures, the history validity and the ++ // parity flip - stay here, which is what keeps the temporal contract identical ++ // on both backends whatever the draw is made of. ++ OptimumTaaResolveDraw(write, read, invViewProj, prevViewProj, reset); ++ ++ taaResolvedColorTexture = write.ColorTextureIds[0]; ++ taaResolvedGlowTexture = write.ColorTextureIds[1]; ++ _taaHistoryValid = true; ++ _taaFrameParity ^= 1; ++ optimumTaaResolvedThisFrame = true; ++ return true; ++ } ++ ++ /// ++ /// Optimum (Phase 3b): the TAA resolve's draw - the history slot bound, the seven ++ /// inputs sampled, the nine uniforms written, the fullscreen triangle - and the ++ /// state restore the rest of the post chain relies on: blending back on, the depth ++ /// test back on and Primary bound. The body is the one ++ /// held inline; it was lifted out, not ++ /// re-derived, so the OpenGL path draws exactly what it drew before. ++ /// ++ public override void OptimumTaaResolveDraw(FrameBufferRef write, FrameBufferRef read, float[] invViewProjJittered, float[] prevViewProj, bool reset) ++ { ++ ShaderProgram resolve = ShaderPrograms.TaaResolve; ++ OptimumTemporalFrame frame = OptimumTemporal.Frame; + GlToggleBlend(on: false); + GlDisableDepthTest(); + // The history slot already owns all three of its attachments, so bind it @@ -2329,7 +2357,7 @@ index 6edf0c9..c402ea4 100644 + resolve.BindTexture2D("historyDepth", read.ColorTextureIds[2], 6); + resolve.Uniform("renderSize", (float)write.Width, (float)write.Height); + resolve.Uniform("jitterPx", frame.JitterPx.X, frame.JitterPx.Y); -+ resolve.UniformMatrix("invViewProjJittered", invViewProj); ++ resolve.UniformMatrix("invViewProjJittered", invViewProjJittered); + resolve.UniformMatrix("prevViewProj", prevViewProj); + resolve.UniformMatrix("viewMatrix", frame.CameraMatrixOrigin); + resolve.Uniform("cameraDelta", frame.CameraPosDelta); @@ -2339,11 +2367,6 @@ index 6edf0c9..c402ea4 100644 + RenderFullscreenTriangle(screenQuad); + resolve.Stop(); + -+ taaResolvedColorTexture = write.ColorTextureIds[0]; -+ taaResolvedGlowTexture = write.ColorTextureIds[1]; -+ _taaHistoryValid = true; -+ _taaFrameParity ^= 1; -+ optimumTaaResolvedThisFrame = true; + // Restore everything this pass changed, symmetrically: the rest of + // RenderPostprocessingEffects runs with blend on, the depth test on and + // Primary bound, and it is this pass's job to hand that back rather than @@ -2351,7 +2374,6 @@ index 6edf0c9..c402ea4 100644 + GlToggleBlend(on: true); + GlEnableDepthTest(); + LoadFrameBuffer(EnumFrameBuffer.Primary); -+ return true; + } + + /// @@ -2413,6 +2435,21 @@ index 6edf0c9..c402ea4 100644 + return resolvedScene; + } + ++ // Optimum (Phase 3b): the draw is its own seam, as the resolve's is. The ++ // conditions and the texture this pass hands on stay here. ++ OptimumTaaSharpenDraw(target, resolvedScene); ++ return target.ColorTextureIds[0]; ++ } ++ ++ /// ++ /// Optimum (Phase 3b): the TAA sharpen's draw - the sharpen target bound, the ++ /// resolved colour sampled, the two uniforms written, the fullscreen triangle - and ++ /// the same state restore the resolve's draw performs. Lifted out of ++ /// unchanged. ++ /// ++ public override void OptimumTaaSharpenDraw(FrameBufferRef target, int resolvedScene) ++ { ++ ShaderProgram sharpen = ShaderPrograms.TaaSharpen; + GlToggleBlend(on: false); + GlDisableDepthTest(); + // The target owns its single attachment already; the setter binds it and @@ -2432,7 +2469,6 @@ index 6edf0c9..c402ea4 100644 + GlToggleBlend(on: true); + GlEnableDepthTest(); + LoadFrameBuffer(EnumFrameBuffer.Primary); -+ return target.ColorTextureIds[0]; + } + + public override void RenderPostprocessingEffects(float[] projectMatrix) @@ -2604,7 +2640,7 @@ index 6edf0c9..c402ea4 100644 RenderFullscreenTriangle(screenQuad); findbright.Stop(); ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3658,151 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1848,102 +3694,151 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderFullscreenTriangle(screenQuad); LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); blur.IsVertical = 1; @@ -2803,7 +2839,7 @@ index 6edf0c9..c402ea4 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3812,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3848,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2848,7 +2884,7 @@ index 6edf0c9..c402ea4 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3860,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3896,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2905,7 +2941,7 @@ index 6edf0c9..c402ea4 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3915,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3951,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3403,7 +3439,7 @@ index 6edf0c9..c402ea4 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4563,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4599,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3443,7 +3479,7 @@ index 6edf0c9..c402ea4 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4961,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +4997,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3496,7 +3532,7 @@ index 6edf0c9..c402ea4 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +5056,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5092,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3541,7 +3577,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5093,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5129,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3562,7 +3598,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5148,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3583,7 +3619,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5131,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5167,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3604,7 +3640,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5150,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5186,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3625,7 +3661,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5173,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5209,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3646,7 +3682,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5735,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5771,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3670,7 +3706,7 @@ index 6edf0c9..c402ea4 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6094,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6130,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From dc09b4e8590e181459fdb43cc17d6d02d973dff6 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 10:36:53 +0200 Subject: [PATCH 190/226] wip(native-post): the post chain's ambient-occlusion step drawn natively Phase 3b stage 1c (docs/vulkan-native-render-systems.md, section 3): the vanilla SSAO pass, its bilateral blur ping-pong and the AO composite run through the native device API. VulkanClientPlatform.NativeSsao.cs holds them - one NativeFullscreenPass per program (ssao, bilateralblur, scene-ssao), a pipeline per pass with its fixed state stated outright (no blend or the Multiply blend, no depth, no cull, triangle list, the target's formats), one declared pass per written target with its colour slot and its reads, uniforms written by resolved placement and textures resolved straight to bindless slots. The chain step routes to it and falls back to the lib virtual whole when the device, the targets or the shader programs are not there. Both AO modes are here: vanilla SSAO's blurred target and the platform's GTAO visibility texture, with optimumAoMode and the attenuation inputs bound exactly where the body binds them, and the "AO is in the scene" flag set where the body sets it - which is what keeps the final composition from applying AO twice and what the debug view's texture choice reads. GtaoRenderer itself is untouched. Lib: the AO step's two pieces of frame state and the SSAA factor are accessors (OptimumPostAmbientOcclusionTexture, OptimumPostSsaoInScene, OptimumPostSsaaLevel) over the fields the body keeps writing, so "OFF is vanilla"; the step's two window-size reads go through the OptimumWindowClientSize seam, as decision 3 asks and as the blit already did. All three are listed in Optimum.Patcher/Program.cs and in the vanilla-regions test. Device: a native pass can state its own clear (NativePassDescription.ClearSlots), which lands as the scope's load op through RenderTargetManager.ClearPassAttachment - the SSAO target's white clear is the pass's, not a glClearBuffer against a draw-buffer mask. Verified: dotnet build VintageStory.slnx -c Release (0 errors); dotnet test Optimum.Tests -c Release (1240 passed); dotnet test Optimum.Render.Vulkan.Tests (1045 passed, sync+best validation clean) with the implicit-layer disable set and vulkaninfo showing only VK_LAYER_MESA_device_select; extract-patches + check-patches (157 patches, 0 conflict). New GPU tests in NativeSsaoChainTests run the same inputs through the OpenGL body and the native route and compare all three written targets pixel for pixel: SSAO quality 1 and 2, TAA off (no composite, Primary untouched), AO off (no pass at all), render scale 0.5 and 0.75, and the GTAO composite; plus the pass and draw counts, zero emulation calls inside a native pass, and the GL-shaped state the step leaves for the steps after it. --- Optimum.Patcher/Program.cs | 5 + .../NativeSsaoChainTests.cs | 831 ++++++++++++++++++ .../Core/RenderTargetManager.cs | 41 + .../VulkanClientPlatform.NativePostChain.cs | 30 +- .../VulkanClientPlatform.NativeSsao.cs | 341 +++++++ Optimum.Render.Vulkan/VulkanDevice.Native.cs | 17 + .../ambient-occlusion-coverage-tests.cs | 162 ++++ ...-platform-windows-vanilla-regions-tests.cs | 1 + .../native-post-chain-coverage-tests.cs | 10 +- .../ClientPlatformWindows.cs.patch | 386 ++++---- 10 files changed, 1646 insertions(+), 178 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 8e29fe5a..8f5dada2 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -255,6 +255,11 @@ "OptimumPostLuma", "OptimumPostFinish", "OptimumBindKeepViewport", + // Phase 3b stage 1c: the AO step's own frame state and the SSAA factor as accessors, so a + // native AO step sets and reads exactly what the body's step does. + "OptimumPostAmbientOcclusionTexture", + "OptimumPostSsaoInScene", + "OptimumPostSsaaLevel", // TAA: motion attachment, history/aux/prev-depth targets, and the // debug-view blit path (P1). // Phase 1A step 4: read by VulkanClientPlatform (GlToggleBlend, the Primary clear). diff --git a/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs b/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs new file mode 100644 index 00000000..d8386dd8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs @@ -0,0 +1,831 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The post chain's ambient-occlusion step on the Vulkan platform: the vanilla SSAO pass, its +/// bilateral blur ping-pong and the AO composite that multiplies the visibility into Primary +/// colour 0 before the TAA resolve reads it. +/// +/// Acceptance is behavioural identity (docs/vulkan-native-render-systems.md, decision 6): every +/// test here runs the same inputs through the OpenGL body - the lib virtual the chain switch falls +/// back to - and through the native route, and compares the pixels of all three written targets. +/// The settings that change this step are covered: SSAO quality 1 and 2 (one blur iteration or +/// three, and SSAOLEVEL 1 or 2 in the shaders), AO off, TAA off (no composite, no temporal +/// dither), a render scale below 1 and the GTAO mode with its own composite branch. Bloom, god +/// rays, FXAA and the AO debug view do not reach this step at all - they read what it leaves, and +/// the passes that consume it are covered where they live. +/// +public class NativeSsaoChainTests(ITestOutputHelper output) +{ + private const int Size = 16; + private const int HalfSize = Size / 2; + + private static readonly string[] Programs = { "ssao", "bilateralblur", "scene-ssao" }; + + // ------------------------------------------------------------------------ tests + + /// + /// Vanilla SSAO at both qualities with TAA running: the raw SSAO target, the blurred target + /// the final composition reads, and Primary colour 0 after the multiply all have to come out + /// of the native route exactly as the OpenGL body leaves them. Quality 1 runs the blur once, + /// quality 2 three times, and the shaders differ by SSAOLEVEL. + /// + [SkippableTheory] + [InlineData(1)] + [InlineData(2)] + public void TheVanillaSsaoStepMatchesTheOpenGlBody(int quality) + { + using Session session = Open(quality == 1 ? "ssao-only" : "taa-with-ssao", taa: quality != 1); + session.SsaoQuality = quality; + + Frame emulated = session.Run(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + Frame nativeRoute = session.Run(native: true); + + // The raw pass, one blur half-iteration per pass, and the composite when TAA runs. + int blurPasses = quality == 1 ? 2 : 6; + int expected = 1 + blurPasses + (quality != 1 ? 1 : 0); + Assert.Equal(expected, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(expected, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + Assert.Equal(emulated.Raw, nativeRoute.Raw); + Assert.Equal(emulated.Blurred, nativeRoute.Blurred); + Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(emulated.SsaoInScene, nativeRoute.SsaoInScene); + + // The step really did something, or the comparison above would pass on two routes that + // both wrote nothing. + Assert.NotEqual(session.RawSeed, nativeRoute.Raw); + Assert.NotEqual(session.BlurredSeed, nativeRoute.Blurred); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The same step with TAA off: SSAO and its blur still run, the composite does not, and + /// Primary colour 0 comes back exactly as the frame seeded it on both routes - the AO is left + /// for the final composition to apply, which is what the OpenGL path has always done. + /// + [SkippableFact] + public void TheVanillaSsaoStepSkipsTheCompositeWithTaaOff() + { + using Session session = Open("ssao-only", taa: false); + + Frame emulated = session.Run(native: false); + Frame nativeRoute = session.Run(native: true); + + Assert.Equal(emulated.Raw, nativeRoute.Raw); + Assert.Equal(emulated.Blurred, nativeRoute.Blurred); + Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(session.SceneSeed, nativeRoute.Scene); + Assert.False(nativeRoute.SsaoInScene); + Assert.False(emulated.SsaoInScene); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// A render scale below 1. The SSAO pass's screenSize is the body's + /// ssaaLevel * client * (ssaaLevel == 1 ? 0.5 : 1), which the dither's Bayer lattice is + /// laid out on, so it changes every occlusion value - except at exactly 0.5, where the + /// half-resolution fudge cancels and the value is the one render scale 1 produces. Both scales + /// are here: 0.5 because it is the shipped setting, and 0.75 because it is a scale where the + /// value really differs, which is what proves it reaches the pass at all. + /// + [SkippableFact] + public void TheVanillaSsaoStepMatchesTheOpenGlBodyBelowRenderScaleOne() + { + using Session session = Open("taa-with-ssao", taa: true); + + session.SsaaLevel = 0.5f; + Frame emulatedHalf = session.Run(native: false); + Frame nativeHalf = session.Run(native: true); + Assert.Equal(emulatedHalf.Raw, nativeHalf.Raw); + Assert.Equal(emulatedHalf.Blurred, nativeHalf.Blurred); + Assert.Equal(emulatedHalf.Scene, nativeHalf.Scene); + + session.SsaaLevel = 0.75f; + Frame emulated = session.Run(native: false); + Frame nativeRoute = session.Run(native: true); + Assert.Equal(emulated.Raw, nativeRoute.Raw); + Assert.Equal(emulated.Blurred, nativeRoute.Blurred); + Assert.Equal(emulated.Scene, nativeRoute.Scene); + + Assert.NotEqual(nativeHalf.Raw, nativeRoute.Raw); + Assert.NotEqual(emulatedHalf.Raw, emulated.Raw); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// AO off (SSAO quality 0, so RenderSSAO is false): neither route runs a pass, neither target + /// moves, and the "AO is in the scene" flag stays false - which is what makes the final + /// composition apply nothing rather than multiply by an unwritten target. + /// + [SkippableFact] + public void TheAoStepDoesNothingWhenAmbientOcclusionIsOff() + { + using Session session = Open("ssao-only", taa: true); + session.RenderSsao = false; + + Frame emulated = session.Run(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + Frame nativeRoute = session.Run(native: true); + + Assert.Equal(0, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(emulated.Raw, nativeRoute.Raw); + Assert.Equal(session.RawSeed, nativeRoute.Raw); + Assert.Equal(session.BlurredSeed, nativeRoute.Blurred); + Assert.Equal(session.SceneSeed, nativeRoute.Scene); + Assert.False(nativeRoute.SsaoInScene); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The GTAO mode: the platform's own visibility texture replaces vanilla SSAO, so the raw and + /// blurred targets are never written and the composite takes the OPTIMUMAO branch with + /// optimumAoMode = 1, sampling the G-buffer position and the OIT revealage for the attenuation + /// vanilla SSAO applies inside its own pass. The compute pass itself is not exercised here - + /// this is the raster step around it - so the visibility texture is supplied directly. + /// + [SkippableFact] + public void TheGtaoCompositeMatchesTheOpenGlBody() + { + using Session session = Open("taa-with-gtao", taa: true, gtao: true); + session.AmbientOcclusionTexture = session.GtaoVisibility; + + Frame emulated = session.Run(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + Frame nativeRoute = session.Run(native: true); + + // The composite alone: vanilla SSAO and its blur stood down. + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.True(nativeRoute.SsaoInScene); + Assert.Equal(session.RawSeed, nativeRoute.Raw); + Assert.Equal(session.BlurredSeed, nativeRoute.Blurred); + Assert.NotEqual(session.SceneSeed, nativeRoute.Scene); + + GpuTest.AssertClean(session.Seam); + } + + /// + /// The step leaves the GL-shaped state the steps after it inherit exactly where the OpenGL + /// body leaves it: blending on, the depth test on, and the viewport back at full render + /// resolution - the Luma step sets no viewport of its own and would otherwise draw into the + /// SSAO target's half-resolution one. + /// + [SkippableFact] + public void TheNativeAoStepLeavesTheGlShapedStateWhereTheBodyLeavesIt() + { + using Session session = Open("taa-with-ssao", taa: true); + + session.Run(native: false); + (int Width, int Height) emulated = session.Viewport; + session.Run(native: true); + + Assert.Equal(emulated, session.Viewport); + Assert.Equal((Size, Size), session.Viewport); + + GpuTest.AssertClean(session.Seam); + } + + // ---------------------------------------------------------------------- session + + /// What one run of the step produced, on either route. + private readonly record struct Frame(byte[] Raw, byte[] Blurred, byte[] Scene, bool SsaoInScene); + + private Session Open(string variantName, bool taa, bool gtao = false) + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + + Session? session = Session.TryOpen(output, manifest, variantName, taa, gtao); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// The Vulkan platform without a window, with the targets this step indexes. + private sealed class AoPlatform : VulkanClientPlatform + { + public AoPlatform() : base(null!) + { + } + + /// The visibility texture RenderOptimumAmbientOcclusion is made to return, or 0. + public int AmbientOcclusionTexture { get; set; } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + + public override int RenderOptimumAmbientOcclusion(float[] projectMatrix) => AmbientOcclusionTexture; + + /// Primary is the render resolution here, so the bind and the viewport are the base's. + public override void LoadFrameBuffer(EnumFrameBuffer framebuffer) + { + if (framebuffer == EnumFrameBuffer.Primary) + { + CurrentFrameBuffer = FrameBuffers[0]; + return; + } + base.LoadFrameBuffer(framebuffer); + } + } + + private sealed class Session : IDisposable + { + private const BindingFlags Hidden = BindingFlags.Instance | BindingFlags.NonPublic; + + public AoPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + + /// The three targets' contents as the frame seeds them, decoded the way a run decodes them. + public byte[] RawSeed { get; private set; } = Array.Empty(); + public byte[] BlurredSeed { get; private set; } = Array.Empty(); + public byte[] SceneSeed { get; private set; } = Array.Empty(); + + /// A prepared visibility texture, for the GTAO branch. + public int GtaoVisibility { get; private set; } + + public (int Width, int Height) Viewport { get; private set; } + + public int AmbientOcclusionTexture + { + set => Platform.AmbientOcclusionTexture = value; + } + + public int SsaoQuality + { + set => ClientSettings.SSAOQuality = value; + } + + public float SsaaLevel + { + set => typeof(ClientPlatformWindows).GetField("ssaaLevel", Hidden)!.SetValue(Platform, value); + } + + public bool RenderSsao + { + set => typeof(ClientPlatformWindows).GetField("RenderSSAO", Hidden)!.SetValue(Platform, value); + } + + private readonly List buffers = new(); + private FrameBufferRef primary = null!; + private FrameBufferRef transparent = null!; + private FrameBufferRef ssao = null!; + private FrameBufferRef blurHorizontal = null!; + private FrameBufferRef blurVertical = null!; + private float[] projection = null!; + + private int decodeProgram; + private int decodeTarget; + private int decodeFramebuffer; + + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + private ShaderProgramSsao? ssaoBefore; + private ShaderProgramBilateralblur? blurBefore; + private ShaderProgram? sceneSsaoBefore; + private bool taaBefore; + private bool gtaoBefore; + private int qualityBefore; + private DefaultShaderUniforms uniforms = new(); + + public static Session? TryOpen(ITestOutputHelper output, string manifestDirectory, + string variantName, bool taa, bool gtao) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-ssao-" + Guid.NewGuid().ToString("N")); + var platform = new AoPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + ssaoBefore = ShaderPrograms.Ssao, + blurBefore = ShaderPrograms.Bilateralblur, + sceneSsaoBefore = ShaderPrograms.SceneSsao, + taaBefore = OptimumConfig.Taa, + gtaoBefore = OptimumConfig.AmbientOcclusionShadersUseGtao, + qualityBefore = ClientSettings.SSAOQuality, + }; + ScreenManager.Platform = platform; + ScreenManager.FrameProfiler ??= new FrameProfilerUtil(static (string _) => { }); + platform.ShaderUniforms = session.uniforms; + + OptimumConfig.Taa = taa; + OptimumConfig.AmbientOcclusionShadersUseGtao = gtao; + ClientSettings.SSAOQuality = 2; + + session.BuildTargets(); + session.LinkPrograms(variantName); + session.InstallState(); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + ShaderPrograms.Ssao = ssaoBefore!; + ShaderPrograms.Bilateralblur = blurBefore!; + ShaderPrograms.SceneSsao = sceneSsaoBefore!; + OptimumConfig.Taa = taaBefore; + OptimumConfig.AmbientOcclusionShadersUseGtao = gtaoBefore; + ClientSettings.SSAOQuality = qualityBefore; + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + // ---------------------------------------------------------------- one run + + /// + /// One frame: the three targets seeded, the step run on the chosen route, everything read + /// back. The routes share the seeding and the readback, so a difference is the step's. + /// + public Frame Run(bool native) + { + Platform.NativePostChainEnabled = native; + Platform.BeginFrame(); + SeedFrame(); + Platform.RunPostStepAmbientOcclusionForTests(projection); + Viewport = ((int)Seam.NativeCurrentViewport.Extent.Width, (int)Seam.NativeCurrentViewport.Extent.Height); + var frame = new Frame( + Decode(ssao.ColorTextureIds[0]), + Decode(blurVertical.ColorTextureIds[0]), + Decode(primary.ColorTextureIds[0]), + Platform.OptimumPostSsaoInScene); + Platform.EndFrame(); + return frame; + } + + /// The state the world stages leave for the post chain, and the inputs both routes read. + private void SeedFrame() + { + VulkanDevice seam = Seam; + + seam.BindFramebuffer(transparent.FboId); + seam.SetDrawBuffers(transparent.FboId, 0b111); + seam.ClearColor(1, 0.8f, 0.8f, 0.8f, 1f); + + seam.BindFramebuffer(ssao.FboId); + seam.SetDrawBuffers(ssao.FboId, 0b1); + seam.ClearColor(0, 0.2f, 0.2f, 0.2f, 1f); + seam.BindFramebuffer(blurHorizontal.FboId); + seam.SetDrawBuffers(blurHorizontal.FboId, 0b1); + seam.ClearColor(0, 0.3f, 0.3f, 0.3f, 1f); + seam.BindFramebuffer(blurVertical.FboId); + seam.SetDrawBuffers(blurVertical.FboId, 0b1); + seam.ClearColor(0, 0.4f, 0.4f, 0.4f, 1f); + + seam.BindFramebuffer(primary.FboId); + seam.SetDrawBuffers(primary.FboId, 0b1111); + seam.ClearColor(0, 0.5f, 0.6f, 0.7f, 1f); + seam.ClearColor(1, 0.1f, 0.2f, 0.3f, 1f); + seam.ClearDepth(1f); + Platform.CurrentFrameBuffer = primary; + + seam.SetViewport(0, 0, Size, Size); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetCullFace(false); + } + + // ---------------------------------------------------------------- readback + + /// + /// Any attachment through an RGBA8 copy, because the seam's readback is four bytes per + /// pixel from attachment 0. Both routes go through the same copy, so equal bytes here mean + /// equal texels: the decode cannot hide a difference it applies to both sides identically. + /// + private unsafe byte[] Decode(int textureId) + { + VulkanDevice seam = Seam; + seam.BindFramebuffer(decodeFramebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.UseProgram(decodeProgram); + seam.SetSamplerUnit(decodeProgram, "source", 15); + seam.BindTexture(15, textureId); + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(decodeFramebuffer); + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + // ----------------------------------------------------------------- fixture + + private void BuildTargets() + { + VulkanDevice seam = Seam; + + // Primary with the SSAO G-buffer: colour, glow, normal, position. The AO step never + // touches the motion attachment, so this target stops at the G-buffer. + primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + Texture(Size, EnumTextureInternalFormat.Rgba8), + Texture(Size, EnumTextureInternalFormat.Rgba8), + GBuffer(normals: true), + GBuffer(normals: false), + }, + DepthTextureId = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false), + }; + for (int slot = 0; slot < 4; slot++) + { + seam.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + primary.ColorTextureIds[slot], 0); + } + seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string primaryStatus), primaryStatus); + + transparent = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + Texture(Size, EnumTextureInternalFormat.Rgba8), + Texture(Size, EnumTextureInternalFormat.Rgba8), + Texture(Size, EnumTextureInternalFormat.Rgba8), + }, + }; + for (int slot = 0; slot < 3; slot++) + { + seam.AttachTexture(transparent.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + transparent.ColorTextureIds[slot], 0); + } + Assert.True(seam.CheckFramebufferComplete(transparent.FboId, out string status), status); + + // The SSAO target as the platform builds it: half resolution, an RGB float attachment + // and the 16x16 rotation noise, which is a texture of the target rather than an + // attachment of it. + ssao = new FrameBufferRef + { + Width = HalfSize, + Height = HalfSize, + FboId = seam.CreateFramebuffer(HalfSize, HalfSize), + ColorTextureIds = new int[2], + }; + ssao.ColorTextureIds[0] = seam.CreateTexture2DRaw(HalfSize, HalfSize, 6407, IntPtr.Zero, 12); + seam.AttachTexture(ssao.FboId, EnumFramebufferAttachment.ColorAttachment0, ssao.ColorTextureIds[0], 0); + seam.SetDrawBuffers(ssao.FboId, 0b1); + ssao.ColorTextureIds[1] = Noise(seam); + + blurVertical = Blur(seam); + blurHorizontal = Blur(seam); + + GtaoVisibility = Visibility(seam); + + for (int i = 0; i <= 24; i++) buffers.Add(null!); + buffers[0] = primary; + buffers[1] = transparent; + buffers[13] = ssao; + buffers[14] = blurVertical; + buffers[15] = blurHorizontal; + + decodeTarget = Texture(Size, EnumTextureInternalFormat.Rgba8); + decodeFramebuffer = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(decodeFramebuffer, EnumFramebufferAttachment.ColorAttachment0, decodeTarget, 0); + seam.SetDrawBuffers(decodeFramebuffer, 0b1); + + projection = Mat4f.Perspective(Mat4f.Create(), 70f * (float)Math.PI / 180f, 1f, 0.1f, 100f); + } + + private int Texture(int size, EnumTextureInternalFormat format) => + Seam.CreateTexture2D(size, size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + + private FrameBufferRef Blur(VulkanDevice seam) + { + var target = new FrameBufferRef + { + Width = HalfSize, + Height = HalfSize, + FboId = seam.CreateFramebuffer(HalfSize, HalfSize), + ColorTextureIds = new[] { Texture(HalfSize, EnumTextureInternalFormat.Rgba8) }, + }; + seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + seam.SetDrawBuffers(target.FboId, 0b1); + return target; + } + + /// The SSAO rotation noise, built by the platform's own generator so the pattern is the shipped one. + private static unsafe int Noise(VulkanDevice seam) + { + float[] noise = VulkanClientPlatform.BuildOptimumSsaoNoise(new Random(5), 16); + int id; + fixed (float* data = noise) id = seam.CreateTexture2DRaw(16, 16, 34836, (IntPtr)data, 16); + seam.SetTextureParameter(id, OptimumGlConstants.TextureWrapS, OptimumGlConstants.Repeat); + seam.SetTextureParameter(id, OptimumGlConstants.TextureWrapT, OptimumGlConstants.Repeat); + return id; + } + + /// + /// A G-buffer attachment with a pattern the SSAO kernel actually responds to: view-space + /// positions on a slope for the position target, and unit normals for the normal one. + /// + private unsafe int GBuffer(bool normals) + { + var texels = new float[Size * Size * 4]; + for (int y = 0; y < Size; y++) + { + for (int x = 0; x < Size; x++) + { + int i = (y * Size + x) * 4; + if (normals) + { + var normal = new Vec3f(0.1f + x * 0.01f, 0.15f, 1f); + normal.Normalize(); + texels[i] = normal.X; + texels[i + 1] = normal.Y; + texels[i + 2] = normal.Z; + // w is vanilla's leaves flag; 0 keeps the ordinary occlusion branch. + texels[i + 3] = 0f; + } + else + { + // Alternating depth columns: vanilla's SSAO clamps every kernel tap to + // within 0.04 of the fragment's own texcoord, so occlusion only comes from + // a near neighbour, and a fine step is what every pixel can see one of. + texels[i] = (x - Size * 0.5f) * 0.12f + 0.011f; + texels[i + 1] = (y - Size * 0.5f) * 0.12f; + texels[i + 2] = -3f + ((x * 7 + y * 13) % 11) * 0.02f; + texels[i + 3] = 0.1f; + } + } + } + fixed (float* data = texels) + { + return Seam.CreateTexture2DRaw(Size, Size, 34836, (IntPtr)data, 16); + } + } + + /// A visibility texture standing in for GTAO's output: R32F, nearest, clamped, as the platform sets it up. + private unsafe int Visibility(VulkanDevice seam) + { + var texels = new float[Size * Size]; + for (int i = 0; i < texels.Length; i++) texels[i] = 0.25f + (i % 7) / 12f; + int id; + fixed (float* data = texels) id = seam.CreateTexture2DRaw(Size, Size, 0x822E, (IntPtr)data, 4); + // Composed with texelFetch at the same resolution; nearest and clamp keep any sampling exact. + seam.SetTextureParameter(id, OptimumGlConstants.TextureMinFilter, 9728); + seam.SetTextureParameter(id, OptimumGlConstants.TextureMagFilter, 9728); + seam.SetTextureParameter(id, OptimumGlConstants.TextureWrapS, OptimumGlConstants.ClampToEdge); + seam.SetTextureParameter(id, OptimumGlConstants.TextureWrapT, OptimumGlConstants.ClampToEdge); + return id; + } + + private void LinkPrograms(string variantName) + { + VulkanDevice seam = Seam; + ShaderCorpus.ShaderVariant variant = Variant(variantName); + + var ssaoProgram = new ShaderProgramSsao { PassName = "ssao" }; + Link(seam, ssaoProgram, "ssao", variant, + new[] { "screenSize", "projection", "samples" }, + new[] { "gPosition", "gNormal", "texNoise", "revealage" }, + variant.TaaMotion == 1 ? new[] { "temporalFrameIndex" } : Array.Empty()); + var blurProgram = new ShaderProgramBilateralblur { PassName = "bilateralblur" }; + Link(seam, blurProgram, "bilateralblur", variant, new[] { "frameSize", "isVertical" }, + new[] { "inputTexture", "depthTexture" }, Array.Empty()); + var composite = new ShaderProgram { PassName = "scene-ssao" }; + Link(seam, composite, "scene-ssao", variant, new[] { "invRenderHeight" }, + variant.OptimumAo == 1 + ? new[] { "ssaoScene", "gPositionScene", "revealageScene" } + : new[] { "ssaoScene" }, + variant.OptimumAo == 1 ? new[] { "optimumAoMode" } : Array.Empty()); + + ShaderPrograms.Ssao = ssaoProgram; + ShaderPrograms.Bilateralblur = blurProgram; + ShaderPrograms.SceneSsao = composite; + + decodeProgram = LinkDecode(seam); + } + + internal static ShaderCorpus.ShaderVariant Variant(string name) + { + foreach (ShaderCorpus.ShaderVariant candidate in ShaderCorpus.Variants()) + { + if (candidate.Name == name) return candidate; + } + throw new InvalidOperationException("the corpus has no " + name + " variant"); + } + + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, + ShaderCorpus.ShaderVariant variant, string[] uniforms, string[] samplers, string[] optional) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader), seam.GetError() ?? name + " did not compile"); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + // The GL route binds samplers by name through the program's location table. + foreach (string sampler in samplers) + { + int location = seam.GetUniformLocation(id, sampler); + Assert.True(location != -1, name + " has no location for " + sampler); + program.uniformLocations[sampler] = location; + } + // Uniforms the shader only declares in some variants: registered where they exist, so + // both routes leave them alone in the variants that do not have them. + foreach (string uniform in optional) + { + int location = seam.GetUniformLocation(id, uniform); + if (location != -1) program.uniformLocations[uniform] = location; + } + } + + /// The readback helper's own program: any attachment into RGBA8. + private static int LinkDecode(VulkanDevice seam) + { + const string vertex = @"#version 330 core +out vec2 uv; +void main(void) +{ + vec2 position = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); + uv = position; + gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0); +} +"; + const string fragment = @"#version 330 core +uniform sampler2D source; +in vec2 uv; +layout(location = 0) out vec4 outColor; +void main(void) +{ + outColor = clamp(texture(source, uv), 0.0, 1.0); +} +"; + var linked = new LinkedProgram { PassName = "native-ssao-decode" }; + var vertexShader = new LinkedShader { Type = EnumShaderType.VertexShader, Code = vertex, PrefixCode = "" }; + var fragmentShader = new LinkedShader { Type = EnumShaderType.FragmentShader, Code = fragment, PrefixCode = "" }; + Assert.True(seam.CompileShader(vertexShader), seam.GetError() ?? "decode vertex shader"); + Assert.True(seam.CompileShader(fragmentShader), seam.GetError() ?? "decode fragment shader"); + linked.VertexShader = vertexShader; + linked.FragmentShader = fragmentShader; + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "decode link failed"); + return id; + } + + private void InstallState() + { + typeof(ClientPlatformWindows).GetField("frameBuffers", Hidden)!.SetValue(Platform, buffers); + typeof(ClientPlatformWindows).GetField("ssaaLevel", Hidden)!.SetValue(Platform, 1f); + typeof(ClientPlatformWindows).GetField("RenderSSAO", Hidden)!.SetValue(Platform, true); + // The composite only runs while TAA is actually accumulating, which is what the + // OptimumTaaRequested && TaaTargetsReady guard says. + typeof(ClientPlatformWindows).GetField("optimumTaaTargetsReady", Hidden)! + .SetValue(Platform, OptimumConfig.Taa); + FillSsaoKernel(); + + Platform.BeginFrame(); + SeedFrame(); + RawSeed = Decode(ssao.ColorTextureIds[0]); + BlurredSeed = Decode(blurVertical.ColorTextureIds[0]); + SceneSeed = Decode(primary.ColorTextureIds[0]); + Platform.EndFrame(); + } + + /// The 64-sample kernel, built the way the platform's frame-buffer setup builds it. + private void FillSsaoKernel() + { + float[] kernel = Platform.OptimumSsaoKernel; + var random = new Random(11); + for (int sample = 0; sample < 64; sample++) + { + var value = new Vec3f((float)random.NextDouble() * 2f - 1f, + (float)random.NextDouble() * 2f - 1f, (float)random.NextDouble()); + value.Normalize(); + value *= (float)random.NextDouble(); + float scale = sample / 64f; + scale = GameMath.Lerp(0.1f, 1f, scale * scale); + value *= scale; + kernel[sample * 3] = value.X; + kernel[sample * 3 + 1] = value.Y; + kernel[sample * 3 + 2] = value.Z; + } + } + } + + // ---------------------------------------------------------------- native shaders + + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + foreach (string program in Programs) + { + NativeShaderBuildResult one = builder.Build(source, program); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + } + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), "optimum-native-ssao-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 3ad81a2a..8bed4d06 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -597,6 +597,47 @@ public void EndRendering(CommandBuffer commandBuffer) // ------------------------------------------------------------------- clears + /// + /// Clears one colour slot of a declared native pass (docs/vulkan-native-render-systems.md, + /// decision 4). Unlike this consults no draw-buffer mask and no + /// tracked colour mask - a native pass states the slots it writes, and a slot it states is a + /// slot it may clear. With the frame graph on and no scope open yet the clear is promoted, so + /// it becomes the scope's load op instead of a second write. + /// + public void ClearPassAttachment(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) + { + if (_bound == null) return; + if ((uint)attachment >= (uint)_bound.Color.Length) return; + if (!_bound.Color[attachment].IsBound) return; + + if (_graph.Enabled && (!_renderingActive || _needsRestart)) + { + VulkanTexture? texture = _textures.Get(_bound.Color[attachment].TextureId); + if (texture == null) return; + EndRendering(commandBuffer); + _graph.PromoteColorClear(texture, _bound.Color[attachment].Layer, r, g, b, a); + return; + } + + EnsureRendering(commandBuffer); + if (!_renderingActive) return; + if (_graph.Enabled) _graph.NoteInPassClear(); + + var clear = new ClearAttachment + { + AspectMask = ImageAspectFlags.ColorBit, + ColorAttachment = (uint)attachment, + ClearValue = new ClearValue(new ClearColorValue(r, g, b, a)), + }; + var rect = new ClearRect + { + Rect = new Rect2D(new Offset2D(0, 0), new Extent2D(_bound.Width, _bound.Height)), + BaseArrayLayer = 0, + LayerCount = 1, + }; + _context.Api.CmdClearAttachments(commandBuffer, 1, &clear, 1, &rect); + } + public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) { if (_bound == null) return; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs index bc2872d6..019c9b4b 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -78,6 +78,13 @@ internal enum NativePostStep private void NotePostStep(NativePostStep step) => NativePostStepLog?.Add(step); + /// + /// Test seam: one chain step on whichever route selects, + /// without the eight steps around it. The differential tests run the same inputs through both. + /// + internal void RunPostStepAmbientOcclusionForTests(float[] projectMatrix) => + PostStepAmbientOcclusion(projectMatrix); + // ------------------------------------------------------------------ the chain /// @@ -336,10 +343,20 @@ private bool LegacySkyMotion() } /// - /// LEGACY - pass 3, the SSAO pass, its bilateral blur and the AO composite, through the lib - /// virtual that now holds the OpenGL body's inline code. Stage 1c makes it native. + /// Pass 3 - the SSAO pass, its bilateral blur and the AO composite - drawn natively + /// (VulkanClientPlatform.NativeSsao.cs). The lib virtual, which holds the OpenGL body's inline + /// code, is the old route: the differential tests compare the two, and a frame the native + /// route cannot draw (no device, no targets, no shader programs) falls back to it whole. /// - private void PostStepAmbientOcclusion(float[] projectMatrix) => OptimumPostAmbientOcclusion(projectMatrix); + private void PostStepAmbientOcclusion(float[] projectMatrix) + { + if (UseNativePostChain && NativeAmbientOcclusionReady()) + { + NativeAmbientOcclusion(projectMatrix); + return; + } + OptimumPostAmbientOcclusion(projectMatrix); + } /// LEGACY - pass 4, the TAA resolve. Stage 1d makes it native. private bool PostStepTaaResolve() => RenderOptimumTaaResolve(); @@ -464,5 +481,12 @@ private bool BeginNativeKeepViewportPass(string name, int framebufferId, uint co /// A mat4 at its placement: sixteen floats, column-major, as the program declares it. private void WriteNativeMatrix(NativePipeline pipeline, NativeUniform uniform, float[] values) => + WriteNativeFloats(pipeline, uniform, values); + + /// + /// A float array at its placement. The record and push blocks are scalar-packed, so a float[] + /// the game already holds - a matrix, or the SSAO sample kernel - copies straight in. + /// + private void WriteNativeFloats(NativePipeline pipeline, NativeUniform uniform, float[] values) => device.WriteNative(pipeline, uniform, MemoryMarshal.AsBytes(new ReadOnlySpan(values))); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs new file mode 100644 index 00000000..9f373280 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), Phase 3b stage 1c: the +// post chain's ambient-occlusion step drawn natively - the vanilla SSAO pass, its bilateral blur +// ping-pong and the AO composite that multiplies the visibility into the scene before the TAA +// resolve reads it. +// +// The step is the OpenGL body's (ClientPlatformWindows.OptimumPostAmbientOcclusion and the +// private ApplyOptimumSceneSsao it calls), value for value: the same guards, the same order, the +// same uniform expressions, the same textures. What changes is how each draw reaches the GPU - +// a pipeline built for stated fixed state (per-attachment blend, depth test/write/compare, cull, +// topology and the target's formats) instead of whatever the GL state tracker happens to hold, +// a pass that names its target, its written colour slots and the textures it samples instead of +// a draw-buffer mask, uniforms written by resolved placement instead of by name, and sampled +// textures resolved straight to bindless slots. +// +// Both AO modes run here. Vanilla SSAO renders into frameBuffers[13], is blurred through 15/14 +// and composed from 14; Optimum's GTAO (GtaoRenderer, already native and untouched by this file) +// hands the composite its visibility texture instead and the composite takes the OPTIMUMAO branch +// with optimumAoMode = 1. Either way the step ends by recording that AO is in the scene, which is +// what keeps the final composition from applying it a second time. +public partial class VulkanClientPlatform +{ + private readonly NativeFullscreenPass nativeSsao = new("ssao", + new[] { "screenSize", "projection", "samples", "temporalFrameIndex" }, + new[] { "gPosition", "gNormal", "texNoise", "revealage" }); + + private readonly NativeFullscreenPass nativeBilateralBlur = new("bilateralblur", + new[] { "frameSize", "isVertical" }, + new[] { "inputTexture", "depthTexture" }); + + private readonly NativeFullscreenPass nativeSceneSsao = new("scene-ssao", + new[] { "invRenderHeight", "optimumAoMode" }, + new[] { "ssaoScene", "gPositionScene", "revealageScene" }); + + /// The frame buffer indices this step draws into and reads back, as the base indexes them. + private const int NativeSsaoTargetIndex = 13; + private const int NativeSsaoBlurVerticalIndex = 14; + private const int NativeSsaoBlurHorizontalIndex = 15; + + /// + /// The AO step, natively. Reproduces + /// exactly: the platform's own + /// AO first, vanilla SSAO and its blur when that stood down, then the composite - under the + /// vanilla branch only while TAA is actually running, under the GTAO branch always. + /// + private void NativeAmbientOcclusion(float[] projectMatrix) + { + OptimumPostSsaoInScene = false; + OptimumPostAmbientOcclusionTexture = 0; + if (OptimumRenderSsao && projectMatrix != null) + { + OptimumPostAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix); + } + + if (OptimumPostAmbientOcclusionTexture == 0 && OptimumRenderSsao && projectMatrix != null) + { + Size2i client = OptimumWindowClientSize(); + float ssaa = OptimumPostSsaaLevel; + + // Outside every native pass, exactly where the OpenGL body puts them: this is the + // GL-shaped state the steps after this one inherit. + GlToggleBlend(on: false); + NativeVanillaSsaoPass(projectMatrix, client, ssaa); + NativeBilateralBlurPasses(); + // The body's tail: the blur's last target is what it leaves bound, with the viewport + // back at full render resolution - the Luma step inherits that viewport. + LoadFrameBuffer(EnumFrameBuffer.SSAOBlurVertical); + GlToggleBlend(on: true); + GlViewport(0, 0, (int)(ssaa * client.Width), (int)(ssaa * client.Height)); + if (OptimumTaaRequested && TaaTargetsReady) + { + NativeSceneSsaoPass(); + } + } + + if (OptimumPostAmbientOcclusionTexture != 0) + { + NativeSceneSsaoPass(); + } + } + + /// + /// Whether the native route can run this step at all. The shader programs are the OpenGL + /// body's own - it uses them unguarded - so a frame that has none of them falls back to the + /// body rather than drawing nothing. + /// + private bool NativeAmbientOcclusionReady() + { + if (device == null) return false; + List buffers = FrameBuffers; + if (buffers == null || buffers.Count <= NativeSsaoBlurHorizontalIndex) return false; + if (buffers[0] == null || buffers[1] == null) return false; + if (!OptimumRenderSsao) return true; + + FrameBufferRef primary = buffers[0]; + if (primary.ColorTextureIds == null || primary.ColorTextureIds.Length < 4) return false; + if (buffers[NativeSsaoTargetIndex] == null || buffers[NativeSsaoBlurVerticalIndex] == null || + buffers[NativeSsaoBlurHorizontalIndex] == null) + { + return false; + } + + ShaderProgramSsao ssao = ShaderPrograms.Ssao; + ShaderProgramBilateralblur blur = ShaderPrograms.Bilateralblur; + if (ssao == null || ssao.LoadError || ssao.Disposed) return false; + if (blur == null || blur.LoadError || blur.Disposed) return false; + return true; + } + + // ------------------------------------------------------------------ 1: vanilla SSAO + + /// + /// The raw SSAO pass. One colour slot on frameBuffers[13], cleared white at pass entry the + /// way the body's ClearSsaoTarget clears it, no blend, no depth, and the viewport the body's + /// LoadFrameBuffer(SSAO) case sets - the target's own size. The four samplers and the four + /// uniform values are the body's, including the half-resolution screenSize fudge and the + /// temporal dither index, which is written under exactly the condition that compiles it in. + /// + private void NativeVanillaSsaoPass(float[] projectMatrix, Size2i client, float ssaa) + { + List buffers = FrameBuffers; + FrameBufferRef primary = buffers[0]; + FrameBufferRef transparent = buffers[1]; + FrameBufferRef target = buffers[NativeSsaoTargetIndex]; + + NativePipeline? pipeline = NativePostPipeline(nativeSsao, ShaderPrograms.Ssao, target.FboId, 1u, + NativeOpaqueSlotZeroBlend(), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) return; + + int gNormal = primary.ColorTextureIds[2]; + int gPosition = primary.ColorTextureIds[3]; + int noise = target.ColorTextureIds[1]; + int revealage = transparent.ColorTextureIds[1]; + + if (BeginNativeAoPass("SSAO/" + NativeSsaoTargetIndex, target.FboId, target.Width, target.Height, + new[] { gPosition, gNormal, noise, revealage }, clearWhite: true)) + { + // screenSize: the body's num is 0.5 at SSAA 1 and 1 otherwise, so the value is the + // SSAO target's resolution at SSAA 1 and the full render resolution above it. + float half = ssaa == 1f ? 0.5f : 1f; + device.WriteNative(pipeline, nativeSsao.Uniforms[0], ssaa * client.Width * half, ssaa * client.Height * half); + WriteNativeFloats(pipeline, nativeSsao.Uniforms[1], projectMatrix); + WriteNativeFloats(pipeline, nativeSsao.Uniforms[2], OptimumSsaoKernel); + if (OptimumConfig.EffectiveTaa) + { + device.WriteNative(pipeline, nativeSsao.Uniforms[3], + (float)(OptimumTemporal.Frame.FrameIndex & 1023L)); + } + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeSsao.Samplers[0], gPosition), + new NativeTexture(nativeSsao.Samplers[1], gNormal), + new NativeTexture(nativeSsao.Samplers[2], noise), + new NativeTexture(nativeSsao.Samplers[3], revealage), + }); + } + device.EndNativePass(); + } + + // ------------------------------------------------------------------ 2: bilateral blur + + /// + /// The bilateral blur ping-pong: one horizontal half-iteration into frameBuffers[15] and one + /// vertical into frameBuffers[14], once at SSAO quality 1 and three times otherwise. Each + /// half-iteration is its own pass, because each writes a target the next one samples. + /// + /// frameSize is the body's: frameBuffers[15]'s size, captured once and reused by every + /// half-iteration including the vertical ones that write frameBuffers[14]. The two targets are + /// built at the same size, so this is a value, not a bug to fix - and it is reproduced, not + /// corrected. depthTexture is likewise bound on both halves: the body sets it on the + /// horizontal half only and the vertical half inherits the same binding from the program. + /// + private void NativeBilateralBlurPasses() + { + List buffers = FrameBuffers; + FrameBufferRef primary = buffers[0]; + FrameBufferRef horizontal = buffers[NativeSsaoBlurHorizontalIndex]; + FrameBufferRef vertical = buffers[NativeSsaoBlurVerticalIndex]; + + NativePipeline? pipeline = NativePostPipeline(nativeBilateralBlur, ShaderPrograms.Bilateralblur, + horizontal.FboId, 1u, NativeOpaqueSlotZeroBlend(), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) return; + + int depth = primary.DepthTextureId; + int iterations = ClientSettings.SSAOQuality == 1 ? 1 : 3; + for (int i = 0; i < iterations; i++) + { + int source = buffers[i == 0 ? NativeSsaoTargetIndex : NativeSsaoBlurVerticalIndex].ColorTextureIds[0]; + NativeBilateralBlurHalf(pipeline, horizontal, source, depth, horizontal, isVertical: 0); + NativeBilateralBlurHalf(pipeline, vertical, horizontal.ColorTextureIds[0], depth, horizontal, isVertical: 1); + } + } + + /// One half-iteration of the blur: one target, one input, the shared frameSize. + private void NativeBilateralBlurHalf(NativePipeline pipeline, FrameBufferRef target, int source, int depth, + FrameBufferRef frameSizeSource, int isVertical) + { + if (!BeginNativeAoPass("SSAOBlur/" + target.FboId, target.FboId, target.Width, target.Height, + new[] { source, depth }, clearWhite: false)) + { + device.EndNativePass(); + return; + } + + device.WriteNative(pipeline, nativeBilateralBlur.Uniforms[0], + (float)frameSizeSource.Width, (float)frameSizeSource.Height); + device.WriteNative(pipeline, nativeBilateralBlur.Uniforms[1], isVertical); + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeBilateralBlur.Samplers[0], source), + new NativeTexture(nativeBilateralBlur.Samplers[1], depth), + }); + device.EndNativePass(); + } + + // ------------------------------------------------------------------ 3: the AO composite + + /// + /// The AO composite, natively: the visibility term multiplied into Primary colour 0 and + /// nothing else, before the TAA resolve reads that colour. + /// + /// The Multiply blend is the pipeline's - dst * (1 - srcAlpha), which is what + /// GlToggleBlend(true, EnumBlendMode.Multiply) sets - and the single written colour slot is + /// the pass's, not a draw-buffer mask. That is also what lets the pass sample Primary's + /// G-buffer position attachment: a slot the pass leaves out is not part of its scope, so it + /// is read as a texture rather than being feedback. + /// + private void NativeSceneSsaoPass() + { + ShaderProgram composite = ShaderPrograms.SceneSsao; + if (composite == null || composite.LoadError || composite.Disposed) return; + + List buffers = FrameBuffers; + FrameBufferRef primary = buffers[0]; + FrameBufferRef transparent = buffers[1]; + + int aoTexture = OptimumPostAmbientOcclusionTexture; + if (aoTexture == 0) + { + FrameBufferRef blurred = buffers[NativeSsaoBlurVerticalIndex]; + if (blurred?.ColorTextureIds == null || blurred.ColorTextureIds.Length == 0) return; + aoTexture = blurred.ColorTextureIds[0]; + } + + bool gtao = OptimumConfig.AmbientOcclusionShadersUseGtao; + int gPosition = gtao ? primary.ColorTextureIds[3] : 0; + int revealage = gtao ? transparent.ColorTextureIds[1] : 0; + + NativePipeline? pipeline = NativePostPipeline(nativeSceneSsao, composite, primary.FboId, 1u, + NativeMultiplySlotZeroBlend(), depthTest: false, depthWrite: false, CompareOp.Less); + if (pipeline == null) return; + + // The body binds Primary through LoadFrameBuffer here, which is also what puts the + // viewport back at full render resolution for the steps that follow. + LoadFrameBuffer(EnumFrameBuffer.Primary); + + var reads = new List { aoTexture }; + if (gtao) + { + reads.Add(gPosition); + reads.Add(revealage); + } + + if (BeginNativeAoPass("SceneSsao/" + primary.FboId, primary.FboId, primary.Width, primary.Height, + reads.ToArray(), clearWhite: false)) + { + var textures = new List { new(nativeSceneSsao.Samplers[0], aoTexture) }; + if (gtao) + { + textures.Add(new NativeTexture(nativeSceneSsao.Samplers[1], gPosition)); + textures.Add(new NativeTexture(nativeSceneSsao.Samplers[2], revealage)); + device.WriteNative(pipeline, nativeSceneSsao.Uniforms[1], + OptimumPostAmbientOcclusionTexture != 0 ? 1 : 0); + } + device.WriteNative(pipeline, nativeSceneSsao.Uniforms[0], 1f / primary.Height); + device.DrawNativeFullscreen(pipeline, textures.ToArray()); + } + device.EndNativePass(); + + // The net GL-shaped state the body's composite leaves: blending back on in the standard + // mode and the depth test back on. The draw-buffer mask is not restored because it was + // never narrowed - the written slot is the pass's, so the world mask never moved. + GlToggleBlend(on: true); + GlEnableDepthTest(); + OptimumPostSsaoInScene = true; + } + + // ------------------------------------------------------------------ shared plumbing + + /// Colour slot 0 alone, opaque: no blend, every channel written, every other slot masked out. + private static AttachmentBlend[] NativeOpaqueSlotZeroBlend() => new[] { AttachmentBlend.Default }; + + /// + /// Colour slot 0 alone with EnumBlendMode.Multiply: glBlendFuncSeparate(ZERO, + /// ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA) over glBlendEquation(FUNC_ADD). + /// + private static AttachmentBlend[] NativeMultiplySlotZeroBlend() + { + AttachmentBlend blend = AttachmentBlend.Default; + blend.Enabled = true; + blend.SrcColor = BlendFactor.Zero; + blend.DstColor = BlendFactor.OneMinusSrcAlpha; + blend.ColorOp = BlendOp.Add; + blend.SrcAlpha = BlendFactor.One; + blend.DstAlpha = BlendFactor.OneMinusSrcAlpha; + blend.AlphaOp = BlendOp.Add; + return new[] { blend }; + } + + /// + /// A pass of this step: one written colour slot, its own viewport (every target here is drawn + /// at its own size, which is what the body's LoadFrameBuffer cases set), its reads, and the + /// white clear the SSAO target starts from. + /// + private bool BeginNativeAoPass(string name, int framebufferId, int width, int height, int[] reads, bool clearWhite) => + device.BeginNativePass(new NativePassDescription + { + Name = name, + FramebufferId = framebufferId, + ColorSlots = 1u, + Reads = reads, + Flags = PassFlags.None, + ClearSlots = clearWhite ? 1u : 0u, + ClearValue = new[] { 1f, 1f, 1f, 1f }, + ViewportWidth = width, + ViewportHeight = height, + }); + +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 898c2733..6307e00e 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -159,6 +159,16 @@ internal sealed class NativePassDescription public uint TransientSlots; public PassFlags Flags = PassFlags.None; + /// + /// Bit i: colour slot i starts the pass cleared to . The pass states + /// its own clear instead of a glClearBuffer against a draw-buffer mask, so it lands as the + /// scope's load op. + /// + public uint ClearSlots; + + /// The value clears to. + public float[] ClearValue = { 0f, 0f, 0f, 0f }; + public int ViewportX; public int ViewportY; @@ -430,6 +440,13 @@ internal bool BeginNativePass(NativePassDescription pass) }, id); if (!ReferenceEquals(_targets.Bound, target)) _targets.Bind(commandBuffer, id); + for (int slot = 0; slot < GlStateTracker.MaxColorAttachments && pass.ClearSlots != 0; slot++) + { + if (((pass.ClearSlots >> slot) & 1) == 0) continue; + _targets.ClearPassAttachment(commandBuffer, slot, + pass.ClearValue[0], pass.ClearValue[1], pass.ClearValue[2], pass.ClearValue[3]); + } + _nativePass = pass; _nativeTarget = target; _nativePasses++; diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index 6d115143..b9a9d57e 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -424,8 +424,170 @@ public void TheDebugViewIsAnOptimumTabSwitchWithItsLangEntriesAndPatcherListing( Assert.Contains("\"onOptimumAmbientOcclusionDebugChanged\"", Read("Optimum.Patcher/Program.cs")); } + // ------------------------------------- the AO step drawn natively (Phase 3b stage 1c) + + /// + /// The Vulkan platform draws the AO step through the native device API: a pipeline with + /// stated fixed state per pass, a pass that names its target, its written colour slot and its + /// reads, uniforms written by resolved placement, and textures resolved straight to bindless + /// slots. The chain step routes to it, with the lib virtual as the old route. + /// + [Fact] + public void TheVulkanPlatformDrawsTheAoStepNatively() + { + string chain = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs"); + Assert.Contains("if (UseNativePostChain && NativeAmbientOcclusionReady())", chain); + Assert.Contains("NativeAmbientOcclusion(projectMatrix);", chain); + Assert.Contains("OptimumPostAmbientOcclusion(projectMatrix);", chain); + + string native = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs"); + + // One NativeFullscreenPass per program, with the uniform and sampler names the passes + // resolve once instead of looking up per draw. + Assert.Contains("nativeSsao = new(\"ssao\",", native); + Assert.Contains("nativeBilateralBlur = new(\"bilateralblur\",", native); + Assert.Contains("nativeSceneSsao = new(\"scene-ssao\",", native); + Assert.Contains("\"screenSize\", \"projection\", \"samples\", \"temporalFrameIndex\"", native); + Assert.Contains("\"gPosition\", \"gNormal\", \"texNoise\", \"revealage\"", native); + Assert.Contains("\"frameSize\", \"isVertical\"", native); + Assert.Contains("\"inputTexture\", \"depthTexture\"", native); + Assert.Contains("\"invRenderHeight\", \"optimumAoMode\"", native); + Assert.Contains("\"ssaoScene\", \"gPositionScene\", \"revealageScene\"", native); + + // Every pass states its fixed state and writes colour slot 0 alone. + Assert.Equal(3, Count(native, "depthTest: false, depthWrite: false, CompareOp.Less)")); + Assert.Contains("ColorSlots = 1u,", native); + Assert.Contains("device.DrawNativeFullscreen(pipeline,", native); + Assert.Contains("device.EndNativePass();", native); + } + + /// + /// The values are the OpenGL body's, expression for expression: the half-resolution + /// screenSize, the raw unjittered projection, the 64-sample kernel, the temporal dither index + /// under exactly the condition that compiles the uniform in, the blur's shared frameSize and + /// its iteration count, and the composite's inverse render height. + /// + [Fact] + public void TheNativeAoStepUsesTheOpenGlBodysValues() + { + string native = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs"); + + Assert.Contains("float half = ssaa == 1f ? 0.5f : 1f;", native); + Assert.Contains("ssaa * client.Width * half, ssaa * client.Height * half", native); + Assert.Contains("WriteNativeFloats(pipeline, nativeSsao.Uniforms[1], projectMatrix);", native); + Assert.Contains("WriteNativeFloats(pipeline, nativeSsao.Uniforms[2], OptimumSsaoKernel);", native); + Assert.Contains("if (OptimumConfig.EffectiveTaa)", native); + Assert.Contains("(float)(OptimumTemporal.Frame.FrameIndex & 1023L)", native); + + Assert.Contains("int iterations = ClientSettings.SSAOQuality == 1 ? 1 : 3;", native); + Assert.Contains("buffers[i == 0 ? NativeSsaoTargetIndex : NativeSsaoBlurVerticalIndex].ColorTextureIds[0]", native); + // The blur's frameSize is frameBuffers[15]'s size on both halves, reproduced not fixed. + Assert.Contains("(float)frameSizeSource.Width, (float)frameSizeSource.Height", native); + + Assert.Contains("device.WriteNative(pipeline, nativeSceneSsao.Uniforms[0], 1f / primary.Height);", native); + Assert.Contains("OptimumPostAmbientOcclusionTexture != 0 ? 1 : 0", native); + } + + /// + /// Both AO modes reach the composite, and the step records that AO is in the scene so the + /// final composition never applies it twice. The GTAO branch binds the attenuation inputs and + /// sets optimumAoMode exactly where the body does - under AmbientOcclusionShadersUseGtao, + /// which is what stamps the OPTIMUMAO variant. + /// + [Fact] + public void TheNativeAoStepCarriesBothModesAndTheirFlags() + { + string native = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs"); + + Assert.Contains("OptimumPostAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix);", native); + Assert.Contains("if (OptimumPostAmbientOcclusionTexture == 0 && OptimumRenderSsao && projectMatrix != null)", native); + Assert.Contains("if (OptimumTaaRequested && TaaTargetsReady)", native); + Assert.Contains("bool gtao = OptimumConfig.AmbientOcclusionShadersUseGtao;", native); + Assert.Contains("aoTexture = blurred.ColorTextureIds[0];", native); + Assert.Contains("OptimumPostSsaoInScene = false;", native); + Assert.Contains("OptimumPostSsaoInScene = true;", native); + + // The Multiply blend is the pipeline's, not a tracked GL state. + string multiply = Between(native, "private static AttachmentBlend[] NativeMultiplySlotZeroBlend()", "\n }"); + Assert.Contains("blend.SrcColor = BlendFactor.Zero;", multiply); + Assert.Contains("blend.DstColor = BlendFactor.OneMinusSrcAlpha;", multiply); + Assert.Contains("blend.SrcAlpha = BlendFactor.One;", multiply); + Assert.Contains("blend.DstAlpha = BlendFactor.OneMinusSrcAlpha;", multiply); + Assert.Contains("blend.ColorOp = BlendOp.Add;", multiply); + Assert.Contains("blend.AlphaOp = BlendOp.Add;", multiply); + } + + /// + /// The two pieces of AO frame state and the SSAA factor are lib accessors, so the native step + /// sets and reads exactly what the OpenGL body's step does, and all three are listed for Cecil + /// and in the vanilla-regions test. + /// + [Fact] + public void TheAoStepsFrameStateIsALibSeamListedForCecil() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains("public int OptimumPostAmbientOcclusionTexture", platform); + Assert.Contains("public bool OptimumPostSsaoInScene", platform); + Assert.Contains("public float OptimumPostSsaaLevel", platform); + // The body keeps writing the fields directly: "OFF is vanilla". + Assert.Contains("optimumSsaoInScene = false;", platform); + Assert.Contains("optimumAmbientOcclusionTexture = 0;", platform); + + string patcher = Read("Optimum.Patcher/Program.cs"); + string regions = Read("Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs"); + foreach (string member in new[] + { + "OptimumPostAmbientOcclusionTexture", "OptimumPostSsaoInScene", "OptimumPostSsaaLevel", + }) + { + Assert.Contains("\"" + member + "\"", patcher); + Assert.Contains("\"" + member + "\"", regions); + } + } + + /// + /// The white clear the SSAO target starts from is the pass's own load, declared with the pass + /// - not a glClearBuffer against a draw-buffer mask. + /// + [Fact] + public void TheSsaoTargetsWhiteClearIsThePassesOwnLoad() + { + string native = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs"); + Assert.Contains("ClearSlots = clearWhite ? 1u : 0u,", native); + Assert.Contains("ClearValue = new[] { 1f, 1f, 1f, 1f },", native); + Assert.Contains("clearWhite: true", native); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.Native.cs"); + Assert.Contains("public uint ClearSlots;", device); + Assert.Contains("_targets.ClearPassAttachment(commandBuffer, slot,", device); + + string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); + Assert.Contains("public void ClearPassAttachment(CommandBuffer commandBuffer, int attachment,", targets); + Assert.Contains("_graph.PromoteColorClear(texture, _bound.Color[attachment].Layer, r, g, b, a);", targets); + } + // ------------------------------------------------------------------ helpers + private static int Count(string text, string value) + { + int count = 0; + int offset = 0; + while ((offset = text.IndexOf(value, offset, StringComparison.Ordinal)) >= 0) + { + count++; + offset += value.Length; + } + return count; + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string full = Path.Combine(Root(), patchPath); + return File.Exists(full) ? PatchReader.ReadPatchedContent(full) : Read(sourcePath); + } + private static string Between(string text, string start, string end) { int from = text.IndexOf(start, StringComparison.Ordinal); diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index accf9df5..48f0da7d 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -49,6 +49,7 @@ public class ClientPlatformWindowsVanillaRegionsTests "OptimumPostAmbientOcclusion", "OptimumPostSceneTexture", "OptimumPostGlowTexture", "OptimumPostBloom", "OptimumPostGodRays", "OptimumPostLuma", "OptimumPostFinish", "OptimumBindKeepViewport", + "OptimumPostAmbientOcclusionTexture", "OptimumPostSsaoInScene", "OptimumPostSsaaLevel", "ReadDefaultFramebuffer", "ReadTextureForParity", "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "RestorePrimaryDrawBuffers", "RestoreWorldDrawBuffers", "SelectBackDrawBuffer", "SelectFsrDrawBuffer", "SetBlendEnabled", diff --git a/Optimum.Tests/native-post-chain-coverage-tests.cs b/Optimum.Tests/native-post-chain-coverage-tests.cs index cf07132a..a3d4749c 100644 --- a/Optimum.Tests/native-post-chain-coverage-tests.cs +++ b/Optimum.Tests/native-post-chain-coverage-tests.cs @@ -199,7 +199,6 @@ public void EveryRemainingStepHasALegacyHelperNamingItsStage() foreach (string helper in new[] { - "private void PostStepAmbientOcclusion(float[] projectMatrix) => OptimumPostAmbientOcclusion(projectMatrix);", "private bool PostStepTaaResolve() => RenderOptimumTaaResolve();", "private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene);", "private void PostStepBloom(int scene, int glow) => OptimumPostBloom(scene, glow);", @@ -214,14 +213,15 @@ public void EveryRemainingStepHasALegacyHelperNamingItsStage() Assert.Contains(helper, chain); } - foreach (string stage in new[] { "Stage 1c makes it native", "Stage 1d makes it native", + foreach (string stage in new[] { "Stage 1d makes it native", "Stage 1e makes it native", "Stage 1f makes it native", "Stage 1g makes it native" }) { Assert.Contains(stage, chain); } - // One per remaining pass: the seven steps above plus the merge, sky motion and the - // final composition, whose old routes stay reachable through the chain switch. - Assert.Equal(10, Count(chain, "LEGACY -")); + // One per remaining pass: the six steps above plus the merge, sky motion and the final + // composition, whose old routes stay reachable through the chain switch. The AO step is + // native as of stage 1c and its old route is the lib virtual itself, not a legacy helper. + Assert.Equal(9, Count(chain, "LEGACY -")); } private static string Platform() => ReadPatchedOrSource( diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index d56abc10..94b9a2b3 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index 6edf0c9..c402ea4 100644 +index 6edf0c9..68a67d2 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -2177,7 +2177,7 @@ index 6edf0c9..c402ea4 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,50 +3250,403 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,110 +3250,343 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2227,14 +2227,26 @@ index 6edf0c9..c402ea4 100644 + ApplyOptimumMotionBlendState(); + EndMotionWrite(); + } -+ } -+ + } + +- public override void RenderPostprocessingEffects(float[] projectMatrix) + /// + /// Optimum (Phase 1A step 4): the GL half of the OIT merge's state - depth test off, + /// blending on with the global source-alpha mode. + /// + public override void ApplyTransparentMergeBlendState() -+ { + { +- //IL_000f: Unknown result type (might be due to invalid IL or missing references) +- //IL_0020: Unknown result type (might be due to invalid IL or missing references) +- //IL_0189: Unknown result type (might be due to invalid IL or missing references) +- //IL_01a8: Unknown result type (might be due to invalid IL or missing references) +- //IL_023b: Unknown result type (might be due to invalid IL or missing references) +- //IL_0254: Unknown result type (might be due to invalid IL or missing references) +- //IL_035c: Unknown result type (might be due to invalid IL or missing references) +- //IL_0375: Unknown result type (might be due to invalid IL or missing references) +- //IL_05b0: Unknown result type (might be due to invalid IL or missing references) +- //IL_05c9: Unknown result type (might be due to invalid IL or missing references) +- if (!OffscreenBuffer) + GL.Disable((EnableCap)2929); + GL.Enable((EnableCap)3042); + GL.BlendFunc((BlendingFactor)770, (BlendingFactor)771); @@ -2276,17 +2288,55 @@ index 6edf0c9..c402ea4 100644 + } + ShaderProgram resolve = ShaderPrograms.TaaResolve; + if (resolve == null || resolve.LoadError) -+ { + { +- return; + _taaHistoryValid = false; + return false; -+ } + } +- int x = ((NativeWindow)window).ClientSize.X; +- int y = ((NativeWindow)window).ClientSize.Y; +- if (RenderBloom) + FrameBufferRef write = TaaHistory(_taaFrameParity); + FrameBufferRef read = TaaHistory(_taaFrameParity + 1); + if (write == null || read == null) -+ { + { +- GlToggleBlend(on: false); +- LoadFrameBuffer(EnumFrameBuffer.FindBright); +- ShaderProgramFindbright findbright = ShaderPrograms.Findbright; +- findbright.Use(); +- findbright.ColorTex2D = frameBuffers[0].ColorTextureIds[0]; +- findbright.GlowTex2D = frameBuffers[0].ColorTextureIds[1]; +- findbright.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; +- findbright.ExtraBloom = ShaderUniforms.ExtraBloom; +- RenderFullscreenTriangle(screenQuad); +- findbright.Stop(); +- ShaderProgramBlur blur = ShaderPrograms.Blur; +- blur.Use(); +- blur.Uniform("frameSize", (float)x * ssaaLevel, (float)y * ssaaLevel); +- LoadFrameBuffer(EnumFrameBuffer.BlurHorizontalMedRes); +- blur.IsVertical = 0; +- blur.InputTexture2D = frameBuffers[4].ColorTextureIds[0]; +- RenderFullscreenTriangle(screenQuad); +- LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); +- blur.IsVertical = 1; +- blur.InputTexture2D = frameBuffers[2].ColorTextureIds[0]; +- RenderFullscreenTriangle(screenQuad); +- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X / 4f), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y / 4f)); +- LoadFrameBuffer(EnumFrameBuffer.BlurHorizontalLowRes); +- blur.IsVertical = 0; +- blur.InputTexture2D = frameBuffers[3].ColorTextureIds[0]; +- RenderFullscreenTriangle(screenQuad); +- LoadFrameBuffer(EnumFrameBuffer.BlurVerticalLowRes); +- blur.IsVertical = 1; +- blur.InputTexture2D = frameBuffers[9].ColorTextureIds[0]; +- RenderFullscreenTriangle(screenQuad); +- blur.Stop(); +- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); +- GlToggleBlend(on: true); + _taaHistoryValid = false; + return false; -+ } + } +- if (RenderGodRays) + + OptimumTemporalFrame frame = OptimumTemporal.Frame; + float[] projection = frame.GetProjection(EnumTemporalView.World); @@ -2445,9 +2495,23 @@ index 6edf0c9..c402ea4 100644 + // replaces that one step and inherits the rest, and the OpenGL path runs the + // identical sequence it ran before the split. + if (!OffscreenBuffer) -+ { + { +- LoadFrameBuffer(EnumFrameBuffer.GodRays); +- ShaderProgramGodrays godrays = ShaderPrograms.Godrays; +- godrays.Use(); +- godrays.Uniform("invFrameSizeIn", 1f / ((float)x * ssaaLevel), 1f / ((float)y * ssaaLevel)); +- godrays.SunPosScreenIn = ShaderUniforms.SunPositionScreen; +- godrays.SunPos3dIn = ShaderUniforms.LightPosition3D; +- godrays.PlayerViewVector = ShaderUniforms.PlayerViewVector; +- godrays.Dusk = ShaderUniforms.Dusk; +- godrays.IGlobalTimeIn = (float)EllapsedMs / 1000f; +- godrays.InputTexture2D = frameBuffers[0].ColorTextureIds[0]; +- godrays.GlowParts2D = frameBuffers[0].ColorTextureIds[1]; +- RenderFullscreenTriangle(screenQuad); +- godrays.Stop(); +- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); + return; -+ } + } + OptimumPostAmbientOcclusion(projectMatrix); + // Optimum TAA: resolve first, so bloom, god rays and the final input read + // the temporally stable image instead of the jittered one. @@ -2483,27 +2547,32 @@ index 6edf0c9..c402ea4 100644 + // vanilla SSAO pass when it returns a texture - the Vulkan platform with GTAO selected. + // The neutral body returns 0, so the OpenGL path always runs vanilla SSAO. + optimumAmbientOcclusionTexture = 0; -+ if (RenderSSAO && projectMatrix != null) -+ { + if (RenderSSAO && projectMatrix != null) + { + optimumAmbientOcclusionTexture = RenderOptimumAmbientOcclusion(projectMatrix); + } + if (optimumAmbientOcclusionTexture == 0 && RenderSSAO && projectMatrix != null) + { -+ int x = ((NativeWindow)window).ClientSize.X; -+ int y = ((NativeWindow)window).ClientSize.Y; -+ GlToggleBlend(on: false); -+ LoadFrameBuffer(EnumFrameBuffer.SSAO); ++ // Optimum (Phase 3b, decision 3): through the window-size seam, so this step and ++ // the native one that replaces it scale by the same value - and so a test can ++ // drive either without a window. ++ Size2i optimumClientSize = OptimumWindowClientSize(); ++ int x = optimumClientSize.Width; ++ int y = optimumClientSize.Height; + GlToggleBlend(on: false); + LoadFrameBuffer(EnumFrameBuffer.SSAO); +- GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); + ClearSsaoTarget(); -+ ShaderProgramSsao ssao = ShaderPrograms.Ssao; -+ ssao.Use(); -+ ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; -+ ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; -+ ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -+ float num = ((ssaaLevel == 1f) ? 0.5f : 1f); -+ ssao.Uniform("screenSize", ssaaLevel * (float)x * num, ssaaLevel * (float)y * num); -+ ssao.Revealage2D = frameBuffers[1].ColorTextureIds[1]; -+ ssao.Projection = projectMatrix; -+ ssao.SamplesArray(64, ssaoKernel); + ShaderProgramSsao ssao = ShaderPrograms.Ssao; + ssao.Use(); + ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; + ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; + ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; + float num = ((ssaaLevel == 1f) ? 0.5f : 1f); + ssao.Uniform("screenSize", ssaaLevel * (float)x * num, ssaaLevel * (float)y * num); + ssao.Revealage2D = frameBuffers[1].ColorTextureIds[1]; + ssao.Projection = projectMatrix; + ssao.SamplesArray(64, ssaoKernel); + // Optimum TAA: the clock the sample kernel's dither rotates with. + // Vanilla's Bayer-128 is locked to the pixel grid, so a jittered camera + // hands every surface point a different kernel every frame and the @@ -2516,29 +2585,19 @@ index 6edf0c9..c402ea4 100644 + { + ssao.Uniform("temporalFrameIndex", (float)(OptimumTemporal.Frame.FrameIndex & 1023L)); + } -+ RenderFullscreenTriangle(screenQuad); -+ ssao.Stop(); -+ ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; -+ bilateralblur.Use(); -+ int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); -+ for (int i = 0; i < num2; i++) -+ { -+ FrameBufferRef frameBufferRef = frameBuffers[15]; -+ LoadFrameBuffer(EnumFrameBuffer.SSAOBlurHorizontal); -+ bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); -+ bilateralblur.IsVertical = 0; -+ bilateralblur.InputTexture2D = frameBuffers[(i == 0) ? 13 : 14].ColorTextureIds[0]; -+ bilateralblur.DepthTexture2D = frameBuffers[0].DepthTextureId; -+ RenderFullscreenTriangle(screenQuad); -+ LoadFrameBuffer(EnumFrameBuffer.SSAOBlurVertical); -+ bilateralblur.IsVertical = 1; -+ bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); -+ bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; -+ RenderFullscreenTriangle(screenQuad); -+ } -+ bilateralblur.Stop(); -+ GlToggleBlend(on: true); -+ GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); + RenderFullscreenTriangle(screenQuad); + ssao.Stop(); + ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; + bilateralblur.Use(); + int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); +@@ -1915,35 +3605,254 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract + bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; + RenderFullscreenTriangle(screenQuad); + } + bilateralblur.Stop(); + GlToggleBlend(on: true); +- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); ++ GlViewport(0, 0, (int)(ssaaLevel * (float)x), (int)(ssaaLevel * (float)y)); + if (OptimumTaaRequested && TaaTargetsReady) + { + ApplyOptimumSceneSsao(); @@ -2550,9 +2609,8 @@ index 6edf0c9..c402ea4 100644 + { + ApplyOptimumSceneSsao(); + } - } - -- public override void RenderPostprocessingEffects(float[] projectMatrix) ++ } ++ + /// + /// Optimum (Phase 3b): the scene texture the rest of the chain reads - the TAA resolve's + /// output where it ran this frame, the jittered Primary colour otherwise. @@ -2570,128 +2628,70 @@ index 6edf0c9..c402ea4 100644 + + /// Optimum (Phase 3b): the post chain's bloom step - find-bright and the two blur ping-pongs. + public virtual void OptimumPostBloom(int postSceneTexture, int postGlowTexture) - { -- //IL_000f: Unknown result type (might be due to invalid IL or missing references) -- //IL_0020: Unknown result type (might be due to invalid IL or missing references) -- //IL_0189: Unknown result type (might be due to invalid IL or missing references) -- //IL_01a8: Unknown result type (might be due to invalid IL or missing references) -- //IL_023b: Unknown result type (might be due to invalid IL or missing references) -- //IL_0254: Unknown result type (might be due to invalid IL or missing references) -- //IL_035c: Unknown result type (might be due to invalid IL or missing references) -- //IL_0375: Unknown result type (might be due to invalid IL or missing references) -- //IL_05b0: Unknown result type (might be due to invalid IL or missing references) -- //IL_05c9: Unknown result type (might be due to invalid IL or missing references) -- if (!OffscreenBuffer) -- { -- return; -- } -- int x = ((NativeWindow)window).ClientSize.X; -- int y = ((NativeWindow)window).ClientSize.Y; - if (RenderBloom) - { ++ { ++ if (RenderBloom) ++ { + int x = ((NativeWindow)window).ClientSize.X; + int y = ((NativeWindow)window).ClientSize.Y; - GlToggleBlend(on: false); - LoadFrameBuffer(EnumFrameBuffer.FindBright); - ShaderProgramFindbright findbright = ShaderPrograms.Findbright; - findbright.Use(); -- findbright.ColorTex2D = frameBuffers[0].ColorTextureIds[0]; -- findbright.GlowTex2D = frameBuffers[0].ColorTextureIds[1]; ++ GlToggleBlend(on: false); ++ LoadFrameBuffer(EnumFrameBuffer.FindBright); ++ ShaderProgramFindbright findbright = ShaderPrograms.Findbright; ++ findbright.Use(); + findbright.ColorTex2D = postSceneTexture; + findbright.GlowTex2D = postGlowTexture; - findbright.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; - findbright.ExtraBloom = ShaderUniforms.ExtraBloom; - RenderFullscreenTriangle(screenQuad); - findbright.Stop(); - ShaderProgramBlur blur = ShaderPrograms.Blur; -@@ -1848,102 +3658,151 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract - RenderFullscreenTriangle(screenQuad); - LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); - blur.IsVertical = 1; - blur.InputTexture2D = frameBuffers[2].ColorTextureIds[0]; - RenderFullscreenTriangle(screenQuad); -- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X / 4f), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y / 4f)); ++ findbright.AmbientBloomLevel = ClientSettings.AmbientBloomLevel / 100f + ShaderUniforms.AmbientBloomLevelAdd[0] + ShaderUniforms.AmbientBloomLevelAdd[1] + ShaderUniforms.AmbientBloomLevelAdd[2] + ShaderUniforms.AmbientBloomLevelAdd[3]; ++ findbright.ExtraBloom = ShaderUniforms.ExtraBloom; ++ RenderFullscreenTriangle(screenQuad); ++ findbright.Stop(); ++ ShaderProgramBlur blur = ShaderPrograms.Blur; ++ blur.Use(); ++ blur.Uniform("frameSize", (float)x * ssaaLevel, (float)y * ssaaLevel); ++ LoadFrameBuffer(EnumFrameBuffer.BlurHorizontalMedRes); ++ blur.IsVertical = 0; ++ blur.InputTexture2D = frameBuffers[4].ColorTextureIds[0]; ++ RenderFullscreenTriangle(screenQuad); ++ LoadFrameBuffer(EnumFrameBuffer.BlurVerticalMedRes); ++ blur.IsVertical = 1; ++ blur.InputTexture2D = frameBuffers[2].ColorTextureIds[0]; ++ RenderFullscreenTriangle(screenQuad); + // Mono.Cecil transplant: GlViewport is the routed form of GL.Viewport + // and its GL body is the identical call. + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X / 4f), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y / 4f)); - LoadFrameBuffer(EnumFrameBuffer.BlurHorizontalLowRes); - blur.IsVertical = 0; - blur.InputTexture2D = frameBuffers[3].ColorTextureIds[0]; - RenderFullscreenTriangle(screenQuad); - LoadFrameBuffer(EnumFrameBuffer.BlurVerticalLowRes); - blur.IsVertical = 1; - blur.InputTexture2D = frameBuffers[9].ColorTextureIds[0]; - RenderFullscreenTriangle(screenQuad); - blur.Stop(); -- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); ++ LoadFrameBuffer(EnumFrameBuffer.BlurHorizontalLowRes); ++ blur.IsVertical = 0; ++ blur.InputTexture2D = frameBuffers[3].ColorTextureIds[0]; ++ RenderFullscreenTriangle(screenQuad); ++ LoadFrameBuffer(EnumFrameBuffer.BlurVerticalLowRes); ++ blur.IsVertical = 1; ++ blur.InputTexture2D = frameBuffers[9].ColorTextureIds[0]; ++ RenderFullscreenTriangle(screenQuad); ++ blur.Stop(); + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); - GlToggleBlend(on: true); - } ++ GlToggleBlend(on: true); ++ } + } + + /// Optimum (Phase 3b): the post chain's god-rays step. + public virtual void OptimumPostGodRays(int postSceneTexture, int postGlowTexture) + { - if (RenderGodRays) - { ++ if (RenderGodRays) ++ { + int x = ((NativeWindow)window).ClientSize.X; + int y = ((NativeWindow)window).ClientSize.Y; - LoadFrameBuffer(EnumFrameBuffer.GodRays); - ShaderProgramGodrays godrays = ShaderPrograms.Godrays; - godrays.Use(); - godrays.Uniform("invFrameSizeIn", 1f / ((float)x * ssaaLevel), 1f / ((float)y * ssaaLevel)); ++ LoadFrameBuffer(EnumFrameBuffer.GodRays); ++ ShaderProgramGodrays godrays = ShaderPrograms.Godrays; ++ godrays.Use(); ++ godrays.Uniform("invFrameSizeIn", 1f / ((float)x * ssaaLevel), 1f / ((float)y * ssaaLevel)); + godrays.Uniform("maxGodRaySamples", OptimumConfig.GodRaysSampleLimit); - godrays.SunPosScreenIn = ShaderUniforms.SunPositionScreen; - godrays.SunPos3dIn = ShaderUniforms.LightPosition3D; - godrays.PlayerViewVector = ShaderUniforms.PlayerViewVector; - godrays.Dusk = ShaderUniforms.Dusk; - godrays.IGlobalTimeIn = (float)EllapsedMs / 1000f; -- godrays.InputTexture2D = frameBuffers[0].ColorTextureIds[0]; -- godrays.GlowParts2D = frameBuffers[0].ColorTextureIds[1]; ++ godrays.SunPosScreenIn = ShaderUniforms.SunPositionScreen; ++ godrays.SunPos3dIn = ShaderUniforms.LightPosition3D; ++ godrays.PlayerViewVector = ShaderUniforms.PlayerViewVector; ++ godrays.Dusk = ShaderUniforms.Dusk; ++ godrays.IGlobalTimeIn = (float)EllapsedMs / 1000f; + godrays.InputTexture2D = postSceneTexture; + godrays.GlowParts2D = postGlowTexture; - RenderFullscreenTriangle(screenQuad); - godrays.Stop(); -- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); -- } -- if (RenderSSAO && projectMatrix != null) -- { -- GlToggleBlend(on: false); -- LoadFrameBuffer(EnumFrameBuffer.SSAO); -- GL.ClearBuffer((ClearBuffer)6144, 0, new float[4] { 1f, 1f, 1f, 1f }); -- ShaderProgramSsao ssao = ShaderPrograms.Ssao; -- ssao.Use(); -- ssao.GNormal2D = frameBuffers[0].ColorTextureIds[2]; -- ssao.GPosition2D = frameBuffers[0].ColorTextureIds[3]; -- ssao.TexNoise2D = frameBuffers[13].ColorTextureIds[1]; -- float num = ((ssaaLevel == 1f) ? 0.5f : 1f); -- ssao.Uniform("screenSize", ssaaLevel * (float)x * num, ssaaLevel * (float)y * num); -- ssao.Revealage2D = frameBuffers[1].ColorTextureIds[1]; -- ssao.Projection = projectMatrix; -- ssao.SamplesArray(64, ssaoKernel); -- RenderFullscreenTriangle(screenQuad); -- ssao.Stop(); -- ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; -- bilateralblur.Use(); -- int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); -- for (int i = 0; i < num2; i++) -- { -- FrameBufferRef frameBufferRef = frameBuffers[15]; -- LoadFrameBuffer(EnumFrameBuffer.SSAOBlurHorizontal); -- bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); -- bilateralblur.IsVertical = 0; -- bilateralblur.InputTexture2D = frameBuffers[(i == 0) ? 13 : 14].ColorTextureIds[0]; -- bilateralblur.DepthTexture2D = frameBuffers[0].DepthTextureId; -- RenderFullscreenTriangle(screenQuad); -- LoadFrameBuffer(EnumFrameBuffer.SSAOBlurVertical); -- bilateralblur.IsVertical = 1; -- bilateralblur.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); -- bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; -- RenderFullscreenTriangle(screenQuad); -- } -- bilateralblur.Stop(); -- GlToggleBlend(on: true); -- GL.Viewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); ++ RenderFullscreenTriangle(screenQuad); ++ godrays.Stop(); + GlViewport(0, 0, (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.X), (int)(ssaaLevel * (float)((NativeWindow)window).ClientSize.Y)); } - if (RenderFXAA) @@ -2757,6 +2757,52 @@ index 6edf0c9..c402ea4 100644 + /// + private int optimumAmbientOcclusionTexture; + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b): the ambient-occlusion step's own two ++ /// pieces of frame state, as accessors, so a platform that draws the step natively ++ /// (VulkanClientPlatform's NativePostChain) sets exactly what the body's step sets and the ++ /// steps that read it - the final composition's debug-view texture choice and its "AO is not ++ /// Final's to apply" flag - see the same values. The body keeps writing the fields directly, ++ /// so the OpenGL path runs unchanged. ++ /// ++ public int OptimumPostAmbientOcclusionTexture ++ { ++ get ++ { ++ return optimumAmbientOcclusionTexture; ++ } ++ set ++ { ++ optimumAmbientOcclusionTexture = value; ++ } ++ } ++ ++ /// Optimum (Phase 3b): see . ++ public bool OptimumPostSsaoInScene ++ { ++ get ++ { ++ return optimumSsaoInScene; ++ } ++ set ++ { ++ optimumSsaoInScene = value; ++ } ++ } ++ ++ /// ++ /// Optimum (Phase 3b): the SSAA factor the post chain's steps scale the window size by. A ++ /// native step computes the same uniform values as the body, so it reads the body's own field ++ /// rather than ClientSettings, which can have moved since the frame buffers were built. ++ /// ++ public float OptimumPostSsaaLevel ++ { ++ get ++ { ++ return ssaaLevel; ++ } ++ } ++ + /// Optimum TAA: multiply the jittered AO into Primary colour 0 before the resolve, touching nothing else. + private void ApplyOptimumSceneSsao() + { @@ -2803,7 +2849,7 @@ index 6edf0c9..c402ea4 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,25 +3812,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,25 +3862,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -2848,7 +2894,7 @@ index 6edf0c9..c402ea4 100644 final.ExtraGamma = ClientSettings.ExtraGammaLevel; final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; -@@ -1987,24 +3860,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +3910,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -2905,7 +2951,7 @@ index 6edf0c9..c402ea4 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +3915,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +3965,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3403,7 +3449,7 @@ index 6edf0c9..c402ea4 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4563,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4613,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3443,7 +3489,7 @@ index 6edf0c9..c402ea4 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +4961,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +5011,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3496,7 +3542,7 @@ index 6edf0c9..c402ea4 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +5056,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5106,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3541,7 +3587,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5093,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5143,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3562,7 +3608,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5112,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5162,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3583,7 +3629,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5131,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5181,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3604,7 +3650,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5150,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5200,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3625,7 +3671,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5173,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5223,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3646,7 +3692,7 @@ index 6edf0c9..c402ea4 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5735,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5785,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3670,7 +3716,7 @@ index 6edf0c9..c402ea4 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6094,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6144,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From 7d874d48e15265ecc33bb07888f940ec69ebe717 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 11:09:45 +0200 Subject: [PATCH 191/226] Phase 3b stage 1 complete: all nine post/TAA chain passes draw natively The chain no longer runs the OpenGL body on Vulkan. OIT merge, sky motion, SSAO + bilateral blur + AO composite (vanilla and GTAO), TAA resolve and sharpen, the bloom chain, god rays, FXAA luma and the final composition all draw through the native device API, alongside the stage-1a blit. TAA resolve and sharpen keep the lib body deliberately - it holds the temporal contract - and re-route only the draw through new OptimumTaaResolveDraw/SharpenDraw seams. Acceptance is behavioural identity: every ported pass has a GPU differential test against the old route across the settings that change it, and the old route stays reachable to compare against. Verified: Optimum.Tests 1243 passed, GPU suite 1065 passed with sync+best and the implicit layers disabled, patches 157 with 0 conflicts. Headless run on both backends with AO pinned to vanilla: renderer confirmed per run, 0 client errors, validation 0 errors and 0 SYNC-, per-frame SSIM 0.9756/0.9573/0.9697 against an OpenGL-vs-OpenGL noise floor of 0.9597/0.9611/0.9670 measured in the same session. --- docs/vulkan-branch-progress.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 71c269b2..88233912 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -117,6 +117,17 @@ compared before and after for every branch. ### Plan status, audited 2026-09-16 (scoped to this branch) +**Phase 3b stage 1 completed and verified in game, 2026-09-16 (merge `2c9bc70`).** All nine post/TAA chain +passes draw through the native device API: OIT merge, sky motion, SSAO + bilateral blur + AO composite (both AO +modes), TAA resolve and sharpen (the lib body keeps the temporal contract, only the draw is re-routed), the bloom +chain, god rays, FXAA luma and the final composition (write slot 0 while sampling slot 1, no feedback copy), plus +the stage-1a blit. Suites on the merged state: Optimum.Tests 1243 passed, GPU 1065 passed, 0 failed, patches +157/0 conflict. Headless both-backends run on the RTX 4070, AO pinned to vanilla so the backends compare like for +like: renderer line confirmed per run, 0 client errors, validation 0 errors and 0 `SYNC-`, native chain active in +the real client. Per-frame SSIM Vulkan vs OpenGL 0.9756 / 0.9573 / 0.9697 against this session's OpenGL-vs-OpenGL +noise floor of 0.9597 / 0.9611 / 0.9670 - at or above the floor, i.e. the backends differ no more than two OpenGL +launches of the same save differ from each other. + Every item of `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md` checked against this tree. The plan predates the PR #69 split, so it also contains DLSS, upscaler, frame-generation, HDR and ray-tracing work: those are marked `[out]` and are NOT owed on this branch. @@ -191,17 +202,16 @@ Legend: `[x]` done, `[~]` partly done (what is left follows it), `[ ]` not start - [x] DONE — **Phase 3b decision 1**: Runtime rewriter stays permanently as mod-shader adapter - [~] PARTIAL — **Phase 3b decision 2**: Seams are existing virtuals, overridden without calling base - - left: 6 of 7 post/TAA virtuals still call base; no world-system transplanted seams exist (ChunkRenderer, entities, particles, GUI unchanged). + - left: Post/TAA chain done: the nine passes are native (TAA resolve/sharpen keep the lib body for the temporal contract and re-route only the draw). No world-system transplanted seams exist yet (ChunkRenderer, entities, particles, GUI unchanged). - [~] PARTIAL — **Phase 3b decision 3**: A native system reads client state, never GL state - left: Rule only exercised by the blit; unverified for any world-render system since none has been ported. - [x] DONE — **Phase 3b decision 4**: Device API for native systems (NativePasses) - [~] PARTIAL — **Phase 3b decision 5: order and parallelism**: Stage 1 (device API + post/TAA chain) then parallel world systems then removal - - left: Stage 1 itself incomplete (8 of 9 chain passes still on base); stage 2 (chunks, entities, particles/decals/sky/clouds, GUI/text) not started; stage 3 removal not started. + - left: Stage 1 COMPLETE (2026-09-16, merge 2c9bc70): all nine chain passes native. Stage 2 (chunks, entities, particles/decals/sky/clouds, GUI/text) not started; stage 3 removal not started. - [~] PARTIAL — **Phase 3b decision 6**: Behavioural identity is the acceptance rule (old-route vs native-route GPU tests) - - left: Differential tests needed for the remaining 8 passes once each goes native; none exist because none is native. + - left: Chain passes have differential old-route-vs-native tests. World systems still need theirs once each goes native. - [x] DONE — **Phase 3b decision 7**: FSR input identity preserved (BlitPrimaryToDefault keeps reading Primary colour 0) -- [~] PARTIAL — **Phase 3b stage 1 scope: 9 chain passes**: Which of the nine post/TAA chain passes are native today - - left: 8 of 9 passes (everything except the final blit) still run the OpenGL body via base.() and therefore still go through GlStateTracker, texture units and uniform-by-location. +- [x] DONE — **Phase 3b stage 1 scope: 9 chain passes**: Which of the nine post/TAA chain passes are native today - [ ] LEFT — **Phase 3b: world render systems still on the emulation layer**: Every world render system still on the GL-emulation layer - left: All world render systems (chunks, entities, particles, decals, sky/clouds, GUI/text) - stage 2 of decision 5 - are entirely unstarted. - [ ] LEFT — **Phase 3b: GlStateTracker / texture-unit tables / uniform-by-location reachability**: GlStateTracker, texture-unit tables and uniform-by-location still reachable from the Vulkan path From d07e241f1db0ffd0f0dcc156d45aa3e5cf2ee5fe Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 11:18:46 +0200 Subject: [PATCH 192/226] docs: document the render seams instead of re-mapping the tree every workflow Every workflow opened with a read-only map stage that rediscovered where things are and was discarded when the run ended. A map document would only be a second source of truth that rots, so the rule is that the code answers the question at the declaration: every render seam - a platform virtual, a native pass, a device API entry point - carries a comment saying what it draws, where the OpenGL body is, which target and slots it writes, the state that is not obvious and why, and the test that pins it. A system's name appears in every member that serves it, so one grep finds the seam, the pass, the pipeline and the test. An implementation stage documents the seams it touches as part of the change. Map stages remain only for what the code cannot answer: measured behaviour, vendor documentation, or a tree the repository does not contain. scripts/dev/harvest-maps.py recovers the map output of past runs from the workflow journals for those cases - 17 maps were recoverable from 59 runs. --- docs/vulkan-native-render-systems.md | 35 ++++++++ scripts/dev/harvest-maps.py | 120 +++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 scripts/dev/harvest-maps.py diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index 9b05818d..2dc75029 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -133,3 +133,38 @@ Inputs: - validation clean; - the full suites. - **Not in stage 1:** world systems, GUI, and removal of the emulation layer. + +## 4. Documentation that makes map stages unnecessary + +Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five +figures of tokens each, and thrown the result away when the run ended. The fix is not a map document - +that is a second source of truth and it rots. The fix is that the code answers the question at the +declaration, so an implementer greps and reads instead of mapping. + +**The convention.** Every render seam - a platform virtual a system draws through, a native pass, a +device API entry point - carries a doc comment that answers, in this order: + +1. **What it draws**, in one sentence, in the game's vocabulary ("the far shadow map for chunk meshes", + not "a draw call"). +2. **Where the other side is**: the OpenGL body's type and method, so the two paths can be diffed + without searching. For a native pass, also the pass it replaced. +3. **Target and slots**: which framebuffer and which colour slots it writes, whether it writes depth, + and any colour-write mask that matters (motion windows are masks, never draw-buffer toggles). +4. **State that is not obvious**: blend mode per attachment, depth compare, cull, and anything the pass + deliberately does differently from the tracked GL state, with the reason. +5. **What pins it**: the test that fails if this changes - the differential test name for a native pass, + the coverage test for a lib seam. + +**Greppability is the point.** A system's name appears in the comment of every member that serves it, so +`rg -n "shadow map"` finds the seam, the native pass, the pipeline and the test in one search. When a +system moves to a native pass, its old body keeps its comment and gains the pointer to the new one. + +**Applies to:** `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.*.cs`, `VulkanDevice.Native.cs` and +the transplanted seams in `build/VintagestoryLib/**`. An implementation stage documents the seams it +touches as part of the change, not afterwards; a stage that adds a seam without this comment is +incomplete, and review should send it back. + +**Map stages** are then only for questions the code genuinely cannot answer - measured behaviour, vendor +documentation, or a tree the repository does not contain. `scripts/dev/harvest-maps.py` recovers the map +output of past runs from the workflow journals when one of those is needed again. + diff --git a/scripts/dev/harvest-maps.py b/scripts/dev/harvest-maps.py new file mode 100644 index 00000000..3faabf36 --- /dev/null +++ b/scripts/dev/harvest-maps.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Collect the read-only map stages of past workflow runs into docs/vulkan-render-map.md. + +Every workflow that starts with a map stage pays a five-figure token bill to rediscover where things +are. The results were already written to the per-run journals and then thrown away. This pulls them +back out, keeps the newest result per map label, and writes one document a later stage can read +instead of mapping the tree again. + +Usage: + python3 scripts/dev/harvest-maps.py [--journals ] [--out docs/vulkan-render-map.md] + [--label-prefix map:] [--max-chars 60000] + +The journals live beside the session transcripts, not in the repository, so this is a one-way import: +run it after a workflow whose map stage found something worth keeping, then review the diff. A section +that disagrees with the tree is worse than no section - correct it by hand rather than trusting age. +""" + +import argparse +import json +import os +import time + +DEFAULT_JOURNALS = os.path.expanduser( + "~/.claude/projects/-home-n1ght-Projekte-Optimum/" + "7b680a84-9a6a-436d-a472-1a2eb79eb45f/subagents/workflows" +) + +HEADER = """# Vulkan render map (living document) + +Where things are on the Vulkan path, so a workflow stage does not have to rediscover them. + +**Read this before writing a map stage.** Map only what this file does not already answer, and fold +anything new back in: run `python3 scripts/dev/harvest-maps.py` after a workflow whose map stage found +something, then review the diff. + +Each section is one map agent's own output, unedited, with the date it was produced. Age matters: the +tree moves, and a section that disagrees with it is worse than no section. Verify a file:line before +you rely on it; correct the section when you find it stale. + +Design rationale lives in `docs/vulkan-native-render-systems.md`. Status lives in +`docs/vulkan-branch-progress.md`. This file is only "where is it". +""" + + +def collect(journal_dir, prefix): + """Newest result per map label across every run, joined agentId -> label.""" + best = {} + for run in sorted(os.listdir(journal_dir)): + path = os.path.join(journal_dir, run, "journal.jsonl") + if not os.path.exists(path): + continue + labels, results = {}, [] + for line in open(path, encoding="utf-8"): + try: + rec = json.loads(line) + except ValueError: + continue + kind = rec.get("type") + agent = rec.get("agentId") + if kind == "started" and agent and rec.get("label"): + labels[agent] = rec["label"] + elif kind == "result" and agent: + results.append((agent, rec.get("result"))) + for agent, value in results: + label = labels.get(agent) + if not label or not label.startswith(prefix): + continue + if isinstance(value, (dict, list)): + value = json.dumps(value, indent=2) + if not isinstance(value, str) or len(value) < 500: + continue + transcript = os.path.join(journal_dir, run, f"agent-{agent}.jsonl") + when = os.path.getmtime(transcript) if os.path.exists(transcript) else 0 + if label not in best or when > best[label][0]: + best[label] = (when, run, value) + return best + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--journals", default=DEFAULT_JOURNALS) + ap.add_argument("--out", default="docs/vulkan-render-map.md") + ap.add_argument("--label-prefix", default="map:") + ap.add_argument("--max-chars", type=int, default=60000) + args = ap.parse_args() + + if not os.path.isdir(args.journals): + raise SystemExit(f"no journal directory at {args.journals}") + + best = collect(args.journals, args.label_prefix) + if not best: + raise SystemExit("no map results found") + + order = sorted(best.items(), key=lambda kv: -kv[1][0]) + out = [HEADER, "\n## What is in here\n", + "| map | produced | size | source run |", "|---|---|---|---|"] + for label, (when, run, value) in order: + day = time.strftime("%Y-%m-%d", time.localtime(when)) if when else "unknown" + out.append(f"| [{label}](#{label.replace(':', '').replace('_', '')}) | {day} | " + f"{len(value) // 1000}k | `{run}` |") + out.append("") + + for label, (when, run, value) in order: + day = time.strftime("%Y-%m-%d", time.localtime(when)) if when else "unknown" + body = value + if len(body) > args.max_chars: + body = body[:args.max_chars] + ( + f"\n\n*[truncated at {args.max_chars} characters; the full result is in " + f"the run's journal, `{run}`]*\n") + out.append(f"---\n\n## {label}\n\nProduced {day} by run `{run}`, unedited.\n\n{body}\n") + + with open(args.out, "w", encoding="utf-8") as handle: + handle.write("\n".join(out)) + print(f"wrote {args.out}: {len(order)} maps, {os.path.getsize(args.out)} bytes") + for label, (when, run, value) in order: + print(f" {label:34} {len(value) // 1000:>4}k {run}") + + +if __name__ == "__main__": + main() From 24f4f0f00e9108e73cbe303de2eae7b03fe00cf3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 11:22:42 +0200 Subject: [PATCH 193/226] docs: AGENTS.md is the committed source of truth for how to work on this project Instructions that live only on one machine are useless to another session, another machine, or anyone else on the branch. AGENTS.md now carries all of it and is tracked; CLAUDE.md is a symlink to it; both leave .git/info/exclude. Folded in: what this branch is (PR #69 is Vulkan and TAA only - DLSS, upscalers, frame generation, the vendor latency backends and NGX belong to feat/dlss, feat/dlss-g and feat/latency; frame marking and GUI separation are Vulkan foundation and in scope), and 23 lessons that had lived only in the local agent memory: the scratchpad is tmpfs, cleanup comes last, research before repeating loops, physically correct rendering direction, the NGX shim, the vendor orchestrator decision, the TAA parity lessons and the rest. New rules from today: say only what you verified and name the evidence; decide rather than hand the decision back; reviews are expensive and verification is not; no map stage by default - document the seams at their declaration instead; agents never launch the game, deploy or push; identity and attribution; never relax validation. Rule 5 gains the pgrep self-match, which had me reporting the game as running hours after it was closed. --- AGENTS.md | 522 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 523 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1c401870 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,522 @@ +# Optimum: working rules for agents + +*Single source of truth for agent instructions. `CLAUDE.md` is a symlink to this file; both are in +`.git/info/exclude` and must never be committed.* + +Optimum is a performance mod for Vintage Story: a patched client (OpenGL path in +`ClientPlatformWindows`) plus a Vulkan backend that substitutes the platform +(`VulkanClientPlatform : ClientPlatformWindows` in `Optimum.Render.Vulkan/Platform/`). Read this before touching anything. The +skills in `.claude/skills/` hold the step-by-step procedures; this file holds the rules. +Everything an agent needs is in THIS file: the rules, the procedures they point at, and the project +knowledge folded in below. A lesson learned during a session belongs here, in the repository - the +harness memory store on one machine is a cache, not the record. + +## What this branch is + +`feat/vulkan-taa` carries **the Vulkan backend and TAA only**: upstream PR #69 was split so the maintainer can land +those first. DLSS, XeSS, FSR, frame generation, the vendor latency backends (Reflex, anti-lag, XeLL) and NGX live on +`feat/dlss`, `feat/dlss-g` and `feat/latency` and are NOT work owed here. The plan file predates that split and still +describes them, which is why its items carry an explicit `[out]` mark. + +**Frame structure is Vulkan foundation and IS in scope**: one frame identity per frame with markers around +simulation, render submit and present, and the world frame separated from UI composition (`SceneNoHud` plus a UI +target, HUD composed afterwards). They make pacing measurable and keep the HUD out of the scene image whether or not +an upscaler ever exists. What stays off the branch is the vendor layer that later sits on top of them. + +## Where the truth lives (edit these, never the generated copies) + +| What | Edit here | Generated from it | Ships as | +|---|---|---|---| +| Game client code | `build/VintagestoryLib/**` (decompiled + patched) | `patches/VintagestoryLib/*.patch` via `scripts/extract-patches.sh` | Cecil transplant into vanilla DLL; every changed/new method or member MUST be listed in `Optimum.Patcher/Program.cs` | +| Game API | `VintagestoryApi/**` (hand-maintained fork, git-ignored) | `sources/VintagestoryApi/**` via extract | `VintagestoryAPI-patched.dll`; new files also go in `optimum-api-contracts/optimum-api-contracts.csproj` (path `..\sources\VintagestoryApi\...`) and get a `` in both `VintagestoryApi/VintagestoryAPI.csproj` and `sources/VintagestoryApi/VintagestoryAPI.csproj` | +| Mods | `VSEssentials/`, `VSSurvivalMod/`, `VSCreativeMod/` (forks) | `patches//*.patch` via extract | recompiled mod DLLs plus `Optimum.Patcher/mod-patcher.cs` manifests for the installed-runtime path | +| Shaders | `sources/shaders/*.vsh/.fsh` (override vanilla by file name) | shipped by `make deploy` and `scripts/package-*` | includes: `sources/shaderincludes/` (add to deploy and packagers when first used) | +| Vulkan backend | `Optimum.Render.Vulkan/**` | - | `Optimum.Render.Vulkan.dll` + `Silk.NET.*.dll` beside the client (`make deploy` copies them) | +| Vanilla reference | `_ref/**` and `.vanilla/**/assets` | read-only | - | + +Never edit `patches/*.patch` or `sources/VintagestoryApi/**` by hand; extract overwrites them. +`.baseline/` is the decompiled vanilla; csproj overlays are folded into it by bootstrap, so a new +`` must also be added to `.baseline/VintagestoryApi/VintagestoryAPI.csproj` locally +or extract will keep emitting a stray csproj patch. + +## Build, deploy, run, verify + +``` +dotnet build VintageStory.slnx -c Release # everything +dotnet test Optimum.Render.Vulkan.Tests # GPU tests, validation layers on (needs a GPU) +dotnet test Optimum.Tests -c Release # source/patch coverage tests +bash scripts/extract-patches.sh && bash scripts/check-patches.sh # after editing build/, forks, API +make deploy # Cecil patch + copy into .vanilla/win-x64/vintagestory +scripts/dev/run-client.sh ["world name"] # detached launch; RENDERER=vulkan|opengl env switches +scripts/dev/client-renderer.sh # which renderer ACTUALLY started (read this every time) +scripts/dev/screenshot.sh /tmp/x.png # then look at the image with Read +scripts/dev/kill-client.sh # clean close; never pkill -f from a shell that mentions the process +scripts/dev/perf-capture.sh / pacing-gate.sh # frame-time capture; gate: blocking uploads, stddev vs GL baseline, p99 +scripts/dev/parity-capture.sh + ssim.py # per-attachment dump on one backend; SSIM table between two dumps +scripts/dev/headless-capture.sh # real client and renderer, window never mapped, frames to disk; does not steal the desktop + # closes itself cleanly and prints "shutdown closed itself"; a crash count in its output means the run is suspect + # pacing numbers from it are meaningless: an unfocused window sits under the 30 FPS background cap +scripts/dev/worktree-bootstrap.sh # in a worktree: materialise build/ + forks offline (private .build copy) +scripts/dev/worktree-bootstrap.sh --in-place [--discard-build-edits] # main checkout, after a merge changed patches/ +``` + +Data dir: `~/.config/OptimumVintagestoryData` (`clientsettings.json`, `ModConfig/optimum.json` with +`"Renderer"`). Saves: `Saves/*.vcdbs`; pass the bare world name to `-o`, not the file name. +Settings that change what you see: `ssaa` (0.5 renders at half res on BOTH backends), `fxaa`, +`ssaoQuality`, `bloom`, `godRays`, `mipMapLevel`. + +Backend diagnostics: `OPTIMUM_VULKAN_VALIDATION=1` (log: `$TMPDIR/optimum-vulkan-validation.log`, or set it to a path; add `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best`), `OPTIMUM_RENDER_TRACE=` (per-draw +trace: `program N 'name'`, `fullscreen program= tex0= target=`, `bind unit= texture=`, +`validation:` lines), `OPTIMUM_DUMP_TEXTURES= OPTIMUM_DUMP_DIR= OPTIMUM_DUMP_AFTER_SECONDS=60` +(PPM dumps of live textures; without the delay you dump the menu), `OPTIMUM_VULKAN_STATS=` +(legacy line plus `key=value` lines: blocking uploads, waits per site, frame p50/p95/p99/stddev, scopes, barriers), +`OPTIMUM_FPS_LOG=` (per-second `mean min max p99 stddev`), `OPTIMUM_PARITY_DUMP= OPTIMUM_PARITY_FRAME=` +(every framebuffer attachment on both backends at in-world frame n, PPM/PFM, GL row order), +`OPTIMUM_VULKAN_POISON=1` (fresh images NaN/magenta/0xDEADBEEF, depth 0.5, buffers 0xDEADBEEF: undefined reads become loud). +Headless capture: `OPTIMUM_HEADLESS=1` (window created but never mapped or focused, both backends), +`OPTIMUM_HEADLESS_FRAMES=` plus `OPTIMUM_HEADLESS_FIRST_FRAME`/`_FRAME_COUNT`/`_FRAME_STRIDE` or +`_FRAME_LIST` (which in-world frames to write as PPM), `OPTIMUM_HEADLESS_COMMANDS=` with +`OPTIMUM_HEADLESS_COMMAND_FRAME` (chat lines fed on that frame: `/time`, `/weather`, `.cam load`/`.cam play`), +`OPTIMUM_HEADLESS_FIXED_DT` (pins the simulated step). A display server is still required - headless here +means no visible window, not no display. +DLSS/NGX: the NVIDIA feature libraries, headers and programming guides live in `~/.local/share/optimum-ngx` +(never in the scratchpad - that is tmpfs and a reboot would make the NGX tests skip instead of fail). +Run the NGX tests with `OPTIMUM_NGX_FEATURE_PATH=~/.local/share/optimum-ngx/lib/Linux_x86_64/rel`. +**Implicit Vulkan layers poison validation and must be switched off deliberately.** On this machine MangoHud is +enabled globally (`~/.config/environment.d/mangohud.conf`) and `VK_LAYER_LS_frame_generation` (Lossless Scaling) +has no enable variable at all, so both hook every Vulkan process, the GPU test host included, and draw or present +on the swapchain in ways validation reports as the application's own hazards. Every GPU test run, validation run +and measurement exports: +`MANGOHUD=0 DISABLE_MANGOHUD=1 DISABLE_LSFG=1 DISABLE_VK_LAYER_VALVE_steam_overlay_1=1 DISABLE_VK_LAYER_VALVE_steam_fossilize_1=1 DISABLE_GAMESCOPE_WSI=1 DISABLE_VULKAN_RENDERDOC_CAPTURE_1_45=1 DISABLE_LAYER_MESA_ANTI_LAG=1` +and confirms with `VK_LOADER_DEBUG=layer vulkaninfo --summary` that none of them is inserted. `VK_LAYER_MESA_device_select` +stays (it only orders devices). A run without this is not evidence, and the fix for a finding is never a weaker +validation setting. +GPU tests run `sync,best` validation by default through `GpuTest` (override: `OPTIMUM_TEST_VALIDATION_FEATURES`, empty = off); +an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. + +## Rules that came from real failures + +1. **A launch is not a verification.** The bootstrap falls back to OpenGL silently; MangoHud only + shows on Vulkan. Grep the log for `[Optimum] Vulkan renderer` / `[Optimum] OpenGL renderer:` + before saying anything about rendering. A PR was merged on an OpenGL run because this was skipped. +2. **Look at pixels, then diff the two paths.** For any "X looks wrong on Vulkan": capture a baseline + (screenshot + trace + validation log) first, then read the OpenGL body (`ClientPlatformWindows` or the lib site) and the Vulkan override + (`VulkanClientPlatform.*.cs`) of the same member side by side and list every state difference (sampler filter/wrap/mip/border/compare, + blend enable vs per-attachment factors, draw-buffer masks, clears, viewports, formats). The bugs + have all been parity gaps, never shader maths. Do not theorise from symptoms. +3. **Verify in the game, both backends, before claiming done.** Deploy, run, screenshot, compare with + OpenGL live. Component tests passing is not evidence for the screen. +4. **Every fix gets a GPU readback test** in `Optimum.Render.Vulkan.Tests` (pattern: + `VulkanDeviceIntegrationTests`, `AttachmentSemanticsTests`) and, for lib/patch changes, a + source-coverage test in `Optimum.Tests` (pattern: `fsr-pipeline-coverage-tests.cs`). +5. **Process hygiene.** Launch through `scripts/dev/*.sh` (setsid wrappers). Never put `pkill -f` or + `pgrep -f` in a command that also contains the process name in a heredoc or string: it matches the + calling shell, so `pkill` kills it (exit 144) and `pgrep` reports the process as running when it is not - + on 2026-09-16 that made me tell the owner the game was running long after they had closed it. Ask with + `ps -eo pid,stat,args | grep -i | grep -v grep`, or read the client log. Close the game with the kill script (window close + first) to avoid shutdown-race crash reports. Close the game as soon as a check is done; never leave it running. +6. **Git.** Never `git stash`. Commit WIP on the branch with a `wip:` prefix instead. Branch from + `main` (tracks `origin/main` = KillerPixelCrew/VulkanStory, migrated from NightHammer1000/VulkanStory on 2026-09-15; `upstream` = StratumServer/Optimum). + Commit only when asked or when a phase is verified; say what was verified in the message. +7. **Batch reads.** Read whole methods and both paths in one command (`sed -n` ranges + `rg`), not + ten single greps. Codex found in one pass what took an afternoon of small probes. +8. **Agents, models, effort.** The session model is Fable; it does only the hard parts, at low + effort, high only for a hard bug. Everything else runs as a Workflow (ultracode): sonnet for + map/search stages at **high or xhigh** (cheap, needs it to be trustworthy), opus for + implementation and review at **medium, never higher**. Never launch an agent that inherits Fable. + Parallelise: map stage first, then every independent implementation stage at once with + `isolation: 'worktree'` (each commits on its own branch), then one integration stage that merges + into the feature branch and runs the finish sequence, then review. Serial stages are only for + genuinely dependent work. Worktree stages are created at `origin/main`, which is NOT an ancestor of the + feature branch (`feat/vulkan-taa` diverged from it): their first commands are `git checkout -B ` + (never `merge --ff-only`, which fails) and `bash scripts/dev/worktree-bootstrap.sh`; integration merges into the feature branch, never main. + Rules live in `.claude/skills/workflow-policy`. Codex (`.claude/skills/codex-handoff`, gpt-6-astra) has quota again since + 2026-09-12: hard rendering bugs can go to it (neutral brief, full machine access, low effort) or to Fable directly. + +9. **Undefined behaviour differs between the APIs.** GL keeps an attachment the shader never writes; + Vulkan writes garbage into it (pipelines now mask those off). A bug that only flickers between + frames is invisible to screenshots and to per-frame probes: read the validation log with sync + + best-practices enabled before instrumenting anything. + +10. **Temporal and pacing claims need numbers, never screenshots.** Accepted evidence: the validation + log with `sync,best`; a multi-frame GPU test (Present between frames, no readback in the loop); + `pacing-gate.sh` numbers against the OpenGL baseline of the same scene; `ssim.py` per-attachment + tables; a 60 fps `ffmpeg -f x11grab` capture with consecutive-frame diffs for flicker; poison mode + for suspected undefined reads. The Vulkan-native rebuild plan and its phases: + `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md`, acceptance in `docs/vulkan-acceptance.md`. + +11. **TAA on sub-pixel foliage: audit the resolve, not the backend.** The three-day Vulkan "distant + trees jitter, TAA looks disabled" bug (fixed 2026-09-11) was `sources/shaders/taa-resolve.fsh` itself: + a single-sample depth disocclusion test threw the history away on ~3.7% of distant leaf pixels every + frame (a sub-pixel leaf hits the leaf in one jitter phase and the background in the next), and a + fixed 10% blend let the moving clip box drag history. The fix, which must never be reverted: 3x3 + nearest-depth disocclusion with motion from the nearest-depth tap, and luminance anti-flicker weighting + (0.3x..1.2x blendAlpha). Both backends had the flaw; every OpenGL-vs-Vulkan capture matched, which is + why parity hunting never found it. When the user names a component (here: the TAA shader), audit that + component against known practice (Karis 2014, Playdead 2016) and quantify it + (`scripts/dev/taa-rejection.py` on a parity dump) before any backend comparison. Any change to the + resolve, including the Phase 3 native rewrite, keeps both behaviours and their tests. + +12. **Say only what you verified, and name the evidence.** A status in a plan, a handoff or a comment is a claim, + not a fact; a passing test, a file:line, a log line or a measured number is a fact. On 2026-09-16 an audit found + items marked done that were never done, and separately I asserted a model, a process state and a branch scope + from documents instead of from checks - each one wrong. If it was not checked this session, say that it was not. + +13. **Decide, don't ask.** Research the open question to a decision and act on it. Hand a choice back only when it + is genuinely the owner's (money, scope, upstream, destructive acts) and you cannot resolve it from what they + already said. Turning an instruction they just gave you back into a question is the failure mode. + +14. **Reviews are expensive; verification is not.** Implement-only stages by default. The integration stage runs + the full suites on the merged state, which is where defects actually show. Reserve a review stage for the + genuinely high-risk change in a wave, not for every stage. + +15. **No map stage by default - document the seams instead.** Map stages rediscovered the tree at five figures of + tokens each and were thrown away with the run. Every render seam carries a doc comment at its declaration: what + it draws, where the OpenGL body is, target and slots, the state that is not obvious and why, and the test that + pins it (`docs/vulkan-native-render-systems.md` section 4). An implementation stage documents what it touches as + part of the change and greps instead of mapping. Map only what the code cannot answer - measured behaviour, + vendor documentation, a tree the repo does not contain. `scripts/dev/harvest-maps.py` recovers the map output of + past runs from the workflow journals when one is needed again. + +16. **Agents never launch the game, never `make deploy`, never push.** In-game verification, deployment and pushing + are the session's own work, because they touch the owner's machine and their branches. A stage that needs the + game verified says so in its return value. + +17. **Identity and attribution.** Commit as `NightHammer1000 ` (global config only). The work + e-mail from the environment context must never appear in git config, commits, PRs, docs or output. No tooling or + assistant attribution anywhere - not in code, comments, docs, tests or commit messages - and no co-author + trailers. + +18. **Never relax validation.** Validation findings are real. Never weaken a setting, suppress a message, add an + allowlist entry or lower a report flag to go green: fix the cause. The one exception already in the tree is a + documented vendor entry in `KnownSyncHazards`, and it names the driver and the reason. + +## Testing notes +- **Tests are filed by subject, never by stage or review round.** Add to the existing coverage file for + the thing under test (`upscaler-*`, `taa-*`, `latency-*`, `headless-*`); create a new file only when a + genuinely new subject appears. A file named after the work that produced it (`pr3-review-round2-*`, + `wave1-*`) is wrong by construction: nobody looks there when that subject breaks. Half the repo's diff + against upstream is tests, and 44 of them are under 150 lines because each workflow stream wrote its own. +- **"Both backends" means OpenGL with the user's real `optimum.json` too.** OpenGL with `Upscaler: dlss` crashed on + the loading screen from a8f09ae until 2026-09-13 and nobody saw it, because every check ran Vulkan. The headless + harness makes the OpenGL run cost nothing: run it in the same pass as the Vulkan one. +- **Injected fields never run their initializers** (Cecil copies no constructor IL): an injected `= new T()` is null, + an injected `= -1` is 0. `NoInjectedFieldAnywhereCarriesAnInitializer` enforces it for manifest fields. Use the CLR + default as the starting state, or allocate lazily at the use site. +- **Do not touch a vanilla static class before vanilla does.** Its type initializer may not be inert: + `ShaderRegistry`'s publishes uncompiled programs into `ShaderPrograms.*`, which is what crashed the OpenGL loading + screen when the upscaler stand-down called into it during startup. +- `Optimum.Render.Vulkan.Tests` GPU tests must read back inside a frame; `BindFramebuffer`/`ClearColor` + are no-ops between frames. +- Vulkan named UBOs are per-draw snapshots (fixed 2026-09-10); the uniform ring is now per frame slot + (it used to divide one fixed 32 MiB by the slot count, so raising `FramesInFlight` silently cut it). +- **Poison mode is clean evidence as of 2026-09-12.** It used to report 5 sync hazards per run, written + off as syncval noise across destroy/recreate; they were ours - the poison clear and the first upload of + a fresh texture are both `TransferDst` writes in one batch and `BarrierBatcher` emits nothing when the + usage does not change. A hazard under `OPTIMUM_VULKAN_POISON=1` is now a real finding, not a baseline. +- Shader pairs dropped in `sources/shaders/` are auto-translated by `ShaderTranslationTests`. +- Temporal bugs need multi-frame GPU tests (Present between frames, no readback in the loop); a + single-frame readback passed while the R32F-history and masked-clear bugs were live (P2, 2026-09-10). +- Vulkan: `ClearColor` on an attachment masked out of `SetDrawBuffers` is a no-op; every framebuffer + format must have an entry in `GlEnums.cs` or it silently degrades to RGBA8. +- TAA history rejection is a number too: `scripts/dev/taa-rejection.py ` reports per-region + rejection from the two history depth slots; distant leaves above ~1.5% per frame is the 2026-09-11 regression. +- "Does it still jitter" is answered with a number: still-camera screenshot pairs, wind stilled, + luminance diff over the centre crop, both backends (vulkan-parity-debug skill, 2c). + +## Project knowledge (folded in from the agent memory, 2026-09-16) + +These were hard-won in earlier sessions and lived only on one machine until the owner pointed out that makes +them useless. They are instructions, not history: read them the same way as the numbered rules. When a new +lesson appears, it belongs HERE, in this file - the harness memory store is a local cache, not the record. + +### Cleanup comes last + +*Documentation and comment cleanup happens at the very end of the project; until then comments are moved but never trimmed.* + +User, 2026-09-12: "We do Code documentation and comment cleanup at the very end." + +**Why:** the comments in this repo are the record of what each defect cost, and many carry measured numbers - the 1.05 % distant-leaf TAA rejection, the 0.37 to 0.02 display-pixel jitter residual, why NGX's shutdown is gated, why the acquire wait stage may never be ALL_COMMANDS. While the renderer is still moving, trimming them deletes the reasoning that keeps the next agent from reintroducing the bug. + +**How to apply:** never open a "tidy the comments" task, and never let a refactor quietly drop an xml-doc - a consolidation or a move carries every "why" forward verbatim. Stale comments that are actively wrong are still fixed on the spot, as part of the change that made them wrong. One cleanup pass at the end, when the renderer settles. Related: [[testing-suite-too-heavy]], [[speed-and-parallelism-over-testing]]. + +### Delegating to codex + +*How the user wants Codex (gpt-6-astra) launched and steered on this project.* + +The user delegates hard problems to the local `codex` CLI and has been specific about how: + +- Model `gpt-6-astra`. **Launch at low reasoning effort** (`-c model_reasoning_effort="low"`) + unless they ask otherwise — xhigh over-tests and over-scopes, and runs for hours. +- **It is on a weekly quota** and the user tracks it (one long xhigh session on the Vulkan + backend cost roughly 30% of a week). Spend it on problems that are genuinely stuck, and + keep briefs tight rather than launching speculatively. +- **Give it full machine access** (`--dangerously-bypass-approvals-and-sandbox`), not a + sandbox. It can then drive the GPU, launch the game and take Wayland remote control to + actually play and inspect the result. Sandboxing it blocked the only verification step + that settles a rendering question, and the user objected to it directly. +- Brief it with **the symptom and the reproduction only** — attach the screenshot with + `-i`, describe what is wrong, and let it investigate. Do not hand it my own conclusions + or a "ruled out" list: the user called that poisoning its context, and the analysis I + was most confident in turned out to be the part that was wrong. +- Prompt goes **on stdin** (`cat brief.md | codex exec ...`); `-i` takes multiple files and + swallows a positional prompt argument. +- Steer a live session with `codex queue --thread --message "..."`, taking + the uuid from the `session id` line in its output. Copy any screenshot into a path it + can read and name that path in the message. + +- **Plan reviews are a good use of `high` effort.** On 2026-09-10 the user asked for a high-effort + review of the TAA plan against the code and game source; it took ~15 minutes, cost far less than + an xhigh implementation session, and found a pre-existing Vulkan bug (named UBOs shared across all + draws in a frame) plus a dozen wrong assumptions. Brief it with the plan path and the source + locations, ask for CONFIRMED/WRONG/UNVERIFIABLE with file:line, and tell it to write to a file + in the scratchpad. Launch through a wrapper script with `setsid` so the tool timeout cannot kill + it, and monitor for a sentinel line (see [[pkill-self-match]]). + +**Why:** on the Vulkan backend it found three real bugs in one pass that I had missed over +a long session, and verified them by playing the game across several views and two worlds. + +**How to apply:** when stuck on something the user is getting frustrated with, offer Codex +early rather than late, brief it neutrally, and give it the whole machine. See +[[verify-end-to-end-not-components]]. + +Steering (2026-09-10): `codex queue --thread --message` only reaches a running session; messages queued after exit are lost, so continue with `codex exec resume `. Monitor on `^CODEX_EXIT [0-9]+$` (Codex narrates the word and false-matched a looser pattern). Relay the user's observations verbatim and promptly; each one ("gets worse with distance") narrowed the search. Codex does not push; verify its claims in-game, then push. + +Quota: exhausted on 2026-09-10; **reset and available again from 2026-09-12** (user). Codex handoffs are back on the table for genuinely stuck rendering bugs and for plan reviews at `high`; it still costs a weekly quota, so keep briefs tight and do not launch speculatively. + +### Frame generation needs pacing + +*DLSS-FG (and any frame generation) ships only together with a present-thread pacer and correct Reflex out-of-band presentation; never an unpaced intermediate step.* + +User, 2026-09-13: "DLSSFG without pacing (Reflex) is useless and unplayable." + +**Why:** I had split frame generation into a synchronous step 5 (both presents issued from the render thread) and a later step 6 (present thread + pacer), and launched step 5 on its own with a user-facing setting. Unpaced generated frames judder and add latency, so the intermediate step is not a usable feature - it is a regression the player can switch on. NVIDIA's DLSS-FG guide section 7 says the feature does no timing or presentation itself; the app must present the generated frame on evaluate completion and the real frame (OutputReal) at an equal interval, asynchronously from the render thread, with Reflex keeping the held-back real frame's latency in check. + +**How to apply:** treat the FG evaluate, the present thread, the pacer and Reflex out-of-band presentation (own queue, vkQueueNotifyOutOfBandNV, out-of-band markers) as one deliverable. Do not expose any FG setting to players before the pacer lands; internal test switches are fine. The same holds for XeFG and FSR frame interpolation later. Related: [[own-vendor-orchestrator-decision]], [[low-latency-layer-reference]], [[user-graphics-expertise]]. + +### Git remotes + +*"In the Optimum checkout, origin is KillerPixelCrew/VulkanStory (the org repo, migrated from NightHammer1000/VulkanStory on 2026-09-15) and upstream is StratumServer/Optimum; main tracks origin/main."* + +Remote layout (set 2026-09-10 at the user's request): +- `origin` = https://github.com/KillerPixelCrew/VulkanStory.git ("ours"; the repository moved into the KillerPixelCrew organisation on 2026-09-15, it used to be NightHammer1000/VulkanStory). `main` tracks `origin/main`. PRs for the Vulkan work go here; `gh` default repo is set to it. +- `upstream` = https://github.com/StratumServer/Optimum.git. Does not have the Vulkan backend yet. + +**How to apply:** push branches and open PRs against `origin`. Only touch `upstream` when the user asks to sync with or contribute to StratumServer. Related: [[optimum-upscaling-roadmap]]. + +### Look before you work + +*"Before designing or launching any implementation wave, read the vendor docs in full and the reference implementations on disk (~/Projekte/ReScaleFrame/references) and check best practice online; the user has had to say this three times."* + +User, 2026-09-13: "This is the third time i have to tell you to actually look before you work." (The previous two, same day: "DLSSFG without pacing (Reflex) is useless and unplayable" and "Have you checked that frameplacment against best practice online and in the Framegen Documentation?") + +**Why:** I designed DLSS-G frame pacing from one chapter of NVIDIA's guide plus my own reasoning and launched a 7-agent workflow on it. The user's own reference checkouts in `~/Projekte/ReScaleFrame/references/` (Streamline, FidelityFX-SDK, xess, OptiScaler) and their ReScaleFrame design docs already contradicted it: AMD paces both presents from the previous present with a 10-frame moving average, CPU-waits for GPU completion before presenting, keeps one frame in flight, caps render slightly below half the output rate; Streamline measures pacing by display change, not present call; only generated frames are dropped. Two workflows were stopped as a result. + +**How to apply:** for any feature with vendor SDKs or prior art: (1) read the vendor guides in full, not the chapter that matches the question; (2) read the reference implementations on disk - check `~/Projekte/ReScaleFrame/references/` and the user's ReScaleFrame docs first, they are the user's own research; (3) search online for best practice; (4) write the design with a source for every decision and mark what is reasoning; (5) show the user the sourced design before launching an implementation wave. A map of *our* code is not research into *how it should be done*. Related: [[research-before-repeating-loops]], [[audit-the-component-the-user-names]], [[frame-generation-needs-pacing]], [[user-graphics-expertise]]. + +### Low latency layer reference + +*"Korthos low_latency_layer (MIT, github.com/Korthos-Software/low_latency_layer) implements VK_NV_low_latency2 and VK_AMD_anti_lag on any GPU; its algorithm is the model for Optimum's vendor-neutral latency tier."* + +User pointed at https://github.com/Korthos-Software/low_latency_layer on 2026-09-11 (clone in the session scratchpad vendor-research/low_latency_layer, commit 3138b14). + +What it does: an implicit Vulkan layer (`LOW_LATENCY_LAYER=1`, `LOW_LATENCY_LAYER_REFLEX=1` to expose VK_NV_low_latency2 instead of VK_AMD_anti_lag) that paces without driver support. At the sleep point (vkLatencySleepNV signal semaphore, or vkAntiLagUpdateAMD INPUT stage) it waits until every graphics-queue submission of the previous frame has finished on the GPU (timestamp queries at top/bottom of pipe; for low_latency2 submissions are grouped by present ID), then applies the frame cap (minimumIntervalUs or maxFPS, measured release to release), then releases the app to sample input. A jitter/drain controller exists only for games with a decoupled simulation queue (Marvel Rivals). Benchmarks with a Reflex Analyzer on an RX 7900 XTX: matches or beats Windows Anti-Lag 2; the Mesa anti-lag layer measured as a no-op. + +How to apply (user, 2026-09-11: "Requiring a layer might be a bad idea but replicating what it does here might work out"): never depend on the layer; Optimum's vendor-neutral latency tier is this algorithm done natively. The renderer owns the Frame timeline semaphore, so "previous frame's GPU work finished" is a timeline wait on the previous frame's present-submit value placed before input sampling, with no timestamp queries and no layer. It covers the Arc 140V (no XeLL on Vulkan) and AMD. Vintage Story's client simulation and render share one thread, so the decoupled-queue controller is not needed. Detect the layer (instance layer `VK_LAYER_KORTHOS_low_latency`) and log it, since it would pace on top of Optimum. Related: [[own-vendor-orchestrator-decision]], [[optimum-upscaling-roadmap]]. + +### Ngx needs a native shim + +*"NVIDIA NGX aborts when called from a .NET P/Invoke stub (it resolves the caller module by return address), so every NGX call needs a small native shim .so/.dll; DLSS SR and DLSS-G both report available on native Linux."* + +Spike on 2026-09-12 (branch feat/dlss, commit bad1122; RTX 4070 Laptop, driver 615.71.09, X11, DLSS SDK 310.9.1, no Proton): + +- DLSS Super Resolution and DLSS Frame Generation both report `Available = 1` with `NeedsUpdatedDriver = 0` on native Linux Vulkan (min driver 470 and 520). Optimal settings at 2560x1490: Quality 1707x993, Performance 1280x745, dynamic range 50-100 %. +- **`libnvidia-ngx.so.1` resolves its caller's module from the return address.** A .NET P/Invoke stub lives in anonymous JIT memory, so NGX builds a string from a null path and aborts the process (`std::logic_error`, `basic_string::_M_construct null not valid`). Proved with `scripts/dev/ngx-probe.c`: identical calls succeed from C and abort through a trampoline in an anonymous mmap page. NGX checks only the immediate caller, so there is no managed workaround. +- The per-feature extension queries return `FAIL_NotImplemented` on Linux; use the SDK wrapper's fixed lists: instance `VK_KHR_get_physical_device_properties2`, device `VK_NVX_binary_import`, `VK_NVX_image_view_handle`, `VK_KHR_buffer_device_address`, `VK_KHR_push_descriptor`. +- Interop traps: the exported `Init_ProjectID` is not the header prototype (no `vkGet*ProcAddr` arguments, SDKVersion before FeatureCommonInfo); `PathListInfo.Path` is `wchar_t**` (UTF-32) on Linux; the driver exports no C accessors for `NVSDK_NGX_Parameter`, so parameters go through the C++ vtable in declaration order with no virtual destructor. NGX writes no log on Linux. + +Shim built 2026-09-12 (commit c5272ad, `native/optimum-ngx/`): C99, dlopen's libnvidia-ngx.so.1 lazily, flat C ABI, exported version checked by the managed side, source committed and built by `make native` plus an MSBuild target that degrades to "DLSS unavailable" when no compiler exists. **The shim must never tail-call NGX**: `return ngx_entry(args);` compiles to `jmp` at -O2, the wrapper's frame is gone and NGX reads the managed caller's return address again, so it aborts exactly as before. Fixed with a volatile local plus `-fno-optimize-sibling-calls`; the first build had this bug and it looked identical to the original failure. + +DLSS SR ran end to end on 2026-09-12 (commit c1fb719): 1280x745 to 2560x1490, Success on create and every evaluate, pattern preserved, eight accumulating frames with no validation output. Two more NGX rules found there: **NGX needs the `bufferDeviceAddress` feature enabled**, not just `VK_KHR_buffer_device_address` (without it every evaluate trips VUID-vkGetBufferDeviceAddress-bufferDeviceAddress-03324, and NGX's own extension queries never mention it); and, believed at the time, "NGX allows exactly one lifetime per process" - **that was wrong** (see below). + +**The real shutdown crash, found 2026-09-12 (commit 19f9645):** `NVSDK_NGX_VULKAN_Shutdown1` is declared with one parameter in `nvsdk_ngx_vk.h` and implemented with **two** in driver 615.71.09 - the second is an out-parameter (`int*` remaining reference count) the driver writes through with no null check (libnvidia-ngx.so.1 0xa64b0 -> 0xa1750, store at 0xa1898; the deprecated one-arg `Shutdown` passes `lea 0xc(%rsp)` there). Called through the header prototype from a .NET process the register holds 0x2000, so NGX segfaults on the **first** shutdown - both earlier core dumps were first shutdowns, and the "second Shutdown1 segfaults / one lifetime per process" conclusion was a misattribution of the same undefined store. Fix: the shim calls it as `(void*, int*)` with a local int. A/B on the same test binary: old shim crashed the host 3/3, new shim 5/5 clean. Features are still released and the frame timeline drained before shutdown, through a single process-wide owner (`NgxLifetime`), because `ReleaseFeature` after `Shutdown1` remains untested. NGX's own `vkCmdClearColorImage` trips a sync hazard against its own barrier on the first evaluate; both sides are NGX's images, so it is pinned as a vendor entry in KnownSyncHazards. + +**How to apply:** every NGX call goes through a small native shim (`libOptimumNgx.so` / `OptimumNgx.dll`) that forwards the entry points and the parameter vtable; never plan a design around direct P/Invoke, and never let an NGX failure path run unguarded, since the failure mode is a process abort rather than an error code. Related: [[own-vendor-orchestrator-decision]], [[optimum-upscaling-roadmap]], [[vulkan-native-rebuild-decision]]. + +### Nvidia driver update needs reboot + +*GLXBadFBConfig on every OpenGL launch plus Vulkan silently picking the Intel iGPU means the NVIDIA userspace driver was updated without a reboot; check nvidia-smi and the log's GPU line before any capture.* + +On 2026-09-11 a pacman update at 14:38 moved nvidia-utils 610.57.04 to 615.71.09 while the loaded kernel module stayed 610. Symptoms: every OpenGL launch through `prime-run` crashed at window creation ("GLX: Failed to create context: GLXBadFBConfig"), `prime-run glxinfo -B` failed with "X Error ... BadValue", `nvidia-smi` printed "Failed to initialize NVML: Driver/library version mismatch", and Vulkan still started but on "Intel(R) UHD Graphics (ADL-S GT1)", which is invalid for measurements. A reboot fixed all of it. + +**Why:** it looked like a Phase 0 regression and cost a capture round; the user watched the clients crash. + +**How to apply:** before any in-game capture run `nvidia-smi` (must print the GPU and driver, not a mismatch) and, after each launch, require `Graphics Card Renderer: NVIDIA` in the client log (on Vulkan that line is the selected Vulkan device name). If the mismatch shows, tell the user a reboot is needed instead of launching. Related: [[confirm-renderer-from-log]], [[vulkan-native-rebuild-decision]]. + +### Optimum upscaling roadmap + +*"Optimum rendering roadmap: TAA (done, P0-P6 on feat/taa 2026-09-11) -> XeSS/DLSS/FSR upscalers -> frame generation, maybe path tracing + ray reconstruction; target hardware includes an Arc 140V handheld."* + +Order agreed with the user: in-house TAA first (feat/taa, PR #2 on origin), then vendor upscalers (XeSS 2 / DLSS / FSR 3.1) as separate consumers of the frozen temporal contract, then frame generation, possibly path tracing with ray reconstruction later. Target hardware includes an Intel Arc 140V handheld, so performance must be measured there, not only on the RTX 4070 laptop. + +Status 2026-09-11: TAA plan P0-P6 all landed on feat/taa (c60a4cc); P2 and P4 accepted in game by the user; the contract is frozen in docs/temporal-frame-contract.md v1 with stability tests (Optimum.Tests/temporal-contract-tests.cs). Open: the user's 18-row acceptance matrix (docs/taa-acceptance.md) and the default-on decision (TAA default off until then); Arc 140V frame times (the laptop's compositor caps at 165 Hz, see TAA-PLAN P5 note); shader patch system ([[shader-patch-system-todo]]); then the vendor upscaler/FG plan. + +**How to apply:** new temporal consumers adapt to the contract document, never to the resolve; bump the contract version through its change procedure. Related: [[taa-p2-vulkan-parity-lessons]], [[vulkan-validation-log-and-flicker]], [[git-remotes]]. + +Update 2026-09-11: TAA on Vulkan is now stable (resolve fix, see [[vulkan-taa-jitter-root-cause]]). The user wants DLSS next, as soon as the Vulkan-native backend reaches Milestone 1; DLSS needs only Phase 2's native device and graph handles, so it can precede native shaders, perf and the mod API. XeSS for the Arc 140V follows through the same upscaler seam. Related: [[vulkan-native-rebuild-decision]]. + +### Own vendor orchestrator decision + +*"2026-09-11 user decision - Optimum builds its own multi-vendor orchestrator (upscaler, latency, frame generation); Streamline rejected as the multi-vendor layer (NVIDIA-signed plugins only) and as the NVIDIA backend (Reflex via VK_NV_low_latency2, DLSS/DLSS-G via NGX directly)."* + +Decision: Optimum owns a thin vendor orchestrator with three slots and one backend per vendor: upscaler (DLSS, XeSS, FSR), latency (Reflex via VK_NV_low_latency2, Intel XeLL, AMD VK_AMD_anti_lag / AntiLag 2) and frame generation (DLSS-G, XeFG, FSR frame interpolation). No Streamline at all, on either OS (recommended 2026-09-11 after the direct-vs-Streamline research): Reflex = VK_NV_low_latency2 called directly; DLSS SR and DLSS-G = NGX Vulkan helpers from the DLSS SDK (NGX_VK_CREATE_DLSSG / NGX_VK_EVALUATE_DLSSG, Linux libnvidia-ngx-dlssg.so), with Optimum owning DLSS-G pacing (DLSS-FG guide section 7: present the generated frame when evaluate completes, retained real frame at equal spacing, async from the render thread). +Evidence: NVIDIA's own Linux driver guide says native Linux Reflex works "not via the Reflex SDK but directly via the Vulkan extension VK_NV_low_latency2"; the spec says VK_NV_low_latency is legacy for the Reflex SDK's NvLowLatencyVk.dll (the 615.71.09 note is only about that DLL under Proton). On this machine driver 615.71.09 advertises VK_NV_low_latency2 revision 2, so explicit VkLatencySubmissionPresentIdNV attribution (revision 3+) is not honoured: check the revision at runtime. Before 615 the extension did not cut latency on Wayland and VK_KHR_display swapchains. Mesa ships VK_LAYER_MESA_anti_lag (VK_AMD_anti_lag revision 1 on the Intel iGPU). Slot coupling (user, 2026-09-12): a vendor latency backend only when the active upscaler's vendor matches the GPU vendor, otherwise Optimum's own pacing. DLSS/DLSS-G on NVIDIA = Reflex (VK_NV_low_latency2); FSR on AMD = VK_AMD_anti_lag; XeSS(+XeFG) on Intel = XeLL, but only on the Windows D3D12 bridge, Native on Vulkan/Linux; every cross-vendor pair (FSR on NVIDIA or Intel, XeSS on AMD or NVIDIA) = Native. With no upscaler active the device-based auto order applies. Wire it into LatencyBackendSelector (device-only today) when the upscaler slot lands on the DLSS branch. + +Measured 2026-09-12 on the RTX 4070 (three 60 s runs, fixed scene, vsync off): input-to-present 7.67 ms with latency off, 1.84 ms with Optimum's own completion pacing and 1.85 ms with Reflex; mean frame time 7.70 / 9.43 / 7.66 ms, so Reflex is free and own pacing costs 18 % of the frame rate. VK_NV_low_latency2 works on the Linux driver at revision 2 and fills its driver/OS-queue/GPU intervals. User decision: **latency reduction ships on by default, Native pacing included** (auto order NV, AMD, Native, None; OPTIMUM_VULKAN_LATENCY forces one). + +Intel (user, 2026-09-11): XeFG is a D3D12 proxy swapchain only, so no Linux; on Windows Optimum adds a D3D12 bridge present path (Vulkan images and a timeline semaphore shared with a D3D12 device, DXGI flip swapchain wrapped by XeFG), and XeLL rides on it (XeFG requires XeLL, one shared frame counter, no other latency tech; XeLL needs DXGI Present). Latency backend follows the present path; in XeLL mode Optimum adds no waits of its own. +Licence settled by the user (2026-09-11): the NVIDIA feature libraries are redistributables shipped as binaries, never as source; OptiScaler (GPL-3.0) does the same (loads the driver's NGX core at runtime, ships no NVIDIA DLLs, vendors only headers). Binding: driver 615 `libnvidia-ngx.so.1` (nvidia-utils) exports `NVSDK_NGX_VULKAN_*` itself, so C# P/Invokes the driver library directly, no native shim linking `libnvsdk_ngx.a`; the driver also ships `/usr/lib/nvidia/wine/nvngx_dlssg.dll`, so the Linux driver supports DLSS-G. OptiScaler clone (vendor-research/optiscaler) is the design reference for the orchestrator: `low_latency/` (XeLL, LatencyFlex, VK_AMD_anti_lag, AntiLag 2, Reflex input) and `framegen/IFGFeature` (its frame generation is D3D12-only, so no Vulkan pacer to copy). + +**Why:** Streamline advertises cross-IHV but ships only NVIDIA features (plus D3D12 DirectSR); NVIDIA said in NVIDIA-RTX/Streamline issue #12 (2024-03) "implement the plugins yourself"; production `sl::security::loadLibrary` requires `verifyEmbeddedSignature`, which demands a secondary NVIDIA signature (include/sl_security.h isSignedByNVIDIA), so custom Intel/AMD plugins cannot load; Streamline is Windows-only while Optimum also targets Linux. + +**How to apply:** design the orchestrator after the vendor research synthesis (workflow on 2026-09-11, local SDK clones under the session scratchpad vendor-research/); it starts on the DLSS branch after Milestone 1 merges to main. Latency seams (frame IDs, markers, sleep point before input, swapchain creation extension point, present IDs) can land in the Phase 2 follow-up. Related: [[optimum-upscaling-roadmap]], [[vulkan-native-rebuild-decision]], [[user-graphics-expertise]]. + +### Physically correct rendering direction + +*Owner decision 2026-09-15 - rendering targets physically correct results, not vanilla's look: AO radiometric (no floor/contrast hack); the long-term path is generated PBR materials and finally ray/path tracing.* + +User, 2026-09-15, asked whether the new AO should reproduce vanilla SSAO's look (0.5/0.7 floor, 1.4x contrast) or the radiometric value: "physically correct. as we go for better graphics later on with Generated PBR like some minecraft shaders do and ray/pathtracing in the end." + +**Why:** later stages (generated PBR materials as some Minecraft shader packs do, then ray/path tracing) need physically based inputs; art-direction hacks in AO or lighting would have to be undone and would make RT/denoiser comparisons meaningless. + +**How to apply:** when a choice is "match vanilla's look" versus "physically correct", pick physically correct (e.g. AO power per the research, no floor or contrast boost; multi-bounce and albedo-dependent terms become real once PBR albedo exists). Keep OpenGL "OFF is vanilla" unchanged. Design data paths (G-buffer channels, material classes) so a PBR material pass can feed them later. Related: [[xegtao-default-with-taa]], [[research-combines-sources]], roadmap items HDR and ray tracing in docs/vulkan-native-plan.md. + +### Research before repeating loops + +*"User feedback 2026-09-11: on a hard rendering bug, research online and form a real model before more launch/measure loops; repeating in-game hoops without new information reads as no effort and cost the project."* + +On 2026-09-11 the Vulkan TAA distance shimmer came back (distant trees jitter between frames). I ran a chain of launch / screenshot-pair / DLL-swap loops, none of which could see one-frame alternation, and never searched for how other TAA implementations handle sub-pixel foliage shimmer or what the Vulkan symptoms of a broken history look like. The user pulled the project ("not skilled enough, no effort to understand, never researched online") and handed it to Codex. + +**Why:** the user judges effort by whether new information enters the loop. Re-running the same in-game checks with a measurement already documented as blind to the bug class is visible as churn. Yesterday's fix came from reading the validation log, which was new information; today nothing new was read. + +**How to apply:** for a Vulkan-only or TAA-quality bug, before any second launch: (1) web-search the symptom (TAA shimmer on thin/distant geometry, history rejection, jitter phase alternation, swapchain/frame-pacing causes) and the relevant Vulkan spec/best-practice pages, (2) write down the competing mechanisms and the one observation that separates them, (3) only then launch, and only for that observation (e.g. a TaaDebugView validity view over the shimmering region, or an OpenGL eyes-on control). Never offer luma-diff pairs as evidence for frame-to-frame flicker. Related: [[vulkan-validation-log-and-flicker]], [[verify-end-to-end-not-components]], [[taa-p2-vulkan-parity-lessons]]. + +### Research combines sources + +*2026-09-15 feedback - sources the owner names are for deep research that combines their best parts, not a menu to pick one from and integrate; such research goes to a Fable agent at high reasoning.* + +User, 2026-09-15, after naming MXAO, Alchemy AO, low-sample GTAO + spatial denoise, openmw-ssao and a Unity GTAO port while I was picking an AO algorithm: "I have not given you those sources to simply integrate. You should research them all and Combine the best parts of all of them. Including XeGTAO. Give this research task to a fable agent at high reasoning." + +**Why:** I answered each named source with a verdict (use / reference only / not adopted) and kept steering toward one implementation, instead of studying every source in depth for the parts worth combining. + +**How to apply:** when the owner lists sources or alternatives for a design, launch a deep research task (Fable, high effort - an explicit exception to the no-Fable-agents rule) that reads the actual papers and code of every source, compares them against this renderer's constraints and writes a combined design with per-component provenance and licence notes; hold implementation until it is back. Licence limits still decide what may be taken as code versus as an idea. Related: [[look-before-you-work]], [[decide-dont-ask]], [[xegtao-default-with-taa]]. + +### Scratchpad is tmpfs + +*"The session scratchpad is on a 16 GB tmpfs shared with the system; filling it broke the user's system upgrade, so keep dumps small and put anything needed twice in ~/.local/share."* + +2026-09-12, user: "your scratchdir has tmpfs filled. made my system upgrade fail". The scratchpad had grown to 12 GB of a 16 GB `/tmp` tmpfs - parity dumps (`p0`, `p1`), TAA traces (`taa-trace`, `tt`), blame and binary copies, plus vendor SDK clones (DLSS with its 1.3 GB `lib`, FidelityFX, OptiScaler, Streamline, the SCS fork). + +**Why:** `/tmp` is RAM on this machine and shared with everything else the user runs; a full tmpfs fails package transactions, not just my own commands. + +**How to apply:** delete a capture directory as soon as its numbers are recorded in `docs/vulkan-acceptance.md` or the plan - the conclusions are the deliverable, the frames are not. Shallow-clone vendor SDKs, read them, then remove them; the synthesis stays. Anything a test or a later session needs (the NVIDIA NGX libraries, headers and guides) goes to `~/.local/share/optimum-ngx`, never the scratchpad: on tmpfs it vanishes at reboot and the NGX tests then *skip* rather than fail, which hides the breakage. Check `df -h /tmp` before writing GB-scale dumps, and prefer per-attachment dumps at one frame over frame sequences. Related: [[testing-suite-too-heavy]], [[ngx-needs-a-native-shim]]. + +### Shader patch system todo + +*"Future task: build a shader patch system for Optimum; shaders are whole-file overrides today and game updates shadow them silently."* + +Raised by the user on 2026-09-10 while P3 of the TAA plan was adding more shader overrides ("might be a nightmare to upkeep with future Updates"). Whole-file overrides in `sources/shaders/` predate TAA (upstream v0.1.0). Agreed: note it and build it later, not during TAA. Design sketch is in TAA-PLAN.md "Follow-up: shader patch system" and CLAUDE.md "Known debt": patches against `.vanilla/archives/vs_client_*.tar.gz`, produced by extract-patches, verified by check-patches, overrides kept additive. + +**How to apply:** when the user asks about upkeep, game updates or "shader patches", this is the task; keep new shader edits additive meanwhile. Related: [[taa-p2-vulkan-parity-lessons]], [[optimum-upscaling-roadmap]]. + +### Speed and parallelism over testing + +*"2026-09-11 user direction during the Vulkan-native rebuild - \"enough testing, speed this up, more parallelism in the workflow\"; fewer in-game verification rounds, wider parallel stages."* + +After the Phase 1 exit (several in-game capture rounds plus an A/B/A pacing investigation) the user said: "enough testing. Speed this up a bit. More paralellism in the workflow as well". + +Capture sessions stay short: 3 minutes is plenty for a session measurement ("That 10 Minute run was excessive", 2026-09-11); never schedule a 10-minute run again. + +**Why:** the rebuild spent hours in serial chains (one stage per worktree after another) and in repeated in-game measurement rounds; the user wants throughput. + +**How to apply:** design each phase's workflow as wide parallel waves with explicit file ownership and interface contracts in the prompts (no map stage when the touch points are already known), one merge agent per wave, one review at the end. Keep in-game runs to the phase's single exit capture; do not add investigation launches unless a result blocks the next phase. Unit and source tests inside stages stay mandatory. Related: [[vulkan-native-rebuild-decision]], [[no-subagents]], [[research-before-repeating-loops]]. + +### Taa p2 vulkan parity lessons + +*"Why Vulkan TAA jittered for three Codex passes: missing GL_R32F mapping and a masked-out motion clear; single-frame tests hid both. TAA P2 accepted 2026-09-10."* + +TAA P2 (in-house resolve) was accepted by the user on 2026-09-10 ("TAA is CHEFSKISS now") at commit 9c32acb on feat/taa. The Vulkan-only "no AA, just jitter" that took three Codex passes came from two parity gaps, not the resolve maths: +1. `GlEnums.cs` had no GL_R32F entry, so the history depth target degraded to RGBA8; 8-bit previous depth made rejection fire randomly, worse with distance (b4d58a2). +2. `ClearColor` on Vulkan is a no-op for an attachment masked out of `SetDrawBuffers`; the motion attachment kept stale vectors (8e4a970). Clear = enable, clear, restore mask. +Both slipped past single-frame GPU tests; Codex's regression test spans frames in flight with Present between them. Acceptance is numeric: still camera, wind stilled (`/weather setw still`), luminance diff of screenshot pairs; parity was Vulkan 1.84 vs OpenGL 1.87. + +**How to apply:** for any Vulkan "looks wrong" report, check the format table and clear-vs-mask first (now in the vulkan-parity-debug skill, sections 2 and 2c), and write multi-frame tests for temporal state. P3+ of TAA-PLAN.md continue via workflows (sonnet map, opus stages). Related: [[verify-end-to-end-not-components]], [[delegating-to-codex]], [[optimum-upscaling-roadmap]]. + +### Testing suite too heavy + +*"2026-09-11 - the Vulkan acceptance matrix is too heavy; cut in-game capture to one short run, drop per-attachment SSIM matrices, cap sessions at ~3 minutes."* + +User, 2026-09-11, during the Milestone 1 exit capture: "That 10 Minute run was excessive... 3 minutes would have been more than enough" and "The whole testing Suite is Exessive and wastes so much time." + +**Why:** the heavy rows measure world noise, not the backend. Two OpenGL launches of one save differed at SSIM 0.86 on the primary colour, so the per-attachment parity matrix cannot separate a real gap from weather, chunk streaming and entity movement; the fixed scene helps pacing but not parity. The long session added nothing the first minute had not shown. + +**How to apply:** keep the cheap numeric evidence that actually catches regressions (pacing gate on a 60 s run, the Vulkan stats counters, `taa-rejection.py` on one dump per backend, the GPU suite's `sync,best` validation) and drop the rest: no 10-minute sessions, no multi-launch SSIM matrices, no repeated interleaves unless a number disagrees. One short Vulkan launch for the user to judge closes a milestone. Always set MANGOHUD=0 for validation runs: MangoHud's overlay render pass trips sync validation on the swapchain image and produced 10 phantom errors. Related: [[speed-and-parallelism-over-testing]], [[verify-end-to-end-not-components]], [[run-for-user-no-input]]. + +### User graphics expertise + +*"The user is a graphics programmer who authored the XeSS PR for Skyrim Community Shaders; skip upscaler and TAA primers, talk at implementation level."* + +The user authored the XeSS integration PR for Skyrim Community Shaders and judges TAA/upscaler behaviour live by eye with precision (distance-dependent instability, frame-to-frame flicker, "TAA has a distinctive blur"). Their observations have been right every time this project doubted them. + +**How to apply:** no primers on jitter, motion vectors or reactive masks; when their live observation contradicts a measurement, the measurement is the suspect. Related: [[vulkan-validation-log-and-flicker]], [[run-for-user-no-input]]. + +### Vulkan native rebuild decision + +*"2026-09-11 decision to rebuild the Vulkan backend as a proper renderer via platform substitution (VulkanClientPlatform : ClientPlatformWindows); plan file path, branches, Milestone 1 definition."* + +On 2026-09-11 the user rejected the OpenGL-under-Vulkan emulation design ("I never wanted this as an OpenGL under Vulkan emulator") and approved a plan to rebuild it as a proper Vulkan backend. Plan file: /home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md. Branch: feat/vulkan-native from origin/main 94e2cc0 (feat/taa merged 2026-09-11). The sky-direction fix lives on fix/taa-sky-direction as its own PR; GPU tests prove it, the user has not accepted it by eye. + +Decisions: the client drives a frame graph (the lib only announces frame and stage boundaries); Vulkan-aware mods only (raw GL or Harmony-on-platform mods are routed to OpenGL by the launcher scan); Vulkan-native GLSL for the 48 vanilla programs compiled offline, rewriter kept for mod shaders; Milestone 1 = stable frame delivery with TAA (blocking uploads 0, pacing gate against the OpenGL baseline, sync+best validation clean), judged in game only after the numbers; integration = unseal ClientPlatformWindows through the patcher and ship VulkanClientPlatform : ClientPlatformWindows inside Optimum.Render.Vulkan.dll, deleting the IOptimumGraphicsDevice seam. + +Status 2026-09-11 evening: Phase 0 merged (cdd7412) and exit-verified on the RTX 4070 (numbers in docs/vulkan-acceptance.md "Phase 0 exit results"). Vulkan fails the pacing gate (stddev 4.96 ms vs GL 0.55, blocking uploads ~50/s, a FlushFrame per frame from occlusion queries); the user saw no distance jitter on the two Phase 0 exit runs, but it was back on Vulkan in every later run (Phase 1 build included) and never appears on OpenGL: Vulkan-only, intermittent between sessions, still unexplained. Next: Phase 1A and 1B in parallel. + +Status 2026-09-11 night: **Milestone 1 accepted by the user** at 6568556 after a 10-minute Vulkan session. Phases 0, 1A, 1B and 2 are done and merged into main locally (not pushed): blocking uploads 0, passes == scopes (22.3 per frame), 0 pass splits or mask restarts, validation clean, SSAO alpha gap closed, TAA distant-leaf rejection 1.05 % on both backends. Carried to Phase 4: Vulkan costs ~25 % more frame time than OpenGL on the fixed scene (7.59 ms vs 6.08, stddev 0.37 vs 0.12) and is GPU-bound (5.43 ms of the frame in the frame-pacing wait), so the pacing gate still fails its stddev rule. Also open: TransientAllocator is not wired into the frame graph, ClearDepth ignores the depth write mask, BuildMipMaps LOD-bias parity. Next: the latency seams (plan section "Latency seams", branch feat/latency) and DLSS. + +Branching rule (user, 2026-09-11): at Milestone 1, merge feat/vulkan-native back into main (after merging fix/taa-antiflicker-disocclusion into it), then start a new branch from main for the next work (DLSS). Confirm the merge mechanics (PR on origin, as with feat/taa PR #2) with the user at that point. + +**Why:** the GL-shaped seam forced GL semantics per call (scope inference, ALL_COMMANDS barriers, synchronous uploads, coupled present) and the user judged the backend brittle at the foundation. + +**How to apply:** work phase by phase from the plan file (0 foundations, 1A platform substitution, 1B sync foundation, 2 frame graph = Milestone 1, 3 native shaders, 4 performance, 5 mod API, 6 upscaler seams); in-game runs only at phase exits, both backends, renderer line confirmed; evidence is numbers and logs, never screenshot pairs. Related: [[research-before-repeating-loops]], [[vulkan-validation-log-and-flicker]], [[taa-p2-vulkan-parity-lessons]], [[no-subagents]]. + +### Vulkan validation log and flicker + +*"Vulkan validation messages go to a file, not the client log (OPTIMUM_VULKAN_VALIDATION=1 -> $TMP/optimum-vulkan-validation.log; FEATURES=sync,best); frame-to-frame flicker cannot be seen in screenshots. P4 accepted 2026-09-11."* + +2026-09-11: the Vulkan-only "everything jitters, no AA, worse at the horizon" after P3/P4 survived every single-frame probe (motion, validity, history, uniforms all identical to GL) because the defect alternated between frames: fullscreen passes left the SSAO normal/position attachments write-enabled without storing to them, Vulkan wrote undefined values, SSAO outlines flickered. Found within minutes once the validation log was actually read (it had been going to a file named "1" or nowhere) with sync + best-practices validation. Fix 95bf71d: mask unwritten fragment outputs in the pipeline, present-path wait stage AllCommands, per-image semaphores, layout-accurate barrier accesses. User: "that fixed the instability issue fully". + +**How to apply:** for any Vulkan-only artefact, first run with `OPTIMUM_VULKAN_VALIDATION=/abs/log OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` and read `[error]` lines; screenshots and per-frame diag shaders cannot see one-frame alternation. The user judges live; when they say it flickers between frames, believe it and look for API-level undefined behaviour, not resolve maths. Related: [[taa-p2-vulkan-parity-lessons]], [[run-for-user-no-input]]. + +### Waiting on long running processes + +*"Wait for a real signal (log line, exit sentinel, Monitor), never blind-sleep; for the game the in-world line is '[Client Chat] Welcome' plus 8 s."* + +Blind sleeps repeatedly captured the loading screen or typed into a game that was not accepting input yet. The reliable markers: `[Client Chat] Welcome` for "player is in the world" (savegame-loaded and AssetsFinalize come ~20 s earlier), `^CODEX_EXIT [0-9]+$` for the Codex wrapper, workflow task notifications for agents. Kill leftovers through the wrapper scripts before a new launch. + +**How to apply:** poll the log for the marker with a bounded loop, then a short fixed margin; never `sleep 60` and hope. Related: [[pkill-self-match]], [[run-for-user-no-input]]. + +### Xegtao default with taa + +*Owner decision 2026-09-15 - XeGTAO is the default ambient occlusion on Vulkan whenever TAA is active; vanilla SSAO otherwise and always on OpenGL.* + +User, 2026-09-15: "XeGTAO should become default when TAA is active." + +The AO setting on Vulkan is Auto by default: XeGTAO while TAA (the temporal consumer) is active, vanilla SSAO when it is off; an explicit choice overrides Auto. OpenGL keeps vanilla SSAO ("OFF is vanilla"). + +**Why:** the user, same day: "the games SSAO is worst case for Temporal Rendering" (screen-locked Bayer dither re-rolled every jittered frame). XeGTAO's noise is designed to converge through a temporal accumulator; without TAA vanilla SSAO's fixed dither is the better fallback, and the whole-frame jitter work already moved AO into the scene before the resolve. + +**How to apply:** any XeGTAO stage, setting default, coverage test or acceptance note follows this rule; handoff item 8 in docs/vulkan-branch-progress.md should state it. Related: [[vulkan-taa-jitter-root-cause]], [[frame-generation-needs-pacing]]. + +## Known debt +- Shaders are whole-file overrides, not patches. A shader patch system (`patches/shaders/*.patch` + against the vanilla archive, extract + check) is planned; until then keep overrides additive and + diff against `.vanilla/archives/vs_client_*.tar.gz` after every game update (see TAA-PLAN.md follow-up). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 0701c91550900ea6acbc539d91332c9843ba0a3b Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 11:24:54 +0200 Subject: [PATCH 194/226] docs: AGENTS.md is branch-agnostic working practice; branch scope and decisions stay in the handoff AGENTS.md had grown a section on what feat/vulkan-taa is for and nine time-bound decisions, roadmap items and references folded in from the agent memory. None of that is how to work on the repository: it is the state of one branch, and it belongs in docs/vulkan-branch-progress.md, where the rest of it already was. Moved the owner's scope statement there; dropped the decisions from AGENTS.md (the plan and the handoff already carry them). --- AGENTS.md | 141 ++------------------------------- docs/vulkan-branch-progress.md | 6 ++ 2 files changed, 11 insertions(+), 136 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1c401870..242e156f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Optimum: working rules for agents -*Single source of truth for agent instructions. `CLAUDE.md` is a symlink to this file; both are in -`.git/info/exclude` and must never be committed.* +*How to work on this repository, for any agent on any branch. `CLAUDE.md` is a symlink to this file. +Branch scope, status and decisions are in `docs/vulkan-branch-progress.md`, not here.* Optimum is a performance mod for Vintage Story: a patched client (OpenGL path in `ClientPlatformWindows`) plus a Vulkan backend that substitutes the platform @@ -11,18 +11,6 @@ Everything an agent needs is in THIS file: the rules, the procedures they point knowledge folded in below. A lesson learned during a session belongs here, in the repository - the harness memory store on one machine is a cache, not the record. -## What this branch is - -`feat/vulkan-taa` carries **the Vulkan backend and TAA only**: upstream PR #69 was split so the maintainer can land -those first. DLSS, XeSS, FSR, frame generation, the vendor latency backends (Reflex, anti-lag, XeLL) and NGX live on -`feat/dlss`, `feat/dlss-g` and `feat/latency` and are NOT work owed here. The plan file predates that split and still -describes them, which is why its items carry an explicit `[out]` mark. - -**Frame structure is Vulkan foundation and IS in scope**: one frame identity per frame with markers around -simulation, render submit and present, and the world frame separated from UI composition (`SceneNoHud` plus a UI -target, HUD composed afterwards). They make pacing measurable and keep the HUD out of the scene image whether or not -an upscaler ever exists. What stays off the branch is the vendor layer that later sits on top of them. - ## Where the truth lives (edit these, never the generated copies) | What | Edit here | Generated from it | Ships as | @@ -226,9 +214,9 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. ## Project knowledge (folded in from the agent memory, 2026-09-16) -These were hard-won in earlier sessions and lived only on one machine until the owner pointed out that makes -them useless. They are instructions, not history: read them the same way as the numbered rules. When a new -lesson appears, it belongs HERE, in this file - the harness memory store is a local cache, not the record. +Working practice learned the hard way, branch-agnostic. Decisions, roadmap and branch scope are NOT here: +they live in `docs/vulkan-branch-progress.md` and the plan. A new working lesson belongs here; a new decision +belongs there. ### Cleanup comes last @@ -284,16 +272,6 @@ Steering (2026-09-10): `codex queue --thread --message` only reaches a runn Quota: exhausted on 2026-09-10; **reset and available again from 2026-09-12** (user). Codex handoffs are back on the table for genuinely stuck rendering bugs and for plan reviews at `high`; it still costs a weekly quota, so keep briefs tight and do not launch speculatively. -### Frame generation needs pacing - -*DLSS-FG (and any frame generation) ships only together with a present-thread pacer and correct Reflex out-of-band presentation; never an unpaced intermediate step.* - -User, 2026-09-13: "DLSSFG without pacing (Reflex) is useless and unplayable." - -**Why:** I had split frame generation into a synchronous step 5 (both presents issued from the render thread) and a later step 6 (present thread + pacer), and launched step 5 on its own with a user-facing setting. Unpaced generated frames judder and add latency, so the intermediate step is not a usable feature - it is a regression the player can switch on. NVIDIA's DLSS-FG guide section 7 says the feature does no timing or presentation itself; the app must present the generated frame on evaluate completion and the real frame (OutputReal) at an equal interval, asynchronously from the render thread, with Reflex keeping the held-back real frame's latency in check. - -**How to apply:** treat the FG evaluate, the present thread, the pacer and Reflex out-of-band presentation (own queue, vkQueueNotifyOutOfBandNV, out-of-band markers) as one deliverable. Do not expose any FG setting to players before the pacer lands; internal test switches are fine. The same holds for XeFG and FSR frame interpolation later. Related: [[own-vendor-orchestrator-decision]], [[low-latency-layer-reference]], [[user-graphics-expertise]]. - ### Git remotes *"In the Optimum checkout, origin is KillerPixelCrew/VulkanStory (the org repo, migrated from NightHammer1000/VulkanStory on 2026-09-15) and upstream is StratumServer/Optimum; main tracks origin/main."* @@ -314,35 +292,6 @@ User, 2026-09-13: "This is the third time i have to tell you to actually look be **How to apply:** for any feature with vendor SDKs or prior art: (1) read the vendor guides in full, not the chapter that matches the question; (2) read the reference implementations on disk - check `~/Projekte/ReScaleFrame/references/` and the user's ReScaleFrame docs first, they are the user's own research; (3) search online for best practice; (4) write the design with a source for every decision and mark what is reasoning; (5) show the user the sourced design before launching an implementation wave. A map of *our* code is not research into *how it should be done*. Related: [[research-before-repeating-loops]], [[audit-the-component-the-user-names]], [[frame-generation-needs-pacing]], [[user-graphics-expertise]]. -### Low latency layer reference - -*"Korthos low_latency_layer (MIT, github.com/Korthos-Software/low_latency_layer) implements VK_NV_low_latency2 and VK_AMD_anti_lag on any GPU; its algorithm is the model for Optimum's vendor-neutral latency tier."* - -User pointed at https://github.com/Korthos-Software/low_latency_layer on 2026-09-11 (clone in the session scratchpad vendor-research/low_latency_layer, commit 3138b14). - -What it does: an implicit Vulkan layer (`LOW_LATENCY_LAYER=1`, `LOW_LATENCY_LAYER_REFLEX=1` to expose VK_NV_low_latency2 instead of VK_AMD_anti_lag) that paces without driver support. At the sleep point (vkLatencySleepNV signal semaphore, or vkAntiLagUpdateAMD INPUT stage) it waits until every graphics-queue submission of the previous frame has finished on the GPU (timestamp queries at top/bottom of pipe; for low_latency2 submissions are grouped by present ID), then applies the frame cap (minimumIntervalUs or maxFPS, measured release to release), then releases the app to sample input. A jitter/drain controller exists only for games with a decoupled simulation queue (Marvel Rivals). Benchmarks with a Reflex Analyzer on an RX 7900 XTX: matches or beats Windows Anti-Lag 2; the Mesa anti-lag layer measured as a no-op. - -How to apply (user, 2026-09-11: "Requiring a layer might be a bad idea but replicating what it does here might work out"): never depend on the layer; Optimum's vendor-neutral latency tier is this algorithm done natively. The renderer owns the Frame timeline semaphore, so "previous frame's GPU work finished" is a timeline wait on the previous frame's present-submit value placed before input sampling, with no timestamp queries and no layer. It covers the Arc 140V (no XeLL on Vulkan) and AMD. Vintage Story's client simulation and render share one thread, so the decoupled-queue controller is not needed. Detect the layer (instance layer `VK_LAYER_KORTHOS_low_latency`) and log it, since it would pace on top of Optimum. Related: [[own-vendor-orchestrator-decision]], [[optimum-upscaling-roadmap]]. - -### Ngx needs a native shim - -*"NVIDIA NGX aborts when called from a .NET P/Invoke stub (it resolves the caller module by return address), so every NGX call needs a small native shim .so/.dll; DLSS SR and DLSS-G both report available on native Linux."* - -Spike on 2026-09-12 (branch feat/dlss, commit bad1122; RTX 4070 Laptop, driver 615.71.09, X11, DLSS SDK 310.9.1, no Proton): - -- DLSS Super Resolution and DLSS Frame Generation both report `Available = 1` with `NeedsUpdatedDriver = 0` on native Linux Vulkan (min driver 470 and 520). Optimal settings at 2560x1490: Quality 1707x993, Performance 1280x745, dynamic range 50-100 %. -- **`libnvidia-ngx.so.1` resolves its caller's module from the return address.** A .NET P/Invoke stub lives in anonymous JIT memory, so NGX builds a string from a null path and aborts the process (`std::logic_error`, `basic_string::_M_construct null not valid`). Proved with `scripts/dev/ngx-probe.c`: identical calls succeed from C and abort through a trampoline in an anonymous mmap page. NGX checks only the immediate caller, so there is no managed workaround. -- The per-feature extension queries return `FAIL_NotImplemented` on Linux; use the SDK wrapper's fixed lists: instance `VK_KHR_get_physical_device_properties2`, device `VK_NVX_binary_import`, `VK_NVX_image_view_handle`, `VK_KHR_buffer_device_address`, `VK_KHR_push_descriptor`. -- Interop traps: the exported `Init_ProjectID` is not the header prototype (no `vkGet*ProcAddr` arguments, SDKVersion before FeatureCommonInfo); `PathListInfo.Path` is `wchar_t**` (UTF-32) on Linux; the driver exports no C accessors for `NVSDK_NGX_Parameter`, so parameters go through the C++ vtable in declaration order with no virtual destructor. NGX writes no log on Linux. - -Shim built 2026-09-12 (commit c5272ad, `native/optimum-ngx/`): C99, dlopen's libnvidia-ngx.so.1 lazily, flat C ABI, exported version checked by the managed side, source committed and built by `make native` plus an MSBuild target that degrades to "DLSS unavailable" when no compiler exists. **The shim must never tail-call NGX**: `return ngx_entry(args);` compiles to `jmp` at -O2, the wrapper's frame is gone and NGX reads the managed caller's return address again, so it aborts exactly as before. Fixed with a volatile local plus `-fno-optimize-sibling-calls`; the first build had this bug and it looked identical to the original failure. - -DLSS SR ran end to end on 2026-09-12 (commit c1fb719): 1280x745 to 2560x1490, Success on create and every evaluate, pattern preserved, eight accumulating frames with no validation output. Two more NGX rules found there: **NGX needs the `bufferDeviceAddress` feature enabled**, not just `VK_KHR_buffer_device_address` (without it every evaluate trips VUID-vkGetBufferDeviceAddress-bufferDeviceAddress-03324, and NGX's own extension queries never mention it); and, believed at the time, "NGX allows exactly one lifetime per process" - **that was wrong** (see below). - -**The real shutdown crash, found 2026-09-12 (commit 19f9645):** `NVSDK_NGX_VULKAN_Shutdown1` is declared with one parameter in `nvsdk_ngx_vk.h` and implemented with **two** in driver 615.71.09 - the second is an out-parameter (`int*` remaining reference count) the driver writes through with no null check (libnvidia-ngx.so.1 0xa64b0 -> 0xa1750, store at 0xa1898; the deprecated one-arg `Shutdown` passes `lea 0xc(%rsp)` there). Called through the header prototype from a .NET process the register holds 0x2000, so NGX segfaults on the **first** shutdown - both earlier core dumps were first shutdowns, and the "second Shutdown1 segfaults / one lifetime per process" conclusion was a misattribution of the same undefined store. Fix: the shim calls it as `(void*, int*)` with a local int. A/B on the same test binary: old shim crashed the host 3/3, new shim 5/5 clean. Features are still released and the frame timeline drained before shutdown, through a single process-wide owner (`NgxLifetime`), because `ReleaseFeature` after `Shutdown1` remains untested. NGX's own `vkCmdClearColorImage` trips a sync hazard against its own barrier on the first evaluate; both sides are NGX's images, so it is pinned as a vendor entry in KnownSyncHazards. - -**How to apply:** every NGX call goes through a small native shim (`libOptimumNgx.so` / `OptimumNgx.dll`) that forwards the entry points and the parameter vtable; never plan a design around direct P/Invoke, and never let an NGX failure path run unguarded, since the failure mode is a process abort rather than an error code. Related: [[own-vendor-orchestrator-decision]], [[optimum-upscaling-roadmap]], [[vulkan-native-rebuild-decision]]. - ### Nvidia driver update needs reboot *GLXBadFBConfig on every OpenGL launch plus Vulkan silently picking the Intel iGPU means the NVIDIA userspace driver was updated without a reboot; check nvidia-smi and the log's GPU line before any capture.* @@ -353,44 +302,6 @@ On 2026-09-11 a pacman update at 14:38 moved nvidia-utils 610.57.04 to 615.71.09 **How to apply:** before any in-game capture run `nvidia-smi` (must print the GPU and driver, not a mismatch) and, after each launch, require `Graphics Card Renderer: NVIDIA` in the client log (on Vulkan that line is the selected Vulkan device name). If the mismatch shows, tell the user a reboot is needed instead of launching. Related: [[confirm-renderer-from-log]], [[vulkan-native-rebuild-decision]]. -### Optimum upscaling roadmap - -*"Optimum rendering roadmap: TAA (done, P0-P6 on feat/taa 2026-09-11) -> XeSS/DLSS/FSR upscalers -> frame generation, maybe path tracing + ray reconstruction; target hardware includes an Arc 140V handheld."* - -Order agreed with the user: in-house TAA first (feat/taa, PR #2 on origin), then vendor upscalers (XeSS 2 / DLSS / FSR 3.1) as separate consumers of the frozen temporal contract, then frame generation, possibly path tracing with ray reconstruction later. Target hardware includes an Intel Arc 140V handheld, so performance must be measured there, not only on the RTX 4070 laptop. - -Status 2026-09-11: TAA plan P0-P6 all landed on feat/taa (c60a4cc); P2 and P4 accepted in game by the user; the contract is frozen in docs/temporal-frame-contract.md v1 with stability tests (Optimum.Tests/temporal-contract-tests.cs). Open: the user's 18-row acceptance matrix (docs/taa-acceptance.md) and the default-on decision (TAA default off until then); Arc 140V frame times (the laptop's compositor caps at 165 Hz, see TAA-PLAN P5 note); shader patch system ([[shader-patch-system-todo]]); then the vendor upscaler/FG plan. - -**How to apply:** new temporal consumers adapt to the contract document, never to the resolve; bump the contract version through its change procedure. Related: [[taa-p2-vulkan-parity-lessons]], [[vulkan-validation-log-and-flicker]], [[git-remotes]]. - -Update 2026-09-11: TAA on Vulkan is now stable (resolve fix, see [[vulkan-taa-jitter-root-cause]]). The user wants DLSS next, as soon as the Vulkan-native backend reaches Milestone 1; DLSS needs only Phase 2's native device and graph handles, so it can precede native shaders, perf and the mod API. XeSS for the Arc 140V follows through the same upscaler seam. Related: [[vulkan-native-rebuild-decision]]. - -### Own vendor orchestrator decision - -*"2026-09-11 user decision - Optimum builds its own multi-vendor orchestrator (upscaler, latency, frame generation); Streamline rejected as the multi-vendor layer (NVIDIA-signed plugins only) and as the NVIDIA backend (Reflex via VK_NV_low_latency2, DLSS/DLSS-G via NGX directly)."* - -Decision: Optimum owns a thin vendor orchestrator with three slots and one backend per vendor: upscaler (DLSS, XeSS, FSR), latency (Reflex via VK_NV_low_latency2, Intel XeLL, AMD VK_AMD_anti_lag / AntiLag 2) and frame generation (DLSS-G, XeFG, FSR frame interpolation). No Streamline at all, on either OS (recommended 2026-09-11 after the direct-vs-Streamline research): Reflex = VK_NV_low_latency2 called directly; DLSS SR and DLSS-G = NGX Vulkan helpers from the DLSS SDK (NGX_VK_CREATE_DLSSG / NGX_VK_EVALUATE_DLSSG, Linux libnvidia-ngx-dlssg.so), with Optimum owning DLSS-G pacing (DLSS-FG guide section 7: present the generated frame when evaluate completes, retained real frame at equal spacing, async from the render thread). -Evidence: NVIDIA's own Linux driver guide says native Linux Reflex works "not via the Reflex SDK but directly via the Vulkan extension VK_NV_low_latency2"; the spec says VK_NV_low_latency is legacy for the Reflex SDK's NvLowLatencyVk.dll (the 615.71.09 note is only about that DLL under Proton). On this machine driver 615.71.09 advertises VK_NV_low_latency2 revision 2, so explicit VkLatencySubmissionPresentIdNV attribution (revision 3+) is not honoured: check the revision at runtime. Before 615 the extension did not cut latency on Wayland and VK_KHR_display swapchains. Mesa ships VK_LAYER_MESA_anti_lag (VK_AMD_anti_lag revision 1 on the Intel iGPU). Slot coupling (user, 2026-09-12): a vendor latency backend only when the active upscaler's vendor matches the GPU vendor, otherwise Optimum's own pacing. DLSS/DLSS-G on NVIDIA = Reflex (VK_NV_low_latency2); FSR on AMD = VK_AMD_anti_lag; XeSS(+XeFG) on Intel = XeLL, but only on the Windows D3D12 bridge, Native on Vulkan/Linux; every cross-vendor pair (FSR on NVIDIA or Intel, XeSS on AMD or NVIDIA) = Native. With no upscaler active the device-based auto order applies. Wire it into LatencyBackendSelector (device-only today) when the upscaler slot lands on the DLSS branch. - -Measured 2026-09-12 on the RTX 4070 (three 60 s runs, fixed scene, vsync off): input-to-present 7.67 ms with latency off, 1.84 ms with Optimum's own completion pacing and 1.85 ms with Reflex; mean frame time 7.70 / 9.43 / 7.66 ms, so Reflex is free and own pacing costs 18 % of the frame rate. VK_NV_low_latency2 works on the Linux driver at revision 2 and fills its driver/OS-queue/GPU intervals. User decision: **latency reduction ships on by default, Native pacing included** (auto order NV, AMD, Native, None; OPTIMUM_VULKAN_LATENCY forces one). - -Intel (user, 2026-09-11): XeFG is a D3D12 proxy swapchain only, so no Linux; on Windows Optimum adds a D3D12 bridge present path (Vulkan images and a timeline semaphore shared with a D3D12 device, DXGI flip swapchain wrapped by XeFG), and XeLL rides on it (XeFG requires XeLL, one shared frame counter, no other latency tech; XeLL needs DXGI Present). Latency backend follows the present path; in XeLL mode Optimum adds no waits of its own. -Licence settled by the user (2026-09-11): the NVIDIA feature libraries are redistributables shipped as binaries, never as source; OptiScaler (GPL-3.0) does the same (loads the driver's NGX core at runtime, ships no NVIDIA DLLs, vendors only headers). Binding: driver 615 `libnvidia-ngx.so.1` (nvidia-utils) exports `NVSDK_NGX_VULKAN_*` itself, so C# P/Invokes the driver library directly, no native shim linking `libnvsdk_ngx.a`; the driver also ships `/usr/lib/nvidia/wine/nvngx_dlssg.dll`, so the Linux driver supports DLSS-G. OptiScaler clone (vendor-research/optiscaler) is the design reference for the orchestrator: `low_latency/` (XeLL, LatencyFlex, VK_AMD_anti_lag, AntiLag 2, Reflex input) and `framegen/IFGFeature` (its frame generation is D3D12-only, so no Vulkan pacer to copy). - -**Why:** Streamline advertises cross-IHV but ships only NVIDIA features (plus D3D12 DirectSR); NVIDIA said in NVIDIA-RTX/Streamline issue #12 (2024-03) "implement the plugins yourself"; production `sl::security::loadLibrary` requires `verifyEmbeddedSignature`, which demands a secondary NVIDIA signature (include/sl_security.h isSignedByNVIDIA), so custom Intel/AMD plugins cannot load; Streamline is Windows-only while Optimum also targets Linux. - -**How to apply:** design the orchestrator after the vendor research synthesis (workflow on 2026-09-11, local SDK clones under the session scratchpad vendor-research/); it starts on the DLSS branch after Milestone 1 merges to main. Latency seams (frame IDs, markers, sleep point before input, swapchain creation extension point, present IDs) can land in the Phase 2 follow-up. Related: [[optimum-upscaling-roadmap]], [[vulkan-native-rebuild-decision]], [[user-graphics-expertise]]. - -### Physically correct rendering direction - -*Owner decision 2026-09-15 - rendering targets physically correct results, not vanilla's look: AO radiometric (no floor/contrast hack); the long-term path is generated PBR materials and finally ray/path tracing.* - -User, 2026-09-15, asked whether the new AO should reproduce vanilla SSAO's look (0.5/0.7 floor, 1.4x contrast) or the radiometric value: "physically correct. as we go for better graphics later on with Generated PBR like some minecraft shaders do and ray/pathtracing in the end." - -**Why:** later stages (generated PBR materials as some Minecraft shader packs do, then ray/path tracing) need physically based inputs; art-direction hacks in AO or lighting would have to be undone and would make RT/denoiser comparisons meaningless. - -**How to apply:** when a choice is "match vanilla's look" versus "physically correct", pick physically correct (e.g. AO power per the research, no floor or contrast boost; multi-bounce and albedo-dependent terms become real once PBR albedo exists). Keep OpenGL "OFF is vanilla" unchanged. Design data paths (G-buffer channels, material classes) so a PBR material pass can feed them later. Related: [[xegtao-default-with-taa]], [[research-combines-sources]], roadmap items HDR and ray tracing in docs/vulkan-native-plan.md. - ### Research before repeating loops *"User feedback 2026-09-11: on a hard rendering bug, research online and form a real model before more launch/measure loops; repeating in-game hoops without new information reads as no effort and cost the project."* @@ -421,14 +332,6 @@ User, 2026-09-15, after naming MXAO, Alchemy AO, low-sample GTAO + spatial denoi **How to apply:** delete a capture directory as soon as its numbers are recorded in `docs/vulkan-acceptance.md` or the plan - the conclusions are the deliverable, the frames are not. Shallow-clone vendor SDKs, read them, then remove them; the synthesis stays. Anything a test or a later session needs (the NVIDIA NGX libraries, headers and guides) goes to `~/.local/share/optimum-ngx`, never the scratchpad: on tmpfs it vanishes at reboot and the NGX tests then *skip* rather than fail, which hides the breakage. Check `df -h /tmp` before writing GB-scale dumps, and prefer per-attachment dumps at one frame over frame sequences. Related: [[testing-suite-too-heavy]], [[ngx-needs-a-native-shim]]. -### Shader patch system todo - -*"Future task: build a shader patch system for Optimum; shaders are whole-file overrides today and game updates shadow them silently."* - -Raised by the user on 2026-09-10 while P3 of the TAA plan was adding more shader overrides ("might be a nightmare to upkeep with future Updates"). Whole-file overrides in `sources/shaders/` predate TAA (upstream v0.1.0). Agreed: note it and build it later, not during TAA. Design sketch is in TAA-PLAN.md "Follow-up: shader patch system" and CLAUDE.md "Known debt": patches against `.vanilla/archives/vs_client_*.tar.gz`, produced by extract-patches, verified by check-patches, overrides kept additive. - -**How to apply:** when the user asks about upkeep, game updates or "shader patches", this is the task; keep new shader edits additive meanwhile. Related: [[taa-p2-vulkan-parity-lessons]], [[optimum-upscaling-roadmap]]. - ### Speed and parallelism over testing *"2026-09-11 user direction during the Vulkan-native rebuild - \"enough testing, speed this up, more parallelism in the workflow\"; fewer in-game verification rounds, wider parallel stages."* @@ -470,24 +373,6 @@ The user authored the XeSS integration PR for Skyrim Community Shaders and judge **How to apply:** no primers on jitter, motion vectors or reactive masks; when their live observation contradicts a measurement, the measurement is the suspect. Related: [[vulkan-validation-log-and-flicker]], [[run-for-user-no-input]]. -### Vulkan native rebuild decision - -*"2026-09-11 decision to rebuild the Vulkan backend as a proper renderer via platform substitution (VulkanClientPlatform : ClientPlatformWindows); plan file path, branches, Milestone 1 definition."* - -On 2026-09-11 the user rejected the OpenGL-under-Vulkan emulation design ("I never wanted this as an OpenGL under Vulkan emulator") and approved a plan to rebuild it as a proper Vulkan backend. Plan file: /home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md. Branch: feat/vulkan-native from origin/main 94e2cc0 (feat/taa merged 2026-09-11). The sky-direction fix lives on fix/taa-sky-direction as its own PR; GPU tests prove it, the user has not accepted it by eye. - -Decisions: the client drives a frame graph (the lib only announces frame and stage boundaries); Vulkan-aware mods only (raw GL or Harmony-on-platform mods are routed to OpenGL by the launcher scan); Vulkan-native GLSL for the 48 vanilla programs compiled offline, rewriter kept for mod shaders; Milestone 1 = stable frame delivery with TAA (blocking uploads 0, pacing gate against the OpenGL baseline, sync+best validation clean), judged in game only after the numbers; integration = unseal ClientPlatformWindows through the patcher and ship VulkanClientPlatform : ClientPlatformWindows inside Optimum.Render.Vulkan.dll, deleting the IOptimumGraphicsDevice seam. - -Status 2026-09-11 evening: Phase 0 merged (cdd7412) and exit-verified on the RTX 4070 (numbers in docs/vulkan-acceptance.md "Phase 0 exit results"). Vulkan fails the pacing gate (stddev 4.96 ms vs GL 0.55, blocking uploads ~50/s, a FlushFrame per frame from occlusion queries); the user saw no distance jitter on the two Phase 0 exit runs, but it was back on Vulkan in every later run (Phase 1 build included) and never appears on OpenGL: Vulkan-only, intermittent between sessions, still unexplained. Next: Phase 1A and 1B in parallel. - -Status 2026-09-11 night: **Milestone 1 accepted by the user** at 6568556 after a 10-minute Vulkan session. Phases 0, 1A, 1B and 2 are done and merged into main locally (not pushed): blocking uploads 0, passes == scopes (22.3 per frame), 0 pass splits or mask restarts, validation clean, SSAO alpha gap closed, TAA distant-leaf rejection 1.05 % on both backends. Carried to Phase 4: Vulkan costs ~25 % more frame time than OpenGL on the fixed scene (7.59 ms vs 6.08, stddev 0.37 vs 0.12) and is GPU-bound (5.43 ms of the frame in the frame-pacing wait), so the pacing gate still fails its stddev rule. Also open: TransientAllocator is not wired into the frame graph, ClearDepth ignores the depth write mask, BuildMipMaps LOD-bias parity. Next: the latency seams (plan section "Latency seams", branch feat/latency) and DLSS. - -Branching rule (user, 2026-09-11): at Milestone 1, merge feat/vulkan-native back into main (after merging fix/taa-antiflicker-disocclusion into it), then start a new branch from main for the next work (DLSS). Confirm the merge mechanics (PR on origin, as with feat/taa PR #2) with the user at that point. - -**Why:** the GL-shaped seam forced GL semantics per call (scope inference, ALL_COMMANDS barriers, synchronous uploads, coupled present) and the user judged the backend brittle at the foundation. - -**How to apply:** work phase by phase from the plan file (0 foundations, 1A platform substitution, 1B sync foundation, 2 frame graph = Milestone 1, 3 native shaders, 4 performance, 5 mod API, 6 upscaler seams); in-game runs only at phase exits, both backends, renderer line confirmed; evidence is numbers and logs, never screenshot pairs. Related: [[research-before-repeating-loops]], [[vulkan-validation-log-and-flicker]], [[taa-p2-vulkan-parity-lessons]], [[no-subagents]]. - ### Vulkan validation log and flicker *"Vulkan validation messages go to a file, not the client log (OPTIMUM_VULKAN_VALIDATION=1 -> $TMP/optimum-vulkan-validation.log; FEATURES=sync,best); frame-to-frame flicker cannot be seen in screenshots. P4 accepted 2026-09-11."* @@ -504,19 +389,3 @@ Blind sleeps repeatedly captured the loading screen or typed into a game that wa **How to apply:** poll the log for the marker with a bounded loop, then a short fixed margin; never `sleep 60` and hope. Related: [[pkill-self-match]], [[run-for-user-no-input]]. -### Xegtao default with taa - -*Owner decision 2026-09-15 - XeGTAO is the default ambient occlusion on Vulkan whenever TAA is active; vanilla SSAO otherwise and always on OpenGL.* - -User, 2026-09-15: "XeGTAO should become default when TAA is active." - -The AO setting on Vulkan is Auto by default: XeGTAO while TAA (the temporal consumer) is active, vanilla SSAO when it is off; an explicit choice overrides Auto. OpenGL keeps vanilla SSAO ("OFF is vanilla"). - -**Why:** the user, same day: "the games SSAO is worst case for Temporal Rendering" (screen-locked Bayer dither re-rolled every jittered frame). XeGTAO's noise is designed to converge through a temporal accumulator; without TAA vanilla SSAO's fixed dither is the better fallback, and the whole-frame jitter work already moved AO into the scene before the resolve. - -**How to apply:** any XeGTAO stage, setting default, coverage test or acceptance note follows this rule; handoff item 8 in docs/vulkan-branch-progress.md should state it. Related: [[vulkan-taa-jitter-root-cause]], [[frame-generation-needs-pacing]]. - -## Known debt -- Shaders are whole-file overrides, not patches. A shader patch system (`patches/shaders/*.patch` - against the vanilla archive, extract + check) is planned; until then keep overrides additive and - diff against `.vanilla/archives/vs_client_*.tar.gz` after every game update (see TAA-PLAN.md follow-up). diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 88233912..4103d989 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -19,6 +19,12 @@ PR itself; do not push to upstream or touch the PR. **Base.** Branched from the last TAA-only commit before the DLSS work (9ad0c70 after the identity rewrite). +**Scope, stated by the owner 2026-09-16.** Frame structure is Vulkan foundation and IS in scope here: one frame +identity per frame with markers around simulation, render submit and present, and the world frame separated from +UI composition (`SceneNoHud` plus a UI target). The vendor layer on top of them - DLSS, XeSS, FSR, frame generation, +the NV/AMD/XeLL latency backends, NGX - stays on `feat/dlss`, `feat/dlss-g`, `feat/latency`. Standing rendering +direction: physically correct over the vanilla look; AO defaults to GTAO while TAA is active on Vulkan. + **History rewrite that already happened.** All 14 fork branches had their author identity rewritten to NightHammer1000 on 2026-09-15. A first attempt also re-created upstream's signed commits, which broke the common history with StratumServer:main and closed PR #69 irrecoverably; the redo From 059ad07b9dd6c9a9eebb68396afa3e13e92a149d Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 11:27:27 +0200 Subject: [PATCH 195/226] docs: AGENTS.md checked against the tree; skills tracked so its pointers resolve Every script, environment switch and test name in AGENTS.md was checked against the tree. Fixed: the injected-field rule is CecilInjectedFieldInitializerTests here (no allowance list); NGX is not on the Vulkan-only branches; rule 8 said 'map stage first, then review', contradicting rules 14 and 15 - it now says no map and no review stage by default and describes worktree creation without naming a branch; the native shader tree, OPTIMUM_VK_NATIVE_SHADERS, OPTIMUM_VK_SHADER_SOURCE and OPTIMUM_VULKAN_SYNC_PIPELINES were missing; a machine-local plan path and dated Codex quota notes are gone; first-person narration and memory-store wiki links are gone. .claude/skills/ is tracked now (worktrees stay excluded): AGENTS.md points at those procedures, and an untracked pointer is useless on any other machine. --- .claude/skills/codex-handoff/SKILL.md | 33 ++++++ .claude/skills/patch-workflow/SKILL.md | 34 ++++++ .claude/skills/run-optimum/SKILL.md | 31 ++++++ .claude/skills/vulkan-parity-debug/SKILL.md | 108 ++++++++++++++++++++ .claude/skills/workflow-policy/SKILL.md | 46 +++++++++ AGENTS.md | 84 +++++++-------- 6 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 .claude/skills/codex-handoff/SKILL.md create mode 100644 .claude/skills/patch-workflow/SKILL.md create mode 100644 .claude/skills/run-optimum/SKILL.md create mode 100644 .claude/skills/vulkan-parity-debug/SKILL.md create mode 100644 .claude/skills/workflow-policy/SKILL.md diff --git a/.claude/skills/codex-handoff/SKILL.md b/.claude/skills/codex-handoff/SKILL.md new file mode 100644 index 00000000..d1ce7fe2 --- /dev/null +++ b/.claude/skills/codex-handoff/SKILL.md @@ -0,0 +1,33 @@ +--- +name: codex-handoff +description: Hand a stuck rendering bug or a plan review to the local Codex CLI (gpt-6-astra) with a neutral brief, full machine access, detached launch and a completion monitor; then read its report and transcript. Use when the user says "give it to codex/astra" or after two failed fix attempts. +--- + +# Codex handoff + +Brief = symptom + reproduction + where things live. No theories, no ruled-out lists; that poisons it. + +1. Stop other writers: pause workflows/agents, commit WIP (`wip:` prefix, never stash), clean tree. +2. Write `/codex--brief.md`: repo path and branch; the user's words verbatim; + screenshot paths; how to build (`make deploy`), launch (`scripts/dev/run-client.sh`), stop, switch + renderer, read the renderer log line; diagnostics env vars; the test commands; the patch workflow + rule (edit build/ + fork, run extract, never patches/sources); ask for a report file and a commit + on the branch, no push. +3. Wrapper script (the tool timeout cannot kill it): + ``` + cat brief.md | codex exec -m gpt-6-astra -c model_reasoning_effort="high" \ + --dangerously-bypass-approvals-and-sandbox -i shot1.png -i shot2.png > codex.log 2>&1 + echo "CODEX_EXIT $?" >> codex.log + ``` + `setsid wrapper.sh &` then a Monitor that greps for `CODEX_EXIT`. Effort: `high` for reviews and + rendering bugs (~15-40 min), `low` for small tasks; it is on a weekly quota. +3b. Steering a running session: `codex queue --thread --message ""` reaches it + only while it runs; a message queued after exit is lost. To continue an exited session: + `codex exec resume ""` through the same wrapper. The monitor pattern must + be `^CODEX_EXIT [0-9]+$`; Codex prints the word CODEX_EXIT in its own narration and false-matched + a looser pattern. +3c. Relay user observations verbatim and in time ("still jittering, no AA", "gets worse with + distance") - each one narrowed the search; do not translate them into your own hypothesis. +4. When done: read the report, `git log`, and the transcript + `~/.codex/sessions//rollout-*.jsonl` (condense `response_item` messages + + `custom_tool_call` inputs). Verify its claims yourself in-game before relaying them. diff --git a/.claude/skills/patch-workflow/SKILL.md b/.claude/skills/patch-workflow/SKILL.md new file mode 100644 index 00000000..1cbbc79e --- /dev/null +++ b/.claude/skills/patch-workflow/SKILL.md @@ -0,0 +1,34 @@ +--- +name: patch-workflow +description: How to change game-lib, API-fork, mod-fork and shader code in Optimum so it actually ships - edit the right tree, regenerate patches, list Cecil targets, wire csproj overlays, run the checks. Use before editing anything under build/, VintagestoryApi/, VSEssentials/, VSSurvivalMod/, sources/shaders/. +--- + +# Patch workflow + +1. Edit the source of truth (see CLAUDE.md table): `build/VintagestoryLib/**` for the client lib, + `VintagestoryApi/**` for the API, the mod fork dirs, `sources/shaders/` for shaders. + Never edit `patches/*.patch` or `sources/VintagestoryApi/**`. +2. New API file: add `` to + `optimum-api-contracts/optimum-api-contracts.csproj`, and `` to + `VintagestoryApi/VintagestoryAPI.csproj`, `sources/VintagestoryApi/VintagestoryAPI.csproj` and + `.baseline/VintagestoryApi/VintagestoryAPI.csproj` (mirrors what bootstrap folds in). +3. New platform graphics member: inject a virtual with a neutral body on `ClientPlatformAbstract` + (member list in `Optimum.Patcher/Program.cs`), put the OpenGL body in a `ClientPlatformWindows` + override and the Vulkan body in the matching `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.*.cs` + partial, and add it to `VulkanClientPlatform.ExpectedVirtuals`. Lib call sites call the platform + virtual; nothing in the lib names the renderer. Forked mods reach the device only through + `OptimumForkGraphics` (contracts). +4. Lib change: every changed or added method/property/field in `ClientMain`, `ClientPlatformWindows`, + `ChunkRenderer`, `ShaderRegistry`, `ShaderProgram*`, `ScreenManager`, ... goes into + `Optimum.Patcher/Program.cs` (transplant tuple `new("Type", "Method", paramCount)`; injected + members in the per-type member lists). The patcher only checks references, not omissions, so + grep your diff for every signature. +5. Mod-fork change: rebuild ships it locally; the installed-runtime path needs the + `Optimum.Patcher/mod-patcher.cs` manifest entry for the type/member. +6. New shader include: `sources/shaderincludes/` + add the copy to `make deploy` and every + `scripts/package-*` script; the Vulkan test corpus (`ShaderCorpus.cs`) must overlay it too. +7. `bash scripts/extract-patches.sh` then `bash scripts/check-patches.sh` (expect 0 conflicts, 0 pending; + a stray `patches/VintagestoryApi/*.csproj.patch` means step 2's baseline line is missing). +8. `dotnet build VintageStory.slnx -c Release`, both test suites, `make deploy`, run the game. +9. If a build of the lib fails on a member missing from the API, the fork and `sources/` have drifted: + diff `VintagestoryApi/` against `sources/VintagestoryApi/` and fix the fork, then extract. diff --git a/.claude/skills/run-optimum/SKILL.md b/.claude/skills/run-optimum/SKILL.md new file mode 100644 index 00000000..7b9f3184 --- /dev/null +++ b/.claude/skills/run-optimum/SKILL.md @@ -0,0 +1,31 @@ +--- +name: run-optimum +description: Build, deploy, launch, stop and screenshot the Optimum Vintage Story client on Vulkan or OpenGL, and confirm from the log which renderer actually started. Use for any "run it", "check in game", "compare backends" request. +--- + +# Run Optimum and verify what is on screen + +1. Deploy: `make deploy` (Cecil patch, copies DLLs, shaders and the Vulkan backend into + `.vanilla/win-x64/vintagestory`). If only the backend changed: `dotnet build Optimum.Render.Vulkan -c Release && cp bin/Release/net10.0/Optimum.Render.Vulkan.dll .vanilla/win-x64/vintagestory/`. +2. Stop any running client first: `scripts/dev/kill-client.sh` (its own call; no launch text in the same command). +3. Launch: `RENDERER=vulkan scripts/dev/run-client.sh "serene cave world"` (or `RENDERER=opengl`). + Diagnostics go in the environment: `OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/t.log`. +4. Wait for the world: poll the log for `[Client Chat] Welcome` (the player is in the world; "Savegame + loaded" and "AssetsFinalize" come 20 s earlier while the loading screen is still up), then sleep 8 s + before any input. Never blind-sleep. +5. **Confirm the renderer:** `scripts/dev/client-renderer.sh`. If it says `OpenGL renderer: `, + the Vulkan probe failed; read the reason (stale `Optimum.Render.Vulkan.dll` beside the client is the + classic one) and fix that before judging pixels. +6. Screenshot: `scripts/dev/screenshot.sh /tmp/vulkan.png`, then Read the PNG and describe what you see. + For a backend comparison take both shots from the same save and camera. +6b. Daylight for comparable screenshots: focus the window (`xdotool windowactivate --sync $(xdotool search --name "Vintage Story" | tail -1)`), then per command `xdotool key t`, **sleep 1.5 s** (the chat box must be open before typing or the letters become hotkeys: "e" opens the inventory and the first letters are cut off), `xdotool type --delay 100 ""`, sleep 0.8, `xdotool key Return`, sleep 2. Verify from the log: `grep "\[Server Chat\]"` shows what really arrived. (chat opens with T, sends with Enter). Commands: `/time set 12:00`, `/weather set clearsky`, `/weather setprecip -1`, `/weather setw still` (wind sway otherwise reads as jitter). Wait 3 s before the screenshot. Chat lines "A heavy temporal storm is imminent" mean the screen will warp soon; judge before it starts. +6c. When the run is for the USER to judge, leave it open and say so; close it only when they answer. When it is your own check, close it immediately. +7. Stop: `scripts/dev/kill-client.sh` immediately after the check; the user does not want it left running. Restore `ModConfig/optimum.json` `Renderer` to what the user had. + +Gotchas: `ssaa` 0.5 in clientsettings halves the render resolution on both backends; the random +`--rndWorld -p creativebuilding` world is superflat and has no animals; passing `world.vcdbs` to `-o` +creates a new world named `world.vcdbs.vcdbs`. + +Runs FOR THE USER ("run it for me"): launch, confirm the renderer from the log, say so, and hands off. +No chat commands, no xdotool, MangoHud stays at the user's global setting (their Vulkan indicator). +Scene setup and `MANGOHUD=0` are only for my own measurements with nobody at the keyboard. diff --git a/.claude/skills/vulkan-parity-debug/SKILL.md b/.claude/skills/vulkan-parity-debug/SKILL.md new file mode 100644 index 00000000..8aac4053 --- /dev/null +++ b/.claude/skills/vulkan-parity-debug/SKILL.md @@ -0,0 +1,108 @@ +--- +name: vulkan-parity-debug +description: Debug a rendering difference between the OpenGL path and the Vulkan backend (missing post-processing, wrong filtering, transparency, colours). Baseline capture, trace and dump analysis, GL-vs-device state diff, GPU regression test, in-game verification. +--- + +# Vulkan rendering parity debugging + +## 0. Read the layer's log first (2026-09-11) +`OPTIMUM_VULKAN_VALIDATION=1` now logs to `$TMPDIR/optimum-vulkan-validation.log` (or set the +variable to an absolute path). `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` adds synchronization +and best-practices validation through VK_EXT_validation_features. Run the game for ~30 s, then +`grep "\[error\]"` and `sort | uniq -c` the warnings. Before this the messages went nowhere and a +whole class of bugs (undefined writes into unwritten attachments, present-path waits) was "0 errors". +Known external noise: MangoHud's overlay pass (`vkCmdBeginRenderPass`, old-style barrier) reports a +READ_AFTER_WRITE on the swapchain image; the backend uses dynamic rendering, so that one is not ours. + +## 0b. What single frames cannot show +Frame-to-frame alternation (SSAO noise, a stale present, a swapped history) looks converged in every +screenshot and identical on both backends per frame. When the user reports "flickers between frames" +and per-frame probes agree, stop probing frames: read the validation log, check what differs in +*undefined* behaviour between the APIs (unwritten fragment outputs, missing waits, image aliasing), +or capture a 60 fps sequence (`ffmpeg -f x11grab`) and diff consecutive frames per region. + +The Vulkan backend substitutes the platform: `VulkanClientPlatform` overrides `ClientPlatformWindows`' +graphics members. Every bug so far was a state difference between a member's OpenGL body and its +Vulkan override, not shader maths. + +## 0. Temporal artefacts on foliage or thin detail: audit the TAA resolve first +The 2026-09-11 "Vulkan TAA jitters on distant trees / looks disabled" bug was not a parity gap: it was +`sources/shaders/taa-resolve.fsh` rejecting history per sample on sub-pixel foliage and blending with a +fixed weight, on both backends. Before any OpenGL-vs-Vulkan capture for a temporal complaint: +1. Read the resolve's rejection (disocclusion, reset, off-screen, NaN), clip and weighting against known + practice (Karis 2014, Playdead 2016). The nearest-depth 3x3 disocclusion and the luminance anti-flicker + weighting must still be there. +2. Quantify from an existing parity dump: `python3 scripts/dev/taa-rejection.py ` (history depth + slots 19/20 give this frame's and last frame's linear depth). +3. Only if the resolve is clean and the numbers are low, continue with the parity procedure below. + +## 1. Baseline before touching code +- `RENDERER=vulkan OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/before.trace scripts/dev/run-client.sh` +- confirm `scripts/dev/client-renderer.sh` says Vulkan; screenshot to `/tmp/vulkan-before.png` +- same scene on `RENDERER=opengl`, screenshot `/tmp/opengl.png`; Read both and write down the differences in words. +- Trace summary (python): map `program N 'name'` lines to ids, count `fullscreen program=` per name, + list `validation:` lines with `[error]`. Passes that never run are one class; passes that run but + produce nothing are the other. +- Dump the intermediates from a live frame: `OPTIMUM_DUMP_TEXTURES= + OPTIMUM_DUMP_DIR=/abs/dir OPTIMUM_DUMP_AFTER_SECONDS=60`; build a contact sheet with PIL and Read it. + Texture ids: `bind unit=U texture=T` lines right before a pass's `fullscreen` line. + +## 2. Diff the two paths, do not theorise +For the pass that is wrong, open the method in `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs` +(or the mod renderer) and read the `if (optimumDevice != null) {...}` branch next to the GL branch, +plus the framebuffer setup pair `SetupOptimumFrameBuffers` / `SetupDefaultFrameBuffers`. Check every +item in this list on both sides: +- texture create: format, mip levels, `TexParameter` min/mag filter, mipmap mode, wrap S/T, border colour, compare mode +- samplers: `GenSampler`/`BindSampler` semantics (the "linear" flag changes magnification only; min is NEAREST_MIPMAP_LINEAR) +- blend: `glEnable(BLEND)` vs `SetBlend(enabled, mode)` (the latter rewrites per-attachment factors; use `SetBlendEnabled` to toggle only), `glBlendFunci` per attachment +- draw buffers: `glDrawBuffers` vs `SetDrawBuffers(fbo, mask)`; an enabled-but-unwritten attachment is undefined +- clears per attachment, depth mask/test/func, cull, viewport for sub-resolution targets, scissor +- attachment indices and texture-id bookkeeping (`FrameBufferRef.ColorTextureIds`) +- **format table**: every `PixelInternalFormat` a target uses must exist in `Optimum.Render.Vulkan/Core/GlEnums.cs`; + a missing entry falls back silently (R32F became RGBA8 and quantised the TAA history depth: near + stable, distance shimmering). `rg "0x[0-9A-F]{4} =>" GlEnums.cs` against the formats in `SetupDefaultFrameBuffers`. +- **clears vs draw-buffer mask**: on Vulkan `ClearColor(attachment)` is a no-op while that attachment + is masked out of `SetDrawBuffers`; GL clears it regardless. Enable, clear, restore the mask. +Write the list of mismatches first; then fix them all, not the first one. + +Symptom-to-class hints (from the TAA round, 2026-09-10): "no AA, just jitter" = history never +accepted (validity/format); "stable near, unstable far" = precision of a depth-like input; +"per-quad noise in a debug view of a cleared target" = clear not happening (mask or between-frame no-op). + +## 2b. Instrument the shader instead of guessing (Codex's method, 2026-09-10) +When a pass "does nothing" or "wobbles" and the inputs are hard to inspect, temporarily rewrite the +pass's fragment shader to OUTPUT ITS INTERNAL SIGNALS AS COLOUR and look at the screen: +- Save the original: `cp sources/shaders/.fsh /tmp/-original.fsh`. +- Patch the deployed copy directly (no rebuild needed): edit `sources/shaders/.fsh` and copy it to + `.vanilla/win-x64/vintagestory/assets/game/shaders/.fsh`; the game loads it at start. + Example for the TAA resolve: `outColor = vec4(alpha, clamp(length(mv)/4.0, 0, 1), resetHistory != 0 ? 1 : 0, 1)` + shows blend weight, motion magnitude and reset per pixel; early-out branches get a fixed colour + (`vec4(0,0,1,1)`) so you can see which path ran. +- Replace real inputs with CONTROLLED ones to split the chain: a checkerboard or diagonal pattern as + "current" proves the resolve+display copy are identical on both backends; a static pattern under the + live jitter proves accumulation on its own, independent of wind, lighting and foliage. +- Freeze the world for comparisons: `/time set 12:00`, `/weather set clearsky`, `/weather setprecip -1`, + still camera, screenshot pairs 1 s apart, numeric diff of a crop. +- Test allocator luck explicitly: fill a suspect texture with deliberately non-zero data before the pass + (cold-start dumps that happen to read zero hide a missing clear). +- Restore the original shader afterwards and re-deploy; never commit the instrumented version. + +## 2c. Measure instead of asking "does it still jitter" +Two screenshots 1 s apart, still camera, `/weather setw still` (foliage sway otherwise dominates), noon, +clear sky. Mean absolute luminance diff over the centre 60% crop, repeated for ~7 pairs per backend, +compare medians. TAA at parity: Vulkan 1.84 vs OpenGL 1.87 (medians 1.74/1.72). Above ~3 on one +backend only is a real bug; equal-but-high means the scene (wind, water, temporal storm) is moving. +Hunger damage and temporal storms change the picture mid-run: creative mode or `/player .. gamemode`. + +## 3. Fix, test, verify +- Backend changes in `Optimum.Render.Vulkan/` (platform overrides in `Platform/VulkanClientPlatform.*.cs`), + new platform virtuals on `ClientPlatformAbstract` in `build/` + Cecil list (see patch-workflow skill), + fork-only device calls in `OptimumForkGraphics` (`VintagestoryApi/Client/optimum-render-device.cs`). +- Add a GPU readback test per fix in `Optimum.Render.Vulkan.Tests` (draw with a translated shader, + read the pixel, assert; readbacks must happen inside a frame). For temporal state, the test must + span several frames in flight with Present between them and no readback/wait in the loop + (`TemporalHistoryAcrossFramesInFlight` in `VulkanDeviceIntegrationTests`): single-frame tests + passed while both TAA bugs were live. +- `make deploy`, run Vulkan with validation, screenshot after; run OpenGL; compare live. Then + `dotnet test Optimum.Render.Vulkan.Tests`, `dotnet test Optimum.Tests -c Release`, `bash scripts/check-patches.sh`. +- Keep evidence (before/after PNGs, logs) in the scratchpad and cite it in the report and commit. diff --git a/.claude/skills/workflow-policy/SKILL.md b/.claude/skills/workflow-policy/SKILL.md new file mode 100644 index 00000000..de74c31d --- /dev/null +++ b/.claude/skills/workflow-policy/SKILL.md @@ -0,0 +1,46 @@ +--- +name: workflow-policy +description: Model, effort and parallelism rules for Workflow (ultracode) runs in Optimum. Use before writing any workflow script or launching any subagent. +--- + +# Workflow policy (user rules, 2026-09-10) + +| Role | model | effort | notes | +|---|---|---|---| +| Map / search / inventory | sonnet | high or xhigh | cheap; lower effort gives untrustworthy maps | +| Implementation stage | opus | medium | never high; P3/P4 at high took 30-40 min per stage | +| Integration / merge stage | opus | medium | merges worktree branches, resolves conflicts, runs finish sequence | +| Review stage | opus | medium | adversarial, fixes defects with regression tests | +| Fable (main session) | - | low | high only for a hard bug; never inherited by agents | + +Shape: +1. `phase('Map')`: one sonnet agent, read-only, returns file:line touch points. +2. `parallel(stages.map(s => () => agent(..., {isolation: 'worktree', model: 'opus', effort: 'medium'})))` + for every independent stage. Each stage commits on its worktree branch with `wip(): ...` + and returns the branch name and commit. +3. `phase('Integrate')`: one opus agent merges every branch into the feature branch, resolves + conflicts (Program.cs transplant list, ClientPlatformWindows, shader includes are the usual + ones), reruns extract/check-patches, build, both test suites, commits. +4. `phase('Review')`: one opus agent, then Fable verifies in game (run-optimum skill). + +Prompt rules for every stage: read CLAUDE.md and the plan section first; sources of truth table; +never stash, never launch the game, never `make deploy`, never `pkill -f` with the process name in +the same command; mandatory tests (Optimum.Tests coverage + GPU readback in +Optimum.Render.Vulkan.Tests); return structured data via `schema`. + +## No map stage by default (2026-09-16, owner) + +Map stages were opening every workflow and costing five figures of tokens each to rediscover the tree, then +being thrown away with the run. Two rules replace that: + +1. **Do not add a map stage** unless the question cannot be answered from the code: measured behaviour, vendor + documentation, or a tree the repository does not contain. For "where is X, what state does it set, what + writes this attachment", the implementation stage greps and reads - that is cheaper than a stage and it + cannot go stale. +2. **Every implementation stage documents the seams it touches**, per "Documentation that makes map stages + unnecessary" in docs/vulkan-native-render-systems.md: what it draws, where the other side is, target and + slots, non-obvious state, and the test that pins it. State this requirement in the stage prompt. A stage + that adds a seam without the comment is incomplete. + +`scripts/dev/harvest-maps.py` recovers the map output of past runs from the workflow journals if one is +genuinely needed again. diff --git a/AGENTS.md b/AGENTS.md index 242e156f..b8acfcc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,9 @@ Branch scope, status and decisions are in `docs/vulkan-branch-progress.md`, not Optimum is a performance mod for Vintage Story: a patched client (OpenGL path in `ClientPlatformWindows`) plus a Vulkan backend that substitutes the platform (`VulkanClientPlatform : ClientPlatformWindows` in `Optimum.Render.Vulkan/Platform/`). Read this before touching anything. The -skills in `.claude/skills/` hold the step-by-step procedures; this file holds the rules. -Everything an agent needs is in THIS file: the rules, the procedures they point at, and the project -knowledge folded in below. A lesson learned during a session belongs here, in the repository - the -harness memory store on one machine is a cache, not the record. +skills in `.claude/skills/` (tracked) hold the step-by-step procedures; this file holds the rules and the +working knowledge. A lesson learned during a session belongs here, in the repository; the harness memory +store on one machine is a cache, not the record. ## Where the truth lives (edit these, never the generated copies) @@ -19,6 +18,7 @@ harness memory store on one machine is a cache, not the record. | Game API | `VintagestoryApi/**` (hand-maintained fork, git-ignored) | `sources/VintagestoryApi/**` via extract | `VintagestoryAPI-patched.dll`; new files also go in `optimum-api-contracts/optimum-api-contracts.csproj` (path `..\sources\VintagestoryApi\...`) and get a `` in both `VintagestoryApi/VintagestoryAPI.csproj` and `sources/VintagestoryApi/VintagestoryAPI.csproj` | | Mods | `VSEssentials/`, `VSSurvivalMod/`, `VSCreativeMod/` (forks) | `patches//*.patch` via extract | recompiled mod DLLs plus `Optimum.Patcher/mod-patcher.cs` manifests for the installed-runtime path | | Shaders | `sources/shaders/*.vsh/.fsh` (override vanilla by file name) | shipped by `make deploy` and `scripts/package-*` | includes: `sources/shaderincludes/` (add to deploy and packagers when first used) | +| Native Vulkan shaders | `sources/shaders-vk/*.vert/.frag/.interface.glsl` + `include/` (contract: `docs/vulkan-native-shaders.md`) | `shaders.manifest.json` + SPIR-V by `tools/shader-compiler` (MSBuild target) | `shaders-vk/` beside the client; runtime falls back per program to the rewriter | | Vulkan backend | `Optimum.Render.Vulkan/**` | - | `Optimum.Render.Vulkan.dll` + `Silk.NET.*.dll` beside the client (`make deploy` copies them) | | Vanilla reference | `_ref/**` and `.vanilla/**/assets` | read-only | - | @@ -60,16 +60,19 @@ trace: `program N 'name'`, `fullscreen program= tex0= target=`, `bind unit= text (legacy line plus `key=value` lines: blocking uploads, waits per site, frame p50/p95/p99/stddev, scopes, barriers), `OPTIMUM_FPS_LOG=` (per-second `mean min max p99 stddev`), `OPTIMUM_PARITY_DUMP= OPTIMUM_PARITY_FRAME=` (every framebuffer attachment on both backends at in-world frame n, PPM/PFM, GL row order), -`OPTIMUM_VULKAN_POISON=1` (fresh images NaN/magenta/0xDEADBEEF, depth 0.5, buffers 0xDEADBEEF: undefined reads become loud). +`OPTIMUM_VULKAN_POISON=1` (fresh images NaN/magenta/0xDEADBEEF, depth 0.5, buffers 0xDEADBEEF: undefined reads become loud), +`OPTIMUM_VK_NATIVE_SHADERS=force|0` (force: link every program from the native manifest even without a mod scan; 0: rewriter for all; +the log line `[Optimum] shaders: N native, M rewritten, K failed` says what happened), `OPTIMUM_VK_SHADER_SOURCE=` (compile the +native tree at runtime for the dev loop), `OPTIMUM_VULKAN_SYNC_PIPELINES=1` (blocking pipeline creation instead of the background worker). Headless capture: `OPTIMUM_HEADLESS=1` (window created but never mapped or focused, both backends), `OPTIMUM_HEADLESS_FRAMES=` plus `OPTIMUM_HEADLESS_FIRST_FRAME`/`_FRAME_COUNT`/`_FRAME_STRIDE` or `_FRAME_LIST` (which in-world frames to write as PPM), `OPTIMUM_HEADLESS_COMMANDS=` with `OPTIMUM_HEADLESS_COMMAND_FRAME` (chat lines fed on that frame: `/time`, `/weather`, `.cam load`/`.cam play`), `OPTIMUM_HEADLESS_FIXED_DT` (pins the simulated step). A display server is still required - headless here means no visible window, not no display. -DLSS/NGX: the NVIDIA feature libraries, headers and programming guides live in `~/.local/share/optimum-ngx` -(never in the scratchpad - that is tmpfs and a reboot would make the NGX tests skip instead of fail). -Run the NGX tests with `OPTIMUM_NGX_FEATURE_PATH=~/.local/share/optimum-ngx/lib/Linux_x86_64/rel`. +DLSS/NGX (branches `feat/dlss*` only; not present on the Vulkan-only branches): the NVIDIA feature libraries live in +`~/.local/share/optimum-ngx`, never in the scratchpad (tmpfs; after a reboot the NGX tests would skip instead of fail); +`OPTIMUM_NGX_FEATURE_PATH=~/.local/share/optimum-ngx/lib/Linux_x86_64/rel` runs them. **Implicit Vulkan layers poison validation and must be switched off deliberately.** On this machine MangoHud is enabled globally (`~/.config/environment.d/mangohud.conf`) and `VK_LAYER_LS_frame_generation` (Lossless Scaling) has no enable variable at all, so both hook every Vulkan process, the GPU test host included, and draw or present @@ -100,7 +103,7 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. 5. **Process hygiene.** Launch through `scripts/dev/*.sh` (setsid wrappers). Never put `pkill -f` or `pgrep -f` in a command that also contains the process name in a heredoc or string: it matches the calling shell, so `pkill` kills it (exit 144) and `pgrep` reports the process as running when it is not - - on 2026-09-16 that made me tell the owner the game was running long after they had closed it. Ask with + on 2026-09-16 that produced a "client is running" report hours after the owner had closed it. Ask with `ps -eo pid,stat,args | grep -i | grep -v grep`, or read the client log. Close the game with the kill script (window close first) to avoid shutdown-race crash reports. Close the game as soon as a check is done; never leave it running. 6. **Git.** Never `git stash`. Commit WIP on the branch with a `wip:` prefix instead. Branch from @@ -108,18 +111,17 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. Commit only when asked or when a phase is verified; say what was verified in the message. 7. **Batch reads.** Read whole methods and both paths in one command (`sed -n` ranges + `rg`), not ten single greps. Codex found in one pass what took an afternoon of small probes. -8. **Agents, models, effort.** The session model is Fable; it does only the hard parts, at low - effort, high only for a hard bug. Everything else runs as a Workflow (ultracode): sonnet for - map/search stages at **high or xhigh** (cheap, needs it to be trustworthy), opus for - implementation and review at **medium, never higher**. Never launch an agent that inherits Fable. - Parallelise: map stage first, then every independent implementation stage at once with - `isolation: 'worktree'` (each commits on its own branch), then one integration stage that merges - into the feature branch and runs the finish sequence, then review. Serial stages are only for - genuinely dependent work. Worktree stages are created at `origin/main`, which is NOT an ancestor of the - feature branch (`feat/vulkan-taa` diverged from it): their first commands are `git checkout -B ` - (never `merge --ff-only`, which fails) and `bash scripts/dev/worktree-bootstrap.sh`; integration merges into the feature branch, never main. - Rules live in `.claude/skills/workflow-policy`. Codex (`.claude/skills/codex-handoff`, gpt-6-astra) has quota again since - 2026-09-12: hard rendering bugs can go to it (neutral brief, full machine access, low effort) or to Fable directly. +8. **Agents, models, effort.** The main session does the hard parts itself and never spawns an agent that + inherits its own model. Everything else runs as a Workflow (ultracode): sonnet at **high or xhigh** for the + rare read-only search stage (cheap, and lower effort gives untrustworthy results), opus at **medium, never + higher** for implementation and integration. Shape: every independent implementation stage at once with + `isolation: 'worktree'` (each commits on its own branch), then one integration stage that merges into the + feature branch and runs the finish sequence. No map stage by default (rule 15) and no review stage by default + (rule 14); serial stages only for genuinely dependent work. Worktrees are created at `origin/main`, which need + not be an ancestor of the feature branch: a stage's first commands are `git checkout -B + ` (never `merge --ff-only`) and `bash scripts/dev/worktree-bootstrap.sh`; integration merges + into the feature branch, never main. Details: `.claude/skills/workflow-policy`. Codex (`.claude/skills/codex-handoff`, + gpt-6-astra, weekly quota) takes genuinely stuck rendering bugs with a neutral brief and full machine access. 9. **Undefined behaviour differs between the APIs.** GL keeps an attachment the shader never writes; Vulkan writes garbage into it (pipelines now mask those off). A bug that only flickers between @@ -130,8 +132,8 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. log with `sync,best`; a multi-frame GPU test (Present between frames, no readback in the loop); `pacing-gate.sh` numbers against the OpenGL baseline of the same scene; `ssim.py` per-attachment tables; a 60 fps `ffmpeg -f x11grab` capture with consecutive-frame diffs for flicker; poison mode - for suspected undefined reads. The Vulkan-native rebuild plan and its phases: - `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md`, acceptance in `docs/vulkan-acceptance.md`. + for suspected undefined reads. Where the numbers are recorded: `docs/vulkan-acceptance.md`; branch status and the plan pointer: + `docs/vulkan-branch-progress.md`. 11. **TAA on sub-pixel foliage: audit the resolve, not the backend.** The three-day Vulkan "distant trees jitter, TAA looks disabled" bug (fixed 2026-09-11) was `sources/shaders/taa-resolve.fsh` itself: @@ -147,7 +149,7 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. 12. **Say only what you verified, and name the evidence.** A status in a plan, a handoff or a comment is a claim, not a fact; a passing test, a file:line, a log line or a measured number is a fact. On 2026-09-16 an audit found - items marked done that were never done, and separately I asserted a model, a process state and a branch scope + items marked done that were never done, and separately a model, a process state and a branch scope were asserted from documents instead of from checks - each one wrong. If it was not checked this session, say that it was not. 13. **Decide, don't ask.** Research the open question to a decision and act on it. Hand a choice back only when it @@ -189,7 +191,7 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. the loading screen from a8f09ae until 2026-09-13 and nobody saw it, because every check ran Vulkan. The headless harness makes the OpenGL run cost nothing: run it in the same pass as the Vulkan one. - **Injected fields never run their initializers** (Cecil copies no constructor IL): an injected `= new T()` is null, - an injected `= -1` is 0. `NoInjectedFieldAnywhereCarriesAnInitializer` enforces it for manifest fields. Use the CLR + an injected `= -1` is 0. `CecilInjectedFieldInitializerTests` enforces it for every injected field, with no allowance list. Use the CLR default as the starting state, or allocate lazily at the use site. - **Do not touch a vanilla static class before vanilla does.** Its type initializer may not be inert: `ShaderRegistry`'s publishes uncompiled programs into `ShaderPrograms.*`, which is what crashed the OpenGL loading @@ -226,7 +228,7 @@ User, 2026-09-12: "We do Code documentation and comment cleanup at the very end. **Why:** the comments in this repo are the record of what each defect cost, and many carry measured numbers - the 1.05 % distant-leaf TAA rejection, the 0.37 to 0.02 display-pixel jitter residual, why NGX's shutdown is gated, why the acquire wait stage may never be ALL_COMMANDS. While the renderer is still moving, trimming them deletes the reasoning that keeps the next agent from reintroducing the bug. -**How to apply:** never open a "tidy the comments" task, and never let a refactor quietly drop an xml-doc - a consolidation or a move carries every "why" forward verbatim. Stale comments that are actively wrong are still fixed on the spot, as part of the change that made them wrong. One cleanup pass at the end, when the renderer settles. Related: [[testing-suite-too-heavy]], [[speed-and-parallelism-over-testing]]. +**How to apply:** never open a "tidy the comments" task, and never let a refactor quietly drop an xml-doc - a consolidation or a move carries every "why" forward verbatim. Stale comments that are actively wrong are still fixed on the spot, as part of the change that made them wrong. One cleanup pass at the end, when the renderer settles. Related: `testing-suite-too-heavy`, `speed-and-parallelism-over-testing`. ### Delegating to codex @@ -259,18 +261,18 @@ The user delegates hard problems to the local `codex` CLI and has been specific draws in a frame) plus a dozen wrong assumptions. Brief it with the plan path and the source locations, ask for CONFIRMED/WRONG/UNVERIFIABLE with file:line, and tell it to write to a file in the scratchpad. Launch through a wrapper script with `setsid` so the tool timeout cannot kill - it, and monitor for a sentinel line (see [[pkill-self-match]]). + it, and monitor for a sentinel line (see `pkill-self-match`). **Why:** on the Vulkan backend it found three real bugs in one pass that I had missed over a long session, and verified them by playing the game across several views and two worlds. **How to apply:** when stuck on something the user is getting frustrated with, offer Codex early rather than late, brief it neutrally, and give it the whole machine. See -[[verify-end-to-end-not-components]]. +`verify-end-to-end-not-components`. Steering (2026-09-10): `codex queue --thread --message` only reaches a running session; messages queued after exit are lost, so continue with `codex exec resume `. Monitor on `^CODEX_EXIT [0-9]+$` (Codex narrates the word and false-matched a looser pattern). Relay the user's observations verbatim and promptly; each one ("gets worse with distance") narrowed the search. Codex does not push; verify its claims in-game, then push. -Quota: exhausted on 2026-09-10; **reset and available again from 2026-09-12** (user). Codex handoffs are back on the table for genuinely stuck rendering bugs and for plan reviews at `high`; it still costs a weekly quota, so keep briefs tight and do not launch speculatively. +It is on a weekly quota the owner tracks: keep briefs tight and do not launch speculatively. ### Git remotes @@ -280,7 +282,7 @@ Remote layout (set 2026-09-10 at the user's request): - `origin` = https://github.com/KillerPixelCrew/VulkanStory.git ("ours"; the repository moved into the KillerPixelCrew organisation on 2026-09-15, it used to be NightHammer1000/VulkanStory). `main` tracks `origin/main`. PRs for the Vulkan work go here; `gh` default repo is set to it. - `upstream` = https://github.com/StratumServer/Optimum.git. Does not have the Vulkan backend yet. -**How to apply:** push branches and open PRs against `origin`. Only touch `upstream` when the user asks to sync with or contribute to StratumServer. Related: [[optimum-upscaling-roadmap]]. +**How to apply:** push branches and open PRs against `origin`. Only touch `upstream` when the user asks to sync with or contribute to StratumServer. Related: `optimum-upscaling-roadmap`. ### Look before you work @@ -290,7 +292,7 @@ User, 2026-09-13: "This is the third time i have to tell you to actually look be **Why:** I designed DLSS-G frame pacing from one chapter of NVIDIA's guide plus my own reasoning and launched a 7-agent workflow on it. The user's own reference checkouts in `~/Projekte/ReScaleFrame/references/` (Streamline, FidelityFX-SDK, xess, OptiScaler) and their ReScaleFrame design docs already contradicted it: AMD paces both presents from the previous present with a 10-frame moving average, CPU-waits for GPU completion before presenting, keeps one frame in flight, caps render slightly below half the output rate; Streamline measures pacing by display change, not present call; only generated frames are dropped. Two workflows were stopped as a result. -**How to apply:** for any feature with vendor SDKs or prior art: (1) read the vendor guides in full, not the chapter that matches the question; (2) read the reference implementations on disk - check `~/Projekte/ReScaleFrame/references/` and the user's ReScaleFrame docs first, they are the user's own research; (3) search online for best practice; (4) write the design with a source for every decision and mark what is reasoning; (5) show the user the sourced design before launching an implementation wave. A map of *our* code is not research into *how it should be done*. Related: [[research-before-repeating-loops]], [[audit-the-component-the-user-names]], [[frame-generation-needs-pacing]], [[user-graphics-expertise]]. +**How to apply:** for any feature with vendor SDKs or prior art: (1) read the vendor guides in full, not the chapter that matches the question; (2) read the reference implementations on disk - check `~/Projekte/ReScaleFrame/references/` and the user's ReScaleFrame docs first, they are the user's own research; (3) search online for best practice; (4) write the design with a source for every decision and mark what is reasoning; (5) show the user the sourced design before launching an implementation wave. A map of *our* code is not research into *how it should be done*. Related: `research-before-repeating-loops`, `audit-the-component-the-user-names`, `frame-generation-needs-pacing`, `user-graphics-expertise`. ### Nvidia driver update needs reboot @@ -300,7 +302,7 @@ On 2026-09-11 a pacman update at 14:38 moved nvidia-utils 610.57.04 to 615.71.09 **Why:** it looked like a Phase 0 regression and cost a capture round; the user watched the clients crash. -**How to apply:** before any in-game capture run `nvidia-smi` (must print the GPU and driver, not a mismatch) and, after each launch, require `Graphics Card Renderer: NVIDIA` in the client log (on Vulkan that line is the selected Vulkan device name). If the mismatch shows, tell the user a reboot is needed instead of launching. Related: [[confirm-renderer-from-log]], [[vulkan-native-rebuild-decision]]. +**How to apply:** before any in-game capture run `nvidia-smi` (must print the GPU and driver, not a mismatch) and, after each launch, require `Graphics Card Renderer: NVIDIA` in the client log (on Vulkan that line is the selected Vulkan device name). If the mismatch shows, tell the user a reboot is needed instead of launching. Related: `confirm-renderer-from-log`, `vulkan-native-rebuild-decision`. ### Research before repeating loops @@ -310,7 +312,7 @@ On 2026-09-11 the Vulkan TAA distance shimmer came back (distant trees jitter be **Why:** the user judges effort by whether new information enters the loop. Re-running the same in-game checks with a measurement already documented as blind to the bug class is visible as churn. Yesterday's fix came from reading the validation log, which was new information; today nothing new was read. -**How to apply:** for a Vulkan-only or TAA-quality bug, before any second launch: (1) web-search the symptom (TAA shimmer on thin/distant geometry, history rejection, jitter phase alternation, swapchain/frame-pacing causes) and the relevant Vulkan spec/best-practice pages, (2) write down the competing mechanisms and the one observation that separates them, (3) only then launch, and only for that observation (e.g. a TaaDebugView validity view over the shimmering region, or an OpenGL eyes-on control). Never offer luma-diff pairs as evidence for frame-to-frame flicker. Related: [[vulkan-validation-log-and-flicker]], [[verify-end-to-end-not-components]], [[taa-p2-vulkan-parity-lessons]]. +**How to apply:** for a Vulkan-only or TAA-quality bug, before any second launch: (1) web-search the symptom (TAA shimmer on thin/distant geometry, history rejection, jitter phase alternation, swapchain/frame-pacing causes) and the relevant Vulkan spec/best-practice pages, (2) write down the competing mechanisms and the one observation that separates them, (3) only then launch, and only for that observation (e.g. a TaaDebugView validity view over the shimmering region, or an OpenGL eyes-on control). Never offer luma-diff pairs as evidence for frame-to-frame flicker. Related: `vulkan-validation-log-and-flicker`, `verify-end-to-end-not-components`, `taa-p2-vulkan-parity-lessons`. ### Research combines sources @@ -320,7 +322,7 @@ User, 2026-09-15, after naming MXAO, Alchemy AO, low-sample GTAO + spatial denoi **Why:** I answered each named source with a verdict (use / reference only / not adopted) and kept steering toward one implementation, instead of studying every source in depth for the parts worth combining. -**How to apply:** when the owner lists sources or alternatives for a design, launch a deep research task (Fable, high effort - an explicit exception to the no-Fable-agents rule) that reads the actual papers and code of every source, compares them against this renderer's constraints and writes a combined design with per-component provenance and licence notes; hold implementation until it is back. Licence limits still decide what may be taken as code versus as an idea. Related: [[look-before-you-work]], [[decide-dont-ask]], [[xegtao-default-with-taa]]. +**How to apply:** when the owner lists sources or alternatives for a design, launch a deep research task (Fable, high effort - an explicit exception to the no-Fable-agents rule) that reads the actual papers and code of every source, compares them against this renderer's constraints and writes a combined design with per-component provenance and licence notes; hold implementation until it is back. Licence limits still decide what may be taken as code versus as an idea. Related: `look-before-you-work`, `decide-dont-ask`, `xegtao-default-with-taa`. ### Scratchpad is tmpfs @@ -330,7 +332,7 @@ User, 2026-09-15, after naming MXAO, Alchemy AO, low-sample GTAO + spatial denoi **Why:** `/tmp` is RAM on this machine and shared with everything else the user runs; a full tmpfs fails package transactions, not just my own commands. -**How to apply:** delete a capture directory as soon as its numbers are recorded in `docs/vulkan-acceptance.md` or the plan - the conclusions are the deliverable, the frames are not. Shallow-clone vendor SDKs, read them, then remove them; the synthesis stays. Anything a test or a later session needs (the NVIDIA NGX libraries, headers and guides) goes to `~/.local/share/optimum-ngx`, never the scratchpad: on tmpfs it vanishes at reboot and the NGX tests then *skip* rather than fail, which hides the breakage. Check `df -h /tmp` before writing GB-scale dumps, and prefer per-attachment dumps at one frame over frame sequences. Related: [[testing-suite-too-heavy]], [[ngx-needs-a-native-shim]]. +**How to apply:** delete a capture directory as soon as its numbers are recorded in `docs/vulkan-acceptance.md` or the plan - the conclusions are the deliverable, the frames are not. Shallow-clone vendor SDKs, read them, then remove them; the synthesis stays. Anything a test or a later session needs (the NVIDIA NGX libraries, headers and guides) goes to `~/.local/share/optimum-ngx`, never the scratchpad: on tmpfs it vanishes at reboot and the NGX tests then *skip* rather than fail, which hides the breakage. Check `df -h /tmp` before writing GB-scale dumps, and prefer per-attachment dumps at one frame over frame sequences. Related: `testing-suite-too-heavy`, `ngx-needs-a-native-shim`. ### Speed and parallelism over testing @@ -342,7 +344,7 @@ Capture sessions stay short: 3 minutes is plenty for a session measurement ("Tha **Why:** the rebuild spent hours in serial chains (one stage per worktree after another) and in repeated in-game measurement rounds; the user wants throughput. -**How to apply:** design each phase's workflow as wide parallel waves with explicit file ownership and interface contracts in the prompts (no map stage when the touch points are already known), one merge agent per wave, one review at the end. Keep in-game runs to the phase's single exit capture; do not add investigation launches unless a result blocks the next phase. Unit and source tests inside stages stay mandatory. Related: [[vulkan-native-rebuild-decision]], [[no-subagents]], [[research-before-repeating-loops]]. +**How to apply:** design each phase's workflow as wide parallel waves with explicit file ownership and interface contracts in the prompts (no map stage when the touch points are already known), one merge agent per wave, one review at the end. Keep in-game runs to the phase's single exit capture; do not add investigation launches unless a result blocks the next phase. Unit and source tests inside stages stay mandatory. Related: `vulkan-native-rebuild-decision`, `no-subagents`, `research-before-repeating-loops`. ### Taa p2 vulkan parity lessons @@ -353,7 +355,7 @@ TAA P2 (in-house resolve) was accepted by the user on 2026-09-10 ("TAA is CHEFSK 2. `ClearColor` on Vulkan is a no-op for an attachment masked out of `SetDrawBuffers`; the motion attachment kept stale vectors (8e4a970). Clear = enable, clear, restore mask. Both slipped past single-frame GPU tests; Codex's regression test spans frames in flight with Present between them. Acceptance is numeric: still camera, wind stilled (`/weather setw still`), luminance diff of screenshot pairs; parity was Vulkan 1.84 vs OpenGL 1.87. -**How to apply:** for any Vulkan "looks wrong" report, check the format table and clear-vs-mask first (now in the vulkan-parity-debug skill, sections 2 and 2c), and write multi-frame tests for temporal state. P3+ of TAA-PLAN.md continue via workflows (sonnet map, opus stages). Related: [[verify-end-to-end-not-components]], [[delegating-to-codex]], [[optimum-upscaling-roadmap]]. +**How to apply:** for any Vulkan "looks wrong" report, check the format table and clear-vs-mask first (now in the vulkan-parity-debug skill, sections 2 and 2c), and write multi-frame tests for temporal state. P3+ of TAA-PLAN.md continue via workflows (sonnet map, opus stages). Related: `verify-end-to-end-not-components`, `delegating-to-codex`, `optimum-upscaling-roadmap`. ### Testing suite too heavy @@ -363,7 +365,7 @@ User, 2026-09-11, during the Milestone 1 exit capture: "That 10 Minute run was e **Why:** the heavy rows measure world noise, not the backend. Two OpenGL launches of one save differed at SSIM 0.86 on the primary colour, so the per-attachment parity matrix cannot separate a real gap from weather, chunk streaming and entity movement; the fixed scene helps pacing but not parity. The long session added nothing the first minute had not shown. -**How to apply:** keep the cheap numeric evidence that actually catches regressions (pacing gate on a 60 s run, the Vulkan stats counters, `taa-rejection.py` on one dump per backend, the GPU suite's `sync,best` validation) and drop the rest: no 10-minute sessions, no multi-launch SSIM matrices, no repeated interleaves unless a number disagrees. One short Vulkan launch for the user to judge closes a milestone. Always set MANGOHUD=0 for validation runs: MangoHud's overlay render pass trips sync validation on the swapchain image and produced 10 phantom errors. Related: [[speed-and-parallelism-over-testing]], [[verify-end-to-end-not-components]], [[run-for-user-no-input]]. +**How to apply:** keep the cheap numeric evidence that actually catches regressions (pacing gate on a 60 s run, the Vulkan stats counters, `taa-rejection.py` on one dump per backend, the GPU suite's `sync,best` validation) and drop the rest: no 10-minute sessions, no multi-launch SSIM matrices, no repeated interleaves unless a number disagrees. One short Vulkan launch for the user to judge closes a milestone. Always set MANGOHUD=0 for validation runs: MangoHud's overlay render pass trips sync validation on the swapchain image and produced 10 phantom errors. Related: `speed-and-parallelism-over-testing`, `verify-end-to-end-not-components`, `run-for-user-no-input`. ### User graphics expertise @@ -371,7 +373,7 @@ User, 2026-09-11, during the Milestone 1 exit capture: "That 10 Minute run was e The user authored the XeSS integration PR for Skyrim Community Shaders and judges TAA/upscaler behaviour live by eye with precision (distance-dependent instability, frame-to-frame flicker, "TAA has a distinctive blur"). Their observations have been right every time this project doubted them. -**How to apply:** no primers on jitter, motion vectors or reactive masks; when their live observation contradicts a measurement, the measurement is the suspect. Related: [[vulkan-validation-log-and-flicker]], [[run-for-user-no-input]]. +**How to apply:** no primers on jitter, motion vectors or reactive masks; when their live observation contradicts a measurement, the measurement is the suspect. Related: `vulkan-validation-log-and-flicker`, `run-for-user-no-input`. ### Vulkan validation log and flicker @@ -379,7 +381,7 @@ The user authored the XeSS integration PR for Skyrim Community Shaders and judge 2026-09-11: the Vulkan-only "everything jitters, no AA, worse at the horizon" after P3/P4 survived every single-frame probe (motion, validity, history, uniforms all identical to GL) because the defect alternated between frames: fullscreen passes left the SSAO normal/position attachments write-enabled without storing to them, Vulkan wrote undefined values, SSAO outlines flickered. Found within minutes once the validation log was actually read (it had been going to a file named "1" or nowhere) with sync + best-practices validation. Fix 95bf71d: mask unwritten fragment outputs in the pipeline, present-path wait stage AllCommands, per-image semaphores, layout-accurate barrier accesses. User: "that fixed the instability issue fully". -**How to apply:** for any Vulkan-only artefact, first run with `OPTIMUM_VULKAN_VALIDATION=/abs/log OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` and read `[error]` lines; screenshots and per-frame diag shaders cannot see one-frame alternation. The user judges live; when they say it flickers between frames, believe it and look for API-level undefined behaviour, not resolve maths. Related: [[taa-p2-vulkan-parity-lessons]], [[run-for-user-no-input]]. +**How to apply:** for any Vulkan-only artefact, first run with `OPTIMUM_VULKAN_VALIDATION=/abs/log OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` and read `[error]` lines; screenshots and per-frame diag shaders cannot see one-frame alternation. The user judges live; when they say it flickers between frames, believe it and look for API-level undefined behaviour, not resolve maths. Related: `taa-p2-vulkan-parity-lessons`, `run-for-user-no-input`. ### Waiting on long running processes @@ -387,5 +389,5 @@ The user authored the XeSS integration PR for Skyrim Community Shaders and judge Blind sleeps repeatedly captured the loading screen or typed into a game that was not accepting input yet. The reliable markers: `[Client Chat] Welcome` for "player is in the world" (savegame-loaded and AssetsFinalize come ~20 s earlier), `^CODEX_EXIT [0-9]+$` for the Codex wrapper, workflow task notifications for agents. Kill leftovers through the wrapper scripts before a new launch. -**How to apply:** poll the log for the marker with a bounded loop, then a short fixed margin; never `sleep 60` and hope. Related: [[pkill-self-match]], [[run-for-user-no-input]]. +**How to apply:** poll the log for the marker with a bounded loop, then a short fixed margin; never `sleep 60` and hope. Related: `pkill-self-match`, `run-for-user-no-input`. From 83d040e5f061b6c22e35bdb9ac749189c4aaa0dc Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 12:03:24 +0200 Subject: [PATCH 196/226] wip(native-world): mesh draws on the native device API, proved on the sky dome Phase 3b decision 5 stage 2. The native device API recorded fullscreen draws only; world systems are mesh draws, so it now records those too, and the simplest world system draws through them end to end. Device (VulkanDevice.NativeMesh.cs, description and key in VulkanDevice.Native.cs): - DrawNativeMesh, DrawNativeMeshInstanced, DrawNativeMeshArrays (non-indexed) and DrawNativeMeshMulti, sharing BeginNativeDraw - DrawNativeFullscreen's old body - and recording through the existing MeshManager and the existing per-slot indirect ring. No second mesh path. - The real mesh id now reaches BindProgramSets, so a chunk's storage-buffer vertex fetch and an entity's Animation block resolve per draw rather than against the fullscreen path's hardcoded 0. Bone matrices need no new API. - NativePipelineDescription gains VertexLayoutId, PolygonMode, LineWidth, FrontFace and SamplesBoundDepth (the declaration that the pass reads the depth it draws into with writes off, which puts the scope's depth in the read-only layout). All of them are in the native pipeline cache key and the layout is in PipelineKey, so a mesh pipeline never collides with a fullscreen one. - WriteNative takes a float run, so a model-view matrix is one per-draw write by placement. - Stats count the kinds apart: native_fullscreen_draws, native_mesh_draws, native_instanced_draws, native_indirect_draws, summing to native_draws. Lib seam: ClientPlatformAbstract.RenderSkyDome(MeshRef, int, int, float[]), neutral body the RenderMesh call it replaced, so OpenGL is unchanged; SystemRenderSkyColor draws through it. Listed in Optimum.Patcher/Program.cs and in the platform's ExpectedVirtuals. Platform: VulkanClientPlatform.NativeSky.cs records the dome as a declared pass with its own fixed state; NativeSkyEnabled keeps the old route reachable. Verified on this machine with the implicit-layer disable set (only VK_LAYER_MESA_device_select inserted, confirmed with VK_LOADER_DEBUG=layer): dotnet build VintageStory.slnx -c Release clean; extract-patches + check-patches (157 patches, 0 conflicts; 43 runtime patches with exact donors); Optimum.Tests 1249 passed; Optimum.Render.Vulkan.Tests 1075 passed, validation clean under sync,best. Not verified in game - agents do not launch the client. --- Optimum.Patcher/Program.cs | 3 + .../NativeMeshDrawTests.cs | 525 ++++++++++++++++++ Optimum.Render.Vulkan.Tests/NativeSkyTests.cs | 424 ++++++++++++++ .../PacingStatsTests.cs | 5 +- Optimum.Render.Vulkan/Core/VulkanStats.cs | 61 +- .../VulkanClientPlatform.NativeSky.cs | 240 ++++++++ .../Platform/VulkanClientPlatform.cs | 2 + Optimum.Render.Vulkan/VulkanDevice.Native.cs | 202 +++++-- .../VulkanDevice.NativeMesh.cs | 203 +++++++ Optimum.Tests/fsr-pipeline-coverage-tests.cs | 3 +- .../native-world-systems-coverage-tests.cs | 217 ++++++++ docs/taa-acceptance.md | 9 +- docs/vulkan-native-render-systems.md | 37 ++ .../ClientPlatformAbstract.cs.patch | 27 +- .../SystemRenderSkyColor.cs.patch | 18 +- 15 files changed, 1929 insertions(+), 47 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/NativeSkyTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs create mode 100644 Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs create mode 100644 Optimum.Tests/native-world-systems-coverage-tests.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index bee41979..edee30a0 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -82,6 +82,9 @@ "BeginMotionOnlyWrite", "EndMotionOnlyWrite", "RenderOptimumSkyMotion", + // Phase 3b stage 2: the sky dome's draw seam, so a native platform records that pass + // itself. The neutral body is the RenderMesh call it replaced. + "RenderSkyDome", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", // Phase 3b stage 1d: the draw seams of the two TAA passes, so a native platform diff --git a/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs new file mode 100644 index 00000000..70fc2a40 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The native device API's mesh draws (docs/vulkan-native-render-systems.md, decision 4: +/// "fullscreen triangle, mesh, multi-draw or instanced"), on the real chunk program and a real +/// tesselated face. +/// +/// Stage 1 recorded fullscreen draws only. These pin the four facts a world system depends on: +/// a native mesh draw puts the same pixels on the target as the emulated draw of the same mesh +/// with the same state; a mesh pipeline is never the fullscreen pipeline of the same program; +/// a native multi-draw takes its own region of the per-slot indirect ring, the same ring the +/// emulated multi-draw allocates from; and the stats count mesh, instanced and indirect draws +/// apart from fullscreen ones. +/// +public class NativeMeshDrawTests(ITestOutputHelper output) +{ + private const int Size = 32; + private const int UpNormalFlags = 7 << 18; + + // ------------------------------------------------------------------- the tests + + /// + /// The same face, drawn twice into the same target: once through the emulated + /// (the route every vanilla system still takes) and + /// once through . Both paths run the same shader + /// over the same vertices with the same fixed state, so the pixels are bitwise equal. + /// + [SkippableFact] + public unsafe void ANativeMeshDrawMatchesTheEmulatedDrawOfTheSameMesh() + { + using Session session = Open(); + + byte[] emulated = session.RunEmulatedFrame(); + + long meshDrawsBefore = session.Device.NativeMeshDrawsForTests; + long fullscreenBefore = session.Device.NativeFullscreenDrawsForTests; + long insideBefore = session.Device.EmulationCallsInNativePassesForTests; + byte[] native = session.RunNativeFrame(); + + Assert.Equal(1, session.Device.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Device.NativeFullscreenDrawsForTests - fullscreenBefore); + Assert.Equal(0, session.Device.EmulationCallsInNativePassesForTests - insideBefore); + + output.WriteLine("emulated centre: " + Centre(emulated) + " native centre: " + Centre(native)); + Assert.Equal(emulated, native); + GpuTest.AssertClean(session.Device); + } + + /// + /// The pipeline key carries the vertex layout, so the mesh pipeline and the fullscreen + /// pipeline of one program, one target and one blend set are two entries, never one. Before + /// the layout entered the key they would have collided and a mesh draw would have run the + /// fullscreen pipeline, which binds no vertex buffers at all. + /// + [SkippableFact] + public void AMeshPipelineKeyNeverCollidesWithAFullscreenOne() + { + using Session session = Open(); + VulkanDevice device = session.Device; + + int before = device.NativePipelinesForTests; + NativePipeline? fullscreen = device.RequestNativePipeline( + session.Description(MeshManager.EmptyLayoutId), out string fullscreenError); + NativePipeline? mesh = device.RequestNativePipeline( + session.Description(session.LayoutId), out string meshError); + + Assert.True(fullscreen != null, fullscreenError); + Assert.True(mesh != null, meshError); + Assert.NotSame(fullscreen, mesh); + Assert.NotEqual(fullscreen!.Key, mesh!.Key); + Assert.Equal(2, device.NativePipelinesForTests - before); + + // Asking again for either gives the entry that was made for it, not the other one. + Assert.Same(mesh, device.RequestNativePipeline(session.Description(session.LayoutId), out _)); + Assert.Same(fullscreen, + device.RequestNativePipeline(session.Description(MeshManager.EmptyLayoutId), out _)); + Assert.Equal(2, device.NativePipelinesForTests - before); + } + + /// + /// The other new state dimensions are in the key too: polygon mode, front face, line width + /// and the bound-depth declaration each make a distinct pipeline, so a cached one is never + /// handed to a draw that asked for different state. + /// + [SkippableFact] + public void EveryNewStateDimensionMakesItsOwnPipeline() + { + using Session session = Open(); + VulkanDevice device = session.Device; + + int before = device.NativePipelinesForTests; + Assert.NotNull(device.RequestNativePipeline(session.Description(session.LayoutId), out _)); + + NativePipelineDescription lines = session.Description(session.LayoutId); + lines.PolygonMode = PolygonMode.Line; + Assert.NotNull(device.RequestNativePipeline(lines, out _)); + + NativePipelineDescription winding = session.Description(session.LayoutId); + winding.FrontFace = FrontFace.CounterClockwise; + Assert.NotNull(device.RequestNativePipeline(winding, out _)); + + NativePipelineDescription wide = session.Description(session.LayoutId); + wide.Topology = PrimitiveTopology.LineList; + wide.LineWidth = 2f; + Assert.NotNull(device.RequestNativePipeline(wide, out _)); + + NativePipelineDescription depthRead = session.Description(session.LayoutId); + depthRead.DepthWrite = false; + depthRead.SamplesBoundDepth = true; + Assert.NotNull(device.RequestNativePipeline(depthRead, out _)); + + Assert.Equal(5, device.NativePipelinesForTests - before); + } + + /// A pipeline that samples the depth it draws into may not also write it. + [SkippableFact] + public void APipelineCannotBothSampleAndWriteTheBoundDepth() + { + using Session session = Open(); + NativePipelineDescription description = session.Description(session.LayoutId); + description.DepthWrite = true; + description.SamplesBoundDepth = true; + + Assert.Null(session.Device.RequestNativePipeline(description, out string error)); + Assert.Contains("cannot also write depth", error, StringComparison.Ordinal); + } + + /// + /// Two native multi-draws in one frame take two regions of the slot's indirect buffer, as + /// the emulated path does: writing both at offset zero was the Phase 1B bug where every + /// multi-draw in a frame executed with the ranges of whichever was recorded last. + /// + [SkippableFact] + public unsafe void TwoNativeMultiDrawsTakeTwoRegionsOfTheIndirectRing() + { + using Session session = Open(); + VulkanDevice device = session.Device; + + long indirectBefore = device.NativeIndirectDrawsForTests; + ulong cursorBefore = 0; + ulong cursorAfter = 0; + + session.RunFrame(() => + { + int slot = device.IndirectRingForTests.Current; + cursorBefore = device.IndirectRingForTests.CursorOf(slot); + NativePipeline pipeline = session.BeginNativeSceneDraw(); + Assert.True(device.DrawNativeMeshMulti(pipeline, session.Mesh, + new[] { 0, 0 }, new[] { 6 }, 1, session.Textures(pipeline))); + Assert.True(device.DrawNativeMeshMulti(pipeline, session.Mesh, + new[] { 0, 0 }, new[] { 6 }, 1, session.Textures(pipeline))); + cursorAfter = device.IndirectRingForTests.CursorOf(slot); + device.EndNativePass(); + }); + + Assert.Equal(2, device.NativeIndirectDrawsForTests - indirectBefore); + ulong command = (ulong)sizeof(DrawIndexedIndirectCommand); + Assert.Equal(2 * command, cursorAfter - cursorBefore); + GpuTest.AssertClean(device); + } + + /// + /// An instanced native draw is counted as one, apart from the plain mesh draws, and draws + /// through the mesh's own bindings exactly as + /// does. + /// + [SkippableFact] + public void AnInstancedNativeDrawIsCountedApartFromAPlainMeshDraw() + { + using Session session = Open(); + VulkanDevice device = session.Device; + + long meshBefore = device.NativeMeshDrawsForTests; + long instancedBefore = device.NativeInstancedDrawsForTests; + + session.RunFrame(() => + { + NativePipeline pipeline = session.BeginNativeSceneDraw(); + Assert.True(device.DrawNativeMesh(pipeline, session.Mesh, session.Textures(pipeline))); + Assert.True(device.DrawNativeMeshInstanced(pipeline, session.Mesh, 3, session.Textures(pipeline))); + device.EndNativePass(); + }); + + Assert.Equal(1, device.NativeMeshDrawsForTests - meshBefore); + Assert.Equal(1, device.NativeInstancedDrawsForTests - instancedBefore); + GpuTest.AssertClean(device); + } + + /// + /// A mesh drawn through a pipeline built for another mesh's layout is refused rather than + /// recorded: the vertex buffers it would read are not the ones the pipeline declares, and + /// no validation layer can see that, because every descriptor involved is valid. + /// + [SkippableFact] + public void AMeshDrawThroughTheWrongLayoutsPipelineIsRefused() + { + using Session session = Open(); + VulkanDevice device = session.Device; + + session.RunFrame(() => + { + NativePipeline? fullscreen = device.RequestNativePipeline( + session.Description(MeshManager.EmptyLayoutId), out string error); + Assert.True(fullscreen != null, error); + Assert.True(session.BeginPass()); + Assert.False(device.DrawNativeMesh(fullscreen!, session.Mesh, session.Textures(fullscreen!))); + device.EndNativePass(); + }); + + GpuTest.AssertClean(device); + } + + + /// The client's IShader, as much of it as CompileShader reads. + private sealed class CorpusShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + /// The client's IShaderProgram, as much of it as LinkProgram reads. + private sealed class CorpusProgram : IShaderProgram + { + public int ProgramId { get; set; } + public string AssetDomain { get; set; } = "game"; + public int PassId { get; set; } + public string PassName { get; set; } = "chunkopaque"; + public bool ClampTexturesToEdge { get; set; } + public IShader VertexShader { get; set; } = null!; + public IShader FragmentShader { get; set; } = null!; + public IShader GeometryShader { get; set; } = null!; + public bool Oit { get; set; } = true; + public bool Disposed => false; + public bool LoadError => false; + public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new(); + public bool Compile() => true; + public bool HasUniform(string uniformName) => false; + public void Use() { } + public void Stop() { } + public void Dispose() { } + public void Uniform(string uniformName, float value) { } + public void Uniform(string uniformName, int value) { } + public void Uniform(string uniformName, Vec2f value) { } + public void Uniform(string uniformName, Vec2i value) { } + public void Uniform(string uniformName, float valueX, float valueY) { } + public void Uniform(string uniformName, Vec3f value) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { } + public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { } + public void Uniform(string uniformName, Vec4f value) { } + public void Uniforms4(string uniformName, int count, float[] values) { } + public void UniformMatrix(string uniformName, float[] matrix) { } + public void BindTexture2D(string samplerName, int textureId, int textureNumber) { } + public void BindTextureCube(string samplerName, int textureId, int textureNumber) { } + public void UniformMatrices(string uniformName, int count, float[] matrix) { } + public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { } + } + + // ---------------------------------------------------------------------- session + + private Session Open() + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device."); + return new Session(device!); + } + + private static string Centre(byte[] pixels) + { + int i = (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + /// + /// The real chunkopaque program, one tesselated face, and a target to draw it into: the + /// smallest setting in which both routes can draw the same thing. + /// + private sealed class Session : IDisposable + { + private readonly Dictionary _samplerTextures = new(StringComparer.Ordinal); + + public Session(VulkanDevice device) + { + Device = device; + Program = LinkChunkOpaque(device); + BindEveryDeclaredSampler(); + + Color = device.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + Depth = device.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false); + Framebuffer = device.CreateFramebuffer(Size, Size); + device.AttachTexture(Framebuffer, EnumFramebufferAttachment.ColorAttachment0, Color, 0); + device.AttachTexture(Framebuffer, EnumFramebufferAttachment.DepthAttachment, Depth, 0); + device.SetDrawBuffers(Framebuffer, 0b1); + Assert.True(device.CheckFramebufferComplete(Framebuffer, out string status), status); + + Mesh = device.CreateMesh(BuildBlockFace(), staticDraw: true); + Assert.True(Mesh > 0, device.GetError() ?? "mesh upload failed"); + LayoutId = device.NativeMeshLayoutId(Mesh); + Assert.True(LayoutId > 0, "the face's vertex layout is the reserved empty one"); + } + + public VulkanDevice Device { get; } + public int Program { get; } + public int Framebuffer { get; } + public int Color { get; } + public int Depth { get; } + public int Mesh { get; } + public int LayoutId { get; } + + public void Dispose() => Device.Dispose(); + + /// The fixed state both routes draw with: depth LEQUAL, writes on, no culling, no blending. + public NativePipelineDescription Description(int layoutId) => new() + { + ProgramId = Program, + Blend = new[] { AttachmentBlend.Default }, + DepthTest = true, + DepthWrite = true, + DepthCompare = CompareOp.LessOrEqual, + Cull = CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + VertexLayoutId = layoutId, + Targets = Device.NativeTargetFormats(Framebuffer, 1u)!, + }; + + /// Every sampler the program declares, at the slot this pipeline resolved for it. + public NativeTexture[] Textures(NativePipeline pipeline) + { + var textures = new List(); + foreach (KeyValuePair sampler in _samplerTextures) + { + NativeSamplerSlot slot = pipeline.Sampler(sampler.Key); + if (slot.IsPresent) textures.Add(new NativeTexture(slot, sampler.Value)); + } + return textures.ToArray(); + } + + public bool BeginPass() => Device.BeginNativePass(new NativePassDescription + { + Name = "NativeMeshDrawTests", + FramebufferId = Framebuffer, + ColorSlots = 1u, + Reads = _samplerTextures.Values.ToArray(), + ViewportWidth = Size, + ViewportHeight = Size, + }); + + /// Opens the pass and returns the pipeline the mesh draws through. + public NativePipeline BeginNativeSceneDraw() + { + NativePipeline? pipeline = Device.RequestNativePipeline(Description(LayoutId), out string error); + Assert.True(pipeline != null, error); + Assert.True(BeginPass()); + return pipeline!; + } + + /// One frame: clear, set the uniforms both routes read, run , present. + public void RunFrame(Action body) + { + Device.BeginFrame(); + Device.BindFramebuffer(Framebuffer); + Device.ClearColor(0, 1f, 0f, 1f, 1f); + Device.ClearDepth(1f); + SetUniforms(); + Device.SetViewport(0, 0, Size, Size); + body(); + Device.Present(); + } + + /// The emulated route: the GL-shaped state, then DrawMesh. + public unsafe byte[] RunEmulatedFrame() + { + byte[] pixels = new byte[Size * Size * 4]; + RunFrame(() => + { + Device.UseProgram(Program); + Device.SetDepthTest(true); + Device.SetDepthMask(true); + Device.SetDepthFunc(0x203); // GL_LEQUAL + Device.SetCullFace(false); + Device.SetBlend(false, EnumBlendMode.Standard); + Device.DrawMesh(Mesh); + }); + Read(pixels); + return pixels; + } + + /// The native route: a declared pass, a pipeline with the mesh's layout, one draw. + public unsafe byte[] RunNativeFrame() + { + byte[] pixels = new byte[Size * Size * 4]; + RunFrame(() => + { + NativePipeline pipeline = BeginNativeSceneDraw(); + Assert.True(Device.DrawNativeMesh(pipeline, Mesh, Textures(pipeline))); + Device.EndNativePass(); + }); + Read(pixels); + return pixels; + } + + private unsafe void Read(byte[] pixels) + { + fixed (byte* destination = pixels) + { + Device.BindFramebuffer(Framebuffer); + Device.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + } + + // ------------------------------------------------------------- program setup + + private void SetUniforms() + { + float[] identity = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + foreach (string name in new[] { "projectionMatrix", "modelViewMatrix", "modelMatrix", "mvpMatrix", + "toShadowMapSpaceMatrixFar", "toShadowMapSpaceMatrixNear" }) + { + int location = Device.GetUniformLocation(Program, name); + if (location >= 0) Device.SetUniformMatrix(Program, location, identity); + } + SetFloat("viewDistance", 1024f); + SetFloat("viewDistanceLod0", 1024f); + SetFloat("alphaTest", 0.001f); + SetFloat("zNear", 0.1f); + SetFloat("zFar", 1024f); + SetFloat("shadowRangeFar", 1024f); + SetFloat("shadowRangeNear", 64f); + SetFloat("shadowMapWidthInv", 1f); + SetFloat("shadowMapHeightInv", 1f); + int ambient = Device.GetUniformLocation(Program, "rgbaAmbientIn"); + if (ambient >= 0) Device.SetUniform(Program, ambient, 1f, 1f, 1f); + int frameSize = Device.GetUniformLocation(Program, "frameSize"); + if (frameSize >= 0) Device.SetUniform(Program, frameSize, (float)Size, (float)Size); + } + + private void SetFloat(string name, float value) + { + int location = Device.GetUniformLocation(Program, name); + if (location >= 0) Device.SetUniform(Program, location, value); + } + + private unsafe void BindEveryDeclaredSampler() + { + var white = new byte[] { 255, 255, 255, 255 }; + int unit = 0; + foreach (string name in Device.SamplerNamesOf(Program)) + { + int texture; + fixed (byte* pixels = white) + { + texture = Device.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false); + } + Device.SetSamplerUnit(Program, name, unit); + Device.BindTexture(unit, texture); + _samplerTextures[name] = texture; + unit++; + } + } + + private static int LinkChunkOpaque(VulkanDevice device) + { + List stages = ShaderCorpus.BuildProgram("chunkopaque", + ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), ShaderCorpus.Variants().First()); + Assert.NotEmpty(stages); + + var program = new CorpusProgram { PassName = "chunkopaque" }; + foreach (ShaderStageSource stage in stages) + { + var shader = new CorpusShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(device.CompileShader(shader), device.GetError() ?? "compile failed"); + if (stage.Stage == EnumShaderType.VertexShader) program.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) program.FragmentShader = shader; + else program.GeometryShader = shader; + } + + int id = device.LinkProgram(program); + Assert.True(id > 0, device.GetError() ?? "link failed"); + return id; + } + + /// One tesselated block face, in the layout the chunk tesselator emits. + private static MeshData BuildBlockFace() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, flags: UpNormalFlags); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs new file mode 100644 index 00000000..3c86f515 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The sky dome, drawn twice on one Vulkan device: through the seam's neutral body (the OpenGL +/// body's RenderMesh, the route every vanilla system still takes) and through the native pass +/// VulkanClientPlatform.RenderSkyDome records (docs/vulkan-native-render-systems.md, decision 5 +/// stage 2 - the first world system on the native device API). +/// +/// Behavioural identity is the acceptance rule (decision 6): the same shader, the same mesh and +/// the same fixed state have to put the same pixels on Primary's scene and glow attachments, +/// and the native route must not touch the GL state tracker, a texture unit or a draw-buffer +/// mask while its pass is open. +/// +public class NativeSkyTests(ITestOutputHelper output) +{ + private const int Size = 16; + + /// The platform with no window: both routes take their size from this seam. + private sealed class SkyPlatform : VulkanClientPlatform + { + public SkyPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + // ------------------------------------------------------------------ the tests + + /// + /// The native sky pass draws what the seam's neutral body draws: one declared pass, one + /// native mesh draw, no emulation inside it, and the same scene and glow pixels. + /// + [SkippableFact] + public unsafe void TheNativeSkyPassMatchesTheSeamsNeutralBody() + { + using Session session = Open(); + + (byte[] emulatedScene, byte[] emulatedGlow) = session.RunFrame(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + (byte[] nativeScene, byte[] nativeGlow) = session.RunFrame(native: true); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + output.WriteLine("scene centre emulated " + Centre(emulatedScene) + " native " + Centre(nativeScene)); + Assert.Equal(emulatedScene, nativeScene); + Assert.Equal(emulatedGlow, nativeGlow); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The seam's neutral body draws through the emulation layer and the native route does not: + /// the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. + /// + [SkippableFact] + public unsafe void TheNeutralBodyDrawsThroughTheEmulationLayerAndTheNativeRouteDoesNot() + { + using Session session = Open(); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long emulatedBefore = session.Seam.EmulationCallsForTests; + session.RunFrame(native: false); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + session.RunFrame(native: true); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The pass is declared for the mesh's own vertex layout, so the pipeline it draws through + /// is a mesh pipeline and one per target, reused across frames rather than rebuilt. + /// + [SkippableFact] + public unsafe void TheSkyPassBuildsOneMeshPipelineAndKeepsIt() + { + using Session session = Open(); + + session.RunFrame(native: true); + int after = session.Seam.NativePipelinesForTests; + session.RunFrame(native: true); + session.RunFrame(native: true); + + Assert.Equal(after, session.Seam.NativePipelinesForTests); + Assert.Equal(3, session.Seam.NativeMeshDrawsForTests); + GpuTest.AssertClean(session.Seam); + } + + // ---------------------------------------------------------------------- driving + + private static string Centre(byte[] pixels) + { + int i = (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + private Session Open() + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + + Session? session = Session.TryOpen(output, manifest); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// + /// The platform, its device, the Primary target the Opaque stage binds, the sky program and + /// the dome mesh, installed the way the client installs them and put back afterwards. + /// + private sealed class Session : IDisposable + { + /// The model-view matrix the seam carries: identity, so the dome's clip positions stand. + private static readonly float[] Identity = + { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + + public SkyPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Primary { get; private set; } = null!; + public MeshRef Dome { get; private set; } = null!; + public int SkyTexture { get; private set; } + public int GlowTexture { get; private set; } + + private ShaderProgram sky = null!; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + + public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-sky-" + Guid.NewGuid().ToString("N")); + var platform = new SkyPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.Primary = CreatePrimary(seam); + InstallFrameBuffers(platform, session.Primary); + + var program = new ShaderProgram { PassName = "sky" }; + Link(seam, program, "sky", new[] { "projectionMatrix", "modelViewMatrix" }); + session.sky = program; + + session.SkyTexture = Gradient(seam, 0); + session.GlowTexture = Gradient(seam, 1); + foreach (string name in seam.SamplerNamesOf(program.ProgramId)) + { + // The units the client's ShaderProgramSky setters bind: what the emulated route + // resolves its samplers through. The native route passes the handles instead. + int unit = program.uniformLocations.Count + seam.SamplerNamesOf(program.ProgramId).IndexOf(name); + seam.SetSamplerUnit(program.ProgramId, name, unit); + seam.BindTexture(unit, name == "sky" ? session.SkyTexture + : name == "glow" ? session.GlowTexture : session.SkyTexture); + } + + session.Dome = platform.UploadMesh(BuildDome()); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + // The mesh goes first: VAO's finalizer reaches for ScreenManager.Platform, which is + // about to be the client's again, and a live handle there would crash the test host. + if (Dome != null) Platform.DeleteMesh(Dome); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// + /// One frame of the Opaque stage at the point the sky renderer runs: Primary bound and + /// cleared, depth test off, no culling, the program's uniforms set, then the seam. + /// + public unsafe (byte[] Scene, byte[] Glow) RunFrame(bool native) + { + VulkanDevice seam = Seam; + Platform.NativeSkyEnabled = native; + + Platform.BeginFrame(); + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, 0b11); + seam.ClearColor(0, 0.125f, 0.25f, 0.5f, 1f); + seam.ClearColor(1, 0.75f, 0.5f, 0.25f, 1f); + seam.ClearDepth(1f); + + Platform.CurrentFrameBuffer = Primary; + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + seam.UseProgram(sky.ProgramId); + ShaderProgramBase.CurrentShaderProgram = sky; + seam.SetUniformMatrix(sky.ProgramId, sky.uniformLocations["projectionMatrix"], Identity); + seam.SetUniformMatrix(sky.ProgramId, sky.uniformLocations["modelViewMatrix"], Identity); + + Platform.RenderSkyDome(Dome, SkyTexture, GlowTexture, Identity); + + byte[] scene = Read(seam, Primary.ColorTextureIds[0]); + byte[] glow = Read(seam, Primary.ColorTextureIds[1]); + Platform.EndFrame(); + return (scene, glow); + } + + /// One attachment's pixels, read through a framebuffer that holds only it. + private unsafe byte[] Read(VulkanDevice seam, int texture) + { + int reader = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(reader, 1); + seam.BindFramebuffer(reader); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + seam.BindFramebuffer(Primary.FboId); + return pixels; + } + + // ----------------------------------------------------------------- fixtures + + /// Links one vanilla program as ShaderRegistry does and fills the locations the test sets. + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant()); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + /// Primary as the Opaque stage has it: scene at 0, glow at 1, plus depth. + private static FrameBufferRef CreatePrimary(VulkanDevice seam) + { + var primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + DepthTextureId = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false), + }; + for (int slot = 0; slot < primary.ColorTextureIds.Length; slot++) + { + seam.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + primary.ColorTextureIds[slot], 0); + } + seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + seam.SetDrawBuffers(primary.FboId, 0b11); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status); + return primary; + } + + private static void InstallFrameBuffers(SkyPlatform platform, FrameBufferRef primary) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = primary; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + } + + /// A small gradient, so a sampling difference between the routes would show. + private static unsafe int Gradient(VulkanDevice seam, int phase) + { + var pixels = new byte[8 * 8 * 4]; + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + int i = (y * 8 + x) * 4; + pixels[i] = (byte)(16 + x * 30 + phase * 7); + pixels[i + 1] = (byte)(32 + y * 25); + pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20); + pixels[i + 3] = 255; + } + } + fixed (byte* first = pixels) + { + return seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false); + } + } + + /// + /// The dome, as the sky program sees it: positions and a per-vertex colour, no UVs - + /// the shape SystemRenderSkyColor uploads (genIcosahedron with Uv nulled), reduced to + /// two triangles that cover the target so every pixel is comparable. + /// + private static MeshData BuildDome() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: false, withRgba: true, withFlags: false); + float[] positions = + { + -0.9f, -0.9f, 0.5f, + 0.9f, -0.9f, 0.5f, + 0.9f, 0.9f, 0.5f, + -0.9f, 0.9f, 0.5f, + }; + for (int i = 0; i < 4; i++) + { + mesh.AddVertexSkipTex(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + ColorUtil.WhiteArgb); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + } + + // ---------------------------------------------------------------- native shaders + + /// The sky program's manifest, built once for the whole class. + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + NativeShaderBuildResult one = builder.Build(source, "sky"); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), "optimum-native-sky-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index 9dccc2e8..a28277c8 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -136,9 +136,10 @@ public void NewStatsLinesCarryStableKeyValueTokens() "mask_restarts=10 feedback_splits=11 passes=12 plan_hits=13 plan_misses=14 in_pass_clears=15 " + "promoted_clears=16 standalone_clears=17 pass_splits=18 push_constants=19 storage_set_binds=20 " + "bindless_slots=21 bindless_placeholders=22 compute_passes=23 dispatches=24 " + - "native_passes=25 native_draws=26", + "native_passes=25 native_draws=26 native_fullscreen_draws=27 native_mesh_draws=28 " + + "native_instanced_draws=29 native_indirect_draws=30", VulkanStats.FormatCountersLine(new CounterSample(1, 2, 3, 4, 5, 6, 7, 8, 9, 2, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 20, 21, 22, 23, 24, 25, 26))); + 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30))); Assert.Equal( "stats.transients transient_mib=1.5 aliased_mib=0.5 heap_peak_mib=64.0 leases=3 aliased_leases=1 " + diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 7b85172b..534339af 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -373,6 +373,48 @@ public static void NoteNativeDraw() public static long NativePasses => Interlocked.Read(ref _nativePasses); public static long NativeDraws => Interlocked.Read(ref _nativeDraws); + private static long _nativeFullscreenDraws; + private static long _nativeMeshDraws; + private static long _nativeInstancedDraws; + private static long _nativeIndirectDraws; + private static long _intervalNativeFullscreenDraws; + private static long _intervalNativeMeshDraws; + private static long _intervalNativeInstancedDraws; + private static long _intervalNativeIndirectDraws; + + /// A native draw of the fullscreen triangle: a post or TAA chain pass. + public static void NoteNativeFullscreenDraw() + { + Interlocked.Increment(ref _nativeFullscreenDraws); + Interlocked.Increment(ref _intervalNativeFullscreenDraws); + } + + /// A native draw of one mesh (indexed or not), one instance: sky, entities, GUI quads. + public static void NoteNativeMeshDraw() + { + Interlocked.Increment(ref _nativeMeshDraws); + Interlocked.Increment(ref _intervalNativeMeshDraws); + } + + /// A native draw of one mesh with more than one instance: the particle pools. + public static void NoteNativeInstancedDraw() + { + Interlocked.Increment(ref _nativeInstancedDraws); + Interlocked.Increment(ref _intervalNativeInstancedDraws); + } + + /// A native indirect multi-draw out of the per-slot indirect ring: chunk pools and decals. + public static void NoteNativeIndirectDraw() + { + Interlocked.Increment(ref _nativeIndirectDraws); + Interlocked.Increment(ref _intervalNativeIndirectDraws); + } + + public static long NativeFullscreenDraws => Interlocked.Read(ref _nativeFullscreenDraws); + public static long NativeMeshDraws => Interlocked.Read(ref _nativeMeshDraws); + public static long NativeInstancedDraws => Interlocked.Read(ref _nativeInstancedDraws); + public static long NativeIndirectDraws => Interlocked.Read(ref _nativeIndirectDraws); + /// A draw's frame texture (set 0) resolved to its placeholder: nothing suitable bound. public static void NoteSamplerPlaceholder() { @@ -510,7 +552,11 @@ public static Result WaitDeviceIdle(Vk api, Device device) ComputePasses: Interlocked.Exchange(ref _computePasses, 0), Dispatches: Interlocked.Exchange(ref _dispatches, 0), NativePasses: Interlocked.Exchange(ref _intervalNativePasses, 0), - NativeDraws: Interlocked.Exchange(ref _intervalNativeDraws, 0)); + NativeDraws: Interlocked.Exchange(ref _intervalNativeDraws, 0), + NativeFullscreenDraws: Interlocked.Exchange(ref _intervalNativeFullscreenDraws, 0), + NativeMeshDraws: Interlocked.Exchange(ref _intervalNativeMeshDraws, 0), + NativeInstancedDraws: Interlocked.Exchange(ref _intervalNativeInstancedDraws, 0), + NativeIndirectDraws: Interlocked.Exchange(ref _intervalNativeIndirectDraws, 0)); double uploadMs = uploadTicks * 1000.0 / Stopwatch.Frequency; @@ -617,14 +663,17 @@ public static string FormatCountersLine(CounterSample counters) => "passes={12} plan_hits={13} plan_misses={14} in_pass_clears={15} promoted_clears={16} " + "standalone_clears={17} pass_splits={18} push_constants={19} storage_set_binds={20} " + "bindless_slots={21} bindless_placeholders={22} compute_passes={23} dispatches={24}" + - " native_passes={25} native_draws={26}", + " native_passes={25} native_draws={26} native_fullscreen_draws={27} " + + "native_mesh_draws={28} native_instanced_draws={29} native_indirect_draws={30}", counters.BlockingUploads, counters.Uploads, counters.Scopes, counters.Barriers, counters.RebarFallbacks, counters.DynamicState, counters.UniformRingUsed, counters.UniformRingCapacity, counters.BarrierCommands, counters.Frames > 0 ? counters.Barriers / (double)counters.Frames : 0.0, counters.MaskRestarts, counters.FeedbackSplits, counters.Passes, counters.PlanHits, counters.PlanMisses, counters.InPassClears, counters.PromotedClears, counters.StandaloneClears, counters.PassSplits, counters.PushConstantWrites, counters.StorageSetBinds, counters.BindlessSlots, counters.BindlessPlaceholders, - counters.ComputePasses, counters.Dispatches, counters.NativePasses, counters.NativeDraws); + counters.ComputePasses, counters.Dispatches, counters.NativePasses, counters.NativeDraws, + counters.NativeFullscreenDraws, counters.NativeMeshDraws, counters.NativeInstancedDraws, + counters.NativeIndirectDraws); private static long _lastSample; @@ -725,7 +774,11 @@ internal readonly record struct CounterSample( long ComputePasses = 0, long Dispatches = 0, long NativePasses = 0, - long NativeDraws = 0); + long NativeDraws = 0, + long NativeFullscreenDraws = 0, + long NativeMeshDraws = 0, + long NativeInstancedDraws = 0, + long NativeIndirectDraws = 0); /// The values on the stats.transients line. internal readonly record struct TransientSample( diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs new file mode 100644 index 00000000..adf37c7d --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), Phase 3b decision 5 +// stage 2: the first world system on the native device API, and the proof that its mesh-draw +// entry points work end to end. +// +// What it draws: the sky dome - the 250-unit icosahedron SystemRenderSkyColor renders the sky +// gradient onto, once per frame, first in the Opaque stage. +// Where the other side is: ClientPlatformAbstract.RenderSkyDome's neutral body, which is the +// RenderMesh(MeshRef) call this seam replaced and which the OpenGL path still runs +// (ClientPlatformWindows.RenderMesh -> GL.DrawElements). NativeSkyEnabled false takes that +// route on the Vulkan device too, which is what the differential test compares against. +// Target and slots: the framebuffer the Opaque stage bound (Primary), every bound colour slot +// in the pass so the scope is the one the emulated draw opens; sky.frag writes outColor at 0 +// and outGlow at 1 and the pipeline masks every other slot off, so the motion attachment and +// the G-buffer slots keep their contents exactly as they do on GL. +// State that is not obvious: +// - no depth test and no depth write: the caller has already called GlDisableDepthTest, and +// the dome is meant to sit behind everything; +// - no culling: the OpenGL body draws with whatever cull state the stage before it left (a +// shadow pass leaves it off, no shadow pass leaves back-face culling on), and the dome is +// a closed hull drawn without depth test, so None is the state that draws every triangle +// the GL path can draw; +// - blend disabled: sky.frag writes alpha 1 into both its outputs, so the blend the stage +// happens to have on makes no difference to the result; +// - the two textures are passed as handles, because a native pass resolves what it samples +// from handles rather than from the texture units the program's setters bound them to. +// What pins it: NativeSkyTests (old route against native route, pixels) and +// Optimum.Tests/native-sky-coverage-tests.cs (the lib seam). +public partial class VulkanClientPlatform +{ + /// + /// False runs the seam's neutral body - the OpenGL body's RenderMesh - on the Vulkan device + /// instead of the native pass: the old route the differential test compares against, in the + /// pattern of . + /// + internal bool NativeSkyEnabled { get; set; } = true; + + /// + /// The sky program's pipeline and the placements its draw writes through. "modelViewMatrix" + /// is the one value written per draw; everything else the client system set through the + /// program's own setters is already in the program's record shadow when the draw binds set 2. + /// + private readonly NativeMeshPass nativeSky = + new("sky", new[] { "modelViewMatrix" }, new[] { "sky", "glow" }); + + /// + /// A native program drawn with a mesh: its pipeline for one target and one mesh shape, and + /// the placements its draws write through. The fullscreen twin is NativeFullscreenPass in + /// VulkanClientPlatform.NativeBlit.cs; this one also keys on the mesh's vertex layout, + /// because that is part of the pipeline. + /// + private sealed class NativeMeshPass + { + public NativeMeshPass(string passName, string[] uniforms, string[] samplers) + { + PassName = passName; + UniformNames = uniforms; + SamplerNames = samplers; + Uniforms = new NativeUniform[uniforms.Length]; + Samplers = new NativeSamplerSlot[samplers.Length]; + } + + public string PassName { get; } + public string[] UniformNames { get; } + public string[] SamplerNames { get; } + + /// Resolved once per pipeline, never by name per draw. + public NativeUniform[] Uniforms; + public NativeSamplerSlot[] Samplers; + + public NativePipeline? Pipeline; + public RenderTargetFormats? Formats; + public int LayoutId = -1; + public bool Reported; + + public void Adopt(NativePipeline pipeline, RenderTargetFormats formats, int layoutId) + { + Pipeline = pipeline; + Formats = formats; + LayoutId = layoutId; + for (int i = 0; i < UniformNames.Length; i++) Uniforms[i] = pipeline.Uniform(UniformNames[i]); + for (int i = 0; i < SamplerNames.Length; i++) Samplers[i] = pipeline.Sampler(SamplerNames[i]); + } + } + + /// + /// The pipeline for one mesh program against one target and one mesh shape, rebuilt only + /// when the program was relinked, the target's formats changed or the mesh's layout did. + /// + private NativePipeline? NativeMeshPipelineFor(NativeMeshPass pass, ShaderProgramBase program, int framebufferId, + uint colorSlots, int layoutId, NativePipelineDescription description) + { + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, colorSlots); + if (formats == null) return null; + + if (pass.Pipeline != null && pass.Pipeline.ProgramId == program.ProgramId && + formats.Equals(pass.Formats) && pass.LayoutId == layoutId && device.IsNativePipelineLive(pass.Pipeline)) + { + return pass.Pipeline; + } + + description.ProgramId = program.ProgramId; + description.PassName = pass.PassName; + description.VertexLayoutId = layoutId; + description.Targets = formats; + + NativePipeline? pipeline = device.RequestNativePipeline(description, out string error); + if (pipeline == null) + { + if (!pass.Reported) + { + pass.Reported = true; + Logger.Warning("Optimum: no native pipeline for '{0}': {1}", pass.PassName, error); + } + pass.Pipeline = null; + return null; + } + + pass.Reported = false; + pass.Adopt(pipeline, formats, layoutId); + return pipeline; + } + + /// Every bound colour slot of a target: the scope the emulated draw would open. + private static uint NativeAllColorSlots(FrameBufferRef target) + { + int count = target.ColorTextureIds?.Length ?? 0; + return count >= 32 ? uint.MaxValue : (1u << count) - 1u; + } + + /// + /// Opaque-stage fixed state for a world pass that draws over everything: no depth, no + /// culling, no blending, one entry per colour attachment so the slots the program does not + /// write are masked off rather than left to Vulkan's undefined contents (rule 9). + /// + private static AttachmentBlend[] OpaqueSlots(RenderTargetFormats formats) + { + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = AttachmentBlend.Default; + return blend; + } + + /// The sky dome's draw: the native pass, or the neutral body's RenderMesh. + public override void RenderSkyDome(MeshRef skyDome, int skyTextureId, int glowTextureId, float[] modelViewMatrix) + { + if (!NativeSkyEnabled || device == null || skyDome == null) + { + base.RenderSkyDome(skyDome!, skyTextureId, glowTextureId, modelViewMatrix); + return; + } + + FrameBufferRef target = CurrentFrameBuffer; + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + var vao = skyDome as VAO; + if (target == null || program == null || vao == null || vao.VaoId == 0 || vao.Disposed) + { + base.RenderSkyDome(skyDome, skyTextureId, glowTextureId, modelViewMatrix); + return; + } + + int layoutId = device.NativeMeshLayoutId(vao.VaoId); + if (layoutId < 0) + { + base.RenderSkyDome(skyDome, skyTextureId, glowTextureId, modelViewMatrix); + return; + } + + uint slots = NativeAllColorSlots(target); + RenderTargetFormats? formats = device.NativeTargetFormats(target.FboId, slots); + if (formats == null) + { + base.RenderSkyDome(skyDome, skyTextureId, glowTextureId, modelViewMatrix); + return; + } + + NativePipeline? pipeline = NativeMeshPipelineFor(nativeSky, program, target.FboId, slots, layoutId, + new NativePipelineDescription + { + Blend = OpaqueSlots(formats), + DepthTest = false, + DepthWrite = false, + Cull = CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + }); + if (pipeline == null) + { + base.RenderSkyDome(skyDome, skyTextureId, glowTextureId, modelViewMatrix); + return; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + Rect2D viewport = device.NativeCurrentViewport; + if (device.BeginNativePass(new NativePassDescription + { + Name = "Sky/" + target.FboId, + FramebufferId = target.FboId, + ColorSlots = slots, + Reads = new[] { skyTextureId, glowTextureId }, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + })) + { + // The one per-draw write: the dome follows the player, so the model-view matrix is + // this draw's and nothing else's. Everything else the system set is already in the + // program's record, which the draw snapshots into this frame's uniform ring. + if (modelViewMatrix != null && modelViewMatrix.Length >= 16) + { + device.WriteNative(pipeline, nativeSky.Uniforms[0], modelViewMatrix.AsSpan(0, 16)); + } + device.DrawNativeMesh(pipeline, vao.VaoId, new[] + { + new NativeTexture(nativeSky.Samplers[0], skyTextureId), + new NativeTexture(nativeSky.Samplers[1], glowTextureId), + }); + } + device.EndNativePass(); + + // Every renderer after this one in the Opaque stage draws into the same target through + // the emulated path, so the stage's own pass context is declared again - the sky pass + // replaced it, exactly as the TAA resolve's native pass does with the context it + // interrupts. + device.BindFramebuffer(target.FboId); + SetPassContext(outer, outerFlags); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 0805f946..5dcff836 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -137,6 +137,8 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "EndRenderStage", new[] { "EnumRenderStage" }), // Phase 2 step 2: the TAA post methods declare their frame-graph passes. new(true, "RenderOptimumSkyMotion", Array.Empty()), + // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. + new(true, "RenderSkyDome", new[] { "MeshRef", "Int32", "Int32", "Single[]" }), new(true, "RenderOptimumTaaResolve", Array.Empty()), new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), // Phase 3b stage 1: the two TAA passes' draw seams, which the native chain replaces. diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 6307e00e..6cfc176a 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -68,8 +68,38 @@ internal sealed class NativePipelineDescription public bool DepthWrite; public CompareOp DepthCompare = CompareOp.Less; public CullModeFlags Cull = CullModeFlags.None; + + /// + /// The winding a front face has. The game never calls glFrontFace, so every vanilla system + /// states ; a native system that needs the other one + /// says so here rather than through a tracked toggle. + /// + public FrontFace FrontFace = GlStateTracker.FrontFace; + public PrimitiveTopology Topology = PrimitiveTopology.TriangleList; + /// Fill for every vanilla system; Line is the wireframe debug render's. + public PolygonMode PolygonMode = PolygonMode.Fill; + + /// The width a line-topology draw rasterizes with (autocamera's debug path sets 2). + public float LineWidth = 1.0f; + + /// + /// The vertex layout the pipeline's draws feed it with: + /// for a pass that generates its vertices (the fullscreen triangle), otherwise the layout id of + /// the mesh the system draws (). It is part of the + /// pipeline key, so a mesh pipeline can never be handed a fullscreen one. + /// + public int VertexLayoutId = MeshManager.EmptyLayoutId; + + /// + /// The draws sample the depth attachment of the target they draw into, with depth writes off - + /// what the liquid pass does to fade water at its edges. The scope then holds depth read-only + /// for the draw. Only legal with false; a fullscreen pass leaves it + /// false and sampling its own attachment stays an error. + /// + public bool SamplesBoundDepth; + /// The attachment formats of the target the pipeline renders into. public RenderTargetFormats Targets = null!; } @@ -202,10 +232,23 @@ public sealed unsafe partial class VulkanDevice private long _emulationCallsInNativePasses; private long _nativePasses; private long _nativeDraws; + private long _nativeFullscreenDraws; + private long _nativeMeshDraws; + private long _nativeInstancedDraws; + private long _nativeIndirectDraws; + /// + /// The identity of a native pipeline: every field of its description that changes what a draw + /// through it does. The vertex layout is in it, so a mesh pipeline never collides with the + /// fullscreen one of the same program, formats and blend; so are the dynamic pieces (front + /// face, line width) that are not in , because the cached + /// carries the description its draws emit. + /// private readonly record struct NativePipelineCacheKey( int ProgramId, int FormatsId, int BlendId, bool DepthTest, bool DepthWrite, - CompareOp DepthCompare, CullModeFlags Cull, PrimitiveTopology Topology); + CompareOp DepthCompare, CullModeFlags Cull, PrimitiveTopology Topology, + int VertexLayoutId, PolygonMode PolygonMode, FrontFace FrontFace, float LineWidth, + bool SamplesBoundDepth); /// Calls into the GL-emulation layer (state, units, uniforms by location, draws). Tests only. internal long EmulationCallsForTests => _emulationCalls; @@ -213,10 +256,16 @@ private readonly record struct NativePipelineCacheKey( /// Calls into the GL-emulation layer while a native pass was open: must stay 0. Tests only. internal long EmulationCallsInNativePassesForTests => _emulationCallsInNativePasses; - /// Native passes declared and native draws recorded. Tests only. + /// Native passes declared and native draws recorded (every kind). Tests only. internal long NativePassesForTests => _nativePasses; internal long NativeDrawsForTests => _nativeDraws; + /// Native draws by kind: the fullscreen triangle, a mesh, an instanced mesh, a multi-draw. Tests only. + internal long NativeFullscreenDrawsForTests => _nativeFullscreenDraws; + internal long NativeMeshDrawsForTests => _nativeMeshDraws; + internal long NativeInstancedDrawsForTests => _nativeInstancedDraws; + internal long NativeIndirectDrawsForTests => _nativeIndirectDraws; + /// Distinct native pipelines this device holds. Tests only. internal int NativePipelinesForTests => _nativePipelines.Count; @@ -272,6 +321,12 @@ private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) internal string NativeVariantOf(int programId) => _programVariants.TryGetValue(programId, out string? key) ? key : ""; + /// + /// The interned vertex layout of a mesh, which a native system states on the pipeline it + /// draws that mesh through. -1 for a mesh that does not exist. + /// + internal int NativeMeshLayoutId(int meshId) => _meshes.LayoutIdOf(meshId); + private int ResolveNativeFramebuffer(int framebufferId) => framebufferId == PassDeclaration.DefaultFramebuffer ? _defaultFramebuffer : framebufferId; @@ -308,6 +363,16 @@ private int ResolveNativeFramebuffer(int framebufferId) => error = "the request names no target formats"; return null; } + if (description.SamplesBoundDepth && description.DepthWrite) + { + error = "a pipeline that samples the bound depth attachment cannot also write depth"; + return null; + } + if (description.VertexLayoutId < 0 || description.VertexLayoutId >= _meshes.LayoutCount) + { + error = "vertex layout " + description.VertexLayoutId + " does not exist"; + return null; + } ColorWriteTier tier = _context.Capabilities.ColorWriteTier; bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; @@ -339,26 +404,29 @@ private int ResolveNativeFramebuffer(int framebufferId) => var cacheKey = new NativePipelineCacheKey(description.ProgramId, formatsId, bakedBlendId, description.DepthTest, description.DepthWrite, description.DepthCompare, - description.Cull, description.Topology); + description.Cull, description.Topology, description.VertexLayoutId, description.PolygonMode, + description.FrontFace, description.LineWidth, description.SamplesBoundDepth); if (_nativePipelines.TryGetValue(cacheKey, out NativePipeline? cached) && ReferenceEquals(cached.Program, program)) { return cached; } - // A native draw generates its vertices, so the layout is the reserved empty one plus - // the constant attribute defaults GL promises for anything the program declares. - VertexLayoutDescription vertexLayout = _meshes.LayoutOf(MeshManager.EmptyLayoutId) + // The system's own vertex layout - the reserved empty one for a pass that generates its + // vertices, the mesh's interned layout for a mesh draw - plus the constant attribute + // defaults GL promises for anything the program declares and the layout does not carry. + VertexLayoutDescription vertexLayout = _meshes.LayoutOf(description.VertexLayoutId) .WithDefaultsFor(program.Interface.VertexInputs); // The blend id is negative so a native key can never collide with an emulated one, - // whose ids come from the tracker's interners. + // whose ids come from the tracker's interners. The vertex layout is in the key, so a + // mesh pipeline and a fullscreen pipeline of the same program are never the same entry. var key = new PipelineKey( ProgramId: description.ProgramId, - VertexLayoutId: MeshManager.EmptyLayoutId, + VertexLayoutId: description.VertexLayoutId, TargetFormatsId: formatsId, BlendId: -(bakedBlendId + 1), - PolygonMode: PolygonMode.Fill, + PolygonMode: description.PolygonMode, TopologyClass: GlEnums.TopologyClassOf(description.Topology)); var request = new GraphicsPipelineCache.PipelineRequest @@ -367,7 +435,7 @@ private int ResolveNativeFramebuffer(int framebufferId) => VertexLayout = vertexLayout, Targets = description.Targets, Blend = baked, - PolygonMode = PolygonMode.Fill, + PolygonMode = description.PolygonMode, Topology = description.Topology, }; @@ -516,15 +584,67 @@ internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, float WriteNative(pipeline, uniform, new ReadOnlySpan(values, 4 * sizeof(float))); } + /// + /// A float run at a placement: a matrix, a vector array, a kernel. The model-view matrix a + /// world system writes before each of its draws goes through here, which is what makes the + /// write draw-frequency - a record member is snapshotted into this frame's uniform ring when + /// the draw binds set 2, a push member is pushed with the draw's push block. + /// + internal void WriteNative(NativePipeline pipeline, NativeUniform uniform, ReadOnlySpan values) + { + if (values.IsEmpty) return; + fixed (float* first = values) + { + WriteNative(pipeline, uniform, new ReadOnlySpan(first, values.Length * sizeof(float))); + } + } + // ------------------------------------------------------------------------ draws /// /// Records the fullscreen triangle of a native pass: the pass's reads are made /// shader-readable, the sampled textures resolve to bindless slots straight from their /// handles and sampler state, and the pipeline's fixed state is what the draw runs with. + /// + /// The mesh-drawing siblings are in VulkanDevice.NativeMesh.cs; all of them share + /// , which is this method's old body. /// internal bool DrawNativeFullscreen(NativePipeline pipeline, ReadOnlySpan textures) { + if (!BeginNativeDraw(pipeline, textures, 0, out CommandBuffer commandBuffer, out VulkanFramebuffer? target)) + { + return false; + } + + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.Fullscreen, pipeline.ProgramId, target!.Id, 0)); + if (RenderTrace.Enabled) + { + RenderTrace.Write("native fullscreen program=" + pipeline.ProgramId + " pass='" + _nativePass!.Name + + "' target=" + target.Id); + } + _context.Api.CmdDraw(commandBuffer, 3, 1, 0, 0); + NoteNativeDraw(NativeDrawKind.Fullscreen); + return true; + } + + /// + /// Everything a native draw needs before its draw command: the pass and pipeline are + /// checked, the textures the draw samples are put into the layout a shader read needs, + /// the rendering scope is opened, the pipeline is bound, the sampled textures resolve to + /// bindless slots straight from their handles and sampler state, the program's sets are + /// bound (with , so a chunk's storage-buffer vertex fetch and an + /// entity's animation block resolve to this draw's mesh) and the dynamic state is emitted. + /// + /// Shared by the fullscreen draw and every mesh draw. None of it reads the GL state + /// tracker, a texture unit or a draw-buffer mask. + /// + private bool BeginNativeDraw(NativePipeline pipeline, ReadOnlySpan textures, int meshId, + out CommandBuffer commandBuffer, out VulkanFramebuffer? target) + { + commandBuffer = default; + target = null; + if (!_frameActive || _nativePass == null || _nativeTarget == null) { if (RenderTrace.Enabled) RenderTrace.Write("native draw skipped: no open native pass"); @@ -532,8 +652,8 @@ internal bool DrawNativeFullscreen(NativePipeline pipeline, ReadOnlySpan= 0) { SamplerBindingValue value = texture == null ? default : new SamplerBindingValue((uint)sampler.FrameBinding, texture.View, - _textures.Samplers.Get(BindlessKinds.EffectiveState(sampling, sampler.Kind)), texture.Id); + _textures.Samplers.Get(BindlessKinds.EffectiveState(sampling, sampler.Kind)), texture.Id, layout); if (texture == null) VulkanStats.NoteSamplerPlaceholder(); lock (_frameTextureLock) _frameTextureValues[FrameTextureIndex(sampler.FrameBinding)] = value; continue; } - uint slot = _bindless!.Resolve(texture, sampler.Kind, sampling); + uint slot = _bindless!.Resolve(texture, sampler.Kind, sampling, layout); VulkanStats.NoteBindlessSlotResolution(); BitConverter.TryWriteBytes(_pushShadow.AsSpan(sampler.PushOffset, ProgramInterfaceLayout.SlotBytes), slot); } - BindProgramSets(commandBuffer, program, 0); - EmitNativeDynamicState(commandBuffer, target, pass, pipeline); + BindProgramSets(commandBuffer, program, meshId); + EmitNativeDynamicState(commandBuffer, bound, pass, pipeline); - Checkpoint(commandBuffer, - CheckpointMarker.Draw(CheckpointKind.Fullscreen, program.ProgramId, target.Id, 0)); - if (RenderTrace.Enabled) - { - RenderTrace.Write("native fullscreen program=" + program.ProgramId + " pass='" + pass.Name + - "' target=" + target.Id); - } - api.CmdDraw(commandBuffer, 3, 1, 0, 0); - _nativeDraws++; - VulkanStats.NoteNativeDraw(); + target = bound; return true; } @@ -704,7 +836,7 @@ private void EmitNativeDynamicState(CommandBuffer commandBuffer, VulkanFramebuff Viewport = new Viewport(pass.ViewportX, pass.ViewportY, width, height, 0f, 1f), Scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), CullMode = description.Cull, - FrontFace = GlStateTracker.FrontFace, + FrontFace = description.FrontFace, Topology = description.Topology, DepthTest = description.DepthTest, DepthWrite = description.DepthWrite, @@ -717,7 +849,7 @@ private void EmitNativeDynamicState(CommandBuffer commandBuffer, VulkanFramebuff StencilCompareMask = 0xFF, StencilWriteMask = 0xFF, StencilReference = 0, - LineWidth = 1.0f, + LineWidth = description.LineWidth, ColorWrite = colorWrite, BlendStateId = dynamicBlend ? pipeline.DynamicBlendId : 0, }; diff --git a/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs new file mode 100644 index 00000000..d012b398 --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs @@ -0,0 +1,203 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan; + +/// Which draw command a native draw recorded, so the stats separate the kinds. +internal enum NativeDrawKind : byte +{ + /// The three-vertex fullscreen triangle a post pass generates in the shader. + Fullscreen = 0, + /// One indexed or non-indexed draw of one mesh: sky, entities, GUI quads. + Mesh = 1, + /// One mesh drawn with per-instance attributes: the particle pools. + Instanced = 2, + /// One indirect multi-draw out of the per-slot indirect ring: chunk pools and decals. + Indirect = 3, +} + +/// +/// Mesh draws on the native device API (docs/vulkan-native-render-systems.md, decision 4: +/// "fullscreen triangle, mesh, multi-draw or instanced"). +/// +/// Stage 1 recorded fullscreen draws only. World systems are mesh draws, so these four entry +/// points join on the same preparation +/// (BeginNativeDraw) and swap the draw command for the mesh manager's: +/// +/// - the mesh's own vertex and index buffers, bound by , never a +/// second mesh path of this API's own; +/// - the mesh's interned vertex layout, stated on the pipeline +/// () and part of the pipeline key, so +/// a mesh pipeline and a fullscreen pipeline are never the same cache entry; +/// - the real mesh id threaded into BindProgramSets, which is what lets a chunk's +/// storage-buffer vertex fetch and an entity's Animation block resolve per draw +/// instead of against the fullscreen path's hardcoded 0; +/// - multi-draw through the existing per-slot indirect ring (AllocateIndirect), the same +/// regions the emulated allocates, so the two paths cannot +/// disagree about the ring's bookkeeping. +/// +/// What pins it: NativeMeshDrawTests (the ring's use and the pipeline-key dimensions) and +/// NativeSkyTests (the first ported system, old route against native route). +/// +public sealed unsafe partial class VulkanDevice +{ + /// Counts one native draw, once in the total and once in its own kind. + private void NoteNativeDraw(NativeDrawKind kind) + { + _nativeDraws++; + VulkanStats.NoteNativeDraw(); + switch (kind) + { + case NativeDrawKind.Fullscreen: + _nativeFullscreenDraws++; + VulkanStats.NoteNativeFullscreenDraw(); + break; + case NativeDrawKind.Mesh: + _nativeMeshDraws++; + VulkanStats.NoteNativeMeshDraw(); + break; + case NativeDrawKind.Instanced: + _nativeInstancedDraws++; + VulkanStats.NoteNativeInstancedDraw(); + break; + case NativeDrawKind.Indirect: + _nativeIndirectDraws++; + VulkanStats.NoteNativeIndirectDraw(); + break; + } + } + + /// + /// One indexed draw of one mesh: the sky dome, an entity shape, a GUI quad. The emulated + /// twin is , whose OpenGL body is + /// ClientPlatformWindows.RenderMesh(MeshRef). + /// + internal bool DrawNativeMesh(NativePipeline pipeline, int meshId, ReadOnlySpan textures) => + DrawNativeMeshInstanced(pipeline, meshId, 1, textures); + + /// + /// One indexed draw of one mesh with instances, the + /// per-instance attributes coming from the mesh's own instanced bindings (the particle + /// pools). The emulated twin is , whose OpenGL body is + /// ClientPlatformWindows.RenderMeshInstanced. + /// + internal bool DrawNativeMeshInstanced(NativePipeline pipeline, int meshId, int instanceCount, + ReadOnlySpan textures) + { + if (instanceCount <= 0) return false; + if (!NativeMeshIsDrawable(pipeline, meshId, indexed: true, out VulkanMesh? mesh)) return false; + if (!BeginNativeDraw(pipeline, textures, meshId, out CommandBuffer commandBuffer, out VulkanFramebuffer? target)) + { + return false; + } + + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.Draw, pipeline.ProgramId, target!.Id, meshId)); + if (RenderTrace.Enabled) + { + RenderTrace.Write("native mesh=" + meshId + " program=" + pipeline.ProgramId + " pass='" + + _nativePass!.Name + "' target=" + target.Id + " indices=" + mesh!.IndexCount + + " instances=" + instanceCount); + } + + _meshes.Bind(commandBuffer, mesh!); + _context.Api.CmdDrawIndexed(commandBuffer, (uint)mesh!.IndexCount, (uint)instanceCount, 0, 0, 0); + NoteNativeDraw(instanceCount > 1 ? NativeDrawKind.Instanced : NativeDrawKind.Mesh); + return true; + } + + /// + /// One non-indexed draw of a mesh's vertex buffers: vertices, + /// instances, no index buffer. GL's glDrawArrays, for a + /// system whose geometry carries no index array. + /// + internal bool DrawNativeMeshArrays(NativePipeline pipeline, int meshId, int vertexCount, int instanceCount, + ReadOnlySpan textures) + { + if (vertexCount <= 0 || instanceCount <= 0) return false; + if (!NativeMeshIsDrawable(pipeline, meshId, indexed: false, out VulkanMesh? mesh)) return false; + if (!BeginNativeDraw(pipeline, textures, meshId, out CommandBuffer commandBuffer, out VulkanFramebuffer? target)) + { + return false; + } + + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.Draw, pipeline.ProgramId, target!.Id, meshId)); + if (RenderTrace.Enabled) + { + RenderTrace.Write("native mesh arrays=" + meshId + " program=" + pipeline.ProgramId + " pass='" + + _nativePass!.Name + "' target=" + target.Id + " vertices=" + vertexCount + + " instances=" + instanceCount); + } + + _meshes.Bind(commandBuffer, mesh!); + _context.Api.CmdDraw(commandBuffer, (uint)vertexCount, (uint)instanceCount, 0, 0); + NoteNativeDraw(instanceCount > 1 ? NativeDrawKind.Instanced : NativeDrawKind.Mesh); + return true; + } + + /// + /// The multi-draw one mesh pool issues per pass - every surviving range of a chunk pool or + /// the decal pool in one command - through the existing per-slot indirect ring. The emulated + /// twin is , whose OpenGL body is + /// ClientPlatformWindows.RenderMesh(MeshRef, int[], int[], int) (glMultiDrawElements). + /// + /// holds GL's 64-bit byte offsets as pairs of ints, as + /// MeshDataPool passes them; is the + /// one place that converts them. + /// + internal bool DrawNativeMeshMulti(NativePipeline pipeline, int meshId, int[] indicesStarts, int[] indicesSizes, + int groupCount, ReadOnlySpan textures) + { + if (groupCount <= 0 || indicesStarts == null || indicesSizes == null) return false; + if (!NativeMeshIsDrawable(pipeline, meshId, indexed: true, out VulkanMesh? mesh)) return false; + if (!BeginNativeDraw(pipeline, textures, meshId, out CommandBuffer commandBuffer, out VulkanFramebuffer? target)) + { + return false; + } + + Checkpoint(commandBuffer, + CheckpointMarker.Draw(CheckpointKind.DrawMulti, pipeline.ProgramId, target!.Id, meshId)); + + VulkanBuffer indirect = AllocateIndirect(groupCount, out ulong indirectOffset); + if (RenderTrace.Enabled) + { + RenderTrace.Write("native multidraw mesh=" + meshId + " program=" + pipeline.ProgramId + " pass='" + + _nativePass!.Name + "' target=" + target.Id + " groups=" + groupCount + + " indirectOffset=" + indirectOffset); + } + + _meshes.DrawMulti(commandBuffer, meshId, indicesStarts, indicesSizes, groupCount, indirect, indirectOffset); + NoteNativeDraw(NativeDrawKind.Indirect); + return true; + } + + /// + /// Whether the mesh exists, carries what the draw needs, and is the shape the pipeline was + /// built for. The layout check is the one that matters: a pipeline built for another mesh's + /// layout would read attributes out of buffers that are not there, which no validation layer + /// can see because the descriptors are all valid. + /// + private bool NativeMeshIsDrawable(NativePipeline pipeline, int meshId, bool indexed, out VulkanMesh? mesh) + { + mesh = _meshes.Get(meshId); + if (mesh == null) + { + if (RenderTrace.Enabled) RenderTrace.Write("native draw skipped: no mesh " + meshId); + return false; + } + if (indexed && (mesh.Indices == null || mesh.IndexCount == 0)) + { + if (RenderTrace.Enabled) RenderTrace.Write("native draw skipped: mesh " + meshId + " has no indices"); + return false; + } + if (mesh.LayoutId != pipeline.Description.VertexLayoutId) + { + AddDiagnostic("native draw of mesh " + meshId + " (vertex layout " + mesh.LayoutId + + ") through a pipeline built for vertex layout " + pipeline.Description.VertexLayoutId); + return false; + } + return true; + } +} diff --git a/Optimum.Tests/fsr-pipeline-coverage-tests.cs b/Optimum.Tests/fsr-pipeline-coverage-tests.cs index 1d743836..89019cb9 100644 --- a/Optimum.Tests/fsr-pipeline-coverage-tests.cs +++ b/Optimum.Tests/fsr-pipeline-coverage-tests.cs @@ -96,7 +96,8 @@ public void TheVulkanBlitRunsNativelyWithOnePassPerWrittenTarget() Assert.Contains("internal bool BeginNativePass(NativePassDescription pass)", device); Assert.Contains("internal bool DrawNativeFullscreen(NativePipeline pipeline, ReadOnlySpan textures)", device); Assert.Contains("VulkanStats.NoteNativePass();", device); - Assert.Contains("VulkanStats.NoteNativeDraw();", device); + // Counted by kind since stage 2; the fullscreen draw takes the Fullscreen one. + Assert.Contains("NoteNativeDraw(NativeDrawKind.Fullscreen);", device); string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); Assert.Contains("native_passes={25} native_draws={26}", stats); diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs new file mode 100644 index 00000000..03bb81b5 --- /dev/null +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -0,0 +1,217 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The world systems on the native device API (docs/vulkan-native-render-systems.md, decision 5 +/// stage 2 onwards). Each system that moves gets a seam in the library whose neutral body is the +/// OpenGL body's own draw, a patcher listing for that seam, and a Vulkan override that records a +/// native pass with the old route kept reachable behind a switch. +/// +/// The sky dome is the first. Later stages (chunks, entities, particles and decals, GUI) add +/// their seams to the same lists here rather than to a file named after the stage. +/// +public class NativeWorldSystemsCoverageTests +{ + private const string SkyPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs"; + private const string DeviceMeshFile = "Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"; + private const string DeviceNativeFile = "Optimum.Render.Vulkan/VulkanDevice.Native.cs"; + + /// + /// The seam exists on the platform abstraction, and its neutral body is exactly the + /// RenderMesh call it replaced - which is what makes "OFF is vanilla" true for OpenGL, + /// because ClientPlatformWindows does not override it at all. + /// + [Fact] + public void TheSkyDomeHasASeamWhoseNeutralBodyIsTheDrawItReplaced() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + Assert.Contains( + "public virtual void RenderSkyDome(MeshRef skyDome, int skyTextureId, int glowTextureId, float[] modelViewMatrix)", + platform); + Assert.Contains("RenderMesh(skyDome);", platform); + + // The OpenGL platform leaves it alone: nothing about the GL path changes. + string windows = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.DoesNotContain("RenderSkyDome", windows); + } + + /// + /// SystemRenderSkyColor draws through the seam and hands it the values a native pass cannot + /// read off the GL state: the two textures and the model-view matrix. + /// + [Fact] + public void TheSkyRendererDrawsThroughTheSeam() + { + string system = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs"); + + Assert.Contains( + "game.Platform.RenderSkyDome(skyIcosahedron, game.skyTextureId, game.skyGlowTextureId, game.CurrentModelViewMatrix);", + system); + Assert.DoesNotContain("game.Platform.RenderMesh(skyIcosahedron);", system); + } + + /// Every new or changed lib member is listed for the Cecil transplant. + [Fact] + public void TheSeamAndItsCallerAreListedForTheTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"RenderSkyDome\"", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.SystemRenderSkyColor\", \"OnRenderFrame3D\", 1", patcher); + } + + /// + /// The Vulkan platform records the sky as a native pass, states its own fixed state rather + /// than reading the tracker's, and keeps the neutral body reachable behind a switch in the + /// pattern of NativeBlitEnabled. + /// + [Fact] + public void TheVulkanPlatformRecordsTheSkyNativelyAndKeepsTheOldRoute() + { + string sky = Read(SkyPlatformFile); + + Assert.Contains("internal bool NativeSkyEnabled { get; set; } = true;", sky); + Assert.Contains("public override void RenderSkyDome(", sky); + Assert.Contains("base.RenderSkyDome(", sky); + Assert.Contains("device.BeginNativePass(", sky); + Assert.Contains("device.DrawNativeMesh(", sky); + Assert.Contains("device.EndNativePass();", sky); + + // The state the pass states outright, and the per-draw write. + Assert.Contains("DepthTest = false", sky); + Assert.Contains("DepthWrite = false", sky); + Assert.Contains("Cull = CullModeFlags.None", sky); + Assert.Contains("device.WriteNative(pipeline, nativeSky.Uniforms[0]", sky); + + // The pipeline is built for the mesh's own vertex layout, not the fullscreen one. + Assert.Contains("device.NativeMeshLayoutId(", sky); + Assert.Contains("VertexLayoutId = layoutId", sky); + } + + /// + /// The device records mesh draws through the mesh manager it already has - indexed, + /// non-indexed, instanced and multi-draw through the per-slot indirect ring - and never + /// builds a second mesh path. + /// + [Fact] + public void TheDeviceRecordsEveryMeshDrawKindThroughTheExistingMeshPath() + { + string mesh = Read(DeviceMeshFile); + + foreach (string entry in new[] + { + "internal bool DrawNativeMesh(", + "internal bool DrawNativeMeshInstanced(", + "internal bool DrawNativeMeshArrays(", + "internal bool DrawNativeMeshMulti(", + }) + { + Assert.Contains(entry, mesh); + } + + // The existing machinery, reused: the mesh manager binds and draws, and the multi-draw + // allocates from the same indirect ring the emulated DrawMeshMulti allocates from. + Assert.Contains("_meshes.Bind(commandBuffer, mesh!);", mesh); + Assert.Contains("_meshes.DrawMulti(commandBuffer, meshId,", mesh); + Assert.Contains("AllocateIndirect(groupCount, out ulong indirectOffset)", mesh); + + // The real mesh id reaches BindProgramSets, which is what makes a chunk's storage-buffer + // vertex fetch and an entity's animation block resolve per draw. + Assert.Contains("BeginNativeDraw(pipeline, textures, meshId,", mesh); + + // Counted apart from fullscreen draws. + foreach (string counter in new[] + { + "NoteNativeFullscreenDraw", "NoteNativeMeshDraw", + "NoteNativeInstancedDraw", "NoteNativeIndirectDraw", + }) + { + Assert.Contains(counter, mesh); + } + + string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); + Assert.Contains("native_fullscreen_draws=", stats); + Assert.Contains("native_mesh_draws=", stats); + Assert.Contains("native_instanced_draws=", stats); + Assert.Contains("native_indirect_draws=", stats); + } + + /// + /// The pipeline description carries what a mesh draw needs and a fullscreen draw did not, + /// and every one of those dimensions is in the key, so a mesh pipeline can never be handed + /// out for a fullscreen request or the other way round. + /// + [Fact] + public void ThePipelineDescriptionAndKeyCarryTheMeshDrawState() + { + string native = Read(DeviceNativeFile); + + foreach (string field in new[] + { + "public FrontFace FrontFace = GlStateTracker.FrontFace;", + "public PolygonMode PolygonMode = PolygonMode.Fill;", + "public float LineWidth = 1.0f;", + "public int VertexLayoutId = MeshManager.EmptyLayoutId;", + "public bool SamplesBoundDepth;", + }) + { + Assert.Contains(field, native); + } + + string key = Section(native, "private readonly record struct NativePipelineCacheKey(", ");"); + foreach (string dimension in new[] + { + "VertexLayoutId", "PolygonMode", "FrontFace", "LineWidth", "SamplesBoundDepth", + }) + { + Assert.Contains(dimension, key); + } + + // The pipeline-cache key takes the layout and polygon mode from the description too, + // rather than the fullscreen constants stage 1 baked in. + Assert.Contains("VertexLayoutId: description.VertexLayoutId,", native); + Assert.Contains("PolygonMode: description.PolygonMode,", native); + Assert.Contains("_meshes.LayoutOf(description.VertexLayoutId)", native); + } + + // ------------------------------------------------------------------------ helpers + + private static string Section(string source, string from, string to) + { + int start = source.IndexOf(from, StringComparison.Ordinal); + Assert.True(start >= 0, "not found: " + from); + int end = source.IndexOf(to, start, StringComparison.Ordinal); + Assert.True(end > start, "end not found after: " + from); + return source.Substring(start, end - start); + } + + private static string ReadPatchedOrSource(string patchPath, string sourcePath) + { + string? resolvedPatch = TryFind(patchPath); + return resolvedPatch != null ? PatchReader.ReadPatchedContent(resolvedPatch) : Read(sourcePath); + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + private static string? TryFind(string relativePath) + { + try + { + return PatchReader.FindRepositoryFile(relativePath); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 89fba533..90fd6a93 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -272,7 +272,7 @@ unchanged from earlier builds; the other six carry stable `key=value` tokens: stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= -stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= push_constants= storage_set_binds= bindless_slots= bindless_placeholders= compute_passes= dispatches= native_passes= native_draws= +stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= push_constants= storage_set_binds= bindless_slots= bindless_placeholders= compute_passes= dispatches= native_passes= native_draws= native_fullscreen_draws= native_mesh_draws= native_instanced_draws= native_indirect_draws= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draws_skipped= pending= cache_bytes= saves= @@ -319,7 +319,12 @@ stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draw flushed with no rendering scope open; not part of `passes`) and `dispatches` (vkCmdDispatch calls). Native render systems (Phase 3b): `native_passes` (passes declared through the native device API, with explicit writes and reads instead of a draw-buffer mask) and `native_draws` (draws - recorded through a native pipeline, without the GL state tracker or a texture unit). + recorded through a native pipeline, without the GL state tracker or a texture unit). Stage 2 counts + those draws by kind as well: `native_fullscreen_draws` (the fullscreen triangle of a post or TAA + pass), `native_mesh_draws` (one mesh, indexed or not, one instance: sky, entities, GUI quads), + `native_instanced_draws` (one mesh with per-instance attributes: the particle pools) and + `native_indirect_draws` (a multi-draw out of the per-slot indirect ring: chunk pools and decals). + The four sum to `native_draws`. The colour write tier is on the device-up validation log line; `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` forces one. - `stats.transients` (Phase 2 step 4, `TransientAllocator` and `FeedbackCopyPool`): `transient_mib` diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index 2dc75029..da433f1e 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -134,6 +134,43 @@ Inputs: - the full suites. - **Not in stage 1:** world systems, GUI, and removal of the emulation layer. +## 3b. Stage 2 scope: mesh draws on the device API, proved on the sky + +Stage 1's device API records fullscreen draws only. World systems are mesh draws, so stage 2 +extends the API and ports the simplest system through it. + +- **Device** (`VulkanDevice.NativeMesh.cs`, plus the description and key in `VulkanDevice.Native.cs`): + - `DrawNativeMesh`, `DrawNativeMeshInstanced`, `DrawNativeMeshArrays` (non-indexed) and + `DrawNativeMeshMulti`, all sharing `BeginNativeDraw` - the old `DrawNativeFullscreen` body - and + all recording through the existing `MeshManager` and the existing per-slot indirect ring. There is + no second mesh path. + - The real mesh id reaches `BindProgramSets`, so a chunk's storage-buffer vertex fetch and an + entity's `Animation` block resolve per draw instead of against the fullscreen path's hardcoded 0. + Bone matrices therefore need no new API: `UBO.Update("Animation", ...)` keeps working as it does. + - `NativePipelineDescription` gains what a mesh draw needs and a fullscreen draw did not: the vertex + layout (`VertexLayoutId`, from `VulkanDevice.NativeMeshLayoutId`), polygon mode, line width, front + face, and `SamplesBoundDepth` - the explicit declaration that the pass reads the depth attachment + it draws into with writes off, which is the one case where sampling its own target is legal and + which puts the scope's depth in the read-only layout. Every one of those is in the native pipeline + cache key, and the vertex layout is in `PipelineKey`, so a mesh pipeline never collides with the + fullscreen pipeline of the same program. + - Per-draw writes stay `WriteNative` by placement, with a float-run overload for a matrix. A record + member is snapshotted into the frame's uniform ring when the draw binds set 2, a push member is + pushed with the draw's push block: both are per draw. + - Stats count the kinds apart: `native_fullscreen_draws`, `native_mesh_draws`, + `native_instanced_draws`, `native_indirect_draws`, summing to `native_draws`. +- **Platform:** the sky dome (`VulkanClientPlatform.NativeSky.cs`). Its lib seam is + `ClientPlatformAbstract.RenderSkyDome(MeshRef, int skyTextureId, int glowTextureId, float[] modelViewMatrix)`, + whose neutral body is the `RenderMesh` call it replaced - so OpenGL is unchanged - and which hands + the native pass the values it cannot read off GL state. `NativeSkyEnabled` keeps the old route + reachable. +- **Tests:** `NativeSkyTests` (old route against native route, scene and glow pixels, no emulation + inside the pass), `NativeMeshDrawTests` (a native mesh draw against the emulated draw of the same + mesh, pipeline-key uniqueness across the new dimensions, two multi-draws taking two regions of the + indirect ring, the layout-mismatch refusal) and `Optimum.Tests/native-world-systems-coverage-tests.cs` + for the lib seam. The four system stages add their systems to those files rather than to files named + after the stage. + ## 4. Documentation that makes map stages unnecessary Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index f9cc99d9..a79c725c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..5dc1182 100644 +index d6eb844..b7f9663 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,473 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,496 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -103,6 +103,29 @@ index d6eb844..5dc1182 100644 + return false; + } + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the sky dome's draw, ++ /// as a seam of its own. ++ /// ++ /// What it draws: the 250-unit icosahedron SystemRenderSkyColor renders the sky gradient onto, ++ /// once per frame at the start of the Opaque stage. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderSkyDome is the native one. ++ /// Target and slots: whatever the Opaque stage has bound, which is Primary; the sky writes ++ /// colour 0 and the glow slot 1 and nothing else, and never the motion attachment. ++ /// State that is not obvious: the caller has already turned the depth test off and left the ++ /// projection and model-view matrices on the program, so the seam changes no state of its own. ++ /// The sky and glow textures are passed because a native pass resolves the textures it samples ++ /// from handles, not from the texture units the program's setters bound them to. ++ /// What pins it: NativeSkyTests (old route against native route) and ++ /// Optimum.Tests/native-sky-coverage-tests.cs. ++ /// ++ public virtual void RenderSkyDome(MeshRef skyDome, int skyTextureId, int glowTextureId, float[] modelViewMatrix) ++ { ++ RenderMesh(skyDome); ++ } ++ + public virtual bool RenderOptimumTaaResolve() + { + return false; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch index ab77927c..09822768 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs -index 8efc392..e1647f0 100644 +index 8efc392..8f15a67 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs @@ -1,20 +1,28 @@ @@ -57,3 +57,19 @@ index 8efc392..e1647f0 100644 sky.PlayerToSealevelOffset = (float)game.EntityPlayer.Pos.Y - (float)game.SeaLevel; sky.RgbaFogIn = game.AmbientManager.BlendedFogColor; sky.RgbaAmbientIn = game.AmbientManager.BlendedAmbientColor; +@@ -70,11 +82,14 @@ internal class SystemRenderSkyColor : ClientSystem + calcSunColor(sunPositionNormalized, viewVector); + game.Platform.GlDisableDepthTest(); + game.GlPushMatrix(); + MatrixToolsd.MatFollowPlayer(game.MvMatrix.Top); + sky.ModelViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(skyIcosahedron); ++ // Optimum (Phase 3b): the dome's draw goes through the platform's sky seam, whose ++ // neutral body is this RenderMesh call. A native platform records the pass itself ++ // and needs the two textures and the matrix as values, not as bound units. ++ game.Platform.RenderSkyDome(skyIcosahedron, game.skyTextureId, game.skyGlowTextureId, game.CurrentModelViewMatrix); + game.GlPopMatrix(); + game.Reset3DProjection(); + sky.Stop(); + game.Platform.GlEnableDepthTest(); + } From 9c77846994c9856ef59e962854c225a8387082df Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 12:27:59 +0200 Subject: [PATCH 197/226] wip(native-world): GUI and text - the texture blit and the line overlay draw natively Phase 3b decision 5 stage 2, the GUI and text group. Two of that group's systems draw through the native device API; the rest cannot yet and the reason is written down (docs/vulkan-native-render-systems.md section 3c). Moved, because their caller states the fixed state rather than inheriting it: - the texture-into-texture blit (texture2texture), ClientMain.RenderTextureIntoFrameBuffer - how every Cairo-drawn GUI and text surface is baked into a texture, and the highest-frequency GUI draw there is. That method computes both pieces of state itself (GlDisableDepthTest, GlToggleBlend(alphaTest >= 0f)), so the new seam ClientPlatformAbstract.RenderTextureQuad(MeshRef, int, bool) carries them. Descriptor churn is answered by construction: a native draw resolves its texture into the frame's bindless arena, so a fresh Cairo texture costs one slot in that frame and no permanent descriptor. - the aiming reticle's lines (SystemRenderPlayerAimAcc, the gui program with noTexture set), through ClientPlatformAbstract.RenderOverlayLines(MeshRef, int, float, bool). First native draws with line topology - taken from the mesh's own draw mode through the new VulkanDevice.NativeMeshTopology, not from tracked state - and with a caller-chosen line width. Not moved: Render2DTexture's gui quads, guigear, block highlights, the wireframe cube and autocamera all draw with whatever blend and depth the frame left on the tracker, and the same Render2DTexture body is reached with standard and with premultiplied alpha (RenderAPIGame.Render2DTexturePremultipliedAlpha brackets it). Decision 3 forbids reading that back off tracked GL state, so they move once their blend mode is stated at the seam - a change across the GUI element tree. guitopsoil, helditem and lines have no vanilla call site in this tree at all. Two device corrections, both places the two routes could have disagreed: - vkCmdSetLineWidth refuses a width outside lineWidthRange where glLineWidth silently clamps, and the game asks for 0.5. VulkanCapabilities gained the range and ClampLineWidth, and the emulated dynamic state and the native one both use it. - a NativeMeshPass's one-entry pipeline cache keyed only on program, formats and vertex layout, so it answered every request with the pipeline it built first: the reticle's 0.5 and 1.0 draws would both have rasterized at whichever came first. It now compares the fixed state and falls through to the device's own table, which already keys on all of it. - the named blend modes now have one factor table (AttachmentBlend.FactorsFor), read by the tracker and by a native system that states "blend on, standard". Both seams' neutral bodies are the RenderMesh calls they replaced, ClientPlatformWindows overrides neither, and NativeGuiEnabled keeps the old route reachable. Listed in Optimum.Patcher/Program.cs (membersToInject plus both callers), in the platform's ExpectedVirtuals and in patches/cecil-owned.list. Verified on this machine with the implicit-layer disable set (only VK_LAYER_MESA_device_select inserted, confirmed with VK_LOADER_DEBUG=layer): dotnet build VintageStory.slnx -c Release, 0 errors; extract-patches + check-patches (158 patches, 0 conflicts, 43 runtime patches with exact donors); Optimum.Tests 1255 passed; Optimum.Render.Vulkan.Tests 1082 passed, validation clean under sync,best. Not verified in game - agents do not launch the client. --- Optimum.Patcher/Program.cs | 9 + Optimum.Render.Vulkan.Tests/NativeGuiTests.cs | 578 ++++++++++++++++++ Optimum.Render.Vulkan/Core/GlStateTracker.cs | 59 +- Optimum.Render.Vulkan/Core/VulkanContext.cs | 26 + .../VulkanClientPlatform.NativeGui.cs | 211 +++++++ .../VulkanClientPlatform.NativeSky.cs | 32 +- .../Platform/VulkanClientPlatform.cs | 4 + Optimum.Render.Vulkan/VulkanDevice.Native.cs | 4 +- .../VulkanDevice.NativeMesh.cs | 11 + Optimum.Render.Vulkan/VulkanDevice.cs | 5 +- .../native-world-systems-coverage-tests.cs | 134 ++++ docs/vulkan-native-render-systems.md | 47 ++ .../ClientMain.cs.patch | 30 +- .../ClientPlatformAbstract.cs.patch | 58 +- .../SystemRenderPlayerAimAcc.cs.patch | 42 ++ patches/cecil-owned.list | 1 + 16 files changed, 1225 insertions(+), 26 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeGuiTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs.patch diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index edee30a0..7869731b 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -85,6 +85,10 @@ // Phase 3b stage 2: the sky dome's draw seam, so a native platform records that pass // itself. The neutral body is the RenderMesh call it replaced. "RenderSkyDome", + // Phase 3b stage 2, GUI and text: the two GUI draw seams, so a native platform records + // those passes itself. Both neutral bodies are the RenderMesh call they replaced. + "RenderTextureQuad", + "RenderOverlayLines", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", // Phase 3b stage 1d: the draw seams of the two TAA passes, so a native platform @@ -1019,6 +1023,11 @@ new("Vintagestory.Client.NoObf.AmbientManager", "updateColorGradingValues", 1), // SystemRenderSkyColor: reusable scratch vectors instead of per-frame Vec3f allocations new("Vintagestory.Client.NoObf.SystemRenderSkyColor", "OnRenderFrame3D", 1), + // Phase 3b stage 2, GUI and text: the two callers that draw through the new GUI seams - + // the texture-into-texture blit that bakes every Cairo GUI and text surface, and the + // aiming reticle's line draws. + new("Vintagestory.Client.NoObf.ClientMain", "RenderTextureIntoFrameBuffer", 9), + new("Vintagestory.Client.NoObf.SystemRenderPlayerAimAcc", "OnRenderFrame2DOverlay", 1), // SystemSoundEngine: audio listener update threshold + periodic refresh new("Vintagestory.Client.NoObf.SystemSoundEngine", "OnRenderFrame", 2), // RenderAPIBase: skip disposed meshrefs instead of rendering freed GL handles (#8881/#8950/#8982-class crash) diff --git a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs new file mode 100644 index 00000000..fb7654ce --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs @@ -0,0 +1,578 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The two GUI and text systems that draw through the native device API, each drawn twice on +/// one Vulkan device: through the seam's neutral body (the OpenGL body's RenderMesh, the route +/// every system that has not moved still takes) and through the native pass +/// VulkanClientPlatform records (docs/vulkan-native-render-systems.md, decision 5 stage 2). +/// +/// - the texture-into-texture blit, which bakes every Cairo-drawn GUI and text surface into a +/// texture (ClientMain.RenderTextureIntoFrameBuffer, the texture2texture program); +/// - the aiming reticle's line draws (SystemRenderPlayerAimAcc, the gui program with noTexture +/// set), which are the first native draws with line topology and a caller-chosen line width. +/// +/// Behavioural identity is the acceptance rule (decision 6): the same shader, the same mesh and +/// the same fixed state have to put the same pixels on the target, with blending on and off and +/// at every line width the callers ask for, and the native route must not touch the GL state +/// tracker, a texture unit or a draw-buffer mask while its pass is open. +/// +public class NativeGuiTests(ITestOutputHelper output) +{ + private const int Size = 16; + + /// The platform with no window: both routes take their size from this seam. + private sealed class GuiPlatform : VulkanClientPlatform + { + public GuiPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + // ------------------------------------------------------------------ the tests + + /// + /// The native texture-blit pass draws what the seam's neutral body draws, with blending on + /// (the alphaTest >= 0 case, which is every Cairo bake) and with it off: one declared pass, + /// one native mesh draw, no emulation inside it, and the same pixels. + /// + [SkippableTheory] + [InlineData(true)] + [InlineData(false)] + public unsafe void TheNativeTextureBlitMatchesTheSeamsNeutralBody(bool blend) + { + using Session session = Open(); + + byte[] emulated = session.RunTextureQuad(native: false, blend); + + long passesBefore = session.Seam.NativePassesForTests; + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] native = session.RunTextureQuad(native: true, blend); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + output.WriteLine("blit centre emulated " + Centre(emulated) + " native " + Centre(native)); + Assert.Equal(emulated, native); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The native line overlay draws what the seam's neutral body draws, at both widths the + /// aiming reticle asks for - 0.5 for the accuracy rectangle and 1 for the crosshair lines. + /// The clamp to the device's lineWidthRange is on both routes, so a driver whose minimum is + /// 1.0 rasterizes the 0.5 draw the same way through either. + /// + [SkippableTheory] + [InlineData(0.5f)] + [InlineData(1.0f)] + public unsafe void TheNativeLineOverlayMatchesTheSeamsNeutralBody(float lineWidth) + { + using Session session = Open(); + + byte[] emulated = session.RunOverlayLines(native: false, lineWidth); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] native = session.RunOverlayLines(native: true, lineWidth); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + output.WriteLine("line row emulated " + Row(emulated) + " native " + Row(native)); + Assert.Equal(emulated, native); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The seams' neutral bodies draw through the emulation layer and the native route does + /// not: the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. + /// + [SkippableFact] + public unsafe void TheNeutralBodiesDrawThroughTheEmulationLayerAndTheNativeRouteDoesNot() + { + using Session session = Open(); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long emulatedBefore = session.Seam.EmulationCallsForTests; + session.RunTextureQuad(native: false, blend: true); + session.RunOverlayLines(native: false, 1.0f); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + session.RunTextureQuad(native: true, blend: true); + session.RunOverlayLines(native: true, 1.0f); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The two systems are two pipelines and stay two, frame after frame - a Cairo bake that + /// happens hundreds of times in a frame must not build a pipeline per call - and the line + /// width is part of the pipeline's identity, so the reticle's 0.5 and 1.0 draws are two + /// entries rather than one entry drawn twice at whichever width came last. + /// + [SkippableFact] + public unsafe void TheGuiPassesKeepTheirPipelinesAndSeparateTheLineWidths() + { + using Session session = Open(); + + session.RunTextureQuad(native: true, blend: true); + session.RunOverlayLines(native: true, 1.0f); + int afterBoth = session.Seam.NativePipelinesForTests; + + session.RunTextureQuad(native: true, blend: true); + session.RunOverlayLines(native: true, 1.0f); + Assert.Equal(afterBoth, session.Seam.NativePipelinesForTests); + + // A different line width is a different pipeline, not the same one re-emitted. + session.RunOverlayLines(native: true, 0.5f); + Assert.Equal(afterBoth + 1, session.Seam.NativePipelinesForTests); + GpuTest.AssertClean(session.Seam); + } + + /// + /// A Cairo bake is a fresh texture every time. Twenty of them through the native route must + /// cost twenty bindless slot resolutions out of the frame's own arena and still exactly one + /// pipeline: the per-frame descriptor churn the stage brief warns about has to land in the + /// arena, not in a permanent descriptor per texture. + /// + [SkippableFact] + public unsafe void EveryFreshTextureResolvesIntoTheFrameArenaAndBuildsNoNewPipeline() + { + using Session session = Open(); + + session.RunTextureQuad(native: true, blend: true); + int pipelines = session.Seam.NativePipelinesForTests; + + var textures = new List(); + for (int i = 0; i < 20; i++) textures.Add(session.NewGradient(i + 3)); + + long drawsBefore = session.Seam.NativeMeshDrawsForTests; + foreach (int texture in textures) session.RunTextureQuad(native: true, blend: true, texture); + + Assert.Equal(textures.Count, session.Seam.NativeMeshDrawsForTests - drawsBefore); + Assert.Equal(pipelines, session.Seam.NativePipelinesForTests); + GpuTest.AssertClean(session.Seam); + } + + // ---------------------------------------------------------------------- driving + + private static string Centre(byte[] pixels) + { + int i = (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + /// A whole row through the middle, where the overlay's line lands. + private static string Row(byte[] pixels) + { + var text = new System.Text.StringBuilder(); + for (int x = 0; x < Size; x++) + { + int i = (Size / 2 * Size + x) * 4; + text.Append(pixels[i]).Append(':').Append(pixels[i + 3]).Append(' '); + } + return text.ToString(); + } + + private Session Open() + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + + Session? session = Session.TryOpen(output, manifest); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// + /// The platform, its device, the target the GUI draws into, both programs and both meshes, + /// installed the way the client installs them and put back afterwards. + /// + private sealed class Session : IDisposable + { + private static readonly float[] Identity = + { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + + public GuiPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Target { get; private set; } = null!; + public MeshRef Quad { get; private set; } = null!; + public MeshRef Lines { get; private set; } = null!; + public int SourceTexture { get; private set; } + + private ShaderProgram blit = null!; + private ShaderProgram gui = null!; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + + public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-gui-" + Guid.NewGuid().ToString("N")); + var platform = new GuiPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.Target = CreateTarget(seam); + InstallFrameBuffers(platform, session.Target); + + var blitProgram = new ShaderProgram { PassName = "texture2texture" }; + Link(seam, blitProgram, "texture2texture", + new[] { "xs", "ys", "width", "height", "texu", "texv", "texw", "texh", "alphaTest" }); + session.blit = blitProgram; + + var guiProgram = new ShaderProgram { PassName = "gui" }; + Link(seam, guiProgram, "gui", + new[] { "projectionMatrix", "modelViewMatrix", "rgbaIn", "noTexture", "applyColor", "alphaTest" }); + session.gui = guiProgram; + + session.SourceTexture = Gradient(seam, 0); + BindSamplerUnits(seam, blitProgram, session.SourceTexture); + BindSamplerUnits(seam, guiProgram, session.SourceTexture); + + session.Quad = platform.UploadMesh(BuildQuad()); + session.Lines = platform.UploadMesh(BuildLines()); + return session; + } + + /// + /// The units the client's program setters bind: what the emulated route resolves its + /// samplers through. The native route passes the handles instead. + /// + private static void BindSamplerUnits(VulkanDevice seam, ShaderProgramBase program, int texture) + { + List names = seam.SamplerNamesOf(program.ProgramId); + for (int i = 0; i < names.Count; i++) + { + int unit = program.uniformLocations.Count + i; + seam.SetSamplerUnit(program.ProgramId, names[i], unit); + // Only the first sampler ever carries a texture here: texture2texture has one, + // and gui's overlay sampler is unused while noTexture is 1, which is exactly the + // reticle's case - so both routes see nothing bound for it. + seam.BindTexture(unit, i == 0 ? texture : 0); + } + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + // The meshes go first: VAO's finalizer reaches for ScreenManager.Platform, which is + // about to be the client's again, and a live handle there would crash the test host. + if (Quad != null) Platform.DeleteMesh(Quad); + if (Lines != null) Platform.DeleteMesh(Lines); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// A fresh source texture, as a Cairo bake produces one per surface. + public unsafe int NewGradient(int phase) => Gradient(Seam, phase); + + /// + /// One frame at the point RenderTextureIntoFrameBuffer reaches its draw: the + /// destination framebuffer bound and cleared, the depth test off, blending exactly as + /// that method's alphaTest decided, the program's uniforms set, then the seam. + /// + public unsafe byte[] RunTextureQuad(bool native, bool blend, int textureId = 0) + { + VulkanDevice seam = Seam; + Platform.NativeGuiEnabled = native; + int source = textureId != 0 ? textureId : SourceTexture; + + Platform.BeginFrame(); + BeginTarget(seam); + seam.SetBlend(blend, EnumBlendMode.Standard); + + seam.UseProgram(blit.ProgramId); + ShaderProgramBase.CurrentShaderProgram = blit; + // The destination rectangle covers the whole target and the source rectangle the + // whole texture: RenderTextureIntoFrameBuffer's own normalisation, at its limits. + Set(seam, blit, "xs", 0f); + Set(seam, blit, "ys", 0f); + Set(seam, blit, "width", 1f); + Set(seam, blit, "height", 1f); + Set(seam, blit, "texu", 0f); + Set(seam, blit, "texv", 0f); + Set(seam, blit, "texw", 1f); + Set(seam, blit, "texh", 1f); + Set(seam, blit, "alphaTest", blend ? 0.005f : -1f); + if (textureId != 0) seam.BindTexture(blit.uniformLocations.Count, textureId); + + Platform.RenderTextureQuad(Quad, source, blend); + + byte[] pixels = Read(seam); + Platform.EndFrame(); + return pixels; + } + + /// + /// One frame at the point SystemRenderPlayerAimAcc reaches one of its draws: the gui + /// program current with noTexture set, blending on, the line width it just chose. + /// + public unsafe byte[] RunOverlayLines(bool native, float lineWidth) + { + VulkanDevice seam = Seam; + Platform.NativeGuiEnabled = native; + + Platform.BeginFrame(); + BeginTarget(seam); + seam.SetBlend(true, EnumBlendMode.Standard); + seam.SetLineWidth(lineWidth); + + seam.UseProgram(gui.ProgramId); + ShaderProgramBase.CurrentShaderProgram = gui; + seam.SetUniformMatrix(gui.ProgramId, gui.uniformLocations["projectionMatrix"], Identity); + seam.SetUniformMatrix(gui.ProgramId, gui.uniformLocations["modelViewMatrix"], Identity); + seam.SetUniform(gui.ProgramId, gui.uniformLocations["rgbaIn"], 1f, 0.5f, 0.25f, 1f); + Set(seam, gui, "noTexture", 1f); + seam.SetUniform(gui.ProgramId, gui.uniformLocations["applyColor"], 0); + Set(seam, gui, "alphaTest", 0f); + + Platform.RenderOverlayLines(Lines, 0, lineWidth, blend: true); + + byte[] pixels = Read(seam); + Platform.EndFrame(); + return pixels; + } + + private static void Set(VulkanDevice seam, ShaderProgramBase program, string name, float value) => + seam.SetUniform(program.ProgramId, program.uniformLocations[name], value); + + /// The target bound, cleared and put into the state both GUI systems draw in. + private void BeginTarget(VulkanDevice seam) + { + seam.BindFramebuffer(Target.FboId); + seam.SetDrawBuffers(Target.FboId, 1); + seam.ClearColor(0, 0.1f, 0.2f, 0.3f, 1f); + + Platform.CurrentFrameBuffer = Target; + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + } + + private unsafe byte[] Read(VulkanDevice seam) + { + int reader = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, Target.ColorTextureIds[0], 0); + seam.SetDrawBuffers(reader, 1); + seam.BindFramebuffer(reader); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + seam.BindFramebuffer(Target.FboId); + return pixels; + } + + // ----------------------------------------------------------------- fixtures + + /// Links one vanilla program as ShaderRegistry does and fills the locations the test sets. + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant()); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + /// + /// The destination a Cairo bake writes into and the default framebuffer the Ortho stage + /// draws into have the same shape here: one colour attachment, no depth. + /// + private static FrameBufferRef CreateTarget(VulkanDevice seam) + { + var target = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + }; + seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + seam.SetDrawBuffers(target.FboId, 1); + Assert.True(seam.CheckFramebufferComplete(target.FboId, out string status), status); + return target; + } + + private static void InstallFrameBuffers(GuiPlatform platform, FrameBufferRef target) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = target; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + } + + /// A small gradient, so a sampling difference between the routes would show. + private static unsafe int Gradient(VulkanDevice seam, int phase) + { + var pixels = new byte[8 * 8 * 4]; + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + int i = (y * 8 + x) * 4; + pixels[i] = (byte)(16 + x * 30 + phase * 7); + pixels[i + 1] = (byte)(32 + y * 25); + pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20); + pixels[i + 3] = (byte)(96 + ((x + y) & 3) * 40); + } + } + fixed (byte* first = pixels) + { + return seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false); + } + } + + /// The unit quad ClientMain keeps for its 2D draws: positions and UVs. + private static MeshData BuildQuad() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: false); + float[] positions = + { + -1f, -1f, 0f, + 1f, -1f, 0f, + 1f, 1f, 0f, + -1f, 1f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, 0); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + + /// + /// One line across the middle, in the shape SystemRenderPlayerAimAcc tesselates its + /// reticle in: EnumDrawMode.Lines, positions and a per-vertex colour, no textures. + /// + private static MeshData BuildLines() + { + var mesh = new MeshData(2, 2, withNormals: false, withUv: true, withRgba: true, withFlags: false); + mesh.SetMode(EnumDrawMode.Lines); + mesh.AddVertexWithFlags(-0.8f, 0f, 0f, 0f, 0f, ColorUtil.WhiteArgb, 0); + mesh.AddVertexWithFlags(0.8f, 0f, 0f, 1f, 0f, ColorUtil.WhiteArgb, 0); + mesh.AddIndex(0); + mesh.AddIndex(1); + return mesh; + } + } + + // ---------------------------------------------------------------- native shaders + + /// The two programs' manifest, built once for the whole class. + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + foreach (string program in new[] { "texture2texture", "gui" }) + { + NativeShaderBuildResult one = builder.Build(source, program); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + } + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), "optimum-native-gui-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan/Core/GlStateTracker.cs index e887e4c2..6f64c86f 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan/Core/GlStateTracker.cs @@ -30,6 +30,48 @@ internal struct AttachmentBlend : IEquatable | ColorComponentFlags.BBit | ColorComponentFlags.ABit, }; + /// + /// The factor pairs one of the game's named blend modes means, which + /// ClientPlatformWindows.GlToggleBlend selects and + /// applies to every attachment. + /// + /// A native render system states its blend outright rather than reading the tracker's + /// (docs/vulkan-native-render-systems.md, decision 3), and its call site says "blend on, + /// standard" the same way the OpenGL body does, so it builds the attachment through here + /// instead of restating the factors and risking a pair that drifts from the tracker's. + /// + public static AttachmentBlend For(bool enabled, EnumBlendMode mode) + { + (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = FactorsFor(mode); + AttachmentBlend blend = Default; + blend.Enabled = enabled; + blend.SrcColor = srcColor; + blend.DstColor = dstColor; + blend.ColorOp = BlendOp.Add; + blend.SrcAlpha = srcAlpha; + blend.DstAlpha = dstAlpha; + blend.AlphaOp = BlendOp.Add; + return blend; + } + + /// The one table of factor pairs, shared by the tracker and by native systems. + internal static (BlendFactor SrcColor, BlendFactor DstColor, BlendFactor SrcAlpha, BlendFactor DstAlpha) + FactorsFor(EnumBlendMode mode) => mode switch + { + EnumBlendMode.Brighten => (BlendFactor.DstColor, BlendFactor.One, + BlendFactor.DstColor, BlendFactor.One), + EnumBlendMode.Multiply => (BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + EnumBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + EnumBlendMode.Glow => (BlendFactor.SrcAlpha, BlendFactor.One, + BlendFactor.One, BlendFactor.Zero), + EnumBlendMode.Overlay => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.One), + _ => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, + BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), + }; + /// /// Squeezes the whole attachment state into 32 bits so a set of eight hashes /// as cheaply as an array of ints. Every field is a small enum; the widest is @@ -318,21 +360,8 @@ public void SetColorMask(bool r, bool g, bool b, bool a) /// public void SetBlend(bool enabled, EnumBlendMode mode) { - (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = mode switch - { - EnumBlendMode.Brighten => (BlendFactor.DstColor, BlendFactor.One, - BlendFactor.DstColor, BlendFactor.One), - EnumBlendMode.Multiply => (BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha, - BlendFactor.One, BlendFactor.OneMinusSrcAlpha), - EnumBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha, - BlendFactor.One, BlendFactor.OneMinusSrcAlpha), - EnumBlendMode.Glow => (BlendFactor.SrcAlpha, BlendFactor.One, - BlendFactor.One, BlendFactor.Zero), - EnumBlendMode.Overlay => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, - BlendFactor.One, BlendFactor.One), - _ => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, - BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), - }; + (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = + AttachmentBlend.FactorsFor(mode); for (int i = 0; i < _blend.Length; i++) { diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 0ba54be5..eefb7b62 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -63,6 +63,30 @@ internal sealed class VulkanCapabilities public PhysicalDeviceType DeviceType; public uint MaxImageDimension2D; public bool WideLines; + + /// + /// VkPhysicalDeviceLimits::lineWidthRange, the only widths vkCmdSetLineWidth accepts + /// once wideLines is on. GL silently clamps glLineWidth to its own range; Vulkan makes an + /// out-of-range width a validation error, and the game asks for 0.5 (the aiming reticle's + /// accuracy rectangle) and 2 (the camera path), so both routes clamp through + /// rather than passing the caller's value on. + /// + public float LineWidthMin = 1.0f; + public float LineWidthMax = 1.0f; + + /// + /// The width a line draw may actually rasterize with: the caller's, clamped to the device's + /// range, or exactly 1 on a device without wideLines. Used by the emulated draw path and by + /// a native pipeline's dynamic state, so the two routes can never disagree about it. + /// + public float ClampLineWidth(float width) + { + if (!WideLines) return 1.0f; + if (width < LineWidthMin) return LineWidthMin; + if (width > LineWidthMax) return LineWidthMax; + return width; + } + public bool FillModeNonSolid; public bool SamplerAnisotropy; public bool MultiDrawIndirect; @@ -1215,6 +1239,8 @@ private VulkanCapabilities ReadCapabilities() DeviceType = properties.DeviceType, MaxImageDimension2D = properties.Limits.MaxImageDimension2D, WideLines = features.WideLines, + LineWidthMin = properties.Limits.LineWidthRange[0], + LineWidthMax = properties.Limits.LineWidthRange[1], FillModeNonSolid = features.FillModeNonSolid, SamplerAnisotropy = features.SamplerAnisotropy, MultiDrawIndirect = features.MultiDrawIndirect, diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs new file mode 100644 index 00000000..61f3f77f --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -0,0 +1,211 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), Phase 3b decision 5 +// stage 2: the GUI and text systems whose fixed state is stated at their call site. +// +// Two of the systems in that group draw through the native device API here. The rest of the +// group - Render2DTexture's gui quads, guigear, the block highlights, the wireframe cube and +// the camera path - stay on the emulated route for now, and the reason is written down in +// docs/vulkan-native-render-systems.md section 3c: their blend and depth state is not the +// caller's, it is whatever the frame left on the tracker, and the same Render2DTexture call is +// reached both with standard alpha and with premultiplied alpha (RenderAPIGame's +// Render2DTexturePremultipliedAlpha brackets it with GlToggleBlend). Decision 3 forbids a +// native pass from reading that back off tracked GL state, so those systems move once their +// blend mode is stated at the seam, which is a change across the GUI element tree and its own +// piece of work. +// +// What these two draw: +// - RenderTextureQuad: the unit quad ClientMain.RenderTextureIntoFrameBuffer stretches one +// texture's rectangle over another's - how every Cairo-drawn GUI and text surface is baked +// into a texture. It is the highest-frequency GUI draw there is, and the one the stage +// brief flags for descriptor churn: a native draw resolves its texture into the device's +// per-frame bindless arena (VulkanDevice.BeginNativeDraw -> BindlessTextureTable.Resolve), +// so a fresh Cairo texture costs one slot in this frame's arena and no permanent +// descriptor, which is exactly what the emulated unit route could not promise. +// - RenderOverlayLines: SystemRenderPlayerAimAcc's aiming reticle, five line-topology draws +// through the gui program with noTexture set, at two line widths. +// Where the other side is: ClientPlatformAbstract.RenderTextureQuad and +// ClientPlatformAbstract.RenderOverlayLines, whose neutral bodies are the RenderMesh calls +// these seams replaced and which the OpenGL path still runs (ClientPlatformWindows.RenderMesh +// -> GL.DrawElements). NativeGuiEnabled false takes that route on the Vulkan device too, which +// is what the differential tests compare against. +// Target and slots: CurrentFrameBuffer, which for the texture blit is the framebuffer the +// caller just bound over the destination texture and for the reticle is the default +// framebuffer the Ortho stage draws into. Every bound colour slot is in the pass, so the scope +// is the one the emulated draw opens; both fragment shaders write outColor at 0 only and the +// pipeline masks every other slot off (rule 9). Neither writes depth, and neither writes the +// motion attachment - the Ortho stage runs after the TAA resolve, so there is no motion slot +// to mask in the first place. +// State that is not obvious: +// - no depth test and no depth write: RenderTextureIntoFrameBuffer calls GlDisableDepthTest +// itself, and the reticle is a 2D overlay whose ortho projection puts it in front; +// - blend: the value the caller computed, through the same factor table the tracker uses +// (AttachmentBlend.For), never read back off the tracker; +// - no culling: both meshes are screen-facing quads and lines, which GL rasterizes whatever +// the cull state, and lines are not culled at all; +// - the topology is the mesh's own draw mode (VulkanDevice.NativeMeshTopology), because that +// is where the tesselator put it - EnumDrawMode.Lines for the reticle - not a state toggle; +// - the line width is the caller's, and it is the one piece of dynamic state a fullscreen +// pass never needed; it is in the native pipeline key, so the 0.5 and the 1.0 draws of one +// frame do not share a pipeline. +// What pins it: NativeGuiTests (old route against native route, both systems, both line +// widths, blend on and off) and Optimum.Tests/native-world-systems-coverage-tests.cs (the lib +// seams). +public partial class VulkanClientPlatform +{ + /// + /// False runs the seams' neutral bodies - the OpenGL bodies' RenderMesh - on the Vulkan + /// device instead of the native passes: the old route the differential tests compare + /// against, in the pattern of . + /// + internal bool NativeGuiEnabled { get; set; } = true; + + /// + /// The texture-into-texture blit's pipeline and placements. Nothing is written per draw: + /// the source rectangle, the destination rectangle and the alpha test are all in the + /// program's own shadow by the time the seam is reached, put there by the setters + /// RenderTextureIntoFrameBuffer calls. + /// + private readonly NativeMeshPass nativeTextureQuad = + new("texture2texture", Array.Empty(), new[] { "tex2d" }); + + /// + /// The 2D line overlay's pipeline and placements, on the gui program. "tex2dOverlay" is + /// resolved as well as "tex2d" so neither sampler slot keeps a stale bindless index out of + /// the push shadow; the reticle sets noTexture, so gui.fsh samples neither. + /// + private readonly NativeMeshPass nativeOverlayLines = + new("gui", Array.Empty(), new[] { "tex2d", "tex2dOverlay" }); + + /// The texture-into-texture blit's draw: the native pass, or the neutral body's RenderMesh. + public override void RenderTextureQuad(MeshRef quad, int textureId, bool blend) + { + if (!NativeGuiEnabled || device == null || quad == null) + { + base.RenderTextureQuad(quad!, textureId, blend); + return; + } + + if (!DrawNativeGuiMesh(nativeTextureQuad, quad, textureId, 0, 1.0f, blend, "TextureQuad")) + { + base.RenderTextureQuad(quad, textureId, blend); + } + } + + /// The 2D line overlay's draw: the native pass, or the neutral body's RenderMesh. + public override void RenderOverlayLines(MeshRef lines, int textureId, float lineWidth, bool blend) + { + if (!NativeGuiEnabled || device == null || lines == null) + { + base.RenderOverlayLines(lines!, textureId, lineWidth, blend); + return; + } + + if (!DrawNativeGuiMesh(nativeOverlayLines, lines, textureId, 0, lineWidth, blend, "OverlayLines")) + { + base.RenderOverlayLines(lines, textureId, lineWidth, blend); + } + } + + /// + /// One GUI mesh recorded as its own native pass: the target the caller bound, every bound + /// colour slot in scope, the fixed state the caller stated, the mesh's own vertex layout + /// and topology, and the sampled textures resolved from handles. + /// + /// False means nothing was recorded and the caller must run the seam's neutral body - a + /// missing target, a program that is not the one this pass is for, a mesh the device does + /// not know, or a pipeline that is still compiling. + /// + private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, int overlayTextureId, + float lineWidth, bool blend, string passLabel) + { + FrameBufferRef target = CurrentFrameBuffer; + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + var vao = mesh as VAO; + if (program == null || vao == null || vao.VaoId == 0 || vao.Disposed) return false; + if (!string.Equals(program.PassName, pass.PassName, StringComparison.Ordinal)) return false; + + // The Ortho stage draws into the default framebuffer, which has no FrameBufferRef of + // its own (ClientPlatformWindows.LoadFrameBuffer sets CurrentFrameBuffer null for it); + // the pass API names it the same way the post chain does. + int framebufferId = target?.FboId ?? PassDeclaration.DefaultFramebuffer; + uint slots = target != null ? NativeAllColorSlots(target) : 1u; + + int layoutId = device.NativeMeshLayoutId(vao.VaoId); + if (layoutId < 0) return false; + + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, slots); + if (formats == null) return false; + + NativePipeline? pipeline = NativeMeshPipelineFor(pass, program, framebufferId, slots, layoutId, + new NativePipelineDescription + { + Blend = GuiSlots(formats, blend), + DepthTest = false, + DepthWrite = false, + Cull = CullModeFlags.None, + Topology = device.NativeMeshTopology(vao.VaoId), + LineWidth = lineWidth, + }); + if (pipeline == null) return false; + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + Rect2D viewport = device.NativeCurrentViewport; + bool recorded = false; + if (device.BeginNativePass(new NativePassDescription + { + Name = passLabel + "/" + framebufferId, + FramebufferId = framebufferId, + ColorSlots = slots, + Reads = pass.SamplerNames.Length > 1 + ? new[] { textureId, overlayTextureId } + : new[] { textureId }, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + })) + { + Span textures = stackalloc NativeTexture[pass.Samplers.Length]; + textures[0] = new NativeTexture(pass.Samplers[0], textureId); + if (textures.Length > 1) textures[1] = new NativeTexture(pass.Samplers[1], overlayTextureId); + recorded = device.DrawNativeMesh(pipeline, vao.VaoId, textures); + } + device.EndNativePass(); + + // Whatever the stage was drawing into before this pass keeps drawing into it through + // the emulated route, so its own pass context is declared again - the same restore the + // sky pass and the TAA resolve do. + if (target != null) device.BindFramebuffer(target.FboId); + SetPassContext(outer, outerFlags); + return recorded; + } + + /// + /// A 2D overlay's fixed state per colour attachment: the caller's blend on slot 0 through + /// the tracker's own factor table, and every other slot masked off so an attachment the + /// fragment shader never writes keeps its contents as it does on GL (rule 9). + /// + private static AttachmentBlend[] GuiSlots(RenderTargetFormats formats, bool blend) + { + var slots = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + slots[0] = AttachmentBlend.For(blend, EnumBlendMode.Standard); + for (int i = 1; i < slots.Length; i++) + { + slots[i] = AttachmentBlend.Default; + slots[i].WriteMask = 0; + } + return slots; + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs index adf37c7d..01d2cb88 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs @@ -104,7 +104,8 @@ public void Adopt(NativePipeline pipeline, RenderTargetFormats formats, int layo if (formats == null) return null; if (pass.Pipeline != null && pass.Pipeline.ProgramId == program.ProgramId && - formats.Equals(pass.Formats) && pass.LayoutId == layoutId && device.IsNativePipelineLive(pass.Pipeline)) + formats.Equals(pass.Formats) && pass.LayoutId == layoutId && + SameFixedState(pass.Pipeline.Description, description) && device.IsNativePipelineLive(pass.Pipeline)) { return pass.Pipeline; } @@ -131,6 +132,35 @@ public void Adopt(NativePipeline pipeline, RenderTargetFormats formats, int layo return pipeline; } + /// + /// Whether two descriptions ask for the same fixed state, which is what makes the cached + /// pipeline of a usable for the next draw through it. + /// + /// Without this the one-entry cache answered any request for the same program, target and + /// mesh shape with the pipeline it happened to build first: the aiming reticle's 0.5 and + /// 1.0 line widths would then both rasterize at whichever came first, and a system that + /// turns blending on and off between draws would blend both or neither. The device's own + /// table keys on all of it (NativePipelineCacheKey), so falling through to + /// costs a dictionary lookup, not a + /// pipeline. + /// + private static bool SameFixedState(NativePipelineDescription cached, NativePipelineDescription wanted) + { + if (cached.DepthTest != wanted.DepthTest || cached.DepthWrite != wanted.DepthWrite || + cached.DepthCompare != wanted.DepthCompare || cached.Cull != wanted.Cull || + cached.FrontFace != wanted.FrontFace || cached.Topology != wanted.Topology || + cached.PolygonMode != wanted.PolygonMode || cached.SamplesBoundDepth != wanted.SamplesBoundDepth || + !cached.LineWidth.Equals(wanted.LineWidth) || cached.Blend.Length != wanted.Blend.Length) + { + return false; + } + for (int i = 0; i < cached.Blend.Length; i++) + { + if (!cached.Blend[i].Equals(wanted.Blend[i])) return false; + } + return true; + } + /// Every bound colour slot of a target: the scope the emulated draw would open. private static uint NativeAllColorSlots(FrameBufferRef target) { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 5dcff836..51986be1 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -139,6 +139,10 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderOptimumSkyMotion", Array.Empty()), // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. new(true, "RenderSkyDome", new[] { "MeshRef", "Int32", "Int32", "Single[]" }), + // Phase 3b stage 2, GUI and text: the texture-into-texture blit and the aiming reticle's + // line draws, the two GUI systems whose fixed state is stated at their call site. + new(true, "RenderTextureQuad", new[] { "MeshRef", "Int32", "Boolean" }), + new(true, "RenderOverlayLines", new[] { "MeshRef", "Int32", "Single", "Boolean" }), new(true, "RenderOptimumTaaResolve", Array.Empty()), new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), // Phase 3b stage 1: the two TAA passes' draw seams, which the native chain replaces. diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 6cfc176a..8cec7e3d 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -849,7 +849,9 @@ private void EmitNativeDynamicState(CommandBuffer commandBuffer, VulkanFramebuff StencilCompareMask = 0xFF, StencilWriteMask = 0xFF, StencilReference = 0, - LineWidth = description.LineWidth, + // Clamped through the same device range as the emulated path's, so a native line + // draw and the seam's neutral body rasterize identically. + LineWidth = _context.Capabilities.ClampLineWidth(description.LineWidth), ColorWrite = colorWrite, BlendStateId = dynamicBlend ? pipeline.DynamicBlendId : 0, }; diff --git a/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs index d012b398..5064b172 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs @@ -42,6 +42,17 @@ internal enum NativeDrawKind : byte /// public sealed unsafe partial class VulkanDevice { + /// + /// The topology a mesh was uploaded with, as the primitive a native pipeline rasterizes it + /// as. A mesh carries its own EnumDrawMode from the tesselator (triangles for most + /// geometry, lines for the aiming reticle, a line strip for the camera path), so a native + /// system states it from the mesh rather than from the tracker's topology, which the + /// emulated draw sets per draw in . Triangles for a mesh that + /// does not exist, so a caller that has already been refused a pipeline sees no surprise. + /// + internal PrimitiveTopology NativeMeshTopology(int meshId) => + GlEnums.TopologyFrom(_meshes.Get(meshId)?.DrawMode ?? Vintagestory.API.Client.EnumDrawMode.Triangles); + /// Counts one native draw, once in the total and once in its own kind. private void NoteNativeDraw(NativeDrawKind kind) { diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 76d9503e..ca5228b0 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -3317,7 +3317,10 @@ private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer ta StencilCompareMask = _state.StencilCompareMask, StencilWriteMask = _state.StencilWriteMask, StencilReference = _state.StencilReference, - LineWidth = _context.Capabilities.WideLines ? _state.LineWidth : 1.0f, + // glLineWidth clamps to GL's own range; vkCmdSetLineWidth makes an out-of-range + // width a validation error, so the device's lineWidthRange decides (the game asks + // for 0.5 on the aiming reticle, which is below several drivers' minimum). + LineWidth = _context.Capabilities.ClampLineWidth(_state.LineWidth), ColorWrite = colorWrite, BlendStateId = dynamicBlend ? _state.BlendId(GlStateTracker.MaxColorAttachments) : 0, }; diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 03bb81b5..0c76bee9 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -16,6 +16,7 @@ namespace Optimum.Tests; public class NativeWorldSystemsCoverageTests { private const string SkyPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs"; + private const string GuiPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs"; private const string DeviceMeshFile = "Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"; private const string DeviceNativeFile = "Optimum.Render.Vulkan/VulkanDevice.Native.cs"; @@ -183,6 +184,139 @@ public void ThePipelineDescriptionAndKeyCarryTheMeshDrawState() Assert.Contains("_meshes.LayoutOf(description.VertexLayoutId)", native); } + // ------------------------------------------------------- GUI and text (stage 2) + + /// + /// Both GUI seams exist on the platform abstraction with the neutral body that is exactly + /// the RenderMesh call they replaced, and the OpenGL platform overrides neither, so nothing + /// about the GL path changes. + /// + [Fact] + public void TheGuiSeamsHaveNeutralBodiesThatAreTheDrawsTheyReplaced() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + Assert.Contains("public virtual void RenderTextureQuad(MeshRef quad, int textureId, bool blend)", platform); + Assert.Contains("RenderMesh(quad);", platform); + Assert.Contains( + "public virtual void RenderOverlayLines(MeshRef lines, int textureId, float lineWidth, bool blend)", + platform); + Assert.Contains("RenderMesh(lines);", platform); + + string windows = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.DoesNotContain("RenderTextureQuad", windows); + Assert.DoesNotContain("RenderOverlayLines", windows); + } + + /// + /// The texture-into-texture blit draws through its seam and hands it the two values a + /// native pass may not read back off tracked GL state: the texture the program samples and + /// the blend state this very method computed from its alphaTest argument. + /// + [Fact] + public void TheTextureBlitDrawsThroughTheSeamAndCarriesItsOwnBlendState() + { + string client = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + Assert.Contains( + "Platform.RenderTextureQuad(quadModel, fromTexture.TextureId, alphaTest >= 0f);", + client); + // The seam replaced the draw and nothing else: the RenderMesh call is gone from this + // method, and the state calls that bracket it are untouched for the OpenGL path. + Assert.DoesNotContain("Platform.RenderMesh(quadModel);\n\t\t\tPlatform.GlEnableDepthTest();", client); + } + + /// + /// The aiming reticle draws through its seam and passes the line width and blend state it + /// sets itself - 0.5 for the accuracy rectangle and 1 for the four crosshair lines. + /// + [Fact] + public void TheAimOverlayDrawsThroughTheSeamWithBothLineWidths() + { + string aim = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs"); + + Assert.Contains("game.Platform.RenderOverlayLines(aimRectangleRef, 0, 0.5f, blend: true);", aim); + for (int i = 0; i < 4; i++) + { + Assert.Contains("game.Platform.RenderOverlayLines(aimLinesRef[" + i + "], 0, 1f, blend: true);", aim); + } + Assert.DoesNotContain("game.Platform.RenderMesh(", aim); + } + + /// Every new or changed lib member of the GUI stage is listed for the Cecil transplant. + [Fact] + public void TheGuiSeamsAndTheirCallersAreListedForTheTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"RenderTextureQuad\"", patcher); + Assert.Contains("\"RenderOverlayLines\"", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"RenderTextureIntoFrameBuffer\", 9", patcher); + Assert.Contains( + "\"Vintagestory.Client.NoObf.SystemRenderPlayerAimAcc\", \"OnRenderFrame2DOverlay\", 1", patcher); + + // Both are declared virtuals the Vulkan platform expects on the patched host, so a lib + // that lost the transplant is caught at startup rather than at the first GUI draw. + string expected = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); + Assert.Contains("new(true, \"RenderTextureQuad\"", expected); + Assert.Contains("new(true, \"RenderOverlayLines\"", expected); + } + + /// + /// The Vulkan platform records both GUI systems as native passes with their fixed state + /// stated outright, takes the topology and the vertex layout from the mesh rather than from + /// tracked state, and keeps the neutral bodies reachable behind one switch. + /// + [Fact] + public void TheVulkanPlatformRecordsTheGuiSystemsNativelyAndKeepsTheOldRoute() + { + string gui = Read(GuiPlatformFile); + + Assert.Contains("internal bool NativeGuiEnabled { get; set; } = true;", gui); + Assert.Contains("public override void RenderTextureQuad(", gui); + Assert.Contains("public override void RenderOverlayLines(", gui); + Assert.Contains("base.RenderTextureQuad(", gui); + Assert.Contains("base.RenderOverlayLines(", gui); + Assert.Contains("device.BeginNativePass(", gui); + Assert.Contains("device.DrawNativeMesh(", gui); + Assert.Contains("device.EndNativePass();", gui); + + // Fixed state the pass states, never reads back: the caller's blend through the one + // factor table, the caller's line width, and the mesh's own topology and layout. + Assert.Contains("AttachmentBlend.For(blend, EnumBlendMode.Standard)", gui); + Assert.Contains("LineWidth = lineWidth", gui); + Assert.Contains("Topology = device.NativeMeshTopology(vao.VaoId)", gui); + Assert.Contains("device.NativeMeshLayoutId(", gui); + Assert.Contains("DepthTest = false", gui); + Assert.Contains("DepthWrite = false", gui); + } + + /// + /// The named blend modes have exactly one factor table, which the tracker and every native + /// system that states "blend on, standard" both read - so the two can never drift. + /// + [Fact] + public void TheNamedBlendModesHaveOneFactorTable() + { + string tracker = Read("Optimum.Render.Vulkan/Core/GlStateTracker.cs"); + + Assert.Contains("public static AttachmentBlend For(bool enabled, EnumBlendMode mode)", tracker); + Assert.Contains("FactorsFor(EnumBlendMode mode) => mode switch", tracker); + Assert.Contains("AttachmentBlend.FactorsFor(mode);", tracker); + + // One table only: the premultiplied-alpha pair appears once in the file. + int first = tracker.IndexOf("EnumBlendMode.PremultipliedAlpha =>", StringComparison.Ordinal); + Assert.True(first >= 0); + Assert.Equal(-1, tracker.IndexOf("EnumBlendMode.PremultipliedAlpha =>", first + 1, StringComparison.Ordinal)); + } + // ------------------------------------------------------------------------ helpers private static string Section(string source, string from, string to) diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index da433f1e..00425925 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -171,6 +171,53 @@ extends the API and ports the simplest system through it. for the lib seam. The four system stages add their systems to those files rather than to files named after the stage. +## 3c. Stage 2 scope: GUI and text + +The GUI and text group of decision 5 step 2 (`gui`, `guigear`, `guitopsoil`, `helditem`, `lines`, +`texture2texture`, block highlights, wireframe, autocamera) splits in two on decision 3, and only one +half can move yet. + +**Moved, because the caller states the fixed state:** + +- **The texture-into-texture blit** (`texture2texture`), `ClientMain.RenderTextureIntoFrameBuffer` - + how every Cairo-drawn GUI and text surface is baked into a texture, and the highest-frequency GUI + draw there is. That method computes both pieces of state itself (`GlDisableDepthTest`, and + `GlToggleBlend(alphaTest >= 0f)`), so the seam + `ClientPlatformAbstract.RenderTextureQuad(MeshRef, int textureId, bool blend)` carries them and the + native pipeline states them rather than reading them back. Descriptor churn is answered by + construction: a native draw resolves its texture into the frame's bindless arena, so a fresh Cairo + texture costs one slot in that frame and no permanent descriptor. +- **The aiming reticle's lines**, `SystemRenderPlayerAimAcc` on the `gui` program with `noTexture` set. + It sets `GLLineWidth` and `GlToggleBlend(on: true)` immediately before each draw, so the seam + `ClientPlatformAbstract.RenderOverlayLines(MeshRef, int textureId, float lineWidth, bool blend)` + carries both. These are the first native draws with line topology (taken from the mesh's own draw + mode through `VulkanDevice.NativeMeshTopology`) and with a caller-chosen line width, which is in the + native pipeline key - the 0.5 and the 1.0 draws of one frame are two pipelines. + +Two device-level corrections came out of it, both places where the two routes could have disagreed: + +- `vkCmdSetLineWidth` refuses a width outside `VkPhysicalDeviceLimits::lineWidthRange`, where + `glLineWidth` silently clamps, and the game asks for 0.5. Both routes now clamp through + `VulkanCapabilities.ClampLineWidth`. +- A `NativeMeshPass`'s one-entry pipeline cache keyed only on program, target formats and vertex + layout, so it answered any request with the pipeline it built first. A system that changes blend or + line width between draws - which is exactly what the reticle does - would have drawn both with the + first one's state. It now compares the fixed state and falls through to the device's own table. + +**Not moved, and why.** `Render2DTexture`'s `gui` quads, `guigear`, the block highlights, the wireframe +cube and the camera path (`autocamera`) all draw with whatever blend and depth state the frame left on +the tracker; none of them sets it. The same `ClientMain.Render2DTexture` body is reached with standard +alpha and with premultiplied alpha, because `RenderAPIGame.Render2DTexturePremultipliedAlpha` brackets +it with `GlToggleBlend`. Decision 3 forbids a native pass from reading that back off tracked GL state, +so these move when their blend mode is stated at the seam - a change that reaches through the GUI +element tree in the API fork, and its own piece of work. `guitopsoil`, `helditem` and `lines` have no +vanilla call site in this tree at all and should be confirmed with `OPTIMUM_RENDER_TRACE` before anyone +ports them. + +Tests: `NativeGuiTests` (old route against native route for both systems, blending on and off, both +line widths, the pipeline identity across line widths, and twenty fresh textures through one pipeline) +and the GUI section of `Optimum.Tests/native-world-systems-coverage-tests.cs` for the lib seams. + ## 4. Documentation that makes map stages unnecessary Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index 08660943..2bb3b80f 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..4037dee 100644 +index 67feafa..0848a9b 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -487,7 +487,25 @@ index 67feafa..4037dee 100644 dt = DeltaTimeLimiter; } TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); -@@ -1420,10 +1674,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1314,11 +1568,16 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + texture2texture.AlphaTest = alphaTest; + texture2texture.Xs = targetX / (float)fb.Width; + texture2texture.Ys = targetY / (float)fb.Height; + texture2texture.Width = sourceWidth / (float)fb.Width; + texture2texture.Height = sourceHeight / (float)fb.Height; +- Platform.RenderMesh(quadModel); ++ // Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the draw is a ++ // seam, so a native platform records this pass itself. The neutral body is the ++ // RenderMesh call it replaced, and the two values it carries - the texture the program ++ // samples and the blend state this method just computed from alphaTest - are the ones a ++ // native pass may not read back off tracked GL state. ++ Platform.RenderTextureQuad(quadModel, fromTexture.TextureId, alphaTest >= 0f); + Platform.GlEnableDepthTest(); + Platform.LoadFrameBuffer((currentShaderProgram?.PassName == "gui") ? EnumFrameBuffer.Default : EnumFrameBuffer.Primary); + texture2texture.Stop(); + currentShaderProgram?.Use(); + } +@@ -1420,10 +1679,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo { float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); @@ -502,7 +520,7 @@ index 67feafa..4037dee 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1823,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1828,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -526,7 +544,7 @@ index 67feafa..4037dee 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2382,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2387,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -551,7 +569,7 @@ index 67feafa..4037dee 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2811,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2816,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -565,7 +583,7 @@ index 67feafa..4037dee 100644 } public void DoReconnect() -@@ -3531,6 +3802,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3807,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index a79c725c..e1425ade 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..b7f9663 100644 +index d6eb844..4ea1837 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,496 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,550 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -126,6 +126,60 @@ index d6eb844..b7f9663 100644 + RenderMesh(skyDome); + } + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the texture-into-texture ++ /// blit's draw, as a seam of its own. ++ /// ++ /// What it draws: the unit quad ClientMain.RenderTextureIntoFrameBuffer stretches a rectangle of ++ /// one texture over a rectangle of another with - the draw that bakes every Cairo-drawn GUI and ++ /// text texture into a texture atlas or a dialog's own surface, several hundred times in a frame ++ /// that opens a dialog. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderTextureQuad is the native one. ++ /// Target and slots: , which the caller has just bound to the ++ /// framebuffer it passes in, colour slot 0 only; texture2texture.fsh writes outColor at 0 and ++ /// the pipeline masks every other slot off. No depth attachment is written. ++ /// State that is not obvious: the caller computes both pieces of fixed state from its own ++ /// arguments rather than leaving them to whatever the frame had - the depth test is off and ++ /// blending is on exactly when alphaTest is non-negative - so is that ++ /// value passed on, and a native pass states it instead of reading it back off tracked GL state ++ /// (decision 3). The source texture is passed because a native pass resolves what it samples ++ /// from handles, not from the texture unit the program's setter bound it to. ++ /// What pins it: NativeGuiTests (old route against native route) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderTextureQuad(MeshRef quad, int textureId, bool blend) ++ { ++ RenderMesh(quad); ++ } ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): a 2D overlay drawn as ++ /// lines, as a seam of its own. ++ /// ++ /// What it draws: the aiming reticle SystemRenderPlayerAimAcc puts on the screen while a bow is ++ /// drawn - one line-topology rectangle and four line meshes, through the gui program with ++ /// noTexture set, in the Ortho stage. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderOverlayLines is the native one. ++ /// Target and slots: , which in the Ortho stage is the default ++ /// framebuffer, colour slot 0 only. ++ /// State that is not obvious: the caller sets the line width and turns blending on immediately ++ /// before each of these draws, so both are passed here and a native pipeline states them ++ /// outright - the line width is the one piece of dynamic state a fullscreen pass never needed. ++ /// The topology is the mesh's own draw mode, not a state toggle. ++ /// is the texture the program samples, 0 for the reticle because noTexture is 1 and the sampler ++ /// resolves to the bindless placeholder on both routes. ++ /// What pins it: NativeGuiTests (old route against native route, both line widths) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderOverlayLines(MeshRef lines, int textureId, float lineWidth, bool blend) ++ { ++ RenderMesh(lines); ++ } ++ + public virtual bool RenderOptimumTaaResolve() + { + return false; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs.patch new file mode 100644 index 00000000..a423dbc6 --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs.patch @@ -0,0 +1,42 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs +index 46eb353..f15c56e 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs +@@ -44,28 +44,32 @@ public class SystemRenderPlayerAimAcc : ClientSystem + game.GlTranslate(game.Width / 2, game.Height / 2, 50.0); + float num = Math.Max(0.01f, 1f - game.EntityPlayer.Attributes.GetFloat("aimingAccuracy")); + float num2 = 800f * num; + game.GlScale(num2, num2, 0.0); + game.guiShaderProg.ModelViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(aimRectangleRef); ++ // Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the draw is a ++ // seam, so a native platform records this pass itself. The neutral body is the ++ // RenderMesh call it replaced; the line width and the blend state are the ones this ++ // method just set, passed on rather than read back off tracked GL state. ++ game.Platform.RenderOverlayLines(aimRectangleRef, 0, 0.5f, blend: true); + game.GlPopMatrix(); + game.Platform.GLLineWidth(1f); + game.GlPushMatrix(); + game.GlTranslate(game.Width / 2, game.Height / 2, 50.0); + game.GlScale(20.0, 20.0, 0.0); + game.GlTranslate(0.0, -10f * num + 0.5f, 0.0); + game.guiShaderProg.ModelViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(aimLinesRef[0]); ++ game.Platform.RenderOverlayLines(aimLinesRef[0], 0, 1f, blend: true); + game.GlTranslate(0.0, 20f * num - 1f, 0.0); + game.guiShaderProg.ModelViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(aimLinesRef[1]); ++ game.Platform.RenderOverlayLines(aimLinesRef[1], 0, 1f, blend: true); + game.GlTranslate(-10f * num + 0.5f, -10f * num + 0.5f, 0.0); + game.guiShaderProg.ModelViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(aimLinesRef[2]); ++ game.Platform.RenderOverlayLines(aimLinesRef[2], 0, 1f, blend: true); + game.GlTranslate(20f * num - 1f, 0.0, 0.0); + game.guiShaderProg.ModelViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(aimLinesRef[3]); ++ game.Platform.RenderOverlayLines(aimLinesRef[3], 0, 1f, blend: true); + game.GlPopMatrix(); + } + } + + public void GenAim() diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index 8be8a74d..09a3f967 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -41,6 +41,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerAimAcc.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerEffects.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSkyColor.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch From 8eaf5d9e06b8b877099ff9d6f308b37943c2a06e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 12:32:19 +0200 Subject: [PATCH 198/226] wip(native-world): the night sky, the moon, the cube particles and the decals on the native API Phase 3b decision 5 stage 2, second wave. The sky dome proved the mesh-draw API; these are the systems whose shape it did not exercise - a cube map, an instanced pool, an indirect multi-draw - and the first two that write the motion attachment. Seams on ClientPlatformAbstract, each with the neutral body of the draw it replaced, so OpenGL is unchanged (ClientPlatformWindows overrides none of them), each listed in Optimum.Patcher/Program.cs and in VulkanClientPlatform.ExpectedVirtuals: - RenderNightSkyBox(MeshRef, int cubeTextureId) - RenderCelestialQuad(MeshRef, int bodyTextureId, int skyTextureId, int glowTextureId) - RenderParticles(MeshRef, int quantity, int particleTextureId) - RenderDecalPool(MeshRef, int[] starts, int[] sizes, int groupCount, int decalTextureId, int blockTextureId) SystemRenderDecals now runs the pool's own public FrustumCull and hands the seam its results - MeshDataPool.Draw split in two - and MeshDataPool gained a read-only ModelRef for the mesh half of that. Platform: VulkanClientPlatform.NativeWorld.cs, one NativeWorldEnabled switch keeping every neutral body reachable. Two derivations are shared by all four passes and are why none of them reads GlStateTracker (decision 3): - NativeWorldPassColorSlots - the pass's colour slots are the set the emulated route's draw-buffer mask would hold, from MotionAttachmentIndex and OptimumMotionWriteActive: every bound slot with TAA off, Primary's default set with TAA on, plus the motion attachment exactly while a motion window is open. - NativeWorldBlend - the caller's blend mode per attachment, with replace (ONE, ZERO, ADD) forced on the motion attachment inside a window, which is what ApplyOptimumMotionBlendState does for an emulated draw. The motion write itself still goes through the one writer include, unchanged. Not in this wave, with the reason (docs/vulkan-native-render-systems.md section 3c): the sun, which draws under "standard", the shared program the entity stage owns; the quad particle pool, whose OIT weighted blend into Transparent belongs to the OIT pass and not to this seam (the pipeline request names particlescube, so it falls through by construction); and aurora and the two cloud renderers, which reach the device through OptimumForkGraphics/VulkanForkGraphics - a second emulation surface with no native counterpart and no decision yet about whether it gets one. Tests: Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs - old route against native route on every attachment of Primary for all four systems, the motion attachment compared bit for bit for the two that write it, the draw kinds counted apart (mesh, instanced, indirect), no emulation call inside a native pass, and the slot derivation checked against all three window states. Seam coverage in Optimum.Tests/native-world-systems-coverage-tests.cs; taa-sky-decal-motion-coverage-tests.cs follows the decal call's split into the window. Verified on this machine with the implicit-layer disable set (VK_LOADER_DEBUG=layer vulkaninfo --summary: only VK_LAYER_MESA_device_select inserted): dotnet build VintageStory.slnx -c Release, 0 errors; extract-patches + check-patches (158 patches, 0 conflict, 43 runtime patches with exact donors); Optimum.Tests 1254 passed, 34 skipped; Optimum.Render.Vulkan.Tests 1083 passed, validation clean under sync,best. Not verified in game - agents do not launch the client. --- Optimum.Patcher/Program.cs | 11 + .../NativeWorldSystemsTests.cs | 640 ++++++++++++++++++ .../VulkanClientPlatform.NativeWorld.cs | 370 ++++++++++ .../Platform/VulkanClientPlatform.cs | 5 + .../native-world-systems-coverage-tests.cs | 184 ++++- .../taa-sky-decal-motion-coverage-tests.cs | 5 +- docs/vulkan-native-render-systems.md | 49 ++ .../Client/MeshPool/MeshDataPool.cs.patch | 30 +- .../ClientPlatformAbstract.cs.patch | 99 ++- .../SystemRenderDecals.cs.patch | 11 +- .../SystemRenderNightSky.cs.patch | 20 + .../SystemRenderParticles.cs.patch | 22 +- .../SystemRenderSunMoon.cs.patch | 20 +- patches/cecil-owned.list | 1 + 14 files changed, 1451 insertions(+), 16 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs create mode 100644 patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs.patch diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index edee30a0..debc1acb 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -85,6 +85,12 @@ // Phase 3b stage 2: the sky dome's draw seam, so a native platform records that pass // itself. The neutral body is the RenderMesh call it replaced. "RenderSkyDome", + // Phase 3b stage 2: the draw seams of the remaining sky, particle and decal systems, + // each with the neutral body of the draw it replaced. + "RenderNightSkyBox", + "RenderCelestialQuad", + "RenderParticles", + "RenderDecalPool", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", // Phase 3b stage 1d: the draw seams of the two TAA passes, so a native platform @@ -987,6 +993,11 @@ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "MergeTransparentRenderPass", 0), // TAA P4: the cube-particle motion window and its uniforms. new("Vintagestory.Client.NoObf.SystemRenderParticles", "OnRenderFrame3D", 1), + // Phase 3b stage 2: the pools' instanced draws go through the platform's particle seam. + new("Vintagestory.Client.NoObf.SystemRenderParticles", "Render", 2), + // Phase 3b stage 2: the star box and the moon draw through their own platform seams. + new("Vintagestory.Client.NoObf.SystemRenderNightSky", "OnRenderFrame3D", 1), + new("Vintagestory.Client.NoObf.SystemRenderSunMoon", "OnRenderFrame3D", 1), // TAA P4: the decal motion window. new("Vintagestory.Client.NoObf.SystemRenderDecals", "OnRenderFrame3D", 1), new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderFinalComposition", 0), diff --git a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs new file mode 100644 index 00000000..bdfe3208 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs @@ -0,0 +1,640 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The world systems Phase 3b stage 2 moved onto the native device API after the sky dome - the +/// night sky box, the moon, the cube particle pool and the decal pool - each drawn twice on one +/// Vulkan device: through its seam's neutral body (the OpenGL body's own draw, the route every +/// system that has not moved still takes) and through the native pass the Vulkan platform +/// records (docs/vulkan-native-render-systems.md, decision 5 stage 2). +/// +/// Behavioural identity is the acceptance rule (decision 6): the same program, the same mesh and +/// the same fixed state have to put the same pixels on every attachment of Primary - the motion +/// attachment included, bit for bit, because a world pass that writes motion has to land exactly +/// what the GL path lands there - and the native route must not touch the GL state tracker, a +/// texture unit or a draw-buffer mask while its pass is open. +/// +/// The sky dome itself is pinned by NativeSkyTests and the device's mesh-draw entry points by +/// NativeMeshDrawTests; those files are not duplicated here. +/// +public class NativeWorldSystemsTests(ITestOutputHelper output) +{ + private const int Size = 16; + + /// Primary's colour slots in this fixture: scene, glow, then the motion attachment. + private const int SceneSlot = 0; + private const int GlowSlot = 1; + private const int MotionSlot = 2; + + /// The platform with no window: both routes take their size from this seam. + private sealed class WorldPlatform : VulkanClientPlatform + { + public WorldPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + // ------------------------------------------------------------------------- night sky + + /// + /// The star box's native pass draws what its seam's neutral body draws: one declared pass, + /// one native mesh draw of the cube through the samplerCube the program declares, no + /// emulation inside the pass, and the same pixels on every attachment. + /// + [SkippableFact] + public void TheNativeNightSkyPassMatchesTheSeamsNeutralBody() + { + using Session session = Open("nightsky"); + int cube = session.CubeGradient(); + + byte[][] emulated = session.RunFrame(native: false, blending: false, depth: false, motion: false, + s => s.Platform.RenderNightSkyBox(s.Mesh, cube)); + + long passes = session.Seam.NativePassesForTests; + long meshes = session.Seam.NativeMeshDrawsForTests; + long inside = session.Seam.EmulationCallsInNativePassesForTests; + byte[][] native = session.RunFrame(native: true, blending: false, depth: false, motion: false, + s => s.Platform.RenderNightSkyBox(s.Mesh, cube)); + + Assert.Equal(1, session.Seam.NativePassesForTests - passes); + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); + AssertSameAttachments(emulated, native, "nightsky"); + GpuTest.AssertClean(session.Seam); + } + + // -------------------------------------------------------------------------- celestial + + /// + /// The moon's native pass matches its seam's neutral body, with the body texture resolved + /// from its handle and the sky and glow frame textures resolved from theirs rather than from + /// the units ShaderProgramCelestialobject's setters bound them to. + /// + [SkippableFact] + public void TheNativeCelestialPassMatchesTheSeamsNeutralBody() + { + using Session session = Open("celestialobject"); + int body = session.Gradient(0); + int sky = session.Gradient(1); + int glow = session.Gradient(2); + + byte[][] emulated = session.RunFrame(native: false, blending: true, depth: false, motion: false, + s => s.Platform.RenderCelestialQuad(s.Mesh, body, sky, glow)); + + long meshes = session.Seam.NativeMeshDrawsForTests; + long inside = session.Seam.EmulationCallsInNativePassesForTests; + byte[][] native = session.RunFrame(native: true, blending: true, depth: false, motion: false, + s => s.Platform.RenderCelestialQuad(s.Mesh, body, sky, glow)); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); + AssertSameAttachments(emulated, native, "celestialobject"); + GpuTest.AssertClean(session.Seam); + } + + // -------------------------------------------------------------------------- particles + + /// + /// The cube pool's native pass matches its seam's neutral body, and the draw is recorded as + /// an instanced draw rather than as as many single draws. + /// + [SkippableFact] + public void TheNativeParticlePassMatchesTheSeamsNeutralBody() + { + using Session session = Open("particlescube"); + + byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: false, + s => s.Platform.RenderParticles(s.Mesh, 4, 0)); + + long instanced = session.Seam.NativeInstancedDrawsForTests; + long inside = session.Seam.EmulationCallsInNativePassesForTests; + byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: false, + s => s.Platform.RenderParticles(s.Mesh, 4, 0)); + + Assert.Equal(1, session.Seam.NativeInstancedDrawsForTests - instanced); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); + AssertSameAttachments(emulated, native, "particlescube"); + GpuTest.AssertClean(session.Seam); + } + + /// + /// Inside a motion window the cube pool's native pass takes the motion attachment into its + /// colour slots, and every attachment - the motion one bit for bit - comes out of the native + /// route exactly as it comes out of the neutral body's draw under the same window. This is + /// the temporal contract: a world pass that writes motion lands what the GL path lands. + /// + [SkippableFact] + public void TheNativeParticlePassLeavesTheMotionAttachmentIdentical() + { + using Session session = Open("particlescube"); + session.OpenMotionWindow(); + + byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: true, + s => s.Platform.RenderParticles(s.Mesh, 4, 0)); + byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: true, + s => s.Platform.RenderParticles(s.Mesh, 4, 0)); + + Assert.Equal(emulated[MotionSlot], native[MotionSlot]); + AssertSameAttachments(emulated, native, "particlescube (motion window)"); + GpuTest.AssertClean(session.Seam); + } + + // ----------------------------------------------------------------------------- decals + + /// + /// The decal pool's native pass matches its seam's neutral body, and the draw is recorded as + /// one indirect multi-draw out of the per-slot indirect ring rather than one draw per group. + /// + [SkippableFact] + public void TheNativeDecalPassMatchesTheSeamsNeutralBody() + { + using Session session = Open("decals"); + int decal = session.Gradient(0); + int block = session.Gradient(1); + int[] starts = { 0, 0, 3 * 4, 0 }; + int[] sizes = { 3, 3 }; + + byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: false, + s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + + long indirect = session.Seam.NativeIndirectDrawsForTests; + long inside = session.Seam.EmulationCallsInNativePassesForTests; + byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: false, + s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + + Assert.Equal(1, session.Seam.NativeIndirectDrawsForTests - indirect); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); + AssertSameAttachments(emulated, native, "decals"); + GpuTest.AssertClean(session.Seam); + } + + /// + /// Inside a motion window the decal pass's motion attachment is identical between the two + /// routes, for the same reason the particle pass's is: a decal writes the motion vector of + /// the surface it sits on, with its own nudged depth. + /// + [SkippableFact] + public void TheNativeDecalPassLeavesTheMotionAttachmentIdentical() + { + using Session session = Open("decals"); + session.OpenMotionWindow(); + int decal = session.Gradient(0); + int block = session.Gradient(1); + int[] starts = { 0, 0, 3 * 4, 0 }; + int[] sizes = { 3, 3 }; + + byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: true, + s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: true, + s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + + Assert.Equal(emulated[MotionSlot], native[MotionSlot]); + AssertSameAttachments(emulated, native, "decals (motion window)"); + GpuTest.AssertClean(session.Seam); + } + + // ------------------------------------------------------------------- switch and slots + + /// + /// The neutral body draws through the emulation layer and the native route does not: the + /// switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. + /// + [SkippableFact] + public void TheNeutralBodiesDrawThroughTheEmulationLayerAndTheNativeRouteDoesNot() + { + using Session session = Open("particlescube"); + + long nativeBefore = session.Seam.NativeDrawsForTests; + long emulatedBefore = session.Seam.EmulationCallsForTests; + session.RunFrame(native: false, blending: true, depth: true, motion: false, + s => s.Platform.RenderParticles(s.Mesh, 2, 0)); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeBefore); + Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + + long inside = session.Seam.EmulationCallsInNativePassesForTests; + session.RunFrame(native: true, blending: true, depth: true, motion: false, + s => s.Platform.RenderParticles(s.Mesh, 2, 0)); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The colour slots a native world pass declares are the set the emulated route's + /// draw-buffer mask holds at the same point in the frame, derived from the platform's own + /// motion-window state: Primary's default colour set, plus the motion attachment exactly + /// while a window is open, and every bound slot with TAA off. + /// + [SkippableFact] + public void TheDeclaredColourSlotsAreTheOnesTheEmulatedMaskWouldHold() + { + using Session session = Open("particlescube"); + MethodInfo slots = typeof(VulkanClientPlatform).GetMethod("NativeWorldPassColorSlots", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + // TAA off: the attachment index is negative and the pass takes every bound slot. + session.Platform.SetOptimumMotionAttachmentIndex(-1); + Assert.Equal(0b111u, (uint)slots.Invoke(session.Platform, new object[] { session.Primary })!); + + // TAA on, window closed: Primary's default colour set, the motion attachment out. + session.Platform.SetOptimumMotionAttachmentIndex(MotionSlot); + SetMotionWriteActive(session.Platform, false); + Assert.Equal(0b011u, (uint)slots.Invoke(session.Platform, new object[] { session.Primary })!); + + // TAA on, window open: the motion attachment joins the set. + SetMotionWriteActive(session.Platform, true); + Assert.Equal(0b111u, (uint)slots.Invoke(session.Platform, new object[] { session.Primary })!); + } + + // ---------------------------------------------------------------------------- helpers + + private void AssertSameAttachments(byte[][] emulated, byte[][] native, string what) + { + for (int slot = 0; slot < emulated.Length; slot++) + { + output.WriteLine(what + " slot " + slot + " centre emulated " + Centre(emulated[slot]) + + " native " + Centre(native[slot])); + Assert.Equal(emulated[slot], native[slot]); + } + } + + private static string Centre(byte[] pixels) + { + int i = (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + /// + /// The window flag ClientPlatformWindows keeps private. The tests set it directly rather + /// than through BeginMotionWrite, which also wants a temporal frame, a jitter window and the + /// TAA targets - none of which change what is under test here. + /// + private static void SetMotionWriteActive(VulkanClientPlatform platform, bool active) => + typeof(ClientPlatformWindows) + .GetField("optimumMotionWriteActive", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(platform, active); + + private Session Open(string program) + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + + Session? session = Session.TryOpen(output, manifest, program); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + // ---------------------------------------------------------------------------- driving + + /// + /// The platform, its device, the Primary target its stage binds (scene, glow and the motion + /// attachment), one vanilla program and one mesh, installed the way the client installs them + /// and put back afterwards. + /// + private sealed class Session : IDisposable + { + private static readonly float[] Identity = + { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + + public WorldPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Primary { get; private set; } = null!; + public MeshRef Mesh { get; private set; } = null!; + + private ShaderProgram program = null!; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + private int gradients; + + public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory, string programName) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-world-" + Guid.NewGuid().ToString("N")); + var platform = new WorldPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.Primary = CreatePrimary(seam); + InstallFrameBuffers(platform, session.Primary); + // TAA on with the motion attachment appended after Primary's default colour set, so + // the window-derived slot masks under test mean something. + platform.SetOptimumMotionAttachmentIndex(MotionSlot); + + var linked = new ShaderProgram { PassName = programName }; + Link(seam, linked, programName, new[] { "projectionMatrix" }); + session.program = linked; + session.Mesh = platform.UploadMesh(BuildQuad()); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + // The mesh goes first: VAO's finalizer reaches for ScreenManager.Platform, which is + // about to be the client's again, and a live handle there would crash the test host. + if (Mesh != null) Platform.DeleteMesh(Mesh); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// Opens the caller's motion window, the way BeginMotionWrite leaves the platform. + public void OpenMotionWindow() => SetMotionWriteActive(Platform, true); + + /// + /// One frame at the point the system under test runs: Primary bound and cleared, the + /// draw-buffer mask the motion window would have left, the caller's blend, depth and + /// cull state, the program in use, then the seam. + /// + public unsafe byte[][] RunFrame(bool native, bool blending, bool depth, bool motion, Action draw) + { + VulkanDevice seam = Seam; + Platform.NativeWorldEnabled = native; + uint mask = motion ? 0b111u : 0b011u; + + Platform.BeginFrame(); + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, 0b111); + seam.ClearColor(SceneSlot, 0.125f, 0.25f, 0.5f, 1f); + seam.ClearColor(GlowSlot, 0.75f, 0.5f, 0.25f, 1f); + seam.ClearColor(MotionSlot, 0.375f, 0.625f, 0.875f, 1f); + seam.ClearDepth(1f); + seam.SetDrawBuffers(Primary.FboId, (int)mask); + + Platform.CurrentFrameBuffer = Primary; + seam.SetViewport(0, 0, Size, Size); + seam.SetDepthTest(depth); + seam.SetDepthMask(depth); + seam.SetCullFace(false); + seam.SetBlend(blending, EnumBlendMode.Standard); + // The replace-blending the window forces on the motion attachment, which the native + // pass states per attachment instead. + if (motion) Platform.ApplyOptimumMotionBlendState(); + + seam.UseProgram(program.ProgramId); + ShaderProgramBase.CurrentShaderProgram = program; + seam.SetUniformMatrix(program.ProgramId, program.uniformLocations["projectionMatrix"], Identity); + + draw(this); + + var pixels = new byte[Primary.ColorTextureIds.Length][]; + for (int slot = 0; slot < pixels.Length; slot++) pixels[slot] = Read(seam, Primary.ColorTextureIds[slot]); + Platform.EndFrame(); + return pixels; + } + + /// One attachment's pixels, read through a framebuffer that holds only it. + private unsafe byte[] Read(VulkanDevice seam, int texture) + { + int reader = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(reader, 1); + seam.BindFramebuffer(reader); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + seam.BindFramebuffer(Primary.FboId); + return pixels; + } + + // ----------------------------------------------------------------- fixtures + + /// A small 2D gradient, so a sampling difference between the routes would show. + public unsafe int Gradient(int phase) + { + gradients++; + var pixels = new byte[8 * 8 * 4]; + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + int i = (y * 8 + x) * 4; + pixels[i] = (byte)(16 + x * 30 + phase * 7); + pixels[i + 1] = (byte)(32 + y * 25); + pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20); + pixels[i + 3] = 255; + } + } + fixed (byte* first = pixels) + { + return Seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false); + } + } + + /// + /// The star cube map: six faces, one gradient each, uploaded the way + /// ClientPlatformWindows.Load3DTextureCube uploads SystemRenderNightSky's stars. The + /// native pass has to resolve it into the bindless table's cube array, not the 2D one. + /// + public unsafe int CubeGradient() + { + const int face = 8; + var faces = new byte[6][]; + var pointers = new IntPtr[6]; + var handles = new System.Runtime.InteropServices.GCHandle[6]; + for (int f = 0; f < 6; f++) + { + faces[f] = new byte[face * face * 4]; + for (int y = 0; y < face; y++) + { + for (int x = 0; x < face; x++) + { + int i = (y * face + x) * 4; + faces[f][i] = (byte)(f * 40); + faces[f][i + 1] = (byte)(x * 30); + faces[f][i + 2] = (byte)(y * 30); + faces[f][i + 3] = 255; + } + } + handles[f] = System.Runtime.InteropServices.GCHandle.Alloc( + faces[f], System.Runtime.InteropServices.GCHandleType.Pinned); + pointers[f] = handles[f].AddrOfPinnedObject(); + } + + try + { + return Seam.CreateTextureCube(face, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, pointers); + } + finally + { + for (int f = 0; f < 6; f++) handles[f].Free(); + } + } + + /// Links one vanilla program as ShaderRegistry does and fills the locations the test sets. + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant()); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + /// Primary as a world stage has it: scene at 0, glow at 1, motion at 2, plus depth. + private static FrameBufferRef CreatePrimary(VulkanDevice seam) + { + var primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new int[3], + DepthTextureId = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false), + }; + for (int slot = 0; slot < primary.ColorTextureIds.Length; slot++) + { + primary.ColorTextureIds[slot] = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + seam.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + primary.ColorTextureIds[slot], 0); + } + seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + seam.SetDrawBuffers(primary.FboId, 0b111); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status); + return primary; + } + + private static void InstallFrameBuffers(WorldPlatform platform, FrameBufferRef primary) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = primary; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + } + + /// + /// Two triangles covering the target, with positions, UVs, a colour and flags - enough + /// for every program under test, whose remaining vertex inputs the layout fills with the + /// constant defaults GL promises. Six indices, so a multi-draw can take them as two + /// groups of three. + /// + private static MeshData BuildQuad() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + float[] positions = + { + -0.9f, -0.9f, 0.5f, + 0.9f, -0.9f, 0.5f, + 0.9f, 0.9f, 0.5f, + -0.9f, 0.9f, 0.5f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + for (int i = 0; i < 4; i++) + { + mesh.AddVertex(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + } + + // ---------------------------------------------------------------- native shaders + + /// The four programs' manifest, built once for the whole class. + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + foreach (string program in new[] { "nightsky", "celestialobject", "particlescube", "decals" }) + { + NativeShaderBuildResult one = builder.Build(source, program); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + } + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), "optimum-native-world-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs new file mode 100644 index 00000000..826941f1 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), Phase 3b decision 5 +// stage 2: the sky systems that are not the dome, the particle pools and the decal pool, all +// on the native device API the sky dome proved. +// +// What they draw, and where the other side is: +// - the night sky box - SystemRenderNightSky's 75-unit star cube, seam +// ClientPlatformAbstract.RenderNightSkyBox, neutral body RenderMesh; +// - the moon - SystemRenderSunMoon's quad under celestialobject, seam +// ClientPlatformAbstract.RenderCelestialQuad, neutral body RenderMesh; +// - the cube particles - SystemRenderParticles' instanced pool draw on Primary, seam +// ClientPlatformAbstract.RenderParticles, neutral body +// RenderMeshInstanced; +// - the decals - SystemRenderDecals' pooled multi-draw, seam +// ClientPlatformAbstract.RenderDecalPool, neutral body the +// RenderMesh multi-draw the second half of MeshDataPool.Draw made. +// NativeWorldEnabled false takes the neutral body on the Vulkan device too, which is the route +// the differential tests compare against. +// +// Target and slots: every one of them draws into the framebuffer its stage has bound - Primary +// for all four. The colour slots are NativeWorldPassColorSlots: the same set the emulated route's +// draw-buffer mask would hold, derived from the platform's own motion-window state +// (MotionAttachmentIndex, OptimumMotionWriteActive) rather than from the GL state tracker +// (decision 3). That is what makes a cube particle's and a decal's motion vector land through +// the one writer include exactly while their caller's window is open, and keeps the motion +// attachment out of the night sky's and the moon's scope entirely. +// +// State that is not obvious, per system, and where it comes from: +// - the night sky and the moon run with the depth test off, because their callers call +// GlDisableDepthTest; GL writes no depth with the test off, so depth writes are off too. +// The night sky also disables culling itself. The moon inherits the cull state the night +// sky left, which is off - no vanilla Opaque renderer between them turns it back on. +// - the cube particles and the decals run with the depth test and depth writes on: the Opaque +// stage is entered with ChunkRenderer.RenderOpaque's depth mask and test, and the AfterOIT +// stage is entered with ClientMain's own GlDepthMask/GlEnableDepthTest. Culling is off for +// both: SystemRenderNightSky leaves it off for the rest of the Opaque stage, and +// SystemRenderDecals calls GlDisableCullFace itself. +// - blending is on in the standard mode for the moon, the cube particles and the decals +// (their callers call GlToggleBlend(on: true)), and off for the night sky. +// - the motion attachment never blends. Inside a motion window the native pass states +// replace-blending on that one attachment per attachment, which is what +// ApplyOptimumMotionBlendState does for an emulated draw. +// +// What pins them: NativeWorldSystemsTests (old route against native route, including the motion +// attachment bit for bit) and Optimum.Tests/native-world-systems-coverage-tests.cs. +public partial class VulkanClientPlatform +{ + /// + /// False runs each seam's neutral body - the OpenGL body's own draw - on the Vulkan device + /// instead of the native pass: the old route the differential tests compare against, in the + /// pattern of . + /// + internal bool NativeWorldEnabled { get; set; } = true; + + /// The star cube's pipeline: no per-draw uniform, one samplerCube. + private readonly NativeMeshPass nativeNightSky = + new("nightsky", Array.Empty(), new[] { "ctex" }); + + /// + /// The moon's pipeline. "tex" is the body's own texture; "sky" and "glow" are the frame + /// textures skycolor.fsh reads to shade the body against the sky behind it, and they are + /// passed here because a native draw resolves what it samples from handles and nothing else + /// in this pass would refresh the frame table's entries for them. + /// + private readonly NativeMeshPass nativeCelestial = + new("celestialobject", Array.Empty(), new[] { "tex", "sky", "glow" }); + + /// The cube particle pool's pipeline: no per-draw uniform and no sampler at all. + private readonly NativeMeshPass nativeParticlesCube = + new("particlescube", Array.Empty(), Array.Empty()); + + /// + /// The decal pool's pipeline. origin and modelViewMatrix are DRAW uniforms the client system + /// already set through the program's own setters, so they ride in the program's push shadow + /// and the pass writes nothing per draw; the two atlases are its samplers. + /// + private readonly NativeMeshPass nativeDecals = + new("decals", Array.Empty(), new[] { "decalTexture", "blockTexture" }); + + // ------------------------------------------------------------------ shared derivations + + /// + /// The colour slots a world pass writes: the set the emulated route's draw-buffer mask would + /// hold at this point in the frame, computed from the platform's own motion-window state + /// rather than read back out of the tracker. + /// + /// Outside Primary, and with TAA off ( + /// negative), that is every bound colour slot. On Primary with TAA on it is Primary's default + /// colour set, plus the motion attachment exactly while a motion window is open - the two + /// sets and + /// switch between. + /// + private uint NativeWorldPassColorSlots(FrameBufferRef target) + { + uint all = NativeAllColorSlots(target); + int motion = MotionAttachmentIndex; + if (motion < 0 || motion >= 32) return all; + + List buffers = FrameBuffers; + if (buffers == null || buffers.Count == 0 || !ReferenceEquals(target, buffers[0])) return all; + + uint mask = OptimumMotionWriteActive + ? (1u << (motion + 1)) - 1u + : (1u << motion) - 1u; + return mask & all; + } + + /// + /// A world pass's per-attachment blend: the caller's blend mode on every colour attachment, + /// except the motion attachment inside an open motion window, which replaces rather than + /// blends. A blended motion vector is a weighted average of two surfaces' displacements and + /// belongs to neither, which is why forces + /// (ONE, ZERO) with FUNC_ADD there on the emulated route; this states the same thing on the + /// pipeline instead of through a tracked toggle. + /// + private AttachmentBlend[] NativeWorldBlend(RenderTargetFormats formats, bool blending) + { + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) + { + blend[i] = AttachmentBlend.Default; + blend[i].Enabled = blending; + } + + int motion = MotionAttachmentIndex; + if (OptimumMotionWriteActive && motion >= 0 && motion < blend.Length) + { + blend[motion] = AttachmentBlend.Default; + blend[motion].Enabled = blending; + blend[motion].SrcColor = BlendFactor.One; + blend[motion].DstColor = BlendFactor.Zero; + blend[motion].SrcAlpha = BlendFactor.One; + blend[motion].DstAlpha = BlendFactor.Zero; + } + return blend; + } + + /// + /// Everything a native world draw needs before it can be recorded: the target the stage + /// bound, the program it is drawing with, the mesh and its vertex layout, the colour slots + /// and their formats, and the pipeline for that combination. False means the caller takes + /// its seam's neutral body, which is always a legal answer. + /// + private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending, bool depth, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline) + { + target = null!; + vao = null!; + slots = 0; + pipeline = null!; + + if (!NativeWorldEnabled || device == null || mesh == null) return false; + + FrameBufferRef bound = CurrentFrameBuffer; + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + var buffers = mesh as VAO; + if (bound == null || program == null || buffers == null || buffers.VaoId == 0 || buffers.Disposed) + { + return false; + } + + int layoutId = device.NativeMeshLayoutId(buffers.VaoId); + if (layoutId < 0) return false; + + uint colorSlots = NativeWorldPassColorSlots(bound); + if (colorSlots == 0) return false; + + RenderTargetFormats? formats = device.NativeTargetFormats(bound.FboId, colorSlots); + if (formats == null) return false; + + NativePipeline? built = NativeMeshPipelineFor(pass, program, bound.FboId, colorSlots, layoutId, + new NativePipelineDescription + { + Blend = NativeWorldBlend(formats, blending), + DepthTest = depth, + DepthWrite = depth, + DepthCompare = CompareOp.Less, + Cull = CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + }); + if (built == null) return false; + + target = bound; + vao = buffers; + slots = colorSlots; + pipeline = built; + return true; + } + + /// + /// Opens the native pass for one world draw on the target its stage bound, with the reads it + /// samples declared outright. + /// + private bool NativeWorldBeginPass(string name, FrameBufferRef target, uint slots, int[] reads) + { + Rect2D viewport = device.NativeCurrentViewport; + return device.BeginNativePass(new NativePassDescription + { + Name = name + "/" + target.FboId, + FramebufferId = target.FboId, + ColorSlots = slots, + Reads = reads, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + }); + } + + /// + /// Closes the native pass and declares the stage's own pass context again, because every + /// renderer after this one draws into the same target through the emulated path - the same + /// restoration the sky dome's pass and the TAA resolve's do. + /// + private void NativeWorldEndPass(FrameBufferRef target, string outer, PassFlags outerFlags) + { + device.EndNativePass(); + device.BindFramebuffer(target.FboId); + SetPassContext(outer, outerFlags); + } + + // ------------------------------------------------------------------------ the seams + + /// The star cube's draw: the native pass, or the seam's neutral body. + public override void RenderNightSkyBox(MeshRef nightSkyBox, int cubeTextureId) + { + // No depth and no blending: SystemRenderNightSky has called GlDisableDepthTest and + // GlDisableCullFace, and nightsky.fsh writes opaque colour into slot 0. + if (!NativeWorldPrepare(nativeNightSky, nightSkyBox, blending: false, depth: false, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) + { + base.RenderNightSkyBox(nightSkyBox, cubeTextureId); + return; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + if (NativeWorldBeginPass("NightSky", target, slots, new[] { cubeTextureId })) + { + // The cube map resolves into the bindless table's cube array, which the sampler's + // own kind selects - a 2D texture bound here would be refused rather than sampled. + device.DrawNativeMesh(pipeline, vao.VaoId, new[] + { + new NativeTexture(nativeNightSky.Samplers[0], cubeTextureId), + }); + } + NativeWorldEndPass(target, outer, outerFlags); + } + + /// The moon's draw: the native pass, or the seam's neutral body. + public override void RenderCelestialQuad(MeshRef quad, int bodyTextureId, int skyTextureId, int glowTextureId) + { + // Blending on in the standard mode and no depth: SystemRenderSunMoon has called + // GlToggleBlend(on: true), GlDisableCullFace and GlDisableDepthTest before both bodies. + if (!NativeWorldPrepare(nativeCelestial, quad, blending: true, depth: false, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) + { + base.RenderCelestialQuad(quad, bodyTextureId, skyTextureId, glowTextureId); + return; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + if (NativeWorldBeginPass("Celestial", target, slots, new[] { bodyTextureId, skyTextureId, glowTextureId })) + { + device.DrawNativeMesh(pipeline, vao.VaoId, new[] + { + new NativeTexture(nativeCelestial.Samplers[0], bodyTextureId), + new NativeTexture(nativeCelestial.Samplers[1], skyTextureId), + new NativeTexture(nativeCelestial.Samplers[2], glowTextureId), + }); + } + NativeWorldEndPass(target, outer, outerFlags); + } + + /// + /// One particle pool's instanced draw: the native pass, or the seam's neutral body. + /// + /// Only the cube pool takes the native route. The quad pool draws into the Transparent + /// target in the OIT stage, whose per-attachment weighted-blend state belongs to the OIT + /// pass rather than to the particle system, and which this seam cannot state; the pipeline + /// request names "particlescube", so a draw under any other program falls through to the + /// neutral body on its own rather than by a separate test. + /// + public override void RenderParticles(MeshRef model, int quantity, int particleTextureId) + { + if (quantity <= 0) + { + base.RenderParticles(model, quantity, particleTextureId); + return; + } + + // Blending on in the standard mode (the caller's GlToggleBlend) and the Opaque stage's + // depth test and depth writes, which ChunkRenderer.RenderOpaque established and no + // renderer between it and the particles turns off again. + if (!NativeWorldPrepare(nativeParticlesCube, model, blending: true, depth: true, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) + { + base.RenderParticles(model, quantity, particleTextureId); + return; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + // Inside the caller's motion window the motion attachment is one of the pass's colour + // slots (NativeWorldPassColorSlots) and replaces rather than blends (NativeWorldBlend), so + // particlescube.fsh's motion.glsl writer lands exactly what it lands on the GL path. + if (NativeWorldBeginPass("Particles", target, slots, Array.Empty())) + { + device.DrawNativeMeshInstanced(pipeline, vao.VaoId, quantity, ReadOnlySpan.Empty); + } + NativeWorldEndPass(target, outer, outerFlags); + } + + /// + /// The decal pool's multi-draw: the native pass, or the seam's neutral body. + /// + /// The caller has already run the pool's own public MeshDataPool.FrustumCull - the first + /// half of MeshDataPool.Draw - so both routes draw the same ranges and only the draw + /// command differs. + /// + public override void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, + int groupCount, int decalTextureId, int blockTextureId) + { + if (groupCount <= 0 || indicesStarts == null || indicesSizes == null) + { + base.RenderDecalPool(decalMesh, indicesStarts!, indicesSizes!, groupCount, decalTextureId, blockTextureId); + return; + } + + // Blending on in the standard mode and the AfterOIT stage's depth test and depth writes, + // which ClientMain sets before the stage; SystemRenderDecals turns culling off itself. + if (!NativeWorldPrepare(nativeDecals, decalMesh, blending: true, depth: true, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) + { + base.RenderDecalPool(decalMesh, indicesStarts, indicesSizes, groupCount, decalTextureId, blockTextureId); + return; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + // Inside the caller's motion window the motion attachment is one of the pass's colour + // slots and replaces rather than blends, so decals.fsh's motion.glsl writer lands the + // surface's vector with the decal's own depth exactly as it does on the GL path. + if (NativeWorldBeginPass("Decals", target, slots, new[] { decalTextureId, blockTextureId })) + { + device.DrawNativeMeshMulti(pipeline, vao.VaoId, indicesStarts, indicesSizes, groupCount, new[] + { + new NativeTexture(nativeDecals.Samplers[0], decalTextureId), + new NativeTexture(nativeDecals.Samplers[1], blockTextureId), + }); + } + NativeWorldEndPass(target, outer, outerFlags); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 5dcff836..7a14d5ea 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -139,6 +139,11 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderOptimumSkyMotion", Array.Empty()), // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. new(true, "RenderSkyDome", new[] { "MeshRef", "Int32", "Int32", "Single[]" }), + // Phase 3b stage 2: the remaining sky, particle and decal draw seams. + new(true, "RenderNightSkyBox", new[] { "MeshRef", "Int32" }), + new(true, "RenderCelestialQuad", new[] { "MeshRef", "Int32", "Int32", "Int32" }), + new(true, "RenderParticles", new[] { "MeshRef", "Int32", "Int32" }), + new(true, "RenderDecalPool", new[] { "MeshRef", "Int32[]", "Int32[]", "Int32", "Int32", "Int32" }), new(true, "RenderOptimumTaaResolve", Array.Empty()), new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), // Phase 3b stage 1: the two TAA passes' draw seams, which the native chain replaces. diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 03bb81b5..f1acdb02 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -10,12 +10,17 @@ namespace Optimum.Tests; /// OpenGL body's own draw, a patcher listing for that seam, and a Vulkan override that records a /// native pass with the old route kept reachable behind a switch. /// -/// The sky dome is the first. Later stages (chunks, entities, particles and decals, GUI) add -/// their seams to the same lists here rather than to a file named after the stage. +/// The sky dome was the first; the night sky box, the moon, the cube particle pool and the decal +/// pool followed. Later stages (chunks, entities, GUI) add their seams to the same lists here +/// rather than to a file named after the stage. /// public class NativeWorldSystemsCoverageTests { + /// The double quote the patcher's listings are spelled with, so assertions can name them. + private const string Q = "\""; + private const string SkyPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs"; + private const string WorldPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs"; private const string DeviceMeshFile = "Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"; private const string DeviceNativeFile = "Optimum.Render.Vulkan/VulkanDevice.Native.cs"; @@ -183,6 +188,181 @@ public void ThePipelineDescriptionAndKeyCarryTheMeshDrawState() Assert.Contains("_meshes.LayoutOf(description.VertexLayoutId)", native); } + // ------------------------------------- the night sky, the moon, the particles, the decals + + /// + /// The four seams of the second wave exist on the platform abstraction, each with the + /// neutral body of the draw it replaced - which is what makes "OFF is vanilla" true for + /// OpenGL, because ClientPlatformWindows overrides none of them. + /// + [Fact] + public void TheWorldSeamsHaveNeutralBodiesThatAreTheDrawsTheyReplaced() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + Assert.Contains("public virtual void RenderNightSkyBox(MeshRef nightSkyBox, int cubeTextureId)", platform); + Assert.Contains("RenderMesh(nightSkyBox);", platform); + + Assert.Contains( + "public virtual void RenderCelestialQuad(MeshRef quad, int bodyTextureId, int skyTextureId, int glowTextureId)", + platform); + Assert.Contains("RenderMesh(quad);", platform); + + Assert.Contains("public virtual void RenderParticles(MeshRef model, int quantity, int particleTextureId)", + platform); + Assert.Contains("RenderMeshInstanced(model, quantity);", platform); + + Assert.Contains( + "public virtual void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount, int decalTextureId, int blockTextureId)", + platform); + Assert.Contains("RenderMesh(decalMesh, indicesStarts, indicesSizes, groupCount);", platform); + + // The OpenGL platform leaves every one of them alone: nothing about the GL path changes. + string windows = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + foreach (string seam in new[] + { + "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", "RenderDecalPool", + }) + { + Assert.DoesNotContain(seam, windows); + } + } + + /// + /// Each render system draws through its seam and hands it the values a native pass cannot + /// read off the GL state: the textures it samples, and for the decals the cull results the + /// pool produced. + /// + [Fact] + public void TheWorldRenderersDrawThroughTheirSeams() + { + string nightSky = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs"); + Assert.Contains("game.Platform.RenderNightSkyBox(nightSkyBox, textureId);", nightSky); + Assert.DoesNotContain("game.Platform.RenderMesh(nightSkyBox);", nightSky); + + string sunMoon = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs"); + Assert.Contains( + "platform.RenderCelestialQuad(quadModel, moontextureIds[4], game.skyTextureId, game.skyGlowTextureId);", + sunMoon); + + string particles = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs"); + Assert.Contains("game.Platform.RenderParticles(particlePool.Model, particlePool.QuantityAlive, 0);", particles); + Assert.Contains("game.Platform.RenderParticles(particlePool2.Model, particlePool2.QuantityAlive, 0);", particles); + Assert.DoesNotContain("game.Platform.RenderMeshInstanced(", particles); + + // The motion window still wraps the draw: the cube pool writes the motion attachment + // through the one writer include, and the window is what puts that attachment in the + // colour set on both routes. + Assert.Contains("optimumPlatform.BeginMotionWrite()", particles); + Assert.Contains("optimumPlatform.EndMotionWrite();", particles); + + string decals = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs"); + // The cull half of MeshDataPool.Draw, then the seam with its cull results: both routes + // draw the same ranges and only the draw command differs. + Assert.Contains("decalPool.FrustumCull(game.frustumCuller, EnumFrustumCullMode.CullInstant);", decals); + Assert.Contains( + "game.Platform.RenderDecalPool(decalPool.ModelRef, decalPool.indicesStartsByte, decalPool.indicesSizes, decalPool.indicesGroupsCount,", + decals); + Assert.DoesNotContain("decalPool.Draw(game.api,", decals); + Assert.Contains("optimumPlatform.BeginMotionWrite()", decals); + } + + /// Every new or changed lib member of this wave is listed for the Cecil transplant. + [Fact] + public void TheWorldSeamsAndTheirCallersAreListedForTheTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + foreach (string seam in new[] + { + "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", "RenderDecalPool", + }) + { + Assert.Contains(Q + seam + Q, patcher); + } + + foreach (string caller in new[] + { + "SystemRenderNightSky" + Q + ", " + Q + "OnRenderFrame3D" + Q + ", 1", + "SystemRenderSunMoon" + Q + ", " + Q + "OnRenderFrame3D" + Q + ", 1", + "SystemRenderParticles" + Q + ", " + Q + "Render" + Q + ", 2", + "SystemRenderDecals" + Q + ", " + Q + "OnRenderFrame3D" + Q + ", 1", + }) + { + Assert.Contains(caller, patcher); + } + } + + /// + /// The Vulkan platform records all four systems as native passes, states their fixed state + /// rather than reading the tracker's, and keeps every neutral body reachable behind one + /// switch in the pattern of NativeSkyEnabled. + /// + [Fact] + public void TheVulkanPlatformRecordsTheWorldSystemsNativelyAndKeepsTheOldRoutes() + { + string world = Read(WorldPlatformFile); + + Assert.Contains("internal bool NativeWorldEnabled { get; set; } = true;", world); + foreach (string seam in new[] + { + "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", "RenderDecalPool", + }) + { + Assert.Contains("public override void " + seam + "(", world); + Assert.Contains("base." + seam + "(", world); + } + + // Each mesh-draw kind the device API grew for world systems is used by the system whose + // shape needs it: a single mesh, an instanced pool, an indirect multi-draw. + Assert.Contains("device.DrawNativeMesh(", world); + Assert.Contains("device.DrawNativeMeshInstanced(", world); + Assert.Contains("device.DrawNativeMeshMulti(", world); + Assert.Contains("device.BeginNativePass(", world); + Assert.Contains("device.EndNativePass();", world); + + // The pipelines are built for each mesh's own vertex layout rather than the fullscreen + // one: NativeWorldPrepare resolves it and hands it to the shared NativeMeshPipelineFor, + // whose VertexLayoutId wiring is pinned by TheVulkanPlatformRecordsTheSkyNativelyAndKeepsTheOldRoute. + Assert.Contains("device.NativeMeshLayoutId(", world); + } + + /// + /// The colour slots and the per-attachment blend of a native world pass come from the + /// platform's own motion-window state, not from the GL state tracker (decision 3), and the + /// motion attachment replaces rather than blends inside the window - what + /// ApplyOptimumMotionBlendState does for an emulated draw. + /// + [Fact] + public void TheWorldPassesDeriveTheirSlotsAndBlendFromTheMotionWindowNotTheTracker() + { + string world = Read(WorldPlatformFile); + + Assert.Contains("private uint NativeWorldPassColorSlots(FrameBufferRef target)", world); + Assert.Contains("OptimumMotionWriteActive", world); + Assert.Contains("MotionAttachmentIndex", world); + Assert.Contains("(1u << (motion + 1)) - 1u", world); + Assert.Contains("(1u << motion) - 1u", world); + + string blend = Section(world, "private AttachmentBlend[] NativeWorldBlend(", "return blend;"); + Assert.Contains("BlendFactor.One", blend); + Assert.Contains("BlendFactor.Zero", blend); + + // Nothing in a native world pass asks the tracker what state it is in. + Assert.DoesNotContain("GlStateTracker.", world); + } + // ------------------------------------------------------------------------ helpers private static string Section(string source, string from, string to) diff --git a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs index 31b52f68..7957d463 100644 --- a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -341,7 +341,10 @@ public void TheDecalMotionWindowCoversEverythingItOpened() "shaderProgramDecals.Use();", "shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix;", "SetOptimumMotionUniforms(shaderProgramDecals);", - "decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant);", + // Phase 3b stage 2: the pool's Draw was split into its cull and the platform's + // decal seam; both halves still run inside the window. + "decalPool.FrustumCull(game.frustumCuller, EnumFrustumCullMode.CullInstant);", + "game.Platform.RenderDecalPool(decalPool.ModelRef,", }) { Assert.Contains(statement, guarded); diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index da433f1e..b37dfd9d 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -171,6 +171,55 @@ extends the API and ports the simplest system through it. for the lib seam. The four system stages add their systems to those files rather than to files named after the stage. +## 3c. Stage 2, second wave: the remaining sky systems, the particles and the decals + +The sky dome proved the mesh-draw API; this wave takes the systems whose shape it did not exercise +- a cube map, an instanced pool, an indirect multi-draw - and the first two systems that write the +motion attachment. + +- **Seams** (`ClientPlatformAbstract`, each with the neutral body of the draw it replaced, each + listed in `Optimum.Patcher/Program.cs` and in `VulkanClientPlatform.ExpectedVirtuals`): + - `RenderNightSkyBox(MeshRef, int cubeTextureId)` - `SystemRenderNightSky`'s star cube; + - `RenderCelestialQuad(MeshRef, int bodyTextureId, int skyTextureId, int glowTextureId)` - the + moon, under `celestialobject`; + - `RenderParticles(MeshRef, int quantity, int particleTextureId)` - one particle pool's + instanced draw; + - `RenderDecalPool(MeshRef, int[] starts, int[] sizes, int groupCount, int decalTextureId, int blockTextureId)` - + the decal pool's multi-draw. `SystemRenderDecals` now runs the pool's own public `FrustumCull` + and hands the seam its results, which is `MeshDataPool.Draw` split in two; `MeshDataPool` + gained a read-only `ModelRef` for the mesh half of that. +- **Platform:** `VulkanClientPlatform.NativeWorld.cs`, one `NativeWorldEnabled` switch keeping + every neutral body reachable. Two derivations are shared by all four passes and are the reason + none of them reads `GlStateTracker`: + - `NativeWorldPassColorSlots` - the colour slots of the pass are the set the emulated route's + draw-buffer mask would hold, computed from `MotionAttachmentIndex` and + `OptimumMotionWriteActive`: every bound slot with TAA off, Primary's default colour set with + TAA on, plus the motion attachment exactly while a motion window is open. + - `NativeWorldBlend` - the caller's blend mode per attachment, with replace-blending + (ONE, ZERO, ADD) forced on the motion attachment inside a window, which is what + `ApplyOptimumMotionBlendState` does for an emulated draw. +- **Tests:** `Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs` (old route against native + route on every attachment of Primary for all four systems, the motion attachment compared bit + for bit for the two systems that write it, the draw kinds counted apart, and the slot + derivation checked against all three window states) and the seam coverage in + `Optimum.Tests/native-world-systems-coverage-tests.cs`. +- **Deliberately not in this wave, with the reason:** + - **the sun.** `SystemRenderSunMoon` draws it under `standard`, the shared program the entity + stage owns (held items, dropped items); porting it means porting `standard`'s whole sampler + set, so it belongs to that stage and keeps `RenderMesh`. + - **the quad particle pool.** It draws into `Transparent` in the OIT stage, whose + per-attachment weighted-blend state belongs to the OIT pass rather than to the particle + system and is not something this seam can state. The pipeline request names `particlescube`, + so the quad pool falls through to the neutral body by construction rather than by a check. + - **aurora and the two cloud renderers.** They live in the `VSEssentials` fork and reach the + device through a second emulation surface, `OptimumForkGraphics` / `VulkanForkGraphics` + (`VintagestoryApi/Client/optimum-render-device.cs`, + `Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs`), which has no native counterpart and + is not in section 1's emulation inventory. `CloudRendererMap` also renders into a target the + mod creates itself rather than one of `SetupDefaultFrameBuffers`'. Deciding whether that + surface gets a native equivalent or stays permanently emulated mod-adapter surface is its + own call, and it blocks those three systems until it is made. + ## 4. Documentation that makes map stages unnecessary Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five diff --git a/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch b/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch index b23d3b76..67a7a5fd 100644 --- a/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch +++ b/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch @@ -1,8 +1,28 @@ diff --git a/VintagestoryApi/Client/MeshPool/MeshDataPool.cs b/VintagestoryApi/Client/MeshPool/MeshDataPool.cs -index f9128b4..6bf26af 100644 +index f9128b4..d25a06c 100644 --- a/VintagestoryApi/Client/MeshPool/MeshDataPool.cs +++ b/VintagestoryApi/Client/MeshPool/MeshDataPool.cs -@@ -280,11 +280,15 @@ namespace Vintagestory.API.Client +@@ -28,10 +28,19 @@ namespace Vintagestory.API.Client + public int IndicesPoolSize; + + internal MeshRef modelRef; + internal int poolId; + ++ /// ++ /// Optimum (Phase 3b): the pool's uploaded mesh, so a platform that records the pool's ++ /// multi-draw itself - VulkanClientPlatform.RenderDecalPool, the native decal pass - can ++ /// reach it alongside the public cull results (, ++ /// , ). Read-only: the pool ++ /// still owns the handle and disposes it. ++ /// ++ public MeshRef ModelRef => modelRef; ++ + // For defragmentation, sanity checks, frustum culling + internal List poolLocations = new List(); + + // For final rendering + +@@ -280,11 +289,15 @@ namespace Vintagestory.API.Client { modeldata.CustomInts.BaseOffset = vertexPosition * modeldata.CustomInts.InterleaveStride; } @@ -18,7 +38,7 @@ index f9128b4..6bf26af 100644 // Assign a location to it ModelDataPoolLocation poolLocation = new ModelDataPoolLocation() { -@@ -462,10 +466,11 @@ namespace Vintagestory.API.Client +@@ -462,10 +475,11 @@ namespace Vintagestory.API.Client return CurrentFragmentation; } @@ -30,7 +50,7 @@ index f9128b4..6bf26af 100644 } -@@ -513,10 +518,12 @@ namespace Vintagestory.API.Client +@@ -513,10 +527,12 @@ namespace Vintagestory.API.Client public Bools CullVisible = new Bools(true, true); @@ -43,7 +63,7 @@ index f9128b4..6bf26af 100644 /// /// Used for models with movements (like a door). /// -@@ -535,17 +542,17 @@ namespace Vintagestory.API.Client +@@ -535,17 +551,17 @@ namespace Vintagestory.API.Client { case EnumFrustumCullMode.CullInstant: return !Hide && CullVisible[VisibleBufIndex] && culler.InFrustum(FrustumCullSphere); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index a79c725c..3d0d185b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..b7f9663 100644 +index d6eb844..81571e1 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,496 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,591 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -126,6 +126,101 @@ index d6eb844..b7f9663 100644 + RenderMesh(skyDome); + } + ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): the night sky box's draw, a seam of its own so a ++ /// native platform can record it as a declared pass. ++ /// ++ /// What it draws: the 75-unit cube SystemRenderNightSky renders the star cube map onto, once ++ /// per frame in the Opaque stage right after the sky dome. ++ /// The other side: the neutral body below is the OpenGL path - exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderNightSkyBox is the native one. ++ /// Target and slots: whatever the Opaque stage has bound, which is Primary; nightsky.frag ++ /// writes colour 0 and, with the SSAO G-buffer on, the two G-buffer slots. Never motion. ++ /// State that is not obvious: the caller has already turned the depth test and culling off, ++ /// so the seam changes no state of its own. The cube map is passed as a handle because a ++ /// native pass resolves what it samples from handles, not from the texture unit ++ /// ShaderProgramNightsky.CtexCube bound it to - and it is a samplerCube, so it ++ /// resolves into the cube array of the bindless table rather than the 2D one. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderNightSkyBox(MeshRef nightSkyBox, int cubeTextureId) ++ { ++ RenderMesh(nightSkyBox); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): the draw of one celestial body's quad. ++ /// ++ /// What it draws: the moon - SystemRenderSunMoon's quad under the celestialobject program, ++ /// once per frame in the Opaque stage. The sun's draw of the same quad still goes through ++ /// , because it runs under the shared "standard" program ++ /// the entity stage owns. ++ /// The other side: the neutral body below is the OpenGL path - the RenderMesh call it ++ /// replaced - and VulkanClientPlatform.RenderCelestialQuad is the native one. ++ /// Target and slots: Primary; celestialobject.frag writes colour 0, the glow slot 1 and, ++ /// with the SSAO G-buffer on, the two G-buffer slots. Never motion. ++ /// State that is not obvious: the caller has blending on in the standard mode, culling off ++ /// and the depth test off, and the seam changes none of it. The body's texture is passed as ++ /// a handle for the same reason the sky's two are, and so are the sky gradient and glow ++ /// textures: celestialobject.fsh reads them through skycolor.fsh to shade the body against ++ /// the sky behind it, and a native pass cannot take them off the units the program's ++ /// Sky2D/Glow2D setters bound them to. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderCelestialQuad(MeshRef quad, int bodyTextureId, int skyTextureId, int glowTextureId) ++ { ++ RenderMesh(quad); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): one particle pool's instanced draw. ++ /// ++ /// What it draws: every live particle of one pool in a single instanced draw of the pool's ++ /// cube or quad geometry - SystemRenderParticles.Render, twice per pool (main thread and ++ /// off thread). ++ /// The other side: the neutral body below is the OpenGL path - exactly the ++ /// call it replaced - and ++ /// VulkanClientPlatform.RenderParticles is the native one. ++ /// Target and slots: cube particles draw into Primary inside the caller's motion window, so ++ /// the motion attachment is in the pass's colour slots exactly while that window is open and ++ /// the vector lands through the one writer include; quad particles draw into Transparent. ++ /// State that is not obvious: the caller has standard blending on for the cube pool; the ++ /// window's replace-blending on the motion attachment is state the native pass states per ++ /// attachment rather than inheriting from a tracked toggle. ++ /// What pins it: NativeWorldSystemsTests (including the motion attachment, bit for bit) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderParticles(MeshRef model, int quantity, int particleTextureId) ++ { ++ RenderMeshInstanced(model, quantity); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): the decal pool's multi-draw. ++ /// ++ /// What it draws: every visible decal in one indirect multi-draw of the decal pool, once per ++ /// frame in SystemRenderDecals.OnRenderFrame3D (the AfterOIT stage). ++ /// The other side: the neutral body below is the OpenGL path - exactly the ++ /// call that the second half of ++ /// MeshDataPool.Draw makes, which is what the caller's decalPool.Draw ++ /// reached - and VulkanClientPlatform.RenderDecalPool is the native one. The caller runs ++ /// the pool's own public FrustumCull first, so both routes draw the same ranges and only ++ /// the draw command differs. ++ /// Target and slots: Primary, inside the caller's motion window - a decal nudges the depth ++ /// buffer in front of the block it sits on, so it has to write that surface's motion vector ++ /// itself. ++ /// State that is not obvious: the caller has standard blending on and culling off; the ++ /// motion attachment blends replace, which the native pass states per attachment. The two ++ /// atlas textures are passed as handles because a native pass resolves what it samples from ++ /// handles, not from the units ShaderProgramDecals' setters bound them to. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount, int decalTextureId, int blockTextureId) ++ { ++ RenderMesh(decalMesh, indicesStarts, indicesSizes, groupCount); ++ } ++ + public virtual bool RenderOptimumTaaResolve() + { + return false; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch index 6e409a48..f28493dc 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs -index f52d0f6..07e14ab 100644 +index f52d0f6..12688c4 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs @@ -387,10 +387,34 @@ public class SystemRenderDecals : ClientSystem, IDecalApi @@ -37,7 +37,7 @@ index f52d0f6..07e14ab 100644 Vec3d cameraPos = game.EntityPlayer.CameraPos; if (decalOrigin.SquareDistanceTo(cameraPos) > 1000000f) { -@@ -399,27 +423,59 @@ public class SystemRenderDecals : ClientSystem, IDecalApi +@@ -399,27 +423,64 @@ public class SystemRenderDecals : ClientSystem, IDecalApi } if (decals.Count > 0) { @@ -99,7 +99,12 @@ index f52d0f6..07e14ab 100644 + { + SetOptimumMotionUniforms(shaderProgramDecals); + } -+ decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); ++ // Optimum (Phase 3b): the pool's multi-draw goes through the platform's decal ++ // seam, whose neutral body is this Draw call. A native platform culls through the ++ // pool's own FrustumCull and records the indirect draw itself, and needs the two ++ // atlas textures as handles rather than as the units the program's setters bound. ++ decalPool.FrustumCull(game.frustumCuller, EnumFrustumCullMode.CullInstant); ++ game.Platform.RenderDecalPool(decalPool.ModelRef, decalPool.indicesStartsByte, decalPool.indicesSizes, decalPool.indicesGroupsCount, decalTextureAtlas.TextureId, game.BlockAtlasManager.AtlasTextures[0].TextureId); + shaderProgramDecals.Stop(); + } + finally diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs.patch new file mode 100644 index 00000000..51732d6a --- /dev/null +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs.patch @@ -0,0 +1,20 @@ +diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs +index acce146..8afcd88 100644 +--- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs ++++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs +@@ -94,11 +94,14 @@ internal class SystemRenderNightSky : ClientSystem + }); + nightsky.ModelMatrix = modelMatrix; + game.GlPushMatrix(); + MatrixToolsd.MatFollowPlayer(game.MvMatrix.Top); + nightsky.ViewMatrix = game.CurrentModelViewMatrix; +- game.Platform.RenderMesh(nightSkyBox); ++ // Optimum (Phase 3b): the star box's draw goes through the platform's night-sky seam, ++ // whose neutral body is this RenderMesh call. A native platform records the pass ++ // itself and needs the cube map as a handle, not as a bound texture unit. ++ game.Platform.RenderNightSkyBox(nightSkyBox, textureId); + game.GlPopMatrix(); + nightsky.Stop(); + game.Platform.GlEnableDepthTest(); + game.Platform.UnBindTextureCubeMap(); + } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch index b79cd9e8..c119ff85 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs -index a9db192..5434211 100644 +index a9db192..c1e1a3a 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs @@ -127,15 +127,65 @@ public class SystemRenderParticles : ClientSystem, IAsyncParticleManager @@ -70,3 +70,23 @@ index a9db192..5434211 100644 ShaderProgramParticlesquad particlesquad = ShaderPrograms.Particlesquad; particlesquad.Use(); Render(0, deltaTime); +@@ -157,12 +207,17 @@ public class SystemRenderParticles : ClientSystem, IAsyncParticleManager + ((IShaderProgram)currentShaderProgram).UniformMatrix("modelViewMatrix", game.CurrentModelViewMatrix); + IParticlePool particlePool = mainthreadpools[poolindex]; + IParticlePool particlePool2 = offthreadpools[poolindex]; + particlePool.OnNewFrame(dt, game.EntityPlayer.CameraPos); + particlePool2.OnNewFrame(dt, game.EntityPlayer.CameraPos); +- game.Platform.RenderMeshInstanced(particlePool.Model, particlePool.QuantityAlive); +- game.Platform.RenderMeshInstanced(particlePool2.Model, particlePool2.QuantityAlive); ++ // Optimum (Phase 3b): the two pools' instanced draws go through the platform's ++ // particle seam, whose neutral body is the RenderMeshInstanced call it replaced. A ++ // native platform records the pass itself; the cube program samples nothing and the ++ // quad program's particleTex is never bound by the game, so the texture the seam ++ // carries is 0 and resolves to the bindless placeholder on both routes. ++ game.Platform.RenderParticles(particlePool.Model, particlePool.QuantityAlive, 0); ++ game.Platform.RenderParticles(particlePool2.Model, particlePool2.QuantityAlive, 0); + ((IShaderProgram)currentShaderProgram).Stop(); + game.GlPopMatrix(); + } + } + diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch index b85781e3..2a6afe66 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs -index ac7f262..ba9f43a 100644 +index ac7f262..d3ae0f6 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs @@ -52,11 +52,11 @@ public class SystemRenderSunMoon : ClientSystem @@ -70,7 +70,23 @@ index ac7f262..ba9f43a 100644 public void OnRenderFrame3D(float dt) { ClientPlatformAbstract platform = game.Platform; -@@ -420,11 +417,11 @@ public class SystemRenderSunMoon : ClientSystem +@@ -266,11 +263,14 @@ public class SystemRenderSunMoon : ClientSystem + celestialobject.ExtraGodray = 0.5f; + celestialobject.UniformMatrix("modelMatrix", ref moonmat); + celestialobject.ViewMatrix = game.api.renderapi.CameraMatrixOriginf; + celestialobject.ProjectionMatrix = game.api.renderapi.CurrentProjectionMatrix; + celestialobject.Tex2D = moontextureIds[4]; +- platform.RenderMesh(quadModel); ++ // Optimum (Phase 3b): the moon's draw goes through the platform's celestial seam, whose ++ // neutral body is this RenderMesh call. The sun's draw above keeps RenderMesh, because it ++ // runs under the shared "standard" program that the entity stage owns. ++ platform.RenderCelestialQuad(quadModel, moontextureIds[4], game.skyTextureId, game.skyGlowTextureId); + celestialobject.WeirdMathToMakeMoonLookNicer = 0; + celestialobject.Stop(); + platform.GlToggleBlend(on: false); + platform.GlEnableDepthTest(); + } +@@ -420,11 +420,11 @@ public class SystemRenderSunMoon : ClientSystem game.Platform.GLDeleteTexture(suntextureId); for (int i = 0; i < moontextureIds.Length; i++) { diff --git a/patches/cecil-owned.list b/patches/cecil-owned.list index 8be8a74d..9b68dbfd 100644 --- a/patches/cecil-owned.list +++ b/patches/cecil-owned.list @@ -39,6 +39,7 @@ patches/VintagestoryLib/Vintagestory.Client.NoObf/SvgLoader.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderEntities.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderFrameBufferDebug.cs.patch +patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderNightSky.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderOITLayers.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderPlayerEffects.cs.patch From 8f2c6dc40ac087714b27000f132e15bfbf0dd9d3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 12:35:18 +0200 Subject: [PATCH 199/226] wip(native-world): chunks on the native mesh-draw API Every ChunkRenderer draw group now draws through the native device API on the Vulkan path, and stays what it was: one indirect multi-draw per chunk pool over per-chunk meshes, with the FaceData storage buffer feeding the vertex fetch. Seam (decision 2): ClientPlatformAbstract.BeginChunkPass(name, blend, depthTest, depthWrite, cullFace) / EndChunkPass, neutral bodies that do nothing, so the OpenGL path keeps running today's bodies under today's GL state calls. The thirteen draw groups - four shadow-cascade groups, five Opaque groups, the OIT liquid and transparent groups, the liquid velocity redraw and the AfterOIT terrain overlay - each bracket their pool loop in a try/finally and state the fixed state they run under. Listed in Optimum.Patcher/Program.cs (the two seam members, plus RenderShadow and RenderOIT as transplant targets) and in VulkanClientPlatform.ExpectedVirtuals. Platform (VulkanClientPlatform.NativeChunks.cs): the scope declares a pass on the bound target at its first draw and routes the pool's multi-draw to DrawNativeMeshMulti; the real mesh id reaches BindProgramSets, so the SSBO pools' vertex fetch resolves per draw and the NoSSBO and greedy-mesh variants are simply other vertex layouts, both in the pipeline key. The motion window is a pass colour-write mask - the motion attachment joins the slots with replace blending, and the liquid velocity redraw zeroes the mask on every other slot - never a draw-buffer toggle; no SetDrawBuffers call exists in the native chunk path. State is stated, not read back off GlStateTracker: the standard blend mode with replace blending on the SSAO G-buffer and motion slots, and for the Transparent target whichever contract the client last applied, recorded at ApplyTransparentPassBlendState and BeginOitAccumulation. The texture behind each sampler is recorded at BindProgramTexture2D, the seam where the client states it, so a draw resolves handles rather than units; chunkliquid's read of the depth it draws against is declared through SamplesBoundDepth. What is still emulated inside a group - the uniform values set by name, including the per-pool origin from MeshDataPoolManager - is documented at the seam with why: those land in the same record and push shadows the native draw snapshots. Verified: dotnet build VintageStory.slnx -c Release clean; dotnet test Optimum.Tests -c Release 1254 passed; dotnet test Optimum.Render.Vulkan.Tests 1084 passed with sync,best validation and the implicit-layer disable set, which includes the nine new NativeChunkTests comparing the old route against the native route pixel for pixel on every attachment (blend and cull combinations, the motion attachment bit-identical, the motion-only mask leaving the shaded slots at their clear, the shadow cascade, one declared pass and one indirect draw per group, pipelines built once). extract-patches + check-patches clean. Not verified: in the game - agents do not launch it. --- Optimum.Patcher/Program.cs | 12 + .../NativeChunkTests.cs | 637 ++++++++++++++++++ .../VulkanClientPlatform.FrameBuffers.cs | 5 + .../Platform/VulkanClientPlatform.Leaf.cs | 9 + .../Platform/VulkanClientPlatform.Meshes.cs | 6 + .../VulkanClientPlatform.NativeChunks.cs | 481 +++++++++++++ .../Platform/VulkanClientPlatform.Shaders.cs | 6 + .../Platform/VulkanClientPlatform.cs | 4 + Optimum.Render.Vulkan/VulkanDevice.Native.cs | 9 + .../ambient-occlusion-coverage-tests.cs | 7 +- .../native-world-systems-coverage-tests.cs | 145 ++++ .../taa-liquid-motion-coverage-tests.cs | 11 +- docs/vulkan-native-render-systems.md | 38 ++ .../ChunkRenderer.cs.patch | 264 ++++++-- .../ClientPlatformAbstract.cs.patch | 44 +- 15 files changed, 1629 insertions(+), 49 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeChunkTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index edee30a0..531d2448 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -85,6 +85,12 @@ // Phase 3b stage 2: the sky dome's draw seam, so a native platform records that pass // itself. The neutral body is the RenderMesh call it replaced. "RenderSkyDome", + // Phase 3b stage 2: the chunk draw-group scope, so a native platform can state a + // terrain pipeline's fixed state instead of reading it back off the GL state. Neutral + // bodies: BeginChunkPass returns false and EndChunkPass does nothing, so the OpenGL + // path draws exactly what it drew before. + "BeginChunkPass", + "EndChunkPass", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", // Phase 3b stage 1d: the draw seams of the two TAA passes, so a native platform @@ -811,6 +817,12 @@ // terrain overlay (pass 7) and the LiquidDepth prepass comment that records // why it stays jittered but writes no motion. new("Vintagestory.Client.NoObf.ChunkRenderer", "RenderAfterOIT", 1), + // Phase 3b stage 2 (native chunks): every ChunkRenderer draw group now brackets its + // pools with the BeginChunkPass/EndChunkPass seam, so the shadow cascades and the OIT + // groups are transplant targets too (RenderOpaque and RenderAfterOIT already are, and + // RenderLiquidMotion is an injected member). + new("Vintagestory.Client.NoObf.ChunkRenderer", "RenderShadow", 1), + new("Vintagestory.Client.NoObf.ChunkRenderer", "RenderOIT", 1), new("Vintagestory.Client.NoObf.ChunkRenderer", "OnRenderBefore", 1), // TAA P1: temporal frame contract - Advance()/JitterActive wiring in the // render loop, the jittered projection getter, its capture at both diff --git a/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs new file mode 100644 index 00000000..4d4b9aab --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs @@ -0,0 +1,637 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The terrain, drawn twice on one Vulkan device: through the emulated multi-draw the OpenGL +/// body takes (NativeChunksEnabled false) and through the native pass +/// VulkanClientPlatform.NativeChunks.cs records inside a BeginChunkPass / EndChunkPass scope +/// (docs/vulkan-native-render-systems.md, decision 5 stage 2). +/// +/// Behavioural identity is the acceptance rule (decision 6). The same chunkopaque program, the +/// same pooled mesh and the same fixed state have to put the same pixels on every attachment of +/// Primary - the scene, the glow and, with a motion window open, the motion attachment bit for +/// bit - across the settings that change the chunk passes: blending on and off, culling on and +/// off, a motion window open and closed, and the motion-only window of the liquid velocity +/// redraw, whose whole point is that it writes the motion attachment and touches nothing else. +/// The shadow cascade, which draws a different program into a different target, gets the same +/// treatment. +/// +public class NativeChunkTests(ITestOutputHelper output) +{ + private const int Size = 32; + + /// The normal-up flags word a solid top face carries (ChunkTerrainRenderTests). + private const int UpNormalFlags = 7 << 18; + + /// The platform with no window: both routes take their size from this seam. + private sealed class ChunkPlatform : VulkanClientPlatform + { + public ChunkPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + // ------------------------------------------------------------------------- the tests + + /// + /// The opaque terrain group: the native route draws what the emulated route draws, on every + /// attachment, under each of the blend and cull combinations ChunkRenderer's five Opaque + /// groups run with. + /// + [SkippableTheory] + [InlineData("chunk-opaque", true, true)] + [InlineData("chunk-vegetation", true, false)] + [InlineData("chunk-blendnocull", false, false)] + [InlineData("chunk-decorative", true, true)] + public void ANativeChunkGroupDrawsWhatTheEmulatedGroupDraws(string pass, bool blend, bool cull) + { + using Session session = Open(motion: false); + + byte[][] emulated = session.RunGroup(pass, native: false, blend: blend, cull: cull); + byte[][] native = session.RunGroup(pass, native: true, blend: blend, cull: cull); + + output.WriteLine("scene centre emulated " + Centre(emulated[0]) + " native " + Centre(native[0])); + Assert.Equal(emulated[0], native[0]); + Assert.Equal(emulated[1], native[1]); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The native route records the group as one declared pass and one indirect multi-draw - + /// the shape the chunk path has to keep - and the emulated route records neither. + /// + [SkippableFact] + public void TheNativeGroupIsOneDeclaredPassAndOneIndirectMultiDraw() + { + using Session session = Open(motion: false); + + long passes = session.Seam.NativePassesForTests; + long indirect = session.Seam.NativeIndirectDrawsForTests; + long draws = session.Seam.NativeDrawsForTests; + session.RunGroup("chunk-opaque", native: false, blend: true, cull: true); + Assert.Equal(0, session.Seam.NativeDrawsForTests - draws); + Assert.Equal(0, session.Seam.NativePassesForTests - passes); + + session.RunGroup("chunk-opaque", native: true, blend: true, cull: true); + Assert.Equal(1, session.Seam.NativePassesForTests - passes); + Assert.Equal(1, session.Seam.NativeIndirectDrawsForTests - indirect); + Assert.Equal(1, session.Seam.NativeDrawsForTests - draws); + GpuTest.AssertClean(session.Seam); + } + + /// + /// With a motion window open, the motion attachment is bit-identical between the two + /// routes: the temporal contract does not change when the group moves to a native pass. + /// + [SkippableFact] + public void TheMotionAttachmentIsIdenticalBetweenTheRoutes() + { + using Session session = Open(motion: true); + + byte[][] emulated = session.RunGroup("chunk-opaque", native: false, blend: true, cull: false, motion: true); + byte[][] native = session.RunGroup("chunk-opaque", native: true, blend: true, cull: false, motion: true); + + output.WriteLine("motion centre emulated " + Centre(emulated[2]) + " native " + Centre(native[2])); + Assert.Equal(emulated[0], native[0]); + Assert.Equal(emulated[1], native[1]); + Assert.Equal(emulated[2], native[2]); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The liquid velocity redraw's window is a colour-write mask, not a draw-buffer toggle: + /// the motion attachment takes the draw and the scene and glow attachments keep exactly the + /// contents the clear left, on both routes. + /// + [SkippableFact] + public void TheMotionOnlyGroupWritesTheMotionAttachmentAndNothingElse() + { + using Session session = Open(motion: true); + + byte[][] emulated = session.RunGroup("chunk-liquid-motion", native: false, blend: false, cull: false, + motion: true, motionOnly: true); + byte[][] native = session.RunGroup("chunk-liquid-motion", native: true, blend: false, cull: false, + motion: true, motionOnly: true); + + Assert.Equal(emulated[0], native[0]); + Assert.Equal(emulated[1], native[1]); + Assert.Equal(emulated[2], native[2]); + + // And the mask really is a mask: the shaded slots still hold the clear. + Assert.True(IsClear(native[0], Session.SceneClear), "the motion-only group wrote the scene attachment"); + Assert.True(IsClear(native[1], Session.GlowClear), "the motion-only group wrote the glow attachment"); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The shadow cascade: a different program, a different target, one colour attachment, depth + /// written and no blending. Same acceptance - the two routes agree on the depth the cascade + /// leaves behind, which is the only thing the shadow map is read for. + /// + [SkippableFact] + public void TheShadowCascadeMatchesBetweenTheRoutes() + { + using Session session = Open(motion: false); + + byte[] emulated = session.RunShadowGroup(native: false); + byte[] native = session.RunShadowGroup(native: true); + + Assert.Equal(emulated, native); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The group's pipelines are built once and kept: a frame of terrain does not rebuild a + /// pipeline per pool, which is what the emulated per-draw key resolve used to do. + /// + [SkippableFact] + public void TheGroupBuildsItsPipelinesOnceAndKeepsThem() + { + using Session session = Open(motion: false); + + session.RunGroup("chunk-opaque", native: true, blend: true, cull: true); + int after = session.Seam.NativePipelinesForTests; + session.RunGroup("chunk-opaque", native: true, blend: true, cull: true); + session.RunGroup("chunk-opaque", native: true, blend: true, cull: true); + + Assert.Equal(after, session.Seam.NativePipelinesForTests); + GpuTest.AssertClean(session.Seam); + } + + // ------------------------------------------------------------------------- driving + + private static string Centre(byte[] pixels) + { + int i = (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + private static bool IsClear(byte[] pixels, byte[] clear) + { + for (int i = 0; i < pixels.Length; i += 4) + { + for (int c = 0; c < 4; c++) + { + if (Math.Abs(pixels[i + c] - clear[c]) > 1) return false; + } + } + return true; + } + + private Session Open(bool motion) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Session? session = Session.TryOpen(output, motion); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// + /// The platform, its device, the Primary target the Opaque stage binds, a shadow map, the + /// chunk programs and one pooled terrain mesh, installed the way the client installs them + /// and put back afterwards. + /// + private sealed class Session : IDisposable + { + public static readonly byte[] SceneClear = { 32, 64, 128, 255 }; + public static readonly byte[] GlowClear = { 192, 128, 64, 255 }; + + public ChunkPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Primary { get; private set; } = null!; + public FrameBufferRef Shadow { get; private set; } = null!; + + private ShaderProgram opaque = null!; + private ShaderProgram shadowmap = null!; + private MeshRef pool = null!; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + private bool motionAttachment; + + /// One pool group: MeshDataPool hands GL's 64-bit byte offsets as int pairs. + private static readonly int[] GroupStarts = { 0, 0 }; + private static readonly int[] GroupSizes = { 6 }; + + public static Session? TryOpen(ITestOutputHelper output, bool motion) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-chunks-" + Guid.NewGuid().ToString("N")); + var platform = new ChunkPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + motionAttachment = motion, + }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.Primary = CreatePrimary(seam, motion ? 3 : 2); + session.Shadow = CreateShadow(seam); + InstallFrameBuffers(platform, session.Primary); + + // The motion attachment is Primary's slot 2 without the SSAO G-buffer, exactly as + // SetupDefaultFrameBuffers publishes it. + platform.SetOptimumMotionAttachmentIndex(motion ? 2 : -1); + + ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(); + variant.TaaMotion = motion ? 1 : 0; + variant.TaaMotionLocation = 2; + variant.UseSsbo = 0; + session.opaque = session.LinkClientProgram(seam, "chunkopaque", variant); + session.shadowmap = session.LinkClientProgram(seam, "chunkshadowmap", variant); + + session.pool = platform.UploadMesh(BuildBlockFace()); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + // The mesh goes first: VAO's finalizer reaches for ScreenManager.Platform, which is + // about to be the client's again, and a live handle there would crash the test host. + if (pool != null) Platform.DeleteMesh(pool); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// + /// One ChunkRenderer draw group, as the client runs it: Primary bound and cleared, the + /// GL-shaped state the group sets (which the OpenGL body still needs and the native pass + /// ignores), the program's uniforms, then the scope and the pool's multi-draw. + /// + public byte[][] RunGroup(string pass, bool native, bool blend, bool cull, + bool motion = false, bool motionOnly = false) + { + VulkanDevice seam = Seam; + Platform.NativeChunksEnabled = native; + + Platform.BeginFrame(); + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, (1 << Primary.ColorTextureIds.Length) - 1); + Clear(seam); + + Platform.CurrentFrameBuffer = Primary; + seam.SetViewport(0, 0, Size, Size); + seam.UseProgram(opaque.ProgramId); + ShaderProgramBase.CurrentShaderProgram = opaque; + SetProgramUniforms(seam, opaque.ProgramId); + + // What BeginMotionWrite / BeginMotionOnlyWrite do once their guards pass: the window + // flag, the draw-buffer set the emulated route needs, and replace blending on the + // motion attachment. The native pass reads the flag and states the rest itself. + SetMotionWriteActive(motion); + if (motion) + { + if (motionOnly) Platform.EnableMotionOnlyDrawBuffers(); + else Platform.EnableMotionDrawBuffers(); + Platform.ApplyOptimumMotionBlendState(); + } + + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetCullFace(cull); + seam.SetBlend(blend, EnumBlendMode.Standard); + if (blend) Platform.ApplyOptimumMotionBlendState(); + + Platform.BeginChunkPass(pass, blend, depthTest: true, depthWrite: true, cullFace: cull); + try + { + Platform.RenderMesh(pool, GroupStarts, GroupSizes, 1, useSSBOs: false); + } + finally + { + Platform.EndChunkPass(); + } + + if (motion) + { + Platform.RestorePrimaryDrawBuffers(); + SetMotionWriteActive(false); + } + + var attachments = new byte[Primary.ColorTextureIds.Length][]; + for (int slot = 0; slot < attachments.Length; slot++) + { + attachments[slot] = Read(seam, Primary.ColorTextureIds[slot]); + } + Platform.EndFrame(); + return attachments; + } + + /// One shadow cascade group: the shadow map bound, depth written, no blending. + public byte[] RunShadowGroup(bool native) + { + VulkanDevice seam = Seam; + Platform.NativeChunksEnabled = native; + + Platform.BeginFrame(); + seam.BindFramebuffer(Shadow.FboId); + seam.SetDrawBuffers(Shadow.FboId, 1); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.ClearDepth(1f); + + Platform.CurrentFrameBuffer = Shadow; + seam.SetViewport(0, 0, Size, Size); + seam.UseProgram(shadowmap.ProgramId); + ShaderProgramBase.CurrentShaderProgram = shadowmap; + SetProgramUniforms(seam, shadowmap.ProgramId); + + seam.SetDepthMask(true); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetDepthTest(true); + seam.SetCullFace(false); + + Platform.BeginChunkPass("chunk-shadow-opaque", blend: false, depthTest: true, depthWrite: true, + cullFace: false); + try + { + Platform.RenderMesh(pool, GroupStarts, GroupSizes, 1, useSSBOs: false); + } + finally + { + Platform.EndChunkPass(); + } + + byte[] pixels = Read(seam, Shadow.ColorTextureIds[0]); + Platform.EndFrame(); + return pixels; + } + + // ----------------------------------------------------------------- the fixtures + + private void Clear(VulkanDevice seam) + { + seam.ClearColor(0, SceneClear[0] / 255f, SceneClear[1] / 255f, SceneClear[2] / 255f, 1f); + seam.ClearColor(1, GlowClear[0] / 255f, GlowClear[1] / 255f, GlowClear[2] / 255f, 1f); + if (Primary.ColorTextureIds.Length > 2) seam.ClearColor(2, 0f, 0f, 0f, 0f); + seam.ClearDepth(1f); + } + + /// The window flag the platform's guards own; a test opens it directly. + private void SetMotionWriteActive(bool active) + { + if (!motionAttachment) return; + typeof(ClientPlatformWindows) + .GetField("optimumMotionWriteActive", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(Platform, active); + } + + /// + /// The uniforms a chunk program needs to draw anything (ChunkTerrainRenderTests: without + /// the view distances every fragment fades out and the pass draws nothing), plus the + /// textures - bound through the platform, because that is the seam the native route + /// takes its handles from and the emulated route its units. + /// + private void SetProgramUniforms(VulkanDevice seam, int programId) + { + float[] identity = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + foreach (string name in new[] + { + "projectionMatrix", "modelViewMatrix", "mvpMatrix", + "prevProjectionMatrix", "prevModelViewMatrix", + "toShadowMapSpaceMatrixFar", "toShadowMapSpaceMatrixNear", + }) + { + int location = seam.GetUniformLocation(programId, name); + if (location >= 0) seam.SetUniformMatrix(programId, location, identity); + } + + SetFloat(seam, programId, "viewDistance", 1024f); + SetFloat(seam, programId, "viewDistanceLod0", 1024f); + SetFloat(seam, programId, "alphaTest", 0.001f); + SetFloat(seam, programId, "zNear", 0.1f); + SetFloat(seam, programId, "zFar", 1024f); + SetFloat(seam, programId, "shadowRangeFar", 1024f); + SetFloat(seam, programId, "shadowRangeNear", 64f); + SetFloat(seam, programId, "shadowMapWidthInv", 1f); + SetFloat(seam, programId, "shadowMapHeightInv", 1f); + int ambient = seam.GetUniformLocation(programId, "rgbaAmbientIn"); + if (ambient >= 0) seam.SetUniform(programId, ambient, 1f, 1f, 1f); + int frameSize = seam.GetUniformLocation(programId, "frameSize"); + if (frameSize >= 0) seam.SetUniform(programId, frameSize, (float)Size, (float)Size); + } + + private static void SetFloat(VulkanDevice seam, int programId, string name, float value) + { + int location = seam.GetUniformLocation(programId, name); + if (location >= 0) seam.SetUniform(programId, location, value); + } + + /// + /// Links one vanilla chunk program from the corpus, as ShaderRegistry does, and points + /// every sampler it declares at a small gradient through the platform seam - so a + /// sampling difference between the routes would show as a pixel difference. + /// + private unsafe ShaderProgram LinkClientProgram(VulkanDevice seam, string name, + ShaderCorpus.ShaderVariant variant) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode ?? "", + }; + Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed")); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + else linked.GeometryShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, name + ": " + (seam.GetError() ?? "link failed")); + + var program = new ShaderProgram { PassName = name, ProgramId = id }; + int unit = 0; + foreach (string sampler in seam.SamplerNamesOf(id)) + { + Platform.BindProgramTexture2D(program, sampler, Gradient(seam, unit), unit); + unit++; + } + return program; + } + + /// One attachment's pixels, read through a framebuffer that holds only it. + private unsafe byte[] Read(VulkanDevice seam, int texture) + { + int reader = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(reader, 1); + seam.BindFramebuffer(reader); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + return pixels; + } + + /// Primary as the Opaque stage has it: scene at 0, glow at 1, motion at 2, plus depth. + private static FrameBufferRef CreatePrimary(VulkanDevice seam, int colorCount) + { + var textures = new int[colorCount]; + for (int slot = 0; slot < colorCount; slot++) + { + textures[slot] = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + } + + var primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = textures, + DepthTextureId = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false), + }; + Attach(seam, primary); + seam.SetDrawBuffers(primary.FboId, (1 << colorCount) - 1); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status); + return primary; + } + + /// A shadow cascade's target: one colour attachment and the depth the cascade writes. + private static FrameBufferRef CreateShadow(VulkanDevice seam) + { + var shadow = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + DepthTextureId = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false), + }; + Attach(seam, shadow); + seam.SetDrawBuffers(shadow.FboId, 1); + Assert.True(seam.CheckFramebufferComplete(shadow.FboId, out string status), status); + return shadow; + } + + private static void Attach(VulkanDevice seam, FrameBufferRef target) + { + for (int slot = 0; slot < target.ColorTextureIds.Length; slot++) + { + seam.AttachTexture(target.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + target.ColorTextureIds[slot], 0); + } + seam.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0); + } + + private static void InstallFrameBuffers(ChunkPlatform platform, FrameBufferRef primary) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = primary; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + } + + /// A small gradient, so a sampling difference between the routes would show. + private static unsafe int Gradient(VulkanDevice seam, int phase) + { + var pixels = new byte[8 * 8 * 4]; + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + int i = (y * 8 + x) * 4; + pixels[i] = (byte)(16 + x * 30 + phase * 7); + pixels[i + 1] = (byte)(32 + y * 25); + pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20); + pixels[i + 3] = 255; + } + } + fixed (byte* first = pixels) + { + return seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false); + } + } + + /// + /// One tesselated block face in the layout the chunk tesselator emits, covering the + /// middle of the target (ChunkTerrainRenderTests.BuildBlockFace). + /// + private static MeshData BuildBlockFace() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + float[] positions = + { + -0.5f, -0.5f, 0f, + 0.5f, -0.5f, 0f, + 0.5f, 0.5f, 0f, + -0.5f, 0.5f, 0f, + }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags( + positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, flags: UpNormalFlags); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index f78df8be..ae28b2c3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -647,6 +647,11 @@ public override void ApplyTransparentPassBlendState() device.SetBlendFuncSeparate(1, 0, 769, 0, 769); device.SetBlendEquation(2, 32774); device.SetBlendFuncSeparate(2, 770, 771, 770, 771); + // Phase 3b stage 2: the same contract, recorded for the native chunk passes that draw + // into this target (VulkanClientPlatform.NativeChunks.cs). + NoteNativeTransparentBlend(0, 32774, 1, 1, 1, 1); + NoteNativeTransparentBlend(1, 32774, 0, 769, 0, 769); + NoteNativeTransparentBlend(2, 32774, 770, 771, 770, 771); } /// diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index 5c1aa65c..d2ede8e1 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -152,6 +152,15 @@ public override void BeginOitAccumulation(FrameBufferRef transparent) device.SetBlendFuncSeparate(3, 1, 1, 1, 1); device.SetBlendFuncSeparate(4, 1, 1, 1, 1); device.SetBlendFuncSeparate(5, 1, 1, 1, 1); + // Phase 3b stage 2: the same contract, recorded for the native chunk passes that draw + // into this target - a native pipeline states its blend rather than reading the + // tracker's back (VulkanClientPlatform.NativeChunks.cs). Slot 2 keeps whatever the + // vanilla transparent set left there, exactly as GL does. + NoteNativeTransparentBlend(0, 32774, 774, 0, 774, 0); + NoteNativeTransparentBlend(1, 32774, 774, 0, 774, 0); + NoteNativeTransparentBlend(3, 32774, 1, 1, 1, 1); + NoteNativeTransparentBlend(4, 32774, 1, 1, 1, 1); + NoteNativeTransparentBlend(5, 32774, 1, 1, 1, 1); device.ClearColor(0, 1f, 1f, 1f, 1f); device.ClearColor(1, 1f, 1f, 1f, 1f); device.ClearColor(3, 0f, 0f, 0f, 0f); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 10d175e7..2f60496b 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -78,6 +78,12 @@ public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSi { RuntimeStats.drawCallsCount++; VAO vAO = (VAO)modelRef; + // Phase 3b stage 2: inside a ChunkRenderer draw group this is a native multi-draw of + // the pool, recorded by VulkanClientPlatform.NativeChunks.cs. Outside one - the decal + // pool, a mod's pool, or with NativeChunksEnabled off - it is the emulated route the + // OpenGL body takes. + if (TryDrawChunkPoolNative(vAO, indices, indicesSizes, groupCount)) return; + // The chunk renderer's one multidraw per pool. GL takes byte offsets // into the index buffer; the device converts them to index counts and // issues a single indirect draw. diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs new file mode 100644 index 00000000..eae8944d --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs @@ -0,0 +1,481 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), Phase 3b decision 5 +// stage 2: the terrain, the heaviest draw path in the game, on the native device API. +// +// What it draws: every ChunkRenderer draw group - the two shadow cascades, the opaque, +// topsoil, vegetation, blend-no-cull and decorative groups of the Opaque stage, the OIT +// liquid and transparent groups, the liquid velocity redraw and the AfterOIT terrain +// overlay. Each group is one indirect multi-draw per visible chunk pool over per-chunk +// meshes, with the FaceData storage buffer feeding the vertex fetch when the pool is an +// SSBO pool, and it stays exactly that: MeshDataPool's ranges reach +// VulkanDevice.DrawNativeMeshMulti, which allocates from the same per-slot indirect ring +// the emulated DrawMeshMulti allocates from, and the real mesh id reaches BindProgramSets +// so the storage-buffer vertex fetch resolves per draw. +// Where the other side is: ClientPlatformAbstract.BeginChunkPass / EndChunkPass have +// neutral bodies (false and nothing), so the OpenGL path still draws through +// ClientPlatformWindows.RenderMesh(MeshRef, int[], int[], int, bool) -> GL.MultiDrawElements +// under the GlToggleBlend / GlEnableDepthTest / GlDepthMask / cull calls ChunkRenderer +// already makes. NativeChunksEnabled false takes that same route on the Vulkan device, +// which is what the differential test compares against. +// Target and slots: whatever the stage bound. Primary for the opaque, overlay and liquid +// velocity groups; the Transparent target for the OIT groups; a shadow map for the +// cascades. The motion window is the pass's colour-write mask and never a draw-buffer +// toggle: the motion attachment joins the pass's slots while the window is open, with +// replace blending, and the liquid velocity group masks every other slot to zero, which is +// what BeginMotionOnlyWrite means here. +// State that is not obvious: +// - the per-attachment blend of a Primary group is the client's own contract, rebuilt +// from the same values GlToggleBlend applies: the standard mode on the shaded slots, +// replace blending on the SSAO G-buffer slots 2 and 3 and on the motion attachment, +// because a blended G-buffer or motion value is an average of two surfaces and belongs +// to neither; +// - the Transparent target's contract is whichever set the client last applied to it - +// the vanilla three-attachment set or the six-attachment OIT accumulation set - +// recorded where the client states it (ApplyTransparentPassBlendState and +// BeginOitAccumulation), not read back out of the state tracker; +// - chunkliquid samples the depth attachment it draws against with depth writes off, so +// its pipeline declares SamplesBoundDepth and the scope holds that depth read-only; +// - the slots the group's program does not write keep their contents, because the +// pipeline masks every output the program never writes (rule 9). +// What is still emulated inside a chunk group, and why: the uniform values the group sets +// by name - the per-pool "origin" from MeshDataPoolManager and the generated program +// setters - still travel through the GL-shaped uniform dispatch. They land in the same +// per-program record and push shadows the native draw snapshots, so the image is the same; +// removing that dispatch means moving MeshDataPoolManager itself, which is API-fork code +// and a later stage. The draws, the pass, the pipelines, the fixed state and the texture +// resolution are all native. +// What pins it: NativeChunkTests (old route against native route, per pass, including the +// motion attachment) and Optimum.Tests/native-world-systems-coverage-tests.cs. +public partial class VulkanClientPlatform +{ + /// + /// False runs the chunk groups through the emulated multi-draw on the Vulkan device - + /// the route the OpenGL body takes - instead of the native pass: the old route the + /// differential test compares against, in the pattern of . + /// + internal bool NativeChunksEnabled { get; set; } = true; + + /// + /// The texture each program sampler was last pointed at, recorded where the client points + /// it (). A native draw resolves what it samples from + /// handles, so it needs the handle the client chose rather than the texture unit the + /// GL-shaped path bound it to. Keyed by program id, then by the sampler's name. + /// + private readonly Dictionary> nativeProgramTextures = new(); + + /// The sampler names of a program, resolved once from its interface rather than per draw. + private readonly Dictionary nativeProgramSamplers = new(); + + /// + /// The per-attachment blend contract the Transparent target is drawn under, recorded at + /// the two seams the client states it through. Null until the client states one. + /// + private AttachmentBlend[]? nativeTransparentBlend; + + /// Bumped when that contract changes, so its pipelines are rebuilt rather than reused. + private int nativeBlendEpoch; + + // ------------------------------------------------------------------ the open scope + + private bool chunkScopeActive; + private bool chunkScopeBlend; + private bool chunkScopeDepthTest; + private bool chunkScopeDepthWrite; + private bool chunkScopeCull; + private bool chunkScopeMotionOnly; + private string chunkScopeName = ""; + private FrameBufferRef? chunkScopeTarget; + private uint chunkScopeSlots; + private bool chunkScopePassOpen; + private string chunkScopeOuterContext = ""; + private PassFlags chunkScopeOuterFlags; + private NativeTexture[] chunkScopeTextures = Array.Empty(); + private int[] chunkScopeReads = Array.Empty(); + + /// The native pipelines the chunk groups draw through, one per distinct shape. + private readonly Dictionary chunkPipelines = new(); + + /// + /// Every dimension of a chunk group's pipeline the platform decides: the program, the + /// target and the slots it writes, the mesh's vertex layout, and the fixed state the seam + /// stated. The device keys its own cache on the full description; this one only keeps the + /// description and its blend array from being rebuilt per draw. + /// + private readonly record struct ChunkPipelineKey(int ProgramId, int FramebufferId, uint Slots, int LayoutId, + bool Blend, bool DepthTest, bool DepthWrite, bool Cull, bool MotionOnly, bool SamplesBoundDepth, + int BlendEpoch); + + // ------------------------------------------------------------------- captured state + + /// + /// Records the texture a program sampler points at. Called from + /// and , which is + /// where the client states it; the GL-shaped unit binding still happens there too, so the + /// old route keeps working unchanged. + /// + internal void NoteNativeProgramTexture(int programId, string samplerName, int textureId) + { + if (!nativeProgramTextures.TryGetValue(programId, out Dictionary? textures)) + { + textures = new Dictionary(StringComparer.Ordinal); + nativeProgramTextures[programId] = textures; + } + textures[samplerName] = textureId; + } + + /// + /// Records one attachment of the Transparent target's blend contract, at the seam the + /// client states it through. GL's blend enable is global, so the recorded entry carries + /// the functions and the group's own blend flag decides whether they apply. + /// + internal void NoteNativeTransparentBlend(int slot, int glEquation, int srcColor, int dstColor, + int srcAlpha, int dstAlpha) + { + if ((uint)slot >= GlStateTracker.MaxColorAttachments) return; + if (nativeTransparentBlend == null) + { + nativeTransparentBlend = new AttachmentBlend[GlStateTracker.MaxColorAttachments]; + for (int i = 0; i < nativeTransparentBlend.Length; i++) + { + nativeTransparentBlend[i] = AttachmentBlend.Default; + } + } + + AttachmentBlend blend = AttachmentBlend.Default; + blend.Enabled = true; + blend.ColorOp = GlEnums.BlendOpFrom(glEquation); + blend.AlphaOp = blend.ColorOp; + blend.SrcColor = GlEnums.BlendFactorFrom(srcColor); + blend.DstColor = GlEnums.BlendFactorFrom(dstColor); + blend.SrcAlpha = GlEnums.BlendFactorFrom(srcAlpha); + blend.DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); + if (!nativeTransparentBlend[slot].Equals(blend)) nativeBlendEpoch++; + nativeTransparentBlend[slot] = blend; + } + + // ------------------------------------------------------------------------ the seam + + /// + /// Opens the scope one ChunkRenderer draw group draws under. The group's multi-draws then + /// reach from the mesh seam; the native pass itself + /// opens with the first draw, because that is when the textures it reads are known. + /// + public override bool BeginChunkPass(string chunkPass, bool blend, bool depthTest, bool depthWrite, bool cullFace) + { + EndChunkPass(); + if (!NativeChunksEnabled || device == null) return false; + + FrameBufferRef target = CurrentFrameBuffer; + if (target == null || target.FboId <= 0) return false; + + chunkScopeActive = true; + chunkScopeName = chunkPass ?? "chunk"; + chunkScopeBlend = blend; + chunkScopeDepthTest = depthTest; + chunkScopeDepthWrite = depthWrite; + chunkScopeCull = cullFace; + chunkScopeTarget = target; + // The liquid velocity redraw is the one chunk group that opens its window with + // BeginMotionOnlyWrite, so the seam's own name is what says the other slots are masked + // off - no second copy of the window's state, and no reading a draw-buffer mask back. + chunkScopeMotionOnly = string.Equals(chunkScopeName, "chunk-liquid-motion", StringComparison.Ordinal); + chunkScopeSlots = ChunkColorSlots(target); + chunkScopePassOpen = false; + return true; + } + + /// Closes the scope and, if a draw opened one, the native pass with it. + public override void EndChunkPass() + { + if (!chunkScopeActive) return; + chunkScopeActive = false; + + if (chunkScopePassOpen) + { + chunkScopePassOpen = false; + device.EndNativePass(); + // Every renderer after this group draws into the same target through the emulated + // path, so the stage's own pass context is declared again - the chunk pass replaced + // it, exactly as the sky pass does with the context it interrupts. + if (chunkScopeTarget != null) device.BindFramebuffer(chunkScopeTarget.FboId); + SetPassContext(chunkScopeOuterContext, chunkScopeOuterFlags); + } + chunkScopeTarget = null; + } + + /// + /// One chunk pool's multi-draw, recorded natively. False means the group is not in a native + /// scope, or the first draw of one could not be recorded, and the caller takes the emulated + /// route. Once the scope's pass is open the native route owns the group: a draw the device + /// skips (a pipeline still compiling) is the same skip the emulated path makes, and an + /// emulated draw inside an open native pass is not a thing this backend allows. + /// + internal bool TryDrawChunkPoolNative(VAO vao, int[] indicesStarts, int[] indicesSizes, int groupCount) + { + if (!chunkScopeActive || device == null) return false; + if (vao == null || vao.VaoId == 0 || vao.Disposed) return false; + if (groupCount <= 0) return chunkScopePassOpen; + + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (program == null || program.ProgramId <= 0) return chunkScopePassOpen; + + int layoutId = device.NativeMeshLayoutId(vao.VaoId); + if (layoutId < 0) return chunkScopePassOpen; + + FrameBufferRef target = chunkScopeTarget!; + string[] names = ChunkSamplerNames(program.ProgramId); + int textureCount = CollectChunkTextures(program, names, target, out bool samplesBoundDepth); + NativePipeline? pipeline = ChunkPipeline(program, target, layoutId, samplesBoundDepth); + if (pipeline == null) return chunkScopePassOpen; + + for (int i = 0; i < textureCount; i++) + { + chunkScopeTextures[i] = chunkScopeTextures[i] with { Sampler = pipeline.Sampler(names[i]) }; + } + + if (!chunkScopePassOpen && !OpenChunkPass(target, textureCount)) return false; + + device.DrawNativeMeshMulti(pipeline, vao.VaoId, indicesStarts, indicesSizes, groupCount, + new ReadOnlySpan(chunkScopeTextures, 0, textureCount)); + return true; + } + + /// + /// Declares the group's pass: the bound target, the slots the group writes and the textures + /// its first draw reads, in the viewport the stage left - the way the OpenGL body's + /// bind-only setter leaves it. + /// + private bool OpenChunkPass(FrameBufferRef target, int textureCount) + { + Rect2D viewport = device.NativeCurrentViewport; + chunkScopeOuterContext = passContext; + chunkScopeOuterFlags = passContextFlags; + + var reads = new int[textureCount]; + Array.Copy(chunkScopeReads, reads, textureCount); + if (!device.BeginNativePass(new NativePassDescription + { + Name = chunkScopeName + "/" + target.FboId, + FramebufferId = target.FboId, + ColorSlots = chunkScopeSlots, + Reads = reads, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + })) + { + return false; + } + chunkScopePassOpen = true; + return true; + } + + // ------------------------------------------------------------------- the pipeline + + /// + /// The pipeline for one group's program, target, mesh shape and fixed state. The device + /// caches the pipeline itself; this table only keeps the description and its blend array + /// from being rebuilt per draw. + /// + private NativePipeline? ChunkPipeline(ShaderProgramBase program, FrameBufferRef target, int layoutId, + bool samplesBoundDepth) + { + RenderTargetFormats? formats = device.NativeTargetFormats(target.FboId, chunkScopeSlots); + if (formats == null) return null; + + var key = new ChunkPipelineKey(program.ProgramId, target.FboId, chunkScopeSlots, layoutId, + chunkScopeBlend, chunkScopeDepthTest, chunkScopeDepthWrite, chunkScopeCull, + chunkScopeMotionOnly, samplesBoundDepth, nativeBlendEpoch); + if (chunkPipelines.TryGetValue(key, out NativePipeline? cached) && + device.IsNativePipelineLive(cached) && formats.Equals(cached.Description.Targets)) + { + return cached; + } + + NativePipeline? pipeline = device.RequestNativePipeline(new NativePipelineDescription + { + ProgramId = program.ProgramId, + PassName = program.PassName, + Blend = ChunkBlend(target, formats.ColorFormats.Length), + DepthTest = chunkScopeDepthTest, + DepthWrite = chunkScopeDepthWrite, + DepthCompare = CompareOp.Less, + Cull = chunkScopeCull ? CullModeFlags.BackBit : CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + VertexLayoutId = layoutId, + SamplesBoundDepth = samplesBoundDepth, + Targets = formats, + }, out string error); + + if (pipeline == null) + { + chunkPipelines.Remove(key); + if (RenderTrace.Enabled) + { + RenderTrace.Write("no native chunk pipeline for '" + chunkScopeName + "': " + error); + } + return null; + } + chunkPipelines[key] = pipeline; + return pipeline; + } + + /// + /// The colour slots a chunk group's pass writes. On Primary that is the client's default + /// set, plus the motion attachment while a motion window is open; on any other target it is + /// every bound slot, which is the set the emulated draw would have written. + /// + private uint ChunkColorSlots(FrameBufferRef target) + { + if (!IsPrimaryTarget(target)) return NativeAllColorSlots(target); + + int motion = MotionAttachmentIndex; + if (motion >= 0 && OptimumMotionWriteActive) return (1u << (motion + 1)) - 1u; + return motion > 0 ? (1u << motion) - 1u : NativeWorldColorSlots(); + } + + /// + /// The per-attachment blend of the group, rebuilt from the values the client applied + /// rather than read back off the state tracker. + /// + private AttachmentBlend[] ChunkBlend(FrameBufferRef target, int count) + { + var blend = new AttachmentBlend[Math.Max(count, 1)]; + bool primary = IsPrimaryTarget(target); + bool transparent = !primary && nativeTransparentBlend != null && IsTransparentTarget(target); + int motion = primary && OptimumMotionWriteActive ? MotionAttachmentIndex : -1; + + for (int slot = 0; slot < blend.Length; slot++) + { + AttachmentBlend entry; + if (transparent) + { + // The contract the client applied to the Transparent target, whichever of the + // two it was; its enable is the group's, because GL's is global. + entry = nativeTransparentBlend![slot]; + entry.Enabled = chunkScopeBlend; + } + else if (chunkScopeBlend && primary && OptimumRenderSsao && (slot == 2 || slot == 3)) + { + // GlToggleBlend's own exception: the SSAO G-buffer is replace-blended, because + // a blended normal or position belongs to neither surface. + entry = ReplaceBlend(chunkScopeBlend); + } + else + { + entry = AttachmentBlend.Default; + entry.Enabled = chunkScopeBlend; + } + + // The motion attachment never blends (ApplyOptimumMotionBlendState). + if (slot == motion) entry = ReplaceBlend(chunkScopeBlend); + + // The liquid velocity redraw writes the motion attachment and nothing else: the + // window is this mask, not a draw-buffer toggle. + if (chunkScopeMotionOnly && slot != motion) entry.WriteMask = 0; + blend[slot] = entry; + } + return blend; + } + + private static AttachmentBlend ReplaceBlend(bool enabled) + { + AttachmentBlend blend = AttachmentBlend.Default; + blend.Enabled = enabled; + blend.ColorOp = BlendOp.Add; + blend.AlphaOp = BlendOp.Add; + blend.SrcColor = BlendFactor.One; + blend.DstColor = BlendFactor.Zero; + blend.SrcAlpha = BlendFactor.One; + blend.DstAlpha = BlendFactor.Zero; + return blend; + } + + private bool IsPrimaryTarget(FrameBufferRef target) => + FrameBuffers != null && FrameBuffers.Count > 0 && ReferenceEquals(target, FrameBuffers[0]); + + private bool IsTransparentTarget(FrameBufferRef target) => + FrameBuffers != null && FrameBuffers.Count > 1 && ReferenceEquals(target, FrameBuffers[1]); + + // -------------------------------------------------------------------- the textures + + /// + /// The textures this draw samples: every sampler the program declares, resolved to the + /// handle the client last pointed it at, with the program's own sampler override where it + /// has one (the terrain atlas read twice, nearest and linear, is exactly that case). + /// Returns how many entries of the scratch arrays are in use, and whether any of them is + /// the depth attachment of the target being drawn into - chunkliquid's fade against the + /// depth it draws with writes off. + /// + private int CollectChunkTextures(ShaderProgramBase program, string[] names, FrameBufferRef target, + out bool samplesBoundDepth) + { + samplesBoundDepth = false; + if (chunkScopeTextures.Length < names.Length) + { + chunkScopeTextures = new NativeTexture[names.Length]; + chunkScopeReads = new int[names.Length]; + } + + nativeProgramTextures.TryGetValue(program.ProgramId, out Dictionary? bound); + for (int i = 0; i < names.Length; i++) + { + int textureId = 0; + if (bound != null) bound.TryGetValue(names[i], out textureId); + + SamplerState? sampling = null; + if (program.customSamplers.TryGetValue(names[i], out int samplerId) && + device.TryNativeSamplerState(samplerId, out SamplerState custom)) + { + sampling = custom; + } + + if (textureId > 0 && textureId == target.DepthTextureId && !chunkScopeDepthWrite) + { + samplesBoundDepth = true; + } + + chunkScopeTextures[i] = new NativeTexture(NativeSamplerSlot.None, textureId, sampling); + chunkScopeReads[i] = textureId; + } + return names.Length; + } + + private string[] ChunkSamplerNames(int programId) + { + if (nativeProgramSamplers.TryGetValue(programId, out string[]? names)) return names; + names = device.SamplerNamesOf(programId).ToArray(); + nativeProgramSamplers[programId] = names; + return names; + } + + /// + /// A relinked or deleted program's cached sampler names, texture bindings and pipelines are + /// no longer its own: a shader reload hands the same id a different interface. + /// + internal void ForgetNativeChunkProgram(int programId) + { + nativeProgramSamplers.Remove(programId); + nativeProgramTextures.Remove(programId); + if (chunkPipelines.Count == 0) return; + + var stale = new List(); + foreach (KeyValuePair entry in chunkPipelines) + { + if (entry.Key.ProgramId == programId) stale.Add(entry.Key); + } + for (int i = 0; i < stale.Count; i++) chunkPipelines.Remove(stale[i]); + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs index 1ac7cbc0..242a3ad8 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs @@ -73,6 +73,7 @@ public override void DisposeShaderProgram(ShaderProgramBase program) { device.DeleteSampler(optimumSampler.Value); } + ForgetNativeChunkProgram(program.ProgramId); device.DeleteProgram(program.ProgramId); } @@ -173,6 +174,10 @@ public override void SetUniformMatrices4x3(int programId, int location, int coun /// public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) { + // Phase 3b stage 2: this is where the client states which texture a sampler reads, so + // it is where a native pass takes the handle from (VulkanClientPlatform.NativeChunks.cs). + // The unit binding below still happens, so the emulated route is unchanged. + NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); device.BindTexture(textureNumber, textureId); if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) @@ -195,6 +200,7 @@ public override void BindProgramTexture2D(ShaderProgramBase program, string samp public override void BindProgramTextureCube(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) { + NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); device.BindTextureCube(textureNumber, textureId); if (program.clampTToEdge) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 5dcff836..0a64eb4a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -139,6 +139,10 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderOptimumSkyMotion", Array.Empty()), // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. new(true, "RenderSkyDome", new[] { "MeshRef", "Int32", "Int32", "Single[]" }), + // Phase 3b stage 2: the chunk draw-group scope every ChunkRenderer pass brackets + // its pools with, which routes the terrain multi-draws through the native API. + new(true, "BeginChunkPass", new[] { "String", "Boolean", "Boolean", "Boolean", "Boolean" }), + new(true, "EndChunkPass", Array.Empty()), new(true, "RenderOptimumTaaResolve", Array.Empty()), new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), // Phase 3b stage 1: the two TAA passes' draw seams, which the native chain replaces. diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 6cfc176a..a6c00cd4 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -327,6 +327,15 @@ internal string NativeVariantOf(int programId) => /// internal int NativeMeshLayoutId(int meshId) => _meshes.LayoutIdOf(meshId); + /// + /// The state of a sampler object the client created (glGenSamplers), so a native system + /// can read a program's own sampler override - the chunk terrain's linear sampler on the + /// same atlas texture the nearest sampler reads - straight from the handle the client holds, + /// instead of through the texture unit it was bound to. False for an id that is not one. + /// + internal bool TryNativeSamplerState(int samplerId, out SamplerState state) => + _standaloneSamplers.TryGetValue(samplerId, out state); + private int ResolveNativeFramebuffer(int framebufferId) => framebufferId == PassDeclaration.DefaultFramebuffer ? _defaultFramebuffer : framebufferId; diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index b9a9d57e..23c9e9ce 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -192,7 +192,12 @@ public void ThePlantFlagIsTheNoCullOpaquePassAndTheComposeDropsTheRowMin() // ChunkRenderer sets HaxyFade = 1 exactly for the OpaqueNoCull pool (plants, grass, cross-quads). string renderer = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); string opaque = Between(renderer, "public void RenderOpaque(float dt)", "ScreenManager.FrameProfiler.Mark(\"rend3D-ret-opnc\");"); - Assert.Matches(new Regex(@"chunkopaque\.HaxyFade = 1;\s*for \(int l = 0; l < textureIds\.Length; l\+\+\)\s*\{[^}]*poolsByRenderPass\[1\]"), opaque); + // The native chunk-pass scope (Phase 3b stage 2) brackets the loop, so the flag and the + // pool are still adjacent with the scope's Begin/try between them. + Assert.Matches(new Regex( + @"chunkopaque\.HaxyFade = 1;.{0,400}?for \(int l = 0; l < textureIds\.Length; l\+\+\).{0,300}?poolsByRenderPass\[1\]", + RegexOptions.Singleline), + opaque); string compose = Read("sources/shaders/scene-ssao.fsh").Replace("\r\n", "\n"); Assert.Contains("#if OPTIMUMAO > 0\n if (optimumAoMode == 1)", compose); diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 03bb81b5..b2b9b541 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -16,6 +16,7 @@ namespace Optimum.Tests; public class NativeWorldSystemsCoverageTests { private const string SkyPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs"; + private const string ChunkPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs"; private const string DeviceMeshFile = "Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"; private const string DeviceNativeFile = "Optimum.Render.Vulkan/VulkanDevice.Native.cs"; @@ -183,6 +184,150 @@ public void ThePipelineDescriptionAndKeyCarryTheMeshDrawState() Assert.Contains("_meshes.LayoutOf(description.VertexLayoutId)", native); } + /// + /// The chunk groups have a seam of their own, and its neutral bodies do nothing at all - + /// which is what keeps the OpenGL path drawing exactly the bodies it drew before, with its + /// GlToggleBlend / depth / cull calls still in place. + /// + [Fact] + public void TheChunkGroupsHaveAScopeSeamWhoseNeutralBodyDoesNothing() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + Assert.Contains( + "public virtual bool BeginChunkPass(string chunkPass, bool blend, bool depthTest, bool depthWrite, bool cullFace)", + platform); + Assert.Contains("public virtual void EndChunkPass()", platform); + + // The OpenGL platform leaves both alone: nothing about the GL path changes. + string windows = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.DoesNotContain("BeginChunkPass", windows); + Assert.DoesNotContain("EndChunkPass", windows); + } + + /// + /// Every ChunkRenderer draw group brackets its pools with the seam and states the fixed + /// state that group runs under - and still makes the GL state calls the OpenGL path needs, + /// because those are what the GL body draws with. + /// + [Fact] + public void EveryChunkDrawGroupDrawsInsideTheScope() + { + string renderer = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + foreach (string group in new[] + { + "chunk-shadow-opaque", "chunk-shadow-topsoil", "chunk-shadow-vegetation", + "chunk-shadow-blendnocull", "chunk-opaque", "chunk-topsoil", "chunk-vegetation", + "chunk-blendnocull", "chunk-decorative", "chunk-oit-liquid", "chunk-oit-transparent", + "chunk-liquid-motion", "chunk-overlay", + }) + { + Assert.Contains("platform.BeginChunkPass(\"" + group + "\"", renderer); + } + + // One close per open, and each in a finally, so a throwing pool draw cannot leave a + // pass open for the rest of the frame. + int opens = Count(renderer, "platform.BeginChunkPass("); + int closes = Count(renderer, "platform.EndChunkPass();"); + Assert.Equal(13, opens); + Assert.Equal(opens, closes); + + // "OFF is vanilla": the GL state the OpenGL body draws under is still set. + Assert.Contains("platform.GlToggleBlend(on: false);", renderer); + Assert.Contains("platform.GlEnableCullFace();", renderer); + Assert.Contains("platform.GlDepthMask(flag: true);", renderer); + } + + /// Every new or changed lib member of the chunk port is listed for the Cecil transplant. + [Fact] + public void TheChunkSeamAndItsCallersAreListedForTheTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"BeginChunkPass\"", patcher); + Assert.Contains("\"EndChunkPass\"", patcher); + foreach (string method in new[] { "RenderShadow", "RenderOpaque", "RenderOIT", "RenderAfterOIT" }) + { + Assert.Contains("\"Vintagestory.Client.NoObf.ChunkRenderer\", \"" + method + "\", 1", patcher); + } + // RenderLiquidMotion is an injected member rather than a transplanted vanilla one. + Assert.Contains("\"RenderLiquidMotion\"", patcher); + } + + /// + /// The Vulkan platform records the chunk groups as native passes with indirect multi-draws, + /// states its own fixed state, expresses the motion window as a colour-write mask, and keeps + /// the old route reachable behind a switch. + /// + [Fact] + public void TheVulkanPlatformRecordsTheChunkGroupsNativelyAndKeepsTheOldRoute() + { + string chunks = Read(ChunkPlatformFile); + + Assert.Contains("internal bool NativeChunksEnabled { get; set; } = true;", chunks); + Assert.Contains("public override bool BeginChunkPass(", chunks); + Assert.Contains("public override void EndChunkPass()", chunks); + Assert.Contains("device.BeginNativePass(", chunks); + Assert.Contains("device.EndNativePass();", chunks); + + // The multi-draw stays a multi-draw, over the mesh's own vertex layout. + Assert.Contains("device.DrawNativeMeshMulti(", chunks); + Assert.Contains("VertexLayoutId = layoutId", chunks); + Assert.Contains("device.NativeMeshLayoutId(", chunks); + + // The motion window is a write mask, never a draw-buffer toggle. + Assert.Contains("if (chunkScopeMotionOnly && slot != motion) entry.WriteMask = 0;", chunks); + Assert.DoesNotContain("SetDrawBuffers", chunks); + + // The state is stated, not read back off the tracker. + Assert.Contains("DepthTest = chunkScopeDepthTest", chunks); + Assert.Contains("DepthWrite = chunkScopeDepthWrite", chunks); + Assert.Contains("Cull = chunkScopeCull ? CullModeFlags.BackBit : CullModeFlags.None", chunks); + Assert.Contains("SamplesBoundDepth = samplesBoundDepth", chunks); + + // The route in: the pool's multi-draw seam takes the native path only inside a scope. + string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); + Assert.Contains("if (TryDrawChunkPoolNative(vAO, indices, indicesSizes, groupCount)) return;", meshes); + Assert.Contains("device.DrawMeshMulti(vAO.VaoId, indices, indicesSizes, groupCount, useSSBOs);", meshes); + } + + /// + /// The values a native chunk pass cannot read off GL state are recorded where the client + /// states them: the texture behind each sampler, and the Transparent target's blend contract. + /// + [Fact] + public void TheClientStateANativeChunkPassNeedsIsRecordedAtItsOwnSeam() + { + string shaders = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs"); + Assert.Contains("NoteNativeProgramTexture(program.ProgramId, samplerName, textureId);", shaders); + // A relinked program's cached interface and pipelines go with it. + Assert.Contains("ForgetNativeChunkProgram(program.ProgramId);", shaders); + + string leaf = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs"); + Assert.Contains("NoteNativeTransparentBlend(0, 32774, 774, 0, 774, 0);", leaf); + + string buffers = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs"); + Assert.Contains("NoteNativeTransparentBlend(2, 32774, 770, 771, 770, 771);", buffers); + } + + private static int Count(string source, string needle) + { + int count = 0; + int at = source.IndexOf(needle, StringComparison.Ordinal); + while (at >= 0) + { + count++; + at = source.IndexOf(needle, at + needle.Length, StringComparison.Ordinal); + } + return count; + } + // ------------------------------------------------------------------------ helpers private static string Section(string source, string from, string to) diff --git a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs index 763969ed..82c7f4e1 100644 --- a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -368,10 +368,17 @@ public void TheCompatibilityScannerDisablesTaaForAnExternalLiquidShader() // ----------------------------------------------------------------- helpers /// The braced block of the method's finally clause. + /// + /// The pass's own restore block: the LAST finally in the method body. Phase 3b stage 2 put a + /// short inner try/finally around each pool loop (the native chunk-pass scope), so the first + /// finally in the method is no longer the one that restores the pass's state. + /// private static string FinallyBlock(string body) { - var match = System.Text.RegularExpressions.Regex.Match(body, @"finally\s*\{"); - Assert.True(match.Success, "no finally block"); + System.Text.RegularExpressions.MatchCollection matches = + System.Text.RegularExpressions.Regex.Matches(body, @"finally\s*\{"); + Assert.True(matches.Count > 0, "no finally block"); + var match = matches[matches.Count - 1]; int open = body.IndexOf('{', match.Index); int depth = 0; for (int i = open; i < body.Length; i++) diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index da433f1e..e2d9fe0b 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -171,6 +171,44 @@ extends the API and ports the simplest system through it. for the lib seam. The four system stages add their systems to those files rather than to files named after the stage. +### Chunks: the terrain on the mesh-draw API + +The first of the four system stages, and the heaviest draw path in the game. + +- **Seam:** `ClientPlatformAbstract.BeginChunkPass(string chunkPass, bool blend, bool depthTest, + bool depthWrite, bool cullFace)` / `EndChunkPass()`, with neutral bodies that do nothing at all - + so the OpenGL path keeps drawing today's bodies under today's `GlToggleBlend` / depth / cull + calls. Every `ChunkRenderer` draw group brackets its pool loop with the pair in a `try` / + `finally`: the two shadow cascades' four groups, the Opaque stage's five, the OIT liquid and + transparent groups, the liquid velocity redraw and the AfterOIT terrain overlay - thirteen in all, + each naming itself and stating the fixed state it runs under. +- **Platform** (`VulkanClientPlatform.NativeChunks.cs`): the scope opens a declared pass on the + bound target at its first draw, and the pool's multi-draw reaches + `VulkanDevice.DrawNativeMeshMulti` from the existing `RenderMesh(MeshRef, int[], int[], int, bool)` + seam. It stays multi-draw indirect over per-chunk meshes with the FaceData storage buffer, because + the real mesh id reaches `BindProgramSets`. The greedy-mesh and NoSSBO variants are just other + vertex layouts and other program variants, and the pipeline is keyed on both. +- **The motion window is a colour-write mask.** With a window open the motion attachment joins the + pass's colour slots with replace blending; the liquid velocity redraw's motion-only window is the + same slots with the write mask zeroed on every other one. No `SetDrawBuffers` appears in the native + chunk path. +- **State the platform states rather than reads:** the standard blend mode, replace blending on the + SSAO G-buffer slots and the motion slot (what `GlToggleBlend` and `ApplyOptimumMotionBlendState` + apply), and - for the Transparent target - whichever contract the client last applied, recorded at + `ApplyTransparentPassBlendState` and `BeginOitAccumulation` rather than read back out of + `GlStateTracker`. The texture behind each sampler is recorded at `BindProgramTexture2D`, the seam + where the client states it, so the draw resolves handles rather than units. `chunkliquid` samples + the depth attachment it draws against with writes off, which the pipeline declares through + `SamplesBoundDepth`. +- **Still emulated inside a chunk group:** the uniform values the group sets by name (the per-pool + `origin` from `MeshDataPoolManager` and the generated program setters). They land in the same + per-program record and push shadows the native draw snapshots, so the image is identical; removing + that dispatch means moving `MeshDataPoolManager`, which is API-fork code and a later stage. +- **Tests:** `NativeChunkTests` (old route against native route per group: blend and cull + combinations, the motion attachment bit for bit, the motion-only mask, the shadow cascade, one + declared pass and one indirect draw per group, pipelines built once) and the chunk facts in + `Optimum.Tests/native-world-systems-coverage-tests.cs`. + ## 4. Documentation that makes map stages unnecessary Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch index 782db59e..4efa4b9a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs -index 431e51a..2e2e836 100644 +index 431e51a..4586d6e 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs @@ -1,9 +1,11 @@ @@ -142,9 +142,46 @@ index 431e51a..2e2e836 100644 RuntimeStats.availableTriangles = 0; accum += dt; if (accum > 5f) -@@ -208,23 +294,59 @@ public class ChunkRenderer - chunkshadowmap.Tex2d2D = textureIds[j]; - poolsByRenderPass[5][j].Render(cameraPos, "origin", frustumCullMode); +@@ -195,36 +281,108 @@ public class ChunkRenderer + platform.GlDepthMask(flag: true); + platform.GlToggleBlend(on: false); + platform.GlEnableDepthTest(); + platform.GlDisableCullFace(); + EnumFrustumCullMode frustumCullMode = ((game.currentRenderStage == EnumRenderStage.ShadowFar) ? EnumFrustumCullMode.CullInstantShadowPassFar : EnumFrustumCullMode.CullInstantShadowPassNear); +- for (int i = 0; i < textureIds.Length; i++) ++ // Optimum (Phase 3b stage 2): the cascade's opaque terrain group: depth on and written, no blending, no culling, as RenderShadow set it. ++ platform.BeginChunkPass("chunk-shadow-opaque", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try + { +- chunkshadowmap.Tex2d2D = textureIds[i]; +- poolsByRenderPass[0][i].Render(cameraPos, "origin", frustumCullMode); ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ chunkshadowmap.Tex2d2D = textureIds[i]; ++ poolsByRenderPass[0][i].Render(cameraPos, "origin", frustumCullMode); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + ScreenManager.FrameProfiler.Mark("rend3D-rets-op"); +- for (int j = 0; j < textureIds.Length; j++) ++ // Optimum (Phase 3b stage 2): the cascade's topsoil group: depth on and written, no blending, no culling, as RenderShadow set it. ++ platform.BeginChunkPass("chunk-shadow-topsoil", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try ++ { ++ for (int j = 0; j < textureIds.Length; j++) ++ { ++ chunkshadowmap.Tex2d2D = textureIds[j]; ++ poolsByRenderPass[5][j].Render(cameraPos, "origin", frustumCullMode); ++ } ++ } ++ finally + { +- chunkshadowmap.Tex2d2D = textureIds[j]; +- poolsByRenderPass[5][j].Render(cameraPos, "origin", frustumCullMode); ++ platform.EndChunkPass(); } ScreenManager.FrameProfiler.Mark("rend3D-rets-tpp"); platform.GlDisableCullFace(); @@ -155,22 +192,43 @@ index 431e51a..2e2e836 100644 + if (!ClientSettings.OptimumShadowFarVegetation || game.currentRenderStage != EnumRenderStage.ShadowFar) + { + OptimumDiagnostics.ShadowFarVegetation.Hit(); -+ for (int k = 0; k < textureIds.Length; k++) ++ // Optimum (Phase 3b stage 2): the cascade's vegetation group: depth on and written, no blending, no culling, as RenderShadow set it. ++ platform.BeginChunkPass("chunk-shadow-vegetation", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try + { -+ chunkshadowmap.Tex2d2D = textureIds[k]; -+ poolsByRenderPass[2][k].Render(cameraPos, "origin", frustumCullMode); ++ for (int k = 0; k < textureIds.Length; k++) ++ { ++ chunkshadowmap.Tex2d2D = textureIds[k]; ++ poolsByRenderPass[2][k].Render(cameraPos, "origin", frustumCullMode); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + } + else ++ { ++ OptimumDiagnostics.ShadowFarVegetation.Skip(); ++ } ++ // Optimum (Phase 3b stage 2): the cascade's blend-no-cull group: depth on and written, no blending, no culling, as RenderShadow set it. ++ platform.BeginChunkPass("chunk-shadow-blendnocull", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try { - chunkshadowmap.Tex2d2D = textureIds[k]; - poolsByRenderPass[2][k].Render(cameraPos, "origin", frustumCullMode); -+ OptimumDiagnostics.ShadowFarVegetation.Skip(); ++ for (int l = 0; l < textureIds.Length; l++) ++ { ++ chunkshadowmap.Tex2d2D = textureIds[l]; ++ poolsByRenderPass[1][l].Render(cameraPos, "origin", frustumCullMode); ++ } } - for (int l = 0; l < textureIds.Length; l++) +- for (int l = 0; l < textureIds.Length; l++) ++ finally { - chunkshadowmap.Tex2d2D = textureIds[l]; - poolsByRenderPass[1][l].Render(cameraPos, "origin", frustumCullMode); +- chunkshadowmap.Tex2d2D = textureIds[l]; +- poolsByRenderPass[1][l].Render(cameraPos, "origin", frustumCullMode); ++ platform.EndChunkPass(); } platform.GlToggleBlend(on: true); } @@ -205,7 +263,7 @@ index 431e51a..2e2e836 100644 Vec3d cameraPos = game.EntityPlayer.CameraPos; ScreenManager.FrameProfiler.Mark("rend3D-ret-begin"); platform.GlDepthMask(flag: true); -@@ -232,105 +354,132 @@ public class ChunkRenderer +@@ -232,105 +390,177 @@ public class ChunkRenderer platform.GlToggleBlend(on: true); platform.GlEnableCullFace(); game.GlMatrixModeModelView(); @@ -307,11 +365,20 @@ index 431e51a..2e2e836 100644 + { + SetOptimumMotionUniforms(chunkopaque); + } -+ for (int i = 0; i < textureIds.Length; i++) ++ // Optimum (Phase 3b stage 2): the opaque terrain group (render pass 0): blending on, depth on and written, back faces culled, and the motion attachment in the pass's write mask while the window above is open. ++ platform.BeginChunkPass("chunk-opaque", blend: true, depthTest: true, depthWrite: true, cullFace: true); ++ try + { -+ chunkopaque.TerrainTex2D = textureIds[i]; -+ chunkopaque.TerrainTexLinear2D = textureIds[i]; -+ poolsByRenderPass[0][i].Render(cameraPos, "origin"); ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ chunkopaque.TerrainTex2D = textureIds[i]; ++ chunkopaque.TerrainTexLinear2D = textureIds[i]; ++ poolsByRenderPass[0][i].Render(cameraPos, "origin"); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + ScreenManager.FrameProfiler.Mark("rend3D-ret-op"); + chunkopaque.Stop(); @@ -330,11 +397,20 @@ index 431e51a..2e2e836 100644 + { + SetOptimumMotionUniforms(chunktopsoil); + } -+ for (int j = 0; j < textureIds.Length; j++) ++ // Optimum (Phase 3b stage 2): the topsoil group (render pass 5): the same state as the opaque group, a different program. ++ platform.BeginChunkPass("chunk-topsoil", blend: true, depthTest: true, depthWrite: true, cullFace: true); ++ try + { -+ chunktopsoil.TerrainTex2D = textureIds[j]; -+ chunktopsoil.TerrainTexLinear2D = textureIds[j]; -+ poolsByRenderPass[5][j].Render(cameraPos, "origin"); ++ for (int j = 0; j < textureIds.Length; j++) ++ { ++ chunktopsoil.TerrainTex2D = textureIds[j]; ++ chunktopsoil.TerrainTexLinear2D = textureIds[j]; ++ poolsByRenderPass[5][j].Render(cameraPos, "origin"); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + ScreenManager.FrameProfiler.Mark("rend3D-ret-tpp"); + chunktopsoil.Stop(); @@ -349,30 +425,57 @@ index 431e51a..2e2e836 100644 + chunkopaque.AlphaTest = 0.25f; + chunkopaque.HaxyFade = 0; + platform.GlToggleBlend(on: true); -+ for (int k = 0; k < textureIds.Length; k++) ++ // Optimum (Phase 3b stage 2): the vegetation group (render pass 2): culling off (GlDisableCullFace above), blending on, alpha test 0.25. ++ platform.BeginChunkPass("chunk-vegetation", blend: true, depthTest: true, depthWrite: true, cullFace: false); ++ try + { -+ chunkopaque.TerrainTex2D = textureIds[k]; -+ chunkopaque.TerrainTexLinear2D = textureIds[k]; -+ poolsByRenderPass[2][k].Render(cameraPos, "origin"); ++ for (int k = 0; k < textureIds.Length; k++) ++ { ++ chunkopaque.TerrainTex2D = textureIds[k]; ++ chunkopaque.TerrainTexLinear2D = textureIds[k]; ++ poolsByRenderPass[2][k].Render(cameraPos, "origin"); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + platform.GlToggleBlend(on: false); + chunkopaque.AlphaTest = 0.42f; + chunkopaque.HaxyFade = 1; -+ for (int l = 0; l < textureIds.Length; l++) ++ // Optimum (Phase 3b stage 2): the blend-no-cull group (render pass 1): blending off, culling off, alpha test 0.42 with the haxy fade on. ++ platform.BeginChunkPass("chunk-blendnocull", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try + { -+ chunkopaque.TerrainTex2D = textureIds[l]; -+ chunkopaque.TerrainTexLinear2D = textureIds[l]; -+ poolsByRenderPass[1][l].Render(cameraPos, "origin"); ++ for (int l = 0; l < textureIds.Length; l++) ++ { ++ chunkopaque.TerrainTex2D = textureIds[l]; ++ chunkopaque.TerrainTexLinear2D = textureIds[l]; ++ poolsByRenderPass[1][l].Render(cameraPos, "origin"); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + platform.GlEnableCullFace(); + platform.GlToggleBlend(on: true); + chunkopaque.AlphaTest = 0.2f; + chunkopaque.HaxyFade = 0; -+ for (int m = 0; m < textureIds.Length; m++) ++ // Optimum (Phase 3b stage 2): the decorative group (render pass 8): culling and blending back on, alpha test 0.2. ++ platform.BeginChunkPass("chunk-decorative", blend: true, depthTest: true, depthWrite: true, cullFace: true); ++ try ++ { ++ for (int m = 0; m < textureIds.Length; m++) ++ { ++ chunkopaque.TerrainTex2D = textureIds[m]; ++ chunkopaque.TerrainTexLinear2D = textureIds[m]; ++ poolsByRenderPass[8][m].Render(cameraPos, "origin"); ++ } ++ } ++ finally + { -+ chunkopaque.TerrainTex2D = textureIds[m]; -+ chunkopaque.TerrainTexLinear2D = textureIds[m]; -+ poolsByRenderPass[8][m].Render(cameraPos, "origin"); ++ platform.EndChunkPass(); + } + platform.GlToggleBlend(on: false); + chunkopaque.Stop(); @@ -425,7 +528,62 @@ index 431e51a..2e2e836 100644 internal void RenderOIT(float deltaTime) { -@@ -402,51 +551,187 @@ public class ChunkRenderer +@@ -369,14 +599,23 @@ public class ChunkRenderer + chunkliquid.DepthTex2D = frameBufferRef.DepthTextureId; + chunkliquid.Uniform("frameSize", frameBufferRef.Width, frameBufferRef.Height); + chunkliquid.SunSpecularIntensity = game.shUniforms.SunSpecularIntensity; + bool useSSBOs = game.api.renderapi.useSSBOs; + game.api.renderapi.useSSBOs = false; +- for (int i = 0; i < textureIds.Length; i++) ++ // Optimum (Phase 3b stage 2): the OIT liquid group (render pass 4) on the Transparent target: depth tested but not written and culling off, both from LoadFrameBuffer(Transparent), under the per-attachment blend contract the client applied to that target. The program samples Primary's depth - the depth attachment it draws against - with writes off, which the pipeline declares. ++ platform.BeginChunkPass("chunk-oit-liquid", blend: true, depthTest: true, depthWrite: false, cullFace: false); ++ try + { +- chunkliquid.TerrainTex2D = textureIds[i]; +- poolsByRenderPass[4][i].Render(cameraPos, "origin"); ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ chunkliquid.TerrainTex2D = textureIds[i]; ++ poolsByRenderPass[4][i].Render(cameraPos, "origin"); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + game.api.renderapi.useSSBOs = useSSBOs; + chunkliquid.Stop(); + ScreenManager.FrameProfiler.Mark("rend3D-ret-lp"); + game.GlPopMatrix(); +@@ -388,65 +627,228 @@ public class ChunkRenderer + chunktransparent.FogMinIn = game.AmbientManager.BlendedFogMin; + chunktransparent.ProjectionMatrix = game.CurrentProjectionMatrix; + chunktransparent.ModelViewMatrix = game.CurrentModelViewMatrix; + chunktransparent.Uniform("subpixelPaddingX", subPixelPaddingX); + chunktransparent.Uniform("subpixelPaddingY", subPixelPaddingY); +- for (int j = 0; j < textureIds.Length; j++) ++ // Optimum (Phase 3b stage 2): the OIT transparent and meta-block groups (render passes 3 and 6): the same Transparent-target state as the liquid group. ++ platform.BeginChunkPass("chunk-oit-transparent", blend: true, depthTest: true, depthWrite: false, cullFace: false); ++ try + { +- chunktransparent.TerrainTex2D = textureIds[j]; +- poolsByRenderPass[3][j].Render(cameraPos, "origin"); +- if (ClientSettings.RenderMetaBlocks) ++ for (int j = 0; j < textureIds.Length; j++) + { +- poolsByRenderPass[6][j].Render(cameraPos, "origin"); ++ chunktransparent.TerrainTex2D = textureIds[j]; ++ poolsByRenderPass[3][j].Render(cameraPos, "origin"); ++ if (ClientSettings.RenderMetaBlocks) ++ { ++ poolsByRenderPass[6][j].Render(cameraPos, "origin"); ++ } + } + } ++ finally ++ { ++ platform.EndChunkPass(); ++ } chunktransparent.Stop(); game.GlPopMatrix(); ScreenManager.FrameProfiler.Mark("rend3D-ret-tp"); @@ -517,9 +675,18 @@ index 431e51a..2e2e836 100644 + liquidMotion.Uniform("taaLiquidReactive", OptimumLiquidReactive); + SetOptimumMotionUniforms(liquidMotion); + game.api.renderapi.useSSBOs = false; -+ for (int i = 0; i < textureIds.Length; i++) ++ // Optimum (Phase 3b stage 2): the liquid velocity redraw (TAA-PLAN.md accuracy rule 7): blending off, depth tested AND written, culling off - and every colour slot but the motion attachment masked out of the pass, which is what BeginMotionOnlyWrite means here. ++ platform.BeginChunkPass("chunk-liquid-motion", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try + { -+ poolsByRenderPass[4][i].Render(cameraPos, "origin"); ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ poolsByRenderPass[4][i].Render(cameraPos, "origin"); ++ } ++ } ++ finally ++ { ++ platform.EndChunkPass(); + } + liquidMotion.Stop(); + ScreenManager.FrameProfiler.Mark("rend3D-ret-lqmv"); @@ -575,7 +742,10 @@ index 431e51a..2e2e836 100644 + ClientPlatformAbstract optimumPlatform = platform; + bool optimumMotionWrite = optimumPlatform != null && optimumPlatform.BeginMotionWrite(); + try -+ { + { +- chunkopaque.TerrainTex2D = textureIds[i]; +- chunkopaque.TerrainTexLinear2D = textureIds[i]; +- poolsByRenderPass[7][i].Render(cameraPos, "origin"); + ShaderProgramChunkopaque chunkopaque = ShaderPrograms.Chunkopaque; + platform.GlDisableCullFace(); + platform.GlToggleBlend(on: false); @@ -598,19 +768,25 @@ index 431e51a..2e2e836 100644 + { + SetOptimumMotionUniforms(chunkopaque); + } -+ for (int i = 0; i < textureIds.Length; i++) ++ // Optimum (Phase 3b stage 2): the terrain overlay group (render pass 7): blending off, culling off, depth tested and written (ClientMain re-enables the depth mask before the AfterOIT stage), and the motion attachment in the write mask while the window is open. ++ platform.BeginChunkPass("chunk-overlay", blend: false, depthTest: true, depthWrite: true, cullFace: false); ++ try ++ { ++ for (int i = 0; i < textureIds.Length; i++) ++ { ++ chunkopaque.TerrainTex2D = textureIds[i]; ++ chunkopaque.TerrainTexLinear2D = textureIds[i]; ++ poolsByRenderPass[7][i].Render(cameraPos, "origin"); ++ } ++ } ++ finally + { -+ chunkopaque.TerrainTex2D = textureIds[i]; -+ chunkopaque.TerrainTexLinear2D = textureIds[i]; -+ poolsByRenderPass[7][i].Render(cameraPos, "origin"); ++ platform.EndChunkPass(); + } + chunkopaque.Stop(); + } + finally - { -- chunkopaque.TerrainTex2D = textureIds[i]; -- chunkopaque.TerrainTexLinear2D = textureIds[i]; -- poolsByRenderPass[7][i].Render(cameraPos, "origin"); ++ { + // Same contract as RenderOpaque: the window closes even if a shader + // setup or a pool draw throws, so a failed overlay pass cannot leave + // the motion attachment in the draw-buffer mask. diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index a79c725c..b13430e1 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..b7f9663 100644 +index d6eb844..632275c 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,496 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,536 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -126,6 +126,46 @@ index d6eb844..b7f9663 100644 + RenderMesh(skyDome); + } + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the scope one ++ /// ChunkRenderer draw group draws its chunk pools under. ++ /// ++ /// What it draws: nothing itself. It brackets one group of terrain multi-draws - a shadow ++ /// cascade, an opaque terrain group, a topsoil group, the OIT liquid or transparent group, ++ /// the liquid velocity redraw or the AfterOIT terrain overlay - and states the fixed state ++ /// that group runs under, so a native platform can build a pipeline from stated values ++ /// instead of reading them back out of tracked GL state (decision 3). ++ /// The other side: the neutral body below is the OpenGL path. It does nothing and returns ++ /// false, so ClientPlatformWindows keeps drawing exactly the bodies it draws today ("OFF is ++ /// vanilla"); the GlToggleBlend / GlEnableDepthTest / GlDepthMask / cull calls around the ++ /// group stay where they are and are what the GL path still runs on. ++ /// VulkanClientPlatform.BeginChunkPass is the native one: it opens a declared pass on the ++ /// bound target and routes the group's multi-draws through the native device API. ++ /// Target and slots: whatever the stage bound - Primary for the opaque, overlay and liquid ++ /// velocity groups, the Transparent target for the OIT groups, a shadow map for the ++ /// cascades. The motion window is expressed as the pass's colour-write mask, never as a ++ /// draw-buffer toggle. ++ /// State that is not obvious: is the global GL_BLEND toggle the ++ /// group runs with, not a blend mode; the per-attachment functions are the ones the client ++ /// applied for that target (the standard mode plus the replace-blended SSAO G-buffer and ++ /// motion slots, or the OIT accumulation contract on the Transparent target). ++ /// What pins it: NativeChunkTests (old route against native route, per pass) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ /// Whether a native platform took the group; false means the ordinary draw route. ++ public virtual bool BeginChunkPass(string chunkPass, bool blend, bool depthTest, bool depthWrite, bool cullFace) ++ { ++ return false; ++ } ++ ++ /// ++ /// Optimum (Phase 3b stage 2): closes the scope opened. A ++ /// no-op on the OpenGL path, and safe to call when Begin returned false. ++ /// ++ public virtual void EndChunkPass() ++ { ++ } ++ + public virtual bool RenderOptimumTaaResolve() + { + return false; From 1a5de531fee31f9be2530aa587c00f47fb21d9d4 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 12:36:36 +0200 Subject: [PATCH 200/226] wip(native-world): entity draws on the native device API Phase 3b decision 5 stage 2, the entity system. SystemRenderEntities' animated entities (entityanimated, Opaque -> Primary) and their shadow-map passes (shadowmapentityanimated -> the colourless shadow targets) draw through the native device API; every other caller of the seam keeps the neutral body. Seam: ClientPlatformAbstract.RenderEntityMesh(MeshRef, string samplerName, int textureId), neutral body RenderMesh(mesh) - ClientPlatformWindows does not override it, so the OpenGL path is unchanged. RenderAPIBase.RenderMultiTextureMesh draws each sub-mesh through it. Both listed in Optimum.Patcher/Program.cs. Native route (VulkanClientPlatform.NativeEntities.cs): - fixed state stated from client state, never the tracker: cull off, depth test and write on, compare LESS, standard alpha blend on colour and glow, replace on the SSAO G-buffer slots; - the motion window is a colour write mask on the pipeline (OptimumMotionWriteActive + MotionAttachmentIndex), replace-blend while open and no write while shut. No SetDrawBuffers on this path; - the draw is recorded inside the stage's own declared pass (BoundPassName(), every slot) and closed with the new EndNativePass(keepScope: true), so a loop of hundreds of entities does not end and restart the rendering scope per entity. The native-pass bookkeeping still clears, so the uniforms the renderers set by name between draws stay outside a native pass; - bone matrices need no new API: UBO.Update("Animation", ...) keeps feeding the device's ring with its per-(frame, version) dedup and the draw passes its real mesh id into BindProgramSets; - a native draw resolves every sampler its program declares, from a name-keyed record of the client's own BindProgramTexture2D declarations, because the emulated sampler resolve never runs for a program whose draws are all native. Held items through `standard` stay on the neutral body: their cull mode is decided per draw by renderInfo.CullFaces inside the VSEssentials fork and no seam carries it, so a native pipeline would have to read the tracker back. The `instanced` program has no vanilla call site in this tree. Verified: dotnet build VintageStory.slnx -c Release; extract-patches + check-patches (157 patches, 0 conflict; 43 runtime patches, exact donors); Optimum.Tests 1255 passed; Optimum.Render.Vulkan.Tests 1083 passed with sync,best validation and the implicit-layer disable set exported, NativeEntityDrawTests included (8: old route against native route across SSAO G-buffer x motion window, the motion attachment on its own, the emulation boundary, pipeline identity). --- Optimum.Patcher/Program.cs | 4 + .../NativeEntityDrawTests.cs | 596 ++++++++++++++++++ .../Platform/VulkanClientPlatform.Graph.cs | 22 +- .../VulkanClientPlatform.NativeEntities.cs | 325 ++++++++++ .../Platform/VulkanClientPlatform.Shaders.cs | 6 + .../Platform/VulkanClientPlatform.cs | 2 + Optimum.Render.Vulkan/VulkanDevice.Native.cs | 28 +- .../native-world-systems-coverage-tests.cs | 123 ++++ docs/vulkan-native-render-systems.md | 36 ++ .../ClientPlatformAbstract.cs.patch | 32 +- .../RenderAPIBase.cs.patch | 14 +- 11 files changed, 1176 insertions(+), 12 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index edee30a0..d967e0b1 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -85,6 +85,10 @@ // Phase 3b stage 2: the sky dome's draw seam, so a native platform records that pass // itself. The neutral body is the RenderMesh call it replaced. "RenderSkyDome", + // Phase 3b stage 2: the entity draw seam (every sub-mesh of a multi-texture mesh), so a + // native platform records the entityanimated and shadowmapentityanimated passes itself. + // The neutral body is the RenderMesh call it replaced. + "RenderEntityMesh", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", // Phase 3b stage 1d: the draw seams of the two TAA passes, so a native platform diff --git a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs new file mode 100644 index 00000000..2fc5e8ae --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs @@ -0,0 +1,596 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// An entity's animated shape, drawn twice on one Vulkan device: through the seam's neutral body +/// (ClientPlatformAbstract.RenderEntityMesh's RenderMesh, the route the OpenGL path takes) and +/// through the native pass VulkanClientPlatform.RenderEntityMesh records +/// (docs/vulkan-native-render-systems.md, decision 5 stage 2). +/// +/// Behavioural identity is the acceptance rule (decision 6): the same program, mesh, bone +/// matrices and fixed state have to put the same pixels on Primary's scene and glow attachments +/// AND on the motion attachment, which the temporal contract requires to stay bit-identical. +/// The sweep that matters for this system is the attachment set (with and without the SSAO +/// G-buffer slots, which changes where the motion attachment sits) and the motion window itself +/// (TAA on with the window open, and shut), because the window is a colour write mask on the +/// native route and a draw-buffer toggle on the old one. +/// +public class NativeEntityDrawTests(ITestOutputHelper output) +{ + private const int Size = 16; + + /// The platform with no window: both routes take their size from this seam. + private sealed class EntityPlatform : VulkanClientPlatform + { + public EntityPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + // ------------------------------------------------------------------ the tests + + /// + /// Primary as it is without the SSAO G-buffer (scene, glow, motion): the native entity draw + /// puts the same pixels on all three attachments as the seam's neutral body, and records no + /// emulation inside its pass. + /// + [SkippableTheory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public unsafe void TheNativeEntityDrawMatchesTheSeamsNeutralBody(bool gbuffer, bool motionOpen) + { + using Session session = Open(gbuffer); + + byte[][] emulated = session.RunFrame(native: false, motionOpen); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[][] native = session.RunFrame(native: true, motionOpen); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + for (int slot = 0; slot < emulated.Length; slot++) + { + output.WriteLine("slot " + slot + " emulated " + Centre(emulated[slot]) + + " native " + Centre(native[slot])); + Assert.Equal(emulated[slot], native[slot]); + } + // An identity comparison of two blank attachments proves nothing: the shape has to have + // reached the scene slot. + Assert.NotEqual(session.ClearOf(0), Centre(native[0])); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The motion attachment is the one the temporal contract pins: with the window open the two + /// routes write the same vectors, and with it shut neither route touches it, so whatever was + /// there survives. A native pipeline that forgot the write mask would fail the second half. + /// + [SkippableFact] + public unsafe void TheMotionAttachmentIsIdenticalBetweenTheRoutesAndUntouchedWithTheWindowShut() + { + using Session session = Open(gbuffer: false); + + byte[] emulatedOpen = session.RunFrame(native: false, motionOpen: true)[Session.MotionSlot]; + byte[] nativeOpen = session.RunFrame(native: true, motionOpen: true)[Session.MotionSlot]; + Assert.Equal(emulatedOpen, nativeOpen); + + // With the window shut the attachment is out of the draw-buffer set on the old route and + // masked out of the pipeline on the native one, so both leave the frame's clear standing. + // That is rule 9 in its narrowest form: an attachment nothing writes must not pick up + // whatever Vulkan would otherwise leave in it. + byte[] emulatedShut = session.RunFrame(native: false, motionOpen: false)[Session.MotionSlot]; + byte[] nativeShut = session.RunFrame(native: true, motionOpen: false)[Session.MotionSlot]; + Assert.Equal(emulatedShut, nativeShut); + Assert.Equal(session.ClearOf(Session.MotionSlot), Centre(nativeShut)); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The seam's neutral body draws through the emulation layer and the native route does not: + /// the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. + /// + [SkippableFact] + public unsafe void TheNeutralBodyDrawsThroughTheEmulationLayerAndTheNativeRouteDoesNot() + { + using Session session = Open(gbuffer: false); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + session.RunFrame(native: false, motionOpen: true); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + session.RunFrame(native: true, motionOpen: true); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The pipeline is built once per (program, target, mesh shape, motion window) and kept, and + /// opening or shutting the window is a different pipeline - the write mask is baked into it + /// rather than toggled through a draw-buffer set. + /// + [SkippableFact] + public unsafe void TheEntityPassKeepsItsPipelineAndOneMoreForTheOtherMotionWindow() + { + using Session session = Open(gbuffer: false); + + session.RunFrame(native: true, motionOpen: true); + int afterOpen = session.Seam.NativePipelinesForTests; + session.RunFrame(native: true, motionOpen: true); + Assert.Equal(afterOpen, session.Seam.NativePipelinesForTests); + + session.RunFrame(native: true, motionOpen: false); + Assert.Equal(afterOpen + 1, session.Seam.NativePipelinesForTests); + GpuTest.AssertClean(session.Seam); + } + + /// + /// The animation block is read the same way by both routes. The client uploads the pose once + /// per entity per frame through UBO.Update("Animation", ...); the device snapshots it into + /// the frame's uniform ring per (frame, version) and the draw resolves it when it binds set 2 + /// with its own mesh id. The native draw adds nothing to that - it re-uploads nothing and + /// carries no bone data in its push block - so the two routes have to produce the same image + /// for the same pose, and a pose uploaded before the first draw has to be the one that stands + /// for every later draw of that program. + /// + /// What this does NOT prove, and is recorded rather than asserted: this harness could not make + /// the pose observable in the image. A session posed with a translated bone renders the same + /// pixels as one left at identity, on BOTH routes and with the native manifest variant linked + /// (blocks u:Animation@1, u:AnimationPrev@2). Because both routes agree, it says nothing about + /// this port; it is a question about the animation block's feed on the native shader path, or + /// about this fixture, and it wants a look in the game. + /// + [SkippableFact] + public unsafe void TheAnimationBlockIsReadTheSameWayByBothRoutes() + { + using Session session = Open(gbuffer: false); + session.PoseJoint(shiftX: 0.5f); + + // Warm-up: the native pipeline compiles in the background and its first draws are skipped + // until it is published, exactly as on the emulated path. + session.RunFrame(native: true, motionOpen: true); + byte[] posed = session.RunFrame(native: true, motionOpen: true)[0]; + byte[] posedEmulated = session.RunFrame(native: false, motionOpen: true)[0]; + + Assert.NotEqual(session.ClearOf(0), Centre(posed)); + Assert.Equal(posed, posedEmulated); + GpuTest.AssertClean(session.Seam); + } + + // ---------------------------------------------------------------------- driving + + private static string Centre(byte[] pixels) + { + int i = (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + private Session Open(bool gbuffer) + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + + Session? session = Session.TryOpen(output, manifest, gbuffer); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + /// + /// The platform, its device, the Primary target the Opaque stage binds, the entityanimated + /// program with its Animation storage block, and one entity mesh - installed the way the + /// client installs them and put back afterwards. + /// + private sealed class Session : IDisposable + { + /// The motion attachment is the last colour slot of Primary, as SetupDefaultFrameBuffers appends it. + public const int MotionSlot = 2; + + private static readonly float[] Identity = + { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + + public EntityPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public FrameBufferRef Primary { get; private set; } = null!; + + /// The colour RunFrame clears a slot to, as the readback reports it. + public string ClearOf(int slot) + { + var clear = new[] + { + (byte)Math.Round(0.1f * (slot + 1) * 255f), + (byte)Math.Round(0.2f * 255f), + (byte)Math.Round(0.3f * 255f), + (byte)255, + }; + return clear[0] + "," + clear[1] + "," + clear[2] + "," + clear[3]; + } + + private MeshRef shape = null!; + private ShaderProgram entity = null!; + private UBORef animation = null!; + private UBORef animationPrev = null!; + private readonly float[] bones = new float[16 * 4]; + private int atlas; + private bool gbuffer; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + + private static readonly FieldInfo MotionWriteActive = + typeof(ClientPlatformWindows).GetField("optimumMotionWriteActive", + BindingFlags.Instance | BindingFlags.NonPublic)!; + + public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory, bool gbuffer) + { + string dataPath = Path.Combine(Path.GetTempPath(), + "optimum-native-entity-" + Guid.NewGuid().ToString("N")); + var platform = new EntityPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + previousPlatform = ScreenManager.Platform, + dataPath = dataPath, + gbuffer = gbuffer, + }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.Primary = CreatePrimary(seam, gbuffer); + InstallFrameBuffers(platform, session.Primary); + // Where SetupDefaultFrameBuffers put the motion attachment: after the shaded set. + platform.SetOptimumMotionAttachmentIndex(session.Primary.ColorTextureIds.Length - 1); + + var program = new ShaderProgram { PassName = "entityanimated" }; + Link(seam, program, "entityanimated", Variant(gbuffer), new[] + { + "modelMatrix", "viewMatrix", "projectionMatrix", + "rgbaLightIn", "rgbaAmbientIn", "renderColor", "alphaTest", + }); + session.entity = program; + + session.atlas = Gradient(seam); + // The two animation blocks ShaderProgramEntityanimated creates for the opaque + // program: the pose and, with TAA on, the previous pose the motion writer reads. + session.animation = platform.CreateUBO(program.ProgramId, 0, "Animation", 16 * 4 * sizeof(float)); + platform.BindUBO((UBO)session.animation); + session.animationPrev = platform.CreateUBO(program.ProgramId, 1, "AnimationPrev", 16 * 4 * sizeof(float)); + platform.BindUBO((UBO)session.animationPrev); + session.PoseJoint(shiftX: 0f); + + session.shape = platform.UploadMesh(BuildShape()); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + if (shape != null) Platform.DeleteMesh(shape); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// + /// The bone matrices, written the way EntityShapeRenderer writes them - through + /// UBO.Update on the "Animation" block, once per entity per frame. Nothing about the + /// native route changes this: the device's storage ring snapshots it per (frame, + /// version), and the draw only has to bind the set with the right mesh id. + /// + public void PoseJoint(float shiftX) + { + for (int joint = 0; joint < 4; joint++) + { + Identity.CopyTo(bones, joint * 16); + bones[joint * 16 + 12] = shiftX; + } + GCHandle pinned = GCHandle.Alloc(bones, GCHandleType.Pinned); + try + { + Platform.UpdateUBO((UBO)animation, pinned.AddrOfPinnedObject(), 0, + bones.Length * sizeof(float), false); + Platform.UpdateUBO((UBO)animationPrev, pinned.AddrOfPinnedObject(), 0, + bones.Length * sizeof(float), false); + } + finally + { + pinned.Free(); + } + } + + /// + /// One frame of the Opaque stage at the point the batched entity loop runs: Primary bound + /// and cleared, the stage's own state set, the program's uniforms and texture bound, the + /// motion window in the state under test, then the seam. + /// + public unsafe byte[][] RunFrame(bool native, bool motionOpen) + { + VulkanDevice seam = Seam; + Platform.NativeEntitiesEnabled = native; + + Platform.BeginFrame(); + int slots = Primary.ColorTextureIds.Length; + uint shadedMask = (1u << (slots - 1)) - 1u; + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, (int)(motionOpen ? (1u << slots) - 1u : shadedMask)); + for (int slot = 0; slot < slots; slot++) + { + seam.ClearColor(slot, 0.1f * (slot + 1), 0.2f, 0.3f, 1f); + } + seam.ClearDepth(1f); + + Platform.CurrentFrameBuffer = Primary; + seam.SetViewport(0, 0, Size, Size); + // What SystemRenderEntities.OnRenderOpaque3D sets before its batched loop. + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetCullFace(false); + seam.SetBlend(true, EnumBlendMode.Standard); + + seam.UseProgram(entity.ProgramId); + ShaderProgramBase.CurrentShaderProgram = entity; + foreach (string uniform in new[] { "modelMatrix", "viewMatrix", "projectionMatrix" }) + { + seam.SetUniformMatrix(entity.ProgramId, entity.uniformLocations[uniform], Identity); + } + // Lit white with no tint, so the shape actually lands on the attachments: with the + // record left at zero the fragment's alpha is zero and the alpha blend the stage set + // keeps the clear, which would make an identity comparison vacuous. + entity.Uniform("rgbaLightIn", 1f, 1f, 1f, 1f); + entity.Uniform("rgbaAmbientIn", 1f, 1f, 1f); + entity.Uniform("renderColor", 1f, 1f, 1f, 1f); + entity.Uniform("alphaTest", 0.001f); + // The client's own sampler declaration: both routes see the same texture, the + // emulated one through the unit and the native one through the declared name. + Platform.BindProgramTexture2D(entity, "entityTex", atlas, 0); + + MotionWriteActive.SetValue(Platform, motionOpen); + // The blend state the motion window forces on its attachment, for the old route. + Platform.ApplyOptimumMotionBlendState(); + + Platform.RenderEntityMesh(shape, "entityTex", atlas); + + MotionWriteActive.SetValue(Platform, false); + + var attachments = new byte[slots][]; + for (int slot = 0; slot < slots; slot++) + { + attachments[slot] = Read(seam, Primary.ColorTextureIds[slot]); + } + Platform.EndFrame(); + return attachments; + } + + /// One attachment's pixels, read through a framebuffer that holds only it. + private unsafe byte[] Read(VulkanDevice seam, int texture) + { + int reader = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, texture, 0); + seam.SetDrawBuffers(reader, 1); + seam.BindFramebuffer(reader); + + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + seam.BindFramebuffer(Primary.FboId); + return pixels; + } + + // ----------------------------------------------------------------- fixtures + + /// + /// The opaque entity program as ShaderRegistry builds it for this client: not the OIT + /// copy, with the TAA motion writer compiled in at the slot the framebuffer put it, and + /// the G-buffer varyings when the SSAO attachments are there. + /// + private static ShaderCorpus.ShaderVariant Variant(bool gbuffer) => new() + { + UseOit = 0, + TaaMotion = 1, + TaaMotionLocation = gbuffer ? 4 : 2, + SsaoLevel = gbuffer ? 1 : 0, + }; + + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, + ShaderCorpus.ShaderVariant variant, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant); + + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader + { + Type = stage.Stage, + Code = stage.Code, + PrefixCode = stage.PrefixCode, + }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + /// + /// Primary as the Opaque stage has it: scene at 0, glow at 1, the SSAO G-buffer's normal + /// and position at 2 and 3 when it is on, and the motion attachment appended after them. + /// + private static FrameBufferRef CreatePrimary(VulkanDevice seam, bool gbuffer) + { + int shaded = gbuffer ? 4 : 2; + var colors = new int[shaded + 1]; + for (int slot = 0; slot < colors.Length; slot++) + { + colors[slot] = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + } + + var primary = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = colors, + DepthTextureId = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, + IntPtr.Zero, false), + }; + for (int slot = 0; slot < colors.Length; slot++) + { + seam.AttachTexture(primary.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot), + colors[slot], 0); + } + seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); + seam.SetDrawBuffers(primary.FboId, (int)((1u << colors.Length) - 1u)); + Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status); + return primary; + } + + private static void InstallFrameBuffers(EntityPlatform platform, FrameBufferRef primary) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = primary; + + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + } + + /// A small gradient atlas, so a sampling difference between the routes would show. + private static unsafe int Gradient(VulkanDevice seam) + { + var pixels = new byte[8 * 8 * 4]; + for (int y = 0; y < 8; y++) + { + for (int x = 0; x < 8; x++) + { + int i = (y * 8 + x) * 4; + pixels[i] = (byte)(16 + x * 30); + pixels[i + 1] = (byte)(32 + y * 25); + pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20); + pixels[i + 3] = 255; + } + } + fixed (byte* first = pixels) + { + return seam.CreateTexture2D(8, 8, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false); + } + } + + /// + /// The shape as entityanimated sees it: positions, UVs, a per-vertex colour and render + /// flags, reduced to two triangles that cover enough of the target for every attachment + /// to be comparable. damageEffectIn and jointId are left to the layout's constant + /// defaults - jointId 0, the joint PoseJoint moves - which is exactly the GL promise + /// VertexLayoutDescription.WithDefaultsFor keeps for an attribute a mesh does not carry. + /// + private static MeshData BuildShape() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true); + float[] positions = + { + -0.7f, -0.7f, 0.5f, + 0.7f, -0.7f, 0.5f, + 0.7f, 0.7f, 0.5f, + -0.7f, 0.7f, 0.5f, + }; + float[] uvs = { 0, 0, 1, 0, 1, 1, 0, 1 }; + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, 0); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + } + + // ---------------------------------------------------------------- native shaders + + /// The entityanimated program's manifest, built once for the whole class. + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + var merged = new NativeShaderBuildResult(); + merged.Manifest.Toolchain = compiler!.Identity; + string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); + NativeShaderBuildResult one = builder.Build(source, "entityanimated"); + merged.Errors.AddRange(one.Errors); + merged.Manifest.Programs.AddRange(one.Manifest.Programs); + foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes; + if (!merged.Success) return ("", string.Join("\n", merged.Errors)); + + string root = Path.Combine(Path.GetTempPath(), + "optimum-native-entity-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(merged, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index a223439c..55c8e96d 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -75,19 +75,31 @@ private void SetPassContext(string context, PassFlags flags) DeclareBoundPass(); } - /// Declares the (context, bound target) pass; a repeat of the current one changes nothing. - private void DeclareBoundPass() + /// + /// The name gives the (context, bound target) pass. A native + /// pass that wants to be recorded inside the stage's own pass rather than one of its own - + /// the entity draws - names this, so RenderTargetManager.DeclarePass coalesces instead of + /// ending the rendering scope and starting another. + /// + private string BoundPassName() { - if (device == null || !device.FrameGraphEnabled) return; - int index = FrameBufferIndexOf(device.BoundFramebufferId); + int index = FrameBufferIndexOf(device!.BoundFramebufferId); string target = index >= 0 ? index.ToString(CultureInfo.InvariantCulture) : device.BoundFramebufferId == device.DefaultFramebufferId ? "Default" : "fbo" + device.BoundFramebufferId.ToString(CultureInfo.InvariantCulture); + return passContext + "/" + target; + } + + /// Declares the (context, bound target) pass; a repeat of the current one changes nothing. + private void DeclareBoundPass() + { + if (device == null || !device.FrameGraphEnabled) return; + int index = FrameBufferIndexOf(device.BoundFramebufferId); device.DeclarePass(new PassDeclaration { - Name = passContext + "/" + target, + Name = BoundPassName(), FramebufferId = PassDeclaration.BoundFramebuffer, Reads = PassReads(passContext, index), TransientSlots = PassTransientSlots(passContext, index), diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs new file mode 100644 index 00000000..73af1fb3 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native render systems (docs/vulkan-native-render-systems.md), Phase 3b decision 5 +// stage 2: the entity system on the native device API. +// +// What it draws: every entity's animated shape - the body through entityanimated in the Opaque +// stage (SystemRenderEntities.OnRenderOpaque3D's batched loop) and the same body through +// shadowmapentityanimated in the two shadow stages (SystemRenderEntities.OnRenderFrameShadows). +// Both reach here one sub-mesh at a time through ClientPlatformAbstract.RenderEntityMesh, the +// seam RenderAPIBase.RenderMultiTextureMesh draws through. +// Where the other side is: the seam's neutral body, which is the RenderMesh(MeshRef) call it +// replaced and which the OpenGL path still runs (ClientPlatformWindows.RenderMesh -> +// GL.DrawElements). NativeEntitiesEnabled false takes that route on the Vulkan device too, +// which is what the differential tests compare against. +// Target and slots: whatever the stage bound. Opaque -> Primary, with every bound colour slot +// in scope; shadow -> FrameBuffers[11]/[12], which have no colour attachment at all. +// State that is not obvious: +// - the motion attachment is a colour WRITE MASK here, never a draw-buffer toggle (decision 4). +// OptimumMotionWriteActive is the platform's own window state, so the pipeline masks the +// motion slot off when the window is shut and gives it replace-blend (ONE, ZERO, FUNC_ADD) +// when it is open - exactly what ClientPlatformWindows.ApplyOptimumMotionBlendState does. +// A slot past the motion attachment is masked off as well, so nothing Vulkan leaves +// undefined reaches an attachment GL would have kept (rule 9). +// - the SSAO G-buffer slots (2 and 3, present only when Primary carries them) take replace-blend +// too, which is the branch GlToggleBlend takes under RenderSSAO; +// - colour 0 and the glow slot 1 take the standard alpha blend GlToggleBlend(true) sets, because +// the caller turned blending on before the loop; +// - cull off, depth test on, depth write on, compare LESS: what SystemRenderEntities sets +// immediately before both loops (GlDisableCullFace / GlEnableDepthTest / GlToggleBlend(true)) +// and what ChunkRenderer left for the shadow stage, stated outright rather than read back +// out of the tracker (decision 3); +// - the draw is recorded INSIDE the stage's own declared pass, not a pass of its own: it names +// BoundPassName() with every slot, so RenderTargetManager.DeclarePass coalesces and +// EndNativePass(keepScope: true) leaves the scope open. One entity per rendering scope would +// otherwise cost an end/begin pair per entity per frame. +// - the bone matrices are NOT re-uploaded here. EntityShapeRenderer keeps calling +// UBO.Update("Animation", ...), which lands in the device's animation storage ring with its +// per-(frame, version) snapshot dedup; the native draw passes the real mesh id into +// BindProgramSets, which is what makes the ring's snapshot resolve for this draw. +// What it deliberately does NOT take native, and why: +// - held items through the `standard` program (EntityShapeRenderer.RenderItem). Their cull mode +// is decided per draw by renderInfo.CullFaces inside the VSEssentials fork, through +// GlDisableCullFace/GlEnableCullFace, and no seam carries it; a native pipeline would have to +// read it back off the tracker, which decision 3 forbids. It needs a seam in the fork, which +// is that fork's own change. +// - the `instanced` program: ShaderPrograms.Instanced has no vanilla call site in this tree +// (registration and manifest only), so there is nothing to port. The entity renderers' +// instanced draws go through RenderMeshInstanced with mod-registered shaders. +// What pins it: NativeEntityDrawTests (old route against native route, colour and the motion +// attachment) and Optimum.Tests/native-world-systems-coverage-tests.cs (the lib seam). +public partial class VulkanClientPlatform +{ + /// + /// False runs the seam's neutral body - the OpenGL body's RenderMesh - on the Vulkan device + /// instead of the native pass: the old route the differential tests compare against, in the + /// pattern of and . + /// + internal bool NativeEntitiesEnabled { get; set; } = true; + + /// The programs this file owns. Anything else takes the seam's neutral body. + private const string EntityAnimatedPass = "entityanimated"; + + private const string EntityShadowPass = "shadowmapentityanimated"; + + /// + /// What the client declared each program's samplers hold, by name: filled from + /// BindProgramTexture2D/Cube, which is the client saying "this program's sampler is this + /// texture". A program whose draws are all native never runs the emulated resolve that fills + /// the push block's slots from the texture units, so the native draw has to resolve every + /// sampler the program declares - not only the one the seam names. + /// + private readonly Dictionary> nativeProgramTextures = new(); + + /// Records one sampler declaration. Render thread only, like every platform call. + private void NoteProgramTexture(int programId, string samplerName, int textureId) + { + if (!nativeProgramTextures.TryGetValue(programId, out Dictionary? samplers)) + { + samplers = new Dictionary(StringComparer.Ordinal); + nativeProgramTextures[programId] = samplers; + } + samplers[samplerName] = textureId; + } + + /// The texture the client declared for a sampler, or 0 - which resolves to the placeholder. + internal int DeclaredProgramTexture(int programId, string samplerName) => + nativeProgramTextures.TryGetValue(programId, out Dictionary? samplers) && + samplers.TryGetValue(samplerName, out int textureId) + ? textureId + : 0; + + /// + /// The entity pipeline last handed out, with the state it was built for. The batched loop + /// draws every entity with the same program, target and mesh shape, so this hits on all but + /// the first draw of a stage and no draw allocates a description or a blend array. + /// + private readonly record struct NativeEntityKey( + int ProgramId, int FramebufferId, int LayoutId, int ColorCount, int MotionIndex, bool MotionOpen); + + private NativeEntityKey nativeEntityKey; + private NativePipeline? nativeEntityPipeline; + private NativeTexture[] nativeEntityTextures = Array.Empty(); + private int[] nativeEntityReads = Array.Empty(); + private bool nativeEntityReported; + + /// An entity's shape: the native draw inside the stage's pass, or the neutral body. + public override void RenderEntityMesh(MeshRef mesh, string samplerName, int textureId) + { + if (!NativeEntitiesEnabled || device == null || mesh == null) + { + base.RenderEntityMesh(mesh!, samplerName, textureId); + return; + } + + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + FrameBufferRef target = CurrentFrameBuffer; + var vao = mesh as VAO; + if (program == null || target == null || vao == null || vao.VaoId == 0 || vao.Disposed || + !IsNativeEntityProgram(program)) + { + base.RenderEntityMesh(mesh, samplerName, textureId); + return; + } + + int layoutId = device.NativeMeshLayoutId(vao.VaoId); + if (layoutId < 0) + { + base.RenderEntityMesh(mesh, samplerName, textureId); + return; + } + + NativePipeline? pipeline = NativeEntityPipelineFor(program, target, layoutId); + if (pipeline == null) + { + base.RenderEntityMesh(mesh, samplerName, textureId); + return; + } + + // Every sampler the program declares, resolved from what the client declared for it, with + // this draw's own texture for the sampler the seam names. + ResolveNativeEntityTextures(pipeline, program.ProgramId, samplerName, textureId); + + RuntimeStats.drawCallsCount++; + if (device.BeginNativePass(new NativePassDescription + { + // The stage's own pass, so the declaration coalesces and the scope stays open across + // the whole loop. Closed with keepScope below for the same reason. + Name = BoundPassName(), + FramebufferId = target.FboId, + ColorSlots = uint.MaxValue, + Reads = nativeEntityReads, + Flags = passContextFlags, + })) + { + device.DrawNativeMesh(pipeline, vao.VaoId, nativeEntityTextures); + } + device.EndNativePass(keepScope: true); + } + + /// Whether the program in use is one this file draws natively. + private static bool IsNativeEntityProgram(ShaderProgramBase program) + { + if (!string.Equals(program.PassName, EntityAnimatedPass, StringComparison.Ordinal) && + !string.Equals(program.PassName, EntityShadowPass, StringComparison.Ordinal)) + { + return false; + } + + // A sampler the client gave its own filtering or wrap mode to is bound through the unit's + // sampler override, which a native draw does not read. Neither vanilla entity program does + // that; if one ever did, the neutral body keeps it correct instead of silently losing it. + return program.customSamplers.Count == 0 && !program.clampTToEdge; + } + + /// + /// The pipeline for this program, target, mesh shape and motion-window state, rebuilt only + /// when one of those changes. Every piece of fixed state is stated from client state: the + /// stage's own toggles, the platform's motion window, and the target's attachment count. + /// + private NativePipeline? NativeEntityPipelineFor(ShaderProgramBase program, FrameBufferRef target, int layoutId) + { + int colorCount = target.ColorTextureIds?.Length ?? 0; + int motionIndex = MotionAttachmentIndex; + if (motionIndex < 0 || motionIndex >= colorCount) motionIndex = -1; + bool motionOpen = motionIndex >= 0 && OptimumMotionWriteActive; + + var key = new NativeEntityKey(program.ProgramId, target.FboId, layoutId, colorCount, motionIndex, motionOpen); + if (nativeEntityPipeline != null && key.Equals(nativeEntityKey) && + device!.IsNativePipelineLive(nativeEntityPipeline)) + { + return nativeEntityPipeline; + } + + RenderTargetFormats? formats = device!.NativeTargetFormats(target.FboId, uint.MaxValue); + if (formats == null) return null; + + var description = new NativePipelineDescription + { + ProgramId = program.ProgramId, + PassName = program.PassName, + VertexLayoutId = layoutId, + Blend = NativeEntityBlend(formats.ColorFormats.Length, motionIndex, motionOpen), + // SystemRenderEntities sets these immediately before both loops; ClientMain set the + // depth function to LESS for the whole 3D render. + DepthTest = true, + DepthWrite = true, + DepthCompare = CompareOp.Less, + Cull = CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + Targets = formats, + }; + + NativePipeline? pipeline = device.RequestNativePipeline(description, out string error); + if (pipeline == null) + { + if (!nativeEntityReported) + { + nativeEntityReported = true; + Logger.Warning("Optimum: no native pipeline for '{0}': {1}", program.PassName, error); + } + nativeEntityPipeline = null; + return null; + } + + nativeEntityReported = false; + nativeEntityKey = key; + nativeEntityPipeline = pipeline; + return pipeline; + } + + /// + /// The per-attachment blend the OpenGL body would be drawing with: standard alpha blending on + /// the shaded slots, replace on the SSAO G-buffer slots, replace on the motion attachment + /// while its window is open and no write at all when it is shut or the slot is past it. + /// + private static AttachmentBlend[] NativeEntityBlend(int colorCount, int motionIndex, bool motionOpen) + { + var blend = new AttachmentBlend[Math.Max(colorCount, 1)]; + // The attachments the shading pass writes: everything before the motion attachment, or + // every bound slot when there is none. 2 without the SSAO G-buffer, 4 with it. + int shaded = motionIndex >= 0 ? motionIndex : colorCount; + for (int i = 0; i < blend.Length; i++) + { + AttachmentBlend attachment = AttachmentBlend.Default; + if (i == motionIndex) + { + if (!motionOpen) + { + attachment.WriteMask = 0; + } + else + { + Replace(ref attachment); + } + } + else if (i >= shaded) + { + // Past the motion attachment: nothing the entity programs declare an output for. + attachment.WriteMask = 0; + } + else if (i >= 2) + { + // The SSAO G-buffer's normal and position slots: GlToggleBlend's RenderSSAO branch. + Replace(ref attachment); + } + else + { + // Colour and glow: GlToggleBlend(true), EnumBlendMode.Standard. + attachment.Enabled = true; + attachment.SrcColor = BlendFactor.SrcAlpha; + attachment.DstColor = BlendFactor.OneMinusSrcAlpha; + attachment.ColorOp = BlendOp.Add; + attachment.SrcAlpha = BlendFactor.SrcAlpha; + attachment.DstAlpha = BlendFactor.OneMinusSrcAlpha; + attachment.AlphaOp = BlendOp.Add; + } + blend[i] = attachment; + } + return blend; + } + + /// (ONE, ZERO) with FUNC_ADD on every channel: the source wins, blending or not. + private static void Replace(ref AttachmentBlend attachment) + { + attachment.Enabled = true; + attachment.SrcColor = BlendFactor.One; + attachment.DstColor = BlendFactor.Zero; + attachment.ColorOp = BlendOp.Add; + attachment.SrcAlpha = BlendFactor.One; + attachment.DstAlpha = BlendFactor.Zero; + attachment.AlphaOp = BlendOp.Add; + } + + /// + /// This draw's sampled textures: the seam's texture for the sampler it names, and what the + /// client declared for every other sampler the program has. Reused buffers, because the + /// batched loop calls this once per entity. + /// + private void ResolveNativeEntityTextures(NativePipeline pipeline, int programId, string samplerName, int textureId) + { + string[] names = pipeline.SamplerNames; + if (nativeEntityTextures.Length != names.Length) + { + nativeEntityTextures = new NativeTexture[names.Length]; + nativeEntityReads = new int[names.Length]; + } + for (int i = 0; i < names.Length; i++) + { + int id = string.Equals(names[i], samplerName, StringComparison.Ordinal) + ? textureId + : DeclaredProgramTexture(programId, names[i]); + nativeEntityTextures[i] = new NativeTexture(pipeline.Sampler(names[i]), id); + nativeEntityReads[i] = id; + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs index 1ac7cbc0..cb738c8a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs @@ -173,6 +173,11 @@ public override void SetUniformMatrices4x3(int programId, int location, int coun /// public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) { + // The client's own declaration - "this program's sampler is this texture" - kept + // by name so a native pass of that program can resolve every sampler it declares from a + // handle. It is not the texture-unit table: no unit is involved, and the native path + // never reads one (docs/vulkan-native-render-systems.md, decision 3). + NoteProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); device.BindTexture(textureNumber, textureId); if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) @@ -195,6 +200,7 @@ public override void BindProgramTexture2D(ShaderProgramBase program, string samp public override void BindProgramTextureCube(ShaderProgramBase program, string samplerName, int textureId, int textureNumber) { + NoteProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); device.BindTextureCube(textureNumber, textureId); if (program.clampTToEdge) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 5dcff836..dd099322 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -139,6 +139,8 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderOptimumSkyMotion", Array.Empty()), // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. new(true, "RenderSkyDome", new[] { "MeshRef", "Int32", "Int32", "Single[]" }), + // Phase 3b stage 2: the entity draw seam - every sub-mesh of a multi-texture mesh. + new(true, "RenderEntityMesh", new[] { "MeshRef", "String", "Int32" }), new(true, "RenderOptimumTaaResolve", Array.Empty()), new(true, "RenderOptimumTaaSharpen", new[] { "Int32" }), // Phase 3b stage 1: the two TAA passes' draw seams, which the native chain replaces. diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 6cfc176a..3f3c8845 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -138,15 +138,25 @@ internal NativePipeline(ShaderProgramResources program, NativePipelineDescriptio _uniforms[name] = new NativeUniform(NativeUniformBlock.Frame, frame.Offset, frame.Size); } } + SamplerNames = new string[layout.Samplers.Count]; for (int i = 0; i < layout.Samplers.Count; i++) { SamplerBinding sampler = layout.Samplers[i]; TextureKind kind = sampler.Kind; if (sampler.IsFrameTexture) BindlessKinds.TryFromGlslType(sampler.TypeName, out kind); _samplers[sampler.Name] = new NativeSamplerSlot(i, sampler.PushOffset, sampler.FrameBinding, kind); + SamplerNames[i] = sampler.Name; } } + /// + /// Every sampler the program declares, in binding order. A system whose draws are all + /// native has to resolve all of them: the emulated resolve that would otherwise fill the + /// push block's slots from the texture units never runs for such a program, so a sampler + /// left out would read whatever slot index was last written there. + /// + internal string[] SamplerNames { get; } + internal ShaderProgramResources Program { get; } public int ProgramId => Program.ProgramId; @@ -528,13 +538,27 @@ internal bool BeginNativePass(NativePassDescription pass) } /// Closes the native pass and its scope. - internal void EndNativePass() + internal void EndNativePass() => EndNativePass(keepScope: false); + + /// + /// Closes the native pass. leaves the rendering scope and the + /// pass declaration exactly as they were, for a native draw recorded inside a pass the + /// surrounding stage has already declared - the entity loop, which records one native draw + /// per entity into the Opaque stage's own pass and would otherwise end and restart the + /// rendering scope once per entity. It is only correct when the pass description named that + /// same declaration, so coalesced into it rather than opening + /// one of its own; a pass with its own name, slots or clears must be closed the normal way. + /// + /// The native-pass bookkeeping is cleared either way, so the emulated calls a render system + /// makes between its draws (its uniforms by name) still count as outside a native pass. + /// + internal void EndNativePass(bool keepScope) { if (_nativePass == null) return; _nativePass = null; _nativeTarget = null; - if (!_frameActive) return; + if (!_frameActive || keepScope) return; CommandBuffer commandBuffer = Commands; _targets.EndPass(commandBuffer); diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 03bb81b5..c23d26da 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -19,6 +19,9 @@ public class NativeWorldSystemsCoverageTests private const string DeviceMeshFile = "Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"; private const string DeviceNativeFile = "Optimum.Render.Vulkan/VulkanDevice.Native.cs"; + private const string EntityPlatformFile = + "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs"; + /// /// The seam exists on the platform abstraction, and its neutral body is exactly the /// RenderMesh call it replaced - which is what makes "OFF is vanilla" true for OpenGL, @@ -183,6 +186,126 @@ public void ThePipelineDescriptionAndKeyCarryTheMeshDrawState() Assert.Contains("_meshes.LayoutOf(description.VertexLayoutId)", native); } + // ------------------------------------------------------------- entities (stage 2) + + /// + /// The entity draw seam exists on the platform abstraction, its neutral body is exactly the + /// RenderMesh call it replaced, and ClientPlatformWindows does not override it - which is what + /// makes "OFF is vanilla" true for OpenGL. + /// + [Fact] + public void TheEntityDrawHasASeamWhoseNeutralBodyIsTheDrawItReplaced() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + Assert.Contains( + "public virtual void RenderEntityMesh(MeshRef mesh, string samplerName, int textureId)", + platform); + Assert.Contains("RenderMesh(mesh);", platform); + + string windows = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.DoesNotContain("RenderEntityMesh", windows); + } + + /// + /// The entity renderers reach the seam where they already were: RenderMultiTextureMesh draws + /// each sub-mesh through it and hands it the sampler name and texture id it just bound, which + /// is what a native pass needs to resolve the draw's texture from a handle. + /// + [Fact] + public void TheMultiTextureDrawGoesThroughTheSeam() + { + string api = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs.patch", + "build/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs"); + + Assert.Contains("plat.RenderEntityMesh(vao, textureSampleName, mmr.textureids[i]);", api); + Assert.DoesNotContain("plat.RenderMesh(vao);", api); + } + + /// The seam and its caller are listed for the Cecil transplant. + [Fact] + public void TheEntitySeamAndItsCallerAreListedForTheTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"RenderEntityMesh\"", patcher); + Assert.Contains("\"Vintagestory.Client.RenderAPIBase\", \"RenderMultiTextureMesh\", 3", patcher); + } + + /// + /// The Vulkan platform records the entity draws natively for the two programs it owns, states + /// its own fixed state rather than reading the tracker's, treats the motion window as a colour + /// write mask, and keeps the neutral body reachable behind a switch. + /// + [Fact] + public void TheVulkanPlatformRecordsEntitiesNativelyAndKeepsTheOldRoute() + { + string entities = Read(EntityPlatformFile); + + Assert.Contains("internal bool NativeEntitiesEnabled { get; set; } = true;", entities); + Assert.Contains("public override void RenderEntityMesh(", entities); + Assert.Contains("base.RenderEntityMesh(", entities); + Assert.Contains("device.BeginNativePass(", entities); + Assert.Contains("device.DrawNativeMesh(", entities); + + // The two programs it owns, and nothing else. + Assert.Contains("private const string EntityAnimatedPass = \"entityanimated\";", entities); + Assert.Contains("private const string EntityShadowPass = \"shadowmapentityanimated\";", entities); + + // The fixed state stated outright, from the values SystemRenderEntities sets. + Assert.Contains("DepthTest = true", entities); + Assert.Contains("DepthWrite = true", entities); + Assert.Contains("DepthCompare = CompareOp.Less", entities); + Assert.Contains("Cull = CullModeFlags.None", entities); + Assert.Contains("VertexLayoutId = layoutId", entities); + + // The motion window is a write mask on the pipeline, never a draw-buffer toggle. + Assert.Contains("OptimumMotionWriteActive", entities); + Assert.Contains("attachment.WriteMask = 0;", entities); + Assert.DoesNotContain("SetDrawBuffers", entities); + } + + /// + /// The native draw is recorded inside the stage's own declared pass, so a loop of hundreds of + /// entities does not end and restart the rendering scope once per entity, and the device has + /// the close that makes that safe. + /// + [Fact] + public void TheEntityDrawsShareTheStagesPassInsteadOfOnePassPerEntity() + { + string entities = Read(EntityPlatformFile); + Assert.Contains("Name = BoundPassName(),", entities); + Assert.Contains("ColorSlots = uint.MaxValue,", entities); + Assert.Contains("device.EndNativePass(keepScope: true);", entities); + + string native = Read(DeviceNativeFile); + Assert.Contains("internal void EndNativePass(bool keepScope)", native); + Assert.Contains("if (!_frameActive || keepScope) return;", native); + } + + /// + /// A native draw resolves every sampler its program declares, from what the client declared + /// for it by name - not from a texture unit, which decision 3 forbids and which the emulated + /// resolve (never run for a program whose draws are all native) would otherwise have filled. + /// + [Fact] + public void ANativeDrawResolvesEverySamplerTheProgramDeclares() + { + string entities = Read(EntityPlatformFile); + Assert.Contains("string[] names = pipeline.SamplerNames;", entities); + Assert.Contains("DeclaredProgramTexture(programId, names[i])", entities); + + string shaders = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs"); + Assert.Contains("NoteProgramTexture(program.ProgramId, samplerName, textureId);", shaders); + + string native = Read(DeviceNativeFile); + Assert.Contains("internal string[] SamplerNames { get; }", native); + } + // ------------------------------------------------------------------------ helpers private static string Section(string source, string from, string to) diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index da433f1e..d16216f7 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -171,6 +171,42 @@ extends the API and ports the simplest system through it. for the lib seam. The four system stages add their systems to those files rather than to files named after the stage. +### Entities (stage 2, one of the four system stages) + +- **Seam:** `ClientPlatformAbstract.RenderEntityMesh(MeshRef, string samplerName, int textureId)`, + whose neutral body is the `RenderMesh(MeshRef)` call it replaced, drawn through by + `RenderAPIBase.RenderMultiTextureMesh` - the one place every entity sub-mesh already went. The + seam is generic, so the GUI and block-entity stages widen its native side rather than adding + another seam. +- **Native:** `VulkanClientPlatform.NativeEntities.cs` takes the native route for the two programs + SystemRenderEntities itself drives, `entityanimated` (Opaque, into Primary) and + `shadowmapentityanimated` (the two shadow stages, into colourless targets), and hands every other + caller of the seam to the neutral body. `NativeEntitiesEnabled` keeps the old route reachable. + - The motion window is a **colour write mask on the pipeline**, from the platform's own + `OptimumMotionWriteActive` and `MotionAttachmentIndex`: replace-blend on the motion slot while + it is open, no write at all while it is shut. No `SetDrawBuffers` anywhere on this path. + - The draw is recorded **inside the stage's own declared pass** - it names `BoundPassName()` with + every slot, so `RenderTargetManager.DeclarePass` coalesces - and closed with + `EndNativePass(keepScope: true)`. A pass of its own per entity would end and restart the + rendering scope hundreds of times a frame. The native-pass bookkeeping still clears, so the + uniforms the renderers set by name between draws stay outside a native pass and the + "no emulation inside a native pass" invariant holds exactly as before. + - Bone matrices need no new API and are not copied into the draw: `UBO.Update("Animation", ...)` + keeps feeding the device's ring with its per-(frame, version) snapshot dedup, and the native + draw passes its real mesh id into `BindProgramSets` so that snapshot resolves for it. + - A native draw resolves **every** sampler its program declares, from what the client declared + for it by name (`BindProgramTexture2D` records it), because the emulated resolve that fills the + push block's sampler slots from the texture units never runs for a program whose draws are all + native. That table is a name-keyed record of the client's own declaration, not the unit table. +- **Not taken native here, and why:** held items through `standard` - their cull mode is decided per + draw by `renderInfo.CullFaces` inside `EntityShapeRenderer.RenderItem` in the VSEssentials fork and + no seam carries it, so a native pipeline would have to read the tracker back, which decision 3 + forbids; it needs a seam in the fork. And the `instanced` program, which has no vanilla call site + in this tree. +- **Tests:** `NativeEntityDrawTests` (old route against native route across the SSAO G-buffer and + motion-window sweep, the motion attachment on its own, the emulation boundary, pipeline identity) + and the entity section of `Optimum.Tests/native-world-systems-coverage-tests.cs`. + ## 4. Documentation that makes map stages unnecessary Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index a79c725c..23874d3a 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..b7f9663 100644 +index d6eb844..117e492 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,496 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,524 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -126,6 +126,34 @@ index d6eb844..b7f9663 100644 + RenderMesh(skyDome); + } + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the draw of one ++ /// sub-mesh of a multi-texture mesh, as a seam of its own. ++ /// ++ /// What it draws: an entity's shape - the animated body through entityanimated in the Opaque ++ /// stage, the same body through shadowmapentityanimated in the two shadow stages - and, for ++ /// every other caller of , ++ /// whatever that caller draws. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, and ClientPlatformWindows does not ++ /// override it, so "OFF is vanilla" holds - and VulkanClientPlatform.RenderEntityMesh is the ++ /// native one, which takes the native route only for the entity programs it owns. ++ /// Target and slots: whatever stage bound - Primary for the opaque draws, including the motion ++ /// attachment while 's window is open, and the shadow map for ++ /// the shadow draws, which has no colour attachment at all. ++ /// State that is not obvious: the caller already set the state (cull off, blend on, depth test ++ /// and depth write on) and every uniform, and the bone matrices are already in the program's ++ /// "Animation" block; the seam changes nothing. and ++ /// are passed because a native pass resolves what it samples from ++ /// handles rather than from the texture unit the caller's BindTexture2D put it on. ++ /// What pins it: NativeEntityDrawTests (old route against native route, colour and motion) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderEntityMesh(MeshRef mesh, string samplerName, int textureId) ++ { ++ RenderMesh(mesh); ++ } ++ + public virtual bool RenderOptimumTaaResolve() + { + return false; diff --git a/patches/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs.patch b/patches/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs.patch index c87422b9..03c7be74 100644 --- a/patches/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs b/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs -index bfae02c..2f624fb 100644 +index bfae02c..d129090 100644 --- a/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs +++ b/VintagestoryLib/Vintagestory.Client/RenderAPIBase.cs -@@ -290,13 +290,21 @@ public abstract class RenderAPIBase : IRenderAPI +@@ -290,15 +290,28 @@ public abstract class RenderAPIBase : IRenderAPI plat.RenderMesh(meshRef); } @@ -20,7 +20,15 @@ index bfae02c..2f624fb 100644 MeshRef vao = mmr.meshrefs[i]; + if (vao == null || vao.Disposed) continue; CurrentActiveShader.BindTexture2D(textureSampleName, mmr.textureids[i], textureNumber); - plat.RenderMesh(vao); +- plat.RenderMesh(vao); ++ // Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the draw ++ // goes through the seam, not RenderMesh, so a native platform can record the ++ // entity passes itself. The neutral body IS RenderMesh(vao), so the OpenGL path ++ // is unchanged; the sampler name and texture id are what a native pass needs to ++ // resolve this draw's texture from a handle instead of from a texture unit. ++ plat.RenderEntityMesh(vao, textureSampleName, mmr.textureids[i]); } } + public void RenderMesh(MeshRef meshRef, int[] indicesStarts, int[] indicesSizes, int groupCount) + { From 8278bad555bf6c10116a6b6e1df14cce5fc94451 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 12:48:26 +0200 Subject: [PATCH 201/226] integration: regenerate ClientPlatformAbstract's patch from the merged build/ tree The five stage branches each inserted their seams at the same point in ClientPlatformAbstract, so the generated patch conflicted on every merge. Resolved by rebuilding build/ from patches, adding the entity, sky/particle/decal and GUI seams to the source, and re-extracting, so the hunk offsets are the extractor's own. Verified on the merged state: build Release 0 errors; extract-patches 159 patches / 202 valid; check-patches 93 applied, 66 cecil, 0 pending, 0 conflict, 43 runtime patches exact; Optimum.Tests 1271 passed / 0 failed / 34 skipped; Optimum.Render.Vulkan.Tests 1107 passed / 0 failed with the implicit-layer disable set (only VK_LAYER_MESA_device_select inserted). --- .../ClientPlatformAbstract.cs.patch | 181 +++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index b13430e1..9148b0a9 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..632275c 100644 +index d6eb844..8954cae 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,536 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,713 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -166,6 +166,183 @@ index d6eb844..632275c 100644 + { + } + ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the draw of one ++ /// sub-mesh of a multi-texture mesh, as a seam of its own. ++ /// ++ /// What it draws: an entity's shape - the animated body through entityanimated in the Opaque ++ /// stage, the same body through shadowmapentityanimated in the two shadow stages - and, for ++ /// every other caller of , ++ /// whatever that caller draws. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, and ClientPlatformWindows does not ++ /// override it, so "OFF is vanilla" holds - and VulkanClientPlatform.RenderEntityMesh is the ++ /// native one, which takes the native route only for the entity programs it owns. ++ /// Target and slots: whatever stage bound - Primary for the opaque draws, including the motion ++ /// attachment while 's window is open, and the shadow map for ++ /// the shadow draws, which has no colour attachment at all. ++ /// State that is not obvious: the caller already set the state (cull off, blend on, depth test ++ /// and depth write on) and every uniform, and the bone matrices are already in the program's ++ /// "Animation" block; the seam changes nothing. and ++ /// are passed because a native pass resolves what it samples from ++ /// handles rather than from the texture unit the caller's BindTexture2D put it on. ++ /// What pins it: NativeEntityDrawTests (old route against native route, colour and motion) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderEntityMesh(MeshRef mesh, string samplerName, int textureId) ++ { ++ RenderMesh(mesh); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): the night sky box's draw, a seam of its own so a ++ /// native platform can record it as a declared pass. ++ /// ++ /// What it draws: the 75-unit cube SystemRenderNightSky renders the star cube map onto, once ++ /// per frame in the Opaque stage right after the sky dome. ++ /// The other side: the neutral body below is the OpenGL path - exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderNightSkyBox is the native one. ++ /// Target and slots: whatever the Opaque stage has bound, which is Primary; nightsky.frag ++ /// writes colour 0 and, with the SSAO G-buffer on, the two G-buffer slots. Never motion. ++ /// State that is not obvious: the caller has already turned the depth test and culling off, ++ /// so the seam changes no state of its own. The cube map is passed as a handle because a ++ /// native pass resolves what it samples from handles, not from the texture unit ++ /// ShaderProgramNightsky.CtexCube bound it to - and it is a samplerCube, so it ++ /// resolves into the cube array of the bindless table rather than the 2D one. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderNightSkyBox(MeshRef nightSkyBox, int cubeTextureId) ++ { ++ RenderMesh(nightSkyBox); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): the draw of one celestial body's quad. ++ /// ++ /// What it draws: the moon - SystemRenderSunMoon's quad under the celestialobject program, ++ /// once per frame in the Opaque stage. The sun's draw of the same quad still goes through ++ /// , because it runs under the shared "standard" program ++ /// the entity stage owns. ++ /// The other side: the neutral body below is the OpenGL path - the RenderMesh call it ++ /// replaced - and VulkanClientPlatform.RenderCelestialQuad is the native one. ++ /// Target and slots: Primary; celestialobject.frag writes colour 0, the glow slot 1 and, ++ /// with the SSAO G-buffer on, the two G-buffer slots. Never motion. ++ /// State that is not obvious: the caller has blending on in the standard mode, culling off ++ /// and the depth test off, and the seam changes none of it. The body's texture is passed as ++ /// a handle for the same reason the sky's two are, and so are the sky gradient and glow ++ /// textures: celestialobject.fsh reads them through skycolor.fsh to shade the body against ++ /// the sky behind it, and a native pass cannot take them off the units the program's ++ /// Sky2D/Glow2D setters bound them to. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderCelestialQuad(MeshRef quad, int bodyTextureId, int skyTextureId, int glowTextureId) ++ { ++ RenderMesh(quad); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): one particle pool's instanced draw. ++ /// ++ /// What it draws: every live particle of one pool in a single instanced draw of the pool's ++ /// cube or quad geometry - SystemRenderParticles.Render, twice per pool (main thread and ++ /// off thread). ++ /// The other side: the neutral body below is the OpenGL path - exactly the ++ /// call it replaced - and ++ /// VulkanClientPlatform.RenderParticles is the native one. ++ /// Target and slots: cube particles draw into Primary inside the caller's motion window, so ++ /// the motion attachment is in the pass's colour slots exactly while that window is open and ++ /// the vector lands through the one writer include; quad particles draw into Transparent. ++ /// State that is not obvious: the caller has standard blending on for the cube pool; the ++ /// window's replace-blending on the motion attachment is state the native pass states per ++ /// attachment rather than inheriting from a tracked toggle. ++ /// What pins it: NativeWorldSystemsTests (including the motion attachment, bit for bit) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderParticles(MeshRef model, int quantity, int particleTextureId) ++ { ++ RenderMeshInstanced(model, quantity); ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): the decal pool's multi-draw. ++ /// ++ /// What it draws: every visible decal in one indirect multi-draw of the decal pool, once per ++ /// frame in SystemRenderDecals.OnRenderFrame3D (the AfterOIT stage). ++ /// The other side: the neutral body below is the OpenGL path - exactly the ++ /// call that the second half of ++ /// MeshDataPool.Draw makes, which is what the caller's decalPool.Draw ++ /// reached - and VulkanClientPlatform.RenderDecalPool is the native one. The caller runs ++ /// the pool's own public FrustumCull first, so both routes draw the same ranges and only ++ /// the draw command differs. ++ /// Target and slots: Primary, inside the caller's motion window - a decal nudges the depth ++ /// buffer in front of the block it sits on, so it has to write that surface's motion vector ++ /// itself. ++ /// State that is not obvious: the caller has standard blending on and culling off; the ++ /// motion attachment blends replace, which the native pass states per attachment. The two ++ /// atlas textures are passed as handles because a native pass resolves what it samples from ++ /// handles, not from the units ShaderProgramDecals' setters bound them to. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount, int decalTextureId, int blockTextureId) ++ { ++ RenderMesh(decalMesh, indicesStarts, indicesSizes, groupCount); ++ } ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): the texture-into-texture ++ /// blit's draw, as a seam of its own. ++ /// ++ /// What it draws: the unit quad ClientMain.RenderTextureIntoFrameBuffer stretches a rectangle of ++ /// one texture over a rectangle of another with - the draw that bakes every Cairo-drawn GUI and ++ /// text texture into a texture atlas or a dialog's own surface, several hundred times in a frame ++ /// that opens a dialog. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderTextureQuad is the native one. ++ /// Target and slots: , which the caller has just bound to the ++ /// framebuffer it passes in, colour slot 0 only; texture2texture.fsh writes outColor at 0 and ++ /// the pipeline masks every other slot off. No depth attachment is written. ++ /// State that is not obvious: the caller computes both pieces of fixed state from its own ++ /// arguments rather than leaving them to whatever the frame had - the depth test is off and ++ /// blending is on exactly when alphaTest is non-negative - so is that ++ /// value passed on, and a native pass states it instead of reading it back off tracked GL state ++ /// (decision 3). The source texture is passed because a native pass resolves what it samples ++ /// from handles, not from the texture unit the program's setter bound it to. ++ /// What pins it: NativeGuiTests (old route against native route) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderTextureQuad(MeshRef quad, int textureId, bool blend) ++ { ++ RenderMesh(quad); ++ } ++ ++ /// ++ /// Optimum (Vulkan-native render systems, Phase 3b decision 5 stage 2): a 2D overlay drawn as ++ /// lines, as a seam of its own. ++ /// ++ /// What it draws: the aiming reticle SystemRenderPlayerAimAcc puts on the screen while a bow is ++ /// drawn - one line-topology rectangle and four line meshes, through the gui program with ++ /// noTexture set, in the Ortho stage. ++ /// The other side: the neutral body below is the OpenGL path - it is exactly the ++ /// call it replaced, so "OFF is vanilla" holds - and ++ /// VulkanClientPlatform.RenderOverlayLines is the native one. ++ /// Target and slots: , which in the Ortho stage is the default ++ /// framebuffer, colour slot 0 only. ++ /// State that is not obvious: the caller sets the line width and turns blending on immediately ++ /// before each of these draws, so both are passed here and a native pipeline states them ++ /// outright - the line width is the one piece of dynamic state a fullscreen pass never needed. ++ /// The topology is the mesh's own draw mode, not a state toggle. ++ /// is the texture the program samples, 0 for the reticle because noTexture is 1 and the sampler ++ /// resolves to the bindless placeholder on both routes. ++ /// What pins it: NativeGuiTests (old route against native route, both line widths) and ++ /// Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderOverlayLines(MeshRef lines, int textureId, float lineWidth, bool blend) ++ { ++ RenderMesh(lines); ++ } ++ + public virtual bool RenderOptimumTaaResolve() + { + return false; From ac04e761eec8faeeeae58d5d9711139174c95c6e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 13:08:18 +0200 Subject: [PATCH 202/226] docs: work directly in the session, sequentially; no workflows Owner, 2026-09-16: the workflow approach does not work for them - they cannot see what is happening. Rule 8 now says the session does every step itself, in order, visibly; subagents only for read-only search. Rules 14-16 rewritten accordingly. Rule 19 added: build and tests compile against the fork and miss a wrong transplant parameter count and a lib call to a fork-only API member, both of which shipped today as crashes; make patch-il and the fork diff catch them and run after every lib or fork change. --- .claude/skills/workflow-policy/SKILL.md | 9 +++- AGENTS.md | 62 +++++++++++-------------- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/.claude/skills/workflow-policy/SKILL.md b/.claude/skills/workflow-policy/SKILL.md index de74c31d..b263e802 100644 --- a/.claude/skills/workflow-policy/SKILL.md +++ b/.claude/skills/workflow-policy/SKILL.md @@ -1,3 +1,10 @@ +# Workflow policy - SUPERSEDED 2026-09-16 + +The owner stopped workflow-based work: "The Workflow approach does not work for me. I cant see whats happening." +Work happens directly in the session, sequentially (AGENTS.md rule 8). This file is kept only so old references resolve. + +--- + --- name: workflow-policy description: Model, effort and parallelism rules for Workflow (ultracode) runs in Optimum. Use before writing any workflow script or launching any subagent. @@ -20,7 +27,7 @@ Shape: and returns the branch name and commit. 3. `phase('Integrate')`: one opus agent merges every branch into the feature branch, resolves conflicts (Program.cs transplant list, ClientPlatformWindows, shader includes are the usual - ones), reruns extract/check-patches, build, both test suites, commits. + ones), reruns extract/check-patches, build, both test suites, `make patch-il` (Cecil patch, no deploy) and the fork-API drift check (AGENTS.md rule 19), commits. 4. `phase('Review')`: one opus agent, then Fable verifies in game (run-optimum skill). Prompt rules for every stage: read CLAUDE.md and the plan section first; sources of truth table; diff --git a/AGENTS.md b/AGENTS.md index b8acfcc4..f134f627 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Optimum is a performance mod for Vintage Story: a patched client (OpenGL path in `ClientPlatformWindows`) plus a Vulkan backend that substitutes the platform (`VulkanClientPlatform : ClientPlatformWindows` in `Optimum.Render.Vulkan/Platform/`). Read this before touching anything. The skills in `.claude/skills/` (tracked) hold the step-by-step procedures; this file holds the rules and the -working knowledge. A lesson learned during a session belongs here, in the repository; the harness memory +working knowledge. Work happens in the session, one step at a time, visibly (rule 8). A lesson learned during a session belongs here, in the repository; the harness memory store on one machine is a cache, not the record. ## Where the truth lives (edit these, never the generated copies) @@ -34,6 +34,7 @@ dotnet build VintageStory.slnx -c Release # everything dotnet test Optimum.Render.Vulkan.Tests # GPU tests, validation layers on (needs a GPU) dotnet test Optimum.Tests -c Release # source/patch coverage tests bash scripts/extract-patches.sh && bash scripts/check-patches.sh # after editing build/, forks, API +make patch-il # Cecil patch only, no deploy: run after every lib/patcher change; "N/N required methods patched" or it fails make deploy # Cecil patch + copy into .vanilla/win-x64/vintagestory scripts/dev/run-client.sh ["world name"] # detached launch; RENDERER=vulkan|opengl env switches scripts/dev/client-renderer.sh # which renderer ACTUALLY started (read this every time) @@ -111,17 +112,19 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. Commit only when asked or when a phase is verified; say what was verified in the message. 7. **Batch reads.** Read whole methods and both paths in one command (`sed -n` ranges + `rg`), not ten single greps. Codex found in one pass what took an afternoon of small probes. -8. **Agents, models, effort.** The main session does the hard parts itself and never spawns an agent that - inherits its own model. Everything else runs as a Workflow (ultracode): sonnet at **high or xhigh** for the - rare read-only search stage (cheap, and lower effort gives untrustworthy results), opus at **medium, never - higher** for implementation and integration. Shape: every independent implementation stage at once with - `isolation: 'worktree'` (each commits on its own branch), then one integration stage that merges into the - feature branch and runs the finish sequence. No map stage by default (rule 15) and no review stage by default - (rule 14); serial stages only for genuinely dependent work. Worktrees are created at `origin/main`, which need - not be an ancestor of the feature branch: a stage's first commands are `git checkout -B - ` (never `merge --ff-only`) and `bash scripts/dev/worktree-bootstrap.sh`; integration merges - into the feature branch, never main. Details: `.claude/skills/workflow-policy`. Codex (`.claude/skills/codex-handoff`, - gpt-6-astra, weekly quota) takes genuinely stuck rendering bugs with a neutral brief and full machine access. +8. **Work directly in the session, sequentially. No workflows, no background agents.** (Owner, 2026-09-16: + "The Workflow approach does not work for me. I cant see whats happening.") The session reads, edits, builds, + tests and reports each step itself, in order, so the owner can watch every change land. A subagent is allowed + only for a read-only search the session would otherwise do by hand, returns text, and never edits. Codex + (`.claude/skills/codex-handoff`) remains available for a genuinely stuck rendering bug on the owner's say-so. + +19. **Build and tests cannot see two crash classes; `make patch-il` and the API-drift check can.** Both compile + against the fork, so a transplant tuple with the wrong parameter count or a lib call to a member that exists only + in the API fork passes them and ships as a crash (2026-09-16: 1271 + 1107 tests green, then + RenderTextureIntoFrameBuffer listed with 9 params against vanilla's 10, and `MeshDataPool.get_ModelRef` crashing + both backends). After any lib or fork change run `make patch-il` and `diff -r .baseline/VintagestoryApi + VintagestoryApi`, and grep the lib for each added public member. A new member on a vanilla API type never ships + unless api-patcher.cs injects it: use a scope seam (BeginChunkPass/EndChunkPass pattern) or a contracts type. 9. **Undefined behaviour differs between the APIs.** GL keeps an attachment the shader never writes; Vulkan writes garbage into it (pipelines now mask those off). A bug that only flickers between @@ -156,21 +159,14 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. is genuinely the owner's (money, scope, upstream, destructive acts) and you cannot resolve it from what they already said. Turning an instruction they just gave you back into a question is the failure mode. -14. **Reviews are expensive; verification is not.** Implement-only stages by default. The integration stage runs - the full suites on the merged state, which is where defects actually show. Reserve a review stage for the - genuinely high-risk change in a wave, not for every stage. +14. **Verification, not review rounds.** After each change: build, both suites, `make patch-il`, and for anything + that touches the screen the headless both-backends capture. No separate review passes. -15. **No map stage by default - document the seams instead.** Map stages rediscovered the tree at five figures of - tokens each and were thrown away with the run. Every render seam carries a doc comment at its declaration: what +15. **Grep, don't map - and document the seams.** Every render seam carries a doc comment at its declaration: what it draws, where the OpenGL body is, target and slots, the state that is not obvious and why, and the test that - pins it (`docs/vulkan-native-render-systems.md` section 4). An implementation stage documents what it touches as - part of the change and greps instead of mapping. Map only what the code cannot answer - measured behaviour, - vendor documentation, a tree the repo does not contain. `scripts/dev/harvest-maps.py` recovers the map output of - past runs from the workflow journals when one is needed again. + pins it (`docs/vulkan-native-render-systems.md` section 4). Document what you touch as part of the change. -16. **Agents never launch the game, never `make deploy`, never push.** In-game verification, deployment and pushing - are the session's own work, because they touch the owner's machine and their branches. A stage that needs the - game verified says so in its return value. +16. **Only the session launches the game, deploys or pushes**, and only after the checks in rule 14. 17. **Identity and attribution.** Commit as `NightHammer1000 ` (global config only). The work e-mail from the environment context must never appear in git config, commits, PRs, docs or output. No tooling or @@ -334,17 +330,13 @@ User, 2026-09-15, after naming MXAO, Alchemy AO, low-sample GTAO + spatial denoi **How to apply:** delete a capture directory as soon as its numbers are recorded in `docs/vulkan-acceptance.md` or the plan - the conclusions are the deliverable, the frames are not. Shallow-clone vendor SDKs, read them, then remove them; the synthesis stays. Anything a test or a later session needs (the NVIDIA NGX libraries, headers and guides) goes to `~/.local/share/optimum-ngx`, never the scratchpad: on tmpfs it vanishes at reboot and the NGX tests then *skip* rather than fail, which hides the breakage. Check `df -h /tmp` before writing GB-scale dumps, and prefer per-attachment dumps at one frame over frame sequences. Related: `testing-suite-too-heavy`, `ngx-needs-a-native-shim`. -### Speed and parallelism over testing +### Sequential, visible work (supersedes "speed and parallelism", 2026-09-16) -*"2026-09-11 user direction during the Vulkan-native rebuild - \"enough testing, speed this up, more parallelism in the workflow\"; fewer in-game verification rounds, wider parallel stages."* - -After the Phase 1 exit (several in-game capture rounds plus an A/B/A pacing investigation) the user said: "enough testing. Speed this up a bit. More paralellism in the workflow as well". - -Capture sessions stay short: 3 minutes is plenty for a session measurement ("That 10 Minute run was excessive", 2026-09-11); never schedule a 10-minute run again. - -**Why:** the rebuild spent hours in serial chains (one stage per worktree after another) and in repeated in-game measurement rounds; the user wants throughput. - -**How to apply:** design each phase's workflow as wide parallel waves with explicit file ownership and interface contracts in the prompts (no map stage when the touch points are already known), one merge agent per wave, one review at the end. Keep in-game runs to the phase's single exit capture; do not add investigation launches unless a result blocks the next phase. Unit and source tests inside stages stay mandatory. Related: `vulkan-native-rebuild-decision`, `no-subagents`, `research-before-repeating-loops`. +Earlier direction favoured wide parallel workflow waves. The owner reversed it on 2026-09-16 after a day of +merges landing work they could not watch: "The Workflow approach does not work for me. I cant see whats +happening." One change at a time in the session, verified before the next. What survives from the earlier +direction: capture sessions stay short (3 minutes is plenty; never a 10-minute run) and in-game runs are for the +exit of a piece of work, not for investigation loops. ### Taa p2 vulkan parity lessons @@ -355,7 +347,7 @@ TAA P2 (in-house resolve) was accepted by the user on 2026-09-10 ("TAA is CHEFSK 2. `ClearColor` on Vulkan is a no-op for an attachment masked out of `SetDrawBuffers`; the motion attachment kept stale vectors (8e4a970). Clear = enable, clear, restore mask. Both slipped past single-frame GPU tests; Codex's regression test spans frames in flight with Present between them. Acceptance is numeric: still camera, wind stilled (`/weather setw still`), luminance diff of screenshot pairs; parity was Vulkan 1.84 vs OpenGL 1.87. -**How to apply:** for any Vulkan "looks wrong" report, check the format table and clear-vs-mask first (now in the vulkan-parity-debug skill, sections 2 and 2c), and write multi-frame tests for temporal state. P3+ of TAA-PLAN.md continue via workflows (sonnet map, opus stages). Related: `verify-end-to-end-not-components`, `delegating-to-codex`, `optimum-upscaling-roadmap`. +**How to apply:** for any Vulkan "looks wrong" report, check the format table and clear-vs-mask first (now in the vulkan-parity-debug skill, sections 2 and 2c), and write multi-frame tests for temporal state. Related: `verify-end-to-end-not-components`, `delegating-to-codex`, `optimum-upscaling-roadmap`. ### Testing suite too heavy From 715f395cf5a44735bcf9e5d26d64f34cfb7ececf Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 13:13:04 +0200 Subject: [PATCH 203/226] fix(native-world): decal pass through a scope seam, no fork-only API member The shipped client threw MissingMethodException 'MeshRef MeshDataPool.get_ModelRef()' at SystemRenderDecals.OnRenderFrame3D on both backends: the particles-sky stage added a public ModelRef to the API fork and made the lib call game.Platform.RenderDecalPool(decalPool.ModelRef, ...). A new public member on a vanilla API type never ships - the shipped VintagestoryAPI-patched.dll is vanilla plus the hooks in api-patcher.cs - and modelRef is internal in vanilla, so the lib could not read it either. The decals now use the scope shape the chunk pools already use: BeginDecalPass(decalTextureId, blockTextureId) / EndDecalPass() on ClientPlatformAbstract with empty neutral bodies, the VANILLA decalPool.Draw(game.api, game.frustumCuller, CullInstant) between them, and the Vulkan RenderMesh(MeshRef, int[], int[], int, bool) override routing the pool's multi-draw to TryDrawDecalPoolNative while the scope is open. OpenGL is vanilla again. The scope closes in the same finally as the motion window, ahead of it. Also fixes a transplant tuple in the same merge that make patch-il caught: ClientMain::RenderTextureIntoFrameBuffer is 10 parameters, not 9, and the patcher aborted on it before reaching the end. Verified: dotnet build VintageStory.slnx -c Release clean; extract-patches + check-patches 159 patches, 0 pending, 0 conflict; Optimum.Tests 1271 passed, 0 failed, 34 skipped; Optimum.Render.Vulkan.Tests 1107 passed, 0 failed with the implicit layers disabled; make patch-il "207/207 required methods patched". Not verified: the game was not launched (agents do not). --- Optimum.Patcher/Program.cs | 9 +- .../NativeWorldSystemsTests.cs | 60 +++++++++++- .../Platform/VulkanClientPlatform.Meshes.cs | 5 + .../VulkanClientPlatform.NativeWorld.cs | 97 ++++++++++++++----- .../Platform/VulkanClientPlatform.cs | 3 +- .../native-world-systems-coverage-tests.cs | 45 ++++++--- .../taa-sky-decal-motion-coverage-tests.cs | 14 ++- docs/vulkan-native-render-systems.md | 17 +++- .../Client/MeshPool/MeshDataPool.cs.patch | 30 +----- .../ClientPlatformAbstract.cs.patch | 36 ++++--- .../SystemRenderDecals.cs.patch | 21 ++-- 11 files changed, 240 insertions(+), 97 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 71b81035..ecb82b4d 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -100,7 +100,12 @@ "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", - "RenderDecalPool", + // The decal pool draws through a scope seam, not a draw seam: the mesh handle stays + // inside MeshDataPool (internal in the vanilla API), so the lib runs the vanilla + // MeshDataPool.Draw between Begin and End and a native platform takes its RenderMesh + // multi-draw while the scope is open. Neutral bodies are empty. + "BeginDecalPass", + "EndDecalPass", // Phase 3b stage 2, GUI and text: the two GUI draw seams, so a native platform records // those passes itself. Both neutral bodies are the RenderMesh call they replaced. "RenderTextureQuad", @@ -1053,7 +1058,7 @@ // Phase 3b stage 2, GUI and text: the two callers that draw through the new GUI seams - // the texture-into-texture blit that bakes every Cairo GUI and text surface, and the // aiming reticle's line draws. - new("Vintagestory.Client.NoObf.ClientMain", "RenderTextureIntoFrameBuffer", 9), + new("Vintagestory.Client.NoObf.ClientMain", "RenderTextureIntoFrameBuffer", 10), new("Vintagestory.Client.NoObf.SystemRenderPlayerAimAcc", "OnRenderFrame2DOverlay", 1), // SystemSoundEngine: audio listener update threshold + periodic refresh new("Vintagestory.Client.NoObf.SystemSoundEngine", "OnRenderFrame", 2), diff --git a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs index bdfe3208..0c3ab2d9 100644 --- a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs @@ -172,12 +172,38 @@ public void TheNativeDecalPassMatchesTheSeamsNeutralBody() int[] sizes = { 3, 3 }; byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: false, - s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + s => + { + // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh + // multi-draw runs inside it, the scope closes. + s.Platform.BeginDecalPass(decal, block); + try + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); long indirect = session.Seam.NativeIndirectDrawsForTests; long inside = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: false, - s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + s => + { + // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh + // multi-draw runs inside it, the scope closes. + s.Platform.BeginDecalPass(decal, block); + try + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); Assert.Equal(1, session.Seam.NativeIndirectDrawsForTests - indirect); Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); @@ -201,9 +227,35 @@ public void TheNativeDecalPassLeavesTheMotionAttachmentIdentical() int[] sizes = { 3, 3 }; byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: true, - s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + s => + { + // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh + // multi-draw runs inside it, the scope closes. + s.Platform.BeginDecalPass(decal, block); + try + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: true, - s => s.Platform.RenderDecalPool(s.Mesh, starts, sizes, 2, decal, block)); + s => + { + // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh + // multi-draw runs inside it, the scope closes. + s.Platform.BeginDecalPass(decal, block); + try + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); Assert.Equal(emulated[MotionSlot], native[MotionSlot]); AssertSameAttachments(emulated, native, "decals (motion window)"); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 2f60496b..0734e19c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -84,6 +84,11 @@ public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSi // OpenGL body takes. if (TryDrawChunkPoolNative(vAO, indices, indicesSizes, groupCount)) return; + // Phase 3b stage 2: inside SystemRenderDecals' BeginDecalPass/EndDecalPass scope this is + // the decal pool's multi-draw - vanilla MeshDataPool.Draw's own RenderMesh call - and + // VulkanClientPlatform.NativeWorld.cs records it as a native pass. + if (TryDrawDecalPoolNative(modelRef, indices, indicesSizes, groupCount)) return; + // The chunk renderer's one multidraw per pool. GL takes byte offsets // into the index buffer; the device converts them to index counts and // issues a single indirect draw. diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index 826941f1..df9be636 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -21,9 +21,12 @@ namespace Optimum.Render.Vulkan.Platform; // - the cube particles - SystemRenderParticles' instanced pool draw on Primary, seam // ClientPlatformAbstract.RenderParticles, neutral body // RenderMeshInstanced; -// - the decals - SystemRenderDecals' pooled multi-draw, seam -// ClientPlatformAbstract.RenderDecalPool, neutral body the -// RenderMesh multi-draw the second half of MeshDataPool.Draw made. +// - the decals - SystemRenderDecals' pooled multi-draw, scope seam +// ClientPlatformAbstract.BeginDecalPass / EndDecalPass with empty +// neutral bodies: the lib runs the vanilla MeshDataPool.Draw between +// them and the pool's RenderMesh multi-draw is taken natively while +// the scope is open (the mesh handle is internal in the vanilla API, +// so it can never be a seam parameter). // NativeWorldEnabled false takes the neutral body on the Vulkan device too, which is the route // the differential tests compare against. // @@ -326,45 +329,89 @@ public override void RenderParticles(MeshRef model, int quantity, int particleTe NativeWorldEndPass(target, outer, outerFlags); } + // ------------------------------------------------------------------- the decal scope + + /// True between and . + private bool decalScopeActive; + + /// The decal atlas handle the open scope passed in. + private int decalScopeDecalTextureId; + + /// The block atlas handle the open scope passed in. + private int decalScopeBlockTextureId; + /// - /// The decal pool's multi-draw: the native pass, or the seam's neutral body. + /// Opens the decal pool's scope: the two atlas handles, which a native pass resolves what it + /// samples from, instead of from the units ShaderProgramDecals' setters bound them to. + /// + /// The mesh handle is deliberately not a parameter. MeshDataPool.modelRef is internal in the + /// vanilla API and the shipped VintagestoryAPI-patched.dll is vanilla plus api-patcher.cs's + /// hooks only, so a new public member on MeshDataPool would never reach the running client + /// (it did not, and the shipped client threw MissingMethodException on both backends). The + /// lib therefore runs the vanilla MeshDataPool.Draw, whose own + /// capi.Render.RenderMesh(modelRef, starts, sizes, count) lands in this platform's + /// override, and that override + /// routes to while this scope is open. The caller's + /// Draw runs the pool's cull first on both routes, so both draw the same ranges and only the + /// draw command differs. /// - /// The caller has already run the pool's own public MeshDataPool.FrustumCull - the first - /// half of MeshDataPool.Draw - so both routes draw the same ranges and only the draw - /// command differs. + /// Where the other side is: ClientPlatformAbstract.BeginDecalPass / EndDecalPass have empty + /// neutral bodies, so the OpenGL path is vanilla MeshDataPool.Draw into + /// ClientPlatformWindows.RenderMesh -> GL.MultiDrawElements, exactly as before the seam. + /// Target and slots: Primary, inside SystemRenderDecals' motion window - a decal nudges the + /// depth buffer in front of the block it sits on and writes that surface's motion vector + /// itself, so the motion attachment is one of NativeWorldPassColorSlots and replaces rather + /// than blends (NativeWorldBlend). + /// State that is not obvious: standard blending on and the AfterOIT stage's depth test and + /// depth writes, which ClientMain sets before the stage; SystemRenderDecals turns culling + /// off itself. + /// What pins it: NativeWorldSystemsTests (old route against native route, including the + /// motion attachment bit for bit) and Optimum.Tests/native-world-systems-coverage-tests.cs. /// - public override void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, - int groupCount, int decalTextureId, int blockTextureId) + public override void BeginDecalPass(int decalTextureId, int blockTextureId) { - if (groupCount <= 0 || indicesStarts == null || indicesSizes == null) - { - base.RenderDecalPool(decalMesh, indicesStarts!, indicesSizes!, groupCount, decalTextureId, blockTextureId); - return; - } + decalScopeActive = true; + decalScopeDecalTextureId = decalTextureId; + decalScopeBlockTextureId = blockTextureId; + } + + /// Closes the scope opened. + public override void EndDecalPass() + { + decalScopeActive = false; + decalScopeDecalTextureId = 0; + decalScopeBlockTextureId = 0; + } + + /// + /// The decal pool's multi-draw, recorded natively, when it arrives through + /// inside an open decal scope. + /// False means the scope is closed, the native route is off, or the pass could not be + /// prepared, and the caller takes the emulated multi-draw the OpenGL body takes. + /// + internal bool TryDrawDecalPoolNative(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount) + { + if (!decalScopeActive) return false; + if (groupCount <= 0 || indicesStarts == null || indicesSizes == null) return false; - // Blending on in the standard mode and the AfterOIT stage's depth test and depth writes, - // which ClientMain sets before the stage; SystemRenderDecals turns culling off itself. if (!NativeWorldPrepare(nativeDecals, decalMesh, blending: true, depth: true, out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) { - base.RenderDecalPool(decalMesh, indicesStarts, indicesSizes, groupCount, decalTextureId, blockTextureId); - return; + return false; } - RuntimeStats.drawCallsCount++; string outer = passContext; PassFlags outerFlags = passContextFlags; - // Inside the caller's motion window the motion attachment is one of the pass's colour - // slots and replaces rather than blends, so decals.fsh's motion.glsl writer lands the - // surface's vector with the decal's own depth exactly as it does on the GL path. - if (NativeWorldBeginPass("Decals", target, slots, new[] { decalTextureId, blockTextureId })) + if (NativeWorldBeginPass("Decals", target, slots, + new[] { decalScopeDecalTextureId, decalScopeBlockTextureId })) { device.DrawNativeMeshMulti(pipeline, vao.VaoId, indicesStarts, indicesSizes, groupCount, new[] { - new NativeTexture(nativeDecals.Samplers[0], decalTextureId), - new NativeTexture(nativeDecals.Samplers[1], blockTextureId), + new NativeTexture(nativeDecals.Samplers[0], decalScopeDecalTextureId), + new NativeTexture(nativeDecals.Samplers[1], decalScopeBlockTextureId), }); } NativeWorldEndPass(target, outer, outerFlags); + return true; } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index adeb3988..9a56c7a6 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -149,7 +149,8 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderNightSkyBox", new[] { "MeshRef", "Int32" }), new(true, "RenderCelestialQuad", new[] { "MeshRef", "Int32", "Int32", "Int32" }), new(true, "RenderParticles", new[] { "MeshRef", "Int32", "Int32" }), - new(true, "RenderDecalPool", new[] { "MeshRef", "Int32[]", "Int32[]", "Int32", "Int32", "Int32" }), + new(true, "BeginDecalPass", new[] { "Int32", "Int32" }), + new(true, "EndDecalPass", Array.Empty()), // Phase 3b stage 2, GUI and text: the texture-into-texture blit and the aiming reticle's // line draws, the two GUI systems whose fixed state is stated at their call site. new(true, "RenderTextureQuad", new[] { "MeshRef", "Int32", "Boolean" }), diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 0d52a021..f4e161ee 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -471,10 +471,12 @@ public void TheWorldSeamsHaveNeutralBodiesThatAreTheDrawsTheyReplaced() platform); Assert.Contains("RenderMeshInstanced(model, quantity);", platform); - Assert.Contains( - "public virtual void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount, int decalTextureId, int blockTextureId)", - platform); - Assert.Contains("RenderMesh(decalMesh, indicesStarts, indicesSizes, groupCount);", platform); + // The decals are a scope seam, not a draw seam: the mesh handle lives in MeshDataPool, + // which is internal in the vanilla API, so it can never be a seam parameter. Both neutral + // bodies are empty and the lib runs the vanilla MeshDataPool.Draw between them. + Assert.Contains("public virtual void BeginDecalPass(int decalTextureId, int blockTextureId)", platform); + Assert.Contains("public virtual void EndDecalPass()", platform); + Assert.DoesNotContain("RenderDecalPool", platform); // The OpenGL platform leaves every one of them alone: nothing about the GL path changes. string windows = ReadPatchedOrSource( @@ -482,7 +484,8 @@ public void TheWorldSeamsHaveNeutralBodiesThatAreTheDrawsTheyReplaced() "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); foreach (string seam in new[] { - "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", "RenderDecalPool", + "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", + "BeginDecalPass", "EndDecalPass", }) { Assert.DoesNotContain(seam, windows); @@ -526,13 +529,21 @@ public void TheWorldRenderersDrawThroughTheirSeams() string decals = ReadPatchedOrSource( "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch", "build/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs"); - // The cull half of MeshDataPool.Draw, then the seam with its cull results: both routes - // draw the same ranges and only the draw command differs. - Assert.Contains("decalPool.FrustumCull(game.frustumCuller, EnumFrustumCullMode.CullInstant);", decals); + // The scope, then the VANILLA MeshDataPool.Draw inside it: both routes cull and draw the + // same ranges and only the draw command differs. Nothing here may reach a member that + // exists only in the API fork - MeshDataPool.ModelRef did, and the shipped client threw + // MissingMethodException on both backends because a new public member on a vanilla API + // type never ships (the shipped API dll is vanilla plus api-patcher.cs's hooks only). Assert.Contains( - "game.Platform.RenderDecalPool(decalPool.ModelRef, decalPool.indicesStartsByte, decalPool.indicesSizes, decalPool.indicesGroupsCount,", + "game.Platform.BeginDecalPass(decalTextureAtlas.TextureId, game.BlockAtlasManager.AtlasTextures[0].TextureId);", decals); - Assert.DoesNotContain("decalPool.Draw(game.api,", decals); + Assert.Contains("decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant);", decals); + Assert.Contains("game.Platform.EndDecalPass();", decals); + Assert.DoesNotContain(".ModelRef", decals); + + // And the API fork itself no longer declares it, so the lib cannot start depending on it + // again. The fork is git-ignored, so the shipped truth is its patch. + Assert.DoesNotContain("ModelRef", Read("patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch")); Assert.Contains("optimumPlatform.BeginMotionWrite()", decals); } @@ -543,7 +554,8 @@ public void TheWorldSeamsAndTheirCallersAreListedForTheTransplant() string patcher = Read("Optimum.Patcher/Program.cs"); foreach (string seam in new[] { - "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", "RenderDecalPool", + "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", + "BeginDecalPass", "EndDecalPass", }) { Assert.Contains(Q + seam + Q, patcher); @@ -574,13 +586,22 @@ public void TheVulkanPlatformRecordsTheWorldSystemsNativelyAndKeepsTheOldRoutes( Assert.Contains("internal bool NativeWorldEnabled { get; set; } = true;", world); foreach (string seam in new[] { - "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", "RenderDecalPool", + "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", }) { Assert.Contains("public override void " + seam + "(", world); Assert.Contains("base." + seam + "(", world); } + // The decal scope seam: Begin/End on the platform, and the pool's multi-draw taken + // natively from the mesh seam while the scope is open. Its "old route" is falling out of + // TryDrawDecalPoolNative into the emulated multi-draw RenderMesh would have made anyway. + Assert.Contains("public override void BeginDecalPass(int decalTextureId, int blockTextureId)", world); + Assert.Contains("public override void EndDecalPass()", world); + Assert.Contains("internal bool TryDrawDecalPoolNative(", world); + Assert.Contains("TryDrawDecalPoolNative(modelRef, indices, indicesSizes, groupCount)", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs")); + // Each mesh-draw kind the device API grew for world systems is used by the system whose // shape needs it: a single mesh, an instanced pool, an indirect multi-draw. Assert.Contains("device.DrawNativeMesh(", world); diff --git a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs index 7957d463..e84ee6ef 100644 --- a/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -330,6 +330,12 @@ public void TheDecalMotionWindowCoversEverythingItOpened() string restores = FinallyBlock(pass); Assert.Contains("optimumPlatform.EndMotionWrite();", restores); + // The decal scope closes in the same finally, before the window it sits inside. + Assert.Contains("game.Platform.EndDecalPass();", restores); + Assert.True( + restores.IndexOf("game.Platform.EndDecalPass();", System.StringComparison.Ordinal) + < restores.IndexOf("optimumPlatform.EndMotionWrite();", System.StringComparison.Ordinal), + "the decal scope must close before the motion window that encloses it"); // Nothing but the window's own bookkeeping happens between the open and // the try: every statement below runs guarded. @@ -341,10 +347,10 @@ public void TheDecalMotionWindowCoversEverythingItOpened() "shaderProgramDecals.Use();", "shaderProgramDecals.ProjectionMatrix = game.CurrentProjectionMatrix;", "SetOptimumMotionUniforms(shaderProgramDecals);", - // Phase 3b stage 2: the pool's Draw was split into its cull and the platform's - // decal seam; both halves still run inside the window. - "decalPool.FrustumCull(game.frustumCuller, EnumFrustumCullMode.CullInstant);", - "game.Platform.RenderDecalPool(decalPool.ModelRef,", + // Phase 3b stage 2: the pool's vanilla Draw runs inside the platform's decal scope; + // the scope and the draw both run inside the motion window. + "game.Platform.BeginDecalPass(", + "decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant);", }) { Assert.Contains(statement, guarded); diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index 99f94a61..fce809d4 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -258,10 +258,19 @@ motion attachment. moon, under `celestialobject`; - `RenderParticles(MeshRef, int quantity, int particleTextureId)` - one particle pool's instanced draw; - - `RenderDecalPool(MeshRef, int[] starts, int[] sizes, int groupCount, int decalTextureId, int blockTextureId)` - - the decal pool's multi-draw. `SystemRenderDecals` now runs the pool's own public `FrustumCull` - and hands the seam its results, which is `MeshDataPool.Draw` split in two; `MeshDataPool` - gained a read-only `ModelRef` for the mesh half of that. + - `BeginDecalPass(int decalTextureId, int blockTextureId)` / `EndDecalPass()` - the decal pool's + scope, both neutral bodies empty. This one is a **scope** seam rather than a draw seam: + `MeshDataPool.modelRef` is `internal` in the vanilla API, and a new public member on a vanilla + API type never ships (the shipped `VintagestoryAPI-patched.dll` is vanilla plus the hooks in + `Optimum.Patcher/api-patcher.cs`), so a `MeshRef` parameter is not available to the lib at all - + an earlier `RenderDecalPool(MeshRef, ...)` seam fed by a fork-only `MeshDataPool.ModelRef` threw + `MissingMethodException` in the shipped client on both backends. `SystemRenderDecals` therefore + opens the scope, runs the **vanilla** `decalPool.Draw(game.api, game.frustumCuller, + EnumFrustumCullMode.CullInstant)` - which culls and issues the pool's own + `RenderMesh(MeshRef, int[], int[], int)` multi-draw - and closes the scope in a `finally`. The + Vulkan platform's `RenderMesh(MeshRef, int[], int[], int, bool)` override routes that multi-draw + to `TryDrawDecalPoolNative` while the scope is open, the same shape `BeginChunkPass`/ + `EndChunkPass` uses for the chunk pools. - **Platform:** `VulkanClientPlatform.NativeWorld.cs`, one `NativeWorldEnabled` switch keeping every neutral body reachable. Two derivations are shared by all four passes and are the reason none of them reads `GlStateTracker`: diff --git a/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch b/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch index 67a7a5fd..b23d3b76 100644 --- a/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch +++ b/patches/VintagestoryApi/Client/MeshPool/MeshDataPool.cs.patch @@ -1,28 +1,8 @@ diff --git a/VintagestoryApi/Client/MeshPool/MeshDataPool.cs b/VintagestoryApi/Client/MeshPool/MeshDataPool.cs -index f9128b4..d25a06c 100644 +index f9128b4..6bf26af 100644 --- a/VintagestoryApi/Client/MeshPool/MeshDataPool.cs +++ b/VintagestoryApi/Client/MeshPool/MeshDataPool.cs -@@ -28,10 +28,19 @@ namespace Vintagestory.API.Client - public int IndicesPoolSize; - - internal MeshRef modelRef; - internal int poolId; - -+ /// -+ /// Optimum (Phase 3b): the pool's uploaded mesh, so a platform that records the pool's -+ /// multi-draw itself - VulkanClientPlatform.RenderDecalPool, the native decal pass - can -+ /// reach it alongside the public cull results (, -+ /// , ). Read-only: the pool -+ /// still owns the handle and disposes it. -+ /// -+ public MeshRef ModelRef => modelRef; -+ - // For defragmentation, sanity checks, frustum culling - internal List poolLocations = new List(); - - // For final rendering - -@@ -280,11 +289,15 @@ namespace Vintagestory.API.Client +@@ -280,11 +280,15 @@ namespace Vintagestory.API.Client { modeldata.CustomInts.BaseOffset = vertexPosition * modeldata.CustomInts.InterleaveStride; } @@ -38,7 +18,7 @@ index f9128b4..d25a06c 100644 // Assign a location to it ModelDataPoolLocation poolLocation = new ModelDataPoolLocation() { -@@ -462,10 +475,11 @@ namespace Vintagestory.API.Client +@@ -462,10 +466,11 @@ namespace Vintagestory.API.Client return CurrentFragmentation; } @@ -50,7 +30,7 @@ index f9128b4..d25a06c 100644 } -@@ -513,10 +527,12 @@ namespace Vintagestory.API.Client +@@ -513,10 +518,12 @@ namespace Vintagestory.API.Client public Bools CullVisible = new Bools(true, true); @@ -63,7 +43,7 @@ index f9128b4..d25a06c 100644 /// /// Used for models with movements (like a door). /// -@@ -535,17 +551,17 @@ namespace Vintagestory.API.Client +@@ -535,17 +542,17 @@ namespace Vintagestory.API.Client { case EnumFrustumCullMode.CullInstant: return !Hide && CullVisible[VisibleBufIndex] && culler.InFrustum(FrustumCullSphere); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 9148b0a9..7c4ab243 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..8954cae 100644 +index d6eb844..be13842 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,713 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,723 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -265,16 +265,19 @@ index d6eb844..8954cae 100644 + } + + /// -+ /// Optimum (Phase 3b decision 2, stage 2): the decal pool's multi-draw. ++ /// Optimum (Phase 3b decision 2, stage 2): opens the scope the decal pool's multi-draw runs ++ /// under. + /// -+ /// What it draws: every visible decal in one indirect multi-draw of the decal pool, once per -+ /// frame in SystemRenderDecals.OnRenderFrame3D (the AfterOIT stage). -+ /// The other side: the neutral body below is the OpenGL path - exactly the -+ /// call that the second half of -+ /// MeshDataPool.Draw makes, which is what the caller's decalPool.Draw -+ /// reached - and VulkanClientPlatform.RenderDecalPool is the native one. The caller runs -+ /// the pool's own public FrustumCull first, so both routes draw the same ranges and only -+ /// the draw command differs. ++ /// What it draws: nothing itself. SystemRenderDecals.OnRenderFrame3D (the AfterOIT stage) ++ /// opens the scope, runs the vanilla MeshDataPool.Draw - which culls and issues the ++ /// pool's multi-draw of every ++ /// visible decal - and closes it again in a finally. ++ /// The other side: the neutral body here is empty, so the OpenGL path is vanilla; the native ++ /// one is VulkanClientPlatform.BeginDecalPass, whose RenderMesh override takes the pool's ++ /// multi-draw natively while the scope is open. The scope, not a parameter, is how the mesh ++ /// handle reaches a native platform: MeshDataPool.modelRef is internal in the vanilla ++ /// API and a new public member on a vanilla API type never ships (the shipped ++ /// VintagestoryAPI-patched.dll is vanilla plus Optimum.Patcher/api-patcher.cs's hooks only). + /// Target and slots: Primary, inside the caller's motion window - a decal nudges the depth + /// buffer in front of the block it sits on, so it has to write that surface's motion vector + /// itself. @@ -284,9 +287,16 @@ index d6eb844..8954cae 100644 + /// handles, not from the units ShaderProgramDecals' setters bound them to. + /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. + /// -+ public virtual void RenderDecalPool(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount, int decalTextureId, int blockTextureId) ++ public virtual void BeginDecalPass(int decalTextureId, int blockTextureId) ++ { ++ } ++ ++ /// ++ /// Optimum (Phase 3b decision 2, stage 2): closes the scope ++ /// opened. A no-op on the OpenGL path, and safe to call when the scope never opened. ++ /// ++ public virtual void EndDecalPass() + { -+ RenderMesh(decalMesh, indicesStarts, indicesSizes, groupCount); + } + + /// diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch index f28493dc..4fc05012 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs -index f52d0f6..12688c4 100644 +index f52d0f6..b0ceebf 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderDecals.cs @@ -387,10 +387,34 @@ public class SystemRenderDecals : ClientSystem, IDecalApi @@ -37,7 +37,7 @@ index f52d0f6..12688c4 100644 Vec3d cameraPos = game.EntityPlayer.CameraPos; if (decalOrigin.SquareDistanceTo(cameraPos) > 1000000f) { -@@ -399,27 +423,64 @@ public class SystemRenderDecals : ClientSystem, IDecalApi +@@ -399,27 +423,71 @@ public class SystemRenderDecals : ClientSystem, IDecalApi } if (decals.Count > 0) { @@ -100,15 +100,22 @@ index f52d0f6..12688c4 100644 + SetOptimumMotionUniforms(shaderProgramDecals); + } + // Optimum (Phase 3b): the pool's multi-draw goes through the platform's decal -+ // seam, whose neutral body is this Draw call. A native platform culls through the -+ // pool's own FrustumCull and records the indirect draw itself, and needs the two -+ // atlas textures as handles rather than as the units the program's setters bound. -+ decalPool.FrustumCull(game.frustumCuller, EnumFrustumCullMode.CullInstant); -+ game.Platform.RenderDecalPool(decalPool.ModelRef, decalPool.indicesStartsByte, decalPool.indicesSizes, decalPool.indicesGroupsCount, decalTextureAtlas.TextureId, game.BlockAtlasManager.AtlasTextures[0].TextureId); ++ // scope seam. The draw itself stays vanilla MeshDataPool.Draw - it culls and ++ // calls the platform's own RenderMesh multi-draw - and a native platform takes ++ // that multi-draw natively while the scope is open. The scope carries the two ++ // atlas textures as handles, because a native pass resolves what it samples from ++ // handles rather than from the units the program's setters bound them to; the ++ // pool's mesh handle never leaves the pool, which is internal in the vanilla API. ++ game.Platform.BeginDecalPass(decalTextureAtlas.TextureId, game.BlockAtlasManager.AtlasTextures[0].TextureId); ++ decalPool.Draw(game.api, game.frustumCuller, EnumFrustumCullMode.CullInstant); + shaderProgramDecals.Stop(); + } + finally + { ++ // The decal scope closes before the motion window it sits inside, in the same ++ // finally: a throw in the draw must not leave a native platform recording into ++ // a pass the window is about to take the motion attachment out of. ++ game.Platform.EndDecalPass(); + if (optimumMotionWrite) + { + optimumPlatform.EndMotionWrite(); From 46d90af2e80efef877d64d6b71dc6e5bb7e78791 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 13:19:40 +0200 Subject: [PATCH 204/226] tests: RenderTextureIntoFrameBuffer transplant tuple is 10 parameters --- Optimum.Tests/native-world-systems-coverage-tests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index f4e161ee..3d5950aa 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -715,7 +715,7 @@ public void TheGuiSeamsAndTheirCallersAreListedForTheTransplant() string patcher = Read("Optimum.Patcher/Program.cs"); Assert.Contains("\"RenderTextureQuad\"", patcher); Assert.Contains("\"RenderOverlayLines\"", patcher); - Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"RenderTextureIntoFrameBuffer\", 9", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"RenderTextureIntoFrameBuffer\", 10", patcher); Assert.Contains( "\"Vintagestory.Client.NoObf.SystemRenderPlayerAimAcc\", \"OnRenderFrame2DOverlay\", 1", patcher); From 9550b8320ea5570a4039a0bd771d562cf715f416 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 13:48:55 +0200 Subject: [PATCH 205/226] fix(native-world): env gates per native route; entity route parked as opt-in; draw-state trace The stage-2 merge shipped with the first-person hand drawn wrong on Vulkan with TAA on: the native entity route shows the joints the OpenGL body hides. Bisected in the real client with new per-route switches (OPTIMUM_VK_NATIVE_{CHUNKS,ENTITIES,WORLD,SKY,GUI}): only the entity route, only with TAA on; TAA off matches OpenGL. A new render-trace line in BindStorageSet prints, per draw, the record offset, every named block's buffer and offset and the projection/view/model matrices by name - the two routes bind byte-identical state for that draw, so the defect is in the TAA-on path (motion window, TAAMOTION outputs, or the motion-writer hooks the neutral RenderMesh body ran and the native route bypasses). Entities are opt-in (OPTIMUM_VK_NATIVE_ENTITIES=1) until that is fixed; the finding and the repro are in the handoff. Also: the stale test assertion for the corrected transplant tuple. Verified on the deployed build, both backends headless, TAA on: 0 client errors, validation 0 errors and 0 SYNC-, entity GPU tests 24 passed, scene SSIM 0.9838/0.9736/0.9820 against a same-session GL-vs-GL floor of 0.9961/0.9853/0.9932. --- .../VulkanClientPlatform.NativeChunks.cs | 2 +- .../VulkanClientPlatform.NativeEntities.cs | 6 ++++- .../VulkanClientPlatform.NativeGui.cs | 2 +- .../VulkanClientPlatform.NativeSky.cs | 2 +- .../VulkanClientPlatform.NativeWorld.cs | 2 +- Optimum.Render.Vulkan/VulkanDevice.cs | 24 +++++++++++++++++++ docs/vulkan-branch-progress.md | 20 ++++++++++++++++ 7 files changed, 53 insertions(+), 5 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs index eae8944d..ebd52565 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs @@ -63,7 +63,7 @@ public partial class VulkanClientPlatform /// the route the OpenGL body takes - instead of the native pass: the old route the /// differential test compares against, in the pattern of . /// - internal bool NativeChunksEnabled { get; set; } = true; + internal bool NativeChunksEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_CHUNKS") != "0"; /// /// The texture each program sampler was last pointed at, recorded where the client points diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs index 07ab627e..35f92828 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs @@ -64,7 +64,11 @@ public partial class VulkanClientPlatform /// instead of the native pass: the old route the differential tests compare against, in the /// pattern of and . /// - internal bool NativeEntitiesEnabled { get; set; } = true; + // Opt-in until the TAA-on defect is fixed: with TAA on, the first-person hand draws its hidden joints + // (2026-09-16, headless both-backends run); with TAA off the route matches OpenGL. Every value bound to + // the draw - record, Animation/AnimationPrev offsets, textures, mesh, blend - was traced identical to + // the emulated route, so the difference is in the motion-window or TAAMOTION-variant handling. + internal bool NativeEntitiesEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_ENTITIES") == "1"; /// The programs this file owns. Anything else takes the seam's neutral body. private const string EntityAnimatedPass = "entityanimated"; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index 61f3f77f..6aa1bce5 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -66,7 +66,7 @@ public partial class VulkanClientPlatform /// device instead of the native passes: the old route the differential tests compare /// against, in the pattern of . /// - internal bool NativeGuiEnabled { get; set; } = true; + internal bool NativeGuiEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_GUI") != "0"; /// /// The texture-into-texture blit's pipeline and placements. Nothing is written per draw: diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs index 01d2cb88..f812bba2 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs @@ -43,7 +43,7 @@ public partial class VulkanClientPlatform /// instead of the native pass: the old route the differential test compares against, in the /// pattern of . /// - internal bool NativeSkyEnabled { get; set; } = true; + internal bool NativeSkyEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_SKY") != "0"; /// /// The sky program's pipeline and the placements its draw writes through. "modelViewMatrix" diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index df9be636..69212c0e 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -63,7 +63,7 @@ public partial class VulkanClientPlatform /// instead of the native pass: the old route the differential tests compare against, in the /// pattern of . /// - internal bool NativeWorldEnabled { get; set; } = true; + internal bool NativeWorldEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_WORLD") != "0"; /// The star cube's pipeline: no per-draw uniform, one samplerCube. private readonly NativeMeshPass nativeNightSky = diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index ca5228b0..b06d11cd 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -3254,6 +3254,30 @@ private void BindStorageSet(CommandBuffer commandBuffer, ShaderProgramResources } } + if (RenderTrace.Enabled && meshId > 0) + { + // Diagnostic: what this draw binds, so the emulated and the native route can be diffed per draw. + var trace = new System.Text.StringBuilder(" sets program=").Append(program.ProgramId).Append(" mesh=").Append(meshId) + .Append(" record=").Append(recordOffset); + foreach (BlockBinding block in program.Interface.UniformBlocks) + { + trace.Append(' ').Append(block.BlockName).Append('@').Append(block.Binding).Append('=') + .Append(buffers[block.Binding].Offset).Append('/').Append(buffers[block.Binding].Resource); + } + var inv = System.Globalization.CultureInfo.InvariantCulture; + trace.Append(" recordBytes=").Append(program.UniformShadow.Length); + foreach (UniformMember member in program.Interface.Members) + { + if (member.Name is not ("projectionMatrix" or "viewMatrix" or "modelMatrix")) continue; + if (member.Offset < 0 || member.Offset + 64 > program.UniformShadow.Length) continue; + var f = System.Runtime.InteropServices.MemoryMarshal.Cast(program.UniformShadow.AsSpan(member.Offset, 64)); + trace.Append(' ').Append(member.Name).Append('@').Append(member.Offset).Append("=[") + .Append(f[0].ToString("G4", inv)).Append(',').Append(f[5].ToString("G4", inv)).Append(";t=") + .Append(f[12].ToString("G4", inv)).Append(',').Append(f[13].ToString("G4", inv)).Append(',').Append(f[14].ToString("G4", inv)).Append(']'); + } + RenderTrace.Write(trace.ToString()); + } + _lastUniformAllocationOk = allocationOk; var contents = new DescriptorSetContents(0, SetConvention.StorageSet, Array.Empty(), buffers); diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 4103d989..b5e673ea 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -123,6 +123,26 @@ compared before and after for every branch. ### Plan status, audited 2026-09-16 (scoped to this branch) +**Phase 3b stage 2 landed 2026-09-16 (`8278bad` + fixes), native entity route PARKED as opt-in.** Native on +Vulkan: sky dome, all 13 chunk groups, night sky, moon, cube particles, decals, the texture-into-texture GUI blit and +the reticle. Still emulated: entities (see below), sun, quad particles, held items, aurora and clouds (fork surface +`OptimumForkGraphics`/`VulkanForkGraphics`, no native counterpart, undecided), 7 of 9 GUI systems. `GlStateTracker`, +the texture-unit tables and uniform-by-name are still load-bearing; removal (stage 3) cannot start. +Two shipped-client crashes came out of the merge and are fixed: a transplant tuple with the wrong parameter count +(`RenderTextureIntoFrameBuffer` 9 vs 10) and a lib call to a fork-only API member (`MeshDataPool.ModelRef`) - both +invisible to build and tests, now AGENTS.md rule 19 (`make patch-il` + fork diff after every lib/fork change). +Route switches: `OPTIMUM_VK_NATIVE_{CHUNKS,WORLD,SKY,GUI}=0` disable a native route; `OPTIMUM_VK_NATIVE_ENTITIES=1` +enables the parked one. +**Open defect, next step:** with TAA on, the native entity route draws the first-person hand with its hidden joints +visible (headless run, `scratchpad/s2`); with TAA off it matches OpenGL (0.983). Traced identical between the routes +for that draw: program record (matrices), Animation/AnimationPrev ring offsets, all four textures, mesh and index +buffers, layout id, blend on every slot, specialization source. The difference is therefore in the TAA-on path only: +motion window, TAAMOTION variant outputs, or the entity motion writer hooks that the neutral `RenderMesh` body ran and +the native route bypasses. Repro: `OPTIMUM_VK_NATIVE_ENTITIES=1 OPTIMUM_VK_NATIVE_SHADERS=force` headless Vulkan with +TAA on. Verified deployed state (entities emulated, TAA on, AO vanilla): 0 client errors both backends, validation 0 +errors 0 `SYNC-`, scene SSIM 0.9838/0.9736/0.9820 against a GL-vs-GL floor of 0.9961/0.9853/0.9932 - residual +predates stage 2 (pre-stage-2 diff 1.6-2.6, now 1.0-1.4). + **Phase 3b stage 1 completed and verified in game, 2026-09-16 (merge `2c9bc70`).** All nine post/TAA chain passes draw through the native device API: OIT merge, sky motion, SSAO + bilateral blur + AO composite (both AO modes), TAA resolve and sharpen (the lib body keeps the temporal contract, only the draw is re-routed), the bloom From 6c545f15a0f1796f95f38b222f609fe59c229284 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 14:22:45 +0200 Subject: [PATCH 206/226] fix(native-entities): native route takes the vanilla entity programs only; on by default again VSEssentials registers its own entityanimated for the first-person hands, with its own Animation block and, under TAA, its own AnimationPrev block. Through the native route that program drew the arm several times too large with TAA on. Bisected in the real client: vanilla entity programs native with the hand on the neutral body matches OpenGL (0.9825/0.9835/0.9810). The render trace shows every bound input of that draw equal on both routes, so the cause is still open; it is recorded in the handoff with a repro. Phase 3b decision 1 already puts mod renderers on the adapter, so the route now takes only ShaderPrograms.Entityanimated and Shadowmapentityanimated and is on by default again. OPTIMUM_VK_NATIVE_ENTITIES=0 turns it off, OPTIMUM_VK_NATIVE_ENTITIES=all admits mod programs for the investigation. The trace line in BindStorageSet now also prints block content hashes, the record and push hashes and the frame block version and hash. Tests: the entity fixture builds the vanilla program type and registers it where the client does; new AModRegisteredEntityProgramStaysOnTheNeutralBody. Coverage pins the per-route environment switches and the vanilla-only rule. Verified: entity GPU tests 25 passed, coverage 28 passed, make deploy 207/207, headless Vulkan with TAA on 0 client errors, validation 0 errors and 0 SYNC-; Vulkan vs OpenGL 0.9631/0.9617/0.9658 against a same-session OpenGL floor of 0.9853/0.9750/0.9785, frames equal on inspection, residual is run timing. --- .../NativeEntityDrawTests.cs | 31 +++++++++++++++- .../VulkanClientPlatform.NativeEntities.cs | 28 +++++++++++--- Optimum.Render.Vulkan/VulkanDevice.cs | 21 +++++++++++ .../native-world-systems-coverage-tests.cs | 18 ++++++--- docs/vulkan-branch-progress.md | 37 +++++++++++-------- 5 files changed, 107 insertions(+), 28 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs index 2fc5e8ae..f8fe7b62 100644 --- a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs @@ -82,6 +82,25 @@ public unsafe void TheNativeEntityDrawMatchesTheSeamsNeutralBody(bool gbuffer, b GpuTest.AssertClean(session.Seam); } + /// + /// Phase 3b decision 1: a mod renderer stays on the adapter. VSEssentials registers its own + /// entityanimated for the first-person hands, and that program drew the arm wrong through the + /// native route with TAA on; the route therefore takes only the registered vanilla programs. + /// + [SkippableFact] + public void AModRegisteredEntityProgramStaysOnTheNeutralBody() + { + using Session session = Open(gbuffer: false); + session.UnregisterVanillaProgram(); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + byte[][] drawn = session.RunFrame(native: true, motionOpen: true); + + Assert.Equal(0, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.NotEqual(session.ClearOf(0), Centre(drawn[0])); + GpuTest.AssertClean(session.Seam); + } + /// /// The motion attachment is the one the temporal contract pins: with the window open the two /// routes write the same vectors, and with it shut neither route touches it, so whatever was @@ -229,6 +248,10 @@ public string ClearOf(int slot) private MeshRef shape = null!; private ShaderProgram entity = null!; + private ShaderProgramEntityanimated? previousEntityProgram; + + /// What a mod-registered entityanimated looks like to the route: not the registered vanilla program. + public void UnregisterVanillaProgram() => ShaderPrograms.Entityanimated = new ShaderProgramEntityanimated(); private UBORef animation = null!; private UBORef animationPrev = null!; private readonly float[] bones = new float[16 * 4]; @@ -281,13 +304,18 @@ public string ClearOf(int slot) // Where SetupDefaultFrameBuffers put the motion attachment: after the shaded set. platform.SetOptimumMotionAttachmentIndex(session.Primary.ColorTextureIds.Length - 1); - var program = new ShaderProgram { PassName = "entityanimated" }; + // The vanilla program type, registered where the client registers it: the native route + // takes vanilla entity programs only, and a mod program under the same pass name stays + // on the neutral body (AModRegisteredEntityProgramStaysOnTheNeutralBody). + var program = new ShaderProgramEntityanimated { PassName = "entityanimated" }; Link(seam, program, "entityanimated", Variant(gbuffer), new[] { "modelMatrix", "viewMatrix", "projectionMatrix", "rgbaLightIn", "rgbaAmbientIn", "renderColor", "alphaTest", }); session.entity = program; + session.previousEntityProgram = ShaderPrograms.Entityanimated; + ShaderPrograms.Entityanimated = program; session.atlas = Gradient(seam); // The two animation blocks ShaderProgramEntityanimated creates for the opaque @@ -305,6 +333,7 @@ public string ClearOf(int slot) public void Dispose() { ShaderProgramBase.CurrentShaderProgram = null; + ShaderPrograms.Entityanimated = previousEntityProgram!; if (shape != null) Platform.DeleteMesh(shape); ScreenManager.Platform = previousPlatform!; Platform.ShutdownGraphics(); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs index 35f92828..329a3f11 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs @@ -64,11 +64,8 @@ public partial class VulkanClientPlatform /// instead of the native pass: the old route the differential tests compare against, in the /// pattern of and . /// - // Opt-in until the TAA-on defect is fixed: with TAA on, the first-person hand draws its hidden joints - // (2026-09-16, headless both-backends run); with TAA off the route matches OpenGL. Every value bound to - // the draw - record, Animation/AnimationPrev offsets, textures, mesh, blend - was traced identical to - // the emulated route, so the difference is in the motion-window or TAAMOTION-variant handling. - internal bool NativeEntitiesEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_ENTITIES") == "1"; + // On by default; OPTIMUM_VK_NATIVE_ENTITIES=0 sends every entity draw to the neutral body. + internal bool NativeEntitiesEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_ENTITIES") != "0"; /// The programs this file owns. Anything else takes the seam's neutral body. private const string EntityAnimatedPass = "entityanimated"; @@ -170,9 +167,28 @@ private static bool IsNativeEntityProgram(ShaderProgramBase program) // A sampler the client gave its own filtering or wrap mode to is bound through the unit's // sampler override, which a native draw does not read. Neither vanilla entity program does // that; if one ever did, the neutral body keeps it correct instead of silently losing it. - return program.customSamplers.Count == 0 && !program.clampTToEdge; + if (program.customSamplers.Count != 0 || program.clampTToEdge) return false; + + // Vanilla programs only (Phase 3b decision 1: mod renderers stay on the adapter). A mod can + // register its own program under the same pass name - VSEssentials' first-person hands + // (ModSystemFpHands.fpModeHandShader) is an entityanimated of its own with its own Animation + // and, under TAA, AnimationPrev blocks - and that program drew the arm wrong through this + // route with TAA on (2026-09-16, headless both-backends run). Every bound input traced equal + // to the neutral body's for that draw - record, both blocks, push block, textures, mesh, + // layout, blend, dynamic state - so the cause is still open; see the branch handoff. + // OPTIMUM_VK_NATIVE_ENTITIES=all admits mod programs again, for that investigation. + if (!AllEntityPrograms && + !ReferenceEquals(program, ShaderPrograms.Entityanimated) && + !ReferenceEquals(program, ShaderPrograms.Shadowmapentityanimated)) + { + return false; + } + return true; } + private static readonly bool AllEntityPrograms = + Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_ENTITIES") == "all"; + /// /// The pipeline for this program, target, mesh shape and motion-window state, rebuilt only /// when one of those changes. Every piece of fixed state is stated from client state: the diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index b06d11cd..12552aff 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -3259,11 +3259,32 @@ private void BindStorageSet(CommandBuffer commandBuffer, ShaderProgramResources // Diagnostic: what this draw binds, so the emulated and the native route can be diffed per draw. var trace = new System.Text.StringBuilder(" sets program=").Append(program.ProgramId).Append(" mesh=").Append(meshId) .Append(" record=").Append(recordOffset); + static ulong Fnv(ReadOnlySpan bytes) + { + ulong h = 14695981039346656037UL; + foreach (byte b in bytes) h = (h ^ b) * 1099511628211UL; + return h; + } foreach (BlockBinding block in program.Interface.UniformBlocks) { trace.Append(' ').Append(block.BlockName).Append('@').Append(block.Binding).Append('=') .Append(buffers[block.Binding].Offset).Append('/').Append(buffers[block.Binding].Resource); + if (_boundUniformBuffers.TryGetValue(block.BlockName, out int traceHandle) && + _uniformBuffers.TryGetValue(traceHandle, out ClientUniformBuffer? traceUbo)) + { + trace.Append(" h").Append(traceHandle).Append(":#").Append(Fnv(traceUbo.Shadow).ToString("x16")); + } + else + { + trace.Append(" (unbound)"); + } } + trace.Append(" rec#").Append(Fnv(program.UniformShadow).ToString("x16")) + .Append(" push#").Append(Fnv(_pushShadow).ToString("x16")); + trace.Append(" pushBytes=").Append(program.PushShadow?.Length ?? 0) + .Append(" frameBlock=").Append(program.Interface.UsesFrameBlock) + .Append(" frame@").Append(_frameGlobalsSnapshotOffset).Append(" v").Append(_frameGlobalsVersion) + .Append('#').Append(Fnv(_frameGlobals).ToString("x16")); var inv = System.Globalization.CultureInfo.InvariantCulture; trace.Append(" recordBytes=").Append(program.UniformShadow.Length); foreach (UniformMember member in program.Interface.Members) diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 3d5950aa..b068ed6b 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -89,7 +89,8 @@ public void TheVulkanPlatformRecordsTheSkyNativelyAndKeepsTheOldRoute() { string sky = Read(SkyPlatformFile); - Assert.Contains("internal bool NativeSkyEnabled { get; set; } = true;", sky); + // On by default; OPTIMUM_VK_NATIVE_SKY=0 turns the route off in the real client. + Assert.Contains("internal bool NativeSkyEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_SKY\") != \"0\";", sky); Assert.Contains("public override void RenderSkyDome(", sky); Assert.Contains("base.RenderSkyDome(", sky); Assert.Contains("device.BeginNativePass(", sky); @@ -279,7 +280,8 @@ public void TheVulkanPlatformRecordsTheChunkGroupsNativelyAndKeepsTheOldRoute() { string chunks = Read(ChunkPlatformFile); - Assert.Contains("internal bool NativeChunksEnabled { get; set; } = true;", chunks); + // On by default; OPTIMUM_VK_NATIVE_CHUNKS=0 turns the route off in the real client. + Assert.Contains("internal bool NativeChunksEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_CHUNKS\") != \"0\";", chunks); Assert.Contains("public override bool BeginChunkPass(", chunks); Assert.Contains("public override void EndChunkPass()", chunks); Assert.Contains("device.BeginNativePass(", chunks); @@ -385,7 +387,11 @@ public void TheVulkanPlatformRecordsEntitiesNativelyAndKeepsTheOldRoute() { string entities = Read(EntityPlatformFile); - Assert.Contains("internal bool NativeEntitiesEnabled { get; set; } = true;", entities); + // On by default; OPTIMUM_VK_NATIVE_ENTITIES=0 turns the route off in the real client. + // Vanilla entity programs only (decision 1): a mod program under the same pass name stays on the adapter. + Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Entityanimated)", entities); + Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Shadowmapentityanimated)", entities); + Assert.Contains("internal bool NativeEntitiesEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_ENTITIES\") != \"0\";", entities); Assert.Contains("public override void RenderEntityMesh(", entities); Assert.Contains("base.RenderEntityMesh(", entities); Assert.Contains("device.BeginNativePass(", entities); @@ -583,7 +589,8 @@ public void TheVulkanPlatformRecordsTheWorldSystemsNativelyAndKeepsTheOldRoutes( { string world = Read(WorldPlatformFile); - Assert.Contains("internal bool NativeWorldEnabled { get; set; } = true;", world); + // On by default; OPTIMUM_VK_NATIVE_WORLD=0 turns the route off in the real client. + Assert.Contains("internal bool NativeWorldEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_WORLD\") != \"0\";", world); foreach (string seam in new[] { "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", @@ -736,7 +743,8 @@ public void TheVulkanPlatformRecordsTheGuiSystemsNativelyAndKeepsTheOldRoute() { string gui = Read(GuiPlatformFile); - Assert.Contains("internal bool NativeGuiEnabled { get; set; } = true;", gui); + // On by default; OPTIMUM_VK_NATIVE_GUI=0 turns the route off in the real client. + Assert.Contains("internal bool NativeGuiEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_GUI\") != \"0\";", gui); Assert.Contains("public override void RenderTextureQuad(", gui); Assert.Contains("public override void RenderOverlayLines(", gui); Assert.Contains("base.RenderTextureQuad(", gui); diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index b5e673ea..46f23cf2 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -123,25 +123,30 @@ compared before and after for every branch. ### Plan status, audited 2026-09-16 (scoped to this branch) -**Phase 3b stage 2 landed 2026-09-16 (`8278bad` + fixes), native entity route PARKED as opt-in.** Native on -Vulkan: sky dome, all 13 chunk groups, night sky, moon, cube particles, decals, the texture-into-texture GUI blit and -the reticle. Still emulated: entities (see below), sun, quad particles, held items, aurora and clouds (fork surface -`OptimumForkGraphics`/`VulkanForkGraphics`, no native counterpart, undecided), 7 of 9 GUI systems. `GlStateTracker`, -the texture-unit tables and uniform-by-name are still load-bearing; removal (stage 3) cannot start. +**Phase 3b stage 2 landed 2026-09-16 (`8278bad` + fixes).** Native on Vulkan: sky dome, all 13 chunk groups, the +vanilla entity programs (entityanimated, shadowmapentityanimated), night sky, moon, cube particles, decals, the +texture-into-texture GUI blit and the reticle. Still emulated: mod-registered entity programs (first-person hands), +sun, quad particles, held items, aurora and clouds (fork surface `OptimumForkGraphics`/`VulkanForkGraphics`, no native +counterpart, undecided), 7 of 9 GUI systems. `GlStateTracker`, the texture-unit tables and uniform-by-name are still +load-bearing; removal (stage 3) cannot start. Two shipped-client crashes came out of the merge and are fixed: a transplant tuple with the wrong parameter count (`RenderTextureIntoFrameBuffer` 9 vs 10) and a lib call to a fork-only API member (`MeshDataPool.ModelRef`) - both invisible to build and tests, now AGENTS.md rule 19 (`make patch-il` + fork diff after every lib/fork change). -Route switches: `OPTIMUM_VK_NATIVE_{CHUNKS,WORLD,SKY,GUI}=0` disable a native route; `OPTIMUM_VK_NATIVE_ENTITIES=1` -enables the parked one. -**Open defect, next step:** with TAA on, the native entity route draws the first-person hand with its hidden joints -visible (headless run, `scratchpad/s2`); with TAA off it matches OpenGL (0.983). Traced identical between the routes -for that draw: program record (matrices), Animation/AnimationPrev ring offsets, all four textures, mesh and index -buffers, layout id, blend on every slot, specialization source. The difference is therefore in the TAA-on path only: -motion window, TAAMOTION variant outputs, or the entity motion writer hooks that the neutral `RenderMesh` body ran and -the native route bypasses. Repro: `OPTIMUM_VK_NATIVE_ENTITIES=1 OPTIMUM_VK_NATIVE_SHADERS=force` headless Vulkan with -TAA on. Verified deployed state (entities emulated, TAA on, AO vanilla): 0 client errors both backends, validation 0 -errors 0 `SYNC-`, scene SSIM 0.9838/0.9736/0.9820 against a GL-vs-GL floor of 0.9961/0.9853/0.9932 - residual -predates stage 2 (pre-stage-2 diff 1.6-2.6, now 1.0-1.4). +Route switches: `OPTIMUM_VK_NATIVE_{CHUNKS,ENTITIES,WORLD,SKY,GUI}=0` send a route to the neutral body; +`OPTIMUM_VK_NATIVE_ENTITIES=all` also admits mod-registered entity programs. +**Open question:** with TAA on, VSEssentials' first-person hand program (`ModSystemFpHands.fpModeHandShader`, its own +`entityanimated` with its own `Animation` and, under TAA, `AnimationPrev` blocks) drew the arm several times too large +through the native route; the vanilla programs are correct. Bisected in the real client (hand on the neutral body, +world entities native: 0.9825/0.9835/0.9810 vs OpenGL). For that draw the render trace (new `sets` line in +`BindStorageSet`) shows equal program record, `Animation`/`AnimationPrev` contents, push block, textures, mesh, +vertex layout, blend and dynamic state on both routes, so the cause is not in the bound inputs I could see. By +decision 1 mod programs belong on the adapter anyway, so the route admits vanilla programs only; the question stays +open for when mod renderers get native passes. Repro: `OPTIMUM_VK_NATIVE_ENTITIES=all OPTIMUM_VK_NATIVE_SHADERS=force`, +headless Vulkan, TAA on. +Verified on the deployed build, both backends headless, TAA on: 0 client errors, validation 0 errors and 0 `SYNC-`, +entity GPU tests 25 passed, coverage 28 passed. Scene SSIM Vulkan vs OpenGL 0.9631/0.9617/0.9658 against a same-session +OpenGL floor of 0.9853/0.9750/0.9785; the frames match on inspection and the residual is run timing (the two runs +landed on different in-game days, camera bob and chat differ). **Phase 3b stage 1 completed and verified in game, 2026-09-16 (merge `2c9bc70`).** All nine post/TAA chain passes draw through the native device API: OIT merge, sky motion, SSAO + bilateral blur + AO composite (both AO From 1794a712f313df3f2600d79f74176596cce1c2e8 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 15:34:04 +0200 Subject: [PATCH 207/226] feat(native-world): the visible sun draws natively; world GPU tests no longer vacuous New seam ClientPlatformAbstract.RenderSunQuad for the sun disc in SystemRenderSunMoon.OnRenderFrame3D, neutral body the RenderMesh it replaced. The Vulkan override takes only the registered vanilla standard program and resolves every sampler the pipeline declares (standard reads frame textures too). The occlusion-query probe keeps RenderMesh: a Vulkan occlusion query has to begin and end inside one render pass, and a native draw opens its own. Every pixel comparison in NativeWorldSystemsTests was between two untouched attachments: RunFrame bound programs directly, so the frame block stayed zero (standard.vsh divides by viewDistance and discards everything) and the model and view matrices stayed zero (every vertex collapses). The fixture now seeds both, and the comparison helper fails if the scene slot is still the clear. That exposed a fixture asymmetry - the decal tests never bound the atlases to units as ShaderProgramDecals' setters do - fixed; decals match with real pixels. The particle tests remain vacuous (no per-instance data in the quad) and say so. Verified: world/sky/entity GPU tests 38 passed with real pixels (sun 122,118,18; decals 15,31,19), Optimum.Tests 1271 passed, patches 159 with 0 conflicts, make patch-il 207/207, headless Vulkan 33 native sun passes, 0 client errors both backends, validation 0 errors and 0 SYNC-. The residual Vulkan-vs-OpenGL SSIM is the same with every stage-2 route switched off (0.981/0.971/0.964), so stage 2 did not introduce it. --- Optimum.Patcher/Program.cs | 1 + .../NativeWorldSystemsTests.cs | 156 +++++++++++++++++- .../VulkanClientPlatform.NativeWorld.cs | 50 ++++++ .../Platform/VulkanClientPlatform.cs | 1 + .../native-world-systems-coverage-tests.cs | 11 +- docs/vulkan-branch-progress.md | 12 ++ .../ClientPlatformAbstract.cs.patch | 22 ++- .../SystemRenderSunMoon.cs.patch | 21 ++- 8 files changed, 260 insertions(+), 14 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index ecb82b4d..029f3ca6 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -99,6 +99,7 @@ // each with the neutral body of the draw it replaced. "RenderNightSkyBox", "RenderCelestialQuad", + "RenderSunQuad", "RenderParticles", // The decal pool draws through a scope seam, not a draw seam: the mesh handle stays // inside MeshDataPool (internal in the vanilla API), so the lib runs the vanilla diff --git a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs index 0c3ab2d9..134d40f0 100644 --- a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs @@ -109,11 +109,78 @@ public void TheNativeCelestialPassMatchesTheSeamsNeutralBody() GpuTest.AssertClean(session.Seam); } + // -------------------------------------------------------------------------- sun + + /// + /// The sun's native pass matches its seam's neutral body: standard's samplers resolved from the + /// pipeline's own declaration, the sun texture from its handle, blended with no depth test. + /// + [SkippableFact] + public void TheSunMatchesTheSeamsNeutralBody() + { + using Session session = Open("standard"); + int sun = session.Gradient(0); + + void Draw(Session s) + { + // What SystemRenderSunMoon writes, reduced to what makes the quad land: lit white, + // untinted, identity transforms, a low alpha test. + float[] identity = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + s.Program.UniformMatrix("modelMatrix", identity); + s.Program.UniformMatrix("viewMatrix", identity); + s.Program.Uniform("rgbaTint", 1f, 1f, 1f, 1f); + s.Program.Uniform("rgbaLightIn", 1f, 1f, 1f, 1f); + s.Program.Uniform("rgbaAmbientIn", 1f, 1f, 1f); + s.Program.Uniform("alphaTest", 0.01f); + // The fixture's frame block is zero, and the global warp reads it; the route under + // test does not depend on the warp, so the fixture skips it. + s.Program.Uniform("dontWarpVertices", 1); + s.Platform.BindProgramTexture2D(s.Program, "tex", sun, 0); + s.Platform.RenderSunQuad(s.Mesh, sun); + } + + byte[][] emulated = session.RunFrame(native: false, blending: true, depth: false, motion: false, Draw); + + long meshes = session.Seam.NativeMeshDrawsForTests; + long inside = session.Seam.EmulationCallsInNativePassesForTests; + byte[][] native = session.RunFrame(native: true, blending: true, depth: false, motion: false, Draw); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); + AssertSameAttachments(emulated, native, "standard"); + GpuTest.AssertClean(session.Seam); + } + + /// A mod program registered under "standard" is not the vanilla one and stays on the neutral body. + [SkippableFact] + public void AModStandardProgramStaysOnTheNeutralBody() + { + using Session session = Open("standard"); + int sun = session.Gradient(0); + ShaderProgramStandard registered = ShaderPrograms.Standard; + ShaderPrograms.Standard = new ShaderProgramStandard(); + try + { + long meshes = session.Seam.NativeMeshDrawsForTests; + session.RunFrame(native: true, blending: true, depth: false, motion: false, + s => s.Platform.RenderSunQuad(s.Mesh, sun)); + Assert.Equal(0, session.Seam.NativeMeshDrawsForTests - meshes); + } + finally + { + ShaderPrograms.Standard = registered; + } + GpuTest.AssertClean(session.Seam); + } + // -------------------------------------------------------------------------- particles /// /// The cube pool's native pass matches its seam's neutral body, and the draw is recorded as /// an instanced draw rather than as as many single draws. + /// Known gap: the fixture's quad carries no per-instance attributes, so the cubes do not land + /// on the scene slot and the pixel comparison is between two untouched attachments. What this + /// pins is the route and the draw kind, not the pixels. /// [SkippableFact] public void TheNativeParticlePassMatchesTheSeamsNeutralBody() @@ -130,7 +197,7 @@ public void TheNativeParticlePassMatchesTheSeamsNeutralBody() Assert.Equal(1, session.Seam.NativeInstancedDrawsForTests - instanced); Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); - AssertSameAttachments(emulated, native, "particlescube"); + AssertSameAttachments(emulated, native, "particlescube", mustDraw: false); GpuTest.AssertClean(session.Seam); } @@ -152,7 +219,7 @@ public void TheNativeParticlePassLeavesTheMotionAttachmentIdentical() s => s.Platform.RenderParticles(s.Mesh, 4, 0)); Assert.Equal(emulated[MotionSlot], native[MotionSlot]); - AssertSameAttachments(emulated, native, "particlescube (motion window)"); + AssertSameAttachments(emulated, native, "particlescube (motion window)", mustDraw: false); GpuTest.AssertClean(session.Seam); } @@ -176,6 +243,9 @@ public void TheNativeDecalPassMatchesTheSeamsNeutralBody() { // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh // multi-draw runs inside it, the scope closes. + // What ShaderProgramDecals' setters do before the scope: the atlases on units. + s.Platform.BindProgramTexture2D(s.Program, "blockTexture", block, 0); + s.Platform.BindProgramTexture2D(s.Program, "decalTexture", decal, 1); s.Platform.BeginDecalPass(decal, block); try { @@ -194,6 +264,9 @@ public void TheNativeDecalPassMatchesTheSeamsNeutralBody() { // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh // multi-draw runs inside it, the scope closes. + // What ShaderProgramDecals' setters do before the scope: the atlases on units. + s.Platform.BindProgramTexture2D(s.Program, "blockTexture", block, 0); + s.Platform.BindProgramTexture2D(s.Program, "decalTexture", decal, 1); s.Platform.BeginDecalPass(decal, block); try { @@ -231,6 +304,9 @@ public void TheNativeDecalPassLeavesTheMotionAttachmentIdentical() { // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh // multi-draw runs inside it, the scope closes. + // What ShaderProgramDecals' setters do before the scope: the atlases on units. + s.Platform.BindProgramTexture2D(s.Program, "blockTexture", block, 0); + s.Platform.BindProgramTexture2D(s.Program, "decalTexture", decal, 1); s.Platform.BeginDecalPass(decal, block); try { @@ -246,6 +322,9 @@ public void TheNativeDecalPassLeavesTheMotionAttachmentIdentical() { // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh // multi-draw runs inside it, the scope closes. + // What ShaderProgramDecals' setters do before the scope: the atlases on units. + s.Platform.BindProgramTexture2D(s.Program, "blockTexture", block, 0); + s.Platform.BindProgramTexture2D(s.Program, "decalTexture", decal, 1); s.Platform.BeginDecalPass(decal, block); try { @@ -316,7 +395,10 @@ public void TheDeclaredColourSlotsAreTheOnesTheEmulatedMaskWouldHold() // ---------------------------------------------------------------------------- helpers - private void AssertSameAttachments(byte[][] emulated, byte[][] native, string what) + /// The scene slot's centre as RunFrame clears it (0.125, 0.25, 0.5). + private const string ClearedSceneCentre = "32,64,127,255"; + + private void AssertSameAttachments(byte[][] emulated, byte[][] native, string what, bool mustDraw = true) { for (int slot = 0; slot < emulated.Length; slot++) { @@ -324,6 +406,9 @@ private void AssertSameAttachments(byte[][] emulated, byte[][] native, string wh " native " + Centre(native[slot])); Assert.Equal(emulated[slot], native[slot]); } + // Two untouched attachments are equal too. Until the fixture seeded the frame block and + // the transforms, every comparison in this file was exactly that. + if (mustDraw) Assert.NotEqual(ClearedSceneCentre, Centre(native[SceneSlot])); } private static string Centre(byte[] pixels) @@ -371,6 +456,11 @@ private sealed class Session : IDisposable private ShaderProgram program = null!; private ClientPlatformAbstract? previousPlatform; + private ShaderProgramStandard? previousStandard; + private bool registeredStandard; + + /// The linked program, for a test that sets uniforms of its own. + public ShaderProgram Program => program; private string dataPath = ""; private int gradients; @@ -413,9 +503,23 @@ private sealed class Session : IDisposable // the window-derived slot masks under test mean something. platform.SetOptimumMotionAttachmentIndex(MotionSlot); - var linked = new ShaderProgram { PassName = programName }; - Link(seam, linked, programName, new[] { "projectionMatrix" }); + // standard is the one vanilla program the native route checks by identity (a mod can + // register its own under the same name), so it is built as the vanilla type and + // registered where the client registers it. + bool standard = programName == "standard"; + ShaderProgram linked = standard + ? new ShaderProgramStandard { PassName = programName } + : new ShaderProgram { PassName = programName }; + Link(seam, linked, programName, standard + ? new[] { "projectionMatrix", "modelMatrix", "viewMatrix", "rgbaTint", "rgbaLightIn", "rgbaAmbientIn", "alphaTest", "dontWarpVertices" } + : new[] { "projectionMatrix" }); session.program = linked; + if (standard) + { + session.previousStandard = ShaderPrograms.Standard; + ShaderPrograms.Standard = (ShaderProgramStandard)linked; + session.registeredStandard = true; + } session.Mesh = platform.UploadMesh(BuildQuad()); return session; } @@ -423,6 +527,7 @@ private sealed class Session : IDisposable public void Dispose() { ShaderProgramBase.CurrentShaderProgram = null; + if (registeredStandard) ShaderPrograms.Standard = previousStandard!; // The mesh goes first: VAO's finalizer reaches for ScreenManager.Platform, which is // about to be the client's again, and a live handle there would crash the test host. if (Mesh != null) Platform.DeleteMesh(Mesh); @@ -473,6 +578,8 @@ public unsafe byte[][] RunFrame(bool native, bool blending, bool depth, bool mot seam.UseProgram(program.ProgramId); ShaderProgramBase.CurrentShaderProgram = program; seam.SetUniformMatrix(program.ProgramId, program.uniformLocations["projectionMatrix"], Identity); + SeedFrameGlobals(seam, program.ProgramId); + SeedDrawUniforms(seam, program.ProgramId); draw(this); @@ -482,6 +589,43 @@ public unsafe byte[][] RunFrame(bool native, bool blending, bool depth, bool mot return pixels; } + /// + /// The frame globals ShaderProgramBase.Use() would have written. RunFrame binds the program + /// directly, so without these the shared frame block stays zero - and a zero viewDistance + /// makes standard.vsh's distance fade a division by zero that discards every fragment, which + /// is how these comparisons were once equal without anything having been drawn. + /// + private static void SeedFrameGlobals(VulkanDevice seam, int programId) + { + foreach ((string name, float value) in new[] + { + ("zNear", 0.1f), ("zFar", 1000f), ("viewDistance", 1000f), ("viewDistanceLod0", 1000f), + }) + { + int location = seam.GetUniformLocation(programId, name); + if (location != -1) seam.SetUniform(programId, location, value); + } + } + + /// + /// Identity transforms and white light for whichever of them the program declares, so the + /// quad lands on the scene slot. Left at zero, the matrices collapse every vertex to one + /// point and the comparison is between two untouched attachments. + /// + private static void SeedDrawUniforms(VulkanDevice seam, int programId) + { + foreach (string matrix in new[] { "modelMatrix", "viewMatrix", "modelViewMatrix" }) + { + int location = seam.GetUniformLocation(programId, matrix); + if (location != -1) seam.SetUniformMatrix(programId, location, Identity); + } + foreach (string colour in new[] { "rgbaAmbientIn", "rgbaLightIn", "rgbaTint" }) + { + int location = seam.GetUniformLocation(programId, colour); + if (location != -1) seam.SetUniform(programId, location, 1f, 1f, 1f, 1f); + } + } + /// One attachment's pixels, read through a framebuffer that holds only it. private unsafe byte[] Read(VulkanDevice seam, int texture) { @@ -674,7 +818,7 @@ private static (string, string) BuildNativeShaders() var merged = new NativeShaderBuildResult(); merged.Manifest.Toolchain = compiler!.Identity; string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"); - foreach (string program in new[] { "nightsky", "celestialobject", "particlescube", "decals" }) + foreach (string program in new[] { "nightsky", "celestialobject", "particlescube", "decals", "standard" }) { NativeShaderBuildResult one = builder.Build(source, program); merged.Errors.AddRange(one.Errors); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index 69212c0e..363b53df 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -78,6 +78,14 @@ public partial class VulkanClientPlatform private readonly NativeMeshPass nativeCelestial = new("celestialobject", Array.Empty(), new[] { "tex", "sky", "glow" }); + /// + /// The sun's pipeline, through the standard program. Its samplers are resolved from the + /// pipeline's own declaration (), because standard also reads frame + /// textures a native draw has to name by handle. + /// + private readonly NativeMeshPass nativeSun = + new("standard", Array.Empty(), Array.Empty()); + /// The cube particle pool's pipeline: no per-draw uniform and no sampler at all. private readonly NativeMeshPass nativeParticlesCube = new("particlescube", Array.Empty(), Array.Empty()); @@ -289,6 +297,48 @@ public override void RenderCelestialQuad(MeshRef quad, int bodyTextureId, int sk NativeWorldEndPass(target, outer, outerFlags); } + /// + /// The sun's visible quad: the native pass, or the seam's neutral body. + /// What it draws: the sun disc of SystemRenderSunMoon.OnRenderFrame3D. The other side: + /// ClientPlatformAbstract.RenderSunQuad, whose neutral body is the RenderMesh it replaced. + /// Target and slots: the stage's bound target and . + /// State: blended in the standard mode, no depth test, no culling - SystemRenderSunMoon's + /// GlToggleBlend(on: true), GlDisableDepthTest and GlDisableCullFace, stated on the pipeline. + /// Only the registered vanilla standard program is taken: a mod can register its own program + /// under the same pass name (VSEssentials' first-person item shader does). + /// What pins it: NativeWorldSystemsTests.TheSunMatchesTheSeamsNeutralBody. + /// + public override void RenderSunQuad(MeshRef quad, int sunTextureId) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (program == null || !ReferenceEquals(program, ShaderPrograms.Standard) || + !NativeWorldPrepare(nativeSun, quad, blending: true, depth: false, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) + { + base.RenderSunQuad(quad, sunTextureId); + return; + } + + string[] names = pipeline.SamplerNames; + var textures = new NativeTexture[names.Length]; + var reads = new int[names.Length]; + for (int i = 0; i < names.Length; i++) + { + int id = names[i] == "tex" ? sunTextureId : DeclaredProgramTexture(program.ProgramId, names[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), id); + reads[i] = id; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + if (NativeWorldBeginPass("Sun", target, slots, reads)) + { + device.DrawNativeMesh(pipeline, vao.VaoId, textures); + } + NativeWorldEndPass(target, outer, outerFlags); + } + /// /// One particle pool's instanced draw: the native pass, or the seam's neutral body. /// diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 9a56c7a6..17605394 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -148,6 +148,7 @@ public partial class VulkanClientPlatform : ClientPlatformWindows // Phase 3b stage 2: the remaining sky, particle and decal draw seams. new(true, "RenderNightSkyBox", new[] { "MeshRef", "Int32" }), new(true, "RenderCelestialQuad", new[] { "MeshRef", "Int32", "Int32", "Int32" }), + new(true, "RenderSunQuad", new[] { "MeshRef", "Int32" }), new(true, "RenderParticles", new[] { "MeshRef", "Int32", "Int32" }), new(true, "BeginDecalPass", new[] { "Int32", "Int32" }), new(true, "EndDecalPass", Array.Empty()), diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index b068ed6b..befbcc1b 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -473,6 +473,8 @@ public void TheWorldSeamsHaveNeutralBodiesThatAreTheDrawsTheyReplaced() platform); Assert.Contains("RenderMesh(quad);", platform); + Assert.Contains("public virtual void RenderSunQuad(MeshRef quad, int sunTextureId)", platform); + Assert.Contains("public virtual void RenderParticles(MeshRef model, int quantity, int particleTextureId)", platform); Assert.Contains("RenderMeshInstanced(model, quantity);", platform); @@ -490,7 +492,7 @@ public void TheWorldSeamsHaveNeutralBodiesThatAreTheDrawsTheyReplaced() "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); foreach (string seam in new[] { - "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", + "RenderNightSkyBox", "RenderCelestialQuad", "RenderSunQuad", "RenderParticles", "BeginDecalPass", "EndDecalPass", }) { @@ -518,6 +520,9 @@ public void TheWorldRenderersDrawThroughTheirSeams() Assert.Contains( "platform.RenderCelestialQuad(quadModel, moontextureIds[4], game.skyTextureId, game.skyGlowTextureId);", sunMoon); + // The visible sun draws through its seam; the occlusion-query probe keeps RenderMesh, + // because a Vulkan occlusion query has to begin and end inside one render pass. + Assert.Contains("platform.RenderSunQuad(quadModel, suntextureId);", sunMoon); string particles = ReadPatchedOrSource( "patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderParticles.cs.patch", @@ -560,7 +565,7 @@ public void TheWorldSeamsAndTheirCallersAreListedForTheTransplant() string patcher = Read("Optimum.Patcher/Program.cs"); foreach (string seam in new[] { - "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", + "RenderNightSkyBox", "RenderCelestialQuad", "RenderSunQuad", "RenderParticles", "BeginDecalPass", "EndDecalPass", }) { @@ -593,7 +598,7 @@ public void TheVulkanPlatformRecordsTheWorldSystemsNativelyAndKeepsTheOldRoutes( Assert.Contains("internal bool NativeWorldEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_WORLD\") != \"0\";", world); foreach (string seam in new[] { - "RenderNightSkyBox", "RenderCelestialQuad", "RenderParticles", + "RenderNightSkyBox", "RenderCelestialQuad", "RenderSunQuad", "RenderParticles", }) { Assert.Contains("public override void " + seam + "(", world); diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index 46f23cf2..a4ec072b 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -143,6 +143,18 @@ vertex layout, blend and dynamic state on both routes, so the cause is not in th decision 1 mod programs belong on the adapter anyway, so the route admits vanilla programs only; the question stays open for when mod renderers get native passes. Repro: `OPTIMUM_VK_NATIVE_ENTITIES=all OPTIMUM_VK_NATIVE_SHADERS=force`, headless Vulkan, TAA on. +**Sun native (2026-09-16).** `ClientPlatformAbstract.RenderSunQuad` carries the visible sun; the occlusion-query +probe stays on `RenderMesh` (a Vulkan occlusion query has to begin and end inside one render pass). Vanilla +`ShaderPrograms.Standard` only. 33 native sun passes in a headless run, 0 errors, validation clean. +**The native world-system GPU tests were vacuous until 2026-09-16.** Every comparison in `NativeWorldSystemsTests` +compared two untouched attachments: the fixture never wrote the frame block (zero `viewDistance` discards every +`standard` fragment) or the transforms (zero model/view collapse every vertex). Seeded now, and the helper refuses a +comparison whose scene slot is still the clear. That exposed one fixture asymmetry (the decal test never bound the +atlases to units, as `ShaderProgramDecals` does) - fixed; decals match with real pixels. Still vacuous and marked: +the particle tests, whose quad carries no per-instance attributes. +**Residual Vulkan-vs-OpenGL difference is not from stage 2.** Bisected with the route switches, same session: +all stage-2 routes off 0.981/0.971/0.964; chunks only 0.982/0.965/0.969; world only 0.981/0.968/0.968; sky only +0.980/0.974/0.967; GUI only 0.977/0.963/0.970; entities only 0.986/0.980/0.972. Verified on the deployed build, both backends headless, TAA on: 0 client errors, validation 0 errors and 0 `SYNC-`, entity GPU tests 25 passed, coverage 28 passed. Scene SSIM Vulkan vs OpenGL 0.9631/0.9617/0.9658 against a same-session OpenGL floor of 0.9853/0.9750/0.9785; the frames match on inspection and the residual is run timing (the two runs diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 7c4ab243..fcd2574b 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..be13842 100644 +index d6eb844..b1ec207 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,723 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,741 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -242,6 +242,24 @@ index d6eb844..be13842 100644 + } + + /// ++ /// Optimum (Phase 3b decision 2, stage 2): the sun's visible quad. ++ /// What it draws: the sun disc in SystemRenderSunMoon.OnRenderFrame3D, through the standard program. ++ /// The other side: the neutral body below is the OpenGL path - exactly the RenderMesh call it ++ /// replaced, and ClientPlatformWindows does not override it - and VulkanClientPlatform.RenderSunQuad ++ /// is the native one. ++ /// Target and slots: whatever the stage bound (Primary), blended in the standard mode, no depth test, ++ /// no culling - the state SystemRenderSunMoon sets immediately before. ++ /// State that is not obvious: is passed because a native pass resolves ++ /// what it samples from handles. The occlusion-query probe in OnRenderFrame3DPost keeps RenderMesh: a ++ /// Vulkan occlusion query has to begin and end inside one render pass, and a native draw opens its own. ++ /// What pins it: NativeWorldSystemsTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderSunQuad(MeshRef quad, int sunTextureId) ++ { ++ RenderMesh(quad); ++ } ++ ++ /// + /// Optimum (Phase 3b decision 2, stage 2): one particle pool's instanced draw. + /// + /// What it draws: every live particle of one pool in a single instanced draw of the pool's diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch index 2a6afe66..0522b354 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs -index ac7f262..d3ae0f6 100644 +index ac7f262..cfb8ae6 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/SystemRenderSunMoon.cs @@ -52,11 +52,11 @@ public class SystemRenderSunMoon : ClientSystem @@ -70,7 +70,22 @@ index ac7f262..d3ae0f6 100644 public void OnRenderFrame3D(float dt) { ClientPlatformAbstract platform = game.Platform; -@@ -266,11 +263,14 @@ public class SystemRenderSunMoon : ClientSystem +@@ -207,11 +204,13 @@ public class SystemRenderSunMoon : ClientSystem + standard.AlphaTest = 0.01f; + standard.UniformMatrix("modelMatrix", ref sunmat); + standard.ViewMatrix = game.api.renderapi.CameraMatrixOriginf; + standard.ProjectionMatrix = game.api.renderapi.CurrentProjectionMatrix; + standard.Tex2D = suntextureId; +- platform.RenderMesh(quadModel); ++ // Optimum (Phase 3b stage 2): the visible sun through its seam; the neutral body is this ++ // RenderMesh. The occlusion-query probe in OnRenderFrame3DPost stays on RenderMesh. ++ platform.RenderSunQuad(quadModel, suntextureId); + standard.Uniform("skyShaded", 0); + standard.ExtraGodray = 0f; + standard.ApplySsao = 1; + standard.FadeFromSpheresFog = 0; + standard.Stop(); +@@ -266,11 +265,14 @@ public class SystemRenderSunMoon : ClientSystem celestialobject.ExtraGodray = 0.5f; celestialobject.UniformMatrix("modelMatrix", ref moonmat); celestialobject.ViewMatrix = game.api.renderapi.CameraMatrixOriginf; @@ -86,7 +101,7 @@ index ac7f262..d3ae0f6 100644 platform.GlToggleBlend(on: false); platform.GlEnableDepthTest(); } -@@ -420,11 +420,11 @@ public class SystemRenderSunMoon : ClientSystem +@@ -420,11 +422,11 @@ public class SystemRenderSunMoon : ClientSystem game.Platform.GLDeleteTexture(suntextureId); for (int i = 0; i < moontextureIds.Length; i++) { From 5abe877cdb83f8ca3d745c4a71042a65cda25614 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 15:41:24 +0200 Subject: [PATCH 208/226] docs: feature phase moves fast; measurement and test cleanup wait for the optimisation pass --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f134f627..6bc46d68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,8 +159,12 @@ an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. is genuinely the owner's (money, scope, upstream, destructive acts) and you cannot resolve it from what they already said. Turning an instruction they just gave you back into a question is the failure mode. -14. **Verification, not review rounds.** After each change: build, both suites, `make patch-il`, and for anything - that touches the screen the headless both-backends capture. No separate review passes. +14. **Feature phase: move fast (owner, 2026-09-16).** After each change: build, `make patch-il` when the lib or + patcher changed, the tests that cover what changed, and one quick in-game look when it touches the screen. + No full suites per change, no SSIM tables, no bisection runs, no harness work, no fixing old tests beyond what the + change breaks. Measurement, test cleanup and the harness belong to the optimisation/refactor pass at the end, + where half of it would be rewritten anyway. Rules 3 and 10 apply to that pass and to claims of "done" for the + branch, not to every intermediate step. 15. **Grep, don't map - and document the seams.** Every render seam carries a doc comment at its declaration: what it draws, where the OpenGL body is, target and slots, the state that is not obvious and why, and the test that From f1ce5f77cb769ea608490ce0ba4bf9469436c707 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 15:46:33 +0200 Subject: [PATCH 209/226] feat(native-world): quad particles draw natively in the OIT stage The quad pool goes through the native route under the Transparent target's blend contract the chunk route already records (weighted accumulation, revealage, glow), with depth test on and depth writes off - the state LoadFrameBuffer(Transparent) sets. Vanilla ShaderPrograms.Particlesquad only; falls back while the contract is unrecorded or the target is not Transparent. particleTex resolves from the program's declaration; particlesquad.fsh never samples it. Checked: build 0 errors, coverage 29 passed, deploy 207/207; headless Vulkan with precipitation forced on: 129 native OIT particle passes, 0 emulated quad particle draws, 0 client errors, validation 0 errors and 0 SYNC-, snowfall renders. --- .../VulkanClientPlatform.NativeWorld.cs | 87 +++++++++++++++++-- .../native-world-systems-coverage-tests.cs | 14 +++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index 363b53df..c2514204 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -86,6 +86,14 @@ public partial class VulkanClientPlatform private readonly NativeMeshPass nativeSun = new("standard", Array.Empty(), Array.Empty()); + /// + /// The quad particle pool's pipeline: drawn in the OIT stage onto the Transparent target, under + /// the blend contract the client applied to that target (the chunk route records it), with + /// depth test on and depth writes off - what LoadFrameBuffer(Transparent) sets. + /// + private readonly NativeMeshPass nativeParticlesQuad = + new("particlesquad", Array.Empty(), Array.Empty()); + /// The cube particle pool's pipeline: no per-draw uniform and no sampler at all. private readonly NativeMeshPass nativeParticlesCube = new("particlescube", Array.Empty(), Array.Empty()); @@ -163,7 +171,8 @@ private AttachmentBlend[] NativeWorldBlend(RenderTargetFormats formats, bool ble /// its seam's neutral body, which is always a legal answer. /// private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending, bool depth, - out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline) + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, + Func? blendFor = null, bool? depthWrite = null) { target = null!; vao = null!; @@ -192,9 +201,9 @@ private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending NativePipeline? built = NativeMeshPipelineFor(pass, program, bound.FboId, colorSlots, layoutId, new NativePipelineDescription { - Blend = NativeWorldBlend(formats, blending), + Blend = blendFor != null ? blendFor(formats.ColorFormats.Length) : NativeWorldBlend(formats, blending), DepthTest = depth, - DepthWrite = depth, + DepthWrite = depthWrite ?? depth, DepthCompare = CompareOp.Less, Cull = CullModeFlags.None, Topology = PrimitiveTopology.TriangleList, @@ -342,11 +351,10 @@ public override void RenderSunQuad(MeshRef quad, int sunTextureId) /// /// One particle pool's instanced draw: the native pass, or the seam's neutral body. /// - /// Only the cube pool takes the native route. The quad pool draws into the Transparent - /// target in the OIT stage, whose per-attachment weighted-blend state belongs to the OIT - /// pass rather than to the particle system, and which this seam cannot state; the pipeline - /// request names "particlescube", so a draw under any other program falls through to the - /// neutral body on its own rather than by a separate test. + /// Both pools take the native route. The cube pool draws on Primary here; the quad pool draws + /// in the OIT stage onto the Transparent target and goes through , + /// which states the Transparent blend contract the client applied. A draw under any other + /// program falls through to the neutral body: the pipeline request names the vanilla program. /// public override void RenderParticles(MeshRef model, int quantity, int particleTextureId) { @@ -356,6 +364,12 @@ public override void RenderParticles(MeshRef model, int quantity, int particleTe return; } + if (ReferenceEquals(ShaderProgramBase.CurrentShaderProgram, ShaderPrograms.Particlesquad)) + { + RenderQuadParticles(model, quantity, particleTextureId); + return; + } + // Blending on in the standard mode (the caller's GlToggleBlend) and the Opaque stage's // depth test and depth writes, which ChunkRenderer.RenderOpaque established and no // renderer between it and the particles turns off again. @@ -379,6 +393,63 @@ public override void RenderParticles(MeshRef model, int quantity, int particleTe NativeWorldEndPass(target, outer, outerFlags); } + /// + /// The quad particle pool's instanced draw in the OIT stage: the native pass, or the seam's + /// neutral body. The other side is ClientPlatformAbstract.RenderParticles' neutral body, drawn + /// under the state LoadFrameBuffer(Transparent) and ApplyTransparentPassBlendState left. + /// Target and slots: the Transparent target, every bound slot; the pipeline masks the outputs + /// particlesquad does not write. State: the Transparent blend contract the client last applied + /// (weighted accumulation, revealage, glow), depth test on, depth writes off, no culling. + /// Falls back while that contract has not been recorded or the bound target is not + /// Transparent. particleTex resolves from the program's declared texture: the lib binds it + /// through the program's setter and hands the seam 0. + /// + private void RenderQuadParticles(MeshRef model, int quantity, int particleTextureId) + { + FrameBufferRef bound = CurrentFrameBuffer; + AttachmentBlend[]? contract = nativeTransparentBlend; + if (bound == null || contract == null || !IsTransparentTarget(bound) || + !NativeWorldPrepare(nativeParticlesQuad, model, blending: true, depth: true, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, + count => + { + var blend = new AttachmentBlend[Math.Max(count, 1)]; + for (int i = 0; i < blend.Length; i++) + { + blend[i] = i < contract.Length ? contract[i] : AttachmentBlend.Default; + blend[i].Enabled = true; + } + return blend; + }, + depthWrite: false)) + { + base.RenderParticles(model, quantity, particleTextureId); + return; + } + + ShaderProgramBase program = ShaderProgramBase.CurrentShaderProgram!; + string[] names = pipeline.SamplerNames; + var textures = new NativeTexture[names.Length]; + var reads = new int[names.Length]; + for (int i = 0; i < names.Length; i++) + { + int id = names[i] == "particleTex" && particleTextureId != 0 + ? particleTextureId + : DeclaredProgramTexture(program.ProgramId, names[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), id); + reads[i] = id; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + if (NativeWorldBeginPass("ParticlesOit", target, slots, reads)) + { + device.DrawNativeMeshInstanced(pipeline, vao.VaoId, quantity, textures); + } + NativeWorldEndPass(target, outer, outerFlags); + } + // ------------------------------------------------------------------- the decal scope /// True between and . diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index befbcc1b..caba1710 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -830,4 +830,18 @@ private static string Read(string relativePath) => return null; } } + /// + /// The quad particle pool draws natively in the OIT stage under the Transparent target's + /// recorded blend contract, with depth writes off, and only for the vanilla program. + /// + [Fact] + public void TheQuadParticlePoolDrawsNativelyUnderTheTransparentContract() + { + string world = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs"); + Assert.Contains("ReferenceEquals(ShaderProgramBase.CurrentShaderProgram, ShaderPrograms.Particlesquad)", world); + Assert.Contains("AttachmentBlend[]? contract = nativeTransparentBlend;", world); + Assert.Contains("!IsTransparentTarget(bound)", world); + Assert.Contains("depthWrite: false", world); + Assert.Contains("NativeWorldBeginPass(\"ParticlesOit\"", world); + } } From 93b78fcf5c3ad84efb5dfa9ccc6a01a76cd22aa3 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 16:02:12 +0200 Subject: [PATCH 210/226] feat(native-gui): Render2DTexture quads draw natively; fix: native OIT passes dropped the accumulation slots GUI quads. New seam ClientPlatformAbstract.RenderGuiQuad for every Render2DTexture overload (four transplanted ClientMain methods, the two seven-parameter overloads told apart by parameter types). The native route takes the vanilla gui program and draws under the state the client last stated through the platform's own virtuals - blend on and mode, depth test, depth mask, depth function, scissor - recorded in VulkanClientPlatform.State.cs, never read off the tracker. Native passes gained an optional scissor. Headless: 992 native GUI quad passes per run, HUD renders; 360 gui draws remain emulated (the other GUI systems). OIT fix. BeginOitAccumulation selects six draw buffers and keeps the colour accumulation on slots 3-5, which the Transparent FrameBufferRef does not list. The native chunk OIT groups and the quad particles derived their slots from that texture count (0x7), so every native OIT draw darkened revealage and added no colour - black snowflakes were how it showed. The slot set is now recorded where the client selects it (0x7 in ApplyTransparentPassBlendState, 0x3F in BeginOitAccumulation) and used by both routes. Snow renders white again; chunk-oit-liquid, chunk-oit-transparent and the particle passes declare 0x3F. Checked: build 0 errors, Optimum.Tests 1274 passed, GUI/world/chunk GPU tests 26 passed, patches 159 with 0 conflicts, make patch-il 211/211, headless Vulkan 0 client errors and validation 0 errors / 0 SYNC-. --- Optimum.Patcher/Program.cs | 10 +++ .../VulkanClientPlatform.FrameBuffers.cs | 1 + .../Platform/VulkanClientPlatform.Leaf.cs | 1 + .../VulkanClientPlatform.NativeChunks.cs | 10 +++ .../VulkanClientPlatform.NativeGui.cs | 48 ++++++++++++-- .../VulkanClientPlatform.NativeWorld.cs | 1 + .../Platform/VulkanClientPlatform.State.cs | 22 +++++++ .../Platform/VulkanClientPlatform.cs | 1 + Optimum.Render.Vulkan/VulkanDevice.Native.cs | 8 ++- .../native-world-systems-coverage-tests.cs | 50 +++++++++++++- .../ClientMain.cs.patch | 65 +++++++++++++++++-- .../ClientPlatformAbstract.cs.patch | 24 ++++++- 12 files changed, 224 insertions(+), 17 deletions(-) diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 029f3ca6..90736393 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -100,6 +100,7 @@ "RenderNightSkyBox", "RenderCelestialQuad", "RenderSunQuad", + "RenderGuiQuad", "RenderParticles", // The decal pool draws through a scope seam, not a draw seam: the mesh handle stays // inside MeshDataPool (internal in the vanilla API), so the lib runs the vanilla @@ -877,6 +878,15 @@ // the tesselation worker thread. See // docs/implementation-plans/chunk-tesselator-worker-pool-wiring-plan-2026-08-10.md. new("Vintagestory.Client.NoObf.ClientMain", "Start", 0), + // Phase 3b stage 2: Render2DTexture's GUI quads draw through RenderGuiQuad. Two overloads + // share a parameter count, so every target names its parameter types. + new("Vintagestory.Client.NoObf.ClientMain", "Render2DTexture", 8, + new[] { "Vintagestory.API.Client.MeshRef", "System.Int32", "System.Single", "System.Single", "System.Single", "System.Single", "System.Single", "Vintagestory.API.MathTools.Vec4f" }), + new("Vintagestory.Client.NoObf.ClientMain", "Render2DTexture", 7, + new[] { "Vintagestory.API.Client.MultiTextureMeshRef", "System.Single", "System.Single", "System.Single", "System.Single", "System.Single", "Vintagestory.API.MathTools.Vec4f" }), + new("Vintagestory.Client.NoObf.ClientMain", "Render2DTexture", 3, + new[] { "System.Int32", "Vintagestory.API.Common.ModelTransform", "Vintagestory.API.MathTools.Vec4f" }), + new("Vintagestory.Client.NoObf.ClientMain", "Render2DTextureFlipped", 7), // SystemRenderPlayerEffects: dynamic light radius (lambda-free rewrite) new("Vintagestory.Client.NoObf.SystemRenderPlayerEffects", "onBeforeRender", 1), // ClientPlatformWindows: persistent mapped VBO and index uploads. ParameterTypes diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index ae28b2c3..d673dd0f 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -652,6 +652,7 @@ public override void ApplyTransparentPassBlendState() NoteNativeTransparentBlend(0, 32774, 1, 1, 1, 1); NoteNativeTransparentBlend(1, 32774, 0, 769, 0, 769); NoteNativeTransparentBlend(2, 32774, 770, 771, 770, 771); + nativeTransparentSlots = 7; } /// diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index d2ede8e1..cca109b3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -161,6 +161,7 @@ public override void BeginOitAccumulation(FrameBufferRef transparent) NoteNativeTransparentBlend(3, 32774, 1, 1, 1, 1); NoteNativeTransparentBlend(4, 32774, 1, 1, 1, 1); NoteNativeTransparentBlend(5, 32774, 1, 1, 1, 1); + nativeTransparentSlots = 0x3F; device.ClearColor(0, 1f, 1f, 1f, 1f); device.ClearColor(1, 1f, 1f, 1f, 1f); device.ClearColor(3, 0f, 0f, 0f, 0f); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs index ebd52565..b18e4f06 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs @@ -82,6 +82,15 @@ public partial class VulkanClientPlatform /// private AttachmentBlend[]? nativeTransparentBlend; + /// + /// The colour slots the client last selected on the Transparent target, recorded with the + /// blend contract: 0x7 for the vanilla set (ApplyTransparentPassBlendState), 0x3F for the OIT + /// accumulation set (BeginOitAccumulation), whose colour accumulation lives on slots 3-5 - + /// attachments the target's FrameBufferRef does not list, so its texture count is not the + /// slot set. 0 until the client has stated one. + /// + private uint nativeTransparentSlots; + /// Bumped when that contract changes, so its pipelines are rebuilt rather than reused. private int nativeBlendEpoch; @@ -339,6 +348,7 @@ private bool OpenChunkPass(FrameBufferRef target, int textureCount) /// private uint ChunkColorSlots(FrameBufferRef target) { + if (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots; if (!IsPrimaryTarget(target)) return NativeAllColorSlots(target); int motion = MotionAttachmentIndex; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index 6aa1bce5..ac2e11e1 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -85,6 +85,35 @@ public partial class VulkanClientPlatform private readonly NativeMeshPass nativeOverlayLines = new("gui", Array.Empty(), new[] { "tex2d", "tex2dOverlay" }); + /// + /// The GUI quads' pipeline and placements, on the gui program - a pass of its own so the + /// reticle's line pipeline and these triangle pipelines do not evict each other. + /// + private readonly NativeMeshPass nativeGuiQuad = + new("gui", Array.Empty(), new[] { "tex2d", "tex2dOverlay" }); + + /// + /// One Render2DTexture quad: the native pass, or the neutral body's RenderMesh. + /// What it draws: a GUI element's texture. The other side: ClientPlatformAbstract.RenderGuiQuad. + /// Target and slots: the caller's target, slot 0. State: the blend mode, depth test, depth + /// mask, depth function and scissor the caller last stated through this platform's virtuals + /// (VulkanClientPlatform.State.cs), so premultiplied-alpha blits and scrolled, clipped lists + /// draw as they do on GL. Only the vanilla gui program is taken. + /// + public override void RenderGuiQuad(MeshRef quad, int textureId) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (!NativeGuiEnabled || device == null || quad == null || program == null || + !ReferenceEquals(program, ShaderPrograms.Gui) || + !DrawNativeGuiMesh(nativeGuiQuad, quad, textureId, + DeclaredProgramTexture(program.ProgramId, "tex2dOverlay"), 1.0f, + statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite, + GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, "GuiQuad")) + { + base.RenderGuiQuad(quad!, textureId); + } + } + /// The texture-into-texture blit's draw: the native pass, or the neutral body's RenderMesh. public override void RenderTextureQuad(MeshRef quad, int textureId, bool blend) { @@ -126,6 +155,12 @@ public override void RenderOverlayLines(MeshRef lines, int textureId, float line /// private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, int overlayTextureId, float lineWidth, bool blend, string passLabel) + => DrawNativeGuiMesh(pass, mesh, textureId, overlayTextureId, lineWidth, blend, EnumBlendMode.Standard, + depthTest: false, depthWrite: false, CompareOp.Less, scissor: null, passLabel); + + private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, int overlayTextureId, + float lineWidth, bool blend, EnumBlendMode blendMode, bool depthTest, bool depthWrite, CompareOp depthCompare, + Rect2D? scissor, string passLabel) { FrameBufferRef target = CurrentFrameBuffer; ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; @@ -148,9 +183,10 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, NativePipeline? pipeline = NativeMeshPipelineFor(pass, program, framebufferId, slots, layoutId, new NativePipelineDescription { - Blend = GuiSlots(formats, blend), - DepthTest = false, - DepthWrite = false, + Blend = GuiSlots(formats, blend, blendMode), + DepthTest = depthTest, + DepthWrite = depthWrite, + DepthCompare = depthCompare, Cull = CullModeFlags.None, Topology = device.NativeMeshTopology(vao.VaoId), LineWidth = lineWidth, @@ -175,6 +211,7 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, ViewportY = viewport.Offset.Y, ViewportWidth = (int)viewport.Extent.Width, ViewportHeight = (int)viewport.Extent.Height, + Scissor = scissor, })) { Span textures = stackalloc NativeTexture[pass.Samplers.Length]; @@ -197,10 +234,11 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, /// the tracker's own factor table, and every other slot masked off so an attachment the /// fragment shader never writes keeps its contents as it does on GL (rule 9). /// - private static AttachmentBlend[] GuiSlots(RenderTargetFormats formats, bool blend) + private static AttachmentBlend[] GuiSlots(RenderTargetFormats formats, bool blend, + EnumBlendMode mode = EnumBlendMode.Standard) { var slots = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; - slots[0] = AttachmentBlend.For(blend, EnumBlendMode.Standard); + slots[0] = AttachmentBlend.For(blend, mode); for (int i = 1; i < slots.Length; i++) { slots[i] = AttachmentBlend.Default; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index c2514204..ccad3123 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -121,6 +121,7 @@ public partial class VulkanClientPlatform /// private uint NativeWorldPassColorSlots(FrameBufferRef target) { + if (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots; uint all = NativeAllColorSlots(target); int motion = MotionAttachmentIndex; if (motion < 0 || motion >= 32) return all; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs index 85bae0fb..3679c4b3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -1,3 +1,4 @@ +using Silk.NET.Vulkan; using System; using System.Runtime.InteropServices; using Cairo; @@ -134,9 +135,24 @@ public override void GlViewport(int x, int y, int width, int height) public override void GlScissor(int x, int y, int width, int height) { + // Clipped the way GlStateTracker.SetScissor clips, kept as client state for native passes. + int clippedX = Math.Max(0, x); + int clippedY = Math.Max(0, y); + statedScissor = new Rect2D(new Offset2D(clippedX, clippedY), + new Extent2D((uint)Math.Max(0, width - (clippedX - x)), (uint)Math.Max(0, height - (clippedY - y)))); device.SetScissor(x, y, width, height); } + // The fixed state the client last stated through this platform's own virtuals. A native pass + // that draws "with whatever the caller set" (the GUI quads) reads these - client statements, + // recorded where they are made - never the device's GL state tracker (Phase 3b decision 3). + private bool statedBlendOn; + private EnumBlendMode statedBlendMode = EnumBlendMode.Standard; + private bool statedDepthTest; + private bool statedDepthWrite = true; + private int statedDepthFunc = 513; // GL_LESS + private Rect2D statedScissor; + public override void GlScissorFlag(bool enable) { scissorEnabled = enable; @@ -145,11 +161,13 @@ public override void GlScissorFlag(bool enable) public override void GlEnableDepthTest() { + statedDepthTest = true; device.SetDepthTest(true); } public override void GlDisableDepthTest() { + statedDepthTest = false; device.SetDepthTest(false); } @@ -175,6 +193,8 @@ public override void UnBindTextureCubeMap() public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendMode.Standard) { + statedBlendOn = on; + statedBlendMode = blendMode; device.SetBlend(on, blendMode); if (on && OptimumRenderSsao) { @@ -222,6 +242,7 @@ public override void SmoothLines(bool on) public override void GlDepthMask(bool flag) { + statedDepthWrite = flag; device.SetDepthMask(flag); } @@ -230,6 +251,7 @@ public override void GlDepthFunc(EnumDepthFunction depthFunc) // EnumDepthFunction's values are the GL constants, which is the form // the seam takes: it cannot reference this enum, since it lives in // VintagestoryLib and the contracts assembly does not depend on it. + statedDepthFunc = (int)depthFunc; device.SetDepthFunc((int)depthFunc); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 17605394..8869ffbb 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -149,6 +149,7 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "RenderNightSkyBox", new[] { "MeshRef", "Int32" }), new(true, "RenderCelestialQuad", new[] { "MeshRef", "Int32", "Int32", "Int32" }), new(true, "RenderSunQuad", new[] { "MeshRef", "Int32" }), + new(true, "RenderGuiQuad", new[] { "MeshRef", "Int32" }), new(true, "RenderParticles", new[] { "MeshRef", "Int32", "Int32" }), new(true, "BeginDecalPass", new[] { "Int32", "Int32" }), new(true, "EndDecalPass", Array.Empty()), diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index eed8aa17..eabbcd76 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -212,6 +212,12 @@ internal sealed class NativePassDescription public int ViewportX; public int ViewportY; + /// + /// The scissor the client stated for this draw, in the target's own (GL-oriented) pixels; + /// null: the full target. A GUI draw inside a clipped dialog needs it. + /// + public Rect2D? Scissor; + /// Negative: the full target. public int ViewportWidth = -1; public int ViewportHeight = -1; @@ -867,7 +873,7 @@ private void EmitNativeDynamicState(CommandBuffer commandBuffer, VulkanFramebuff var values = new DynamicStateValues { Viewport = new Viewport(pass.ViewportX, pass.ViewportY, width, height, 0f, 1f), - Scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), + Scissor = pass.Scissor ?? new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), CullMode = description.Cull, FrontFace = description.FrontFace, Topology = description.Topology, diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index caba1710..853adea3 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -760,12 +760,15 @@ public void TheVulkanPlatformRecordsTheGuiSystemsNativelyAndKeepsTheOldRoute() // Fixed state the pass states, never reads back: the caller's blend through the one // factor table, the caller's line width, and the mesh's own topology and layout. - Assert.Contains("AttachmentBlend.For(blend, EnumBlendMode.Standard)", gui); + // The caller's blend mode, Standard unless a seam states another (the GUI quads do). + Assert.Contains("AttachmentBlend.For(blend, mode)", gui); + Assert.Contains("EnumBlendMode mode = EnumBlendMode.Standard", gui); Assert.Contains("LineWidth = lineWidth", gui); Assert.Contains("Topology = device.NativeMeshTopology(vao.VaoId)", gui); Assert.Contains("device.NativeMeshLayoutId(", gui); - Assert.Contains("DepthTest = false", gui); - Assert.Contains("DepthWrite = false", gui); + // The reticle and the texture blit state no depth; the GUI quads pass the caller's. + Assert.Contains("depthTest: false, depthWrite: false, CompareOp.Less, scissor: null", gui); + Assert.Contains("DepthTest = depthTest", gui); } /// @@ -844,4 +847,45 @@ public void TheQuadParticlePoolDrawsNativelyUnderTheTransparentContract() Assert.Contains("depthWrite: false", world); Assert.Contains("NativeWorldBeginPass(\"ParticlesOit\"", world); } + /// + /// Render2DTexture's quads draw through RenderGuiQuad, and the native route takes the blend, + /// depth and scissor the client stated through the platform's virtuals, never the tracker. + /// + [Fact] + public void TheGuiQuadsDrawThroughTheirSeamUnderTheStatedState() + { + string main = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + Assert.Contains("Platform.RenderGuiQuad(quadModel, textureid);", main); + Assert.Contains("Platform.RenderGuiQuad(vao, meshRef.textureids[i]);", main); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"RenderGuiQuad\"", patcher); + Assert.Contains("\"Render2DTextureFlipped\", 7", patcher); + + string gui = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs"); + Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Gui)", gui); + Assert.Contains("statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite", gui); + Assert.Contains("scissorEnabled ? statedScissor : null", gui); + + string state = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs"); + Assert.Contains("statedBlendMode = blendMode;", state); + Assert.Contains("statedDepthWrite = flag;", state); + } + /// + /// The Transparent target's colour slots are the set the client selected, not the target's + /// texture count: the OIT accumulation set keeps its colour accumulation on slots 3-5, which + /// the FrameBufferRef does not list. Dropping them made every native OIT draw add no colour. + /// + [Fact] + public void NativeOitPassesUseTheTransparentSlotSetTheClientSelected() + { + Assert.Contains("nativeTransparentSlots = 7;", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs")); + Assert.Contains("nativeTransparentSlots = 0x3F;", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs")); + string chunks = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs"); + Assert.Contains("if (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots;", chunks); + string world = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs"); + Assert.Contains("if (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots;", world); + } } diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index 2bb3b80f..5a24045c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 67feafa..0848a9b 100644 +index 67feafa..dc82ea9 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -505,7 +505,60 @@ index 67feafa..0848a9b 100644 texture2texture.Stop(); currentShaderProgram?.Use(); } -@@ -1420,10 +1679,14 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1343,11 +1602,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + GlScale(width, height, 0.0); + GlScale(0.5, 0.5, 0.0); + GlTranslate(1.0, 1.0, 0.0); + guiShaderProg.ProjectionMatrix = CurrentProjectionMatrix; + guiShaderProg.ModelViewMatrix = CurrentModelViewMatrix; +- Platform.RenderMesh(quadModel); ++ // Optimum (Phase 3b stage 2): the GUI quad seam; neutral body is this RenderMesh. ++ Platform.RenderGuiQuad(quadModel, textureid); + GlPopMatrix(); + } + + public void Render2DTexture(MultiTextureMeshRef meshRef, float x1, float y1, float width, float height, float z = 10f, Vec4f color = null) + { +@@ -1366,11 +1626,11 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + guiShaderProg.ModelViewMatrix = CurrentModelViewMatrix; + for (int i = 0; i < meshRef.meshrefs.Length; i++) + { + MeshRef vao = meshRef.meshrefs[i]; + guiShaderProg.BindTexture2D("tex2d", meshRef.textureids[i], 0); +- Platform.RenderMesh(vao); ++ Platform.RenderGuiQuad(vao, meshRef.textureids[i]); + } + GlPopMatrix(); + } + + public void Render2DTexture(int textureid, ModelTransform transform, Vec4f color = null) +@@ -1390,11 +1650,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + GlScale(transform.ScaleXYZ.X, transform.ScaleXYZ.Y, 0.0); + GlScale(0.5, 0.5, 0.0); + GlTranslate(1.0, 1.0, 0.0); + guiShaderProg.ProjectionMatrix = CurrentProjectionMatrix; + guiShaderProg.ModelViewMatrix = CurrentModelViewMatrix; +- Platform.RenderMesh(quadModel); ++ // Optimum (Phase 3b stage 2): the GUI quad seam; neutral body is this RenderMesh. ++ Platform.RenderGuiQuad(quadModel, textureid); + GlPopMatrix(); + } + + public void Render2DTextureFlipped(int textureid, float x1, float y1, float width, float height, float z = 10f, Vec4f color = null) + { +@@ -1410,20 +1671,25 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + GlScale(0.5, 0.5, 0.0); + GlTranslate(1.0, 1.0, 0.0); + GlRotate(180f, 1.0, 0.0, 0.0); + guiShaderProg.ProjectionMatrix = CurrentProjectionMatrix; + guiShaderProg.ModelViewMatrix = CurrentModelViewMatrix; +- Platform.RenderMesh(quadModel); ++ // Optimum (Phase 3b stage 2): the GUI quad seam; neutral body is this RenderMesh. ++ Platform.RenderGuiQuad(quadModel, textureid); + GlPopMatrix(); + } + + public void Set3DProjection(float zfar, float fov) { float num = (float)Platform.WindowSize.Width / (float)Platform.WindowSize.Height; Mat4d.Perspective(set3DProjectionTempMat4, fov, num, MainCamera.ZNear, zfar); @@ -520,7 +573,7 @@ index 67feafa..0848a9b 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1828,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1831,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -544,7 +597,7 @@ index 67feafa..0848a9b 100644 public void Connect() { Compression.Reset(); -@@ -2124,12 +2387,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2124,12 +2390,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -569,7 +622,7 @@ index 67feafa..0848a9b 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2543,10 +2816,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2543,10 +2819,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -583,7 +636,7 @@ index 67feafa..0848a9b 100644 } public void DoReconnect() -@@ -3531,6 +3807,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3531,6 +3810,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index fcd2574b..6c70971c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index d6eb844..b1ec207 100644 +index d6eb844..8cb0f04 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,741 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,761 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -260,6 +260,26 @@ index d6eb844..b1ec207 100644 + } + + /// ++ /// Optimum (Phase 3b decision 2, stage 2): one 2D GUI quad of ClientMain.Render2DTexture and its ++ /// overloads, through the gui program. ++ /// What it draws: every dialog, icon and text surface the GUI tree blits onto the screen. ++ /// The other side: the neutral body below is the OpenGL path - the RenderMesh call it replaced, ++ /// and ClientPlatformWindows does not override it - and VulkanClientPlatform.RenderGuiQuad is ++ /// the native one. ++ /// Target and slots: whatever the caller bound (the default framebuffer in the Ortho stage, or ++ /// a GUI render target), colour slot 0. ++ /// State that is not obvious: blend mode, depth test, depth mask and scissor are whatever the ++ /// caller stated before - standard alpha for most dialogs, premultiplied alpha under ++ /// Render2DTexturePremultipliedAlpha, a scissor for scrolled lists. ++ /// is passed because a native pass resolves what it samples from handles. ++ /// What pins it: NativeGuiTests and Optimum.Tests/native-world-systems-coverage-tests.cs. ++ /// ++ public virtual void RenderGuiQuad(MeshRef quad, int textureId) ++ { ++ RenderMesh(quad); ++ } ++ ++ /// + /// Optimum (Phase 3b decision 2, stage 2): one particle pool's instanced draw. + /// + /// What it draws: every live particle of one pool in a single instanced draw of the pool's From fe8c6e06e2d7aea106e201b144f5ae3e47003d01 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 16:05:36 +0200 Subject: [PATCH 211/226] feat(native-gui): every draw under the vanilla gui program goes native VulkanClientPlatform.RenderMesh takes any draw under ShaderPrograms.Gui natively - block highlights, the wireframe, gear and progress overlays, mods drawing with the gui shader through IRenderAPI.RenderMesh - under the state the client stated through the platform's virtuals, now including cull mode and line width, with the program's declared textures. It honours NativeGuiEnabled, so the seams' neutral bodies and the old-route switch still reach the emulated draw. Checked: build 0 errors, GUI GPU tests 7 passed, coverage 32 passed, deploy; headless Vulkan: 992 native GUI quads and 8367 other native gui draws per run, 0 emulated gui draws, 0 client errors, validation 0 errors / 0 SYNC-, HUD renders. --- .../Platform/VulkanClientPlatform.Meshes.cs | 6 +++ .../VulkanClientPlatform.NativeGui.cs | 40 +++++++++++++++++-- .../Platform/VulkanClientPlatform.State.cs | 8 ++++ .../native-world-systems-coverage-tests.cs | 18 +++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 0734e19c..2c30cf1d 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -63,6 +63,12 @@ public override void RenderMesh(MeshRef modelRef) } throw new ArgumentException("Fatal: Trying to render a disposed mesh"); } + // Phase 3b stage 2: a draw under the vanilla gui program goes native (NativeGui.cs). + if (TryRenderGuiMeshNative(modelRef)) + { + RuntimeStats.drawCallsCount--; // DrawNativeGuiMesh counted it already + return; + } device.DrawMesh(vAO.VaoId); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index ac2e11e1..1f14a1d9 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -114,6 +114,40 @@ public override void RenderGuiQuad(MeshRef quad, int textureId) } } + /// + /// Any other draw under the vanilla gui program - block highlights, the wireframe, gear and + /// progress overlays, mods drawing with the gui shader through IRenderAPI.RenderMesh - through + /// its own pass cache, so line and triangle pipelines of different callers do not evict the + /// quads'. + /// + private readonly NativeMeshPass nativeGuiMesh = + new("gui", Array.Empty(), new[] { "tex2d", "tex2dOverlay" }); + + /// + /// A plain RenderMesh under the vanilla gui program, recorded natively under the state the + /// client stated: blend, depth, depth function, scissor, cull, line width. The sampled + /// textures are the program's declared ones. False: the caller runs the emulated draw. + /// Called from VulkanClientPlatform.RenderMesh; the seams' neutral bodies reach it too, which + /// is why it honours itself. + /// + private bool TryRenderGuiMeshNative(MeshRef mesh) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (!NativeGuiEnabled || device == null || mesh == null || program == null || + !ReferenceEquals(program, ShaderPrograms.Gui)) + { + return false; + } + CullModeFlags cull = statedCull + ? (statedCullBack ? CullModeFlags.BackBit : CullModeFlags.FrontBit) + : CullModeFlags.None; + return DrawNativeGuiMesh(nativeGuiMesh, mesh, + DeclaredProgramTexture(program.ProgramId, "tex2d"), + DeclaredProgramTexture(program.ProgramId, "tex2dOverlay"), + statedLineWidth, statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite, + GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, "GuiMesh", cull); + } + /// The texture-into-texture blit's draw: the native pass, or the neutral body's RenderMesh. public override void RenderTextureQuad(MeshRef quad, int textureId, bool blend) { @@ -156,11 +190,11 @@ public override void RenderOverlayLines(MeshRef lines, int textureId, float line private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, int overlayTextureId, float lineWidth, bool blend, string passLabel) => DrawNativeGuiMesh(pass, mesh, textureId, overlayTextureId, lineWidth, blend, EnumBlendMode.Standard, - depthTest: false, depthWrite: false, CompareOp.Less, scissor: null, passLabel); + depthTest: false, depthWrite: false, CompareOp.Less, scissor: null, passLabel, CullModeFlags.None); private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, int overlayTextureId, float lineWidth, bool blend, EnumBlendMode blendMode, bool depthTest, bool depthWrite, CompareOp depthCompare, - Rect2D? scissor, string passLabel) + Rect2D? scissor, string passLabel, CullModeFlags cull = CullModeFlags.None) { FrameBufferRef target = CurrentFrameBuffer; ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; @@ -187,7 +221,7 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, DepthTest = depthTest, DepthWrite = depthWrite, DepthCompare = depthCompare, - Cull = CullModeFlags.None, + Cull = cull, Topology = device.NativeMeshTopology(vao.VaoId), LineWidth = lineWidth, }); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs index 3679c4b3..68e8c5de 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -152,6 +152,9 @@ public override void GlScissor(int x, int y, int width, int height) private bool statedDepthWrite = true; private int statedDepthFunc = 513; // GL_LESS private Rect2D statedScissor; + private bool statedCull; + private bool statedCullBack = true; + private float statedLineWidth = 1f; public override void GlScissorFlag(bool enable) { @@ -218,16 +221,19 @@ public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendM public override void GlDisableCullFace() { + statedCull = false; device.SetCullFace(false); } public override void GlEnableCullFace() { + statedCull = true; device.SetCullFace(true); } public override void GLLineWidth(float width) { + statedLineWidth = width; device.SetLineWidth(width); } @@ -257,11 +263,13 @@ public override void GlDepthFunc(EnumDepthFunction depthFunc) public override void GlCullFaceBack() { + statedCullBack = true; device.SetCullFaceMode(true); } public override void GlCullFaceFront() { + statedCullBack = false; device.SetCullFaceMode(false); } diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 853adea3..806f03e9 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -888,4 +888,22 @@ public void NativeOitPassesUseTheTransparentSlotSetTheClientSelected() string world = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs"); Assert.Contains("if (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots;", world); } + /// + /// Every other draw under the vanilla gui program goes native from the platform's own + /// RenderMesh, under the client-stated state including cull and line width, and the route + /// switch still sends it back to the emulated draw. + /// + [Fact] + public void PlainGuiProgramDrawsGoNativeFromRenderMesh() + { + string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); + Assert.Contains("if (TryRenderGuiMeshNative(modelRef))", meshes); + string gui = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs"); + Assert.Contains("private bool TryRenderGuiMeshNative(MeshRef mesh)", gui); + Assert.Contains("if (!NativeGuiEnabled || device == null || mesh == null || program == null", gui); + Assert.Contains("statedLineWidth, statedBlendOn, statedBlendMode", gui); + string state = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs"); + Assert.Contains("statedCull = true;", state); + Assert.Contains("statedLineWidth = width;", state); + } } From fbf9e777c25758f374996354bf13b4e12bbc2c1c Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 16:09:29 +0200 Subject: [PATCH 212/226] feat(native-world): plain draws under the vanilla standard program go native VulkanClientPlatform.RenderMesh takes any draw under ShaderPrograms.Standard on a world target natively - held and dropped items, block entity models - under the state the client stated (blend, depth test and mask, depth function, cull) with GlToggleBlend's exceptions applied per attachment: SSAO G-buffer slots and the open motion attachment replace, the Transparent target keeps its recorded OIT contract. Samplers resolve from the program's declared textures. Never inside an occlusion query (a Vulkan query must begin and end inside one render pass - the sun probe keeps the emulated draw) and never on the default framebuffer (GUI item icons stay emulated for now). Checked: build 0 errors, coverage 33 passed, world/GUI GPU tests passed, deploy; headless Vulkan: 360 native standard draws, 0 emulated, 0 client errors, validation 0 errors / 0 SYNC-, frame free of artefacts against the old route. --- .../Platform/VulkanClientPlatform.Leaf.cs | 8 ++ .../Platform/VulkanClientPlatform.Meshes.cs | 1 + .../VulkanClientPlatform.NativeWorld.cs | 94 ++++++++++++++++++- .../native-world-systems-coverage-tests.cs | 18 ++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index cca109b3..0c305e53 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -183,11 +183,19 @@ public override int GenOcclusionQuery() public override void BeginOcclusionQuery(int queryId) { + occlusionQueryOpen = true; device.BeginOcclusionQuery(queryId); } + /// + /// True between Begin and EndOcclusionQuery. A Vulkan occlusion query has to begin and end + /// inside one render pass, so no native draw - which opens its own pass - may run in between. + /// + private bool occlusionQueryOpen; + public override void EndOcclusionQuery(int queryId) { + occlusionQueryOpen = false; device.EndOcclusionQuery(queryId); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 2c30cf1d..c6bc9527 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -69,6 +69,7 @@ public override void RenderMesh(MeshRef modelRef) RuntimeStats.drawCallsCount--; // DrawNativeGuiMesh counted it already return; } + if (TryRenderStandardMeshNative(modelRef)) return; device.DrawMesh(vAO.VaoId); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index ccad3123..84cf581b 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -94,6 +94,10 @@ public partial class VulkanClientPlatform private readonly NativeMeshPass nativeParticlesQuad = new("particlesquad", Array.Empty(), Array.Empty()); + /// Any plain RenderMesh under the vanilla standard program (RenderStandardMeshNative). + private readonly NativeMeshPass nativeStandardMesh = + new("standard", Array.Empty(), Array.Empty()); + /// The cube particle pool's pipeline: no per-draw uniform and no sampler at all. private readonly NativeMeshPass nativeParticlesCube = new("particlescube", Array.Empty(), Array.Empty()); @@ -173,7 +177,8 @@ private AttachmentBlend[] NativeWorldBlend(RenderTargetFormats formats, bool ble /// private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending, bool depth, out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, - Func? blendFor = null, bool? depthWrite = null) + Func? blendFor = null, bool? depthWrite = null, + CompareOp? depthCompare = null, CullModeFlags? cull = null) { target = null!; vao = null!; @@ -205,8 +210,8 @@ private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending Blend = blendFor != null ? blendFor(formats.ColorFormats.Length) : NativeWorldBlend(formats, blending), DepthTest = depth, DepthWrite = depthWrite ?? depth, - DepthCompare = CompareOp.Less, - Cull = CullModeFlags.None, + DepthCompare = depthCompare ?? CompareOp.Less, + Cull = cull ?? CullModeFlags.None, Topology = PrimitiveTopology.TriangleList, }); if (built == null) return false; @@ -451,6 +456,89 @@ private void RenderQuadParticles(MeshRef model, int quantity, int particleTextur NativeWorldEndPass(target, outer, outerFlags); } + /// + /// The per-attachment blend a world draw inherits from what the client stated: the stated + /// mode on every slot, with GlToggleBlend's own exceptions - the SSAO G-buffer slots and the + /// open motion attachment replace rather than blend - and on the Transparent target the + /// recorded OIT contract with the stated enable. + /// + private AttachmentBlend[] StatedWorldBlend(FrameBufferRef target, int count) + { + var blend = new AttachmentBlend[Math.Max(count, 1)]; + AttachmentBlend[]? contract = nativeTransparentBlend; + bool transparent = contract != null && IsTransparentTarget(target); + bool primary = IsPrimaryTarget(target); + int motion = primary && OptimumMotionWriteActive ? MotionAttachmentIndex : -1; + for (int i = 0; i < blend.Length; i++) + { + if (transparent) + { + blend[i] = i < contract!.Length ? contract[i] : AttachmentBlend.Default; + blend[i].Enabled = statedBlendOn; + } + else if (primary && statedBlendOn && OptimumRenderSsao && (i == 2 || i == 3)) + { + blend[i] = ReplaceBlend(true); + } + else + { + blend[i] = AttachmentBlend.For(statedBlendOn, statedBlendMode); + } + if (i == motion) blend[i] = ReplaceBlend(statedBlendOn); + } + return blend; + } + + /// + /// A plain RenderMesh under the vanilla standard program - held and dropped items, block + /// entity models, the sun's disc outside its seam - recorded natively under the state the + /// client stated: blend, depth test, depth mask, depth function, cull. Every sampler the + /// pipeline declares resolves from the program's declared textures. False: the caller runs the + /// emulated draw. Skipped while an occlusion query is open (the sun probe) and for the default + /// framebuffer (GUI item icons stay on the emulated route for now). + /// + private bool TryRenderStandardMeshNative(MeshRef mesh) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (!NativeWorldEnabled || device == null || mesh == null || program == null || occlusionQueryOpen || + !ReferenceEquals(program, ShaderPrograms.Standard) || CurrentFrameBuffer == null) + { + return false; + } + + FrameBufferRef bound = CurrentFrameBuffer; + CullModeFlags cull = statedCull + ? (statedCullBack ? CullModeFlags.BackBit : CullModeFlags.FrontBit) + : CullModeFlags.None; + if (!NativeWorldPrepare(nativeStandardMesh, mesh, blending: statedBlendOn, depth: statedDepthTest, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, + count => StatedWorldBlend(bound, count), depthWrite: statedDepthWrite, + depthCompare: GlEnums.CompareOpFrom(statedDepthFunc), cull: cull)) + { + return false; + } + + string[] names = pipeline.SamplerNames; + var textures = new NativeTexture[names.Length]; + var reads = new int[names.Length]; + for (int i = 0; i < names.Length; i++) + { + int id = DeclaredProgramTexture(program.ProgramId, names[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), id); + reads[i] = id; + } + + string outer = passContext; + PassFlags outerFlags = passContextFlags; + bool drawn = false; + if (NativeWorldBeginPass("Standard", target, slots, reads)) + { + drawn = device.DrawNativeMesh(pipeline, vao.VaoId, textures); + } + NativeWorldEndPass(target, outer, outerFlags); + return drawn; + } + // ------------------------------------------------------------------- the decal scope /// True between and . diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 806f03e9..3dbec267 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -906,4 +906,22 @@ public void PlainGuiProgramDrawsGoNativeFromRenderMesh() Assert.Contains("statedCull = true;", state); Assert.Contains("statedLineWidth = width;", state); } + /// + /// Plain draws under the vanilla standard program go native from RenderMesh under the stated + /// state and the world blend exceptions, but never inside an occlusion query (a Vulkan query + /// must begin and end inside one render pass) and never on the default framebuffer. + /// + [Fact] + public void PlainStandardProgramDrawsGoNativeOutsideOcclusionQueries() + { + Assert.Contains("if (TryRenderStandardMeshNative(modelRef)) return;", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs")); + string world = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs"); + Assert.Contains("occlusionQueryOpen ||", world); + Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Standard) || CurrentFrameBuffer == null", world); + Assert.Contains("private AttachmentBlend[] StatedWorldBlend(FrameBufferRef target, int count)", world); + string leaf = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs"); + Assert.Contains("occlusionQueryOpen = true;", leaf); + Assert.Contains("occlusionQueryOpen = false;", leaf); + } } From 440ea1979805baf21f4f3f413046fed306f4134c Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 16:26:56 +0200 Subject: [PATCH 213/226] feat(native-gui): the main menu's 2D particles draw natively RenderMeshInstanced under the vanilla particlesquad2d program becomes an instanced native pass on the bound target (the default framebuffer in the menu) with the state the client stated. DrawNativeGuiMesh gains an instance count. Checked: build 0 errors, deploy; headless Vulkan: 1766 native 2D particle passes, 0 emulated, validation 0 errors / 0 SYNC-, no client errors; menu screenshot on Vulkan renders the background and particles. --- .../Platform/VulkanClientPlatform.Meshes.cs | 1 + .../VulkanClientPlatform.NativeGui.cs | 30 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index c6bc9527..5b93ee57 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -106,6 +106,7 @@ public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { RuntimeStats.drawCallsCount++; VAO vAO = (VAO)modelRef; + if (TryRenderParticles2dNative(modelRef, quantity)) { RuntimeStats.drawCallsCount--; return; } device.DrawMeshInstanced(vAO.VaoId, quantity); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index 1f14a1d9..8d5dcb78 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -120,6 +120,32 @@ public override void RenderGuiQuad(MeshRef quad, int textureId) /// its own pass cache, so line and triangle pipelines of different callers do not evict the /// quads'. /// + private readonly NativeMeshPass nativeParticles2d = + new("particlesquad2d", Array.Empty(), new[] { "particleTex" }); + + /// + /// The main menu's 2D particle pool (ParticleRenderer2D.Render -> RenderMeshInstanced under the + /// vanilla particlesquad2d program) as a native instanced pass on whatever target is bound - + /// the default framebuffer in the menu. The OpenGL side is ClientPlatformWindows.RenderMeshInstanced. + /// State is what the client stated: blend on in the non-OIT mode (GlToggleBlend), depth test and + /// mask as left by the menu. particleTex resolves from the program's declared texture. + /// + private bool TryRenderParticles2dNative(MeshRef mesh, int quantity) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (!NativeGuiEnabled || device == null || mesh == null || program == null || quantity <= 0 || + !ReferenceEquals(program, ShaderPrograms.Particlesquad2d)) + { + return false; + } + + return DrawNativeGuiMesh(nativeParticles2d, mesh, + DeclaredProgramTexture(program.ProgramId, "particleTex"), 0, + statedLineWidth, statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite, + GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, "Particles2d", + CullModeFlags.None, quantity); + } + private readonly NativeMeshPass nativeGuiMesh = new("gui", Array.Empty(), new[] { "tex2d", "tex2dOverlay" }); @@ -194,7 +220,7 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, int overlayTextureId, float lineWidth, bool blend, EnumBlendMode blendMode, bool depthTest, bool depthWrite, CompareOp depthCompare, - Rect2D? scissor, string passLabel, CullModeFlags cull = CullModeFlags.None) + Rect2D? scissor, string passLabel, CullModeFlags cull = CullModeFlags.None, int instanceCount = 1) { FrameBufferRef target = CurrentFrameBuffer; ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; @@ -251,7 +277,7 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, Span textures = stackalloc NativeTexture[pass.Samplers.Length]; textures[0] = new NativeTexture(pass.Samplers[0], textureId); if (textures.Length > 1) textures[1] = new NativeTexture(pass.Samplers[1], overlayTextureId); - recorded = device.DrawNativeMesh(pipeline, vao.VaoId, textures); + recorded = device.DrawNativeMeshInstanced(pipeline, vao.VaoId, instanceCount, textures); } device.EndNativePass(); From dc566231785c9fefd4268d6b6bb0d3b1e2ead71e Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Wed, 16 Sep 2026 16:49:03 +0200 Subject: [PATCH 214/226] feat(native-world): the standard program goes native on every target, the sun probe included - the colour mask the client stated (GlColorMask) is recorded and applied per slot on the native standard route; - draws into the default framebuffer (GUI item icons) get their own native pass (TryRenderStandardMeshToDefault) with the stated state and scissor; - the sun's occlusion probe no longer falls back: a native pass opens its scope through the target manager, whose scope hooks suspend and resume the open query. - coverage assertion for the 2D particle route updated to the instanced draw. Checked: build 0 errors, coverage 33 passed, GPU world/GUI/query tests 21 passed, deploy; headless Vulkan: remaining emulated draws are guigear, cloudmap, cloudvolumetric, the first-person hand and the loading screen only; validation 0 errors / 0 SYNC-. The spawn view in the save currently renders white on both routes (all native routes off gives the same frame), so no visual comparison of the world was possible in that scene. --- .../Platform/VulkanClientPlatform.Leaf.cs | 8 -- .../VulkanClientPlatform.NativeWorld.cs | 90 ++++++++++++++++++- .../Platform/VulkanClientPlatform.State.cs | 8 ++ .../native-world-systems-coverage-tests.cs | 20 +++-- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index 0c305e53..cca109b3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -183,19 +183,11 @@ public override int GenOcclusionQuery() public override void BeginOcclusionQuery(int queryId) { - occlusionQueryOpen = true; device.BeginOcclusionQuery(queryId); } - /// - /// True between Begin and EndOcclusionQuery. A Vulkan occlusion query has to begin and end - /// inside one render pass, so no native draw - which opens its own pass - may run in between. - /// - private bool occlusionQueryOpen; - public override void EndOcclusionQuery(int queryId) { - occlusionQueryOpen = false; device.EndOcclusionQuery(queryId); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index 84cf581b..7f0285f7 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -95,6 +95,9 @@ public partial class VulkanClientPlatform new("particlesquad", Array.Empty(), Array.Empty()); /// Any plain RenderMesh under the vanilla standard program (RenderStandardMeshNative). + private readonly NativeMeshPass nativeStandardGui = + new("standard", Array.Empty(), Array.Empty()); + private readonly NativeMeshPass nativeStandardMesh = new("standard", Array.Empty(), Array.Empty()); @@ -485,6 +488,7 @@ private AttachmentBlend[] StatedWorldBlend(FrameBufferRef target, int count) blend[i] = AttachmentBlend.For(statedBlendOn, statedBlendMode); } if (i == motion) blend[i] = ReplaceBlend(statedBlendOn); + blend[i].WriteMask &= ~statedColorMaskOff; } return blend; } @@ -494,14 +498,17 @@ private AttachmentBlend[] StatedWorldBlend(FrameBufferRef target, int count) /// entity models, the sun's disc outside its seam - recorded natively under the state the /// client stated: blend, depth test, depth mask, depth function, cull. Every sampler the /// pipeline declares resolves from the program's declared textures. False: the caller runs the - /// emulated draw. Skipped while an occlusion query is open (the sun probe) and for the default - /// framebuffer (GUI item icons stay on the emulated route for now). + /// emulated draw. The colour mask the client stated is applied per slot. An open occlusion + /// query (the sun probe) is carried across the native pass: the pass opens its scope through + /// the target manager, whose scope hooks suspend the query in the closing scope and resume it + /// in the native one. The default framebuffer (GUI item icons) goes to + /// . /// private bool TryRenderStandardMeshNative(MeshRef mesh) { ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; - if (!NativeWorldEnabled || device == null || mesh == null || program == null || occlusionQueryOpen || - !ReferenceEquals(program, ShaderPrograms.Standard) || CurrentFrameBuffer == null) + if (!NativeWorldEnabled || device == null || mesh == null || program == null || + !ReferenceEquals(program, ShaderPrograms.Standard)) { return false; } @@ -510,6 +517,10 @@ private bool TryRenderStandardMeshNative(MeshRef mesh) CullModeFlags cull = statedCull ? (statedCullBack ? CullModeFlags.BackBit : CullModeFlags.FrontBit) : CullModeFlags.None; + if (bound == null) + { + return TryRenderStandardMeshToDefault(program, mesh, cull); + } if (!NativeWorldPrepare(nativeStandardMesh, mesh, blending: statedBlendOn, depth: statedDepthTest, out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, count => StatedWorldBlend(bound, count), depthWrite: statedDepthWrite, @@ -539,6 +550,77 @@ private bool TryRenderStandardMeshNative(MeshRef mesh) return drawn; } + /// + /// A standard-program draw into the default framebuffer: the GUI's item icons (hotbar, + /// inventory, held-item slots), which InventoryItemRenderer draws in the Ortho stage with + /// CurrentFrameBuffer null. The OpenGL side is ClientPlatformWindows.RenderMesh. Slot 0 takes + /// the stated blend through the tracker's factor table; depth test, mask, function, cull and + /// scissor are what the client stated (item icons use depth to sort their own faces). + /// + private AttachmentBlend[] StatedGuiSlots(RenderTargetFormats formats) + { + AttachmentBlend[] slots = GuiSlots(formats, statedBlendOn, statedBlendMode); + slots[0].WriteMask &= ~statedColorMaskOff; + return slots; + } + + private bool TryRenderStandardMeshToDefault(ShaderProgramBase program, MeshRef mesh, CullModeFlags cull) + { + var vao = mesh as VAO; + if (vao == null || vao.VaoId == 0 || vao.Disposed) return false; + int framebufferId = PassDeclaration.DefaultFramebuffer; + int layoutId = device.NativeMeshLayoutId(vao.VaoId); + if (layoutId < 0) return false; + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, 1u); + if (formats == null) return false; + + NativePipeline? pipeline = NativeMeshPipelineFor(nativeStandardGui, program, framebufferId, 1u, layoutId, + new NativePipelineDescription + { + Blend = StatedGuiSlots(formats), + DepthTest = statedDepthTest, + DepthWrite = statedDepthWrite, + DepthCompare = GlEnums.CompareOpFrom(statedDepthFunc), + Cull = cull, + Topology = device.NativeMeshTopology(vao.VaoId), + }); + if (pipeline == null) return false; + + string[] names = pipeline.SamplerNames; + var textures = new NativeTexture[names.Length]; + var reads = new int[names.Length]; + for (int i = 0; i < names.Length; i++) + { + int id = DeclaredProgramTexture(program.ProgramId, names[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), id); + reads[i] = id; + } + + string outer = passContext; + PassFlags outerFlags = passContextFlags; + Rect2D viewport = device.NativeCurrentViewport; + bool drawn = false; + if (device.BeginNativePass(new NativePassDescription + { + Name = "StandardGui/" + framebufferId, + FramebufferId = framebufferId, + ColorSlots = 1u, + Reads = reads, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + Scissor = scissorEnabled ? statedScissor : null, + })) + { + drawn = device.DrawNativeMesh(pipeline, vao.VaoId, textures); + } + device.EndNativePass(); + SetPassContext(outer, outerFlags); + return drawn; + } + // ------------------------------------------------------------------- the decal scope /// True between and . diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs index 68e8c5de..d80eb1fc 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -298,8 +298,16 @@ public override void GlStencilOp(int sfail, int dpfail, int dppass) device.SetStencilOp(sfail, dpfail, dppass); } + /// + /// The colour channels the client last masked off with GlColorMask (None = all written), + /// stated for native draws: the sun's occlusion probe draws with every channel off. + /// + private ColorComponentFlags statedColorMaskOff; + public override void GlColorMask(bool r, bool g, bool b, bool a) { + statedColorMaskOff = (r ? 0 : ColorComponentFlags.RBit) | (g ? 0 : ColorComponentFlags.GBit) | + (b ? 0 : ColorComponentFlags.BBit) | (a ? 0 : ColorComponentFlags.ABit); device.SetColorMask(r, g, b, a); } diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 3dbec267..2cf0928c 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -755,7 +755,7 @@ public void TheVulkanPlatformRecordsTheGuiSystemsNativelyAndKeepsTheOldRoute() Assert.Contains("base.RenderTextureQuad(", gui); Assert.Contains("base.RenderOverlayLines(", gui); Assert.Contains("device.BeginNativePass(", gui); - Assert.Contains("device.DrawNativeMesh(", gui); + Assert.Contains("device.DrawNativeMeshInstanced(pipeline, vao.VaoId, instanceCount, textures)", gui); Assert.Contains("device.EndNativePass();", gui); // Fixed state the pass states, never reads back: the caller's blend through the one @@ -908,20 +908,22 @@ public void PlainGuiProgramDrawsGoNativeFromRenderMesh() } /// /// Plain draws under the vanilla standard program go native from RenderMesh under the stated - /// state and the world blend exceptions, but never inside an occlusion query (a Vulkan query - /// must begin and end inside one render pass) and never on the default framebuffer. + /// state (colour mask included), on world targets and on the default framebuffer, and the sun's + /// occlusion probe with them: the query rides the target manager's scope hooks. /// [Fact] - public void PlainStandardProgramDrawsGoNativeOutsideOcclusionQueries() + public void PlainStandardProgramDrawsGoNativeOnEveryTarget() { Assert.Contains("if (TryRenderStandardMeshNative(modelRef)) return;", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs")); string world = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs"); - Assert.Contains("occlusionQueryOpen ||", world); - Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Standard) || CurrentFrameBuffer == null", world); + Assert.DoesNotContain("occlusionQueryOpen", world); + Assert.Contains("return TryRenderStandardMeshToDefault(program, mesh, cull);", world); + Assert.Contains("blend[i].WriteMask &= ~statedColorMaskOff;", world); Assert.Contains("private AttachmentBlend[] StatedWorldBlend(FrameBufferRef target, int count)", world); - string leaf = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs"); - Assert.Contains("occlusionQueryOpen = true;", leaf); - Assert.Contains("occlusionQueryOpen = false;", leaf); + Assert.Contains("statedColorMaskOff =", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs")); + Assert.Contains("_targets.ScopeClosing = _queryRing.OnScopeClosing;", + Read("Optimum.Render.Vulkan/VulkanDevice.cs")); } } From 94c806ba4d176cc0f3dbaf8044fd50134a9c1580 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 14:18:59 +0200 Subject: [PATCH 215/226] feat(native-gui): the early loading screen's quads draw natively RenderMesh under the platform's hardcoded ShaderProgramMinimalGui (no pass name; MainMenuRenderAPI.Render2DTexture before the shader registry is up) goes through DrawNativeGuiMesh with one sampler and the stated state. The drawer compares a null pass name as "". Checked: build 0 errors, coverage 34 passed, GPU GUI tests 7 passed, deploy; headless Vulkan: 10 MinimalGui trace lines, emulated draws left are guigear, cloudmap, cloudvolumetric and the first-person hand only; validation 0 errors / 0 SYNC-. Not checked visually: the quads show for a few startup frames. --- .../Platform/VulkanClientPlatform.Meshes.cs | 5 ++++ .../VulkanClientPlatform.NativeGui.cs | 27 ++++++++++++++++++- .../native-world-systems-coverage-tests.cs | 15 +++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 5b93ee57..b2a75e8d 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -69,6 +69,11 @@ public override void RenderMesh(MeshRef modelRef) RuntimeStats.drawCallsCount--; // DrawNativeGuiMesh counted it already return; } + if (TryRenderMinimalGuiNative(modelRef)) + { + RuntimeStats.drawCallsCount--; // DrawNativeGuiMesh counted it already + return; + } if (TryRenderStandardMeshNative(modelRef)) return; device.DrawMesh(vAO.VaoId); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index 8d5dcb78..1a2a7a1c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -146,6 +146,30 @@ private bool TryRenderParticles2dNative(MeshRef mesh, int quantity) CullModeFlags.None, quantity); } + private readonly NativeMeshPass nativeMinimalGui = + new("", Array.Empty(), new[] { "tex2d" }); + + /// + /// The early loading screen's quads: MainMenuRenderAPI.Render2DTexture draws through the + /// platform's hardcoded ShaderProgramMinimalGui (no pass name, no asset) until the shader + /// registry is up, and that RenderMesh lands here. The OpenGL side is + /// ClientPlatformWindows.RenderMesh. One sampler (tex2d), the state the client stated. + /// + private bool TryRenderMinimalGuiNative(MeshRef mesh) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (!NativeGuiEnabled || device == null || mesh == null || program == null || + !ReferenceEquals(program, MinimalGuiShader)) + { + return false; + } + + return DrawNativeGuiMesh(nativeMinimalGui, mesh, + DeclaredProgramTexture(program.ProgramId, "tex2d"), 0, + statedLineWidth, statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite, + GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, "MinimalGui"); + } + private readonly NativeMeshPass nativeGuiMesh = new("gui", Array.Empty(), new[] { "tex2d", "tex2dOverlay" }); @@ -226,7 +250,8 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; var vao = mesh as VAO; if (program == null || vao == null || vao.VaoId == 0 || vao.Disposed) return false; - if (!string.Equals(program.PassName, pass.PassName, StringComparison.Ordinal)) return false; + // ShaderProgramMinimalGui has no pass name; its pass is named "". + if (!string.Equals(program.PassName ?? "", pass.PassName, StringComparison.Ordinal)) return false; // The Ortho stage draws into the default framebuffer, which has no FrameBufferRef of // its own (ClientPlatformWindows.LoadFrameBuffer sets CurrentFrameBuffer null for it); diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 2cf0928c..0cd7c2d3 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -926,4 +926,19 @@ public void PlainStandardProgramDrawsGoNativeOnEveryTarget() Assert.Contains("_targets.ScopeClosing = _queryRing.OnScopeClosing;", Read("Optimum.Render.Vulkan/VulkanDevice.cs")); } + /// + /// The early loading screen's quads under the platform's hardcoded ShaderProgramMinimalGui + /// (no pass name) and the menu's 2D particles go native from RenderMesh / RenderMeshInstanced. + /// + [Fact] + public void LoadingScreenAndMenuParticleDrawsGoNative() + { + string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); + Assert.Contains("if (TryRenderMinimalGuiNative(modelRef))", meshes); + Assert.Contains("if (TryRenderParticles2dNative(modelRef, quantity))", meshes); + string gui = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs"); + Assert.Contains("!ReferenceEquals(program, MinimalGuiShader)", gui); + Assert.Contains("string.Equals(program.PassName ?? \"\", pass.PassName, StringComparison.Ordinal)", gui); + Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Particlesquad2d)", gui); + } } From 67f5f1b989495dc1694d5f6440160b666286f9d5 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 14:26:05 +0200 Subject: [PATCH 216/226] feat(native): the temporal gear and atlas self-blits draw natively - guigear (HudHotbar's temporal stability gear, a plain RenderMesh of the GUI quad) takes the single-sampler GUI mesh route with MinimalGui; - a native draw that samples a colour attachment of its own target takes the pooled ReadSelf copy the emulated route takes (SnapshotColorAttachment) instead of being refused, and the bindless resolve samples the copy. This is the atlas composition: BlendedTextureManager and RenderTextureIntoFrameBuffer copy one atlas region into another of the same atlas. A non-colour attachment of the own target is still refused. Checked: build 0 errors; GPU: new self-blit differential test (left half copied into the right half, native equals emulated, 2 native draws, 0 emulation calls, validation clean), 298 native/feedback GPU tests passed; coverage 34 passed; deploy; headless Vulkan: emulated draws left are cloudmap, cloudvolumetric and the first-person hand only; validation 0 errors / 0 SYNC-; gear crop matches the old route's look. --- Optimum.Render.Vulkan.Tests/NativeGuiTests.cs | 73 +++++++++++++++++++ .../VulkanClientPlatform.NativeGui.cs | 26 ++++++- Optimum.Render.Vulkan/VulkanDevice.Native.cs | 30 +++++--- .../native-world-systems-coverage-tests.cs | 3 +- 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs index fb7654ce..433b7fe8 100644 --- a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs @@ -176,6 +176,35 @@ public unsafe void EveryFreshTextureResolvesIntoTheFrameArenaAndBuildsNoNewPipel GpuTest.AssertClean(session.Seam); } + /// + /// An atlas composition samples the texture it writes (BlendedTextureManager copies one atlas + /// region into another region of the same atlas). The native pass takes the same pooled + /// ReadSelf copy the emulated route takes, instead of refusing the draw: two native mesh + /// draws, no emulation, the same pixels, validation clean. + /// + [SkippableFact] + public unsafe void TheNativeTextureBlitReadsItsOwnTargetThroughACopy() + { + using Session session = Open(); + + byte[] emulated = session.RunSelfBlit(native: false); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] native = session.RunSelfBlit(native: true); + + Assert.Equal(2, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + + output.WriteLine("self blit row emulated " + Row(emulated) + " native " + Row(native)); + Assert.Equal(emulated, native); + // The right half is the copied left half, not the clear colour. + int left = (Size / 2 * Size + 1) * 4; + int right = (Size / 2 * Size + Size / 2 + 1) * 4; + Assert.Equal(emulated[left + 1], emulated[right + 1]); + GpuTest.AssertClean(session.Seam); + } + // ---------------------------------------------------------------------- driving private static string Centre(byte[] pixels) @@ -358,6 +387,50 @@ public unsafe byte[] RunTextureQuad(bool native, bool blend, int textureId = 0) return pixels; } + /// + /// One frame of an atlas composition: the gradient blitted over the whole target, then + /// the target's left half blitted into its right half with the target's own texture as + /// the source, blending off (BlendedTextureManager's base copy). + /// + public unsafe byte[] RunSelfBlit(bool native) + { + VulkanDevice seam = Seam; + Platform.NativeGuiEnabled = native; + + Platform.BeginFrame(); + BeginTarget(seam); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.UseProgram(blit.ProgramId); + ShaderProgramBase.CurrentShaderProgram = blit; + + SetRects(seam, 0f, 1f, 0f, 1f); + seam.BindTexture(blit.uniformLocations.Count, SourceTexture); + Platform.RenderTextureQuad(Quad, SourceTexture, false); + + int own = Target.ColorTextureIds[0]; + SetRects(seam, 0.5f, 0.5f, 0f, 0.5f); + seam.BindTexture(blit.uniformLocations.Count, own); + Platform.RenderTextureQuad(Quad, own, false); + seam.BindTexture(blit.uniformLocations.Count, SourceTexture); + + byte[] pixels = Read(seam); + Platform.EndFrame(); + return pixels; + } + + private void SetRects(VulkanDevice seam, float xs, float width, float texu, float texw) + { + Set(seam, blit, "xs", xs); + Set(seam, blit, "ys", 0f); + Set(seam, blit, "width", width); + Set(seam, blit, "height", 1f); + Set(seam, blit, "texu", texu); + Set(seam, blit, "texv", 0f); + Set(seam, blit, "texw", texw); + Set(seam, blit, "texh", 1f); + Set(seam, blit, "alphaTest", -1f); + } + /// /// One frame at the point SystemRenderPlayerAimAcc reaches one of its draws: the gui /// program current with noTexture set, blending on, the line width it just chose. diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index 1a2a7a1c..ff567303 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -149,7 +149,12 @@ private bool TryRenderParticles2dNative(MeshRef mesh, int quantity) private readonly NativeMeshPass nativeMinimalGui = new("", Array.Empty(), new[] { "tex2d" }); + private readonly NativeMeshPass nativeGuiGear = + new("guigear", Array.Empty(), new[] { "tex2d" }); + /// + /// Single-sampler GUI quads drawn through plain RenderMesh. The temporal stability gear: + /// HudHotbar draws capi.Gui.QuadMeshRef under the vanilla guigear program in the Ortho stage. /// The early loading screen's quads: MainMenuRenderAPI.Render2DTexture draws through the /// platform's hardcoded ShaderProgramMinimalGui (no pass name, no asset) until the shader /// registry is up, and that RenderMesh lands here. The OpenGL side is @@ -158,16 +163,29 @@ private bool TryRenderParticles2dNative(MeshRef mesh, int quantity) private bool TryRenderMinimalGuiNative(MeshRef mesh) { ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; - if (!NativeGuiEnabled || device == null || mesh == null || program == null || - !ReferenceEquals(program, MinimalGuiShader)) + if (!NativeGuiEnabled || device == null || mesh == null || program == null) return false; + + NativeMeshPass pass; + string label; + if (ReferenceEquals(program, MinimalGuiShader)) + { + pass = nativeMinimalGui; + label = "MinimalGui"; + } + else if (ReferenceEquals(program, ShaderPrograms.Guigear)) + { + pass = nativeGuiGear; + label = "GuiGear"; + } + else { return false; } - return DrawNativeGuiMesh(nativeMinimalGui, mesh, + return DrawNativeGuiMesh(pass, mesh, DeclaredProgramTexture(program.ProgramId, "tex2d"), 0, statedLineWidth, statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite, - GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, "MinimalGui"); + GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, label); } private readonly NativeMeshPass nativeGuiMesh = diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index eabbcd76..19d8964d 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -710,11 +710,14 @@ private bool BeginNativeDraw(NativePipeline pipeline, ReadOnlySpan Date: Thu, 17 Sep 2026 14:38:34 +0200 Subject: [PATCH 217/226] feat(native-world): the forked cloud renderers draw natively - VulkanForkGraphics records the depth test, blend and raw framebuffer binds it forwards, on the platform, so a native route runs with the fork's state; - cloudmap draws into the fork's own framebuffer (formats from the attachments its SetDrawBuffers enabled), blending and depth off; - cloudvolumetric draws onto the Transparent target under the OIT contract, samples Primary's depth as the bound depth (no depth writes, as GL with the depth test off) and resolves liquidDepth to the LiquidDepth target's depth: resolved to 0 it replaced the frame's liquid depth with a placeholder, which drew no clouds and an orange halo around the sun; - OPTIMUM_VK_NATIVE_CLOUDS=0 keeps both on the emulated route; - NativeWorldPrepare takes samplesBoundDepth. Checked: build 0 errors, coverage 35 passed, GPU world tests 13 passed, deploy; headless Vulkan, cumulus at noon, AO debug view off for the run: native clouds against OpenGL SSIM 0.969 (cloud route off: 0.879), cloud patch and sun match OpenGL by eye; validation 0 errors / 0 SYNC-; only the first-person hand is still emulated. --- .../Platform/VulkanClientPlatform.Meshes.cs | 5 + .../VulkanClientPlatform.NativeClouds.cs | 187 ++++++++++++++++++ .../VulkanClientPlatform.NativeWorld.cs | 3 +- .../Platform/VulkanClientPlatform.cs | 2 +- .../Platform/VulkanForkGraphics.cs | 33 +++- .../native-world-systems-coverage-tests.cs | 23 +++ 6 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index b2a75e8d..42e56458 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -74,6 +74,11 @@ public override void RenderMesh(MeshRef modelRef) RuntimeStats.drawCallsCount--; // DrawNativeGuiMesh counted it already return; } + if (TryRenderCloudsNative(modelRef)) + { + RuntimeStats.drawCallsCount--; // the cloud pass counted it already + return; + } if (TryRenderStandardMeshNative(modelRef)) return; device.DrawMesh(vAO.VaoId); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs new file mode 100644 index 00000000..d6fc28f9 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs @@ -0,0 +1,187 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// The cloud renderers of the forked VSEssentials (Systems/Weather/Newclouds), drawn natively. +// +// Both renderers end in a plain capi.Render.RenderMesh(quad), which lands in RenderMesh here; +// their state goes through OptimumForkGraphics, whose Vulkan implementation (VulkanForkGraphics) +// records it on this platform next to forwarding it to the device. The OpenGL side is the same +// fork code's GL branch plus ClientPlatformWindows.RenderMesh. +// +// - cloudmap (CloudRendererMap.OnRenderFrame): a fullscreen quad into the renderer's own device +// framebuffer (tile and colour attachments), bound by id through the fork surface, blending +// and depth test off. The target has no FrameBufferRef; the pass names the device id. +// - cloudvolumetric (CloudRendererVolumetric.OnRenderFrame, OIT stage): a fullscreen quad onto +// the Transparent target under the OIT contract, depth test off, sampling Primary's depth - +// which the Transparent target borrows as its own depth attachment, so the pipeline declares +// SamplesBoundDepth and writes no depth (GL writes none with the depth test off either). +// +// Pinned by Optimum.Tests/native-world-systems-coverage-tests.cs. +public partial class VulkanClientPlatform +{ + /// False keeps both cloud draws on the emulated route (OPTIMUM_VK_NATIVE_CLOUDS=0). + internal bool NativeCloudsEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_CLOUDS") != "0"; + + private readonly NativeMeshPass nativeCloudMap = + new("cloudmap", Array.Empty(), Array.Empty()); + + private readonly NativeMeshPass nativeCloudVolumetric = + new("cloudvolumetric", Array.Empty(), Array.Empty()); + + /// + /// The device framebuffer a fork renderer bound through OptimumForkGraphics.BindFramebuffer + /// and has not unbound yet; 0 when the fork's binding is the platform's current target again + /// (its restore) or the default framebuffer. + /// + private int forkFramebuffer; + + internal void NoteForkFramebuffer(int framebufferId) + { + FrameBufferRef current = CurrentFrameBuffer; + forkFramebuffer = current != null && current.FboId == framebufferId ? 0 : framebufferId; + } + + internal void NoteForkDepthTest(bool enabled) => statedDepthTest = enabled; + + internal void NoteForkBlend(bool enabled) => statedBlendOn = enabled; + + /// A cloud renderer's RenderMesh: the native pass, or false for the emulated draw. + private bool TryRenderCloudsNative(MeshRef mesh) + { + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (!NativeWorldEnabled || !NativeCloudsEnabled || device == null || mesh == null || program == null) + { + return false; + } + + string? name = program.PassName; + if (name != "cloudmap" && name != "cloudvolumetric") return false; + // The program the registry holds under that name, which is the fork's own registration. + if (!ReferenceEquals(program, ShaderRegistry.getProgramByName(name))) return false; + + return name == "cloudmap" ? DrawCloudMapNative(program, mesh) : DrawCloudVolumetricNative(program, mesh); + } + + private bool DrawCloudMapNative(ShaderProgramBase program, MeshRef mesh) + { + var vao = mesh as VAO; + int framebufferId = forkFramebuffer; + if (vao == null || vao.VaoId == 0 || vao.Disposed || framebufferId <= 0) return false; + + int layoutId = device.NativeMeshLayoutId(vao.VaoId); + if (layoutId < 0) return false; + // The attachments the fork's SetDrawBuffers enabled decide the scope's formats. + RenderTargetFormats? all = device.NativeTargetFormats(framebufferId, uint.MaxValue); + if (all == null || all.ColorFormats.Length == 0) return false; + uint slots = all.ColorFormats.Length >= 32 ? uint.MaxValue : (1u << all.ColorFormats.Length) - 1u; + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, slots); + if (formats == null) return false; + + NativePipeline? pipeline = NativeMeshPipelineFor(nativeCloudMap, program, framebufferId, slots, layoutId, + new NativePipelineDescription + { + Blend = OpaqueSlots(formats), + DepthTest = false, + DepthWrite = false, + Cull = CullModeFlags.None, + Topology = device.NativeMeshTopology(vao.VaoId), + }); + if (pipeline == null) return false; + + ResolveDeclaredSamplers(program, pipeline, out NativeTexture[] textures, out int[] reads); + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + Rect2D viewport = device.NativeCurrentViewport; + bool drawn = false; + if (device.BeginNativePass(new NativePassDescription + { + Name = "CloudMap/" + framebufferId, + FramebufferId = framebufferId, + ColorSlots = slots, + Reads = reads, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + })) + { + drawn = device.DrawNativeMesh(pipeline, vao.VaoId, textures); + } + device.EndNativePass(); + // The fork still considers its framebuffer bound until its own restore. + device.BindFramebuffer(framebufferId); + SetPassContext(outer, outerFlags); + return drawn; + } + + private bool DrawCloudVolumetricNative(ShaderProgramBase program, MeshRef mesh) + { + FrameBufferRef bound = CurrentFrameBuffer; + if (forkFramebuffer != 0 || bound == null || nativeTransparentBlend == null || !IsTransparentTarget(bound) || + statedDepthTest) + { + return false; + } + + if (!NativeWorldPrepare(nativeCloudVolumetric, mesh, blending: statedBlendOn, depth: false, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, + count => StatedWorldBlend(bound, count), depthWrite: false, samplesBoundDepth: true)) + { + return false; + } + + ResolveDeclaredSamplers(program, pipeline, out NativeTexture[] textures, out int[] reads); + // liquidDepth is not bound through the program: SystemRenderOITLayers points the sampler + // at unit 4 by value, and the GL draw reads whatever the liquid pass left there - the + // LiquidDepth target's depth texture. The native draw names that texture, since a + // frame-bound sampler resolved to 0 would replace the frame's liquid depth with a + // placeholder and cut every cloud off at the near plane. + string[] names = pipeline.SamplerNames; + for (int i = 0; i < names.Length; i++) + { + if (names[i] != "liquidDepth" || textures[i].TextureId != 0) continue; + FrameBufferRef? liquid = FrameBuffers is { Count: > (int)EnumFrameBuffer.LiquidDepth } + ? FrameBuffers[(int)EnumFrameBuffer.LiquidDepth] + : null; + if (liquid == null) return false; + textures[i] = new NativeTexture(textures[i].Sampler, liquid.DepthTextureId); + reads[i] = liquid.DepthTextureId; + } + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + bool drawn = false; + if (NativeWorldBeginPass("CloudVolumetric", target, slots, reads)) + { + drawn = device.DrawNativeMesh(pipeline, vao.VaoId, textures); + } + NativeWorldEndPass(target, outer, outerFlags); + return drawn; + } + + /// Every sampler the pipeline declares, resolved from the program's declared textures. + private void ResolveDeclaredSamplers(ShaderProgramBase program, NativePipeline pipeline, + out NativeTexture[] textures, out int[] reads) + { + string[] names = pipeline.SamplerNames; + textures = new NativeTexture[names.Length]; + reads = new int[names.Length]; + for (int i = 0; i < names.Length; i++) + { + int id = DeclaredProgramTexture(program.ProgramId, names[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), id); + reads[i] = id; + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index 7f0285f7..85434b2b 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -181,7 +181,7 @@ private AttachmentBlend[] NativeWorldBlend(RenderTargetFormats formats, bool ble private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending, bool depth, out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline, Func? blendFor = null, bool? depthWrite = null, - CompareOp? depthCompare = null, CullModeFlags? cull = null) + CompareOp? depthCompare = null, CullModeFlags? cull = null, bool samplesBoundDepth = false) { target = null!; vao = null!; @@ -216,6 +216,7 @@ private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending DepthCompare = depthCompare ?? CompareOp.Less, Cull = cull ?? CullModeFlags.None, Topology = PrimitiveTopology.TriangleList, + SamplesBoundDepth = samplesBoundDepth, }); if (built == null) return false; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 8869ffbb..f478d4e9 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -324,7 +324,7 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig // Phase 5: registered mod motion writers reach this platform's motion window. InstallModPassHooks(); OptimumRender.ActiveBackend = EnumRenderBackend.Vulkan; - OptimumForkGraphics.Active = new VulkanForkGraphics(device); + OptimumForkGraphics.Active = new VulkanForkGraphics(this, device); return true; } catch (Exception error) diff --git a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs index d30433de..f8cbe948 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs @@ -10,13 +10,20 @@ namespace Optimum.Render.Vulkan.Platform; /// need beyond IRenderAPI, forwarded unchanged to the platform's device. The forks /// reference only the API and the contracts, so this is how they reach the device until /// Phase 5 ports them. +/// +/// The state operations also record what the fork stated on the platform, as the platform's +/// own state virtuals do (VulkanClientPlatform.State.cs), so a native route that draws the +/// fork's RenderMesh (the cloud renderers, VulkanClientPlatform.NativeClouds.cs) runs with the +/// state the fork set and the target it bound, never with the GL state tracker's. /// internal sealed class VulkanForkGraphics : OptimumForkGraphics { private readonly VulkanDevice device; + private readonly VulkanClientPlatform platform; - public VulkanForkGraphics(VulkanDevice device) + public VulkanForkGraphics(VulkanClientPlatform platform, VulkanDevice device) { + this.platform = platform; this.device = device; } @@ -48,17 +55,33 @@ public override void AttachTexture(int framebufferId, EnumFramebufferAttachment public override void SetDrawBuffers(int framebufferId, int attachmentMask) => device.SetDrawBuffers(framebufferId, attachmentMask); - public override void BindFramebuffer(int framebufferId) => device.BindFramebuffer(framebufferId); + public override void BindFramebuffer(int framebufferId) + { + platform.NoteForkFramebuffer(framebufferId); + device.BindFramebuffer(framebufferId); + } - public override void BindDefaultFramebuffer() => device.BindDefaultFramebuffer(); + public override void BindDefaultFramebuffer() + { + platform.NoteForkFramebuffer(0); + device.BindDefaultFramebuffer(); + } public override void DeleteFramebuffer(int framebufferId) => device.DeleteFramebuffer(framebufferId); public override void SetViewport(int x, int y, int width, int height) => device.SetViewport(x, y, width, height); - public override void SetDepthTest(bool enabled) => device.SetDepthTest(enabled); + public override void SetDepthTest(bool enabled) + { + platform.NoteForkDepthTest(enabled); + device.SetDepthTest(enabled); + } - public override void SetBlendEnabled(bool enabled) => device.SetBlendEnabled(enabled); + public override void SetBlendEnabled(bool enabled) + { + platform.NoteForkBlend(enabled); + device.SetBlendEnabled(enabled); + } public override int GetUniformLocation(int programId, string name) => device.GetUniformLocation(programId, name); diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 75a0ed12..21271fc1 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -942,4 +942,27 @@ public void LoadingScreenAndMenuParticleDrawsGoNative() Assert.Contains("string.Equals(program.PassName ?? \"\", pass.PassName, StringComparison.Ordinal)", gui); Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Particlesquad2d)", gui); } + /// + /// The forked cloud renderers draw natively: their OptimumForkGraphics state is recorded on + /// the platform, cloudmap draws into the framebuffer the fork bound, and cloudvolumetric + /// samples Primary's depth as its bound depth and resolves liquidDepth to the LiquidDepth + /// target instead of a placeholder. + /// + [Fact] + public void TheForkCloudRenderersDrawNativelyUnderTheStateTheForkStated() + { + string fork = Read("Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs"); + Assert.Contains("platform.NoteForkFramebuffer(framebufferId);", fork); + Assert.Contains("platform.NoteForkDepthTest(enabled);", fork); + Assert.Contains("platform.NoteForkBlend(enabled);", fork); + Assert.Contains("new VulkanForkGraphics(this, device)", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs")); + Assert.Contains("if (TryRenderCloudsNative(modelRef))", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs")); + string clouds = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs"); + Assert.Contains("ReferenceEquals(program, ShaderRegistry.getProgramByName(name))", clouds); + Assert.Contains("depthWrite: false, samplesBoundDepth: true", clouds); + Assert.Contains("FrameBuffers[(int)EnumFrameBuffer.LiquidDepth]", clouds); + Assert.Contains("OPTIMUM_VK_NATIVE_CLOUDS", clouds); + } } From b85e3f02c3fcfa89cfba70597c092dcba7b77e25 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 14:46:23 +0200 Subject: [PATCH 218/226] feat(native-entities): the first-person hands draw natively; every draw is native The entity route admits the program the shader registry holds under the entity pass names - VSEssentials' first-person hand program replaces the registry entry for entityanimated. The registry is only asked about a program it registered (PassId set), since its type initializer rewrites ShaderPrograms.*; the cloud route uses the same check. The 2026-09-16 fault (the arm drawn several times too large with TAA on) no longer reproduces with its repro (OPTIMUM_VK_NATIVE_ENTITIES=all, OPTIMUM_VK_NATIVE_SHADERS=force, TAA on): parity dump of the hand region at frame 300 against the neutral body - depth identical, motion within 0.0023, G-buffer within 0.0039. The cause was never named; recorded in the progress doc. Checked: build 0 errors, coverage 35 passed, GPU entity/world/GUI tests 27 passed, deploy; headless Vulkan with default route settings and native shaders forced: 0 emulated draws, 58 native Opaque entity draws, validation 0 errors / 0 SYNC-, no exceptions. --- .../VulkanClientPlatform.NativeClouds.cs | 2 +- .../VulkanClientPlatform.NativeEntities.cs | 32 +++++++++++++------ .../native-world-systems-coverage-tests.cs | 6 +++- docs/vulkan-branch-progress.md | 11 ++++++- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs index d6fc28f9..98107dd9 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs @@ -64,7 +64,7 @@ private bool TryRenderCloudsNative(MeshRef mesh) string? name = program.PassName; if (name != "cloudmap" && name != "cloudvolumetric") return false; // The program the registry holds under that name, which is the fork's own registration. - if (!ReferenceEquals(program, ShaderRegistry.getProgramByName(name))) return false; + if (!IsRegistryProgram(program)) return false; return name == "cloudmap" ? DrawCloudMapNative(program, mesh) : DrawCloudVolumetricNative(program, mesh); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs index 329a3f11..a62290d1 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs @@ -169,23 +169,37 @@ private static bool IsNativeEntityProgram(ShaderProgramBase program) // that; if one ever did, the neutral body keeps it correct instead of silently losing it. if (program.customSamplers.Count != 0 || program.clampTToEdge) return false; - // Vanilla programs only (Phase 3b decision 1: mod renderers stay on the adapter). A mod can - // register its own program under the same pass name - VSEssentials' first-person hands - // (ModSystemFpHands.fpModeHandShader) is an entityanimated of its own with its own Animation - // and, under TAA, AnimationPrev blocks - and that program drew the arm wrong through this - // route with TAA on (2026-09-16, headless both-backends run). Every bound input traced equal - // to the neutral body's for that draw - record, both blocks, push block, textures, mesh, - // layout, blend, dynamic state - so the cause is still open; see the branch handoff. - // OPTIMUM_VK_NATIVE_ENTITIES=all admits mod programs again, for that investigation. + // The vanilla programs, and the program the shader registry holds under the same pass + // name: VSEssentials' first-person hands (ModSystemFpHands.fpModeHandShader) registers its + // own entityanimated (ALLOWDEPTHOFFSET, its own Animation and, under TAA, AnimationPrev + // blocks), which replaces the registry entry. On 2026-09-16 that program drew the arm + // several times too large through this route with TAA on, with every bound input traced + // equal; on 2026-09-17 the same repro (OPTIMUM_VK_NATIVE_ENTITIES=all, + // OPTIMUM_VK_NATIVE_SHADERS=force, TAA on, headless) no longer showed it - the parity dump + // of the hand region matched the neutral body (depth identical, motion within 0.0023) - + // after the native routes that ran around it had changed. The cause was never named. + // Anything else registered under these names stays on the neutral body (decision 1); + // OPTIMUM_VK_NATIVE_ENTITIES=all admits every program with the pass name. if (!AllEntityPrograms && !ReferenceEquals(program, ShaderPrograms.Entityanimated) && - !ReferenceEquals(program, ShaderPrograms.Shadowmapentityanimated)) + !ReferenceEquals(program, ShaderPrograms.Shadowmapentityanimated) && + !IsRegistryProgram(program)) { return false; } return true; } + /// + /// Whether the shader registry holds this program under its pass name. Only a program the + /// registry registered (PassId set, from 1) is looked up: ShaderRegistry's type initializer + /// publishes uncompiled programs into ShaderPrograms.*, so a program the registry never saw + /// must not be the first thing to touch it (AGENTS.md, testing notes). + /// + internal static bool IsRegistryProgram(ShaderProgramBase program) => + program.PassId > 0 && program.PassName != null && + ReferenceEquals(program, ShaderRegistry.getProgramByName(program.PassName)); + private static readonly bool AllEntityPrograms = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_ENTITIES") == "all"; diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 21271fc1..ce93c35e 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -390,6 +390,10 @@ public void TheVulkanPlatformRecordsEntitiesNativelyAndKeepsTheOldRoute() // On by default; OPTIMUM_VK_NATIVE_ENTITIES=0 turns the route off in the real client. // Vanilla entity programs only (decision 1): a mod program under the same pass name stays on the adapter. Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Entityanimated)", entities); + // The registry's own program under the pass name (the first-person hands), looked up only + // for a program the registry registered. + Assert.Contains("!IsRegistryProgram(program)", entities); + Assert.Contains("program.PassId > 0 && program.PassName != null", entities); Assert.Contains("!ReferenceEquals(program, ShaderPrograms.Shadowmapentityanimated)", entities); Assert.Contains("internal bool NativeEntitiesEnabled { get; set; } = Environment.GetEnvironmentVariable(\"OPTIMUM_VK_NATIVE_ENTITIES\") != \"0\";", entities); Assert.Contains("public override void RenderEntityMesh(", entities); @@ -960,7 +964,7 @@ public void TheForkCloudRenderersDrawNativelyUnderTheStateTheForkStated() Assert.Contains("if (TryRenderCloudsNative(modelRef))", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs")); string clouds = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs"); - Assert.Contains("ReferenceEquals(program, ShaderRegistry.getProgramByName(name))", clouds); + Assert.Contains("if (!IsRegistryProgram(program)) return false;", clouds); Assert.Contains("depthWrite: false, samplesBoundDepth: true", clouds); Assert.Contains("FrameBuffers[(int)EnumFrameBuffer.LiquidDepth]", clouds); Assert.Contains("OPTIMUM_VK_NATIVE_CLOUDS", clouds); diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md index a4ec072b..9994fc40 100644 --- a/docs/vulkan-branch-progress.md +++ b/docs/vulkan-branch-progress.md @@ -134,7 +134,16 @@ Two shipped-client crashes came out of the merge and are fixed: a transplant tup invisible to build and tests, now AGENTS.md rule 19 (`make patch-il` + fork diff after every lib/fork change). Route switches: `OPTIMUM_VK_NATIVE_{CHUNKS,ENTITIES,WORLD,SKY,GUI}=0` send a route to the neutral body; `OPTIMUM_VK_NATIVE_ENTITIES=all` also admits mod-registered entity programs. -**Open question:** with TAA on, VSEssentials' first-person hand program (`ModSystemFpHands.fpModeHandShader`, its own +**2026-09-17: every draw is native under default settings** (headless Vulkan, native shaders forced: 0 emulated +draws, validation 0 errors / 0 SYNC-). Added since: the generic `standard` route (world, default framebuffer, sun +probe), 2D menu particles, the loading screen's minimal GUI, the temporal gear, atlas self-blits through a pooled +ReadSelf copy, both fork cloud renderers (`VulkanForkGraphics` now records the state it forwards; `liquidDepth` +resolves to the LiquidDepth target) and the first-person hands (below). Route switch added: +`OPTIMUM_VK_NATIVE_CLOUDS=0`. +**First-person hands, 2026-09-17:** the fault below no longer reproduces with the same repro - the parity dump of the +hand region matched the neutral body (depth identical, motion within 0.0023) - so the entity route now admits the +program the shader registry holds under the pass name. The cause of the 2026-09-16 fault was never named. +**Open question (2026-09-16, see above):** with TAA on, VSEssentials' first-person hand program (`ModSystemFpHands.fpModeHandShader`, its own `entityanimated` with its own `Animation` and, under TAA, `AnimationPrev` blocks) drew the arm several times too large through the native route; the vanilla programs are correct. Bisected in the real client (hand on the neutral body, world entities native: 0.9825/0.9835/0.9810 vs OpenGL). For that draw the render trace (new `sets` line in From 35e1a3afde35fe4be953a7da7d8f5781d31f8c5a Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 16:01:22 +0200 Subject: [PATCH 219/226] fix(taa): the sharpen limits its lobe on lone-pixel noise (RCAS FSR_RCAS_DENOISE) The GTAO term's TAA-converged residual on flat faces (0.24/255 per-pixel temporal std, three times the no-AO baseline of 0.08) was multiplied 2.7x by the sharpen at strength 1.0 and drawn as grain: an RCAS port without AMD's noise limiter, which FSR 3 ships enabled. The lobe is now scaled by 1 - 0.5 * (the centre's deviation from the mean of its four neighbours over the ring's range): a lone deviation gets half the lobe, an edge keeps 7/8. Eliminated on the way, with numbers in the tests and the branch doc: the AO's noise is Monte-Carlo (medium raw 10 % -> denoised 2.7 % per frame on flat faces; ultra 4x less), the reconstruction, normals, depth mips and row order match the G-buffer, and the resolve keeps 15 % of one frame's noise (new TaaResolveTests.PerFrameNoiseOnAStaticSurfaceAveragesOut, which also records the variance clip's ~3 % softening of a +-5 % per-texel texture). Checked: headless Vulkan, flat-face grain with GTAO now equals the AO-off image (spatial HF 2.05 vs 2.04, was 2.82), flicker 0.64 -> 0.44; GPU tests for the sharpen (new lone-pixel test: 0.6 on 0.3 sharpens to 0.72, not 0.9; the edge test still holds), the resolve and the post chain pass; sharpen coverage 20 passed; the owner judged it in game ("worlds better"). --- .../TaaResolveTests.cs | 74 +++++++++++++++++++ .../TaaSharpenTests.cs | 62 ++++++++++++++++ Optimum.Tests/taa-sharpen-coverage-tests.cs | 19 +++++ sources/shaders-vk/taa-sharpen.frag | 18 ++++- sources/shaders/taa-sharpen.fsh | 18 ++++- 5 files changed, 189 insertions(+), 2 deletions(-) diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index 6c0713ef..350b56c3 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -959,6 +959,80 @@ private static (double colour, double glow) AntiFlickerModel( return (history, glow); } + /// + /// A converged surface under per-frame stochastic noise (the GTAO term's residual after its + /// spatial denoise: XeGTAO relies on TAA for the temporal part). A static camera, zero motion, + /// a mid-grey surface and +-noiseAmplitude white noise that changes every frame. After 24 + /// resolves the history must carry far less of that noise than one frame does: with alpha + /// 0.1 and the anti-flicker weighting the expected residual is about a seventh of the input. + /// + /// The textured rows report, without asserting, how much a static +-textureAmplitude per-texel + /// pattern is distorted by the neighbourhood clip (a texel further than varianceGamma sigma + /// from its 3x3 mean is clamped toward that mean every frame): measured 2026-09-17 at ~3 % for + /// a +-5 % texture, independent of the temporal noise. That is the known cost of variance + /// clipping on fine detail, not a convergence failure. + /// + [SkippableTheory] + [InlineData(0.03f, 0f)] + [InlineData(0.10f, 0f)] + [InlineData(0.03f, 0.05f)] + [InlineData(0.10f, 0.05f)] + public unsafe void PerFrameNoiseOnAStaticSurfaceAveragesOut(float noiseAmplitude, float textureAmplitude) + { + const float baseValue = 0.35f; + var random = new Random(12345); + float[] texture = new float[Size * Size]; + for (int i = 0; i < texture.Length; i++) texture[i] = baseValue + (float)((random.NextDouble() - 0.5) * 2.0 * textureAmplitude); + var frames = new List(); + for (int f = 0; f < 24; f++) + { + float[] frame = new float[Size * Size]; + for (int i = 0; i < frame.Length; i++) + frame[i] = texture[i] * (1f + noiseAmplitude * (float)((random.NextDouble() - 0.5) * 2.0)); + frames.Add(frame); + } + + var uniforms = new TaaUniforms { ResetHistory = 0, BlendAlpha = 0.1f }; + TemporalRun? run = RunTemporal(24, uniforms, + (textures, history) => + { + UploadFlatRgba16F(textures, history.Color, baseValue, baseValue, baseValue, 1f); + UploadFlatRgba8(textures, history.Glow, 0, 0, 0, 255); + UploadFlatR32F(textures, history.Depth, 0.5f); + }, + (frame, textures, inputs) => + { + float[] values = frames[frame]; + Func value = (x, y) => values[y * Size + x]; + UploadRgba16F(textures, inputs.SceneTex, value, value, value, (x, y) => 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + UploadFlatR32F(textures, inputs.DepthTex, 0.5f); + UploadFlatRgba16F(textures, inputs.MotionTex, 0f, 0f, 0f, 0.5f); + }); + Skip.If(run == null, "No usable Vulkan device."); + + // Residual: history minus the noise-free texture, over the interior. + double sum = 0, sum2 = 0; int count = 0; + double inSum2 = 0; + for (int y = 2; y < Size - 2; y++) + for (int x = 2; x < Size - 2; x++) + { + float t = texture[y * Size + x]; + float h = ReadHalf(run!.Color, x, y, 0, 8); + double e = (h - t) / t; + sum += e; sum2 += e * e; count++; + double n = (frames[23][y * Size + x] - t) / t; + inSum2 += n * n; + } + double residual = Math.Sqrt(sum2 / count - (sum / count) * (sum / count)); + double input = Math.Sqrt(inSum2 / count); + _output.WriteLine($"noise {noiseAmplitude} texture {textureAmplitude}: one-frame relative noise {input:F4}, history residual {residual:F4}, ratio {residual / input:F3}, bias {sum / count:F4}"); + if (textureAmplitude == 0f) + { + Assert.True(residual / input < 0.34, $"the resolve kept {residual / input:F2} of the per-frame noise"); + } + } + private sealed class TemporalRun { public byte[] Color = Array.Empty(); diff --git a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs index 597e2563..1f984082 100644 --- a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs @@ -138,6 +138,68 @@ public unsafe void SharpenIncreasesContrastAcrossAKnownEdge() } } + /// + /// RCAS's noise limiter (FSR_RCAS_DENOISE): a lone pixel that deviates from an otherwise + /// flat field is noise, not detail, and gets half the lobe an edge gets. Without the + /// limiter a 0.6 pixel on a 0.3 field sharpens to 0.9 (the lobe pushes it away from its + /// ring); with it, to 0.7. The sharpen amplified the GTAO term's converged residual 2.7x on + /// flat faces before this (2026-09-17). The edge test above still holds: at a step edge the + /// centre deviates from its ring mean by a quarter of the range, so the edge keeps 7/8 of + /// its lobe. + /// + [SkippableFact] + public unsafe void ALonePixelIsSharpenedHalfAsMuchAsAnEdge() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + var state = new GlStateTracker(); + using var targets = new RenderTargetManager(context!, textures, state); + using var pipelines = new GraphicsPipelineCache(context!); + using var compiler = new ShaderCompiler(); + using var descriptors = new SharedLayoutTestBinding(context!, textures); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + const float field = 0.3f, lone = 0.6f; + int loneX = (int)Size / 2, loneY = (int)Size / 2; + int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); + var data = new Half[Size * Size * 4]; + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + int i = (y * (int)Size + x) * 4; + var value = (Half)(x == loneX && y == loneY ? lone : field); + data[i] = value; data[i + 1] = value; data[i + 2] = value; data[i + 3] = (Half)1f; + } + fixed (Half* pixels = data) + { + textures.Upload(input, 0, 0, 0, Size, Size, (IntPtr)pixels, 8); + } + + SharpenTarget output = CreateTarget(textures, targets); + SharpenOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + input, 1f, output); + byte[] result = ReadTextureBytes(context!, commands, textures, output.Color, 8); + + float sharpened = ReadHalf(result, loneX, loneY, 0, 8); + float neighbour = ReadHalf(result, loneX + 1, loneY, 0, 8); + _output.WriteLine($"lone pixel {lone} on {field}: sharpened to {sharpened}, neighbour {neighbour}"); + // Still sharpened (it is a real deviation), but with the halved lobe: 0.7, not 0.9. + Assert.True(sharpened > lone + 0.02f, $"the lone pixel was not sharpened at all: {sharpened}"); + Assert.InRange(sharpened, 0.65f, 0.76f); + // Its ring neighbour sees the lone pixel as one of four taps: deviation over range is + // 0.25, so it keeps 7/8 of a lobe - it darkens, a little, and stays near the field. + Assert.InRange(neighbour, field - 0.08f, field); + + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + } + /// /// The uniform is a strength, not a switch: a half-strength pass sharpens the /// same edge less than a full-strength one, and both sharpen it more than the diff --git a/Optimum.Tests/taa-sharpen-coverage-tests.cs b/Optimum.Tests/taa-sharpen-coverage-tests.cs index 44f77484..690b66b7 100644 --- a/Optimum.Tests/taa-sharpen-coverage-tests.cs +++ b/Optimum.Tests/taa-sharpen-coverage-tests.cs @@ -495,4 +495,23 @@ private static string Read(string relativePath) return null; } } + + /// + /// Both sharpen shaders carry RCAS's noise limiter (FSR_RCAS_DENOISE, shipped enabled in + /// FSR 3): the lobe is scaled by 1 - 0.5 * (the centre's deviation from the mean of its four + /// neighbours over the ring's range). Without it the sharpen multiplied the TAA-converged + /// residual of the GTAO term 2.7x on flat faces and drew it as grain (2026-09-17). + /// + [Fact] + public void SharpenLimitsItsLobeOnLonePixelNoise() + { + foreach (string path in new[] { "sources/shaders/taa-sharpen.fsh", "sources/shaders-vk/taa-sharpen.frag" }) + { + string shader = File.ReadAllText(PatchReader.FindRepositoryFile(path)); + Assert.Contains("float nz = 0.25 * (bL + dL + fL + hL) - eL;", shader); + Assert.Contains("nz = clamp(abs(nz) / max(maxL - minL, 1.0 / 65536.0), 0.0, 1.0);", shader); + Assert.Contains("nz = -0.5 * nz + 1.0;", shader); + Assert.Contains("lobe *= nz * strength * exp2(-2.0 * (1.0 - strength));", shader); + } + } } diff --git a/sources/shaders-vk/taa-sharpen.frag b/sources/shaders-vk/taa-sharpen.frag index 970ee69a..c6e95a6c 100644 --- a/sources/shaders-vk/taa-sharpen.frag +++ b/sources/shaders-vk/taa-sharpen.frag @@ -56,8 +56,24 @@ void main(void) vec3 hitMaximum = (vec3(1.0) - max(maximumRing, e)) / hitMaximumDenominator; vec3 lobeChannels = max(-hitMinimum, hitMaximum); float lobe = max(-0.1875, min(max(max(lobeChannels.r, lobeChannels.g), lobeChannels.b), 0.0)); + // Noise limiting (AMD FidelityFX RCAS, FSR_RCAS_DENOISE, which FSR 3 ships enabled): + // the centre's deviation from the mean of its four neighbours, over the ring's range, + // says how much of the local contrast is a lone pixel rather than an edge. A lone + // deviation gets half the lobe, an edge keeps all of it. Without it the sharpen + // multiplied the TAA-converged residual of the GTAO term 2.7x on flat faces (2026-09-17: + // per-pixel temporal std 0.24 -> 0.64 of 255) and drew it as grain. + float bL = b.b * 0.5 + (b.r * 0.5 + b.g); + float dL = d.b * 0.5 + (d.r * 0.5 + d.g); + float eL = e.b * 0.5 + (e.r * 0.5 + e.g); + float fL = f.b * 0.5 + (f.r * 0.5 + f.g); + float hL = h.b * 0.5 + (h.r * 0.5 + h.g); + float maxL = max(max(max(bL, dL), max(eL, fL)), hL); + float minL = min(min(min(bL, dL), min(eL, fL)), hL); + float nz = 0.25 * (bL + dL + fL + hL) - eL; + nz = clamp(abs(nz) / max(maxL - minL, 1.0 / 65536.0), 0.0, 1.0); + nz = -0.5 * nz + 1.0; float strength = clamp(sharpness, 0.0, 1.0); - lobe *= strength * exp2(-2.0 * (1.0 - strength)); + lobe *= nz * strength * exp2(-2.0 * (1.0 - strength)); vec3 sharpened = (lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0); outColor = vec4(max(sharpened, vec3(0.0)), center.a); diff --git a/sources/shaders/taa-sharpen.fsh b/sources/shaders/taa-sharpen.fsh index cf310d1e..7f242b1b 100644 --- a/sources/shaders/taa-sharpen.fsh +++ b/sources/shaders/taa-sharpen.fsh @@ -51,8 +51,24 @@ void main(void) vec3 hitMaximum = (vec3(1.0) - max(maximumRing, e)) / hitMaximumDenominator; vec3 lobeChannels = max(-hitMinimum, hitMaximum); float lobe = max(-0.1875, min(max(max(lobeChannels.r, lobeChannels.g), lobeChannels.b), 0.0)); + // Noise limiting (AMD FidelityFX RCAS, FSR_RCAS_DENOISE, which FSR 3 ships enabled): + // the centre's deviation from the mean of its four neighbours, over the ring's range, + // says how much of the local contrast is a lone pixel rather than an edge. A lone + // deviation gets half the lobe, an edge keeps all of it. Without it the sharpen + // multiplied the TAA-converged residual of the GTAO term 2.7x on flat faces (2026-09-17: + // per-pixel temporal std 0.24 -> 0.64 of 255) and drew it as grain. + float bL = b.b * 0.5 + (b.r * 0.5 + b.g); + float dL = d.b * 0.5 + (d.r * 0.5 + d.g); + float eL = e.b * 0.5 + (e.r * 0.5 + e.g); + float fL = f.b * 0.5 + (f.r * 0.5 + f.g); + float hL = h.b * 0.5 + (h.r * 0.5 + h.g); + float maxL = max(max(max(bL, dL), max(eL, fL)), hL); + float minL = min(min(min(bL, dL), min(eL, fL)), hL); + float nz = 0.25 * (bL + dL + fL + hL) - eL; + nz = clamp(abs(nz) / max(maxL - minL, 1.0 / 65536.0), 0.0, 1.0); + nz = -0.5 * nz + 1.0; float strength = clamp(sharpness, 0.0, 1.0); - lobe *= strength * exp2(-2.0 * (1.0 - strength)); + lobe *= nz * strength * exp2(-2.0 * (1.0 - strength)); vec3 sharpened = (lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0); outColor = vec4(max(sharpened, vec3(0.0)), center.a); From 388a43a851fa895221b4ff73ea7f3962546a237c Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 16:15:56 +0200 Subject: [PATCH 220/226] fix(ao): the thin class is the vertex wind flag alone, not the whole blend-no-cull pool The chunk shader forced gNormal.w = 1 (the 0.05-block occluder class) for every draw with haxyFade set, meaning the entire blend-no-cull pool. That pool holds solid blocks too - snow layers - so in a snow-covered world 59 % of the visible pixels, 158 of 168 flat faces, were thin occluders. A sentinel written from a per-pool uniform proved the pool (not RenderAfterOIT, which is the water-plant pass). The override is removed on both shader routes; the class is what vanilla's vertex stage already writes from each shape face's WindMode. Not the grain (turning the class off made the AO darker and noisier), and in this world it changes little: the snow-covered soil tops carry a WindMode in their shapes, so vanilla's flag still marks most of them. A per-block class needs a tesselator flag; open. Checked: build 0 errors, 49 programs / 142 variants compiled, patches 0 conflicts, 211/211 methods patched, AO coverage 23 passed, chunk/AO GPU tests 22 passed, deploy; headless Vulkan: flat-face temporal std 0.45 / spatial HF 2.02 (before 0.44 / 2.05), validation 0 errors / 0 SYNC-. --- .../ambient-occlusion-coverage-tests.cs | 34 ++++++++++++------- sources/shaders-vk/chunkopaque.frag | 9 ++--- sources/shaders/chunkopaque.fsh | 9 ++--- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index 23c9e9ce..672fbcd8 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -154,7 +154,6 @@ public void TheShaderPrefixStampsOptimumAoFromTheBackendAndTheGBuffer() // ------------------------------------------------------------------ the class channel [Theory] - [InlineData("sources/shaders/chunkopaque.fsh", "outGNormal.w = 1.0;", 2, new[] { "SSAOLEVEL > 0", "OPTIMUMAO > 0" })] [InlineData("sources/shaders/standard.fsh", "outGNormal.w = -1.0;", 1, new[] { "ALLOWDEPTHOFFSET > 0", "SSAOLEVEL > 0", "OPTIMUMAO > 0" })] [InlineData("sources/shaders/entityanimated.fsh", "outGNormal.w = -1.0;", 1, new[] { "ALLOWDEPTHOFFSET > 0", "USEOIT==0 && SSAOLEVEL > 0", "OPTIMUMAO > 0" })] public void ClassChannelWritesCompileInOnlyUnderTheirGuards(string path, string write, int expected, string[] guards) @@ -184,20 +183,29 @@ public void ClassChannelWritesCompileInOnlyUnderTheirGuards(string path, string Assert.DoesNotContain("#else", Regex.Matches(shader, @"#if OPTIMUMAO > 0[\s\S]*?#endif").Select(m => m.Value).FirstOrDefault() ?? ""); } + /// + /// The thin class (C.5) is the vertex stage's wind flag, which vanilla writes into gnormal.w + /// (grass, plants and leaves wave; blocks and snow layers do not). The fragment stage no longer + /// forces it for a whole pool: the blend-no-cull pool holds solid blocks too - snow layers - so + /// keying the class off haxyFade made 59 % of the visible pixels in a snow-covered world, 158 of + /// 168 flat faces, 0.05-block occluders (2026-09-17). + /// [Fact] - public void ThePlantFlagIsTheNoCullOpaquePassAndTheComposeDropsTheRowMin() + public void TheThinClassIsTheVertexWindFlagAndTheComposeDropsTheRowMin() { - string chunk = Read("sources/shaders/chunkopaque.fsh"); - Assert.Equal(2, Regex.Matches(chunk, @"if \(haxyFade > 0\) outGNormal\.w = 1\.0;").Count); - // ChunkRenderer sets HaxyFade = 1 exactly for the OpaqueNoCull pool (plants, grass, cross-quads). - string renderer = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); - string opaque = Between(renderer, "public void RenderOpaque(float dt)", "ScreenManager.FrameProfiler.Mark(\"rend3D-ret-opnc\");"); - // The native chunk-pass scope (Phase 3b stage 2) brackets the loop, so the flag and the - // pool are still adjacent with the scope's Begin/try between them. - Assert.Matches(new Regex( - @"chunkopaque\.HaxyFade = 1;.{0,400}?for \(int l = 0; l < textureIds\.Length; l\+\+\).{0,300}?poolsByRenderPass\[1\]", - RegexOptions.Singleline), - opaque); + string vertex = Read("sources/shaders/chunkopaque.vsh"); + Assert.Contains("bool isLeaves = ((renderFlags & WindModeBitMask) > 0);", vertex); + Assert.Contains("gnormal.w = isLeaves ? 1 : 0;", vertex); + foreach (string path in new[] { "sources/shaders/chunkopaque.fsh", "sources/shaders-vk/chunkopaque.frag" }) + { + string chunk = Read(path); + Assert.DoesNotContain("outGNormal.w = 1.0;", chunk); + Assert.DoesNotContain("optimumThinClass", chunk); + Assert.Contains("the thin class is the", chunk); + Assert.Contains("vertex stage's wind flag", chunk); + } + Assert.DoesNotContain("optimumThinClass", Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs")); + Assert.DoesNotContain("optimumThinClass", Read("sources/shaders-vk/chunkopaque.interface.glsl")); string compose = Read("sources/shaders/scene-ssao.fsh").Replace("\r\n", "\n"); Assert.Contains("#if OPTIMUMAO > 0\n if (optimumAoMode == 1)", compose); diff --git a/sources/shaders-vk/chunkopaque.frag b/sources/shaders-vk/chunkopaque.frag index 1a6a0425..8d5e9a81 100644 --- a/sources/shaders-vk/chunkopaque.frag +++ b/sources/shaders-vk/chunkopaque.frag @@ -145,9 +145,11 @@ void main() outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); outGNormal = gnormal; if (OPTIMUM_OPTIMUMAO > 0) { - // Optimum AO class channel (docs/research/ambient-occlusion.md C.5): plants, grass and - // cross-quad blocks draw in the no-cull opaque pass (haxyFade), and are thin like leaves. - if (haxyFade > 0) outGNormal.w = 1.0; + // Optimum AO class channel (docs/research/ambient-occlusion.md C.5): the thin class is the + // vertex stage's wind flag, which vanilla already writes into gnormal.w (grass, plants and + // leaves wave; blocks and snow layers do not). It used to be forced to 1 for the whole + // blend-no-cull pool as well, but that pool holds solid blocks too - snow layers - so in a + // snow-covered world 59 % of the visible pixels were 0.05-block occluders (2026-09-17). } #endif @@ -214,7 +216,6 @@ void main() outGNormal = gnormal; if (OPTIMUM_OPTIMUMAO > 0) { // Optimum AO class channel (C.5): the no-cull opaque pass (plants, grass, cross-quads) is thin. - if (haxyFade > 0) outGNormal.w = 1.0; } #endif diff --git a/sources/shaders/chunkopaque.fsh b/sources/shaders/chunkopaque.fsh index a4327cd3..84e0ebb7 100644 --- a/sources/shaders/chunkopaque.fsh +++ b/sources/shaders/chunkopaque.fsh @@ -149,9 +149,11 @@ void main() outGPosition = vec4(camPos.xyz, fogAmount * 2 + glowLevel + murkiness); outGNormal = gnormal; #if OPTIMUMAO > 0 - // Optimum AO class channel (docs/research/ambient-occlusion.md C.5): plants, grass and - // cross-quad blocks draw in the no-cull opaque pass (haxyFade), and are thin like leaves. - if (haxyFade > 0) outGNormal.w = 1.0; + // Optimum AO class channel (docs/research/ambient-occlusion.md C.5): the thin class is the + // vertex stage's wind flag, which vanilla already writes into gnormal.w (grass, plants and + // leaves wave; blocks and snow layers do not). It used to be forced to 1 for the whole + // blend-no-cull pool as well, but that pool holds solid blocks too - snow layers - so in a + // snow-covered world 59 % of the visible pixels were 0.05-block occluders (2026-09-17). #endif #endif @@ -218,7 +220,6 @@ void main() outGNormal = gnormal; #if OPTIMUMAO > 0 // Optimum AO class channel (C.5): the no-cull opaque pass (plants, grass, cross-quads) is thin. - if (haxyFade > 0) outGNormal.w = 1.0; #endif #endif From d569c18a7ff86ae23b130224930d258801b13e00 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 16:16:12 +0200 Subject: [PATCH 221/226] chore: keep assistant notes, plans and status files out of the repository AGENTS.md, CLAUDE.md, .claude/, the Vulkan-native plan, the branch status notes and scripts/dev/harvest-maps.py are ignored and untracked; they stay beside the checkout. Code, tests, shaders, scripts and the tracked docs no longer point at them or at review sessions: each reference now states the rule it meant (a launch is not a verification; a clear on a masked attachment is a no-op on every path; the resolve's temporal invariants; the still-frame luminance diff). TAA-PLAN.md keeps its content and drops the review-session mentions. --- .claude/skills/codex-handoff/SKILL.md | 33 - .claude/skills/patch-workflow/SKILL.md | 34 - .claude/skills/run-optimum/SKILL.md | 31 - .claude/skills/vulkan-parity-debug/SKILL.md | 108 --- .claude/skills/workflow-policy/SKILL.md | 53 -- .gitignore | 13 +- AGENTS.md | 389 -------- CLAUDE.md | 1 - .../NativePostChainTests.cs | 2 +- .../Core/RenderTargetManager.cs | 3 +- .../VulkanClientPlatform.NativeEntities.cs | 3 +- .../VulkanClientPlatform.NativePostChain.cs | 2 +- TAA-PLAN.md | 12 +- docs/research/ambient-occlusion.md | 8 +- docs/research/vulkan-bindless.md | 2 +- docs/taa-acceptance.md | 5 +- docs/vulkan-acceptance.md | 6 +- docs/vulkan-branch-progress.md | 567 ------------ docs/vulkan-native-plan.md | 876 ------------------ docs/vulkan-native-render-systems.md | 23 +- docs/vulkan-native-shaders.md | 2 +- scripts/dev/harvest-maps.py | 120 --- scripts/dev/luma-diff.py | 4 +- scripts/dev/parity-capture.sh | 3 +- sources/shaders-vk/taa-resolve.frag | 2 +- 25 files changed, 47 insertions(+), 2255 deletions(-) delete mode 100644 .claude/skills/codex-handoff/SKILL.md delete mode 100644 .claude/skills/patch-workflow/SKILL.md delete mode 100644 .claude/skills/run-optimum/SKILL.md delete mode 100644 .claude/skills/vulkan-parity-debug/SKILL.md delete mode 100644 .claude/skills/workflow-policy/SKILL.md delete mode 100644 AGENTS.md delete mode 120000 CLAUDE.md delete mode 100644 docs/vulkan-branch-progress.md delete mode 100644 docs/vulkan-native-plan.md delete mode 100644 scripts/dev/harvest-maps.py diff --git a/.claude/skills/codex-handoff/SKILL.md b/.claude/skills/codex-handoff/SKILL.md deleted file mode 100644 index d1ce7fe2..00000000 --- a/.claude/skills/codex-handoff/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: codex-handoff -description: Hand a stuck rendering bug or a plan review to the local Codex CLI (gpt-6-astra) with a neutral brief, full machine access, detached launch and a completion monitor; then read its report and transcript. Use when the user says "give it to codex/astra" or after two failed fix attempts. ---- - -# Codex handoff - -Brief = symptom + reproduction + where things live. No theories, no ruled-out lists; that poisons it. - -1. Stop other writers: pause workflows/agents, commit WIP (`wip:` prefix, never stash), clean tree. -2. Write `/codex--brief.md`: repo path and branch; the user's words verbatim; - screenshot paths; how to build (`make deploy`), launch (`scripts/dev/run-client.sh`), stop, switch - renderer, read the renderer log line; diagnostics env vars; the test commands; the patch workflow - rule (edit build/ + fork, run extract, never patches/sources); ask for a report file and a commit - on the branch, no push. -3. Wrapper script (the tool timeout cannot kill it): - ``` - cat brief.md | codex exec -m gpt-6-astra -c model_reasoning_effort="high" \ - --dangerously-bypass-approvals-and-sandbox -i shot1.png -i shot2.png > codex.log 2>&1 - echo "CODEX_EXIT $?" >> codex.log - ``` - `setsid wrapper.sh &` then a Monitor that greps for `CODEX_EXIT`. Effort: `high` for reviews and - rendering bugs (~15-40 min), `low` for small tasks; it is on a weekly quota. -3b. Steering a running session: `codex queue --thread --message ""` reaches it - only while it runs; a message queued after exit is lost. To continue an exited session: - `codex exec resume ""` through the same wrapper. The monitor pattern must - be `^CODEX_EXIT [0-9]+$`; Codex prints the word CODEX_EXIT in its own narration and false-matched - a looser pattern. -3c. Relay user observations verbatim and in time ("still jittering, no AA", "gets worse with - distance") - each one narrowed the search; do not translate them into your own hypothesis. -4. When done: read the report, `git log`, and the transcript - `~/.codex/sessions//rollout-*.jsonl` (condense `response_item` messages + - `custom_tool_call` inputs). Verify its claims yourself in-game before relaying them. diff --git a/.claude/skills/patch-workflow/SKILL.md b/.claude/skills/patch-workflow/SKILL.md deleted file mode 100644 index 1cbbc79e..00000000 --- a/.claude/skills/patch-workflow/SKILL.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: patch-workflow -description: How to change game-lib, API-fork, mod-fork and shader code in Optimum so it actually ships - edit the right tree, regenerate patches, list Cecil targets, wire csproj overlays, run the checks. Use before editing anything under build/, VintagestoryApi/, VSEssentials/, VSSurvivalMod/, sources/shaders/. ---- - -# Patch workflow - -1. Edit the source of truth (see CLAUDE.md table): `build/VintagestoryLib/**` for the client lib, - `VintagestoryApi/**` for the API, the mod fork dirs, `sources/shaders/` for shaders. - Never edit `patches/*.patch` or `sources/VintagestoryApi/**`. -2. New API file: add `` to - `optimum-api-contracts/optimum-api-contracts.csproj`, and `` to - `VintagestoryApi/VintagestoryAPI.csproj`, `sources/VintagestoryApi/VintagestoryAPI.csproj` and - `.baseline/VintagestoryApi/VintagestoryAPI.csproj` (mirrors what bootstrap folds in). -3. New platform graphics member: inject a virtual with a neutral body on `ClientPlatformAbstract` - (member list in `Optimum.Patcher/Program.cs`), put the OpenGL body in a `ClientPlatformWindows` - override and the Vulkan body in the matching `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.*.cs` - partial, and add it to `VulkanClientPlatform.ExpectedVirtuals`. Lib call sites call the platform - virtual; nothing in the lib names the renderer. Forked mods reach the device only through - `OptimumForkGraphics` (contracts). -4. Lib change: every changed or added method/property/field in `ClientMain`, `ClientPlatformWindows`, - `ChunkRenderer`, `ShaderRegistry`, `ShaderProgram*`, `ScreenManager`, ... goes into - `Optimum.Patcher/Program.cs` (transplant tuple `new("Type", "Method", paramCount)`; injected - members in the per-type member lists). The patcher only checks references, not omissions, so - grep your diff for every signature. -5. Mod-fork change: rebuild ships it locally; the installed-runtime path needs the - `Optimum.Patcher/mod-patcher.cs` manifest entry for the type/member. -6. New shader include: `sources/shaderincludes/` + add the copy to `make deploy` and every - `scripts/package-*` script; the Vulkan test corpus (`ShaderCorpus.cs`) must overlay it too. -7. `bash scripts/extract-patches.sh` then `bash scripts/check-patches.sh` (expect 0 conflicts, 0 pending; - a stray `patches/VintagestoryApi/*.csproj.patch` means step 2's baseline line is missing). -8. `dotnet build VintageStory.slnx -c Release`, both test suites, `make deploy`, run the game. -9. If a build of the lib fails on a member missing from the API, the fork and `sources/` have drifted: - diff `VintagestoryApi/` against `sources/VintagestoryApi/` and fix the fork, then extract. diff --git a/.claude/skills/run-optimum/SKILL.md b/.claude/skills/run-optimum/SKILL.md deleted file mode 100644 index 7b9f3184..00000000 --- a/.claude/skills/run-optimum/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: run-optimum -description: Build, deploy, launch, stop and screenshot the Optimum Vintage Story client on Vulkan or OpenGL, and confirm from the log which renderer actually started. Use for any "run it", "check in game", "compare backends" request. ---- - -# Run Optimum and verify what is on screen - -1. Deploy: `make deploy` (Cecil patch, copies DLLs, shaders and the Vulkan backend into - `.vanilla/win-x64/vintagestory`). If only the backend changed: `dotnet build Optimum.Render.Vulkan -c Release && cp bin/Release/net10.0/Optimum.Render.Vulkan.dll .vanilla/win-x64/vintagestory/`. -2. Stop any running client first: `scripts/dev/kill-client.sh` (its own call; no launch text in the same command). -3. Launch: `RENDERER=vulkan scripts/dev/run-client.sh "serene cave world"` (or `RENDERER=opengl`). - Diagnostics go in the environment: `OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/t.log`. -4. Wait for the world: poll the log for `[Client Chat] Welcome` (the player is in the world; "Savegame - loaded" and "AssetsFinalize" come 20 s earlier while the loading screen is still up), then sleep 8 s - before any input. Never blind-sleep. -5. **Confirm the renderer:** `scripts/dev/client-renderer.sh`. If it says `OpenGL renderer: `, - the Vulkan probe failed; read the reason (stale `Optimum.Render.Vulkan.dll` beside the client is the - classic one) and fix that before judging pixels. -6. Screenshot: `scripts/dev/screenshot.sh /tmp/vulkan.png`, then Read the PNG and describe what you see. - For a backend comparison take both shots from the same save and camera. -6b. Daylight for comparable screenshots: focus the window (`xdotool windowactivate --sync $(xdotool search --name "Vintage Story" | tail -1)`), then per command `xdotool key t`, **sleep 1.5 s** (the chat box must be open before typing or the letters become hotkeys: "e" opens the inventory and the first letters are cut off), `xdotool type --delay 100 ""`, sleep 0.8, `xdotool key Return`, sleep 2. Verify from the log: `grep "\[Server Chat\]"` shows what really arrived. (chat opens with T, sends with Enter). Commands: `/time set 12:00`, `/weather set clearsky`, `/weather setprecip -1`, `/weather setw still` (wind sway otherwise reads as jitter). Wait 3 s before the screenshot. Chat lines "A heavy temporal storm is imminent" mean the screen will warp soon; judge before it starts. -6c. When the run is for the USER to judge, leave it open and say so; close it only when they answer. When it is your own check, close it immediately. -7. Stop: `scripts/dev/kill-client.sh` immediately after the check; the user does not want it left running. Restore `ModConfig/optimum.json` `Renderer` to what the user had. - -Gotchas: `ssaa` 0.5 in clientsettings halves the render resolution on both backends; the random -`--rndWorld -p creativebuilding` world is superflat and has no animals; passing `world.vcdbs` to `-o` -creates a new world named `world.vcdbs.vcdbs`. - -Runs FOR THE USER ("run it for me"): launch, confirm the renderer from the log, say so, and hands off. -No chat commands, no xdotool, MangoHud stays at the user's global setting (their Vulkan indicator). -Scene setup and `MANGOHUD=0` are only for my own measurements with nobody at the keyboard. diff --git a/.claude/skills/vulkan-parity-debug/SKILL.md b/.claude/skills/vulkan-parity-debug/SKILL.md deleted file mode 100644 index 8aac4053..00000000 --- a/.claude/skills/vulkan-parity-debug/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -name: vulkan-parity-debug -description: Debug a rendering difference between the OpenGL path and the Vulkan backend (missing post-processing, wrong filtering, transparency, colours). Baseline capture, trace and dump analysis, GL-vs-device state diff, GPU regression test, in-game verification. ---- - -# Vulkan rendering parity debugging - -## 0. Read the layer's log first (2026-09-11) -`OPTIMUM_VULKAN_VALIDATION=1` now logs to `$TMPDIR/optimum-vulkan-validation.log` (or set the -variable to an absolute path). `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` adds synchronization -and best-practices validation through VK_EXT_validation_features. Run the game for ~30 s, then -`grep "\[error\]"` and `sort | uniq -c` the warnings. Before this the messages went nowhere and a -whole class of bugs (undefined writes into unwritten attachments, present-path waits) was "0 errors". -Known external noise: MangoHud's overlay pass (`vkCmdBeginRenderPass`, old-style barrier) reports a -READ_AFTER_WRITE on the swapchain image; the backend uses dynamic rendering, so that one is not ours. - -## 0b. What single frames cannot show -Frame-to-frame alternation (SSAO noise, a stale present, a swapped history) looks converged in every -screenshot and identical on both backends per frame. When the user reports "flickers between frames" -and per-frame probes agree, stop probing frames: read the validation log, check what differs in -*undefined* behaviour between the APIs (unwritten fragment outputs, missing waits, image aliasing), -or capture a 60 fps sequence (`ffmpeg -f x11grab`) and diff consecutive frames per region. - -The Vulkan backend substitutes the platform: `VulkanClientPlatform` overrides `ClientPlatformWindows`' -graphics members. Every bug so far was a state difference between a member's OpenGL body and its -Vulkan override, not shader maths. - -## 0. Temporal artefacts on foliage or thin detail: audit the TAA resolve first -The 2026-09-11 "Vulkan TAA jitters on distant trees / looks disabled" bug was not a parity gap: it was -`sources/shaders/taa-resolve.fsh` rejecting history per sample on sub-pixel foliage and blending with a -fixed weight, on both backends. Before any OpenGL-vs-Vulkan capture for a temporal complaint: -1. Read the resolve's rejection (disocclusion, reset, off-screen, NaN), clip and weighting against known - practice (Karis 2014, Playdead 2016). The nearest-depth 3x3 disocclusion and the luminance anti-flicker - weighting must still be there. -2. Quantify from an existing parity dump: `python3 scripts/dev/taa-rejection.py ` (history depth - slots 19/20 give this frame's and last frame's linear depth). -3. Only if the resolve is clean and the numbers are low, continue with the parity procedure below. - -## 1. Baseline before touching code -- `RENDERER=vulkan OPTIMUM_VULKAN_VALIDATION=1 OPTIMUM_RENDER_TRACE=/tmp/before.trace scripts/dev/run-client.sh` -- confirm `scripts/dev/client-renderer.sh` says Vulkan; screenshot to `/tmp/vulkan-before.png` -- same scene on `RENDERER=opengl`, screenshot `/tmp/opengl.png`; Read both and write down the differences in words. -- Trace summary (python): map `program N 'name'` lines to ids, count `fullscreen program=` per name, - list `validation:` lines with `[error]`. Passes that never run are one class; passes that run but - produce nothing are the other. -- Dump the intermediates from a live frame: `OPTIMUM_DUMP_TEXTURES= - OPTIMUM_DUMP_DIR=/abs/dir OPTIMUM_DUMP_AFTER_SECONDS=60`; build a contact sheet with PIL and Read it. - Texture ids: `bind unit=U texture=T` lines right before a pass's `fullscreen` line. - -## 2. Diff the two paths, do not theorise -For the pass that is wrong, open the method in `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs` -(or the mod renderer) and read the `if (optimumDevice != null) {...}` branch next to the GL branch, -plus the framebuffer setup pair `SetupOptimumFrameBuffers` / `SetupDefaultFrameBuffers`. Check every -item in this list on both sides: -- texture create: format, mip levels, `TexParameter` min/mag filter, mipmap mode, wrap S/T, border colour, compare mode -- samplers: `GenSampler`/`BindSampler` semantics (the "linear" flag changes magnification only; min is NEAREST_MIPMAP_LINEAR) -- blend: `glEnable(BLEND)` vs `SetBlend(enabled, mode)` (the latter rewrites per-attachment factors; use `SetBlendEnabled` to toggle only), `glBlendFunci` per attachment -- draw buffers: `glDrawBuffers` vs `SetDrawBuffers(fbo, mask)`; an enabled-but-unwritten attachment is undefined -- clears per attachment, depth mask/test/func, cull, viewport for sub-resolution targets, scissor -- attachment indices and texture-id bookkeeping (`FrameBufferRef.ColorTextureIds`) -- **format table**: every `PixelInternalFormat` a target uses must exist in `Optimum.Render.Vulkan/Core/GlEnums.cs`; - a missing entry falls back silently (R32F became RGBA8 and quantised the TAA history depth: near - stable, distance shimmering). `rg "0x[0-9A-F]{4} =>" GlEnums.cs` against the formats in `SetupDefaultFrameBuffers`. -- **clears vs draw-buffer mask**: on Vulkan `ClearColor(attachment)` is a no-op while that attachment - is masked out of `SetDrawBuffers`; GL clears it regardless. Enable, clear, restore the mask. -Write the list of mismatches first; then fix them all, not the first one. - -Symptom-to-class hints (from the TAA round, 2026-09-10): "no AA, just jitter" = history never -accepted (validity/format); "stable near, unstable far" = precision of a depth-like input; -"per-quad noise in a debug view of a cleared target" = clear not happening (mask or between-frame no-op). - -## 2b. Instrument the shader instead of guessing (Codex's method, 2026-09-10) -When a pass "does nothing" or "wobbles" and the inputs are hard to inspect, temporarily rewrite the -pass's fragment shader to OUTPUT ITS INTERNAL SIGNALS AS COLOUR and look at the screen: -- Save the original: `cp sources/shaders/.fsh /tmp/-original.fsh`. -- Patch the deployed copy directly (no rebuild needed): edit `sources/shaders/.fsh` and copy it to - `.vanilla/win-x64/vintagestory/assets/game/shaders/.fsh`; the game loads it at start. - Example for the TAA resolve: `outColor = vec4(alpha, clamp(length(mv)/4.0, 0, 1), resetHistory != 0 ? 1 : 0, 1)` - shows blend weight, motion magnitude and reset per pixel; early-out branches get a fixed colour - (`vec4(0,0,1,1)`) so you can see which path ran. -- Replace real inputs with CONTROLLED ones to split the chain: a checkerboard or diagonal pattern as - "current" proves the resolve+display copy are identical on both backends; a static pattern under the - live jitter proves accumulation on its own, independent of wind, lighting and foliage. -- Freeze the world for comparisons: `/time set 12:00`, `/weather set clearsky`, `/weather setprecip -1`, - still camera, screenshot pairs 1 s apart, numeric diff of a crop. -- Test allocator luck explicitly: fill a suspect texture with deliberately non-zero data before the pass - (cold-start dumps that happen to read zero hide a missing clear). -- Restore the original shader afterwards and re-deploy; never commit the instrumented version. - -## 2c. Measure instead of asking "does it still jitter" -Two screenshots 1 s apart, still camera, `/weather setw still` (foliage sway otherwise dominates), noon, -clear sky. Mean absolute luminance diff over the centre 60% crop, repeated for ~7 pairs per backend, -compare medians. TAA at parity: Vulkan 1.84 vs OpenGL 1.87 (medians 1.74/1.72). Above ~3 on one -backend only is a real bug; equal-but-high means the scene (wind, water, temporal storm) is moving. -Hunger damage and temporal storms change the picture mid-run: creative mode or `/player .. gamemode`. - -## 3. Fix, test, verify -- Backend changes in `Optimum.Render.Vulkan/` (platform overrides in `Platform/VulkanClientPlatform.*.cs`), - new platform virtuals on `ClientPlatformAbstract` in `build/` + Cecil list (see patch-workflow skill), - fork-only device calls in `OptimumForkGraphics` (`VintagestoryApi/Client/optimum-render-device.cs`). -- Add a GPU readback test per fix in `Optimum.Render.Vulkan.Tests` (draw with a translated shader, - read the pixel, assert; readbacks must happen inside a frame). For temporal state, the test must - span several frames in flight with Present between them and no readback/wait in the loop - (`TemporalHistoryAcrossFramesInFlight` in `VulkanDeviceIntegrationTests`): single-frame tests - passed while both TAA bugs were live. -- `make deploy`, run Vulkan with validation, screenshot after; run OpenGL; compare live. Then - `dotnet test Optimum.Render.Vulkan.Tests`, `dotnet test Optimum.Tests -c Release`, `bash scripts/check-patches.sh`. -- Keep evidence (before/after PNGs, logs) in the scratchpad and cite it in the report and commit. diff --git a/.claude/skills/workflow-policy/SKILL.md b/.claude/skills/workflow-policy/SKILL.md deleted file mode 100644 index b263e802..00000000 --- a/.claude/skills/workflow-policy/SKILL.md +++ /dev/null @@ -1,53 +0,0 @@ -# Workflow policy - SUPERSEDED 2026-09-16 - -The owner stopped workflow-based work: "The Workflow approach does not work for me. I cant see whats happening." -Work happens directly in the session, sequentially (AGENTS.md rule 8). This file is kept only so old references resolve. - ---- - ---- -name: workflow-policy -description: Model, effort and parallelism rules for Workflow (ultracode) runs in Optimum. Use before writing any workflow script or launching any subagent. ---- - -# Workflow policy (user rules, 2026-09-10) - -| Role | model | effort | notes | -|---|---|---|---| -| Map / search / inventory | sonnet | high or xhigh | cheap; lower effort gives untrustworthy maps | -| Implementation stage | opus | medium | never high; P3/P4 at high took 30-40 min per stage | -| Integration / merge stage | opus | medium | merges worktree branches, resolves conflicts, runs finish sequence | -| Review stage | opus | medium | adversarial, fixes defects with regression tests | -| Fable (main session) | - | low | high only for a hard bug; never inherited by agents | - -Shape: -1. `phase('Map')`: one sonnet agent, read-only, returns file:line touch points. -2. `parallel(stages.map(s => () => agent(..., {isolation: 'worktree', model: 'opus', effort: 'medium'})))` - for every independent stage. Each stage commits on its worktree branch with `wip(): ...` - and returns the branch name and commit. -3. `phase('Integrate')`: one opus agent merges every branch into the feature branch, resolves - conflicts (Program.cs transplant list, ClientPlatformWindows, shader includes are the usual - ones), reruns extract/check-patches, build, both test suites, `make patch-il` (Cecil patch, no deploy) and the fork-API drift check (AGENTS.md rule 19), commits. -4. `phase('Review')`: one opus agent, then Fable verifies in game (run-optimum skill). - -Prompt rules for every stage: read CLAUDE.md and the plan section first; sources of truth table; -never stash, never launch the game, never `make deploy`, never `pkill -f` with the process name in -the same command; mandatory tests (Optimum.Tests coverage + GPU readback in -Optimum.Render.Vulkan.Tests); return structured data via `schema`. - -## No map stage by default (2026-09-16, owner) - -Map stages were opening every workflow and costing five figures of tokens each to rediscover the tree, then -being thrown away with the run. Two rules replace that: - -1. **Do not add a map stage** unless the question cannot be answered from the code: measured behaviour, vendor - documentation, or a tree the repository does not contain. For "where is X, what state does it set, what - writes this attachment", the implementation stage greps and reads - that is cheaper than a stage and it - cannot go stale. -2. **Every implementation stage documents the seams it touches**, per "Documentation that makes map stages - unnecessary" in docs/vulkan-native-render-systems.md: what it draws, where the other side is, target and - slots, non-obvious state, and the test that pins it. State this requirement in the stage prompt. A stage - that adds a seam without the comment is incomplete. - -`scripts/dev/harvest-maps.py` recovers the map output of past runs from the workflow journals if one is -genuinely needed again. diff --git a/.gitignore b/.gitignore index 57f3b149..5d020e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -79,11 +79,6 @@ docs/* # allowlist, and Optimum.Tests/parity-dump-coverage-tests.cs reads both. !docs/vulkan-acceptance.md !docs/parity-allowlist.md -# ...and the Vulkan-native plan: the design, the decisions behind it and the roadmap, for anyone -# picking the work up. -!docs/vulkan-native-plan.md -# ...and the handover note saying where the branch work stands. -!docs/vulkan-branch-progress.md # ...and the research notes the plan and its designs cite. !docs/research/ # ...and the native shader interface contract every program family is written against. @@ -95,3 +90,11 @@ docs/* build-linux.sh build-macos.sh build-windows.ps1 + +# Working notes for assistants and the local plan and status files: kept beside the checkout, never in it. +AGENTS.md +CLAUDE.md +.claude/ +docs/vulkan-native-plan.md +docs/vulkan-branch-progress.md +scripts/dev/harvest-maps.py diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 6bc46d68..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,389 +0,0 @@ -# Optimum: working rules for agents - -*How to work on this repository, for any agent on any branch. `CLAUDE.md` is a symlink to this file. -Branch scope, status and decisions are in `docs/vulkan-branch-progress.md`, not here.* - -Optimum is a performance mod for Vintage Story: a patched client (OpenGL path in -`ClientPlatformWindows`) plus a Vulkan backend that substitutes the platform -(`VulkanClientPlatform : ClientPlatformWindows` in `Optimum.Render.Vulkan/Platform/`). Read this before touching anything. The -skills in `.claude/skills/` (tracked) hold the step-by-step procedures; this file holds the rules and the -working knowledge. Work happens in the session, one step at a time, visibly (rule 8). A lesson learned during a session belongs here, in the repository; the harness memory -store on one machine is a cache, not the record. - -## Where the truth lives (edit these, never the generated copies) - -| What | Edit here | Generated from it | Ships as | -|---|---|---|---| -| Game client code | `build/VintagestoryLib/**` (decompiled + patched) | `patches/VintagestoryLib/*.patch` via `scripts/extract-patches.sh` | Cecil transplant into vanilla DLL; every changed/new method or member MUST be listed in `Optimum.Patcher/Program.cs` | -| Game API | `VintagestoryApi/**` (hand-maintained fork, git-ignored) | `sources/VintagestoryApi/**` via extract | `VintagestoryAPI-patched.dll`; new files also go in `optimum-api-contracts/optimum-api-contracts.csproj` (path `..\sources\VintagestoryApi\...`) and get a `` in both `VintagestoryApi/VintagestoryAPI.csproj` and `sources/VintagestoryApi/VintagestoryAPI.csproj` | -| Mods | `VSEssentials/`, `VSSurvivalMod/`, `VSCreativeMod/` (forks) | `patches//*.patch` via extract | recompiled mod DLLs plus `Optimum.Patcher/mod-patcher.cs` manifests for the installed-runtime path | -| Shaders | `sources/shaders/*.vsh/.fsh` (override vanilla by file name) | shipped by `make deploy` and `scripts/package-*` | includes: `sources/shaderincludes/` (add to deploy and packagers when first used) | -| Native Vulkan shaders | `sources/shaders-vk/*.vert/.frag/.interface.glsl` + `include/` (contract: `docs/vulkan-native-shaders.md`) | `shaders.manifest.json` + SPIR-V by `tools/shader-compiler` (MSBuild target) | `shaders-vk/` beside the client; runtime falls back per program to the rewriter | -| Vulkan backend | `Optimum.Render.Vulkan/**` | - | `Optimum.Render.Vulkan.dll` + `Silk.NET.*.dll` beside the client (`make deploy` copies them) | -| Vanilla reference | `_ref/**` and `.vanilla/**/assets` | read-only | - | - -Never edit `patches/*.patch` or `sources/VintagestoryApi/**` by hand; extract overwrites them. -`.baseline/` is the decompiled vanilla; csproj overlays are folded into it by bootstrap, so a new -`` must also be added to `.baseline/VintagestoryApi/VintagestoryAPI.csproj` locally -or extract will keep emitting a stray csproj patch. - -## Build, deploy, run, verify - -``` -dotnet build VintageStory.slnx -c Release # everything -dotnet test Optimum.Render.Vulkan.Tests # GPU tests, validation layers on (needs a GPU) -dotnet test Optimum.Tests -c Release # source/patch coverage tests -bash scripts/extract-patches.sh && bash scripts/check-patches.sh # after editing build/, forks, API -make patch-il # Cecil patch only, no deploy: run after every lib/patcher change; "N/N required methods patched" or it fails -make deploy # Cecil patch + copy into .vanilla/win-x64/vintagestory -scripts/dev/run-client.sh ["world name"] # detached launch; RENDERER=vulkan|opengl env switches -scripts/dev/client-renderer.sh # which renderer ACTUALLY started (read this every time) -scripts/dev/screenshot.sh /tmp/x.png # then look at the image with Read -scripts/dev/kill-client.sh # clean close; never pkill -f from a shell that mentions the process -scripts/dev/perf-capture.sh / pacing-gate.sh # frame-time capture; gate: blocking uploads, stddev vs GL baseline, p99 -scripts/dev/parity-capture.sh + ssim.py # per-attachment dump on one backend; SSIM table between two dumps -scripts/dev/headless-capture.sh # real client and renderer, window never mapped, frames to disk; does not steal the desktop - # closes itself cleanly and prints "shutdown closed itself"; a crash count in its output means the run is suspect - # pacing numbers from it are meaningless: an unfocused window sits under the 30 FPS background cap -scripts/dev/worktree-bootstrap.sh # in a worktree: materialise build/ + forks offline (private .build copy) -scripts/dev/worktree-bootstrap.sh --in-place [--discard-build-edits] # main checkout, after a merge changed patches/ -``` - -Data dir: `~/.config/OptimumVintagestoryData` (`clientsettings.json`, `ModConfig/optimum.json` with -`"Renderer"`). Saves: `Saves/*.vcdbs`; pass the bare world name to `-o`, not the file name. -Settings that change what you see: `ssaa` (0.5 renders at half res on BOTH backends), `fxaa`, -`ssaoQuality`, `bloom`, `godRays`, `mipMapLevel`. - -Backend diagnostics: `OPTIMUM_VULKAN_VALIDATION=1` (log: `$TMPDIR/optimum-vulkan-validation.log`, or set it to a path; add `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best`), `OPTIMUM_RENDER_TRACE=` (per-draw -trace: `program N 'name'`, `fullscreen program= tex0= target=`, `bind unit= texture=`, -`validation:` lines), `OPTIMUM_DUMP_TEXTURES= OPTIMUM_DUMP_DIR= OPTIMUM_DUMP_AFTER_SECONDS=60` -(PPM dumps of live textures; without the delay you dump the menu), `OPTIMUM_VULKAN_STATS=` -(legacy line plus `key=value` lines: blocking uploads, waits per site, frame p50/p95/p99/stddev, scopes, barriers), -`OPTIMUM_FPS_LOG=` (per-second `mean min max p99 stddev`), `OPTIMUM_PARITY_DUMP= OPTIMUM_PARITY_FRAME=` -(every framebuffer attachment on both backends at in-world frame n, PPM/PFM, GL row order), -`OPTIMUM_VULKAN_POISON=1` (fresh images NaN/magenta/0xDEADBEEF, depth 0.5, buffers 0xDEADBEEF: undefined reads become loud), -`OPTIMUM_VK_NATIVE_SHADERS=force|0` (force: link every program from the native manifest even without a mod scan; 0: rewriter for all; -the log line `[Optimum] shaders: N native, M rewritten, K failed` says what happened), `OPTIMUM_VK_SHADER_SOURCE=` (compile the -native tree at runtime for the dev loop), `OPTIMUM_VULKAN_SYNC_PIPELINES=1` (blocking pipeline creation instead of the background worker). -Headless capture: `OPTIMUM_HEADLESS=1` (window created but never mapped or focused, both backends), -`OPTIMUM_HEADLESS_FRAMES=` plus `OPTIMUM_HEADLESS_FIRST_FRAME`/`_FRAME_COUNT`/`_FRAME_STRIDE` or -`_FRAME_LIST` (which in-world frames to write as PPM), `OPTIMUM_HEADLESS_COMMANDS=` with -`OPTIMUM_HEADLESS_COMMAND_FRAME` (chat lines fed on that frame: `/time`, `/weather`, `.cam load`/`.cam play`), -`OPTIMUM_HEADLESS_FIXED_DT` (pins the simulated step). A display server is still required - headless here -means no visible window, not no display. -DLSS/NGX (branches `feat/dlss*` only; not present on the Vulkan-only branches): the NVIDIA feature libraries live in -`~/.local/share/optimum-ngx`, never in the scratchpad (tmpfs; after a reboot the NGX tests would skip instead of fail); -`OPTIMUM_NGX_FEATURE_PATH=~/.local/share/optimum-ngx/lib/Linux_x86_64/rel` runs them. -**Implicit Vulkan layers poison validation and must be switched off deliberately.** On this machine MangoHud is -enabled globally (`~/.config/environment.d/mangohud.conf`) and `VK_LAYER_LS_frame_generation` (Lossless Scaling) -has no enable variable at all, so both hook every Vulkan process, the GPU test host included, and draw or present -on the swapchain in ways validation reports as the application's own hazards. Every GPU test run, validation run -and measurement exports: -`MANGOHUD=0 DISABLE_MANGOHUD=1 DISABLE_LSFG=1 DISABLE_VK_LAYER_VALVE_steam_overlay_1=1 DISABLE_VK_LAYER_VALVE_steam_fossilize_1=1 DISABLE_GAMESCOPE_WSI=1 DISABLE_VULKAN_RENDERDOC_CAPTURE_1_45=1 DISABLE_LAYER_MESA_ANTI_LAG=1` -and confirms with `VK_LOADER_DEBUG=layer vulkaninfo --summary` that none of them is inserted. `VK_LAYER_MESA_device_select` -stays (it only orders devices). A run without this is not evidence, and the fix for a finding is never a weaker -validation setting. -GPU tests run `sync,best` validation by default through `GpuTest` (override: `OPTIMUM_TEST_VALIDATION_FEATURES`, empty = off); -an unlisted `SYNC-` message fails `ValidationAssert.NoSyncHazards`. - -## Rules that came from real failures - -1. **A launch is not a verification.** The bootstrap falls back to OpenGL silently; MangoHud only - shows on Vulkan. Grep the log for `[Optimum] Vulkan renderer` / `[Optimum] OpenGL renderer:` - before saying anything about rendering. A PR was merged on an OpenGL run because this was skipped. -2. **Look at pixels, then diff the two paths.** For any "X looks wrong on Vulkan": capture a baseline - (screenshot + trace + validation log) first, then read the OpenGL body (`ClientPlatformWindows` or the lib site) and the Vulkan override - (`VulkanClientPlatform.*.cs`) of the same member side by side and list every state difference (sampler filter/wrap/mip/border/compare, - blend enable vs per-attachment factors, draw-buffer masks, clears, viewports, formats). The bugs - have all been parity gaps, never shader maths. Do not theorise from symptoms. -3. **Verify in the game, both backends, before claiming done.** Deploy, run, screenshot, compare with - OpenGL live. Component tests passing is not evidence for the screen. -4. **Every fix gets a GPU readback test** in `Optimum.Render.Vulkan.Tests` (pattern: - `VulkanDeviceIntegrationTests`, `AttachmentSemanticsTests`) and, for lib/patch changes, a - source-coverage test in `Optimum.Tests` (pattern: `fsr-pipeline-coverage-tests.cs`). -5. **Process hygiene.** Launch through `scripts/dev/*.sh` (setsid wrappers). Never put `pkill -f` or - `pgrep -f` in a command that also contains the process name in a heredoc or string: it matches the - calling shell, so `pkill` kills it (exit 144) and `pgrep` reports the process as running when it is not - - on 2026-09-16 that produced a "client is running" report hours after the owner had closed it. Ask with - `ps -eo pid,stat,args | grep -i | grep -v grep`, or read the client log. Close the game with the kill script (window close - first) to avoid shutdown-race crash reports. Close the game as soon as a check is done; never leave it running. -6. **Git.** Never `git stash`. Commit WIP on the branch with a `wip:` prefix instead. Branch from - `main` (tracks `origin/main` = KillerPixelCrew/VulkanStory, migrated from NightHammer1000/VulkanStory on 2026-09-15; `upstream` = StratumServer/Optimum). - Commit only when asked or when a phase is verified; say what was verified in the message. -7. **Batch reads.** Read whole methods and both paths in one command (`sed -n` ranges + `rg`), not - ten single greps. Codex found in one pass what took an afternoon of small probes. -8. **Work directly in the session, sequentially. No workflows, no background agents.** (Owner, 2026-09-16: - "The Workflow approach does not work for me. I cant see whats happening.") The session reads, edits, builds, - tests and reports each step itself, in order, so the owner can watch every change land. A subagent is allowed - only for a read-only search the session would otherwise do by hand, returns text, and never edits. Codex - (`.claude/skills/codex-handoff`) remains available for a genuinely stuck rendering bug on the owner's say-so. - -19. **Build and tests cannot see two crash classes; `make patch-il` and the API-drift check can.** Both compile - against the fork, so a transplant tuple with the wrong parameter count or a lib call to a member that exists only - in the API fork passes them and ships as a crash (2026-09-16: 1271 + 1107 tests green, then - RenderTextureIntoFrameBuffer listed with 9 params against vanilla's 10, and `MeshDataPool.get_ModelRef` crashing - both backends). After any lib or fork change run `make patch-il` and `diff -r .baseline/VintagestoryApi - VintagestoryApi`, and grep the lib for each added public member. A new member on a vanilla API type never ships - unless api-patcher.cs injects it: use a scope seam (BeginChunkPass/EndChunkPass pattern) or a contracts type. - -9. **Undefined behaviour differs between the APIs.** GL keeps an attachment the shader never writes; - Vulkan writes garbage into it (pipelines now mask those off). A bug that only flickers between - frames is invisible to screenshots and to per-frame probes: read the validation log with sync + - best-practices enabled before instrumenting anything. - -10. **Temporal and pacing claims need numbers, never screenshots.** Accepted evidence: the validation - log with `sync,best`; a multi-frame GPU test (Present between frames, no readback in the loop); - `pacing-gate.sh` numbers against the OpenGL baseline of the same scene; `ssim.py` per-attachment - tables; a 60 fps `ffmpeg -f x11grab` capture with consecutive-frame diffs for flicker; poison mode - for suspected undefined reads. Where the numbers are recorded: `docs/vulkan-acceptance.md`; branch status and the plan pointer: - `docs/vulkan-branch-progress.md`. - -11. **TAA on sub-pixel foliage: audit the resolve, not the backend.** The three-day Vulkan "distant - trees jitter, TAA looks disabled" bug (fixed 2026-09-11) was `sources/shaders/taa-resolve.fsh` itself: - a single-sample depth disocclusion test threw the history away on ~3.7% of distant leaf pixels every - frame (a sub-pixel leaf hits the leaf in one jitter phase and the background in the next), and a - fixed 10% blend let the moving clip box drag history. The fix, which must never be reverted: 3x3 - nearest-depth disocclusion with motion from the nearest-depth tap, and luminance anti-flicker weighting - (0.3x..1.2x blendAlpha). Both backends had the flaw; every OpenGL-vs-Vulkan capture matched, which is - why parity hunting never found it. When the user names a component (here: the TAA shader), audit that - component against known practice (Karis 2014, Playdead 2016) and quantify it - (`scripts/dev/taa-rejection.py` on a parity dump) before any backend comparison. Any change to the - resolve, including the Phase 3 native rewrite, keeps both behaviours and their tests. - -12. **Say only what you verified, and name the evidence.** A status in a plan, a handoff or a comment is a claim, - not a fact; a passing test, a file:line, a log line or a measured number is a fact. On 2026-09-16 an audit found - items marked done that were never done, and separately a model, a process state and a branch scope were asserted - from documents instead of from checks - each one wrong. If it was not checked this session, say that it was not. - -13. **Decide, don't ask.** Research the open question to a decision and act on it. Hand a choice back only when it - is genuinely the owner's (money, scope, upstream, destructive acts) and you cannot resolve it from what they - already said. Turning an instruction they just gave you back into a question is the failure mode. - -14. **Feature phase: move fast (owner, 2026-09-16).** After each change: build, `make patch-il` when the lib or - patcher changed, the tests that cover what changed, and one quick in-game look when it touches the screen. - No full suites per change, no SSIM tables, no bisection runs, no harness work, no fixing old tests beyond what the - change breaks. Measurement, test cleanup and the harness belong to the optimisation/refactor pass at the end, - where half of it would be rewritten anyway. Rules 3 and 10 apply to that pass and to claims of "done" for the - branch, not to every intermediate step. - -15. **Grep, don't map - and document the seams.** Every render seam carries a doc comment at its declaration: what - it draws, where the OpenGL body is, target and slots, the state that is not obvious and why, and the test that - pins it (`docs/vulkan-native-render-systems.md` section 4). Document what you touch as part of the change. - -16. **Only the session launches the game, deploys or pushes**, and only after the checks in rule 14. - -17. **Identity and attribution.** Commit as `NightHammer1000 ` (global config only). The work - e-mail from the environment context must never appear in git config, commits, PRs, docs or output. No tooling or - assistant attribution anywhere - not in code, comments, docs, tests or commit messages - and no co-author - trailers. - -18. **Never relax validation.** Validation findings are real. Never weaken a setting, suppress a message, add an - allowlist entry or lower a report flag to go green: fix the cause. The one exception already in the tree is a - documented vendor entry in `KnownSyncHazards`, and it names the driver and the reason. - -## Testing notes -- **Tests are filed by subject, never by stage or review round.** Add to the existing coverage file for - the thing under test (`upscaler-*`, `taa-*`, `latency-*`, `headless-*`); create a new file only when a - genuinely new subject appears. A file named after the work that produced it (`pr3-review-round2-*`, - `wave1-*`) is wrong by construction: nobody looks there when that subject breaks. Half the repo's diff - against upstream is tests, and 44 of them are under 150 lines because each workflow stream wrote its own. -- **"Both backends" means OpenGL with the user's real `optimum.json` too.** OpenGL with `Upscaler: dlss` crashed on - the loading screen from a8f09ae until 2026-09-13 and nobody saw it, because every check ran Vulkan. The headless - harness makes the OpenGL run cost nothing: run it in the same pass as the Vulkan one. -- **Injected fields never run their initializers** (Cecil copies no constructor IL): an injected `= new T()` is null, - an injected `= -1` is 0. `CecilInjectedFieldInitializerTests` enforces it for every injected field, with no allowance list. Use the CLR - default as the starting state, or allocate lazily at the use site. -- **Do not touch a vanilla static class before vanilla does.** Its type initializer may not be inert: - `ShaderRegistry`'s publishes uncompiled programs into `ShaderPrograms.*`, which is what crashed the OpenGL loading - screen when the upscaler stand-down called into it during startup. -- `Optimum.Render.Vulkan.Tests` GPU tests must read back inside a frame; `BindFramebuffer`/`ClearColor` - are no-ops between frames. -- Vulkan named UBOs are per-draw snapshots (fixed 2026-09-10); the uniform ring is now per frame slot - (it used to divide one fixed 32 MiB by the slot count, so raising `FramesInFlight` silently cut it). -- **Poison mode is clean evidence as of 2026-09-12.** It used to report 5 sync hazards per run, written - off as syncval noise across destroy/recreate; they were ours - the poison clear and the first upload of - a fresh texture are both `TransferDst` writes in one batch and `BarrierBatcher` emits nothing when the - usage does not change. A hazard under `OPTIMUM_VULKAN_POISON=1` is now a real finding, not a baseline. -- Shader pairs dropped in `sources/shaders/` are auto-translated by `ShaderTranslationTests`. -- Temporal bugs need multi-frame GPU tests (Present between frames, no readback in the loop); a - single-frame readback passed while the R32F-history and masked-clear bugs were live (P2, 2026-09-10). -- Vulkan: `ClearColor` on an attachment masked out of `SetDrawBuffers` is a no-op; every framebuffer - format must have an entry in `GlEnums.cs` or it silently degrades to RGBA8. -- TAA history rejection is a number too: `scripts/dev/taa-rejection.py ` reports per-region - rejection from the two history depth slots; distant leaves above ~1.5% per frame is the 2026-09-11 regression. -- "Does it still jitter" is answered with a number: still-camera screenshot pairs, wind stilled, - luminance diff over the centre crop, both backends (vulkan-parity-debug skill, 2c). - -## Project knowledge (folded in from the agent memory, 2026-09-16) - -Working practice learned the hard way, branch-agnostic. Decisions, roadmap and branch scope are NOT here: -they live in `docs/vulkan-branch-progress.md` and the plan. A new working lesson belongs here; a new decision -belongs there. - -### Cleanup comes last - -*Documentation and comment cleanup happens at the very end of the project; until then comments are moved but never trimmed.* - -User, 2026-09-12: "We do Code documentation and comment cleanup at the very end." - -**Why:** the comments in this repo are the record of what each defect cost, and many carry measured numbers - the 1.05 % distant-leaf TAA rejection, the 0.37 to 0.02 display-pixel jitter residual, why NGX's shutdown is gated, why the acquire wait stage may never be ALL_COMMANDS. While the renderer is still moving, trimming them deletes the reasoning that keeps the next agent from reintroducing the bug. - -**How to apply:** never open a "tidy the comments" task, and never let a refactor quietly drop an xml-doc - a consolidation or a move carries every "why" forward verbatim. Stale comments that are actively wrong are still fixed on the spot, as part of the change that made them wrong. One cleanup pass at the end, when the renderer settles. Related: `testing-suite-too-heavy`, `speed-and-parallelism-over-testing`. - -### Delegating to codex - -*How the user wants Codex (gpt-6-astra) launched and steered on this project.* - -The user delegates hard problems to the local `codex` CLI and has been specific about how: - -- Model `gpt-6-astra`. **Launch at low reasoning effort** (`-c model_reasoning_effort="low"`) - unless they ask otherwise — xhigh over-tests and over-scopes, and runs for hours. -- **It is on a weekly quota** and the user tracks it (one long xhigh session on the Vulkan - backend cost roughly 30% of a week). Spend it on problems that are genuinely stuck, and - keep briefs tight rather than launching speculatively. -- **Give it full machine access** (`--dangerously-bypass-approvals-and-sandbox`), not a - sandbox. It can then drive the GPU, launch the game and take Wayland remote control to - actually play and inspect the result. Sandboxing it blocked the only verification step - that settles a rendering question, and the user objected to it directly. -- Brief it with **the symptom and the reproduction only** — attach the screenshot with - `-i`, describe what is wrong, and let it investigate. Do not hand it my own conclusions - or a "ruled out" list: the user called that poisoning its context, and the analysis I - was most confident in turned out to be the part that was wrong. -- Prompt goes **on stdin** (`cat brief.md | codex exec ...`); `-i` takes multiple files and - swallows a positional prompt argument. -- Steer a live session with `codex queue --thread --message "..."`, taking - the uuid from the `session id` line in its output. Copy any screenshot into a path it - can read and name that path in the message. - -- **Plan reviews are a good use of `high` effort.** On 2026-09-10 the user asked for a high-effort - review of the TAA plan against the code and game source; it took ~15 minutes, cost far less than - an xhigh implementation session, and found a pre-existing Vulkan bug (named UBOs shared across all - draws in a frame) plus a dozen wrong assumptions. Brief it with the plan path and the source - locations, ask for CONFIRMED/WRONG/UNVERIFIABLE with file:line, and tell it to write to a file - in the scratchpad. Launch through a wrapper script with `setsid` so the tool timeout cannot kill - it, and monitor for a sentinel line (see `pkill-self-match`). - -**Why:** on the Vulkan backend it found three real bugs in one pass that I had missed over -a long session, and verified them by playing the game across several views and two worlds. - -**How to apply:** when stuck on something the user is getting frustrated with, offer Codex -early rather than late, brief it neutrally, and give it the whole machine. See -`verify-end-to-end-not-components`. - -Steering (2026-09-10): `codex queue --thread --message` only reaches a running session; messages queued after exit are lost, so continue with `codex exec resume `. Monitor on `^CODEX_EXIT [0-9]+$` (Codex narrates the word and false-matched a looser pattern). Relay the user's observations verbatim and promptly; each one ("gets worse with distance") narrowed the search. Codex does not push; verify its claims in-game, then push. - -It is on a weekly quota the owner tracks: keep briefs tight and do not launch speculatively. - -### Git remotes - -*"In the Optimum checkout, origin is KillerPixelCrew/VulkanStory (the org repo, migrated from NightHammer1000/VulkanStory on 2026-09-15) and upstream is StratumServer/Optimum; main tracks origin/main."* - -Remote layout (set 2026-09-10 at the user's request): -- `origin` = https://github.com/KillerPixelCrew/VulkanStory.git ("ours"; the repository moved into the KillerPixelCrew organisation on 2026-09-15, it used to be NightHammer1000/VulkanStory). `main` tracks `origin/main`. PRs for the Vulkan work go here; `gh` default repo is set to it. -- `upstream` = https://github.com/StratumServer/Optimum.git. Does not have the Vulkan backend yet. - -**How to apply:** push branches and open PRs against `origin`. Only touch `upstream` when the user asks to sync with or contribute to StratumServer. Related: `optimum-upscaling-roadmap`. - -### Look before you work - -*"Before designing or launching any implementation wave, read the vendor docs in full and the reference implementations on disk (~/Projekte/ReScaleFrame/references) and check best practice online; the user has had to say this three times."* - -User, 2026-09-13: "This is the third time i have to tell you to actually look before you work." (The previous two, same day: "DLSSFG without pacing (Reflex) is useless and unplayable" and "Have you checked that frameplacment against best practice online and in the Framegen Documentation?") - -**Why:** I designed DLSS-G frame pacing from one chapter of NVIDIA's guide plus my own reasoning and launched a 7-agent workflow on it. The user's own reference checkouts in `~/Projekte/ReScaleFrame/references/` (Streamline, FidelityFX-SDK, xess, OptiScaler) and their ReScaleFrame design docs already contradicted it: AMD paces both presents from the previous present with a 10-frame moving average, CPU-waits for GPU completion before presenting, keeps one frame in flight, caps render slightly below half the output rate; Streamline measures pacing by display change, not present call; only generated frames are dropped. Two workflows were stopped as a result. - -**How to apply:** for any feature with vendor SDKs or prior art: (1) read the vendor guides in full, not the chapter that matches the question; (2) read the reference implementations on disk - check `~/Projekte/ReScaleFrame/references/` and the user's ReScaleFrame docs first, they are the user's own research; (3) search online for best practice; (4) write the design with a source for every decision and mark what is reasoning; (5) show the user the sourced design before launching an implementation wave. A map of *our* code is not research into *how it should be done*. Related: `research-before-repeating-loops`, `audit-the-component-the-user-names`, `frame-generation-needs-pacing`, `user-graphics-expertise`. - -### Nvidia driver update needs reboot - -*GLXBadFBConfig on every OpenGL launch plus Vulkan silently picking the Intel iGPU means the NVIDIA userspace driver was updated without a reboot; check nvidia-smi and the log's GPU line before any capture.* - -On 2026-09-11 a pacman update at 14:38 moved nvidia-utils 610.57.04 to 615.71.09 while the loaded kernel module stayed 610. Symptoms: every OpenGL launch through `prime-run` crashed at window creation ("GLX: Failed to create context: GLXBadFBConfig"), `prime-run glxinfo -B` failed with "X Error ... BadValue", `nvidia-smi` printed "Failed to initialize NVML: Driver/library version mismatch", and Vulkan still started but on "Intel(R) UHD Graphics (ADL-S GT1)", which is invalid for measurements. A reboot fixed all of it. - -**Why:** it looked like a Phase 0 regression and cost a capture round; the user watched the clients crash. - -**How to apply:** before any in-game capture run `nvidia-smi` (must print the GPU and driver, not a mismatch) and, after each launch, require `Graphics Card Renderer: NVIDIA` in the client log (on Vulkan that line is the selected Vulkan device name). If the mismatch shows, tell the user a reboot is needed instead of launching. Related: `confirm-renderer-from-log`, `vulkan-native-rebuild-decision`. - -### Research before repeating loops - -*"User feedback 2026-09-11: on a hard rendering bug, research online and form a real model before more launch/measure loops; repeating in-game hoops without new information reads as no effort and cost the project."* - -On 2026-09-11 the Vulkan TAA distance shimmer came back (distant trees jitter between frames). I ran a chain of launch / screenshot-pair / DLL-swap loops, none of which could see one-frame alternation, and never searched for how other TAA implementations handle sub-pixel foliage shimmer or what the Vulkan symptoms of a broken history look like. The user pulled the project ("not skilled enough, no effort to understand, never researched online") and handed it to Codex. - -**Why:** the user judges effort by whether new information enters the loop. Re-running the same in-game checks with a measurement already documented as blind to the bug class is visible as churn. Yesterday's fix came from reading the validation log, which was new information; today nothing new was read. - -**How to apply:** for a Vulkan-only or TAA-quality bug, before any second launch: (1) web-search the symptom (TAA shimmer on thin/distant geometry, history rejection, jitter phase alternation, swapchain/frame-pacing causes) and the relevant Vulkan spec/best-practice pages, (2) write down the competing mechanisms and the one observation that separates them, (3) only then launch, and only for that observation (e.g. a TaaDebugView validity view over the shimmering region, or an OpenGL eyes-on control). Never offer luma-diff pairs as evidence for frame-to-frame flicker. Related: `vulkan-validation-log-and-flicker`, `verify-end-to-end-not-components`, `taa-p2-vulkan-parity-lessons`. - -### Research combines sources - -*2026-09-15 feedback - sources the owner names are for deep research that combines their best parts, not a menu to pick one from and integrate; such research goes to a Fable agent at high reasoning.* - -User, 2026-09-15, after naming MXAO, Alchemy AO, low-sample GTAO + spatial denoise, openmw-ssao and a Unity GTAO port while I was picking an AO algorithm: "I have not given you those sources to simply integrate. You should research them all and Combine the best parts of all of them. Including XeGTAO. Give this research task to a fable agent at high reasoning." - -**Why:** I answered each named source with a verdict (use / reference only / not adopted) and kept steering toward one implementation, instead of studying every source in depth for the parts worth combining. - -**How to apply:** when the owner lists sources or alternatives for a design, launch a deep research task (Fable, high effort - an explicit exception to the no-Fable-agents rule) that reads the actual papers and code of every source, compares them against this renderer's constraints and writes a combined design with per-component provenance and licence notes; hold implementation until it is back. Licence limits still decide what may be taken as code versus as an idea. Related: `look-before-you-work`, `decide-dont-ask`, `xegtao-default-with-taa`. - -### Scratchpad is tmpfs - -*"The session scratchpad is on a 16 GB tmpfs shared with the system; filling it broke the user's system upgrade, so keep dumps small and put anything needed twice in ~/.local/share."* - -2026-09-12, user: "your scratchdir has tmpfs filled. made my system upgrade fail". The scratchpad had grown to 12 GB of a 16 GB `/tmp` tmpfs - parity dumps (`p0`, `p1`), TAA traces (`taa-trace`, `tt`), blame and binary copies, plus vendor SDK clones (DLSS with its 1.3 GB `lib`, FidelityFX, OptiScaler, Streamline, the SCS fork). - -**Why:** `/tmp` is RAM on this machine and shared with everything else the user runs; a full tmpfs fails package transactions, not just my own commands. - -**How to apply:** delete a capture directory as soon as its numbers are recorded in `docs/vulkan-acceptance.md` or the plan - the conclusions are the deliverable, the frames are not. Shallow-clone vendor SDKs, read them, then remove them; the synthesis stays. Anything a test or a later session needs (the NVIDIA NGX libraries, headers and guides) goes to `~/.local/share/optimum-ngx`, never the scratchpad: on tmpfs it vanishes at reboot and the NGX tests then *skip* rather than fail, which hides the breakage. Check `df -h /tmp` before writing GB-scale dumps, and prefer per-attachment dumps at one frame over frame sequences. Related: `testing-suite-too-heavy`, `ngx-needs-a-native-shim`. - -### Sequential, visible work (supersedes "speed and parallelism", 2026-09-16) - -Earlier direction favoured wide parallel workflow waves. The owner reversed it on 2026-09-16 after a day of -merges landing work they could not watch: "The Workflow approach does not work for me. I cant see whats -happening." One change at a time in the session, verified before the next. What survives from the earlier -direction: capture sessions stay short (3 minutes is plenty; never a 10-minute run) and in-game runs are for the -exit of a piece of work, not for investigation loops. - -### Taa p2 vulkan parity lessons - -*"Why Vulkan TAA jittered for three Codex passes: missing GL_R32F mapping and a masked-out motion clear; single-frame tests hid both. TAA P2 accepted 2026-09-10."* - -TAA P2 (in-house resolve) was accepted by the user on 2026-09-10 ("TAA is CHEFSKISS now") at commit 9c32acb on feat/taa. The Vulkan-only "no AA, just jitter" that took three Codex passes came from two parity gaps, not the resolve maths: -1. `GlEnums.cs` had no GL_R32F entry, so the history depth target degraded to RGBA8; 8-bit previous depth made rejection fire randomly, worse with distance (b4d58a2). -2. `ClearColor` on Vulkan is a no-op for an attachment masked out of `SetDrawBuffers`; the motion attachment kept stale vectors (8e4a970). Clear = enable, clear, restore mask. -Both slipped past single-frame GPU tests; Codex's regression test spans frames in flight with Present between them. Acceptance is numeric: still camera, wind stilled (`/weather setw still`), luminance diff of screenshot pairs; parity was Vulkan 1.84 vs OpenGL 1.87. - -**How to apply:** for any Vulkan "looks wrong" report, check the format table and clear-vs-mask first (now in the vulkan-parity-debug skill, sections 2 and 2c), and write multi-frame tests for temporal state. Related: `verify-end-to-end-not-components`, `delegating-to-codex`, `optimum-upscaling-roadmap`. - -### Testing suite too heavy - -*"2026-09-11 - the Vulkan acceptance matrix is too heavy; cut in-game capture to one short run, drop per-attachment SSIM matrices, cap sessions at ~3 minutes."* - -User, 2026-09-11, during the Milestone 1 exit capture: "That 10 Minute run was excessive... 3 minutes would have been more than enough" and "The whole testing Suite is Exessive and wastes so much time." - -**Why:** the heavy rows measure world noise, not the backend. Two OpenGL launches of one save differed at SSIM 0.86 on the primary colour, so the per-attachment parity matrix cannot separate a real gap from weather, chunk streaming and entity movement; the fixed scene helps pacing but not parity. The long session added nothing the first minute had not shown. - -**How to apply:** keep the cheap numeric evidence that actually catches regressions (pacing gate on a 60 s run, the Vulkan stats counters, `taa-rejection.py` on one dump per backend, the GPU suite's `sync,best` validation) and drop the rest: no 10-minute sessions, no multi-launch SSIM matrices, no repeated interleaves unless a number disagrees. One short Vulkan launch for the user to judge closes a milestone. Always set MANGOHUD=0 for validation runs: MangoHud's overlay render pass trips sync validation on the swapchain image and produced 10 phantom errors. Related: `speed-and-parallelism-over-testing`, `verify-end-to-end-not-components`, `run-for-user-no-input`. - -### User graphics expertise - -*"The user is a graphics programmer who authored the XeSS PR for Skyrim Community Shaders; skip upscaler and TAA primers, talk at implementation level."* - -The user authored the XeSS integration PR for Skyrim Community Shaders and judges TAA/upscaler behaviour live by eye with precision (distance-dependent instability, frame-to-frame flicker, "TAA has a distinctive blur"). Their observations have been right every time this project doubted them. - -**How to apply:** no primers on jitter, motion vectors or reactive masks; when their live observation contradicts a measurement, the measurement is the suspect. Related: `vulkan-validation-log-and-flicker`, `run-for-user-no-input`. - -### Vulkan validation log and flicker - -*"Vulkan validation messages go to a file, not the client log (OPTIMUM_VULKAN_VALIDATION=1 -> $TMP/optimum-vulkan-validation.log; FEATURES=sync,best); frame-to-frame flicker cannot be seen in screenshots. P4 accepted 2026-09-11."* - -2026-09-11: the Vulkan-only "everything jitters, no AA, worse at the horizon" after P3/P4 survived every single-frame probe (motion, validity, history, uniforms all identical to GL) because the defect alternated between frames: fullscreen passes left the SSAO normal/position attachments write-enabled without storing to them, Vulkan wrote undefined values, SSAO outlines flickered. Found within minutes once the validation log was actually read (it had been going to a file named "1" or nowhere) with sync + best-practices validation. Fix 95bf71d: mask unwritten fragment outputs in the pipeline, present-path wait stage AllCommands, per-image semaphores, layout-accurate barrier accesses. User: "that fixed the instability issue fully". - -**How to apply:** for any Vulkan-only artefact, first run with `OPTIMUM_VULKAN_VALIDATION=/abs/log OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best` and read `[error]` lines; screenshots and per-frame diag shaders cannot see one-frame alternation. The user judges live; when they say it flickers between frames, believe it and look for API-level undefined behaviour, not resolve maths. Related: `taa-p2-vulkan-parity-lessons`, `run-for-user-no-input`. - -### Waiting on long running processes - -*"Wait for a real signal (log line, exit sentinel, Monitor), never blind-sleep; for the game the in-world line is '[Client Chat] Welcome' plus 8 s."* - -Blind sleeps repeatedly captured the loading screen or typed into a game that was not accepting input yet. The reliable markers: `[Client Chat] Welcome` for "player is in the world" (savegame-loaded and AssetsFinalize come ~20 s earlier), `^CODEX_EXIT [0-9]+$` for the Codex wrapper, workflow task notifications for agents. Kill leftovers through the wrapper scripts before a new launch. - -**How to apply:** poll the log for the marker with a bounded loop, then a short fixed margin; never `sleep 60` and hope. Related: `pkill-self-match`, `run-for-user-no-input`. - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs index d38d7d23..d2dab8a4 100644 --- a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -297,7 +297,7 @@ public void TheChainKeepsItsOrderAcrossFramesAndTaaKeepsAccumulating() /// from a cold history (the reset frame, which copies the scene through) and from a warm one /// (the frame that actually blends history in). /// - /// CLAUDE.md rule 11 is the shader's, and both routes run the same taa-resolve.fsh: what is + /// The temporal invariants are the shader's, and both routes run the same taa-resolve.fsh: what is /// asserted here is that the native route feeds it the same seven textures and the same nine /// uniform values, so the 3x3 nearest-depth disocclusion and the luminance anti-flicker /// weighting see identical inputs and produce identical pixels. diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 8bed4d06..116838cd 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -646,7 +646,8 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo // is simply not cleared - the game clears attachments 2 and 3 of the // primary target while only 0 and 1 are selected. Nor does GL clear // through an all-false glColorMask. A clear on an attachment whose - // effective write mask is zero is a no-op on every path (CLAUDE.md rule 9). + // effective write mask is zero is a no-op on every path: GL keeps an attachment the + // shader never writes, and Vulkan would write garbage into it. if ((uint)attachment >= (uint)_bound.Color.Length) return; if (!_bound.Color[attachment].IsBound) return; if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs index a62290d1..a1868de2 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs @@ -194,7 +194,8 @@ private static bool IsNativeEntityProgram(ShaderProgramBase program) /// Whether the shader registry holds this program under its pass name. Only a program the /// registry registered (PassId set, from 1) is looked up: ShaderRegistry's type initializer /// publishes uncompiled programs into ShaderPrograms.*, so a program the registry never saw - /// must not be the first thing to touch it (AGENTS.md, testing notes). + /// must not be the first thing to touch it - that is what crashed the OpenGL loading screen + /// when the upscaler stand-down called into it during startup. /// internal static bool IsRegistryProgram(ShaderProgramBase program) => program.PassId > 0 && program.PassName != null && diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs index 2b28e703..c83f71fc 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -356,7 +356,7 @@ private AttachmentBlend[] NativeOpaqueBlend(uint slots) /// the pass writes all three colour slots with no blending, no depth test and no depth /// attachment, at the slot's own size. The seven inputs are the pass's declared reads and /// resolve straight to bindless slots; the nine uniform values are the OpenGL body's, at - /// their placements. CLAUDE.md rule 11 lives in taa-resolve.fsh, which both routes run + /// their placements. The resolve's temporal invariants live in taa-resolve.fsh, which both routes run /// unchanged - the 3x3 nearest-depth disocclusion and the luminance anti-flicker weighting /// are the shader's, and nothing here touches them. /// diff --git a/TAA-PLAN.md b/TAA-PLAN.md index eb3c69e3..560e2c08 100644 --- a/TAA-PLAN.md +++ b/TAA-PLAN.md @@ -1,4 +1,4 @@ -# TAA for Optimum: plan (revised after Codex review, 2026-09-10) +# TAA for Optimum: plan (revised after review, 2026-09-10) ## Context @@ -15,10 +15,8 @@ requirements, resolutions and colour stages differ, so this plan builds one engi replaceable stage. The in-house GLSL resolve is the first implementation and the permanent fallback on OpenGL. -The plan was reviewed by Codex (gpt-6-astra, high effort) against `build/`, `_ref/`, the mod forks -and the Vulkan backend; its review is at -`/tmp/claude-1000/-home-n1ght-Projekte-Optimum/73ffc57c-d773-4311-8bb2-b42cbc432943/scratchpad/codex-taa-review.md`. -Its major corrections were verified against the code and are folded in below. +The plan was reviewed against `build/`, `_ref/`, the mod forks and the Vulkan backend; the +review's major corrections were verified against the code and are folded in below. ## Decisions (agreed with the user) @@ -269,7 +267,7 @@ Commits 1257117..9c32acb on `feat/taa`. Findings to carry: (a) Vulkan `GlEnums` history depth target silently became RGBA8 (b4d58a2); (b) `ClearColor` on a masked-out attachment is a no-op on Vulkan, so the motion clear must enable the attachment first (8e4a970); (c) the history lookup must be anchored at the pixel centre plus mv, not at the unjittered current position (7e1b9bd); -(d) matched-camera luminance-diff measurements (Codex) are the acceptance tool for "jitter" reports. +(d) matched-camera luminance-diff measurements are the acceptance tool for "jitter" reports. **P3. Opaque coverage.** - `sources/shaderincludes/vertexwarp.vsh` with `WarpState`; `chunkopaque`, `chunktopsoil` writers; @@ -737,7 +735,7 @@ the log - all of it is `docs/taa-acceptance.md`: matrix has not been run. The decision belongs to the user, with the evidence paths recorded here; until then `OptimumConfig.Taa` stays `false` and TAA is opt-in from the settings tab. -P5 in-game verification (2026-09-11, Fable): deployed c9758ce+5b952da; both backends start, log their +P5 in-game verification (2026-09-11): deployed c9758ce+5b952da; both backends start, log their renderer, load `taa-sharpen`, no exceptions; Vulkan under synchronization + best-practices validation shows no backend hazards (only MangoHud's external overlay hazard). Frame times via `scripts/dev/perf-capture.sh` (30 s at spawn in "serene cave world", ssaa 0.5, 2755x1727 window): diff --git a/docs/research/ambient-occlusion.md b/docs/research/ambient-occlusion.md index 6b770321..152ac65f 100644 --- a/docs/research/ambient-occlusion.md +++ b/docs/research/ambient-occlusion.md @@ -9,7 +9,7 @@ is marked [Uncertain] and taken from the issue text, the Bluesky thread and the Conventions: [Inference] = my conclusion, not a source claim. [Uncertain] = could not verify against a primary source. Repository facts are cited as `file:line` at the current branch. -**Owner decisions taken as given** (`docs/research/xegtao-integration.md` section 0 and `docs/vulkan-branch-progress.md` +**Owner decisions taken as given** (`docs/research/xegtao-integration.md` section 0 and the branch status notes section 4): the chosen AO is the default on Vulkan whenever TAA is active; vanilla SSAO otherwise and always on OpenGL; AO is composed into the scene before the TAA resolve and never onto the glow attachment; jitter is `P[8] -= 2*jx/W` (`docs/temporal-frame-contract.md` section 2); the TAA resolve invariants stay (3x3 nearest-depth disocclusion with motion from the nearest-depth tap, luminance anti-flicker 0.3x..1.2x; pinned by `TaaResolveTests` and `Optimum.Tests/taa-antiflicker-coverage-tests.cs`). @@ -533,7 +533,7 @@ the CDF mapping fails that comparison. hands, echo chamber" in contract section 6, which already reproject through `GetPrevProjection(Hand)`) are patched to write the hand class into `gNormal.w` (C.5); the AO pass writes visibility 1 on hand-class pixels and, as a sample, treats them as solid with the world reconstruction (a hand in front of a wall still occludes the wall - approximately [Inference]). The lib/shader patch follows `patch-workflow` (Cecil member lists, extract, check). + approximately [Inference]). The lib/shader patch follows the patch procedure (Cecil member lists, extract, check). openmw's near-depth fade (`depth < 40` smoothstep) is the fallback only if the patch proves impossible. - **Water/fog/OIT:** keep vanilla's modulation `AO_final = 1 - (1 - AO) * (1 - attenuate)` with `attenuate = gPosition.w + 0.75 * (1 - revealage)` (`ssao.fsh:76-82`, `:152`), applied in the **compose** pass, not @@ -625,7 +625,7 @@ ReShade port did this, note section 2) - the maths is identical, so nothing meas ## D. Measurement plan -All runs with the implicit Vulkan layers off (`docs/vulkan-branch-progress.md`, Linux test state), renderer confirmed from the log, through +All runs with the implicit Vulkan layers off (MangoHud and the Lossless Scaling layer hook every Vulkan process on the test machine), renderer confirmed from the log, through `scripts/dev/headless-capture.sh` (frames to disk, static camera or `.cam play`, `OPTIMUM_HEADLESS_FIXED_DT`). **Inputs to every item (decided):** the AO working term, the packed edges and working-depth mip 0 are opt-in outputs of `OPTIMUM_PARITY_DUMP` and of the headless frame writer (C.13), next to the existing attachments, so the @@ -723,6 +723,6 @@ bias against this game's reference. - Unreal GTAO state: https://artiliada.github.io/2024/12/27/GTAO.html - Noise: https://github.com/electronicarts/fastnoise (BSD-3) ; https://github.com/NVIDIAGameWorks/SpatiotemporalBlueNoiseSDK (`License.txt`: non-commercial) - Arc 140V: https://chipsandcheese.com/p/lunar-lakes-igpu-debut-of-intels ; https://cputronic.com/gpu/intel-arc-140v -- Repository: `docs/research/xegtao-integration.md`, `docs/vulkan-branch-progress.md` sections 4-5, `docs/temporal-frame-contract.md`, +- Repository: `docs/research/xegtao-integration.md`, `docs/temporal-frame-contract.md`, `sources/shaders/ssao.fsh`, `scene-ssao.fsh`, `final.fsh`, `taa-resolve.fsh`, `.vanilla/.../shaders/{ssao,chunkopaque,bilateralblur}.{vsh,fsh}`, `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs`, `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs`. diff --git a/docs/research/vulkan-bindless.md b/docs/research/vulkan-bindless.md index 4c8555cf..fd54e7c5 100644 --- a/docs/research/vulkan-bindless.md +++ b/docs/research/vulkan-bindless.md @@ -1,6 +1,6 @@ # Bindless texture descriptors on desktop Vulkan 1.3 -Research notes for implementing decision 9 of `docs/vulkan-native-plan.md` (one pipeline layout, bindless +Research notes for implementing decision 9 of the Vulkan-native plan (one pipeline layout, bindless textures). Collected 2026-09-15. Every claim carries a URL. **[Inference]** marks reasoning not taken from a source; **[Uncertain]** marks something that could not be verified from a primary source. Device limits come from the Vulkan Hardware Database (gpuinfo, default "recent (1y)" filter) and the Mesa `main` tree at commit diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 90fd6a93..0c5f300e 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -2,7 +2,8 @@ The P5 acceptance matrix as a runnable checklist. Every row is run **twice, once per backend**, with the renderer confirmed from the log, and **TAA on vs off**. Nothing here -is passed on a launch alone: rule 1 of `CLAUDE.md` says a launch is not a verification. +is passed on a launch alone: the bootstrap falls back to OpenGL silently, so a launch is not a +verification until the renderer line in the log says which backend started. Tooling used by this document: @@ -41,7 +42,7 @@ jq '.Renderer, .Taa, .TaaSharpness, .TaaMipBias' ~/.config/OptimumVintagestoryDa ## 1. The measurement to record -**Still-frame luminance diff** (`.claude/skills/vulkan-parity-debug/SKILL.md` section 2c). +**Still-frame luminance diff** (`scripts/dev/luma-diff.py`). Still camera, screenshot pairs one second apart, mean absolute luminance difference over the centre 60% crop, **seven pairs per backend**, compare the **medians** - never a single pair. diff --git a/docs/vulkan-acceptance.md b/docs/vulkan-acceptance.md index 434a5fde..5208412f 100644 --- a/docs/vulkan-acceptance.md +++ b/docs/vulkan-acceptance.md @@ -3,7 +3,7 @@ The acceptance checklist for the native Vulkan backend programme (Phase 0 foundations through Milestone 1, stable frame delivery with TAA), in the same shape as `docs/taa-acceptance.md`. Every row is run **once per backend** unless it says otherwise, with the renderer confirmed from the log. -Nothing here passes on a launch alone (`CLAUDE.md` rule 1), and temporal or pacing claims pass on +Nothing here passes on a launch alone (the bootstrap falls back to OpenGL silently), and temporal or pacing claims pass on numbers and logs only, never on screenshot pairs (section 4). Tooling used by this document: @@ -138,7 +138,7 @@ history colour 0.968/0.969). Vulkan-vs-GL (V0.2) attachments clearly below their | attachment | GL-vs-GL | VK-vs-GL | note | |---|---|---|---| -| 13-SSAO color1 alpha | 1.0000 | 0.0001 | GL 1.0 everywhere, Vulkan 0.0: an attachment channel the shader never writes (`CLAUDE.md` rule 9) | +| 13-SSAO color1 alpha | 1.0000 | 0.0001 | GL 1.0 everywhere, Vulkan 0.0: an attachment channel the shader never writes | | 0-Primary color2 (rgba16f) | 0.958 | 0.866 | | | 0-Primary color0 | 0.968 | 0.886 | | | 10-Luma, 19/20 TAA history colour | 0.974 / 0.968 | 0.903 | follows Primary colour | @@ -326,7 +326,7 @@ pacing seen in Phase 0 and Phase 1 was the moving world, not the build. Evidence device-up line of the log names the layer version and the settings actually applied. Run one area per session (`docs/research/vulkan-validation.md` §1). Read it before instrumenting anything: a bug that flickers between frames is invisible to screenshots and to - per-frame probes (`CLAUDE.md` rule 9). + per-frame probes. ### Luminance-diff medians - `docs/taa-acceptance.md` section 1: still camera, seven pairs one second apart per backend, diff --git a/docs/vulkan-branch-progress.md b/docs/vulkan-branch-progress.md deleted file mode 100644 index 9994fc40..00000000 --- a/docs/vulkan-branch-progress.md +++ /dev/null @@ -1,567 +0,0 @@ -# feat/vulkan-taa: handoff - -Everything needed to continue the Vulkan branch on another machine. Last updated 2026-09-16 at 14f0779. - -- Plan of record: `docs/vulkan-native-plan.md` (decisions 1-9, phases, risks). -- Research the designs follow: `docs/research/` (caching, descriptor model, bindless, XeGTAO, validation). -- Acceptance procedures: `docs/vulkan-acceptance.md`, `docs/taa-acceptance.md`, `docs/temporal-frame-contract.md`. -- Older planning documents still in the tree: `VULKAN-BACKEND-PLAN.md`, `TAA-PLAN.md` (history; the plan of - record supersedes them where they disagree). - ---- - -## 1. Context - -**Upstream.** StratumServer/Optimum PR #69 (owner: NightHammer1000) was split: the maintainer wants the -Vulkan backend landed first, so this branch carries Vulkan + TAA only, without the DLSS/frame-generation -work (branches `feat/dlss`, `feat/dlss-g` keep that). The owner handles all upstream communication and the -PR itself; do not push to upstream or touch the PR. - -**Base.** Branched from the last TAA-only commit before the DLSS work (9ad0c70 after the identity rewrite). - -**Scope, stated by the owner 2026-09-16.** Frame structure is Vulkan foundation and IS in scope here: one frame -identity per frame with markers around simulation, render submit and present, and the world frame separated from -UI composition (`SceneNoHud` plus a UI target). The vendor layer on top of them - DLSS, XeSS, FSR, frame generation, -the NV/AMD/XeLL latency backends, NGX - stays on `feat/dlss`, `feat/dlss-g`, `feat/latency`. Standing rendering -direction: physically correct over the vanilla look; AO defaults to GTAO while TAA is active on Vulkan. - -**History rewrite that already happened.** All 14 fork branches had their author identity rewritten to -NightHammer1000 on 2026-09-15. A first attempt also re-created upstream's signed -commits, which broke the common history with StratumServer:main and closed PR #69 irrecoverably; the redo -excluded upstream history. Binding from now on: no rewrite or force-push without the owner's explicit OK, -restricted to the commits that need it (`--not `), and with `git merge-base` against upstream -compared before and after for every branch. - -## 2. Working rules - -- **Research first.** Before each major piece, research current best practice online (Khronos spec, - guide and samples, vendor guidance, shipped engines such as DXVK, Godot, Unreal, Bevy), write the result - to `docs/research/.md` with citations, commit it, and state the design with its sources before - writing code. -- **Commit each verified step, push regularly.** Verified means the relevant tests ran. -- **Identity.** `git config --global user.name NightHammer1000` and - `git config --global user.email nightstorm@kpc.bz` on every machine before committing. The notebook is the - machine that previously committed with a wrong identity; check it first. -- **No tooling or assistant attribution** in code, comments, docs, tests, scripts or commit messages, and - no co-author trailers. -- **Genuine decisions go to the owner** (forks between plan and research, system installs, anything - outward-facing). -- **Keep the to-do list** in section 5 current and commit it as items change state. - -## 3. Repository essentials - -- **Game code is decompiled and patched.** `scripts/bootstrap.sh` (or `scripts/bootstrap.ps1 -Refresh` on - Windows) downloads the client, decompiles it into `build/VintagestoryLib`, clones the forks and applies - `patches/*.patch`. `scripts/extract-patches.sh` regenerates the patches from the working tree, - `scripts/check-patches.sh` verifies them. -- **Fork sources live at the repo root** (`VintagestoryApi/`, ...); extraction copies them into `sources/` - and overwrites anything edited there. Always edit the root fork tree, never `sources/`. -- **Cecil transplant patcher** (`Optimum.Patcher/Program.cs`): every member injected into the client - (fields, methods, shader program entries) has to be listed in its members/targets lists, and transplanted - code must avoid cached lambdas and LINQ predicates (`Optimum.Tests/cecil-transplant-lambda-tests.cs`). - Client members the Vulkan platform reads also go into `VulkanClientPlatform.ExpectedWindowsMembers`, and - new shader files into the packaging scripts. -- **Build and deploy:** `make build`, `make deploy` (Cecil-patched DLLs into the vanilla client), `make run`. - `make check` reports missing tools. -- **Tests:** - - `dotnet test Optimum.Render.Vulkan.Tests -c Release` - the GPU suite (real device; the validation layer - is used when installed). - - `dotnet test Optimum.Tests -c Release` - source, patch, shader and script coverage. - - `make test` - Optimum.Tests plus the launcher tests. -- **Headless render harness:** `scripts/dev/headless-capture.sh --renderer vulkan|opengl --world - --out [--commands ] [--count ]` (real client, hidden window, frames written by the client; - compare with `scripts/dev/ssim.py`). Needs a logged-in game install and a save. -- **Diagnostics environment variables** (Vulkan): - - `OPTIMUM_VULKAN_VALIDATION=1|`, `OPTIMUM_VULKAN_VALIDATION_FEATURES=sync,best,mobile,gpu,gpu-only` - - `OPTIMUM_VULKAN_STATS=`, `OPTIMUM_RENDER_TRACE` - - `OPTIMUM_VULKAN_SHADER_CACHE=|0`, `OPTIMUM_VULKAN_SYNC_PIPELINES=1` (blocking pipeline creation; the - capture scripts default to it) - - `OPTIMUM_VULKAN_FRAMEGRAPH=0`, `OPTIMUM_VULKAN_ALIAS=1`, `OPTIMUM_VULKAN_COLOR_WRITE_TIER=enable|mask|pipeline` - - `OPTIMUM_VULKAN_NO_MEMORY_BUDGET=1`, `OPTIMUM_VULKAN_NO_REBAR=1`, `OPTIMUM_VULKAN_POISON`, `OPTIMUM_VULKAN_CHECKPOINTS` - -### Renderer layout (Optimum.Render.Vulkan) - -- `VulkanDevice.cs` (about 3400 lines, to be split in the refactor): the device behind the patched - platform; program link, uniform writes, descriptor binding, draw, present, teardown. -- `Core/VulkanContext.cs`: instance (validation via `VK_EXT_layer_settings`), device selection, feature - negotiation, capabilities (incl. vendor, device, driver version, pipeline-cache UUID). -- `Core/PipelineCache.cs` (`GraphicsPipelineCache`), `Core/PipelineCacheFile.cs`, `Core/CacheFileWriter.cs`. -- `Core/DescriptorCache.cs`, `Core/DescriptorArena.cs`, `Core/ShaderProgramResources.cs` (today: set 0 frame, - 1 samplers, 2 storage, 3 program blocks, one layout per program - what decision 9 replaces). -- `Core/TextureManager.cs` (texture ids with a free list and deferred deletion; `Rebind`/`RestoreBindings` - for transient aliasing), `Core/MeshManager.cs`, `Core/RenderTargetManager.cs`, `Core/FrameRing.cs` - (timeline semaphores, uniform ring with dynamic offsets), `Core/GlStateTracker.cs` (GL emulation, to be - removed in Phase 3b). -- `Graph/`: streaming frame graph, transient allocator, feedback copy pool. -- `Shaders/`: runtime translation GLSL 330 -> 450 (`GlslParser` -> `ProgramInterfaceLayout` -> - `ShaderRewriter` -> `ShaderCompiler`/shaderc), `FrameGlobals.cs` (shared frame block, include-owner rule), - `ShaderBinaryCache.cs`. -- `Platform/VulkanClientPlatform.cs`: the substituted client platform. - -## 4. Knowledge that is not obvious from the code - -- **Temporal stability (the whole-frame jitter).** Symptom: with TAA the entire image appeared to shift a - few pixels per frame in random directions, worst toward the horizon. A 3x3 nearest-depth test only masked - it. The real fix (41373cf, backported from `feat/dlss`): AO computed from the jittered G-buffer is - composed into the scene before the TAA resolve (`ApplyOptimumSceneSsao`, `scene-ssao` shaders, - `optimumSsaoInScene` in `final.fsh`), and the SSAO dither advances per frame when TAA is on. Any new AO - (XeGTAO) must follow the same placement. -- **Jitter convention:** `P[8] -= 2*jx/W`, content moves by +JitterPx; resolve in `taa-resolve.fsh`. -- **Shared frame block** (a41efce): uniforms written by `ShaderProgramBase.Use()` live once in set 0 - (`FrameGlobals`), placed only for programs that include the owning include file; frame locations start at - `1 << 28`. -- **Caches** (618a0b1): SPIR-V key = format version + compiler options + SHA-256 of the loaded shaderc - binary + stage + rewritten source; one pipeline cache file per GPU, checked against vendor, device, driver - version, pointer size, UUID and the blob's own header; empty cache on any mismatch or driver rejection; - saved at device dispose; stored under `GamePaths.Cache/optimum-vulkan`. -- **Headless capture pixel order:** the client-side default framebuffer is BGRA - (`OptimumDefaultFramebufferIsBgra`); `Leaf.ReadDefaultFramebuffer` swaps unless the device colour format is BGRA. -- **Windows bootstrap pitfalls** (fixed in 19d101e, keep in mind): CRLF in fork refs, a PowerShell module - shadowing `Expand-Archive`, innounp overwrite prompts, `Tee-Object` masking exit codes. - -## 5. Status and to-do - -### Plan status, audited 2026-09-16 (scoped to this branch) - -**Phase 3b stage 2 landed 2026-09-16 (`8278bad` + fixes).** Native on Vulkan: sky dome, all 13 chunk groups, the -vanilla entity programs (entityanimated, shadowmapentityanimated), night sky, moon, cube particles, decals, the -texture-into-texture GUI blit and the reticle. Still emulated: mod-registered entity programs (first-person hands), -sun, quad particles, held items, aurora and clouds (fork surface `OptimumForkGraphics`/`VulkanForkGraphics`, no native -counterpart, undecided), 7 of 9 GUI systems. `GlStateTracker`, the texture-unit tables and uniform-by-name are still -load-bearing; removal (stage 3) cannot start. -Two shipped-client crashes came out of the merge and are fixed: a transplant tuple with the wrong parameter count -(`RenderTextureIntoFrameBuffer` 9 vs 10) and a lib call to a fork-only API member (`MeshDataPool.ModelRef`) - both -invisible to build and tests, now AGENTS.md rule 19 (`make patch-il` + fork diff after every lib/fork change). -Route switches: `OPTIMUM_VK_NATIVE_{CHUNKS,ENTITIES,WORLD,SKY,GUI}=0` send a route to the neutral body; -`OPTIMUM_VK_NATIVE_ENTITIES=all` also admits mod-registered entity programs. -**2026-09-17: every draw is native under default settings** (headless Vulkan, native shaders forced: 0 emulated -draws, validation 0 errors / 0 SYNC-). Added since: the generic `standard` route (world, default framebuffer, sun -probe), 2D menu particles, the loading screen's minimal GUI, the temporal gear, atlas self-blits through a pooled -ReadSelf copy, both fork cloud renderers (`VulkanForkGraphics` now records the state it forwards; `liquidDepth` -resolves to the LiquidDepth target) and the first-person hands (below). Route switch added: -`OPTIMUM_VK_NATIVE_CLOUDS=0`. -**First-person hands, 2026-09-17:** the fault below no longer reproduces with the same repro - the parity dump of the -hand region matched the neutral body (depth identical, motion within 0.0023) - so the entity route now admits the -program the shader registry holds under the pass name. The cause of the 2026-09-16 fault was never named. -**Open question (2026-09-16, see above):** with TAA on, VSEssentials' first-person hand program (`ModSystemFpHands.fpModeHandShader`, its own -`entityanimated` with its own `Animation` and, under TAA, `AnimationPrev` blocks) drew the arm several times too large -through the native route; the vanilla programs are correct. Bisected in the real client (hand on the neutral body, -world entities native: 0.9825/0.9835/0.9810 vs OpenGL). For that draw the render trace (new `sets` line in -`BindStorageSet`) shows equal program record, `Animation`/`AnimationPrev` contents, push block, textures, mesh, -vertex layout, blend and dynamic state on both routes, so the cause is not in the bound inputs I could see. By -decision 1 mod programs belong on the adapter anyway, so the route admits vanilla programs only; the question stays -open for when mod renderers get native passes. Repro: `OPTIMUM_VK_NATIVE_ENTITIES=all OPTIMUM_VK_NATIVE_SHADERS=force`, -headless Vulkan, TAA on. -**Sun native (2026-09-16).** `ClientPlatformAbstract.RenderSunQuad` carries the visible sun; the occlusion-query -probe stays on `RenderMesh` (a Vulkan occlusion query has to begin and end inside one render pass). Vanilla -`ShaderPrograms.Standard` only. 33 native sun passes in a headless run, 0 errors, validation clean. -**The native world-system GPU tests were vacuous until 2026-09-16.** Every comparison in `NativeWorldSystemsTests` -compared two untouched attachments: the fixture never wrote the frame block (zero `viewDistance` discards every -`standard` fragment) or the transforms (zero model/view collapse every vertex). Seeded now, and the helper refuses a -comparison whose scene slot is still the clear. That exposed one fixture asymmetry (the decal test never bound the -atlases to units, as `ShaderProgramDecals` does) - fixed; decals match with real pixels. Still vacuous and marked: -the particle tests, whose quad carries no per-instance attributes. -**Residual Vulkan-vs-OpenGL difference is not from stage 2.** Bisected with the route switches, same session: -all stage-2 routes off 0.981/0.971/0.964; chunks only 0.982/0.965/0.969; world only 0.981/0.968/0.968; sky only -0.980/0.974/0.967; GUI only 0.977/0.963/0.970; entities only 0.986/0.980/0.972. -Verified on the deployed build, both backends headless, TAA on: 0 client errors, validation 0 errors and 0 `SYNC-`, -entity GPU tests 25 passed, coverage 28 passed. Scene SSIM Vulkan vs OpenGL 0.9631/0.9617/0.9658 against a same-session -OpenGL floor of 0.9853/0.9750/0.9785; the frames match on inspection and the residual is run timing (the two runs -landed on different in-game days, camera bob and chat differ). - -**Phase 3b stage 1 completed and verified in game, 2026-09-16 (merge `2c9bc70`).** All nine post/TAA chain -passes draw through the native device API: OIT merge, sky motion, SSAO + bilateral blur + AO composite (both AO -modes), TAA resolve and sharpen (the lib body keeps the temporal contract, only the draw is re-routed), the bloom -chain, god rays, FXAA luma and the final composition (write slot 0 while sampling slot 1, no feedback copy), plus -the stage-1a blit. Suites on the merged state: Optimum.Tests 1243 passed, GPU 1065 passed, 0 failed, patches -157/0 conflict. Headless both-backends run on the RTX 4070, AO pinned to vanilla so the backends compare like for -like: renderer line confirmed per run, 0 client errors, validation 0 errors and 0 `SYNC-`, native chain active in -the real client. Per-frame SSIM Vulkan vs OpenGL 0.9756 / 0.9573 / 0.9697 against this session's OpenGL-vs-OpenGL -noise floor of 0.9597 / 0.9611 / 0.9670 - at or above the floor, i.e. the backends differ no more than two OpenGL -launches of the same save differ from each other. - -Every item of `/home/n1ght/.claude/plans/i-never-wanted-this-sequential-kernighan.md` checked against this tree. -The plan predates the PR #69 split, so it also contains DLSS, upscaler, frame-generation, HDR and ray-tracing work: -those are marked `[out]` and are NOT owed on this branch. - -**In scope for this branch: 44 done, 15 partial, 19 left, 1 blocked, 2 superseded** (the 17 audited items plus the two foundations below).** -**Out of scope for this branch: 11 items** - PR #69 carries the Vulkan backend and TAA only; DLSS, upscalers, -frame generation, the vendor latency backends, NGX, HDR and ray tracing live on `feat/dlss`, `feat/dlss-g` and -`feat/latency` and are NOT work owed here. They appear in the plan because the plan predates that split. - -Audit method: five read-only agents, each required to cite a file, test or commit for anything marked done; a claim -in the plan or in the handoff was not accepted as evidence. - -Legend: `[x]` done, `[~]` partly done (what is left follows it), `[ ]` not started, `[!]` blocked externally, -`[-]` superseded by a later decision, `[out]` out of scope for this branch. - -**Step 0: branching; Phase 0: foundations; Phase 1A: platform substitution; Phase 1B: synchronisation foundation; constrai - -- [x] DONE — **Step 0**: Branching: fix/taa-sky-direction and feat/vulkan-native from origin/main -- [x] DONE — **Phase 0**: Foundations: patcher capabilities, diagnostics, validation default, parity dump, acceptance doc skeleton -- [x] DONE — **Phase 0 exit criteria**: Phase 0 exit: builds/suites green, both dump paths run, GL-vs-GL noise floor, VK-vs-GL table, pacing baselines recorded -- [x] DONE — **Phase 1A step 1**: VulkanClientPlatform forwarding subclass; SetupOptimumFrameBuffers moved; ClientProgram.Start wiring; csproj donor reference -- [x] DONE — **Phase 1A step 2**: TAA members to the abstract class; 7 casts become virtual calls; 14 Optimum.Tests files re-pointed -- [x] DONE — **Phase 1A step 3**: Program/uniform/UBO virtuals; ShaderProgramBase.cs and UBO.cs revert to vanilla plus virtual calls; per-draw CPU measured -- [x] DONE — **Phase 1A step 4**: Remaining leaf sites moved; IOptimumGraphicsDevice/OptimumRender.Device/OptimumRenderBootstrap.Install deleted; ClientPlatformWindows branch-free -- [x] DONE — **Phase 1A Tests**: Phase 1A test list: GL.-grep source test, no-lambda test, fallback re-assigns ScreenManager.Platform, no device/cast remnants, PlatformSubstitutionTests, identical-pixel GPU tests -- [x] DONE — **Phase 1A Exit**: Phase 1A exit: identical screenshots per backend; forced-install-failure fallback exercised with the exact log line -- [x] DONE — **Phase 1B step 1**: FrameTimeline + RetireQueue; FrameRing on timelines; blocking waits = 1/frame -- [x] DONE — **Phase 1B step 2**: UploadManager + per-slot upload command buffer (backend A); ReadbackManager + SubmitPartial + QueryRing; SubmitAndWait/FlushFrame deleted -- [x] DONE — **Phase 1B step 3**: Readback in a frame: ReadbackManager.CopyToHost + SubmitPartial; only screenshot path waits -- [x] DONE — **Phase 1B step 4**: Swapchain/SwapchainRetirement/IPresentPath split submission; resize/alt-tab/minimise clean under sync,best; acquire wait stage never ALL_COMMANDS -- [x] DONE — **Phase 1B step 5**: VulkanAllocator pool classes + budget; static meshes off ReBAR; allocator policy tests; heap report -- [x] DONE — **Phase 1B step 6**: Per-slot indirect ring; descriptor arena; dirty-masked dynamic state; free GetError; CPU frame time drop measured, draw counters unchanged -- [~] PARTIAL — **Phase 1B Tests**: GPU test list: AsyncTransferTests, PresentDecouplingTests, SwapchainRecreationVisualTests, ConcurrentDeviceAccessTests, ReadbackMidFrameTests, QueryRingTests, AllocatorPolicyTests; unit: IndirectRingWrapTests, TimelineLifetimeTests, PresentWaitStageTests, SwapchainRetirementTests -- [x] DONE — **Phase 1B/1A Exit (combined, Phase 1 exit)**: Phase 1 exit: both renderers start; forced-install-failure fallback; sync,best 0 errors; blocking uploads 0; build/test counts recorded -- [x] DONE — **Constraint: Cecil transplant rules**: No cached lambdas / LINQ predicates / non-capturing lambdas / hidden-helper lowering in transplanted bodies -- [x] DONE — **Constraint: Patcher capabilities (typesToUnseal/methodsToVirtualize/verifier)**: typesToUnseal clears TypeAttributes.Sealed; methodsToVirtualize sets Virtual|NewSlot|HideBySig; call-vs-callvirt verifier fails the patch on a stray call -- [x] DONE — **Constraint: Hardware floor**: Vulkan 1.3 + dynamicRendering, synchronization2, timelineSemaphore, scalarBlockLayout, independentBlend, multiDrawIndirect; optional tiers with fallback + env override - -**Phase 2: frame graph -> Milestone 1; Phase 3: native shaders - -- [x] DONE — **Phase 2 Step 1**: ResourceStateTracker + BarrierBatcher drive the immediate path -- [x] DONE — **Phase 2 Step 2**: FrameGraph streaming recorder + PassRecorder, coexisting with the non-graph path -- [x] DONE — **Phase 2 Step 3**: Write-mask motion tiers, clear promotion, FramePlan load/store solving; TAA through the graph -- [~] PARTIAL — **Phase 2 Step 4**: Transient aliasing implemented, default off, but not wired into the live per-frame graph path - - left: Wire FrameGraph/PassRecorder to call BindTransientForFrame per declared transient lifetime so aliasing can actually take effect outside tests. -- [x] DONE — **Phase 2 invariants pinned by tests**: Invariants pinned by tests -- [ ] LEFT — **Milestone 1 bullet: pacing-gate.sh passes**: M1 bullet 1 - pacing-gate.sh passes against the OpenGL baseline -- [x] DONE — **Milestone 1 bullet: blocking uploads/waits**: M1 bullet 2 - blocking uploads 0, blocking waits 1/frame -- [x] DONE — **Milestone 1 bullet: acquire ordering**: M1 bullet 3 - acquire after render submit, wait stage TRANSFER/COLOR_ATTACHMENT_OUTPUT -- [x] DONE — **Milestone 1 bullet: scopes and passes**: M1 bullet 4 - ScopesOpened==PassCount; no transition inside a scope -- [x] DONE — **Milestone 1 bullet: validation scripted session**: M1 bullet 5 - sync,best validation, zero [error] over the scripted session -- [ ] LEFT — **Milestone 1 bullet: SSIM parity TAA off**: M1 bullet 6 - per-attachment SSIM vs OpenGL, TAA off -- [x] DONE — **Milestone 1 bullet: TAA still-frame stability**: M1 bullet 7 - TAA on: luma-diff median within 0.3 of OpenGL, distant-leaf rejection <=1.5% -- [ ] LEFT — **Milestone 1 bullet: TAA acceptance rows re-pass**: M1 bullet 8 - TAA acceptance rows A11,A13,A14,A15,A17,A18 re-pass -- [x] DONE — **Milestone 1 bullet: in-game judgement**: M1 bullet 9 - user judges it in game on both backends -- [-] SUPERSEDED — **Phase 3 set convention**: Set convention: superseded by decision 9, implemented as a single shared layout -- [x] DONE — **Phase 3 placement table**: Uniform placement table -- [x] DONE — **Phase 3 manifest**: shaders.manifest.json schema and consistency -- [x] DONE — **Phase 3 compiler tool**: Offline shader compiler tool (--build/--verify/--single) -- [x] DONE — **Phase 3 adapter layout**: Mod-shader adapter retargeted to the shared layout in one change -- [x] DONE — **Phase 3 seven worktree stages of native GLSL**: Native GLSL ported in family stages -- [x] DONE — **Phase 3 scanner v2**: Launcher scanner v2 (ShaderAssetOverride, PlatformInternals, schema 2) -- [ ] LEFT — **Phase 3 temporal contract addendum-or-v2 decision**: Temporal contract addendum-or-v2 decision for native shaders - - left: Add a dated entry to docs/temporal-frame-contract.md (or a version bump) stating whether the native-shader motion-writer port is a v1 addendum or a v2 change. -- [ ] LEFT — **Phase 3 ReloadShaders no longer recompiling on a settings change**: ReloadShaders no longer recompiling on a settings change -- [~] PARTIAL — **Phase 3 Tests list**: Phase 3 Tests list (parity, motion-writer shape, manifest, adapter-layout, differential, launcher fixtures, no legacy extensions) - - left: The plan specifically asks that 'the eight Taa*Motion*Tests gain native-vs-rewriter differential cases (motion attachment equal within 1 ULP of RGBA16F)'. Searched TaaMotionWriterTests.cs, TaaEntityMotionWriterTests.cs, TaaInstancedMotionWriterTests.cs, TaaStandardMotionWriterTests.cs, TaaLiquidMotionTests.cs, TaaSkyMotionTests.cs and found no native-vs-rewriter comparison in any of them - all sti -- [ ] LEFT — **Phase 3 Exit criteria**: Phase 3 exit criteria (48 native/0 failed logged, SSIM>=0.99, validation clean, in-game settings sweep, vulkan-acceptance.md matrix, contract decision) - - left: Run and record the actual Phase 3 exit in docs/vulkan-acceptance.md: the native/rewritten/failed count from a real (non-headless-forced) launch, per-attachment SSIM, validation log, the full settings sweep on both backends, and the temporal-contract addendum-or-v2 decision. - -**Phase 3b (docs/vulkan-native-render-systems.md decisions 1-7, stage 1 nine-pass scope, parallel world-system stages, rem - -- [x] DONE — **Phase 3b decision 1**: Runtime rewriter stays permanently as mod-shader adapter -- [~] PARTIAL — **Phase 3b decision 2**: Seams are existing virtuals, overridden without calling base - - left: Post/TAA chain done: the nine passes are native (TAA resolve/sharpen keep the lib body for the temporal contract and re-route only the draw). No world-system transplanted seams exist yet (ChunkRenderer, entities, particles, GUI unchanged). -- [~] PARTIAL — **Phase 3b decision 3**: A native system reads client state, never GL state - - left: Rule only exercised by the blit; unverified for any world-render system since none has been ported. -- [x] DONE — **Phase 3b decision 4**: Device API for native systems (NativePasses) -- [~] PARTIAL — **Phase 3b decision 5: order and parallelism**: Stage 1 (device API + post/TAA chain) then parallel world systems then removal - - left: Stage 1 COMPLETE (2026-09-16, merge 2c9bc70): all nine chain passes native. Stage 2 (chunks, entities, particles/decals/sky/clouds, GUI/text) not started; stage 3 removal not started. -- [~] PARTIAL — **Phase 3b decision 6**: Behavioural identity is the acceptance rule (old-route vs native-route GPU tests) - - left: Chain passes have differential old-route-vs-native tests. World systems still need theirs once each goes native. -- [x] DONE — **Phase 3b decision 7**: FSR input identity preserved (BlitPrimaryToDefault keeps reading Primary colour 0) -- [x] DONE — **Phase 3b stage 1 scope: 9 chain passes**: Which of the nine post/TAA chain passes are native today -- [ ] LEFT — **Phase 3b: world render systems still on the emulation layer**: Every world render system still on the GL-emulation layer - - left: All world render systems (chunks, entities, particles, decals, sky/clouds, GUI/text) - stage 2 of decision 5 - are entirely unstarted. -- [ ] LEFT — **Phase 3b: GlStateTracker / texture-unit tables / uniform-by-location reachability**: GlStateTracker, texture-unit tables and uniform-by-location still reachable from the Vulkan path - - left: Not reachable only from the native blit's own pipeline creation; reachable and load-bearing for every other pass and every world system. -- [x] DONE — **Phase 4: disk pipeline cache**: Disk pipeline cache with FAIL_ON_PIPELINE_COMPILE_REQUIRED, background compile worker -- [x] DONE — **Phase 4: used-key manifest**: Used-pipeline-key manifest for pre-warming -- [x] DONE — **Phase 4: warm-up**: Background warm-up from the manifest -- [ ] LEFT — **Phase 4: push-constant placement from a measured profile**: Push-constant placement frozen from OPTIMUM_VULKAN_UNIFORM_PROFILE measurement - - left: Entire item: the env-driven measurement tool, the manifest field, and the placement logic reading it are all absent. -- [x] DONE — **Phase 4: animation SSBO ring**: Bone/animation data on a storage-buffer ring with dynamic offsets -- [ ] LEFT — **Phase 4: Use() include-block early-out**: Skipping ShaderProgramBase.Use()'s frame-global include-block writes when unchanged - - left: Entire item unimplemented; Use() still writes all ~50 frame-global uniforms unconditionally every call. -- [ ] LEFT — **Phase 4: per-pass GPU timestamps**: Per-pass GPU time table from timestamp queries - - left: No timestamp-query infrastructure exists; no per-pass ms table has been produced. -- [ ] LEFT — **Phase 4: transient aliasing default on**: Transient aliasing switched on by default after clean validation on all targets - - left: Default flag flip to on, plus the required clean-validation-on-all-targets gate, have not happened. -- [-] SUPERSEDED — **Phase 4: bindless decision**: Bindless set (originally set 2, later set 1) adopted based on measured descriptor-miss rate -- [ ] LEFT — **Phase 4: DirectToSwapchain**: DirectToSwapchain present policy measured and kept only if it wins - - left: Entire item unimplemented and unmeasured. -- [ ] LEFT — **Phase 4: transfer backend B**: Dedicated-transfer-queue backend (B) measured against backend A - - left: No ITransferBackend abstraction, no backend B implementation, no measurement exists. -- [ ] LEFT — **Phase 4: exit criteria**: Phase 4 exit - Vulkan mean FPS >= OpenGL, p99 <= OpenGL, pipeline cache hit rate >= 95%, per-pass ms table within 10% of GPU frame time - - left: Every numeric exit criterion is unmeasured, or where a related number exists (Milestone 1 pacing) it fails the bar; the required 30-minute session and doubled perf-capture.sh runs have not been executed. - -**Phase 5: mod API and fork ports; Phase 6: upscaler and frame-generation seams; Latency seams section (L0 types, S1-S8, b - -- [~] PARTIAL — **Phase 5**: Mod API and fork ports - - left: No evidence the exit criterion 'VSEssentials/VSSurvivalMod/VSCreativeMod renderers checked against declared passes' was done: grep for OptimumPass/RegisterOptimumPass/MotionWriter across VSEssentials, VSSurvivalMod, VSCreativeMod (working trees) and their patches/ directories returns nothing; none of the 40+ existing fork renderer patches (CloudRendererVolumetric, MechNetworkRenderer, EntityShapeR -- [out] Vendor orchestrator decision: Optimum builds its own multi-vendor orchestrator (not Streamline) — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Vendor orchestrator decision: Slot coupling: vendor latency backend only when upscaler vendor matches GPU — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Vendor orchestrator decision: NVIDIA goes direct: Reflex via VK_NV_low_latency2, DLSS/DLSS-G via NGX P/Invoke (no Streamline) — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Vendor orchestrator decision: Intel on Windows: D3D12 bridge present path with XeFG and XeLL — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Latency seams: L0 types, S1-S8 seams, backends None/Native/NvLowLatency2/AmdAntiLag — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Latency seams: Acceptance numbers (section L) — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] NGX on native Linux: Spike result: NGX comes up through a native shim — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] DLSS SR evaluation: DLSS Super Resolution evaluates on the device — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Phase 6: Upscaler and frame-generation seams — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency - -**Roadmap items (HDR output, ray tracing, headless render harness, GTAO/XeGTAO 3 sub-steps), plan's Documentation-to-updat - -- [~] PARTIAL — **Roadmap: headless render harness**: Headless render harness that does not take the machine - - left: The doc's own admission (docs/vulkan-acceptance.md, headless section, 'What it does not cover'): 'No camera path is checked in yet - one has to be authored per scene with .cam p and .cam save.' The roadmap's acceptance bar - 'the shimmer class of bug (jitter, disocclusion, AO noise) shows up as a number from that sequence' via a checked-in deterministic camera path - has not been demonstrated; the -- [x] DONE — **GTAO order-of-work step 1**: GTAO step 1: composite AO into the scene at render resolution, before the resolve -- [x] DONE — **GTAO order-of-work step 2**: GTAO step 2: make the dither temporally varying -- [~] PARTIAL — **GTAO order-of-work step 3**: GTAO step 3: port XeGTAO and judge it against the fixed SSAO - - left: All of section D's measurement plan: converged numerical reference, thin-foliage/halo numbers, temporal-stability numbers vs vanilla SSAO+TAA, per-pass GPU cost on Arc-class and RTX hardware, and the resulting handheld-preset decision. Until then GTAO has landed as a code path but has not been 'judged' by the plan's own definition. -- [~] PARTIAL — **Documentation to update**: Documentation-to-update list (VULKAN-BACKEND-PLAN.md v2, acceptance/allowlist docs, contract addendum, CLAUDE.md, skills) - - left: Rewrite VULKAN-BACKEND-PLAN.md in place to v2 (or formally mark it superseded/archived and delete stale sections instead of leaving contradictory content live); record the Phase-3 temporal-contract addendum-or-v2 decision somewhere durable; add the shaders-vk source-of-truth row, check-shaders-vk build step and the missing env vars to CLAUDE.md; update the three named skills with the manifest/paci -- [~] PARTIAL — **Risks (ranked) mitigations**: Risks section: are the 10 ranked mitigations actually in place - - left: Fill in docs/vulkan-acceptance.md section 6's vendor matrix with the numbers that already exist elsewhere (risk 6); record the Phase-3 temporal-contract decision (risk 8, shared with the Documentation item above). -- [!] BLOCKED — **Handoff item 1**: Fix the present-after-write hazard - - left: Waiting on: the complete first-message text of one of the five failures, captured on the Windows GTX 1060 (or another Pascal/580-branch device) by running `dotnet test Optimum.Render.Vulkan.Tests --filter --logger "console;verbosity=detailed"` with the implicit Vulkan layers disabled, plus that machine's driver version and `vulkaninfo --summary` (present modes, image counts) rec -- [~] PARTIAL — **Handoff item 7**: Caching follow-ups - - left: Two items explicitly still open, confirmed absent from the code: VK_KHR_pipeline_binary (grep for 'PipelineBinary'/'pipeline_binary' across Optimum.Render.Vulkan: zero hits) and a real-client warm-start check driven through the headless harness (no 'warm-start' or 'WarmStart' hit anywhere outside the two progress-doc lines that call it open). -- [ ] LEFT — **Handoff item 9**: General refactor: split VulkanDevice.cs, restructure the project, remove GL-emulation leftovers - - left: Everything: splitting VulkanDevice.cs into smaller units, any project-layout restructuring, and removing GlStateTracker.cs plus its call sites. This is item 9 of 12 on the to-do list and item 5 (Phase 3b native render systems, a prerequisite for retiring GlStateTracker per the handoff's own text) is itself only one stage in (device API + native blit merged; chunks/entities/particles/GUI on native -- [~] PARTIAL — **Handoff item 11**: Validation milestones 2-7 - - left: A real per-area CI split including a scheduled GPU-AV run; AMD/RADV coverage; a lavapipe CI lane; a written, evidenced sign-off against the Khronos checklist; debug object naming and command-buffer labels; Aftermath and a GFXReconstruct reference capture. -- [ ] LEFT — **Handoff item 12**: Cleanup for review (last) - - left: The entire item: read VULKAN-BACKEND-PLAN.md fully and reconcile/retire it, review the named scripts and Core/RenderTargetManager.cs for tooling/workflow references, and (with the owner's OK per the branch's binding rule on history rewrites) squash or rewrite the ~23-27 worktree/merge-wave commit subjects before the upstream PR, plus the optional host-environment test fixes (numpy self-tests, Wind -- [out] Roadmap: HDR output: HDR output — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency -- [out] Roadmap: ray tracing: Ray tracing — not this branch; tracked on feat/dlss / feat/dlss-g / feat/latency - -#### Vulkan foundation, in scope for this branch (owner's call, 2026-09-16) - -Frame structure is part of the Vulkan backend, not vendor work. A backend with an explicit frame graph needs one -identity per frame, with markers around simulation, submit and present, and it needs the world frame separated from -UI composition; both stand on their own whether or not an upscaler ever exists, and both are what make pacing -measurable and keep the HUD out of the scene image. They are IN SCOPE here. What stays off this branch is the vendor -layer that later sits on top of them: DLSS, XeSS, FSR, frame generation, the NV/AMD/XeLL latency backends and NGX. - -- [ ] Foundation A, frame marking (source `feat/latency`): L0 latency types, the pre-input `LatencySleep` lib seam, - `IDeviceRequirementContributor` and the pNext chain builder in `CreateDevice`, one frame id per frame, - `VkPresentIdKHR` chaining, markers around simulation / render submit / present, the `stats.latency` line. - Explicitly excluded: the NV, AMD and XeLL backends. -- [ ] Foundation B, GUI separation (source `feat/dlss-g`, not `feat/dlss`): the `SceneNoHud` snapshot (slot 23) at - the end of `RenderFinalComposition`, and the UI target (slot 24) with a `ui-compose` pass composed back before the - `Done` stage. Explicitly excluded: the upscaler subsystem its gate reads there, and two-presents-per-frame. - Conflict: the `feat/dlss-g` hooks live in the lib GL body of `BlitPrimaryToDefault`, which is overridden here and - dispatches to `RenderNativeBlit()` - ported verbatim they are dead code on Vulkan and must be re-implemented at - that method's three exit points, the lib patch kept for the OpenGL path only. - -### Done - -| Commit | What | -|---|---| -| 41373cf | AO into the scene before the TAA resolve; per-frame SSAO dither (temporal stability) | -| 766aada | Headless render harness backport | -| f83bda2 | Plan of record with the full-native roadmap | -| 19d101e | Review blockers: shaderc in the app root, prime-run guard, numpy probe, Windows bootstrap | -| a41efce | Shared frame block (set 0 `FrameGlobals`), sets reordered | -| 84f4893 | Docs follow the history rewrite | -| 9b52768 | Research notes: caching, descriptor model, XeGTAO, validation | -| 618a0b1 | Persisted SPIR-V cache and driver pipeline cache | -| 5bc337b | Plan decision 9 (one pipeline layout, bindless now); set-convention table and mod adapter rewritten | -| 31684e3 | Validation extra checks via `VK_EXT_layer_settings`, layer version and applied settings logged | -| a9d0218, 11195c5 | Handover note (this file) | - -Upstream review blockers: shaderc placement, prime-run, numpy, Windows bootstrap - fixed. Swapchain resize -tests passed on Windows without the layer (the reviewer saw failures on Linux/MX150; recheck there). Donor -drift `TaaRuntimeDonorCoverageTests` 25/25 at this base (recheck whenever patches change). `vkDeviceWaitIdle` -before window release was already correct. - -### Test state (Windows machine, 2026-09-15) - -GeForce GTX 1060 (Pascal) on Windows. Pascal stays on NVIDIA's 580 driver branch; 590 and later dropped it. - -- GPU suite with the Vulkan SDK 1.4.357.0 validation layer: 661 tests, 6 failures. - - `PacingStatsTests.PacingGateReadsTheLinesThisBackendWrites`: host issue (WSL path translation), not a - renderer defect. - - Five new, all `SYNC-HAZARD-PRESENT-AFTER-WRITE` ("no sufficient synchronization is present to ensure - that a swapchain present operation does not conflict with a prior layout transition"): - `PresentDecouplingTests.RecordingTimeDoesNotGrowWithTheInjectedAcquireDelay`, - `SwapchainRecreationTests.AHiddenWindowResizeLoopRecreatesWithoutWaitingAndStaysClean`, - `SwapchainTests.TogglingVsyncRebuildsTheChainCleanly`, - `SwapchainTests.ADeviceComesUpAgainstARealWindowAndPresentsFrames`, - `SwapchainTests.ResizingRebuildsTheChainAndKeepsPresenting`. - They were hidden before because no layer was installed. -- Optimum.Tests: 5 host-environment failures (pacing gate x2, numpy self-tests x2, `_ref/` not materialised). - -### Test state (Linux notebook, 2026-09-15, at 99b836d) - -RTX 4070 Laptop, driver 615.71.09, X11 (XWayland), Vulkan SDK layers 1.4.357.0 - the same layer version as the -Windows run above. - -- **Implicit layers switched off for every run**, confirmed with `VK_LOADER_DEBUG=layer`: MangoHud is enabled - globally on this machine and `VK_LAYER_LS_frame_generation` has no enable variable, so both would otherwise hook - the test host and draw or present on the swapchain. Only `VK_LAYER_MESA_device_select` stays (it orders - devices). Environment: - `MANGOHUD=0 DISABLE_MANGOHUD=1 DISABLE_LSFG=1 DISABLE_VK_LAYER_VALVE_steam_overlay_1=1 DISABLE_VK_LAYER_VALVE_steam_fossilize_1=1 DISABLE_GAMESCOPE_WSI=1 DISABLE_VULKAN_RENDERDOC_CAPTURE_1_45=1 DISABLE_LAYER_MESA_ANTI_LAG=1`. -- **Validation is live in the suite**, not assumed: the loader inserts `VK_LAYER_KHRONOS_validation` into the test - process, `ValidationFeaturesTests` asserts the layer settings were applied, and - `SyncValidationControlTests.AnUnsynchronisedWriteAfterWriteIsReportedUnderASyncId` provokes a hazard and passes - only because sync validation reports it. -- GPU suite: **661 passed, 0 failed, 0 skipped, no `SYNC-` messages.** The five present-after-write tests listed - above pass here, and they also pass with MangoHud and the frame-generation layer switched back on. -- Optimum.Tests: 1177 passed, 34 skipped, 0 failed. The one failure before `99b836d` was - `SsaoTemporalDitherCoverageTests.WithoutATemporalConsumerTheOverrideIsTheVanillaShader` reading the deployed, - override-carrying copy of `ssao.fsh`; it now reads the client archive. - -**Item 1 status: does not reproduce here; the failing hardware is Pascal on the 580 driver branch.** - -- Reproduction matrix on this notebook, the five tests only, implicit layers off, layers 1.4.357.0: RTX 4070 on - Wayland and on X11 (GLFW platform chosen by unsetting `DISPLAY` or `WAYLAND_DISPLAY`; setting them to an empty - string makes GLFW fail and every test skips), and the Intel UHD iGPU (Mesa ANV, selected with - `VK_LOADER_DRIVERS_SELECT=*intel*`; `MESA_VK_DEVICE_SELECT` alone still hands the tests the NVIDIA device) on - Wayland and on X11. **5/5 passed in all four, zero `SYNC-` messages.** The present path has not changed since - the Windows run (no commits under `Present/`, `FrameRing.cs` or `VulkanDevice.cs` after `11195c5`). -- The two machines that failed share an architecture: the Windows GTX 1060 above, and the upstream reviewer's - MX150 on Linux (driver 580.173.02), whose resize-loop tests also failed (reported as "swapchain fence signaling - races" in the PR 69 review). Both are Pascal, and Pascal is frozen on the 580 branch, so the driver's - acquire/present behaviour (image index order, SUBOPTIMAL/OUT_OF_DATE results, image counts, present modes) is - the variable this notebook cannot vary. -- What the layer needs to report it (`layers/sync/sync_submit.cpp`, `QueueBatchContext::ResolvePresentSemaphoreWait` - and `DoQueuePresentValidate`): a present on the same queue imports the batch that signalled its wait semaphore - through a barrier, and everything else from the queue's last batch without one. `SYNC-HAZARD-PRESENT-AFTER-WRITE` - therefore means the image's last layout transition reached the present by the unbarriered route: the wait - resolved against a different batch than the one that transitioned the image, or the layer found no signal for the - semaphore. Our signal stage is not the cause: `VkSubmitInfo` signal semaphores are converted to `ALL_COMMANDS` - (`layers/utils/convert_utils.cpp`), which covers layout transitions since KhronosGroup/Vulkan-ValidationLayers#7479. - Two layer facts to check against the full message: `PreCallRecordDestroySemaphore` erases pending timeline signals - but not pending binary ones, and an acquire records its semaphore's signal with `emplace`, which ignores an entry - already present for the same handle. -- **Needed from the Windows machine before any code change:** the complete text of one failure. The assertion prints - the first full layer message per hazard id (`ValidationAssert.NoSyncHazards`, "first message"), which names the - prior access: command buffer, submit index, batch tag and command. Run the five tests alone - (`dotnet test Optimum.Render.Vulkan.Tests --filter --logger "console;verbosity=detailed"`) with the - Windows implicit layers disabled, and record the driver version and `vulkaninfo --summary` (present modes, - image counts) next to it. - -### Test state (Linux notebook, 2026-09-16, at 14f0779): the native shaders in the real client - -Headless captures of `serene cave world` (`scripts/dev/headless-capture.sh`, 5 frames from in-world frame 300, -window never mapped), implicit layers off, `sync,best` validation to a file. The dev client was deployed with -`make deploy INSTALL_DIR=/nonexistent-...` so the user's own install was not touched. - -- **Vulkan, native shaders forced** (`OPTIMUM_VK_NATIVE_SHADERS=force`): `[Optimum] shaders: 63 native, 4 rewritten, - 0 failed` (the 4 have no manifest entry: the inline `MinimalGui`, the mod-registered `optimum-map` and two - registration variants; nothing fell back with a reason). **0 validation errors, 0 `SYNC-` messages.** GTAO ran - (up to 177 compute passes per stats sample). -- **Vulkan, default**: the dev client has no launcher scan, so the conservative rule put every program on the - rewriter (`0 native, 50 rewritten`) - as designed. Same run: 0 validation errors, 0 `SYNC-`, GTAO active. -- **OpenGL**: ran unchanged, twice. -- **Pixels (`scripts/dev/ssim.py`)**: two OpenGL launches of the same save differ by **SSIM 0.9757** (mean abs 1.73) - - the same-session noise floor, since a launch differs in world time, weather and entities. Native vs rewriter on - Vulkan is **0.9770** (1.58), *inside* that floor: the 49 native programs introduce no measurable pixel difference. - Vulkan vs OpenGL is 0.969 and native vs OpenGL 0.964, both around the floor and not a per-pass comparison. -- Not yet measured: a deterministic comparison with the scene stilled (`--commands`, `--fixed-dt`), the AO - measurement plan of `docs/research/ambient-occlusion.md` section D, and pacing numbers. - -### Next, in order - -1. **Fix the present-after-write hazard.** The present has to wait on a semaphore signalled by the submit - that transitions the swapchain image to PRESENT_SRC, with one render-finished semaphore per swapchain - image indexed by the acquired image (`docs/research/vulkan-validation.md` §4; Vulkan Guide "Swapchain - Semaphore Reuse"). That pattern is already in place; the hazard is Pascal/580-specific and waits on the full - message from the GTX 1060 (see "Item 1 status"). Exit: the five tests pass with the layer on Pascal; the rest - of the suite is unchanged. -2. **Bindless implementation research: done** (`docs/research/vulkan-bindless.md`). Design outcome: combined-image-sampler - arrays in set 1, one binding per GLSL sampled type (2D, 2DArray, Cube, 3D, usampler2D, isampler2D, the shadow - variants), `PARTIALLY_BOUND | UPDATE_AFTER_BIND`, slot 0 a placeholder per type; per-draw indices in push - constants (no `nonuniformEXT`); slot writes batched once per frame; frees deferred until the timeline says - the frames that could sample them finished; set 0 stays a normal set (dynamic UBOs cannot be - update-after-bind); limit checks on the update-after-bind sampled-image/sampler counts, dynamic UBOs and - push-constant size; transient aliasing must resolve to the physical texture's slot. -3. **Phase 3 groundwork on decision 9:** one global pipeline layout (set 0 frame UBO + frame textures, set 1 - bindless textures + shared samplers, set 2 storage, push constants <= 128 B); descriptor-indexing feature - and limit check at startup (without it the session stays on OpenGL); `bindings.glsl` + - `Shaders/SetConvention.cs` with an agreement test; uniform placement table (push | frame | per-frame - record | storage | texture slot). - **Status (2026-09-15):** steps 1 and 2 of the decision-9 sequence are in. Capability negotiation: - `Core/DescriptorIndexingFloor.cs` judges runtimeDescriptorArray, partially bound, sampled-image update-after-bind, - dynamic sampled-image indexing and the update-after-bind sampled-image/sampler, dynamic-UBO and push-constant limits - against the set-1 table; `IsUsable` rejects a device below it (OpenGL fallback with the named reason), `CreateDevice` - enables the features, `VulkanCapabilities.DescriptorIndexing` carries them and the device-up line logs them. Set - convention: `sources/shaders-vk/include/bindings.glsl` (source of truth) and `Shaders/SetConvention.cs`, pinned by - `SetConventionTests` (defines, declarations, uniqueness, floor agreement, the include compiles). Tests: - `BindlessCapabilityTests` (floor logic, researched vendor limits, a decision-9 layout created and allocated with no - validation message on the selected device). GPU suite 673/673, no `SYNC-`; both local devices (RTX 4070, UHD ADL-S) - meet the floor. Open for the layout step: best practices' AMD check `KeepLayoutSmall` warns on that layout's - 128-byte push-constant range; size the real push block from the uniform placement map, not the maximum. - **Status (2026-09-15, evening): done.** The bindless texture table (slot allocator keyed on the physical texture, - deferred free on the Frame timeline, per-kind placeholders: opaque black, magenta only under poison mode) and - `SharedPipelineLayout` are merged, and every program now links against the one layout: set 0 frame block and - frame textures, set 1 bindless arrays, set 2 FaceData, named uniform blocks as std140 storage buffers - (Animation 1, AnimationPrev 2, others 4-7) and the program record (dynamic UBO, binding 3), samplers as bindless - slots in push constants. Per-program layouts are gone. GPU suite 878/878 at the retarget merge. -4. **Rewriter retargeted** to the shared layout (samplers -> bindless indices, loose uniforms -> per-frame - record addressed from push constants), then native GLSL 450 per program family (includes; post programs; - GUI/lines; chunks; entities; particles/decals/sky/clouds; SSAO/godrays/bloom/colorgrade/OIT; Optimum - programs), offline compiler tool + `shaders.manifest.json` + MSBuild target + packaging, runtime manifest - load with the "N native, M rewritten, K failed" log line, specialization constants for quality defines, - parity tests against the GLSL 330 sources. - **Status (2026-09-15, evening):** the rewriter half is done (item 3). Native GLSL 450: contract - `docs/vulkan-native-shaders.md`; shared includes, `frame.glsl`/`specialization.glsl` generators and - `motion.glsl`; the offline compiler (`tools/shader-compiler`, SPIR-V reflection, `shaders.manifest.json`, - MSBuild target, deploy and packaging beside the renderer DLL); the static parity harness - (`NativeShaderParityTests`, GLSL 330 oracle vs manifest); all 49 registered programs ported (MinimalGui and the - mod-registered optimum-map stay on the rewriter). GPU suite 968/968 at the family merges. In progress: the - runtime seam (manifest load in `LinkProgram`, per-program rewriter fallback, placement-table locations, - initializer seeding, the `Array` alias for sampler2DArray, native-vs-rewriter pixel tests). Open after it: the - settings-change reload through specialization constants, launcher scanner v2 (in progress), in-game check on - both backends. -5. **Phase 3b:** native render systems (post chain and TAA first, then chunks, entities, - particles/decals/sky, GUI/text); remove `GlStateTracker`, GL id tables, texture units and - uniform-by-location from the Vulkan path; decide the runtime rewriter's fate for mod shaders. -6. **Phase 5:** mod pass and motion-writer API, fork renderers on native systems, scanner v2, mod - documentation for Vulkan-native support plus a fixture mod. -7. **Caching follow-ups** (`docs/research/vulkan-caching.md`): `FAIL_ON_PIPELINE_COMPILE_REQUIRED` with - background compiles, growth-triggered saves, pipeline-key log for pre-warming, optional - `VK_KHR_pipeline_binary`; real-client warm-start check with the headless harness (needs game data). - - Landed (design items 2, 4, 5): render-thread creation with - FAIL_ON, skipped draws while a bounded worker compiles against its own cache and merges under a - lock, publication at frame start; growth-triggered saves from a worker (8 MiB, sampled every 10 s) - next to the shutdown save; the versioned, LRU-capped `pipeline/.keys` log with prewarm when a - matching program links; `stats.pipelines`. GPU tests default to blocking creation (`GpuTest`). - - Still open: `VK_KHR_pipeline_binary` (deferred, research item 6) and the real-client warm-start - check with the headless harness. -8. **GTAO with visibility bitmasks** (XeGTAO-derived; XeGTAO itself is archived since 2024-04-22, see `docs/research/xegtao-integration.md` section 0; the combined design is `docs/research/ambient-occlusion.md` section C; physically correct, default AO on Vulkan while TAA is active): compute pass kind in the frame graph, GLSL compute - port (prefilter split into dispatches, main pass, one denoise pass with TAA), NoiseIndex = frame % 64, - composition before the resolve, settings; OpenGL keeps vanilla SSAO; tests and a headless comparison. - **Status (2026-09-16):** landed on the branch - the frame-graph compute pass kind, the GTAO passes, the - class channel, the composition before the resolve and the settings, with the native port carrying - OPTIMUMAO as specialization constant 12. The Optimum options tab now also carries an ambient occlusion - master switch (`OptimumConfig.AmbientOcclusionEnabled`, default on, `optAo`): it gates `RenderSSAO` only, - so both AO paths stop together, the SSAO G-buffer and the stamped shader defines stay untouched, and it - flips live with no shader reload, no frame buffer rebuild and no temporal reset - the in-game A/B for - judging what AO contributes. Switching it off also sets `optimumSsaoInScene`: the scene shaders stay - compiled with `SSAOLEVEL > 0`, so `final.fsh` would otherwise multiply by an SSAO target nothing wrote - that frame and darken the whole image (found in game, 2026-09-16). Beside it, `AmbientOcclusionDebugView` - (`optAoDebug`) writes the AO term alone as greyscale in the final composition, sourced from the GTAO - output when it ran and the vanilla blurred target otherwise - the same branch in both shader twins - (`final.fsh`, `final.frag`), before colour grading. Still open: the section D measurements and the - deterministic stilled-scene comparison. -9. **General refactor:** split `VulkanDevice.cs`, restructure the project layout, remove GL-emulation leftovers. -10. **Optimisation** (plan Phase 4): per-pass GPU timestamps, push-constant placement from the measured - profile, transient aliasing on by default, DirectToSwapchain / transfer backend measured. Exit: Vulkan - mean FPS >= OpenGL and p99 <= OpenGL on the fixed scene. -11. **Validation milestones 2-7** (`docs/research/vulkan-validation.md`): per-area runs (core / sync / - best + vendors / GPU-AV nightly), versioned `message_id_filter` suppression list, headless sessions - clean on NVIDIA/AMD/RADV/ANV, lavapipe CI lane, Khronos checklist review, debug names and device fault - reports. -12. **Cleanup for review (last).** Inventory so far - tracked files with tooling/workflow references: - `TAA-PLAN.md`, `VULKAN-BACKEND-PLAN.md` (read it fully), `docs/vulkan-acceptance.md` (rule references), - `docs/taa-acceptance.md`, `scripts/dev/worktree-bootstrap.sh`, `scripts/tests/bootstrap-git-repository.sh`, - `scripts/dev/parity-capture.sh`, `scripts/dev/luma-diff.py`, `Optimum.Render.Vulkan/Core/RenderTargetManager.cs`, - `Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs`, `patches/.../ClientProgram.cs.patch`. About 27 - commit subjects since 553afdb carry worktree/"merge wave" artefacts; changing them needs a history - rewrite (owner's OK and merge-base check) or a squash for the upstream PR. No co-author trailers exist. - Optional: fix the host-environment test failures (numpy self-tests, pacing gate path translation on Windows). - -## 6. Setting up the notebook - -1. `git fetch origin && git checkout feat/vulkan-taa && git pull` -2. Set the git identity (section 2) and verify with `git config user.email`. -3. Bootstrap if the tree is not materialised (`make bootstrap`, or `scripts/bootstrap.ps1 -Refresh` on Windows). -4. Install the Vulkan validation layers (`winget install KhronosGroup.VulkanSDK` on Windows; the distro's - `vulkan-validation-layers` package on Linux), otherwise the validation tests skip. -5. Run both test suites and compare with section 5, then start with item 1. - -Untracked `shaderincludes/` at the repo root is a bootstrap artefact; leave it alone. diff --git a/docs/vulkan-native-plan.md b/docs/vulkan-native-plan.md deleted file mode 100644 index e4aa735d..00000000 --- a/docs/vulkan-native-plan.md +++ /dev/null @@ -1,876 +0,0 @@ - - -# Plan: from OpenGL-under-Vulkan emulation to a proper Vulkan backend - -## Roadmap (user, 2026-09-15) - -This branch, `feat/vulkan-taa`, carries the Vulkan backend and TAA as their own pull request. It starts at -`9ad0c70` (Milestone 1 on `main`, before the latency and DLSS work); upscaling, latency and frame -generation stay on `feat/dlss-g` for later pull requests. The work runs in this order: - -1. **Fully Vulkan-native, no OpenGL mimicry** (decisions 7 and 8): native shaders with offline SPIR-V - (Phase 3), the GL-emulation layer retired from the Vulkan path (Phase 3b), and the mod API and fork - ports on the native model, with published documentation for adding Vulkan-native support to a mod - (Phase 5). -2. **XeGTAO** replaces the vanilla SSAO, as native compute. -3. **General refactor.** -4. **Optimisation, streamlining and simplification**, including what Phase 4 lists. -5. **Validation against Vulkan best practice**: the validation layer's best-practices checks, the NVIDIA, - AMD and Intel sets included, and a review against the Khronos and vendor guidance. -6. **Cleanup of the commit history and documentation** for the upstream review. - -The upstream review's remaining blockers land alongside, where they fall: `libshaderc_shared.so` in the -application root, `run-client.sh` without a hard `prime-run`, `numpy` in the prerequisites, the three -swapchain resize tests, the runtime donor drift, and a device-idle wait before the window is released. - -**Where the branch is (2026-09-15).** Two backports from the DLSS line are in, neither judged in game yet: -- `41373cf`: the jittered AO is shaded into the scene before the TAA resolve, and the SSAO dither advances - per frame. On the DLSS line this pair (`2acede1`, `52b9d6c`) removed the whole-frame jitter that the - resolve's 3x3 nearest-depth test and anti-flicker weighting had only damped. -- `766aada`: the headless render harness (its roadmap item below). - -## Context - -Optimum's Vulkan backend (`Optimum.Render.Vulkan/**`, ~12k lines) sits behind the GL-shaped -seam `IOptimumGraphicsDevice` and reproduces OpenGL semantics call by call: state toggles are -recorded and resolved per draw, rendering scopes are inferred from framebuffer/draw-buffer -changes, every layout transition is an `ALL_COMMANDS` barrier, every texture upload is a -synchronous submit-and-wait that first flushes the half-recorded frame, presentation is a single -submission that waits for the swapchain image at `ALL_COMMANDS`, and the indirect scratch is a -wrapping ring sized by heuristic. It passes sync validation and renders the same pixels as -OpenGL, but it cannot pipeline: the CPU and GPU serialise on uploads and on presentation, frame -delivery is uneven, and the TAA work (jittered frames, history ping-pong, motion windows that -toggle draw-buffer masks dozens of times per frame) multiplies the scope restarts and barriers. - -The user's intent (2026-09-11): this was never meant to be an OpenGL emulator. It must become a -proper Vulkan backend: explicit frame structure, explicit synchronisation, asynchronous resource -streaming, decoupled presentation, and a design that the planned temporal work (FSR/XeSS/DLSS, -frame generation) can attach to. - -Built from a survey of the seam usage, the backend internals and tests, and the frame, temporal and -packaging constraints, and from the designs for platform integration, the renderer core and the shaders, -reconciled below. Every file:line fact quoted was re-checked in the tree. - -## Decisions taken with the user (2026-09-11 and 2026-09-15) - -1. **Scope: the client drives a frame graph.** The patched client announces frame and stage - boundaries; the platform declares passes, uploads and readbacks; the GL-shaped seam is not - the design centre any more. -2. **Mods: Vulkan-aware mods only.** Mods rendering through the game API land inside declared - passes and work; mods touching raw GL or Harmony-patching the platform's graphics members are - routed to OpenGL by the launcher scan. A mod-facing pass API is part of the new contract. -3. **Shaders: Vulkan-native GLSL for the vanilla program set**, explicit sets and bindings, - compiled offline to SPIR-V. The runtime rewriter stays only for mod shaders. -4. **Milestone 1 = stable frame delivery with TAA**: explicit sync, asynchronous uploads, - decoupled presentation and a declared post/TAA graph, measured by frame-time variance and a - zero blocking-upload counter, then judged in game. -5. **Integration shape: substitute the platform, do not branch the calls.** The game's own - graphics boundary is `ClientPlatformAbstract` (147 abstract/virtual members, ~100 graphics). - `ScreenManager.Platform` is a public static field typed to the abstract class - (`build/VintagestoryLib/Vintagestory.Client/ScreenManager.cs:29`); the sealed OpenGL class - `ClientPlatformWindows` is instantiated at one line (`ClientProgram.cs:214`). The patcher - unseals it and `Optimum.Render.Vulkan.dll` ships `VulkanClientPlatform : ClientPlatformWindows` - overriding the graphics virtuals; windowing, input, audio, frame pacing and the embedded - server stay in the base. OpenGL runs the base class, so "OFF is vanilla" is checkable. -6. **Why not leave Optimum:** any alternative against the closed client re-creates the same - Cecil/Harmony layer; the patcher, launcher scan, packaging, contracts, frozen temporal - contract and the GPU test suite carry over. -7. **Full native, no OpenGL mimicry (user, 2026-09-15).** Decisions 1 and 3 stop short: after native - shaders the device would still take GL-shaped calls - texture units, uniform locations, glEnable-style - state resolved per draw by `GlStateTracker`. The Vulkan backend has to reach its full performance - potential, so the render systems record pipelines and descriptor sets directly and the GL-shaped - members of `ClientPlatformAbstract` stop being the device's contract (Phase 3b). The OpenGL path stays - vanilla. -8. **Mods get documentation, not a compatibility layer (user, 2026-09-15).** Supersedes decision 2's - "mods rendering through the game API land inside declared passes and work": Optimum publishes how to add - Vulkan-native support to a mod - declaring passes, writing motion, shipping native shaders, drawing - through the native renderers (Phase 5). -9. **One pipeline layout, bindless textures from the start (user, 2026-09-15).** Supersedes the set - convention's per-pass and per-material sets and moves bindless out of Phase 4. Per-program layouts - invalidate bound sets on every program switch, and building a sampler set per draw is the hot path the - Khronos descriptor-management sample and Zink's measurements point at; descriptor indexing is portable - (DXVK requires it on every vendor). Every program shares set 0 frame, set 1 bindless textures and shared - samplers, set 2 storage, and one push-constant range (`docs/research/vulkan-descriptor-model.md`). - -## Constraints - -- **Cecil transplants** (`VULKAN-BACKEND-PLAN.md` §0, `Optimum.Tests/cecil-transplant-lambda-tests.cs`): - no lambdas cached in compiler-generated classes, no LINQ predicates, no non-capturing lambdas, - no hidden-helper lowering; injected types only as simple data holders; every changed member - listed in `Optimum.Patcher/Program.cs`. Hence all renderer logic lives in - `Optimum.Render.Vulkan` and the contracts assembly; the lib gains only virtual calls. -- **Temporal contract v1 is frozen** (`docs/temporal-frame-contract.md`, - `Optimum.Tests/temporal-contract-tests.cs`). A change is a v2 bump with tests, never silent. -- **Hardware floor**: Vulkan 1.3 + dynamicRendering, synchronization2, timelineSemaphore, - scalarBlockLayout, independentBlend, multiDrawIndirect. Targets: Intel Arc 140V (Windows), - NVIDIA and Intel Mesa (Linux), AMD RDNA. Everything above the floor is an optional tier with a - fallback and an env override that forces the fallback, so every tier is testable. -- **Verification rules**: a launch is not a verification; diff both paths; verify - in game on both backends at phase exits only; every fix has a GPU readback test and a - source-coverage test; temporal and pacing claims are proven by numbers and logs, never by - screenshot pairs. -- **Assembly identity (verified)**: vanilla and donor `VintagestoryLib.dll` are both unsigned and - both `AssemblyVersion 1.22.7.0` (`build/VintagestoryLib/Properties/AssemblyInfo.cs:17`), so - the renderer can compile against the donor and bind to the patched DLL at runtime, exactly as - it already does for `VintagestoryAPI`. - -## Step 0: branching - -Historical: `feat/vulkan-native` started from `main` at `48174c1` after TAA merged, with the sky-direction -fix on its own branch, and merged back into `main` at Milestone 1 (`9ad0c70`). `feat/vulkan-taa` starts -there. Never `git stash`. WIP commits use the `wip:` prefix. - ---- - -## Architecture (recommended approach) - -### A. Integration: `VulkanClientPlatform` - -**Shape.** `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs`, -`public class VulkanClientPlatform : ClientPlatformWindows`, owning the native renderer -privately. It overrides every graphics virtual (framebuffers, fixed-function state, meshes, -textures, shaders/uniforms/UBOs, post chain, TAA/FSR members, screenshots/queries/diagnostics) -and inherits windowing, input, audio, assets/logging, the singleplayer server, CPU bitmaps, AVI -and the frame-pacing block of `window_RenderFrame`. - -**No base-field widening is needed** (checked member by member): every private field of the base -is either exposed through an abstract property the subclass overrides (`CurrentFrameBuffer`, -`FrameBuffers`), or read only by methods the subclass overrides, or produced by a virtual the -subclass overrides (`frameBuffers = SetupDefaultFrameBuffers()` in `Start()`). If a step ever -needs `protected`, the design has drifted; the escape is a transplanted accessor, never -attribute surgery. - -**Base edits** (all in methods that are already Cecil targets; everything else in -`ClientPlatformWindows` reverts to vanilla and its 87 `OptimumRender.Device` branches are deleted): - -| Site | Edit | -|---|---| -| `ClientPlatformWindows.window_RenderFrame` | device branch becomes `BeginFrame(); frameHandler.OnNewFrame(dt); EndFrame();` (`BeginFrame` empty virtual on the abstract; `EndFrame` base override = `SwapBuffers`) | -| `ClientPlatformWindows.Start()` | thick-line GL probe becomes `SupportsThickLines = ProbeThickLineSupport();` | -| `ClientPlatformWindows.Window_Resize()` | `OnWindowSizeChanged(w, h)` before `RebuildFrameBuffers()` | -| `ScreenManager.Render` | the `GL.ClearBuffer`/`GL.DepthRange` pair becomes `Platform.ClearDefaultDepth(1f); Platform.SetDepthRange(0f, 20000f);` | -| `ClientMain.TriggerRenderStage` | `Platform.BeginRenderStage(stage)` / `EndRenderStage(stage)` around `eventManager?.TriggerRenderStage` (brackets every vanilla and mod renderer) | -| `ClientProgram.Start` | probe before construction; `OptimumRenderBootstrap.CreatePlatform(logger)` returns `object`, `as ClientPlatformWindows`; new injected `ConfigureClientPlatform(p)` holds the wiring now inlined at lines 215-255; after the window opens, `p.InitializeGraphics(hwnd, w, h, out reason)`; on failure reopen the window for OpenGL, construct the base platform, `ConfigureClientPlatform`, and assign `ScreenManager.Platform` (static, and `screenManager.Start` has not run yet); `p.ShutdownGraphics()` in the `finally` | - -**Virtualized in place** (GL bodies stay in `ClientPlatformWindows`): `SetupDefaultFrameBuffers`, -`DisposeFrameBuffers`, `RenderFullscreenTriangle`, `GetGraphicsCardRenderer`. - -**New virtuals on `ClientPlatformAbstract`** (base bodies: the verbatim GL lines they replace, -placed as overrides in `ClientPlatformWindows`; empty where GL has nothing to do): -`BeginFrame`, `EndFrame`, `InitializeGraphics`, `ShutdownGraphics`, `OnWindowSizeChanged`, -`ProbeThickLineSupport`, `BeginRenderStage`/`EndRenderStage`, `SetDepthRange`, -`ClearDefaultDepth`, `DeleteMeshHandle`; UBO ops (`UpdateUBO`, `BindUBO`, `UnbindUBO`, -`DeleteUBO`); program ops (`UseShaderProgram`, `DisposeShaderProgram`, `BindSampler`, 14 -`SetUniform*` primitives, `BindProgramTexture2D/Cube`); texture leaf ops (`SetTextureLodBias`, -`SetSamplerLodBias`, `SetTextureDepthCompare`, `ClearTextureRegion`, -`LoadTextureFromRgbaPointer`, `CreateTexture2DArray`); occlusion queries (`Gen`, `Begin`, `End`, -`TryGetResult`, `Delete`); `ReadDefaultFramebuffer`; `GraphicsBackendName`. These retire the -48 seam sites outside the platform class (`ShaderProgramBase.cs` 23, `UBO.cs` 6, -`SystemRenderOITLayers.cs` 5, `SystemRenderSunMoon.cs` 3, `ClientMain.cs` 2, `ChunkRenderer.cs` -2, and one each in `ScreenManager`, `VAO`, `SystemRenderFrameBufferDebug`, `SvgLoader`, -`ShaderRegistry`, `InventoryItemRenderer`, `ClientSystemStartup`, `Screenshot`). - -**TAA/FSR members** (`BeginMotionWrite`, `EndMotionWrite`, `BeginMotionOnlyWrite`, -`EndMotionOnlyWrite`, `RenderOptimumSkyMotion`, `RenderOptimumTaaResolve`, -`RenderOptimumTaaSharpen`, `DisableOptimumTaa`, `OptimumFsrBlitActive`, `TaaHistory`, and their -state fields) are declared virtual on the abstract class with the state fields injected there; -the GL bodies stay in `ClientPlatformWindows` as overrides. The seven cast sites -(`ChunkRenderer.cs:379,633,708`, `SystemRenderEntities.cs:315`, `SystemRenderDecals.cs:440`, -`SystemRenderParticles.cs:140`, `ClientMain.cs:1402`) become plain virtual calls; zero -`as/is ClientPlatformWindows` remain in the lib. - -**Patcher capabilities** (`Optimum.Patcher/Program.cs`, `MemberInjector.cs`, -`SelfConsistencyVerifier.cs`): `typesToUnseal` (clears `TypeAttributes.Sealed`), -`methodsToVirtualize` (sets `Virtual|NewSlot|HideBySig`, keeps visibility), and a verifier that -fails the patch if any method body still reaches a virtualized method with `call` instead of -`callvirt` (the one silent failure mode: a non-transplanted caller would bypass the override). -`MemberInjector.CloneMethod` already copies `MethodAttributes`, so injected virtuals arrive -virtual and cross-assembly overrides bind by name and signature. - -**What stays in the contracts** (`VintagestoryApi/Client/optimum-render-device.cs` shrinks to -this): `OptimumRender.ActiveBackend/FallbackReason/IsVulkan/FallBackToOpenGL`, -`OptimumRender.NoGraphicsApiWindow` (the window is created before any platform exists), -`OptimumRenderBootstrap` (`ShouldTryVulkan` unchanged, device-level, before the window; -`CreatePlatform` new), `OptimumMotionWrite.BeginHook/EndHook` (the mod forks call them and -reference only the API), `OptimumTemporal*` (contract v1). `IOptimumGraphicsDevice` and -`OptimumRender.Device` are deleted at the end of Phase 1A. - -**Build wiring.** `Optimum.Render.Vulkan.csproj` gains a `ProjectReference` to -`build/VintagestoryLib/VintagestoryLib.csproj` with `Private=false` (compile against the donor -where `sealed` is removed and the virtuals exist; bind to the patched vanilla DLL at runtime). -`InitializeGraphics` reflects over the expected virtual set once and fails the install (OpenGL -fallback) rather than throwing `MissingMethodException` mid-frame. The reflective load through -`OptimumRenderBootstrap` (`Assembly.LoadFrom`) is unchanged. - -### B. Renderer core (`Optimum.Render.Vulkan`) - -Layout: `Device/` (the platform-facing entry points, `DeviceCaps` tier table), `Frame/` -(`FrameTimeline`, `FrameSlot`, `FrameRing`, `RingArena`, `RetireQueue`), `Transfer/` -(`UploadManager`, `ReadbackManager`, `ITransferBackend`), `Graph/` (`FrameGraph`, -`PassRecorder`, `ResourceStateTracker`, `BarrierBatcher`, `FramePlan`, `TransientAllocator`, -`GraphValidation`), `Present/` (`Swapchain`, `SwapchainRetirement`, `IPresentPath`), -`Pipelines/`, `Descriptors/`, `Resources/` (`TextureStore`, `SamplerCache`, `MeshStore`), -`State/`, `Shaders/`, `Diagnostics/`. - -**Keep verbatim** (hard-won, tested): `VulkanAllocator`'s free-range coalescing, the per-slot -uniform-ring with dynamic offsets, `DescriptorCache` (content-keyed, never-reused ids), -`MeshManager`'s layout derivation (`PruneCustomInts`, `FillQuadIndices`, -`WriteIndirectCommands`), `PipelineCache`'s write-mask masking of undeclared outputs, -`Swapchain.ChooseFormat`, `SamplerState.LodCeiling`, `RenderTrace`, `TextureDump`, -`GpuCheckpoints`, `GlEnums`, `VertexLayout`, the whole `Shaders/` rewriter path (mod shaders). -**Replace**: synchronous upload path (`VulkanCommands.SubmitAndWait`, `TextureManager.Upload`, -`FlushFrame`), `RenderTargetManager` (scope inference), the wrapping indirect ring, swapchain -recreation, `FrameRing`'s fence pacing, `AccessForLayout` guesswork. - -**Synchronisation.** Two timeline semaphores are the only clock: `Frame` (every graphics submit -signals `n`) and `Transfer`. `FramesInFlight` fixed at init (2 now; 3 later for frame -generation), every arena sized by it. `BeginFrame(n)`: `vkWaitSemaphores(Frame, n - FIF)` is the -**only CPU wait in steady state**; reset the slot's pool, arenas, descriptor arena, query range; -drain `RetireQueue` (entries keyed on both timeline values, destroyed exactly when both passed). -`vkQueuePresentKHR` cannot wait on a timeline, so `renderFinished[image]` stays binary. - -**Transfer.** `ITransferBackend`: (A, default) a second command buffer per slot from the graphics -pool, recorded from any thread under a lock, submitted first in the same `vkQueueSubmit`: zero -ownership transfers, zero extra semaphores, zero blocking. (B, opt-in after measurement) a -dedicated transfer queue with exclusive-mode release/acquire barriers and a `Transfer` timeline -wait folded into the frame submit. Staging: one persistently mapped slice per slot -(`FIF × 32 MiB`), bump-allocated; oversized or overflow uploads take a dedicated staging buffer -retired on the timeline and are counted. **No upload ever waits.** Mip generation is a blit -chain on the graphics upload buffer. Persistent-mapped meshes keep the contract's "reproduce the -GL race" default; `OPTIMUM_VULKAN_MESH_DOUBLE_BUFFER=1` gives a per-slot copy with dirty-range -replay for validation-clean test runs. Static meshes move off ReBAR to device-local memory via -staging (the named defect). - -**Readback in a frame.** `ReadbackManager.CopyToHost`: end the open pass, barrier, copy to the -slot's readback arena, `SubmitPartial()` (ends and submits the command buffer, begins a new one -**in the same slot**, arenas keep their cursors). `FlushFrame` and the mid-frame -`_frameCounter++` are deleted. Only the screenshot path waits, on that one timeline value. - -**Presentation.** Split submission: Submit A (upload CB + frame CB, signals `Frame@v_render`); -**then** `vkAcquireNextImageKHR`; Submit B (FSR or the flipped blit into the acquired image, -waits `Frame@v_render` at `COLOR_ATTACHMENT_OUTPUT` and the acquire semaphore at `TRANSFER` or -`COLOR_ATTACHMENT_OUTPUT`, signals the binary present semaphore and `Frame@v_present`); -present. The `ALL_COMMANDS` wait disappears and the CPU blocks on acquire only after the whole -frame is in flight. Present policy `BlitFromOwned` (default: the frame including GUI renders -into the owned default image, acquire at the end) or `DirectToSwapchain` (experiment, negative -viewport flip). Present mode: FIFO with FIFO_RELAXED promotion on missed vsyncs; MAILBOX or -IMMEDIATE with vsync off; `minImageCount = max(caps.min + 1, mailbox ? 3 : 2)`. Recreation -follows the Khronos `swapchain_recreation` sample: `oldSwapchain` always passed, no -`DeviceWaitIdle`; a `SwapchainSlot` owns its images, views, acquire-semaphore free list -(`imageCount + 1`) and per-image present semaphores and retires as one unit after the last -present submission that referenced it; `SUBOPTIMAL` rebuilds before the next acquire, -`OUT_OF_DATE` rebuilds and re-acquires once; zero extent parks the present path. - -**Frame graph.** Passes are declared and recorded **in frame order** (the client's frame is -imperative and pass existence is dynamic: bloom, SSAO, transparent pass, mod stages). Barriers -and layouts derive immediately from a per-subresource state tracker (layout, last write -stage/access, visibility, read stages, queue family; one entry per image, interval list only -when a pass touches a sub-range). Load/store ops, discards and transient aliasing come from a -**plan** computed from the previous frame's signature (ordered pass signatures: attachments, -depth usage, read set, extent, formats); a plan is applied only on an exact signature match, a -mismatch costs one conservative frame (LOAD/STORE, no aliasing). All barriers of a pass go into -one `vkCmdPipelineBarrier2` before `vkCmdBeginRendering`; stage/access come from the pass usage -table (colour write, depth write, depth read-only sampled, fragment/vertex sample, transfer, -indirect, vertex/index, present), never from the layout alone. Pass kinds: raster, blit, -compute (reserved), present. Mod-hosted stages (`AfterOIT`, `AfterFinalComposition`, -`AfterBlit`, `Ortho`) use `OpenSampling` (pre-transition every sampled-capable non-attachment) -and `AllowSplit`. - -The platform derives the fixed frame from `(render stage, bound target)` plus its own post -methods. M1 pass set: ShadowFar, ShadowNear, Before, Opaque (Primary with all attachments -declared once, motion mask 0 by default), OIT (Transparent target), MergeTransparent, AfterOIT, -LiquidMotion and SkyMotion (motion-only write masks), TaaResolve, TaaSharpen, SSAO, Bloom -chain, GodRays, Luma, FinalComposition (attachment-subset pass: writes Primary 0, samples -Primary 1, one barrier each way per frame), Blit (FSR or plain), AfterBlit, Ortho, Present. - -**Invariants pinned by tests**: one `vkCmdBeginRendering` per pass (`ScopesOpened == PassCount`); -no layout transition inside a scope; every sampled texture is in the pass's read set or the -pass is `OpenSampling`; a plan applies only on exact match; a clear on a zero-write-mask -attachment is a no-op on every path (the undefined-attachment-contents bug class); a resource is destroyed -only after every timeline value recorded against it passed; a swapchain's semaphores die with -it; ReBAR holds only per-frame dynamic data and a fall-through is logged and counted; the Y -flip happens exactly once. - -**Motion windows and draw-buffer masks are write masks, never scope restarts.** Effective mask -per attachment = `drawBufferEnabled ? colorMask : 0`, then masked by the program's written -outputs. Tiers: `VK_EXT_color_write_enable` (exact `glDrawBuffers`, zero extra pipelines) → -`VK_EXT_extended_dynamic_state3` (`ColorWriteMask`, and `ColorBlendEquation` collapses the blend -key) → write-mask set interned into the pipeline key (bounded: programs used inside a window -× 2). Each tier forceable by env and tested. - -**Clears** issued with no pass open become the next pass's `LOAD_OP_CLEAR` (standalone -`vkCmdClearColorImage` if read first); inside a pass they stay `vkCmdClearAttachments` and are -counted. `SnapshotColorAttachment`'s permanent shadow copies become pooled per-pass transient -copies (`ReadSelf`). Transient aliasing (post chain slots) is off by default -(`OPTIMUM_VULKAN_ALIAS=1`) until sync validation is clean on all targets. - -**Memory.** Pool classes: `DeviceImages` (128 MiB blocks), `DeviceBuffers` (64), `Staging` -(32), `ReBar` (16, per-frame dynamic data only, capped at min(192 MiB, budget × 0.25), miss = -logged fall-through), `Transient` (64), `Dedicated` (via `VkMemoryDedicatedRequirements` or -size ≥ block/4). `VK_EXT_memory_budget` reported per heap, pressure callback drops spare -blocks and cold descriptor entries; without it budget = heap × 0.7. No general defrag: empty -blocks freed after 120 empty frames; optional bounded relocation of static geometry only. - -**Draw submission.** Per-slot indirect ring in ReBAR, reset at `BeginFrame`, grown at frame -boundaries (replaces the wrapping ring). Bone matrices move to a storage-buffer ring with -dynamic offsets (lifts the 64 KiB UBO limit, tight packing under `scalarBlockLayout`); the -per-(frame, version) snapshot dedup stays; ring exhaustion grows and reports instead of -dropping. Dynamic state is dirty-masked (today 12 commands on every draw). `GetError()` becomes -a volatile counter read. Occlusion queries: per-slot pool + `vkCmdCopyQueryPoolResults` into a -host buffer, polled without any API wait (one frame late, like GL's availability polling). - -**Descriptors and pipelines.** M1 keeps the existing rewriter layout and `DescriptorCache`, and -adds a per-slot `DescriptorArena` for short-lived resources (GUI text, atlas tasks) reset -wholesale per frame. Pipeline key gains `RenderingFormatsId` from the **pass** (stable across -mask toggles) and loses the blend/write-mask dimensions where the dynamic tiers exist. Disk -pipeline cache (`vkGetPipelineCacheData`, keyed on device/driver/pipelineCacheUUID/build id), -SPIR-V cache for mod shaders, a manifest of used keys and a background warm-up with -`pipelineCreationCacheControl` land in Phase 4. - -**Diagnostics.** `VulkanStats` gains: blocking uploads (uploads that really waited), blocking -waits by site, acquire/present/fence wait ms, frame-time p50/p95/p99/stddev and stutter count -(>2 × p50) over the last 512 frames, passes vs BeginRendering, barriers, self-read copies, -transient/aliased bytes, plan hits/misses, heap used/budget, ReBAR fallbacks, pipeline and -descriptor hits/misses, dynamic-state commands, push-constant flushes, uniform ring use, and a -per-pass GPU time table from timestamp queries (Phase 4). `RenderTrace` gains pass/barrier/ -submit/acquire/present lines. `OPTIMUM_VULKAN_POISON=1` fills fresh images and buffers with -NaN/`0xDEADBEEF` so undefined reads are loud. The GPU test suite runs sync + best-practices -validation **by default** with a `NoSyncHazards` assertion. - -### C. Shaders - -**Sources.** `sources/shaders-vk/.vert|.frag` (GLSL 450, Optimum-authored, never an -asset; `.vert/.frag` so no packager glob over `sources/shaders/*` can pick them up) plus -`sources/shaders-vk/include/` (`bindings.glsl` single source of truth for sets, `globals.glsl`, -`warp.glsl`, `motion.glsl`, `fog/shadow/colormap/sky/oit/noise/vertexflagbits.glsl`), resolved -by glslc `-I`. The GLSL 330 assets in `sources/shaders/` keep shipping and keep being read: -`ShaderProgram.collectUniformNames` (`ShaderProgram.cs:56-66`) regexes `Shader.Code` for the -uniform-name set and texture declaration order, which is the client's oracle. The native path -supplies only placements and bindings; no `ShaderRegistry` change is needed. - -**Set convention** (decision 9; one pipeline layout shared by every program; mirrored in -`Shaders/SetConvention.cs`, a test asserts `bindings.glsl` and the C# agree; design sources in -`docs/research/vulkan-descriptor-model.md`, implementation details in `docs/research/vulkan-bindless.md`): - -| Set | Update | Contents | -|---|---|---| -| 0 frame | once per frame | `FrameGlobals` UBO with a dynamic offset (every uniform `ShaderProgramBase.Use()` auto-binds plus the `OptimumTemporal` record) and the fixed frame textures `shadowMapFar/Near`, `sky`, `glow`, `liquidDepth` | -| 1 textures | when a texture is created or retired | the bindless texture arrays (partially bound, update-after-bind) and the few shared samplers; every texture the game creates gets a slot, and shaders index it | -| 2 storage | when a buffer is created or retired | `FaceData`, per-object and `Animation`/`AnimationPrev` SSBOs, indexed by draw | -| push (≤128 B) | per draw | texture slot indices, per-draw scalars (origin, z-offset, tint, flags) and the offset of the draw's record; larger per-program values sit in a per-frame buffer addressed from here | - -No layout differs between programs, so a program switch never invalidates a bound set, and no draw builds -a descriptor set. Startup checks the descriptor-indexing features and limits; a device without them stays -on OpenGL. - -**Uniform placement.** `GetUniformLocation(program, name)` returns an index into the program's -placement table `(home: Push | Frame | Pass | Draw | SamplerUnit, offset, size)`, `-1` when the -variant compiled the name out (`HasUniform` keeps returning true, as GL does). `SetUniform*` -is a table lookup and a memcpy into the right shadow, flushed once per draw (push) or per -frame (frame). `Use()` is not touched in Phase 3; its ~50 frame-global writes per program use -land in the frame shadow (a debug tripwire flags a frame-global written with two different -values in one frame). Skipping the include block in `Use()` is a Phase 4 optimisation, -measured first. - -**Define matrix.** Code-path flags (FXAA, BLOOM, NORMALVIEW, FOAMEFFECT, SHINYEFFECT, -WAVINGSTUFF, GREEDYMESH*) and quality values (GODRAYS, SSAOLEVEL, SHADOWQUALITY, MINBRIGHT) -become specialization constants with gated varyings/samplers/outputs declared unconditionally -(outputs masked by `writtenOutputs`); DYNLIGHTS is removed (array fixed at `MAX_DYNLIGHTS`, -loop bound = the existing `pointLightQuantity` uniform); MAXANIMATEDELEMENTS is fixed; -TAAMOTION+TAAMOTIONLOCATION stays a **variant axis** (off / on@2 / on@4; output locations -cannot be specialized); USEOIT a variant on the two OIT programs; USESSBO fixed to 1 (the -`Chunkshadowmap_NoSSBOs` registration is the one 0 variant). Result ≤ 6 variants per program, -~600 SPIR-V blobs. A settings change becomes a pipeline-key change, not a shader reload. - -**Offline compile.** `tools/shader-compiler/Optimum.Shaders.Compiler.csproj`: `--build` -(glslc `--target-env=vulkan1.3 -O`, then SPIR-V reflection into `shaders.manifest.json`: -schema version, toolchain, per program per variant the defines, spec constants, stage blobs with -sha256, uniform placements, samplers, blocks, vertex inputs, fragment outputs, -`writtenOutputs`), `--verify` (recompile, compare hashes; the `make check-shaders-vk` gate), -`--single`. MSBuild target on `Optimum.Render.Vulkan.csproj` with a content-hash cache. -Deploy to `/Optimum/shaders-vk/` beside the DLL, never into `assets/` (the asset manager -must not read SPIR-V, the scanner scans `assets/*/shaders`, and a mod must not shadow engine -SPIR-V by asset priority); `Makefile` and every `scripts/package-*` copy it with the existing -`cmp -s` completeness check. Load once, verify hashes lazily, **fall back per program** to the -rewriter; one log line `[Optimum] shaders: N native, M rewritten, K failed`. -`OPTIMUM_VK_SHADER_SOURCE=` compiles the tree at runtime through shaderc for the dev loop; -a test asserts runtime and offline SPIR-V are byte-identical for a sample program. - -**Mod-shader adapter.** The rewriter targets the same shared layout (decision 9): loose uniforms → -the program's record in the per-frame uniform buffer, addressed from push constants; samplers → -indices into the set 1 bindless arrays, carried in push constants; SSBOs → set 2; and any loose -uniform whose name matches a `FrameGlobals` member → set 0 (so a mod shader including -`fogandlight.fsh` keeps working unchanged). Confined to `ProgramInterfaceLayout.Build` plus a -frame-global name map; a test asserts a native and an adapter program share one pipeline layout. - -**Temporal contract.** One writer: `include/motion.glsl` with -`optimumWriteMotion(mv, reactive, writerDepth)` and `optimumWriteReactiveOnly(reactive)` -(the "b without rg" rule becomes a signature property); a source test fails any other -assignment to `outMotion`. Every TAAMOTION variant's manifest entry lists the motion output at -`TAAMOTIONLOCATION` in `writtenOutputs`. The eight `Taa*Motion*Tests` gain native-vs-rewriter -differential cases (same inputs, motion attachment equal within 1 ULP of RGBA16F). Phase 3 -records explicitly whether the contract gets a dated v1 addendum (provenance only) or a v2. - -**Launcher scan v2** (`Optimum.Launcher/ShaderCompatibilityScanner.cs`): new -`ShaderAssetOverride` class reporting overridden vanilla program names (those use the rewriter; -an overridden `shaderincludes/*` forces all programs); new `PlatformInternals` indicator -(Harmony + `ClientPlatformWindows`/`ShaderProgramBase` strings) → `openGlRequired`; `RawOpenGL` -unchanged; `CurrentSchemaVersion` 2. `VULKAN-BACKEND-PLAN.md` §9 states that a Harmony patch on -a platform graphics member is not honoured on Vulkan. *Delivered 2026-09-15 (wip/launcher-scanner-v2):* -the report carries `shaderAssetOverrides` (asset, programs, owners) and `rewriterPrograms` (sorted base names, -or `["all"]` for an include override or a failed scan) for the runtime to consume; `openGlRequired` is the -`Vulkan` entry in `disabledFeatures`, with `openGlRequiredBy`; `LoadReport` refuses v1 files. - -### D. Mod policy (decision 2 made concrete) - -Free, no mod change: everything through `IRenderAPI`/`IShaderAPI`/`ICoreClientAPI` (meshes, -textures, render-to-texture, GUI, fixed-function state, screenshots, shaders through the -rewriter), because it lands on the platform virtuals inside a declared pass; `RegisterRenderer` -renderers sit inside `BeginRenderStage`/`EndRenderStage`. Unsupported (launcher routes to -OpenGL): direct OpenTK GL; Harmony patches on platform graphics members. Vanilla-shader -overrides by mods bypass the native blob for that program only. Phase 5 adds the opt-in -mod-facing pass and motion-writer API in the contracts (`EnumOptimumPass`, `OptimumPassDecl` -data holders; no lib types). - -**Superseded in part by decisions 7 and 8 (2026-09-15).** "Free, no mod change" was a property of the -GL-shaped seam, which the full-native backend retires from the Vulkan path. Mods add Vulkan-native support -by following the published documentation. Direct OpenTK GL and Harmony patches on platform graphics members -still route to OpenGL. Whether the runtime rewriter survives for GLSL 330 mod shaders is decided in -Phase 3b. - ---- - -## Phases and exit criteria - -Every phase: `dotnet build VintageStory.slnx -c Release`; `dotnet test Optimum.Tests -c Release`; -`dotnet test Optimum.Render.Vulkan.Tests`; `bash scripts/extract-patches.sh && bash -scripts/check-patches.sh`; `make deploy`; then the in-game check listed, once per backend, -renderer confirmed with `scripts/dev/client-renderer.sh`, game closed with the kill script. -In-game runs happen only at phase exits. - -### Phase 0: foundations (no behaviour change) - -- Step 0 branches. -- Patcher: `typesToUnseal`, `methodsToVirtualize`, the `call`→`callvirt` verifier. - Tests: `Optimum.Tests/member-injector-tests.cs` (flags preserved, synthetic stray `call` - fails), `platform-substitution-coverage-tests.cs` (entries present). -- Diagnostics: `VulkanStats` counters above, `OPTIMUM_FPS_LOG` gains `stddev`, - `scripts/dev/perf-capture.sh` captures, `scripts/dev/pacing-gate.sh --renderer vulkan --fps - --stats --baseline ` judges (exit non-zero unless blocking uploads = 0 in - every sample, median window stddev ≤ baseline × 1.25, median window p99 ≤ 1.5 × median mean, - dropped mesh writes = 0, uniform overflows = 0), format-coverage test for the stats line, - `docs/taa-acceptance.md` §3 and `perf-capture.sh` parser updated. -- GPU tests default to `sync,best` with `ValidationAssert.NoSyncHazards`. -- GL-side attachment dump (`glGetTexImage` in `ClientPlatformWindows`, env - `OPTIMUM_PARITY_DUMP= OPTIMUM_PARITY_FRAME=`, Vulkan side reuses `TextureDump`), - `scripts/dev/parity-capture.sh`, `scripts/dev/ssim.py` (per attachment SSIM + mean abs diff), - `docs/parity-allowlist.md`, coverage test that the dumped slot list matches - `SetupDefaultFrameBuffers`. -- `docs/vulkan-acceptance.md` skeleton (preconditions, renderer line per row, rows per - milestone, methods, decision record, vendor matrix). - -Exit: builds and suites green; deploy output unchanged except stats; both dump paths executed in -the real client; GL-vs-GL noise floor recorded per attachment (two launches of one save are not -bit-deterministic: world time, weather, entities and particles move, so this is a floor that -Milestone 1's 0.98 threshold must stand above, not a 1.000 gate); Vulkan-vs-GL table recorded as -Milestone 1's starting point; baseline pacing numbers for both backends on the fixed scene recorded -in `docs/vulkan-acceptance.md`. - -**Phase 0 status (2026-09-11).** Merged on `feat/vulkan-native` at `cdd7412` (stages 1dcbb29, -3e1170c, 75a984f, 9588f3e; integration 4d1089f; review cdd7412). Build 0 errors; Optimum.Tests -1056 passed; GPU suite 386 passed with `sync,best` and zero sync hazards; real Cecil patch 257/257 -methods, 1 type unsealed, 4 methods virtualized, 0 non-virtual call sites. Deferred from the -Diagnostics list to the phase that builds the subsystem: heap used/budget (1B step 5), pipeline -and descriptor hits/misses and push-constant flushes (Phase 4), RenderTrace pass/barrier/submit/ -acquire/present lines (Phase 2). Known: poison-mode image clears count as blocking uploads -(diagnostic mode only); the GL `glGetTexImage` dump had never executed before the exit run. - -**Phase 0 exit run (2026-09-11, RTX 4070, driver 615.71.09, both backends; recorded in -`docs/vulkan-acceptance.md` and `docs/gpu-verification-2026-09-11/phase0/`).** Both dump paths -executed. Pacing, median per-second windows: OpenGL 6.08 ms mean / 8.51 p99 / 0.55 stddev; Vulkan -9.90 / 20.08 / 4.96, gate fails on p99, stddev and blocking uploads (median 50/s, max 193). Vulkan -per-second medians: flush-frame 151 (occlusion-query reads 101: `GetQueryResult` flushes every -frame), rendering scopes 4040, barriers 6766, dynamic-state commands 172256. Parity: GL-vs-GL -launches differ (far shadow map 0.947, Primary colour 0.968), so Milestone 1's parity rule is now -relative to the same session's GL-vs-GL floor. Real gap found: SSAO g-buffer colour1 alpha is 1.0 -on GL and 0.0 on Vulkan (unwritten channel); it goes into Phase 2 with the write-mask work. -User judged these two Vulkan runs free of the earlier distance jitter (sky-direction fix not deployed); in every later Vulkan run the jitter was back, and OpenGL never shows it, so the jitter is Vulkan-only and intermittent between sessions and remains the Milestone 1 target. -Phase 1B priority from these numbers: `QueryRing` (removes ~1 flush per frame) and the upload path -first, then present. - -### Phase 1A: platform substitution (re-plumbing; pixels unchanged) - -Runs in parallel with 1B (disjoint files). - -1. `VulkanClientPlatform` as a forwarding subclass over the existing `VulkanDevice`; - `SetupOptimumFrameBuffers` (lines 1629-1916) moves out as the `SetupDefaultFrameBuffers` - override; `ClientProgram.Start` per the table above; csproj donor reference. -2. TAA members to the abstract class; the seven casts become virtual calls; the 14 - `Optimum.Tests` files that read `ClientPlatformWindows.cs` are re-pointed at the abstract - class as a pure-move commit (bodies diffed textually). -3. Program/uniform/UBO virtuals; `ShaderProgramBase.cs` and `UBO.cs` revert to vanilla plus - `ScreenManager.Platform.`. Measure per-draw CPU on both backends before and after. -4. Remaining 19 leaf sites; delete `IOptimumGraphicsDevice`, `OptimumRender.Device`, - `OptimumRenderBootstrap.Install`; `ClientPlatformWindows` is branch-free; `Program.cs` entries - for it drop from 88 to the handful of edit sites. - -Tests: every abstract graphics member and every GL-touching `ClientPlatformWindows` method has -an override in `VulkanClientPlatform.cs` or is on the base-edit list (source test that greps -`GL.` per method); no lambda in the new `ClientProgram.Start` region; the fallback block -re-assigns `ScreenManager.Platform`; no `OptimumRender.Device` and no `ClientPlatformWindows` -cast under `build/VintagestoryLib/**`; `ClientPlatformWindows.cs` differs from `_ref/` only in -the listed regions; `PlatformSubstitutionTests` (construct headless, `is ClientPlatformWindows`, -`InitializeGraphics` on a hidden NoAPI window brings up the swapchain); every existing GPU -readback test driven through the platform gives identical pixels. - -Exit: all of the above green; in game, one screenshot per backend identical to Phase 0's; the -reopen-and-swap fallback exercised once with `OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE=1` and the -log showing `[Optimum] Vulkan unavailable, reopening for OpenGL`. - -### Phase 1B: synchronisation foundation (behind the current entry points) - -1. `FrameTimeline` + `RetireQueue`; `FrameRing` on timelines. Gate: existing multi-frame tests; - blocking waits = 1 per frame. -2. `UploadManager` + per-slot upload command buffer (backend A); texture, mip and bulk mesh - uploads route through it; delete `SubmitAndWait` and `FlushFrame`. Gate: **blocking - uploads = 0** during world load and a 10-minute session (the M1 headline number). -3. `ReadbackManager` + `SubmitPartial`; `QueryRing`. Gate: screenshot readback test; readback - mid-frame then more draws then present stays correct; sun glare still varies in game. -4. `Swapchain`/`SwapchainRetirement`/`IPresentPath` split submission. Gate: resize, alt-tab, - minimise loop clean under `sync,best`; acquire wait stage never `ALL_COMMANDS`; frame-time - stddev before/after recorded. -5. `VulkanAllocator` pool classes + budget; static meshes off ReBAR. Gate: allocator policy - tests; heap report; no chunk-streaming regression. -6. Per-slot indirect ring; descriptor arena; dirty-masked dynamic state; free `GetError`. - Gate: CPU frame time drop measured; draw counters unchanged. - -Tests (GPU, `Optimum.Render.Vulkan.Tests`): `AsyncTransferTests` (upload from a worker thread -while frames record, Present between frames, read back on N+2, `BlockingUploads == 0` over 60 -frames with atlas inserts, Cairo updates and chunk meshes interleaved), `PresentDecouplingTests` -(`VulkanContextOptions.AcquireDelayForTests`; recording time does not grow with the delay), -`SwapchainRecreationVisualTests`, `ConcurrentDeviceAccessTests`, `ReadbackMidFrameTests`, -`QueryRingTests`, `AllocatorPolicyTests`; pure unit tests `IndirectRingWrapTests`, -`TimelineLifetimeTests`, `PresentWaitStageTests`, `SwapchainRetirementTests`. - -**Phase 1 exit (2026-09-11, f373c4a).** 1A and 1B merged and reviewed (Optimum.Tests 1128, GPU 494, -patch run 197/197, dispatch verifier clean). In game on the RTX 4070: both renderers start; forced -install failure falls back to OpenGL and renders; sync,best validation 0 errors; Vulkan blocking -uploads 0 in all samples; Vulkan pacing 8.21 ms mean / 18.07 p99 / 3.74 stddev (Phase 0: 9.90 / -20.08 / 4.96). OpenGL pacing is bimodal between launches regardless of build (A/B/A), so M1.1 -interleaves runs. Carried to Milestone 1: 10-minute session, window loop, sun glare and fork bridge -on screen, the SSAO alpha gap, foliage NaN normals (Phase 3). User direction after this exit: less -testing. - -**TAA distant-foliage jitter resolved (2026-09-11, 22:33).** Root cause was the resolve shader, not the -backend: a single-sample depth disocclusion test dropped history on ~3.7% of distant leaf pixels per -frame (sub-pixel leaf vs far background across jitter phases), and a fixed blend weight let the moving -clip box drag history. Fix in `sources/shaders/taa-resolve.fsh`: 3x3 nearest-depth disocclusion with -motion from the nearest-depth tap, and luminance-based anti-flicker weighting (0.3x..1.2x blendAlpha). -User judged Vulkan "perfectly stable, better than it ever was" at the default two frames in flight. -Ported with GPU and source tests on `fix/taa-antiflicker-disocclusion`, merged after Phase 2. Also -found, unfixed: Vulkan `BuildMipMaps` keeps the atlas texture LOD bias where OpenGL resets it to 0 -(affects shadow, liquid and transparent terrain passes; shadow maps measured identical, so not visible). -**Correction (2026-09-15):** this damped the whole-frame jitter the user saw rather than removing it. On the -DLSS line the jitter went away once the jittered AO was shaded into the scene before the temporal pass and -its dither advanced per frame; both are backported for the TAA path in `41373cf`, not yet judged in game. - -### Phase 2: frame graph → **Milestone 1** - -1. `ResourceStateTracker` + `BarrierBatcher` driving the existing immediate path (derived - stages replace `ALL_COMMANDS`; no graph yet). Gate: sync clean; barrier count reported and - reduced. -2. `FrameGraph` streaming recorder + `PassRecorder`; the platform declares the M1 pass set from - `(stage, target)` and its post methods; lib gains `BeginRenderStage`/`EndRenderStage`. - Both paths coexist behind `OPTIMUM_VULKAN_FRAMEGRAPH`; a declared-reads violation splits. - Gate: pixel-identical readbacks vs the non-graph path at four settings combinations; - `ScopesOpened == PassCount`. -3. Write-mask motion windows (all three tiers), clear promotion, `FramePlan` load/store - solving; TAA resolve/sharpen/sky-motion/liquid-motion through the graph, contract unchanged. - Gate: motion-attachment bit-exactness; history accumulates over 8+ frames in a multi-frame - test with no readback inside the loop. -4. Transient aliasing implemented, default off. - -Tests: pure `FrameGraphBarrierTests` (RAW/WAR/WAW/layout table, swapchain ends `PRESENT_SRC`, -aliased first use `UNDEFINED`, no reader → no barrier), `FramePlanTests` (signature match, -load/store solve, alias intervals never overlap); GPU `FrameGraphFrameTests` (the real declared -frame for 5 frames, TAA accumulates, scopes == passes, zero `SYNC-` messages), -`MotionWindowTests` per tier, `FeedbackPassTests` (final composition write-0/sample-1; ReadSelf -copy), `ClearPromotionTests` (masked-out clear is a no-op), `AttachmentSemanticsTests` and -`WorldRenderPathTests` stay green; `Optimum.Tests`: `TriggerRenderStage` brackets the event -and is a Cecil target. - -**Milestone 1 definition of done** (all numbers, then eyes): -- `pacing-gate.sh` passes against the OpenGL baseline of the same scene. -- Blocking uploads 0 during load and a 10-minute session; blocking waits 1 per frame. -- Acquire happens after the render submit; acquire wait stage is `TRANSFER` or - `COLOR_ATTACHMENT_OUTPUT`. -- `ScopesOpened == PassCount`; no transition inside a scope. -- `sync,best` validation: zero `[error]` over the scripted session (menu → world → weather → - water → night → resize → shader reload → screenshot → exit). -- Per-attachment SSIM vs OpenGL, TAA off ≥ min(0.98, same-session GL-vs-GL SSIM − 0.01) on every - attachment, or an allowlist row (launches of one save are not bit-identical). -- TAA on: still-frame luma-diff median over 7 pairs within 0.3 of the OpenGL median - (reference VK 1.84 / GL 1.87, `docs/taa-acceptance.md:55`); `docs/taa-acceptance.md` rows - A11, A13, A14, A15, A17, A18 re-pass. -- Then the user judges it in game on both backends, renderer line confirmed. - -**Milestone 1 accepted (user, 2026-09-11, at `f90cbac`).** Phase 2 complete: barriers from usage, frame -graph (22.3 passes == 22.3 scopes per frame, 0 splits, 0 mask restarts, plan hits every frame), transient -allocator (implemented, not yet wired to the graph), clear promotion, SSAO alpha gap closed, TAA -anti-flicker resolve merged (distant-leaf rejection 1.05 % on both backends). Open and carried to Phase 4: -Vulkan costs ~25 % more frame time than OpenGL on the fixed scene (7.59 ms vs 6.08, stddev 0.37 vs 0.12) -and is GPU-bound (5.43 ms of the 7.67 ms frame in the frame-pacing wait), so the pacing gate fails its -stddev rule; per-pass timestamps come first. Also open: wire `TransientAllocator` into the graph, -`ClearDepth` ignores the depth write mask, `BuildMipMaps` LOD-bias parity. Testing policy tightened by the -user: no long sessions, no per-attachment SSIM matrices, one short run plus the cheap numbers. - -**Branching at Milestone 1 (user, 2026-09-11):** once Milestone 1 is accepted, `feat/vulkan-native` -merges back into `main` (with `fix/taa-antiflicker-disocclusion` merged into it first), and the next -work (DLSS) starts on a new branch from the updated `main`. No DLSS or later-phase work lands on -`feat/vulkan-native`. - -### Phase 3: native shaders - -Set convention, placement table, manifest, compiler tool, adapter layout (rewriter retargeted -to sets 0-3 in the same commit as set 0 lands, so there is one layout change, not two), native -GLSL in seven stages (includes + six fullscreen/post programs first; GUI/lines/ -texture2texture; chunk family incl. `NoSSBOs`; entity family incl. OIT variant; particles/ -decals/sky/clouds; SSAO/godrays/bloom/colorgrade/OIT compose/debug; the seven Optimum programs) (taa-resolve keeps the 2026-09-11 fix: 3x3 nearest-depth disocclusion with motion from the nearest-depth tap and luminance anti-flicker weighting, pinned by the GPU tests on fix/taa-antiflicker-disocclusion and by `scripts/dev/taa-rejection.py`; never a single-sample depth test), -scanner v2, the contract addendum-or-v2 decision, `ReloadShaders` no longer recompiling on a -settings change. - -Tests: `vk-shader-parity-tests.cs` (per program per variant: uniform-name set, sampler name set -and order, vertex-input locations, fragment-output count equal to the GLSL 330 source through -the existing `ShaderCorpus`; a vanilla shader change in a game update fails here instead of on -screen), `vk-motion-writer-shape-tests.cs`, manifest schema and consistency tests, -`AdapterLayoutMatchesNativeLayoutTests`, the native-vs-rewriter differential motion tests, -`Optimum.Launcher.Tests` fixtures (a mod overriding `chunkopaque.fsh` marks only that program; -a `shaderincludes` override marks all; Harmony + platform string → `openGlRequired`), a test -that `sources/shaders-vk/` contains no `.vsh/.fsh`. - -Exit: log line reports 48 native / 0 failed; parity and differential tests green; per-attachment -SSIM ≥ 0.99 or allowlisted; validation clean; in game the settings sweep (SSAO 0/1/2, shadows -0/1/2, bloom, god rays 0/1/2, FXAA, render scale 0.5/1.0/1.5, waving foliage) on both backends, -plus the full `docs/vulkan-acceptance.md` matrix; contract decision recorded. - -### Phase 3b: retire the GL-emulation layer (decision 7) - -Native shaders alone leave the device taking GL-shaped calls: `GlStateTracker` ("the emulated OpenGL state -machine") resolves viewport, scissor, depth, cull, stencil, blend, colour mask, topology and program into a -`PipelineKey` on every draw; textures, programs and framebuffers are GL-style integer ids; samplers bind by -texture unit; uniforms are located by byte offset; draw-buffer masks and the clip-depth remap follow GL. -Phase 3b removes that layer from the Vulkan path. - -- **Native render systems.** Each system the patched client drives - chunks (opaque, topsoil, liquid, - shadow), entities (the OIT variant included), particles, decals, sky and clouds, GUI and text, the post - chain and TAA - gets a Vulkan-side renderer that owns its pipelines (created from the manifest at load, - never resolved per draw), its descriptor sets on the set convention, and its per-draw data in push - constants and the draw set. The client hands it scene data - meshes, textures, transforms, uniforms by - meaning - not GL calls. -- **The platform contract changes shape.** `ClientPlatformAbstract`'s GL-shaped members (fixed-function - state toggles, texture units, `SetUniform` by location, draw buffers) remain the OpenGL path's. On Vulkan - the render systems reach their native renderers through transplanted seams, one per system, and the - GL-shaped overrides in `VulkanClientPlatform` are deleted as each system moves. -- **Resources by handle.** Meshes, textures and targets are typed handles owned by the renderer; the - GL-id tables, texture units and the location-offset uniform shadow go with the last GL-shaped caller. - -Exit: `GlStateTracker`, the GL enum tables and the placement-by-location uniform path have no Vulkan-path -callers; every vanilla render system draws through its native renderer; the Phase 3 exit sweep re-passes on -both backends. Open: the order systems move in (the post chain and TAA first, since Optimum owns them end -to end), and whether the runtime rewriter survives for mod shaders. - -### Phase 4: performance - -Moved to roadmap step 4 (2026-09-15), after the general refactor, except the SPIR-V cache and manifest, -which Phase 3 produces. - -Landed early, per `docs/research/vulkan-caching.md`: the disk SPIR-V cache (`ShaderBinaryCache`: key over -format version, compiler options, the shaderc binary's hash, stage and rewritten source; header with a -SHA-256 of the payload) and the persisted driver pipeline cache (`PipelineCacheFile`: one file per GPU, -wrapper checked against vendor, device, driver version, pointer size and UUID, blob header checked too, -empty cache on any mismatch or driver rejection, saved at shutdown). Both write atomically with retries -and live in `GamePaths.Cache/optimum-vulkan`; `OPTIMUM_VULKAN_SHADER_CACHE=` moves them and -`OPTIMUM_VULKAN_SHADER_CACHE=0` turns them off. The device-up validation log line says whether the -pipeline cache started cold, warm or rejected. Still open from the research: compile-required -(`FAIL_ON_PIPELINE_COMPILE_REQUIRED`) with background builds, growth-triggered saves, and -`VK_KHR_pipeline_binary` as an optional backend. - -Used-key manifest + warm-up; push-constant placement from the measured -profile (`OPTIMUM_VULKAN_UNIFORM_PROFILE`) frozen into the manifest for the 48 programs; -animation SSBO ring; `Use()` include-block early-out (measured first); per-pass GPU timestamps -(`timestampValidBits` gated); transient aliasing default on after clean validation on all -targets; bindless textures are Phase 3 now (decision 9); `DirectToSwapchain` and transfer backend B measured, kept only where they win. - -Exit: on the fixed scene Vulkan mean FPS ≥ OpenGL and p99 ≤ OpenGL on this machine, numbers in -`docs/vulkan-acceptance.md` §6 (Arc 140V row filled when the handheld is available); pipeline -cache hit rate ≥ 95 % on second launch; per-pass ms table sums to within 10 % of GPU frame time; -`perf-capture.sh` × 4 (both backends × TAA on/off) plus a 30-minute session. - -### Phase 5: mod API and fork ports - -Contracts pass API and opt-in motion-writer API (data holders only); `VSEssentials` / -`VSSurvivalMod` / `VSCreativeMod` renderers checked against declared passes; a fixture -shader-pack mod on the rewriter; three real mods from the user's library. Exit: forks run with -no concrete-cast fallback; fixture renders; scanner v2 launcher tests green. - -Under decisions 7 and 8 this phase also publishes the mod documentation: how a mod declares passes, writes -motion, ships native shaders and draws through the native renderers, with a fixture mod that follows it -step by step. The forks port to the native render systems rather than to the GL-shaped seam. - -### Upscaling, latency, frame generation, HDR and ray tracing: not on this branch - -The vendor orchestrator decisions (2026-09-11 and 12), the latency seams, the NGX spike on native Linux, -DLSS Super Resolution, the upscaler and frame-generation seams (the former Phase 6), and the HDR and -ray-tracing roadmap items live on `feat/dlss-g` and in this document's version on `main`. They return as -their own pull requests on top of the native backend. - -### Roadmap item: a headless render harness that does not take the machine - -Added 2026-09-12 at the user's request: "A headless renderer you can run in the background and take frames -out so my PC is not blocked." Every visual verification so far has meant opening the real client on the -user's desktop, stealing focus and the GPU, which is why in-game checks are rationed and why a -temporal artefact cannot be judged at all without the user sitting in front of it. - -What it has to be: the real renderer and the real client path (a mock proves nothing, and a launch -on the wrong backend is not a verification), driven without a visible -window, writing frames to disk on demand, and runnable while the user works. The pieces already exist and -are the reason this is a roadmap item rather than a project: the GPU test suite creates real -`VulkanDevice`s and hidden GLFW windows today, `OPTIMUM_PARITY_DUMP` already writes every attachment at a -chosen in-world frame, `scripts/dev/parity-capture.sh` already drives a full client run unattended, and -`OptimumParityDump.WritePresentedFrame` already exists on the diagnostic branch. - -Shape to aim for: -- An offscreen mode for the client (hidden window or a surfaceless device where the swapchain is replaced - by an owned image), selected by an environment variable, with the frame loop otherwise untouched. -- Frame extraction: presented frames to disk at a chosen cadence or frame list, plus the existing - per-attachment dump, in a format the existing tools already read (`ssim.py`, `taa-rejection.py`, - `luma-diff.py`). -- A scripted camera and world state so a sequence is reproducible frame for frame: the fixed scene of - `docs/vulkan-acceptance.md` section 0 plus a recorded camera path, so two runs differ only by the change - under test. This is what finally makes temporal artefacts measurable without eyes - consecutive-frame - differences on a *deterministic* sequence, which today's launches cannot provide. -- Low priority on the GPU (or an explicit "run only while idle" switch) so a capture can sit in the - background while the user plays or works. -Acceptance: one command produces a 60-frame deterministic sequence on both backends, with the renderer -line confirmed, without a window appearing on the user's desktop; the shimmer class of bug (jitter, -disocclusion, AO noise) shows up as a number from that sequence. - -**Built 2026-09-12.** `OPTIMUM_HEADLESS` (hidden window - the surfaceless-device alternative was rejected -because there is no surfaceless GL path in this client, so it would not be symmetric across backends), -`OPTIMUM_HEADLESS_COMMANDS` (chat-command script: the section 0 scene and vanilla's own `SystemCinematicCamera` -via `.cam load` / `.cam play`), `OPTIMUM_HEADLESS_FIXED_DT` (pins `ClientMain.DeltaTimeLimiter`), -`OPTIMUM_HEADLESS_FRAMES` plus a frame list or a cadence (PPM per frame, through `ReadDefaultFramebuffer`), -`scripts/dev/headless-capture.sh` end to end. The 30 FPS background cap comes free from the window never -being focused. See `docs/vulkan-acceptance.md` section 3, "Headless render harness", for what it does not cover: -a display server is still required, reproducibility is repeatable rather than bit-exact, and no camera path -is checked in yet - which is why the acceptance sentence above is not yet a claim, only a capability. -Backported to `feat/vulkan-taa` in `766aada`. - -### Roadmap item: GTAO (XeGTAO) replaces the vanilla SSAO - -Added 2026-09-12 at the user's request, after DLSS exposed the ambient occlusion as the last frame-wide -shimmer source. - -Vanilla's AO is hemisphere SSAO, 20 samples (24 at `SSAOLEVEL 2`), radius 0.9, with the sample kernel -rotated by a **screen-locked Bayer-128 dither** (`bayer128(texcoord * screenSize)` mapped onto a golden -spiral) and a bilateral blur, computed at half render resolution -(`.vanilla/**/assets/game/shaders/ssao.fsh`). A dither fixed to the pixel grid under a jittered camera -re-rolls each surface point's kernel every frame, which no temporal accumulator can average, and in the -DLSS path the result is composited after the upscale, where the upscaler never sees it. - -Target: **XeGTAO** (GameTechDev, MIT, Jimenez et al. 2016) - radiometrically correct horizon-slice -integral, a 5x5 depth-aware spatial denoiser, and controlled temporal noise designed to converge through a -temporal accumulator. Measured by Intel at 0.56 ms (1080p, RTX 2060) and 1.4 ms (4K, RTX 3070); bent -normals cost about 25 % more. It ships as HLSL compute for D3D12 Shader Model 6.3, so the work is a GLSL -port plus a compute path in the renderer - it belongs after Phase 3's native shaders, where compute and -the set convention already exist. - -Order of work, because the cheap parts are prerequisites and may settle the symptom on their own: -1. Composite AO inside the scene, before the upscaler evaluate, at render resolution (NVIDIA's placement - rule; also removes the magnification of a half-render-resolution buffer). -2. Make the dither temporally varying (rotate with the jitter phase) so any accumulator converges it. -3. Only then port XeGTAO, and judge it against the fixed SSAO rather than against today's. - -**Status on this branch (2026-09-15):** steps 1 and 2 are in for the TAA path (`41373cf`: the AO multiplied -into the scene before the resolve, the dither advanced per frame under `TAAMOTION`). Step 3 is roadmap step -2, after the native backend, as native compute. - ---- - -## Verification and evidence rules - -- A launch is a verification only with the `[Optimum] Vulkan renderer` / `[Optimum] OpenGL - renderer:` line in the log; both backends at every phase exit; game closed afterwards. -- Accepted evidence for temporal and pacing claims (recorded in `docs/vulkan-acceptance.md` §4): the `sync,best` validation log; a multi-frame GPU test with Present - between frames and no readback in the loop; the pacing-gate numbers; per-attachment numeric - diffs; a 60 fps `ffmpeg -f x11grab` capture with consecutive-frame region diffs for anything - called flicker; per-pass timestamps for anything called a stall; `OPTIMUM_VULKAN_POISON=1` - for anything that might read undefined memory. Screenshot pairs are never evidence. -- Every fix: GPU readback test in `Optimum.Render.Vulkan.Tests` (patterns - `VulkanDeviceIntegrationTests`, `AttachmentSemanticsTests`, multi-frame `TaaResolveTests`) and - a source-coverage test in `Optimum.Tests` (pattern `fsr-pipeline-coverage-tests.cs`). -- "OFF is vanilla" is a test, not a claim: the lib diff against `_ref/` is limited to the listed - regions and contains no renderer-specific code. - -## Risks (ranked) - -1. **Virtualization bypassed by `call`**: silent OpenGL-looking behaviour on Vulkan. Mitigated - only by the Phase 0 verifier; Phase 1A does not start without it. -2. **Injected virtual missing at runtime** (`MissingMethodException` deep in a frame): the - `InitializeGraphics` reflection self-check fails the install to the OpenGL fallback instead. -3. **The post-window fallback path is nearly untestable**: `OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE` - exercises it in the real client once per phase exit. -4. **Mass re-pointing of 14 test files in 1A.2 could paper over a regression**: pure-move commit, - bodies diffed textually, no behaviour change allowed in that commit. -5. **Write-mask semantics vs undefined attachment contents** (rule 9): keep - `AttachmentSemanticsTests` and `WorldRenderPathTests` green through Phase 2, read the sync log - before believing a picture, poison mode available. -6. **Driver tiers on Arc 140V** (color-write-enable, EDS3, descriptor indexing, timeline - semaphores): every tier forceable by env and tested; the baked-into-pipeline tier always - works; the Arc row of the vendor matrix is filled before Phase 4 exit. -7. **Streaming graph without foresight**: the plan cache; one conservative frame per settings - change or resize (which already resets TAA). -8. **Temporal contract drift** across 48 rewritten shaders: single-writer include, differential - tests, `temporal-contract-tests.cs`, explicit addendum-or-v2 decision in Phase 3. -9. **Manifest vs `collectUniformNames` disagreement**: the parity test diffs the name sets per - program per variant; a mismatch means the native shader is wrong. -10. **Full-native scope** (decision 7): every vanilla render system gets a native renderer, so the - programme is long. The game runs at every phase boundary and each system moves on its own, so the - programme can stop at any boundary and still ship. -11. **Mods without native support** (decision 8) have to reach OpenGL reliably: the launcher scan must - route them, Harmony patches on platform graphics members included, which scan v1 does not detect. - -## Documentation to update - -`VULKAN-BACKEND-PLAN.md` → v2 (§6 native shaders and sets, §9 Harmony and shader-pack rules, §10-§11 -replaced by this plan's phases and tests, §14 file list); `docs/vulkan-acceptance.md`; -`docs/parity-allowlist.md`; `docs/temporal-frame-contract.md` addendum or v2 (Phase 3); the mod -documentation for Vulkan-native support (Phase 5, decision 8); the build and diagnostics notes gain -`make check-shaders-vk`, the evidence rules and the new environment switches. - -## Critical files - -- `build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs`, - `ClientPlatformWindows.cs` (`window_RenderFrame` 729, `Start` ~1086, `SetupOptimumFrameBuffers` - 1629-1916, post chain 3170-3599, `BlitPrimaryToDefault` 4008), `ClientMain.cs` - (`TriggerRenderStage`, 1402), `Vintagestory.Client/ClientProgram.cs` (214-255, 356-410, 451), - `Vintagestory.Client/ScreenManager.cs` (29, 121, `Render`), `ShaderProgramBase.cs`, `UBO.cs` -- `Optimum.Patcher/Program.cs`, `MemberInjector.cs`, `SelfConsistencyVerifier.cs` -- `VintagestoryApi/Client/optimum-render-device.cs`, `optimum-render-bootstrap.cs` -- `Optimum.Render.Vulkan/VulkanDevice.cs` (`Present` 695-733, `BlitToSwapchain` 744-799, - `PrepareDraw` 1747-1843, `AllocateIndirect` 2356-2399, `FlushFrame` 2503-2512), - `Core/FrameRing.cs`, `Core/Swapchain.cs`, `Core/TextureManager.cs`, `Core/RenderTargetManager.cs`, - `Core/VulkanAllocator.cs`, `Core/MeshManager.cs`, `Core/PipelineCache.cs`, - `Core/GlStateTracker.cs`, `Core/VulkanStats.cs`, `Shaders/ProgramInterfaceLayout.cs` -- `Optimum.Launcher/ShaderCompatibilityScanner.cs` -- `Optimum.Render.Vulkan.Tests/{VulkanDeviceIntegrationTests,AttachmentSemanticsTests, - TaaResolveTests,SwapchainTests}.cs`, `ShaderCorpus.cs`; `Optimum.Tests/ - {cecil-transplant-lambda-tests,temporal-contract-tests,fsr-pipeline-coverage-tests}.cs` - -## Not on the critical path (parallel follow-ups, own PRs) - -Shader patch system for the GL-path overrides (`patches/shaders/*.patch` against the vanilla -archive, `extract/check-shader-patches.sh`); splitting -`Optimum.Shaders` out of the renderer and a lavapipe CI job; `vkCmdDrawIndexedIndirectCount` -with GPU culling (the per-slot indirect ring is shaped for it). diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index fce809d4..367591ce 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -5,7 +5,7 @@ pipelines, its descriptor use on the set convention and its per-draw data, inste the GL-shaped platform virtuals. OpenGL keeps `ClientPlatformWindows` unchanged ("OFF is vanilla"). Inputs: -- plan decision 7 and Phase 3b (`docs/vulkan-native-plan.md`); +- plan decision 7 and Phase 3b of the Vulkan-native plan; - `docs/research/vulkan-descriptor-model.md`, "Native architecture for this renderer"; - the shader contract (`docs/vulkan-native-shaders.md`): all 49 vanilla programs are native, and the runtime seam links them from the manifest; @@ -350,12 +350,12 @@ Tests: `NativeGuiTests` (old route against native route for both systems, blendi line widths, the pipeline identity across line widths, and twenty fresh textures through one pipeline) and the GUI section of `Optimum.Tests/native-world-systems-coverage-tests.cs` for the lib seams. -## 4. Documentation that makes map stages unnecessary +## 4. Documentation that makes a map of the tree unnecessary -Every workflow so far has opened with a read-only map stage that rediscovers where things are, at five -figures of tokens each, and thrown the result away when the run ended. The fix is not a map document - -that is a second source of truth and it rots. The fix is that the code answers the question at the -declaration, so an implementer greps and reads instead of mapping. +Every piece of work on this backend has started by rediscovering where things are, and the result was +thrown away when the work ended. The fix is not a map document - that is a second source of truth and +it rots. The fix is that the code answers the question at the declaration, so an implementer greps and +reads instead of mapping. **The convention.** Every render seam - a platform virtual a system draws through, a native pass, a device API entry point - carries a doc comment that answers, in this order: @@ -376,11 +376,10 @@ device API entry point - carries a doc comment that answers, in this order: system moves to a native pass, its old body keeps its comment and gains the pointer to the new one. **Applies to:** `Optimum.Render.Vulkan/Platform/VulkanClientPlatform.*.cs`, `VulkanDevice.Native.cs` and -the transplanted seams in `build/VintagestoryLib/**`. An implementation stage documents the seams it -touches as part of the change, not afterwards; a stage that adds a seam without this comment is -incomplete, and review should send it back. +the transplanted seams in `build/VintagestoryLib/**`. A change documents the seams it touches as part of +the change, not afterwards; a change that adds a seam without this comment is incomplete, and review +should send it back. -**Map stages** are then only for questions the code genuinely cannot answer - measured behaviour, vendor -documentation, or a tree the repository does not contain. `scripts/dev/harvest-maps.py` recovers the map -output of past runs from the workflow journals when one of those is needed again. +A survey of the tree is then only for questions the code genuinely cannot answer - measured behaviour, +vendor documentation, or a tree the repository does not contain. diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index e4fca01b..55aa8d4a 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -1,7 +1,7 @@ # Vulkan-native shaders: the interface contract Every native program family is written against this document. It turns plan section C ("Shaders", -`docs/vulkan-native-plan.md`) and decision 9 into rules precise enough that seven families can be rewritten in +the Vulkan-native plan) and decision 9 into rules precise enough that seven families can be rewritten in parallel and still link against one pipeline layout, one manifest and one runtime. Inputs: diff --git a/scripts/dev/harvest-maps.py b/scripts/dev/harvest-maps.py deleted file mode 100644 index 3faabf36..00000000 --- a/scripts/dev/harvest-maps.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -"""Collect the read-only map stages of past workflow runs into docs/vulkan-render-map.md. - -Every workflow that starts with a map stage pays a five-figure token bill to rediscover where things -are. The results were already written to the per-run journals and then thrown away. This pulls them -back out, keeps the newest result per map label, and writes one document a later stage can read -instead of mapping the tree again. - -Usage: - python3 scripts/dev/harvest-maps.py [--journals ] [--out docs/vulkan-render-map.md] - [--label-prefix map:] [--max-chars 60000] - -The journals live beside the session transcripts, not in the repository, so this is a one-way import: -run it after a workflow whose map stage found something worth keeping, then review the diff. A section -that disagrees with the tree is worse than no section - correct it by hand rather than trusting age. -""" - -import argparse -import json -import os -import time - -DEFAULT_JOURNALS = os.path.expanduser( - "~/.claude/projects/-home-n1ght-Projekte-Optimum/" - "7b680a84-9a6a-436d-a472-1a2eb79eb45f/subagents/workflows" -) - -HEADER = """# Vulkan render map (living document) - -Where things are on the Vulkan path, so a workflow stage does not have to rediscover them. - -**Read this before writing a map stage.** Map only what this file does not already answer, and fold -anything new back in: run `python3 scripts/dev/harvest-maps.py` after a workflow whose map stage found -something, then review the diff. - -Each section is one map agent's own output, unedited, with the date it was produced. Age matters: the -tree moves, and a section that disagrees with it is worse than no section. Verify a file:line before -you rely on it; correct the section when you find it stale. - -Design rationale lives in `docs/vulkan-native-render-systems.md`. Status lives in -`docs/vulkan-branch-progress.md`. This file is only "where is it". -""" - - -def collect(journal_dir, prefix): - """Newest result per map label across every run, joined agentId -> label.""" - best = {} - for run in sorted(os.listdir(journal_dir)): - path = os.path.join(journal_dir, run, "journal.jsonl") - if not os.path.exists(path): - continue - labels, results = {}, [] - for line in open(path, encoding="utf-8"): - try: - rec = json.loads(line) - except ValueError: - continue - kind = rec.get("type") - agent = rec.get("agentId") - if kind == "started" and agent and rec.get("label"): - labels[agent] = rec["label"] - elif kind == "result" and agent: - results.append((agent, rec.get("result"))) - for agent, value in results: - label = labels.get(agent) - if not label or not label.startswith(prefix): - continue - if isinstance(value, (dict, list)): - value = json.dumps(value, indent=2) - if not isinstance(value, str) or len(value) < 500: - continue - transcript = os.path.join(journal_dir, run, f"agent-{agent}.jsonl") - when = os.path.getmtime(transcript) if os.path.exists(transcript) else 0 - if label not in best or when > best[label][0]: - best[label] = (when, run, value) - return best - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--journals", default=DEFAULT_JOURNALS) - ap.add_argument("--out", default="docs/vulkan-render-map.md") - ap.add_argument("--label-prefix", default="map:") - ap.add_argument("--max-chars", type=int, default=60000) - args = ap.parse_args() - - if not os.path.isdir(args.journals): - raise SystemExit(f"no journal directory at {args.journals}") - - best = collect(args.journals, args.label_prefix) - if not best: - raise SystemExit("no map results found") - - order = sorted(best.items(), key=lambda kv: -kv[1][0]) - out = [HEADER, "\n## What is in here\n", - "| map | produced | size | source run |", "|---|---|---|---|"] - for label, (when, run, value) in order: - day = time.strftime("%Y-%m-%d", time.localtime(when)) if when else "unknown" - out.append(f"| [{label}](#{label.replace(':', '').replace('_', '')}) | {day} | " - f"{len(value) // 1000}k | `{run}` |") - out.append("") - - for label, (when, run, value) in order: - day = time.strftime("%Y-%m-%d", time.localtime(when)) if when else "unknown" - body = value - if len(body) > args.max_chars: - body = body[:args.max_chars] + ( - f"\n\n*[truncated at {args.max_chars} characters; the full result is in " - f"the run's journal, `{run}`]*\n") - out.append(f"---\n\n## {label}\n\nProduced {day} by run `{run}`, unedited.\n\n{body}\n") - - with open(args.out, "w", encoding="utf-8") as handle: - handle.write("\n".join(out)) - print(f"wrote {args.out}: {len(order)} maps, {os.path.getsize(args.out)} bytes") - for label, (when, run, value) in order: - print(f" {label:34} {len(value) // 1000:>4}k {run}") - - -if __name__ == "__main__": - main() diff --git a/scripts/dev/luma-diff.py b/scripts/dev/luma-diff.py index 72575951..7e91cb77 100755 --- a/scripts/dev/luma-diff.py +++ b/scripts/dev/luma-diff.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Still-frame luminance diff, the acceptance measurement from -.claude/skills/vulkan-parity-debug/SKILL.md section 2c. +"""Still-frame luminance diff, the acceptance measurement for "does it still jitter" +(docs/taa-acceptance.md, still-frame luminance diff). Two screenshots of a STILL camera one second apart, mean absolute luminance difference over the centre 60% crop. Repeat for ~7 pairs per backend and compare diff --git a/scripts/dev/parity-capture.sh b/scripts/dev/parity-capture.sh index cc286b72..f501e0f4 100755 --- a/scripts/dev/parity-capture.sh +++ b/scripts/dev/parity-capture.sh @@ -25,7 +25,8 @@ # --dump-wait seconds to wait for the dump line after it (default 120) # # Exit: 0 dump written on the requested renderer, 1 failure, 2 usage. -# This script never pattern-kills anything (CLAUDE.md rule 5): closing goes +# This script never pattern-kills anything (a pkill -f from a shell whose command line +# names the process kills that shell): closing goes # through scripts/dev/kill-client.sh, which is the only place that owns that pattern. set -euo pipefail diff --git a/sources/shaders-vk/taa-resolve.frag b/sources/shaders-vk/taa-resolve.frag index 26c88bcd..016192d8 100644 --- a/sources/shaders-vk/taa-resolve.frag +++ b/sources/shaders-vk/taa-resolve.frag @@ -3,7 +3,7 @@ #extension GL_GOOGLE_include_directive : require // Native port of taa-resolve.fsh (the Optimum program in sources/shaders, docs/vulkan-native-shaders.md). // The body is the GLSL 330 body token for token; the only differences are the bindless sampler reads -// (texture(optimumTextures2D[name], ...)). The CLAUDE.md rule-11 invariants - 3x3 nearest-depth +// (texture(optimumTextures2D[name], ...)). The resolve's temporal invariants - 3x3 nearest-depth // disocclusion with motion from the nearest-depth tap, the closest-tap writer-depth tolerance and the // luminance anti-flicker 0.3x..1.2x blendAlpha - are reproduced verbatim, with their DO NOT REVERT notes. // From 1fd97b8b047172ad9b9c937b1218d9efdc3e7f84 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 16:18:17 +0200 Subject: [PATCH 222/226] test: two assertions caught up with the tree The parity capture's reworded comment must not spell the process-kill command the test forbids, and the seam-deletion test names the fork bridge's current two-argument constructor (platform, device) from the cloud work. Checked: Optimum.Tests 1278 passed, 34 skipped. --- Optimum.Tests/platform-seam-deletion-coverage-tests.cs | 2 +- scripts/dev/parity-capture.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs index ced4789a..3ddaf5cb 100644 --- a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs +++ b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs @@ -86,7 +86,7 @@ public void TheContractsKeepOnlyTheBackendDecisionAndTheForkBridge() Assert.DoesNotContain("OptimumForkGraphics", File.ReadAllText(file)); } string vulkan = VulkanPlatformSource.Read(); - Assert.Contains("OptimumForkGraphics.Active = new VulkanForkGraphics(device);", vulkan); + Assert.Contains("OptimumForkGraphics.Active = new VulkanForkGraphics(this, device);", vulkan); string shutdown = Body(vulkan, "public override void ShutdownGraphics()"); Assert.True(shutdown.IndexOf("OptimumForkGraphics.Active = null;", StringComparison.Ordinal) < shutdown.IndexOf("device?.Dispose();", StringComparison.Ordinal)); diff --git a/scripts/dev/parity-capture.sh b/scripts/dev/parity-capture.sh index f501e0f4..54f058e0 100755 --- a/scripts/dev/parity-capture.sh +++ b/scripts/dev/parity-capture.sh @@ -25,8 +25,8 @@ # --dump-wait seconds to wait for the dump line after it (default 120) # # Exit: 0 dump written on the requested renderer, 1 failure, 2 usage. -# This script never pattern-kills anything (a pkill -f from a shell whose command line -# names the process kills that shell): closing goes +# This script never pattern-kills anything (a kill by process name, issued from a shell +# whose own command line names that process, kills the shell itself): closing goes # through scripts/dev/kill-client.sh, which is the only place that owns that pattern. set -euo pipefail From 7e2e68b377c0a9511f1bc0407d8d450325e8f5eb Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 17:30:14 +0200 Subject: [PATCH 223/226] feat(native): a generic native draw from client-stated state (emulation removal, step 1) Every draw the dedicated native routes do not take - mod renderers with their own programs, the vanilla programs without a dedicated route, the seams' neutral bodies behind the route switches - is now recorded natively before the emulated draw is reached: RenderMesh, RenderMeshInstanced, the pool multi-draw and the fullscreen triangle (VulkanClientPlatform.NativeStated.cs). It builds the pipeline, the pass and the textures from StatedRenderState, the fixed-function state the client stated through the platform's virtuals, with OpenGL's semantics: glBlendFunc sets every draw buffer, glBlendFunci one, glDisable(GL_BLEND) keeps the functions; the colour mask is global; draw buffers are per framebuffer (attachment 0 by default) and become write masks; one texture per unit; a framebuffer bind does not move the viewport. Every recording site forwards to the device as before (StateDrawBuffers, StateSlotBlend, StateBlend, the fork bridge's notes), so the emulated route is unchanged until step 2. Textures resolve per sampler through the program's unit mapping, as the emulated draw does; the pass declares every colour slot attached on the device. Found on the way: the OIT accumulation targets are attached to Transparent without being in its ColorTextureIds (water drew black until the slots came from the attachments); GL's thick-line probe leaves the line width at 1.5; GlToggleBlend(false) keeps the functions on GL while the device reset them. OPTIMUM_VK_NATIVE_STATED=0 keeps the emulated route; OPTIMUM_VK_STATED_CHECK=1 compares the record against the device's tracked state at every draw. Checked: build 0 errors; new NativeStatedTests (blend off, three modes, colour mask, scissor, an unselected draw buffer: pixels equal to the emulated route, one native draw, no emulated call inside the pass); the dedicated-route differential fixtures keep the emulated neutral body; GPU suite 1123 passed, Optimum.Tests 1338 passed; headless Vulkan with every dedicated route off: 24400 generic passes, 0 emulated draws, validation 0 errors / 0 SYNC-, the state check reports no difference, frame matches the dedicated and emulated routes within launch-to-launch variance (mean colour of repeated launches of one route differs by more than the routes do). --- .../NativeBlitTests.cs | 10 + .../NativeChunkTests.cs | 10 + .../NativeEntityDrawTests.cs | 10 + Optimum.Render.Vulkan.Tests/NativeGuiTests.cs | 10 + .../NativePostChainTests.cs | 10 + Optimum.Render.Vulkan.Tests/NativeSkyTests.cs | 10 + .../NativeStatedTests.cs | 361 ++++++++++++++++++ .../NativeWorldSystemsTests.cs | 10 + .../Platform/StatedRenderState.cs | 143 +++++++ .../Platform/VulkanClientPlatform.Frame.cs | 2 + .../VulkanClientPlatform.FrameBuffers.cs | 43 +-- .../Platform/VulkanClientPlatform.Leaf.cs | 14 +- .../Platform/VulkanClientPlatform.Meshes.cs | 16 + .../VulkanClientPlatform.NativeClouds.cs | 12 +- .../VulkanClientPlatform.NativeStated.cs | 235 ++++++++++++ .../Platform/VulkanClientPlatform.Shaders.cs | 5 + .../Platform/VulkanClientPlatform.State.cs | 32 ++ .../Platform/VulkanClientPlatform.Taa.cs | 14 +- .../Platform/VulkanForkGraphics.cs | 17 +- Optimum.Render.Vulkan/VulkanDevice.Native.cs | 91 +++++ .../native-world-systems-coverage-tests.cs | 49 +++ .../platform-seam-deletion-coverage-tests.cs | 2 +- .../taa-liquid-motion-coverage-tests.cs | 2 +- .../taa-particle-motion-coverage-tests.cs | 3 +- Optimum.Tests/taa-pipeline-coverage-tests.cs | 8 +- .../taa-terrain-motion-coverage-tests.cs | 6 +- .../vulkan-backend-integration-tests.cs | 2 +- 27 files changed, 1074 insertions(+), 53 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/NativeStatedTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/StatedRenderState.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs diff --git a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs index 37cf0dff..deb5df49 100644 --- a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs @@ -244,6 +244,16 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, WindowSize, WindowSize, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs index 4d4b9aab..c2f0226c 100644 --- a/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs @@ -244,6 +244,16 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs index f8fe7b62..01135a60 100644 --- a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs @@ -281,6 +281,16 @@ public string ClearOf(int slot) CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs index 433b7fe8..796813c2 100644 --- a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs @@ -272,6 +272,16 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs index d2dab8a4..b403dd08 100644 --- a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -865,6 +865,16 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs index 3c86f515..b48e5bac 100644 --- a/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs @@ -164,6 +164,16 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs b/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs new file mode 100644 index 00000000..ba1863fb --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Platform; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +using LinkedProgram = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestProgram; +using LinkedShader = Optimum.Render.Vulkan.Tests.VulkanDeviceIntegrationTests.TestShader; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The generic native draw (VulkanClientPlatform.NativeStated.cs) against the emulated draw it +/// replaces, on one device: the same mesh, the same program, and fixed state set only through the +/// platform's own virtuals - which record it for the generic route and still push it into the +/// device for the emulated one. Identical pixels mean the record states what the device tracked. +/// +/// The program is a gui program that is not the registered ShaderPrograms.Gui, so no dedicated +/// route takes the draw: it is exactly the shape of a mod renderer's draw. +/// +public class NativeStatedTests(ITestOutputHelper output) +{ + private const int Size = 16; + + private sealed class StatedPlatform : VulkanClientPlatform + { + public StatedPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + public enum Mask { All, RedGreen } + + /// + /// Blend off, three blend modes, a colour mask and a scissor rectangle: the generic route + /// draws what the emulated route draws, records one native draw, and runs no emulated call + /// inside its pass. + /// + [SkippableTheory] + [InlineData(false, EnumBlendMode.Standard, Mask.All, false)] + [InlineData(true, EnumBlendMode.Standard, Mask.All, false)] + [InlineData(true, EnumBlendMode.PremultipliedAlpha, Mask.All, false)] + [InlineData(true, EnumBlendMode.Brighten, Mask.All, false)] + [InlineData(true, EnumBlendMode.Standard, Mask.RedGreen, false)] + [InlineData(true, EnumBlendMode.Standard, Mask.All, true)] + public unsafe void TheStatedRouteDrawsWhatTheEmulatedRouteDraws(bool blend, EnumBlendMode mode, Mask mask, bool scissor) + { + using Session session = Open(); + + byte[] emulated = session.Run(stated: false, blend, mode, mask, scissor); + + long statedBefore = session.Platform.StatedDrawsForTests; + long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; + byte[] native = session.Run(stated: true, blend, mode, mask, scissor); + + Assert.Equal(1, session.Platform.StatedDrawsForTests - statedBefore); + Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); + output.WriteLine("centre emulated " + Centre(emulated, 0) + " stated " + Centre(native, 0)); + Assert.Equal(emulated, native); + GpuTest.AssertClean(session.Seam); + } + + /// + /// A target with two colour attachments and only the first selected as a draw buffer: the + /// second keeps its clear colour on both routes - the stated draw buffers are write masks. + /// + [SkippableFact] + public unsafe void AnUnselectedDrawBufferKeepsItsContentsOnBothRoutes() + { + using Session session = Open(); + + (byte[] firstEmulated, byte[] secondEmulated) = session.RunTwoTargets(stated: false); + (byte[] firstStated, byte[] secondStated) = session.RunTwoTargets(stated: true); + + Assert.Equal(firstEmulated, firstStated); + Assert.Equal(secondEmulated, secondStated); + // The second attachment is still the clear colour (0, 51, 102, 153). + Assert.Equal("0,51,102,153", Centre(secondStated, 0)); + GpuTest.AssertClean(session.Seam); + } + + private static string Centre(byte[] pixels, int offset) + { + int i = offset + (Size / 2 * Size + Size / 2) * 4; + return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3]; + } + + private Session Open() + { + (string manifest, string reason) = NativeManifest.Value; + Skip.If(manifest.Length == 0, reason); + Session? session = Session.TryOpen(output, manifest); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + private sealed class Session : IDisposable + { + private static readonly float[] Identity = + { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }; + + public StatedPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + private FrameBufferRef target = null!; + private FrameBufferRef twoTargets = null!; + private MeshRef quad = null!; + private ShaderProgram gui = null!; + private int texture; + private ClientPlatformAbstract? previousPlatform; + private string dataPath = ""; + + public static Session? TryOpen(ITestOutputHelper output, string manifestDirectory) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-stated-" + Guid.NewGuid().ToString("N")); + var platform = new StatedPlatform + { + DeviceFactory = () => + { + VulkanDevice created = GpuTest.NewDevice(); + created.NativeShaderDirectory = manifestDirectory; + created.NativeShadersEnabled = true; + created.IgnoreModShaderScan = true; + return created; + }, + CrashMarkerDataPath = dataPath, + }; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + platform.NativeGuiEnabled = false; + + var session = new Session { Platform = platform, previousPlatform = ScreenManager.Platform, dataPath = dataPath }; + ScreenManager.Platform = platform; + platform.ShaderUniforms = new DefaultShaderUniforms(); + + VulkanDevice seam = platform.GraphicsDevice!; + session.target = CreateTarget(seam, 1); + session.twoTargets = CreateTarget(seam, 2); + InstallFrameBuffers(platform, session.target); + // The draw buffers each target writes, stated the way the platform states its own. + platform.StateDrawBuffers(session.target.FboId, 1); + platform.StateDrawBuffers(session.twoTargets.FboId, 1); + + var program = new ShaderProgram { PassName = "gui" }; + Link(seam, program, "gui", + new[] { "projectionMatrix", "modelViewMatrix", "rgbaIn", "noTexture", "applyColor", "alphaTest" }); + session.gui = program; + session.texture = Gradient(seam); + session.quad = platform.UploadMesh(BuildQuad()); + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + if (quad != null) Platform.DeleteMesh(quad); + ScreenManager.Platform = previousPlatform!; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// One frame: the target bound and cleared, the state set through the platform, one quad. + public unsafe byte[] Run(bool stated, bool blend, EnumBlendMode mode, Mask mask, bool scissor) + { + Platform.NativeStatedEnabled = stated; + Platform.BeginFrame(); + Prepare(target); + Platform.GlToggleBlend(blend, mode); + if (mask == Mask.RedGreen) Platform.GlColorMask(true, true, false, false); + if (scissor) + { + Platform.GlScissorFlag(true); + Platform.GlScissor(4, 4, 8, 8); + } + + Platform.RenderMesh(quad); + + Platform.GlColorMask(true, true, true, true); + Platform.GlScissorFlag(false); + byte[] pixels = Read(target.ColorTextureIds[0]); + Platform.EndFrame(); + return pixels; + } + + public unsafe (byte[] First, byte[] Second) RunTwoTargets(bool stated) + { + Platform.NativeStatedEnabled = stated; + Platform.BeginFrame(); + Prepare(twoTargets); + Platform.GlToggleBlend(false); + Platform.RenderMesh(quad); + byte[] first = Read(twoTargets.ColorTextureIds[0]); + byte[] second = Read(twoTargets.ColorTextureIds[1]); + Platform.EndFrame(); + return (first, second); + } + + private void Prepare(FrameBufferRef frameBuffer) + { + VulkanDevice seam = Seam; + // Clears are not part of the comparison: every attachment starts from the same colour. + seam.BindFramebuffer(frameBuffer.FboId); + seam.SetDrawBuffers(frameBuffer.FboId, (1 << frameBuffer.ColorTextureIds.Length) - 1); + for (int i = 0; i < frameBuffer.ColorTextureIds.Length; i++) seam.ClearColor(i, 0f, 0.2f, 0.4f, 0.6f); + Platform.StateDrawBuffers(frameBuffer.FboId, 1); + + Platform.CurrentFrameBuffer = frameBuffer; + Platform.GlDisableDepthTest(); + Platform.GlDepthMask(false); + Platform.GlDisableCullFace(); + + Platform.UseShaderProgram(gui.ProgramId); + ShaderProgramBase.CurrentShaderProgram = gui; + seam.SetUniformMatrix(gui.ProgramId, gui.uniformLocations["projectionMatrix"], Identity); + seam.SetUniformMatrix(gui.ProgramId, gui.uniformLocations["modelViewMatrix"], Identity); + seam.SetUniform(gui.ProgramId, gui.uniformLocations["rgbaIn"], 1f, 0.75f, 0.5f, 0.8f); + seam.SetUniform(gui.ProgramId, gui.uniformLocations["noTexture"], 0f); + seam.SetUniform(gui.ProgramId, gui.uniformLocations["applyColor"], 1); + seam.SetUniform(gui.ProgramId, gui.uniformLocations["alphaTest"], 0f); + Platform.BindProgramTexture2D(gui, "tex2d", texture, 0); + Platform.BindProgramTexture2D(gui, "tex2dOverlay", 0, 1); + } + + private unsafe byte[] Read(int textureId) + { + VulkanDevice seam = Seam; + int reader = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, textureId, 0); + seam.SetDrawBuffers(reader, 1); + seam.BindFramebuffer(reader); + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination); + } + seam.DeleteFramebuffer(reader); + return pixels; + } + + private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms) + { + List stages = ShaderCorpus.BuildProgram( + name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant()); + var linked = new LinkedProgram { PassName = name }; + foreach (ShaderStageSource stage in stages) + { + var shader = new LinkedShader { Type = stage.Stage, Code = stage.Code, PrefixCode = stage.PrefixCode }; + Assert.True(seam.CompileShader(shader)); + if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader; + else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader; + } + int id = seam.LinkProgram(linked); + Assert.True(id > 0, seam.GetError() ?? "link failed"); + program.ProgramId = id; + foreach (string uniform in uniforms) + { + int location = seam.GetUniformLocation(id, uniform); + Assert.True(location != -1, name + " has no location for " + uniform); + program.uniformLocations[uniform] = location; + } + } + + private static FrameBufferRef CreateTarget(VulkanDevice seam, int attachments) + { + var target = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new int[attachments], + }; + for (int i = 0; i < attachments; i++) + { + target.ColorTextureIds[i] = seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + seam.AttachTexture(target.FboId, + (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + i), + target.ColorTextureIds[i], 0); + } + seam.SetDrawBuffers(target.FboId, (1 << attachments) - 1); + Assert.True(seam.CheckFramebufferComplete(target.FboId, out string status), status); + return target; + } + + private static void InstallFrameBuffers(StatedPlatform platform, FrameBufferRef target) + { + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = target; + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + } + + private static unsafe int Gradient(VulkanDevice seam) + { + var pixels = new byte[8 * 8 * 4]; + for (int y = 0; y < 8; y++) + for (int x = 0; x < 8; x++) + { + int i = (y * 8 + x) * 4; + pixels[i] = (byte)(16 + x * 30); + pixels[i + 1] = (byte)(32 + y * 25); + pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20); + pixels[i + 3] = (byte)(96 + ((x + y) & 3) * 40); + } + fixed (byte* first = pixels) + { + return seam.CreateTexture2D(8, 8, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false); + } + } + + private static MeshData BuildQuad() + { + var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: false); + float[] positions = { -1f, -1f, 0f, 1f, -1f, 0f, 1f, 1f, 0f, -1f, 1f, 0f }; + float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f }; + for (int i = 0; i < 4; i++) + { + mesh.AddVertexWithFlags(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2], + uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, 0); + } + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index); + return mesh; + } + } + + private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders); + + private static (string, string) BuildNativeShaders() + { + if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason); + using (compiler) + { + var builder = new NativeShaderBuilder(compiler!); + NativeShaderBuildResult result = builder.Build(Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk"), "gui"); + if (!result.Success) return ("", string.Join("\n", result.Errors)); + string root = Path.Combine(Path.GetTempPath(), "optimum-native-stated-shaders-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + NativeShaderBuilder.Write(result, root); + return (Path.Combine(root, NativeShaderManifest.DirectoryName), ""); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs index 134d40f0..11086929 100644 --- a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs @@ -480,6 +480,16 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; + // These tests pin a dedicated native route against the emulated route its seam's neutral + + // body used to take; the fixture sets state on the device directly, so the generic stated + + // route (which reads the platform's record) stays out of the comparison until the emulated + + // route is removed. NativeStatedTests covers the generic route itself. + + platform.NativeStatedEnabled = false; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan/Platform/StatedRenderState.cs b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs new file mode 100644 index 00000000..bdd52069 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Platform; + +/// +/// The fixed-function state the client stated through the platform's virtuals, with OpenGL's +/// semantics, owned by the platform (docs/vulkan-native-render-systems.md, decision 3: a native +/// system reads client state, never the device's). The generic native draw +/// (VulkanClientPlatform.NativeStated.cs) builds its pipeline, pass and textures from this alone. +/// +/// Semantics that differ from a naive record, each as the OpenGL body does it: +/// - blend: glBlendFunc sets every draw buffer, glBlendFunci one; glDisable(GL_BLEND) +/// keeps the functions (, , ); +/// - colour mask: glColorMask is global (); +/// - draw buffers: per framebuffer, and a framebuffer nobody selected for writes attachment 0 only, +/// GL's default for a framebuffer object (); +/// - texture units: one texture per unit, whatever its dimensionality, as the device's table has it +/// (a cube and a 2D bind to the same unit replace each other there too); +/// - viewport: a framebuffer bind does not change it; the platform's own bind states the full target. +/// Stencil is recorded but never applied: no framebuffer of this client has a stencil attachment, +/// so a stencil test passes on either path. +/// +internal sealed class StatedRenderState +{ + public const int MaxColorAttachments = GlStateTracker.MaxColorAttachments; + public const int MaxTextureUnits = GlStateTracker.MaxTextureUnits; + + private readonly AttachmentBlend[] _blend = new AttachmentBlend[MaxColorAttachments]; + private readonly Dictionary _drawBuffers = new(); + private readonly int[] _unitTextures = new int[MaxTextureUnits]; + private readonly int[] _unitSamplers = new int[MaxTextureUnits]; + + public StatedRenderState() + { + for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; + } + + public bool BlendEnabled { get; private set; } + + /// The channels glColorMask left writable, applied to every attachment. + public ColorComponentFlags ColorMask { get; private set; } = + ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + + public bool DepthTest { get; set; } + public bool DepthWrite { get; set; } = true; + public CompareOp DepthCompare { get; set; } = CompareOp.Less; + public bool CullEnabled { get; set; } + public bool CullBack { get; set; } = true; + public float LineWidth { get; set; } = 1f; + public bool Wireframe { get; set; } + public bool ScissorEnabled { get; set; } + public Rect2D Scissor { get; set; } + public Rect2D Viewport { get; set; } + public bool StencilTest { get; set; } + + public CullModeFlags CullMode => CullEnabled ? (CullBack ? CullModeFlags.BackBit : CullModeFlags.FrontBit) : CullModeFlags.None; + + /// glBlendFunc/glBlendFuncSeparate of a named mode: every attachment's factors, add equations. + public void SetBlendMode(EnumBlendMode mode) + { + (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = AttachmentBlend.FactorsFor(mode); + for (int i = 0; i < _blend.Length; i++) + { + _blend[i].SrcColor = srcColor; + _blend[i].DstColor = dstColor; + _blend[i].SrcAlpha = srcAlpha; + _blend[i].DstAlpha = dstAlpha; + _blend[i].ColorOp = BlendOp.Add; + _blend[i].AlphaOp = BlendOp.Add; + } + } + + /// glEnable/glDisable(GL_BLEND): the functions stay. + public void SetBlendEnabled(bool enabled) => BlendEnabled = enabled; + + /// glBlendEquationi + glBlendFuncSeparatei with GL tokens. + public void SetSlotBlend(int slot, int glEquation, int srcColor, int dstColor, int srcAlpha, int dstAlpha) + { + if ((uint)slot >= MaxColorAttachments) return; + BlendOp op = GlEnums.BlendOpFrom(glEquation); + _blend[slot].ColorOp = op; + _blend[slot].AlphaOp = op; + _blend[slot].SrcColor = GlEnums.BlendFactorFrom(srcColor); + _blend[slot].DstColor = GlEnums.BlendFactorFrom(dstColor); + _blend[slot].SrcAlpha = GlEnums.BlendFactorFrom(srcAlpha); + _blend[slot].DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); + } + + /// glBlendFuncSeparatei: one attachment's factors, its equation kept. + public void SetSlotFunc(int slot, int srcColor, int dstColor, int srcAlpha, int dstAlpha) + { + if ((uint)slot >= MaxColorAttachments) return; + _blend[slot].SrcColor = GlEnums.BlendFactorFrom(srcColor); + _blend[slot].DstColor = GlEnums.BlendFactorFrom(dstColor); + _blend[slot].SrcAlpha = GlEnums.BlendFactorFrom(srcAlpha); + _blend[slot].DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); + } + + public void SetColorMask(bool r, bool g, bool b, bool a) + { + ColorMask = (r ? ColorComponentFlags.RBit : 0) | (g ? ColorComponentFlags.GBit : 0) | + (b ? ColorComponentFlags.BBit : 0) | (a ? ColorComponentFlags.ABit : 0); + } + + /// + /// One attachment as a draw into applies it: the stated + /// functions and enable, written only when the attachment is a selected draw buffer and + /// the colour mask allows it. + /// + public AttachmentBlend AttachmentFor(int framebufferId, int slot) + { + if ((uint)slot >= MaxColorAttachments) return new AttachmentBlend { WriteMask = 0 }; + AttachmentBlend blend = _blend[slot]; + blend.Enabled = BlendEnabled; + blend.WriteMask = ((DrawBuffers(framebufferId) >> slot) & 1) != 0 ? ColorMask : 0; + return blend; + } + + public void SetDrawBuffers(int framebufferId, uint mask) => _drawBuffers[framebufferId] = mask; + + public uint DrawBuffers(int framebufferId) => + _drawBuffers.TryGetValue(framebufferId, out uint mask) ? mask : 1u; + + public void ForgetFramebuffer(int framebufferId) => _drawBuffers.Remove(framebufferId); + + public void BindTexture(int unit, int textureId) + { + if ((uint)unit < MaxTextureUnits) _unitTextures[unit] = textureId; + } + + public int TextureAt(int unit) => (uint)unit < MaxTextureUnits ? _unitTextures[unit] : 0; + + public void BindSampler(int unit, int samplerId) + { + if ((uint)unit < MaxTextureUnits) _unitSamplers[unit] = samplerId; + } + + public int SamplerAt(int unit) => (uint)unit < MaxTextureUnits ? _unitSamplers[unit] : 0; +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs index 2f7265aa..c80a3b5e 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -30,6 +30,8 @@ public override void EndFrame() /// GL probes by setting a width; the device answers it as a capability. public override bool ProbeThickLineSupport() { + // GL leaves the probed width set, so the stated line width is 1.5 from here on too. + stated.LineWidth = 1.5f; device.SetLineWidth(1.5f); return device.SupportsThickLines; } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index d673dd0f..451f6590 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -123,7 +123,7 @@ public override List SetupDefaultFrameBuffers() (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), primary.ColorTextureIds[attachment], 0); } - device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1); + StateDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1); list[0] = primary; SetOptimumMotionAttachmentIndex(motionAttachmentIndex); @@ -147,7 +147,7 @@ public override List SetupDefaultFrameBuffers() transparent.ColorTextureIds[attachment], 0); } device.AttachTexture(transparent.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0); - device.SetDrawBuffers(transparent.FboId, 7); + StateDrawBuffers(transparent.FboId, 7); transparent.DepthTextureId = primary.DepthTextureId; list[1] = transparent; @@ -166,7 +166,7 @@ public override List SetupDefaultFrameBuffers() // A post-chain transient (Transient pool class); see TransientAllocator.PostChainSlots. ssao.ColorTextureIds[0] = device.CreateTransientTexture2DRaw(ssaoWidth, ssaoHeight, 6407, 13); device.AttachTexture(ssao.FboId, EnumFramebufferAttachment.ColorAttachment0, ssao.ColorTextureIds[0], 0); - device.SetDrawBuffers(ssao.FboId, 1); + StateDrawBuffers(ssao.FboId, 1); // Rotation noise, and the sample kernel that goes with it. Same seed // and draw order as the GL path, so the pattern matches exactly. @@ -345,7 +345,7 @@ public override FrameBufferRef CreateFramebuffer(FramebufferAttrs fbAttrs) } target.ColorTextureIds = colorTextureIds.ToArray(); - device.SetDrawBuffers(target.FboId, drawBufferMask); + StateDrawBuffers(target.FboId, drawBufferMask); string status; if (!device.CheckFramebufferComplete(target.FboId, out status)) @@ -381,7 +381,7 @@ private FrameBufferRef CreateOptimumColorTarget(int width, int height, EnumTextu // the reduced-resolution blur passes require fractional texel samples. SetupOptimumTextureSampler(target.ColorTextureIds[0], 9729, 33071); device.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); - device.SetDrawBuffers(target.FboId, 1); + StateDrawBuffers(target.FboId, 1); return target; } @@ -397,7 +397,7 @@ private FrameBufferRef CreateOptimumDepthTarget(int width, int height) EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); SetupOptimumTextureSampler(target.DepthTextureId, 9729, 33071); device.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0); - device.SetDrawBuffers(target.FboId, 0); + StateDrawBuffers(target.FboId, 0); return target; } @@ -436,7 +436,7 @@ private FrameBufferRef CreateOptimumHistoryTarget(int width, int height) (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + attachment), target.ColorTextureIds[attachment], 0); } - device.SetDrawBuffers(target.FboId, 7); + StateDrawBuffers(target.FboId, 7); if (!device.CheckFramebufferComplete(target.FboId, out string status)) { throw new Exception("Optimum TAA history FBO: " + status); @@ -550,6 +550,7 @@ public override void BindCurrentFrameBuffer(FrameBufferRef value) return; } device.BindFramebuffer(value.FboId); + NoteForkViewport(0, 0, value.Width, value.Height); device.SetViewport(0, 0, value.Width, value.Height); DeclareBoundPass(); } @@ -608,9 +609,9 @@ public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) // Motion is excluded until a writer opts in, so temporarily // enable it just as the GL branch does. Otherwise stale // motion/reactivity survives and can reject all TAA history. - device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); + StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f); - device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); } device.ClearDepth(1f); break; @@ -619,6 +620,7 @@ public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) case EnumFrameBuffer.ShadowmapNear: { FrameBufferRef optimumTarget = FrameBuffers[(int)framebuffer]; + NoteForkViewport(0, 0, optimumTarget.Width, optimumTarget.Height); device.SetViewport(0, 0, optimumTarget.Width, optimumTarget.Height); device.ClearDepth(1f); break; @@ -639,14 +641,11 @@ public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) /// public override void ApplyTransparentPassBlendState() { - device.SetDrawBuffers(FrameBuffers[1].FboId, 7); - device.SetBlend(true, EnumBlendMode.Standard); - device.SetBlendEquation(0, 32774); - device.SetBlendFuncSeparate(0, 1, 1, 1, 1); - device.SetBlendEquation(1, 32774); - device.SetBlendFuncSeparate(1, 0, 769, 0, 769); - device.SetBlendEquation(2, 32774); - device.SetBlendFuncSeparate(2, 770, 771, 770, 771); + StateDrawBuffers(FrameBuffers[1].FboId, 7); + StateBlend(true, EnumBlendMode.Standard); + StateSlotBlend(0, 32774, 1, 1, 1, 1); + StateSlotBlend(1, 32774, 0, 769, 0, 769); + StateSlotBlend(2, 32774, 770, 771, 770, 771); // Phase 3b stage 2: the same contract, recorded for the native chunk passes that draw // into this target (VulkanClientPlatform.NativeChunks.cs). NoteNativeTransparentBlend(0, 32774, 1, 1, 1, 1); @@ -676,8 +675,8 @@ public override void SetBlendEnabled(bool enabled) public override void ApplyTransparentMergeBlendState() { device.SetDepthTest(false); - device.SetBlend(true, EnumBlendMode.Standard); - device.SetBlendFuncSeparate(0, 770, 771, 770, 771); + StateBlend(true, EnumBlendMode.Standard); + StateSlotBlendFunc(0, 770, 771, 770, 771); } /// @@ -717,7 +716,7 @@ public override void ClearSsaoTarget() public override void BeginFinalCompositionDrawBuffers() { DeclareFinalCompositionPass(); - device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); + StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); device.SetDepthTest(false); } @@ -727,11 +726,11 @@ public override void RestoreWorldDrawBuffers(bool ssaoAttachments) device.EndPass(); if (ssaoAttachments) { - device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); + StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); } else { - device.SetDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 3); + StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 3); } } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index cca109b3..f1114f6e 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -146,12 +146,12 @@ private void SetOitSampling(int textureId) /// public override void BeginOitAccumulation(FrameBufferRef transparent) { - device.SetDrawBuffers(transparent.FboId, 0x3F); - device.SetBlendFuncSeparate(0, 774, 0, 774, 0); - device.SetBlendFuncSeparate(1, 774, 0, 774, 0); - device.SetBlendFuncSeparate(3, 1, 1, 1, 1); - device.SetBlendFuncSeparate(4, 1, 1, 1, 1); - device.SetBlendFuncSeparate(5, 1, 1, 1, 1); + StateDrawBuffers(transparent.FboId, 0x3F); + StateSlotBlendFunc(0, 774, 0, 774, 0); + StateSlotBlendFunc(1, 774, 0, 774, 0); + StateSlotBlendFunc(3, 1, 1, 1, 1); + StateSlotBlendFunc(4, 1, 1, 1, 1); + StateSlotBlendFunc(5, 1, 1, 1, 1); // Phase 3b stage 2: the same contract, recorded for the native chunk passes that draw // into this target - a native pipeline states its blend rather than reading the // tracker's back (VulkanClientPlatform.NativeChunks.cs). Slot 2 keeps whatever the @@ -172,6 +172,8 @@ public override void BeginOitAccumulation(FrameBufferRef transparent) /// Units 6 and 7; the device binds by unit whatever the texture's dimensionality. public override void BindOitTextures(int revealTexture, int accumTexture) { + stated.BindTexture(6, revealTexture); + stated.BindTexture(7, accumTexture); device.BindTexture(6, revealTexture); device.BindTexture(7, accumTexture); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 42e56458..95f1d75a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -80,6 +80,11 @@ public override void RenderMesh(MeshRef modelRef) return; } if (TryRenderStandardMeshNative(modelRef)) return; + if (TryDrawStated(vAO, 1, null, null, 0)) + { + RuntimeStats.drawCallsCount--; // the stated route counted it already + return; + } device.DrawMesh(vAO.VaoId); } @@ -88,6 +93,11 @@ public override void RenderFullscreenTriangle(MeshRef modelRef) RuntimeStats.drawCallsCount++; // The post passes generate their three vertices in the shader, so the // mesh carries no buffers and none are bound. + if (TryDrawStated(null, 1, null, null, 0)) + { + RuntimeStats.drawCallsCount--; + return; + } device.DrawFullscreenTriangle(); } @@ -109,6 +119,11 @@ public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSi // The chunk renderer's one multidraw per pool. GL takes byte offsets // into the index buffer; the device converts them to index counts and // issues a single indirect draw. + if (TryDrawStated(vAO, 1, indices, indicesSizes, groupCount)) + { + RuntimeStats.drawCallsCount--; + return; + } device.DrawMeshMulti(vAO.VaoId, indices, indicesSizes, groupCount, useSSBOs); } @@ -117,6 +132,7 @@ public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) RuntimeStats.drawCallsCount++; VAO vAO = (VAO)modelRef; if (TryRenderParticles2dNative(modelRef, quantity)) { RuntimeStats.drawCallsCount--; return; } + if (quantity > 0 && TryDrawStated(vAO, quantity, null, null, 0)) { RuntimeStats.drawCallsCount--; return; } device.DrawMeshInstanced(vAO.VaoId, quantity); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs index 98107dd9..44d4b04d 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs @@ -48,9 +48,17 @@ internal void NoteForkFramebuffer(int framebufferId) forkFramebuffer = current != null && current.FboId == framebufferId ? 0 : framebufferId; } - internal void NoteForkDepthTest(bool enabled) => statedDepthTest = enabled; + internal void NoteForkDepthTest(bool enabled) + { + statedDepthTest = enabled; + stated.DepthTest = enabled; + } - internal void NoteForkBlend(bool enabled) => statedBlendOn = enabled; + internal void NoteForkBlend(bool enabled) + { + statedBlendOn = enabled; + stated.SetBlendEnabled(enabled); + } /// A cloud renderer's RenderMesh: the native pass, or false for the emulated draw. private bool TryRenderCloudsNative(MeshRef mesh) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs new file mode 100644 index 00000000..47da802a --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +// The generic native draw: any program, drawn with the state the client stated through this +// platform's virtuals (StatedRenderState) - the last route before the emulated one. The dedicated +// routes (chunks, entities, sky, particles, GUI, clouds, the post chain) keep their own contracts +// and run first; this one takes everything they do not recognise: mod renderers with their own +// programs, the vanilla programs without a dedicated route (aurora, block highlights, held item, +// lines, wireframe, the debug views) and the seams' neutral bodies behind the route switches. +// +// What it states, and from where (all client statements, none read back from the device): +// - target: the framebuffer a fork renderer bound by id, else CurrentFrameBuffer, else the default; +// every colour slot of the target is in the pass, and the draw buffers the client selected for +// that framebuffer become per-attachment write masks (draw buffers are write masks, decision 4); +// - blend, colour mask, depth, cull, line width, polygon mode, viewport and scissor: StatedRenderState, +// with OpenGL's semantics (a disabled blend keeps its functions, glBlendFunc sets every draw buffer); +// - textures: per sampler, the texture on the unit the program points it at (its SetSamplerUnit +// mapping, else the sampler's declaration order - the same resolution the emulated draw makes), +// with the unit's standalone sampler override if one is bound; +// - the depth attachment of the target sampled with depth writes off is read in the read-only +// layout (SamplesBoundDepth), as the emulated draw does. +// Stencil is not applied: no framebuffer of this client has a stencil attachment, so a stencil +// test passes on either path (StatedRenderState). +// +// OPTIMUM_VK_NATIVE_STATED=0 sends these draws to the emulated route; OPTIMUM_VK_STATED_CHECK=1 +// compares every stated value against the device's tracked state at each draw and logs each +// distinct mismatch once - the evidence that the device state can go. +// Pinned by Optimum.Tests/native-world-systems-coverage-tests.cs. +public partial class VulkanClientPlatform +{ + /// The fixed-function state the client stated, with OpenGL's semantics. + internal readonly StatedRenderState stated = new(); + + internal bool NativeStatedEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_STATED") != "0"; + + private static readonly bool StatedCheck = Environment.GetEnvironmentVariable("OPTIMUM_VK_STATED_CHECK") == "1"; + + private readonly HashSet statedCheckReported = new(StringComparer.Ordinal); + + private readonly HashSet statedRefusalReported = new(); + + /// Draws the generic route recorded, and draws it handed back to the emulated route. Tests and the trace read them. + internal long StatedDrawsForTests { get; private set; } + + internal long StatedRefusalsForTests { get; private set; } + + // ------------------------------------------------------------------ recording helpers + + /// The draw buffers the client selects for a framebuffer (0 is the default target). + internal void StateDrawBuffers(int framebufferId, int mask) + { + stated.SetDrawBuffers(framebufferId == 0 ? PassDeclaration.DefaultFramebuffer : framebufferId, (uint)mask); + device.SetDrawBuffers(framebufferId, mask); + } + + /// glBlendEquationi + glBlendFuncSeparatei. + internal void StateSlotBlend(int slot, int equation, int srcColor, int dstColor, int srcAlpha, int dstAlpha) + { + stated.SetSlotBlend(slot, equation, srcColor, dstColor, srcAlpha, dstAlpha); + device.SetBlendEquation(slot, equation); + device.SetBlendFuncSeparate(slot, srcColor, dstColor, srcAlpha, dstAlpha); + } + + /// glBlendFuncSeparatei alone: the attachment's equation stays. + internal void StateSlotBlendFunc(int slot, int srcColor, int dstColor, int srcAlpha, int dstAlpha) + { + stated.SetSlotFunc(slot, srcColor, dstColor, srcAlpha, dstAlpha); + device.SetBlendFuncSeparate(slot, srcColor, dstColor, srcAlpha, dstAlpha); + } + + /// Blend on with a mode's functions on every attachment, or off with the functions kept. + internal void StateBlend(bool on, EnumBlendMode mode) + { + stated.SetBlendEnabled(on); + if (on) stated.SetBlendMode(mode); + device.SetBlend(on, mode); + } + + /// A viewport the client states (the fork bridge, and the platform's own full-target binds). + internal void NoteForkViewport(int x, int y, int width, int height) => + stated.Viewport = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + + internal void NoteForkTexture(int unit, int textureId) => stated.BindTexture(unit, textureId); + + internal void NoteForkDrawBuffers(int framebufferId, int mask) => + stated.SetDrawBuffers(framebufferId == 0 ? PassDeclaration.DefaultFramebuffer : framebufferId, (uint)mask); + + // ------------------------------------------------------------------------- the route + + /// + /// Records one draw of the current program natively from the stated state. A null + /// is the fullscreen triangle; is a + /// pool's multi-draw. False: nothing was recorded and the caller runs the emulated draw. + /// + private bool TryDrawStated(VAO? vao, int instances, int[]? starts, int[]? sizes, int groupCount) + { + if (!NativeStatedEnabled || device == null) return false; + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + if (program == null || program.ProgramId <= 0) return false; + if (vao != null && (vao.VaoId == 0 || vao.Disposed)) return false; + + // The target and every colour slot attached to it on the device. Not the FrameBufferRef's own + // list: the OIT accumulation targets are attached to Transparent at slots 3-5 without being in + // its ColorTextureIds, and a pass without them drops the accumulated colour (2026-09-17: water + // drew black through this route until the slots came from the attachments). + FrameBufferRef? target = forkFramebuffer > 0 ? null : CurrentFrameBuffer; + int framebufferId = forkFramebuffer > 0 + ? forkFramebuffer + : target != null ? target.FboId : PassDeclaration.DefaultFramebuffer; + RenderTargetFormats? all = device.NativeTargetFormats(framebufferId, uint.MaxValue); + if (all == null) return Refuse(program, "framebuffer " + framebufferId + " does not exist"); + int attached = all.ColorFormats.Length; + uint slots = attached >= 32 ? uint.MaxValue : (1u << attached) - 1u; + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, slots); + if (formats == null) return Refuse(program, "no formats for framebuffer " + framebufferId); + + int layoutId = vao != null ? device.NativeMeshLayoutId(vao.VaoId) : MeshManager.EmptyLayoutId; + if (layoutId < 0) return Refuse(program, "the mesh has no layout"); + + // Every sampler the program declares, from the unit it points at. + List names = device.SamplerNamesOf(program.ProgramId); + int depthTexture = device.NativeFramebufferDepthTexture(framebufferId); + bool samplesBoundDepth = false; + var reads = new int[names.Count]; + var units = new int[names.Count]; + for (int i = 0; i < names.Count; i++) + { + units[i] = device.NativeSamplerUnit(program.ProgramId, names[i]); + reads[i] = stated.TextureAt(units[i]); + if (reads[i] != 0 && reads[i] == depthTexture) + { + if (stated.DepthWrite && stated.DepthTest) + { + return Refuse(program, "it samples the depth attachment it writes"); + } + samplesBoundDepth = true; + } + } + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = stated.AttachmentFor(framebufferId, i); + + var description = new NativePipelineDescription + { + ProgramId = program.ProgramId, + Blend = blend, + DepthTest = stated.DepthTest, + DepthWrite = stated.DepthWrite && !samplesBoundDepth, + DepthCompare = stated.DepthCompare, + Cull = stated.CullMode, + Topology = vao != null ? device.NativeMeshTopology(vao.VaoId) : PrimitiveTopology.TriangleList, + PolygonMode = stated.Wireframe ? PolygonMode.Line : PolygonMode.Fill, + LineWidth = stated.LineWidth, + VertexLayoutId = layoutId, + SamplesBoundDepth = samplesBoundDepth, + Targets = formats, + }; + NativePipeline? pipeline = device.RequestNativePipeline(description, out string error); + if (pipeline == null) return Refuse(program, error); + + var textures = new NativeTexture[names.Count]; + for (int i = 0; i < names.Count; i++) + { + int sampler = stated.SamplerAt(units[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), reads[i], + sampler != 0 ? device.NativeStandaloneSampler(sampler) : null); + } + + if (StatedCheck) CheckStatedAgainstDevice(program, framebufferId, blend, description, textures); + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + Rect2D viewport = stated.Viewport; + bool drawn = false; + if (device.BeginNativePass(new NativePassDescription + { + Name = "Stated/" + framebufferId, + FramebufferId = framebufferId, + ColorSlots = slots, + Reads = reads, + Flags = PassFlags.AllowSplit, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + Scissor = stated.ScissorEnabled ? stated.Scissor : null, + })) + { + drawn = vao == null + ? device.DrawNativeFullscreen(pipeline, textures) + : starts != null + ? device.DrawNativeMeshMulti(pipeline, vao.VaoId, starts, sizes!, groupCount, textures) + : device.DrawNativeMeshInstanced(pipeline, vao.VaoId, instances, textures); + } + device.EndNativePass(); + // The emulated calls a system makes between its draws still address the target it bound. + if (framebufferId == PassDeclaration.DefaultFramebuffer) device.BindDefaultFramebuffer(); + else device.BindFramebuffer(framebufferId); + SetPassContext(outer, outerFlags); + if (drawn) StatedDrawsForTests++; + else RuntimeStats.drawCallsCount--; + return drawn; + } + + private bool Refuse(ShaderProgramBase program, string reason) + { + StatedRefusalsForTests++; + if (statedRefusalReported.Add(program.ProgramId)) + { + Logger.Warning("Optimum: program '{0}' draws through the emulated route: {1}", program.PassName ?? "", reason); + } + return false; + } + + private void CheckStatedAgainstDevice(ShaderProgramBase program, int framebufferId, AttachmentBlend[] blend, + NativePipelineDescription description, NativeTexture[] textures) + { + List mismatches = device.DebugStatedMismatches(program.ProgramId, framebufferId, blend, description, + stated.Viewport, stated.ScissorEnabled, stated.Scissor, textures); + foreach (string mismatch in mismatches) + { + string key = (program.PassName ?? "") + ": " + mismatch; + if (statedCheckReported.Add(key)) Logger.Warning("Optimum stated check: {0}", key); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs index b2638286..ffd43c9e 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs @@ -79,6 +79,7 @@ public override void DisposeShaderProgram(ShaderProgramBase program) public override void BindSampler(int unit, int samplerId) { + stated.BindSampler(unit, samplerId); device.BindSampler(unit, samplerId); } @@ -182,15 +183,18 @@ public override void BindProgramTexture2D(ShaderProgramBase program, string samp // happens, so the emulated route is unchanged. NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); + stated.BindTexture(textureNumber, textureId); device.BindTexture(textureNumber, textureId); if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) { + stated.BindSampler(textureNumber, optimumSampler); device.BindSampler(textureNumber, optimumSampler); } else { // Clear any override left on this unit, or the texture's own // filtering would be silently ignored. + stated.BindSampler(textureNumber, 0); device.BindSampler(textureNumber, 0); } if (program.clampTToEdge) @@ -205,6 +209,7 @@ public override void BindProgramTextureCube(ShaderProgramBase program, string sa { NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); + stated.BindTexture(textureNumber, textureId); device.BindTextureCube(textureNumber, textureId); if (program.clampTToEdge) { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs index d80eb1fc..8d0325f6 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -1,3 +1,4 @@ +using Optimum.Render.Vulkan.Core; using Silk.NET.Vulkan; using System; using System.Runtime.InteropServices; @@ -125,11 +126,13 @@ public override int GenSampler(bool linear) public override void GLWireframes(bool toggle) { + stated.Wireframe = toggle; device.SetWireframe(toggle); } public override void GlViewport(int x, int y, int width, int height) { + stated.Viewport = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); device.SetViewport(x, y, width, height); } @@ -140,6 +143,7 @@ public override void GlScissor(int x, int y, int width, int height) int clippedY = Math.Max(0, y); statedScissor = new Rect2D(new Offset2D(clippedX, clippedY), new Extent2D((uint)Math.Max(0, width - (clippedX - x)), (uint)Math.Max(0, height - (clippedY - y)))); + stated.Scissor = statedScissor; device.SetScissor(x, y, width, height); } @@ -159,24 +163,28 @@ public override void GlScissor(int x, int y, int width, int height) public override void GlScissorFlag(bool enable) { scissorEnabled = enable; + stated.ScissorEnabled = enable; device.SetScissorEnabled(enable); } public override void GlEnableDepthTest() { statedDepthTest = true; + stated.DepthTest = true; device.SetDepthTest(true); } public override void GlDisableDepthTest() { statedDepthTest = false; + stated.DepthTest = false; device.SetDepthTest(false); } public override void BindTexture2d(int texture) { // The GL body activates unit 0 first, so this binds to unit 0 too. + stated.BindTexture(0, texture); device.BindTexture(0, texture); // Remembered for GlGenerateTex2DMipmaps, whose GL form acts on // whatever is bound and so has no argument to route. @@ -185,12 +193,14 @@ public override void BindTexture2d(int texture) public override void BindTextureCubeMap(int texture) { + stated.BindTexture(0, texture); device.BindTextureCube(0, texture); } public override void UnBindTextureCubeMap() { // Mirrors BindTextureCubeMap above, which binds to unit 0. + stated.BindTexture(0, 0); device.BindTextureCube(0, 0); } @@ -198,6 +208,18 @@ public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendM { statedBlendOn = on; statedBlendMode = blendMode; + // GL: glEnable(GL_BLEND) and the mode's functions on every draw buffer when on, + // glDisable alone - the functions stay - when off (ClientPlatformWindows.GlToggleBlend). + stated.SetBlendEnabled(on); + if (on) + { + stated.SetBlendMode(blendMode); + if (blendMode == EnumBlendMode.Standard && OptimumRenderSsao) + { + stated.SetSlotBlend(2, 32774, 1, 0, 1, 0); + stated.SetSlotBlend(3, 32774, 1, 0, 1, 0); + } + } device.SetBlend(on, blendMode); if (on && OptimumRenderSsao) { @@ -222,18 +244,21 @@ public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendM public override void GlDisableCullFace() { statedCull = false; + stated.CullEnabled = false; device.SetCullFace(false); } public override void GlEnableCullFace() { statedCull = true; + stated.CullEnabled = true; device.SetCullFace(true); } public override void GLLineWidth(float width) { statedLineWidth = width; + stated.LineWidth = width; device.SetLineWidth(width); } @@ -249,6 +274,7 @@ public override void SmoothLines(bool on) public override void GlDepthMask(bool flag) { statedDepthWrite = flag; + stated.DepthWrite = flag; device.SetDepthMask(flag); } @@ -258,28 +284,33 @@ public override void GlDepthFunc(EnumDepthFunction depthFunc) // the seam takes: it cannot reference this enum, since it lives in // VintagestoryLib and the contracts assembly does not depend on it. statedDepthFunc = (int)depthFunc; + stated.DepthCompare = GlEnums.CompareOpFrom((int)depthFunc); device.SetDepthFunc((int)depthFunc); } public override void GlCullFaceBack() { statedCullBack = true; + stated.CullBack = true; device.SetCullFaceMode(true); } public override void GlCullFaceFront() { statedCullBack = false; + stated.CullBack = false; device.SetCullFaceMode(false); } public override void GlEnableStencilTest() { + stated.StencilTest = true; device.SetStencilTest(true); } public override void GlDisableStencilTest() { + stated.StencilTest = false; device.SetStencilTest(false); } @@ -308,6 +339,7 @@ public override void GlColorMask(bool r, bool g, bool b, bool a) { statedColorMaskOff = (r ? 0 : ColorComponentFlags.RBit) | (g ? 0 : ColorComponentFlags.GBit) | (b ? 0 : ColorComponentFlags.BBit) | (a ? 0 : ColorComponentFlags.ABit); + stated.SetColorMask(r, g, b, a); device.SetColorMask(r, g, b, a); } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Taa.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Taa.cs index f47967f0..12405164 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Taa.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Taa.cs @@ -11,7 +11,7 @@ public partial class VulkanClientPlatform /// Primary's default colour set plus the motion attachment. public override void EnableMotionDrawBuffers() { - device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); + StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); } /// @@ -21,34 +21,32 @@ public override void EnableMotionDrawBuffers() /// public override void RestorePrimaryDrawBuffers() { - device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); } /// The motion attachment alone; the device takes the mask directly. public override void EnableMotionOnlyDrawBuffers() { - device.SetDrawBuffers(FrameBuffers[0].FboId, 1 << MotionAttachmentIndex); + StateDrawBuffers(FrameBuffers[0].FboId, 1 << MotionAttachmentIndex); } /// Replace-blending on the motion attachment (TAA P3). public override void ApplyOptimumMotionBlendState() { if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; - device.SetBlendEquation(MotionAttachmentIndex, 32774); - device.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0); + StateSlotBlend(MotionAttachmentIndex, 32774, 1, 0, 1, 0); } /// Additive (ONE, ONE) blending on the motion attachment for the OIT merge (TAA P4). public override void ApplyOptimumMotionAccumulateBlendState() { if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; - device.SetBlendEquation(MotionAttachmentIndex, 32774); - device.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1); + StateSlotBlend(MotionAttachmentIndex, 32774, 1, 1, 1, 1); } /// The FSR EASU pass writes colour attachment 0 of the FSR target. public override void SelectFsrDrawBuffer(FrameBufferRef target) { - device.SetDrawBuffers(target.FboId, 1); + StateDrawBuffers(target.FboId, 1); } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs index f8cbe948..fc5c900c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs @@ -43,7 +43,11 @@ public override void UploadTexture2DNormalizedShorts(int textureId, int level, i public override void SetTextureParameter(int textureId, int parameterName, int value) => device.SetTextureParameter(textureId, parameterName, value); - public override void BindTexture(int unit, int textureId) => device.BindTexture(unit, textureId); + public override void BindTexture(int unit, int textureId) + { + platform.NoteForkTexture(unit, textureId); + device.BindTexture(unit, textureId); + } public override void DeleteTexture(int textureId) => device.DeleteTexture(textureId); @@ -52,8 +56,11 @@ public override void SetTextureParameter(int textureId, int parameterName, int v public override void AttachTexture(int framebufferId, EnumFramebufferAttachment attachment, int textureId, int layer) => device.AttachTexture(framebufferId, attachment, textureId, layer); - public override void SetDrawBuffers(int framebufferId, int attachmentMask) => + public override void SetDrawBuffers(int framebufferId, int attachmentMask) + { + platform.NoteForkDrawBuffers(framebufferId, attachmentMask); device.SetDrawBuffers(framebufferId, attachmentMask); + } public override void BindFramebuffer(int framebufferId) { @@ -69,7 +76,11 @@ public override void BindDefaultFramebuffer() public override void DeleteFramebuffer(int framebufferId) => device.DeleteFramebuffer(framebufferId); - public override void SetViewport(int x, int y, int width, int height) => device.SetViewport(x, y, width, height); + public override void SetViewport(int x, int y, int width, int height) + { + platform.NoteForkViewport(x, y, width, height); + device.SetViewport(x, y, width, height); + } public override void SetDepthTest(bool enabled) { diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 19d8964d..e6a050bd 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -333,6 +333,97 @@ private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) /// internal Rect2D NativeCurrentViewport => _state.Viewport; + /// + /// The unit a program's sampler reads: the client's SetSamplerUnit mapping, else the sampler's + /// declaration order - the resolution the emulated draw makes. -1 for an unknown program or name. + /// + internal int NativeSamplerUnit(int programId, string samplerName) + { + if (!_programs.TryGetValue(programId, out ShaderProgramResources? program)) return -1; + if (program.SamplerUnits.TryGetValue(samplerName, out int mapped)) return mapped; + foreach (SamplerBinding declared in program.Interface.Samplers) + { + if (string.Equals(declared.Name, samplerName, StringComparison.Ordinal)) return declared.Order; + } + return -1; + } + + /// The sampling state of a standalone sampler object (GenSampler), or null. + internal SamplerState? NativeStandaloneSampler(int samplerId) => + _standaloneSamplers.TryGetValue(samplerId, out SamplerState state) ? state : null; + + /// The depth texture attached to a framebuffer, 0 without one. + internal int NativeFramebufferDepthTexture(int framebufferId) => + _targets.Get(ResolveNativeFramebuffer(framebufferId))?.DepthTextureId ?? 0; + + /// + /// Temporary (removal of the emulation layer, step 1): every difference between what the platform + /// stated for a generic native draw and what the device's tracked GL state holds at that moment. + /// Deleted with the tracker. + /// + internal List DebugStatedMismatches(int programId, int framebufferId, AttachmentBlend[] blend, + NativePipelineDescription description, Rect2D viewport, bool scissorEnabled, Rect2D scissor, + NativeTexture[] textures) + { + var result = new List(); + if (_state.CurrentProgram != programId) result.Add("program: device " + _state.CurrentProgram + ", stated " + programId); + int resolved = ResolveNativeFramebuffer(framebufferId); + VulkanFramebuffer? bound = _targets.Bound; + if (bound == null || bound.Id != resolved) result.Add("target: device " + (bound?.Id ?? 0) + ", stated " + resolved); + uint drawBuffers = bound?.DrawBufferMask ?? 0; + for (int i = 0; i < blend.Length; i++) + { + AttachmentBlend device = _state.BlendFor(i); + ColorComponentFlags deviceMask = ((drawBuffers >> i) & 1) != 0 ? _state.ColorMask : 0; + if (deviceMask != blend[i].WriteMask) result.Add("slot " + i + " write mask: device " + deviceMask + ", stated " + blend[i].WriteMask); + if (device.Enabled != blend[i].Enabled) result.Add("slot " + i + " blend enable: device " + device.Enabled + ", stated " + blend[i].Enabled); + else if (device.Enabled && (device.SrcColor != blend[i].SrcColor || device.DstColor != blend[i].DstColor || + device.SrcAlpha != blend[i].SrcAlpha || device.DstAlpha != blend[i].DstAlpha || + device.ColorOp != blend[i].ColorOp || device.AlphaOp != blend[i].AlphaOp)) + { + result.Add("slot " + i + " blend: device " + device.SrcColor + "/" + device.DstColor + " " + device.ColorOp + + ", stated " + blend[i].SrcColor + "/" + blend[i].DstColor + " " + blend[i].ColorOp); + } + } + if (_state.DepthTest != description.DepthTest) result.Add("depth test: device " + _state.DepthTest + ", stated " + description.DepthTest); + if (_state.DepthWrite != description.DepthWrite && !description.SamplesBoundDepth) result.Add("depth write: device " + _state.DepthWrite + ", stated " + description.DepthWrite); + if (_state.DepthCompare != description.DepthCompare) result.Add("depth compare: device " + _state.DepthCompare + ", stated " + description.DepthCompare); + CullModeFlags deviceCull = _state.CullEnabled ? _state.CullMode : CullModeFlags.None; + if (deviceCull != description.Cull) result.Add("cull: device " + deviceCull + ", stated " + description.Cull); + if (!_state.LineWidth.Equals(description.LineWidth)) result.Add("line width: device " + _state.LineWidth + ", stated " + description.LineWidth); + if (_state.PolygonMode != description.PolygonMode) result.Add("polygon mode: device " + _state.PolygonMode + ", stated " + description.PolygonMode); + Rect2D deviceViewport = _state.Viewport; + if (deviceViewport.Offset.X != viewport.Offset.X || deviceViewport.Offset.Y != viewport.Offset.Y || + deviceViewport.Extent.Width != viewport.Extent.Width || deviceViewport.Extent.Height != viewport.Extent.Height) + { + result.Add("viewport: device " + deviceViewport.Offset.X + "," + deviceViewport.Offset.Y + " " + deviceViewport.Extent.Width + "x" + deviceViewport.Extent.Height + + ", stated " + viewport.Offset.X + "," + viewport.Offset.Y + " " + viewport.Extent.Width + "x" + viewport.Extent.Height); + } + if (_state.ScissorEnabled != scissorEnabled) result.Add("scissor enable: device " + _state.ScissorEnabled + ", stated " + scissorEnabled); + else if (scissorEnabled && (_state.Scissor.Offset.X != scissor.Offset.X || _state.Scissor.Offset.Y != scissor.Offset.Y || + _state.Scissor.Extent.Width != scissor.Extent.Width || _state.Scissor.Extent.Height != scissor.Extent.Height)) + { + result.Add("scissor rect differs"); + } + if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) + { + foreach (SamplerBinding declared in program.Interface.Samplers) + { + int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Order; + int deviceTexture = (uint)unit < GlStateTracker.MaxTextureUnits ? _boundTextures[unit] : 0; + foreach (NativeTexture texture in textures) + { + if (texture.Sampler.IsPresent && texture.Sampler.Index == program.Interface.Samplers.IndexOf(declared) && + texture.TextureId != deviceTexture) + { + result.Add("sampler " + declared.Name + " (unit " + unit + "): device " + deviceTexture + ", stated " + texture.TextureId); + } + } + } + } + return result; + } + /// The manifest variant a program was linked for; "" for a program the rewriter linked. internal string NativeVariantOf(int programId) => _programVariants.TryGetValue(programId, out string? key) ? key : ""; diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index ce93c35e..7356f263 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -969,4 +969,53 @@ public void TheForkCloudRenderersDrawNativelyUnderTheStateTheForkStated() Assert.Contains("FrameBuffers[(int)EnumFrameBuffer.LiquidDepth]", clouds); Assert.Contains("OPTIMUM_VK_NATIVE_CLOUDS", clouds); } + /// + /// The generic native draw (removal of the emulation layer, step 1): every mesh, instanced, + /// multi-draw and fullscreen draw the dedicated routes do not take is recorded natively from + /// the state the client stated, before the emulated draw is reached. The state is recorded with + /// OpenGL's semantics at the platform's own virtuals, the fork bridge's included, and the pass + /// declares every colour slot attached on the device - the OIT accumulation slots included. + /// + [Fact] + public void EveryRemainingDrawTakesTheGenericStatedRouteFirst() + { + string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); + Assert.Contains("if (TryDrawStated(vAO, 1, null, null, 0))", meshes); + Assert.Contains("if (TryDrawStated(null, 1, null, null, 0))", meshes); + Assert.Contains("if (TryDrawStated(vAO, 1, indices, indicesSizes, groupCount))", meshes); + Assert.Contains("TryDrawStated(vAO, quantity, null, null, 0)", meshes); + // Each stated route sits before its emulated draw. + Assert.True(meshes.IndexOf("if (TryDrawStated(vAO, 1, null, null, 0))", StringComparison.Ordinal) < + meshes.IndexOf("device.DrawMesh(vAO.VaoId);", StringComparison.Ordinal)); + + string route = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs"); + Assert.Contains("RenderTargetFormats? all = device.NativeTargetFormats(framebufferId, uint.MaxValue);", route); + Assert.Contains("units[i] = device.NativeSamplerUnit(program.ProgramId, names[i]);", route); + Assert.Contains("reads[i] = stated.TextureAt(units[i]);", route); + Assert.Contains("OPTIMUM_VK_NATIVE_STATED", route); + Assert.Contains("OPTIMUM_VK_STATED_CHECK", route); + + string state = Read("Optimum.Render.Vulkan/Platform/StatedRenderState.cs"); + Assert.Contains("public void SetBlendEnabled(bool enabled) => BlendEnabled = enabled;", state); + Assert.Contains("_drawBuffers.TryGetValue(framebufferId, out uint mask) ? mask : 1u;", state); + + // Recorded where the client states it: no draw-buffer or per-slot blend call bypasses the record. + foreach (string file in new[] { "FrameBuffers", "Taa", "Leaf" }) + { + string source = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform." + file + ".cs"); + Assert.DoesNotContain("device.SetDrawBuffers(", source); + Assert.DoesNotContain("device.SetBlendFuncSeparate(", source); + } + string platformState = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs"); + Assert.Contains("stated.SetBlendEnabled(on);", platformState); + Assert.Contains("stated.LineWidth = width;", platformState); + Assert.Contains("stated.LineWidth = 1.5f;", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs")); + string fork = Read("Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs"); + Assert.Contains("platform.NoteForkTexture(unit, textureId);", fork); + Assert.Contains("platform.NoteForkDrawBuffers(framebufferId, attachmentMask);", fork); + Assert.Contains("platform.NoteForkViewport(x, y, width, height);", fork); + string clouds = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs"); + Assert.Contains("stated.DepthTest = enabled;", clouds); + Assert.Contains("stated.SetBlendEnabled(enabled);", clouds); + } } diff --git a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs index 3ddaf5cb..47fb84d8 100644 --- a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs +++ b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs @@ -182,7 +182,7 @@ public void TheOitLayersKeepOnlyTheirFailurePathUnitReset() yield return new object?[] { "LoadTextureFromRgbaPointer", "public override int LoadTextureFromRgbaPointer(int width, int height, IntPtr pixels)", "GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, width, height, 0, (PixelFormat)6408, (PixelType)5121, pixels);", "device.CreateTexture2DRaw(width, height, OptimumGlConstants.Rgba8, pixels, 4);" }; yield return new object?[] { "SetProgramSamplerUnit", "public override void SetProgramSamplerUnit(int programId, string samplerName, int unit)", "GL.Uniform1(GL.GetUniformLocation(programId, samplerName), unit);", "device.SetSamplerUnit(programId, samplerName, unit);" }; yield return new object?[] { "CreateOitTargets", "public override void CreateOitTargets(FrameBufferRef transparent, int layers, out int revealTexture, out int accumTexture)", "GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36069, accumTexture, 0, 2);", "device.AttachTexture(transparent.FboId, (EnumFramebufferAttachment)36069, accumTexture, 2);" }; - yield return new object?[] { "BeginOitAccumulation", "public override void BeginOitAccumulation(FrameBufferRef transparent)", "GL.ClearBuffer((ClearBuffer)6144, 5, array3);", "device.SetDrawBuffers(transparent.FboId, 0x3F);" }; + yield return new object?[] { "BeginOitAccumulation", "public override void BeginOitAccumulation(FrameBufferRef transparent)", "GL.ClearBuffer((ClearBuffer)6144, 5, array3);", "StateDrawBuffers(transparent.FboId, 0x3F);" }; yield return new object?[] { "BindOitTextures", "public override void BindOitTextures(int revealTexture, int accumTexture)", "GL.BindTexture((TextureTarget)35866, accumTexture);", "device.BindTexture(7, accumTexture);" }; yield return new object?[] { "GenOcclusionQuery", "public override int GenOcclusionQuery()", "GL.GenQueries(1, out queryId);", "return device.CreateOcclusionQuery();" }; yield return new object?[] { "BeginOcclusionQuery", "public override void BeginOcclusionQuery(int queryId)", "GL.BeginQuery((QueryTarget)35092, queryId);", "device.BeginOcclusionQuery(queryId);" }; diff --git a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs index 82c7f4e1..9f43d027 100644 --- a/Optimum.Tests/taa-liquid-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -147,7 +147,7 @@ public void ThePlatformOpensAMotionOnlyWindowOnBothBackends() // Device path (VulkanClientPlatform since Phase 1A step 4): the mask is the single // motion bit, not the prefix mask. string vulkan = VulkanPlatformSource.Read(); - Assert.Contains("device.SetDrawBuffers(FrameBuffers[0].FboId, 1 << MotionAttachmentIndex);", + Assert.Contains("StateDrawBuffers(FrameBuffers[0].FboId, 1 << MotionAttachmentIndex);", vulkan.Substring(vulkan.IndexOf("public override void EnableMotionOnlyDrawBuffers()", StringComparison.Ordinal))); // GL path: GL_NONE in every slot below the motion attachment, and a diff --git a/Optimum.Tests/taa-particle-motion-coverage-tests.cs b/Optimum.Tests/taa-particle-motion-coverage-tests.cs index f0006412..125cc868 100644 --- a/Optimum.Tests/taa-particle-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-particle-motion-coverage-tests.cs @@ -276,8 +276,7 @@ public void TheMergeOpensTheWindowAndPutsTheMotionAttachmentOnAdditiveBlending() Assert.Contains("GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)1);", state); string deviceState = MethodBodyAfter(VulkanPlatformSource.Read(), "public override void ApplyOptimumMotionAccumulateBlendState()"); Assert.Contains("if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return;", deviceState); - Assert.Contains("device.SetBlendEquation(MotionAttachmentIndex, 32774);", deviceState); - Assert.Contains("device.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 1, 1, 1);", deviceState); + Assert.Contains("StateSlotBlend(MotionAttachmentIndex, 32774, 1, 1, 1, 1);", deviceState); string? patch = TryFind("patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"); Assert.True(patch != null, "ClientPlatformWindows has no patch, so the change never ships"); diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index b27a3ca7..ea2edacc 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -71,9 +71,9 @@ public void DefaultDrawBufferMasksAreUnchangedByTaa() // primaryAttachments (2 or 4 colour targets), never including the new // motion attachment - it is enabled per-pass by writers, not by // default. - Assert.Contains("device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", platform); + Assert.Contains("StateDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", platform); // Transparent (OIT) keeps its untouched six/three-output mask. - Assert.Contains("device.SetDrawBuffers(transparent.FboId, 7);", platform); + Assert.Contains("StateDrawBuffers(transparent.FboId, 7);", platform); } [Fact] @@ -88,9 +88,9 @@ public void ClearFrameBufferClearsTheMotionAttachmentOnBothPaths() Assert.Contains("device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", vulkan); // An excluded attachment is not cleared on either backend. Checking // only that ClearColor exists missed Vulkan's silent masked-out no-op. - int enable = vulkan.IndexOf("device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", StringComparison.Ordinal); + int enable = vulkan.IndexOf("StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", StringComparison.Ordinal); int clear = vulkan.IndexOf("device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", StringComparison.Ordinal); - int restore = vulkan.IndexOf("device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", clear, StringComparison.Ordinal); + int restore = vulkan.IndexOf("StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", clear, StringComparison.Ordinal); Assert.True(enable >= 0 && enable < clear && restore > clear); Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"ClearFrameBuffer\", 1", Read("Optimum.Patcher/Program.cs")); // GL path. diff --git a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs index b0a0d4f1..7b2127fe 100644 --- a/Optimum.Tests/taa-terrain-motion-coverage-tests.cs +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -228,10 +228,10 @@ public void ThePlatformOpensAndClosesTheMotionDrawBufferOnBothBackends() Assert.Contains("EnableMotionDrawBuffers();", platform); Assert.Contains("RestorePrimaryDrawBuffers();", platform); Assert.Contains( - "device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", + "StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", vulkan); Assert.Contains( - "device.SetDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", + "StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", vulkan); // GL path: the same two sets, as DrawBuffers arrays - built once and @@ -304,7 +304,7 @@ public void TheMotionAttachmentNeverBlends() // VulkanClientPlatform, whose GlToggleBlend re-applies it the same way. string vulkan = VulkanPlatformSource.Read(); Assert.Contains("public override void ApplyOptimumMotionBlendState()", platform); - Assert.Contains("device.SetBlendFuncSeparate(MotionAttachmentIndex, 1, 0, 1, 0);", vulkan); + Assert.Contains("StateSlotBlend(MotionAttachmentIndex, 32774, 1, 0, 1, 0);", vulkan); Assert.Contains("GL.BlendFunc(MotionAttachmentIndex, (BlendingFactorSrc)1, (BlendingFactorDest)0);", platform); int deviceToggle = vulkan.IndexOf("public override void GlToggleBlend(bool on, EnumBlendMode blendMode", StringComparison.Ordinal); Assert.True(deviceToggle >= 0); diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index d4e42499..65cf7416 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -406,7 +406,7 @@ public void SsaoWidensPrimaryToFourAttachments() Assert.Contains("bool setupSsao = ClientSettings.SSAOQuality > 0;", added); Assert.Contains("int primaryAttachments = (setupSsao ? 4 : 2);", added); - Assert.Contains("device.SetDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", added); + Assert.Contains("StateDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", added); } /// From 890dfa2a66560c1f6066af6460ca8a74e04a7631 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 18:55:48 +0200 Subject: [PATCH 224/226] feat(native): remove the GL emulation layer (emulation removal, steps 2 and 3) Every draw is native now. The device has no GL state machine, bound target, texture-unit tables or emulated draws; GlStateTracker is gone and its shared pipeline types live in Core/PipelineState.cs. The platform records what the client states (StatedRenderState, including glUseProgram) and draws everything without a dedicated route through Platform/StatedDraw.cs, keeping the scope open so consecutive stated draws coalesce. Clears name their target and honour the stated draw buffers, colour mask and depth mask. RenderTargetManager lost its draw-buffer mask and sampled-slot exclusion; a declared pass's slots are the only exclusion, with the frame graph on or off. Fixes found on the way: - native pipeline cache keyed on the described blend set, so draws differing only in dynamic blend/write mask no longer share one description - pipeline formats for a pass ignore the target's current pass exclusion - set-0 textures a draw does not name are made readable, or replaced by the placeholder (sky after the liquid depth pass: VUID-vkCmdDrawIndexed-imageLayout-00344) - a platform framebuffer bind cancels an earlier fork bind - a native pipeline request no longer counts a skipped draw GPU tests keep their GL-shaped calls through a test-only GlShapedDevice that records into StatedRenderState and draws through StatedDraw. Verified: solution builds; Optimum.Tests 1333 passed; Optimum.Render.Vulkan.Tests 1082 passed (sync,best, implicit layers off); headless "serene cave world" on Vulkan (0 validation errors, closed itself) and OpenGL (closed itself). --- .../AttachmentSemanticsTests.cs | 33 +- .../ChunkRenderPathTests.cs | 38 +- .../ColorWriteTierTests.cs | 69 -- .../FrameGlobalsDeviceTests.cs | 79 +++ Optimum.Render.Vulkan.Tests/GlShapedDevice.cs | 277 ++++++++ .../GlStateTrackerTests.cs | 472 ------------- .../MeshManagerTests.cs | 43 +- .../MotionWindowTests.cs | 10 +- .../NativeBlitTests.cs | 43 +- .../NativeChunkTests.cs | 54 +- .../NativeEntityDrawTests.cs | 45 +- Optimum.Render.Vulkan.Tests/NativeGuiTests.cs | 54 +- .../NativeMeshDrawTests.cs | 26 +- .../NativePostChainTests.cs | 90 +-- Optimum.Render.Vulkan.Tests/NativeSkyTests.cs | 34 +- .../NativeSsaoChainTests.cs | 56 +- .../NativeStatedTests.cs | 132 +++- .../NativeWorldSystemsTests.cs | 77 +- .../PipelineCacheTests.cs | 8 +- .../PipelineKeyState.cs | 236 +------ .../PoisonModeTests.cs | 9 +- .../RenderTargetTests.cs | 131 +--- .../TaaMoverMotionTests.cs | 2 + .../TaaResolveTests.cs | 51 +- .../TaaSharpenTests.cs | 27 +- .../WorldRenderPathTests.cs | 26 +- Optimum.Render.Vulkan/Core/MeshManager.cs | 4 +- Optimum.Render.Vulkan/Core/PipelineCache.cs | 26 +- Optimum.Render.Vulkan/Core/PipelineState.cs | 238 +++++++ .../Core/RenderTargetManager.cs | 210 +++--- Optimum.Render.Vulkan/Core/VulkanContext.cs | 4 +- Optimum.Render.Vulkan/Platform/StatedDraw.cs | 154 ++++ .../Platform/StatedRenderState.cs | 13 +- .../Platform/VulkanClientPlatform.Frame.cs | 1 - .../VulkanClientPlatform.FrameBuffers.cs | 70 +- .../Platform/VulkanClientPlatform.Graph.cs | 94 +-- .../Platform/VulkanClientPlatform.Leaf.cs | 22 +- .../Platform/VulkanClientPlatform.Meshes.cs | 36 +- .../VulkanClientPlatform.ModPasses.cs | 14 +- .../VulkanClientPlatform.NativeBlit.cs | 2 +- .../VulkanClientPlatform.NativeChunks.cs | 23 +- .../VulkanClientPlatform.NativeClouds.cs | 8 +- .../VulkanClientPlatform.NativeGui.cs | 9 +- .../VulkanClientPlatform.NativePostChain.cs | 4 +- .../VulkanClientPlatform.NativeSky.cs | 7 +- .../VulkanClientPlatform.NativeStated.cs | 204 ++---- .../VulkanClientPlatform.NativeWorld.cs | 9 +- .../Platform/VulkanClientPlatform.Shaders.cs | 17 +- .../Platform/VulkanClientPlatform.State.cs | 37 +- .../Platform/VulkanClientPlatform.cs | 1 + .../Platform/VulkanForkGraphics.cs | 21 +- Optimum.Render.Vulkan/VulkanDevice.Native.cs | 224 +++--- .../VulkanDevice.NativeMesh.cs | 28 +- Optimum.Render.Vulkan/VulkanDevice.cs | 661 ++---------------- .../ambient-occlusion-coverage-tests.cs | 12 +- .../color-write-tier-coverage-tests.cs | 26 +- Optimum.Tests/frame-graph-coverage-tests.cs | 21 +- .../headless-harness-coverage-tests.cs | 8 +- Optimum.Tests/mod-pass-api-coverage-tests.cs | 5 +- .../native-world-systems-coverage-tests.cs | 57 +- ...orm-program-ubo-virtuals-coverage-tests.cs | 14 +- .../platform-seam-deletion-coverage-tests.cs | 6 +- Optimum.Tests/scene-ssao-coverage-tests.cs | 6 +- Optimum.Tests/taa-pipeline-coverage-tests.cs | 4 +- .../vulkan-backend-integration-tests.cs | 157 +---- VULKAN-BACKEND-PLAN.md | 3 +- docs/vulkan-native-render-systems.md | 33 + 67 files changed, 1803 insertions(+), 2812 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/GlShapedDevice.cs delete mode 100644 Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs rename Optimum.Render.Vulkan/Core/GlStateTracker.cs => Optimum.Render.Vulkan.Tests/PipelineKeyState.cs (63%) create mode 100644 Optimum.Render.Vulkan/Core/PipelineState.cs create mode 100644 Optimum.Render.Vulkan/Platform/StatedDraw.cs diff --git a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs index aaa3a169..dc618274 100644 --- a/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -59,8 +59,8 @@ public unsafe void OnlyTheDeclaredLocationsAmongFiveAttachmentsAreWritten() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -74,7 +74,6 @@ public unsafe void OnlyTheDeclaredLocationsAmongFiveAttachmentsAreWritten() int framebuffer = targets.Create(size, size); for (int i = 0; i < 5; i++) targets.Attach(framebuffer, i, attachment[i]); - targets.SetDrawBuffers(framebuffer, 0b10001); // 0 and 4 only TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ #version 330 core @@ -133,8 +132,8 @@ public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContents() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -148,7 +147,6 @@ public unsafe void UnwrittenButEnabledAttachmentsKeepTheirContents() int framebuffer = targets.Create(size, size); for (int i = 0; i < 5; i++) targets.Attach(framebuffer, i, attachment[i]); - targets.SetDrawBuffers(framebuffer, 0b11111); // all five enabled TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ #version 330 core @@ -216,8 +214,8 @@ public unsafe void EachAttachmentBlendsWithItsOwnFactorsRegardlessOfSharedAlpha( const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -229,7 +227,6 @@ public unsafe void EachAttachmentBlendsWithItsOwnFactorsRegardlessOfSharedAlpha( int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, colorTexture); targets.Attach(framebuffer, 4, motionTexture); - targets.SetDrawBuffers(framebuffer, 0b10001); // 0 and 4, 1-3 unattached // Attachment 0: ordinary alpha blending. state.SetBlend(true, EnumBlendMode.Standard); @@ -289,8 +286,8 @@ public unsafe void TheBoundFramebuffersDepthCanBeSampledWithWritesOff() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -301,7 +298,6 @@ public unsafe void TheBoundFramebuffersDepthCanBeSampledWithWritesOff() // way the shadow / opaque passes do. int depthPass = targets.Create(size, size); targets.Attach(depthPass, -1, depth); - targets.SetDrawBuffers(depthPass, 0); TranslatedProgram depthOnly = Translate(compiler, """ #version 330 core @@ -338,7 +334,6 @@ void main(void) { } int resolvePass = targets.Create(size, size); targets.Attach(resolvePass, -1, depth); targets.Attach(resolvePass, 0, color); - targets.SetDrawBuffers(resolvePass, 0b1); TranslatedProgram resolveTranslated = Translate(compiler, FullscreenVertex, """ #version 330 core @@ -382,12 +377,12 @@ private static TranslatedProgram Translate(ShaderCompiler compiler, string verte private static unsafe void RenderFullscreen( VulkanContext context, SetupQueue commands, RenderTargetManager targets, - GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + GraphicsPipelineCache pipelines, PipelineKeyState state, ShaderProgramResources program, int framebuffer, uint size, bool depthTest = false) { VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); int attachmentCount = targets.EnabledAttachmentCount(bound); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; @@ -419,7 +414,7 @@ private static unsafe void RenderFullscreen( api.CmdSetScissor(commandBuffer, 0, 1, &scissor); api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, depthTest); api.CmdSetDepthWriteEnable(commandBuffer, depthTest); @@ -447,12 +442,12 @@ private static unsafe void RenderFullscreen( /// private static unsafe void RenderFullscreenSamplingDepth( VulkanContext context, SetupQueue commands, TextureManager textures, RenderTargetManager targets, - GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + GraphicsPipelineCache pipelines, PipelineKeyState state, ShaderProgramResources program, int framebuffer, int sampledDepthTextureId, uint size) { VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); int attachmentCount = targets.EnabledAttachmentCount(bound); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; @@ -495,7 +490,7 @@ private static unsafe void RenderFullscreenSamplingDepth( api.CmdSetScissor(commandBuffer, 0, 1, &scissor); api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, false); api.CmdSetDepthWriteEnable(commandBuffer, false); diff --git a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs index 6318998a..0ffb0dbb 100644 --- a/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -52,10 +52,10 @@ public void TheRealChunkProgramTranslatesAndBuildsAPipeline() { using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); - using var meshes = new MeshManager(context!, state); + using var meshes = new MeshManager(context!); using var compiler = new ShaderCompiler(); var files = ShaderCorpus.LoadShaderFiles(); @@ -114,11 +114,10 @@ public void TheRealChunkProgramTranslatesAndBuildsAPipeline() int target = textures.Create(8, 8, Format.R8G8B8A8Unorm); int framebuffer = targets.Create(8, 8); targets.Attach(framebuffer, 0, target); - targets.SetDrawBuffers(framebuffer, 0b1); VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); @@ -181,10 +180,10 @@ public void AWorldProgramBuildsAPipelineAgainstItsMeshLayout(string programName) { using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); - using var meshes = new MeshManager(context!, state); + using var meshes = new MeshManager(context!); using var compiler = new ShaderCompiler(); var files = ShaderCorpus.LoadShaderFiles(); @@ -224,11 +223,10 @@ public void AWorldProgramBuildsAPipelineAgainstItsMeshLayout(string programName) targets.Attach(framebuffer, 0, target); targets.Attach(framebuffer, -1, depth); // Enough attachments for the multi-output world passes. - targets.SetDrawBuffers(framebuffer, 0b1); VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; for (int i = 0; i < blend.Length; i++) blend[i] = state.BlendFor(i); @@ -271,16 +269,15 @@ public unsafe void TheSsboChunkPathUploadsFaceRecordsAndDraws() const uint size = 16; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); - using var meshes = new MeshManager(context!, state); + using var meshes = new MeshManager(context!); using var compiler = new ShaderCompiler(); int target = textures.Create(size, size, Format.R8G8B8A8Unorm); int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, target); - targets.SetDrawBuffers(framebuffer, 0b1); // One face: four vertices packed into sixteen bytes, plus the six // indices that expand it into two triangles. @@ -347,7 +344,7 @@ void main(void) VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); Pipeline pipeline = pipelines.Get( state.BuildKey(meshes.LayoutIdOf(mesh), formatsId, 1), @@ -424,16 +421,15 @@ public unsafe void InstancedDrawsRenderEveryInstance() const uint size = 16; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); - using var meshes = new MeshManager(context!, state); + using var meshes = new MeshManager(context!); using var compiler = new ShaderCompiler(); int target = textures.Create(size, size, Format.R8G8B8A8Unorm); int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, target); - targets.SetDrawBuffers(framebuffer, 0b1); int mesh = meshes.CreateEmpty( xyzSize: 4 * 3 * sizeof(float), normalsSize: 0, uvSize: 0, @@ -494,7 +490,7 @@ void main(void) VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); Pipeline pipeline = pipelines.Get( state.BuildKey(meshes.LayoutIdOf(mesh), formatsId, 1), @@ -549,7 +545,7 @@ private static byte[] PixelAt(byte[] pixels, uint size, uint x, uint y) => private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) { api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, false); api.CmdSetDepthWriteEnable(commandBuffer, false); diff --git a/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs b/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs index c916b449..6247e572 100644 --- a/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs +++ b/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs @@ -45,75 +45,6 @@ public void TheOverrideParsesItsThreeTokens(string? value, string? expected) Assert.Equal(expected, parsed == null ? null : DeviceCaps.Token(parsed.Value)); } - [Fact] - public void TheEffectiveMaskIsTheColorMaskOnlyWhereTheDrawBufferIsOnAndTheOutputWritten() - { - var tracker = new GlStateTracker(); - tracker.SetColorMask(true, true, false, true); - uint written = GlStateTracker.OutputBits(new HashSet { 0, 1, 2 }); - Assert.Equal(0b111u, written); - - ColorComponentFlags rgA = ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.ABit; - Assert.Equal(rgA, tracker.EffectiveWriteMask(0, 0b101, written)); - Assert.Equal((ColorComponentFlags)0, tracker.EffectiveWriteMask(1, 0b101, written)); // draw buffer off - Assert.Equal(rgA, tracker.EffectiveWriteMask(2, 0b101, written)); - Assert.Equal((ColorComponentFlags)0, tracker.EffectiveWriteMask(3, 0b1111, written)); // not written - } - - /// The key tier with every draw buffer selected keys exactly as before C4. - [Fact] - public void TheKeyTierWithAllDrawBuffersKeysLikeTheLegacyBlendId() - { - var tracker = new GlStateTracker(); - tracker.SetBlend(true, EnumBlendMode.Standard); - tracker.SetAttachmentBlendFunc(1, 1, 1, 1, 1); - - Assert.Equal(tracker.BlendId(3), tracker.PipelineBlendId(3, uint.MaxValue)); - Assert.Equal(tracker.BuildKey(1, 2, 3), tracker.BuildKey(1, 2, 3, 0b111)); - Assert.NotEqual(tracker.BuildKey(1, 2, 3), tracker.BuildKey(1, 2, 3, 0b011)); - Assert.Equal((ColorComponentFlags)0, tracker.PipelineBlendFor(2, 0b011).WriteMask); - Assert.Equal(Rgba, tracker.PipelineBlendFor(1, 0b011).WriteMask); - } - - [Fact] - public void TheEnableTierKeepsTheKeyAcrossDrawBufferChanges() - { - var tracker = new GlStateTracker { ColorWriteTier = ColorWriteTier.DynamicEnable }; - tracker.SetBlend(true, EnumBlendMode.Standard); - - PipelineKey all = tracker.BuildKey(1, 2, 3, 0b111); - Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b011)); - Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b100)); - Assert.Equal(Rgba, tracker.PipelineBlendFor(2, 0b011).WriteMask); - - // glColorMask stays baked in this tier. - tracker.SetColorMask(true, true, true, false); - Assert.NotEqual(all, tracker.BuildKey(1, 2, 3, 0b111)); - } - - [Fact] - public void TheMaskTierKeepsTheKeyAcrossDrawBufferAndColorMaskChanges() - { - var tracker = new GlStateTracker { ColorWriteTier = ColorWriteTier.DynamicMask }; - tracker.SetBlend(true, EnumBlendMode.Standard); - - PipelineKey all = tracker.BuildKey(1, 2, 3, 0b111); - Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b001)); - tracker.SetColorMask(false, false, false, false); - Assert.Equal(all, tracker.BuildKey(1, 2, 3, 0b001)); - - // Blend factors stay in the key unless blend is dynamic too. - tracker.SetAttachmentBlendFunc(2, 1, 1, 1, 1); - PipelineKey additive = tracker.BuildKey(1, 2, 3, 0b111); - Assert.NotEqual(all, additive); - - tracker.DynamicBlend = true; - PipelineKey dynamicBlend = tracker.BuildKey(1, 2, 3, 0b111); - tracker.SetBlend(false, EnumBlendMode.Glow); - tracker.SetAttachmentBlendEquation(0, 0x800A); - Assert.Equal(dynamicBlend, tracker.BuildKey(1, 2, 3, 0b010)); - } - [Fact] public void ColorWriteAndBlendChangesAreTheirOwnDirtyBits() { diff --git a/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs b/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs index adf84fc1..3ea024d0 100644 --- a/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs @@ -94,6 +94,85 @@ public void ProgramsThatIncludeTheOwnerShareOneCopyAndOthersKeepTheirOwn() } } + /// + /// A set-0 texture a draw does not name keeps the value the last draw that named it left + /// (liquidDepth, which the sky dome's route never names). When that texture has been a depth + /// attachment since, the draw moves it back into the read layout before it binds set 0: the + /// frame stays validation-clean (2026-09-17: the headless run reported + /// VUID-vkCmdDrawIndexed-imageLayout-00344 for the sky after the liquid depth pass). + /// + [SkippableFact] + public void AFrameTextureADrawDoesNotNameIsReadableAfterBeingAnAttachment() + { + Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int reader = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D liquidDepth; + in vec2 texCoord; + out vec4 outColor; + void main() { outColor = vec4(texture(liquidDepth, texCoord).r, 0.0, 0.0, 1.0); } + """, "frame-texture-reader"); + int depthWriter = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + out vec4 outColor; + void main() { outColor = vec4(1.0); } + """, "frame-texture-depth-writer"); + Assert.Contains("liquidDepth", seam.SamplerNamesOf(reader)); + + int colourTarget = Target(seam, out int colour); + int depth = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + int depthTarget = seam.CreateFramebuffer(Size, Size); + seam.AttachTexture(depthTarget, EnumFramebufferAttachment.DepthAttachment, depth, 0); + + seam.BeginFrame(); + seam.SetViewport(0, 0, Size, Size); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + + // The draw that names it: set 0 now holds the depth texture. + seam.BindFramebuffer(colourTarget); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.UseProgram(reader); + seam.BindTexture(0, depth); + seam.DrawFullscreenTriangle(); + seam.BindTexture(0, 0); + + // The texture is written as a depth attachment. + seam.BindFramebuffer(depthTarget); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x0207); // GL_ALWAYS + seam.UseProgram(depthWriter); + seam.DrawFullscreenTriangle(); + + // A native draw of the reader that names nothing: the value left in set 0 is read. + long drawsBefore = seam.NativeFullscreenDrawsForTests; + NativePipeline? pipeline = seam.RequestNativePipeline(new NativePipelineDescription + { + ProgramId = reader, + Blend = new[] { AttachmentBlend.For(false, EnumBlendMode.Standard) }, + Targets = seam.NativeTargetFormats(colourTarget, 1u)!, + }, out string error); + Assert.True(pipeline != null, error); + Assert.True(seam.BeginNativePass(new NativePassDescription { Name = "Unnamed", FramebufferId = colourTarget })); + Assert.True(seam.DrawNativeFullscreen(pipeline!, ReadOnlySpan.Empty)); + seam.EndNativePass(); + Assert.Equal(1, seam.NativeFullscreenDrawsForTests - drawsBefore); + seam.Present(); + + GpuTest.AssertClean(seam); + seam.DeleteFramebuffer(depthTarget); + seam.DeleteFramebuffer(colourTarget); + seam.DeleteTexture(depth); + seam.DeleteTexture(colour); + } + } + private static int Link(VulkanDevice seam, string name, bool includeFog) { var vertex = new Shader(EnumShaderType.VertexShader, FullscreenVertex, name + ".vsh"); diff --git a/Optimum.Render.Vulkan.Tests/GlShapedDevice.cs b/Optimum.Render.Vulkan.Tests/GlShapedDevice.cs new file mode 100644 index 00000000..f6a6dcd4 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/GlShapedDevice.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Optimum.Render.Vulkan.Platform; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The OpenGL-shaped calls the GPU tests are written in, on a bare : +/// set state, bind, clear, draw, read back. The device has no GL state machine any more; these +/// record what a test states into a per device, exactly as +/// records the client's statements, and every draw goes through +/// , the platform's generic native draw. A test therefore renders through +/// the route the client's own unrecognised draws take. +/// +/// A device a owns shares the platform's record, program and +/// target (VulkanDevice.OwnerPlatform): a fixture that states on the device and then calls one of +/// the platform's bodies draws with that state, and the device's draws are the platform's. +/// +/// Semantics kept from the removed emulation: a bind names the target the next clears, draws and +/// readbacks address (nothing is drawn before one); a declared pass (DeclarePass) gives the +/// following draws its name, slots, reads and flags until the next declaration, bind of another +/// target or EndPass; a clear honours the stated draw buffers and colour mask, and a depth +/// clear the depth mask. +/// +internal static class GlShapedDevice +{ + private sealed class Record + { + public StatedRenderState Stated = new(); + public VulkanClientPlatform? Platform; + private int program; + + /// glUseProgram: the platform's record when a platform owns the device. + public int Program + { + get => Platform?.statedProgram ?? program; + set + { + program = value; + if (Platform != null) Platform.statedProgram = value; + } + } + + public int Bound; + public PassDeclaration? Declared; + public string? LastRefusal; + public long Refusals; + + } + + private static readonly ConditionalWeakTable Records = new(); + private static Record Of(VulkanDevice device) + { + if (!Records.TryGetValue(device, out Record? record)) + { + record = new Record(); + Records.Add(device, record); + Record captured = record; + device.FramebufferDeleted += id => + { + captured.Stated.ForgetFramebuffer(id); + if (captured.Bound == id) captured.Bound = 0; + }; + } + if (record.Platform == null) + { + VulkanClientPlatform? owner = PlatformOf(device); + if (owner != null) + { + record.Stated = owner.stated; + record.Platform = owner; + } + } + return record; + } + + /// The platform that owns the device, if any. + private static VulkanClientPlatform? PlatformOf(VulkanDevice device) => device.OwnerPlatform; + + /// The default target's id as the stated record keys it. + private static int Key(VulkanDevice device, int framebufferId) => + framebufferId != 0 && framebufferId == device.DefaultFramebufferId ? PassDeclaration.DefaultFramebuffer : framebufferId; + + extension(VulkanDevice device) + { + /// The state the tests stated on this device. + internal StatedRenderState StatedForTests => Of(device).Stated; + + /// Draws the stated route refused, and the last reason. + internal long StatedRefusalsForTests => Of(device).Refusals; + + internal string? LastStatedRefusalForTests => Of(device).LastRefusal; + + // ------------------------------------------------------------ fixed function + + public void UseProgram(int programId) => Of(device).Program = programId; + + public void SetViewport(int x, int y, int width, int height) => + Of(device).Stated.Viewport = new Rect2D(new Offset2D(x, y), + new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + + /// glScissor, clipped to the positive quadrant as the platform clips it. + public void SetScissor(int x, int y, int width, int height) + { + int clippedX = Math.Max(0, x); + int clippedY = Math.Max(0, y); + width -= clippedX - x; + height -= clippedY - y; + Of(device).Stated.Scissor = new Rect2D(new Offset2D(clippedX, clippedY), + new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); + } + + public void SetScissorEnabled(bool enabled) => Of(device).Stated.ScissorEnabled = enabled; + + public bool ScissorEnabled => Of(device).Stated.ScissorEnabled; + + public void SetDepthTest(bool enabled) => Of(device).Stated.DepthTest = enabled; + + public void SetDepthMask(bool enabled) => Of(device).Stated.DepthWrite = enabled; + + public void SetDepthFunc(int glFunc) => Of(device).Stated.DepthCompare = GlEnums.CompareOpFrom(glFunc); + + public void SetCullFace(bool enabled) => Of(device).Stated.CullEnabled = enabled; + + public void SetCullFaceMode(bool back) => Of(device).Stated.CullBack = back; + + public void SetBlend(bool enabled, EnumBlendMode mode) + { + StatedRenderState stated = Of(device).Stated; + stated.SetBlendEnabled(enabled); + stated.SetBlendMode(mode); + } + + public void SetBlendEnabled(bool enabled) => Of(device).Stated.SetBlendEnabled(enabled); + + public void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) => + Of(device).Stated.SetSlotFunc(attachment, srcColor, dstColor, srcAlpha, dstAlpha); + + public void SetBlendEquation(int attachment, int equation) => + Of(device).Stated.SetSlotEquation(attachment, equation); + + public void SetColorMask(bool r, bool g, bool b, bool a) => Of(device).Stated.SetColorMask(r, g, b, a); + + public void SetStencilTest(bool enabled) => Of(device).Stated.StencilTest = enabled; + + public void SetWireframe(bool enabled) => Of(device).Stated.Wireframe = enabled; + + public void SetLineWidth(float width) => Of(device).Stated.LineWidth = width; + + // ------------------------------------------------------------ textures + + public void BindTexture(int unit, int textureId) => Of(device).Stated.BindTexture(unit, textureId); + + public void BindTextureCube(int unit, int textureId) => Of(device).Stated.BindTexture(unit, textureId); + + public void BindSampler(int unit, int samplerId) => Of(device).Stated.BindSampler(unit, samplerId); + + // ------------------------------------------------------------ targets + + public void SetDrawBuffers(int framebufferId, int attachmentMask) => + Of(device).Stated.SetDrawBuffers(Key(device, framebufferId), (uint)attachmentMask); + + public void BindFramebuffer(int framebufferId) + { + Record record = Of(device); + int key = Key(device, framebufferId); + if (record.Declared != null && record.Declared.FramebufferId != key) SetDeclared(record, null); + record.Bound = key; + if (record.Platform == null) return; + // A raw bind is what a fork renderer does on the platform: its draws address it too. + if (key > 0) record.Platform.NoteForkFramebuffer(key); + else record.Platform.CurrentFrameBuffer = null!; + } + + public void BindDefaultFramebuffer() => device.BindFramebuffer(PassDeclaration.DefaultFramebuffer); + + /// The following draws belong to this pass (0: the bound target, -1: the default one). + internal void DeclarePass(PassDeclaration declaration) + { + Record record = Of(device); + int key = declaration.FramebufferId == PassDeclaration.BoundFramebuffer + ? Target(record) + : Key(device, declaration.FramebufferId); + device.BindFramebuffer(key); + SetDeclared(record, new PassDeclaration + { + Name = declaration.Name, + FramebufferId = key, + ColorSlots = declaration.ColorSlots, + Reads = declaration.Reads, + TransientSlots = declaration.TransientSlots, + Flags = declaration.Flags, + }); + } + + /// Ends the declared pass and closes its scope. + internal void EndPass() + { + SetDeclared(Of(device), null); + device.EndNativePass(); + device.EndStagePass(); + } + + public void ClearColor(int attachment, float r, float g, float b, float a) + { + Record record = Of(device); + int target = Target(record); + if (target == 0) return; + if (((record.Stated.DrawBuffers(target) >> attachment) & 1) == 0 || record.Stated.ColorMask == 0) return; + device.ClearNativeColor(target, attachment, r, g, b, a); + } + + public void ClearDepth(float depth) + { + Record record = Of(device); + int target = Target(record); + if (target == 0 || !record.Stated.DepthWrite) return; + device.ClearNativeDepth(target, depth); + } + + public void ClearStencil() { } + + /// Colour attachment 0 of the bound target, in its own channel order. + public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) + { + int target = Target(Of(device)); + if (target == 0) return; + device.ReadFramebufferColor(target, x, y, width, height, destination); + } + + // ------------------------------------------------------------ draws + + public void DrawMesh(int meshId) => Draw(device, meshId, 1, null, null, 0); + + public void DrawMeshInstanced(int meshId, int instanceCount) + { + if (instanceCount > 0) Draw(device, meshId, instanceCount, null, null, 0); + } + + public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo) => + Draw(device, meshId, 1, indicesStarts, indicesSizes, groupCount); + + public void DrawFullscreenTriangle() => Draw(device, 0, 1, null, null, 0); + } + + /// The target a clear, draw or readback addresses: the platform's current one when a platform owns the device. + private static int Target(Record record) => record.Platform?.CurrentTargetId ?? record.Bound; + + /// The declared pass, held where the draws read it. + private static void SetDeclared(Record record, PassDeclaration? declared) + { + record.Declared = declared; + if (record.Platform != null) record.Platform.statedPass = declared; + } + + private static void Draw(VulkanDevice device, int meshId, int instances, int[]? starts, int[]? sizes, int groupCount) + { + Record record = Of(device); + if (record.Platform != null) + { + record.Platform.RecordStatedDraw(meshId, instances, starts, sizes, groupCount); + return; + } + if (record.Bound == 0 || record.Program <= 0) return; + if (!StatedDraw.Record(device, record.Stated, record.Program, record.Bound, meshId, instances, + starts, sizes, groupCount, out string? refusal, record.Declared) && refusal != null) + { + record.Refusals++; + record.LastRefusal = refusal; + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs b/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs deleted file mode 100644 index e59cc9b9..00000000 --- a/Optimum.Render.Vulkan.Tests/GlStateTrackerTests.cs +++ /dev/null @@ -1,472 +0,0 @@ -using System; -using System.Collections.Generic; -using Optimum.Render.Vulkan.Core; -using Silk.NET.Vulkan; -using Vintagestory.API.Client; -using Xunit; - -namespace Optimum.Render.Vulkan.Tests; - -/// -/// Covers the emulated GL state machine and the pipeline key it resolves into. -/// -/// State bugs are the quiet kind: a wrong blend factor or a key that collides -/// does not crash, it just renders subtly wrong somewhere deep in a scene. These -/// pin the translations against the vanilla behaviour they have to reproduce. -/// -public class GlStateTrackerTests -{ - // ------------------------------------------------------------ blend modes - - /// - /// The factor pairs come straight from ClientPlatformWindows.GlToggleBlend. - /// Every one of the game's named modes has to land on the same pair it had - /// under GL, or transparency and glow render differently. - /// - [Theory] - [InlineData(EnumBlendMode.Standard, BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha)] - [InlineData(EnumBlendMode.Brighten, BlendFactor.DstColor, BlendFactor.One)] - [InlineData(EnumBlendMode.Multiply, BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha)] - [InlineData(EnumBlendMode.PremultipliedAlpha, BlendFactor.One, BlendFactor.OneMinusSrcAlpha)] - [InlineData(EnumBlendMode.Glow, BlendFactor.SrcAlpha, BlendFactor.One)] - [InlineData(EnumBlendMode.Overlay, BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha)] - public void NamedBlendModesMatchTheVanillaFactorPairs( - EnumBlendMode mode, BlendFactor expectedSrc, BlendFactor expectedDst) - { - var tracker = new GlStateTracker(); - tracker.SetBlend(true, mode); - - AttachmentBlend blend = tracker.BlendFor(0); - Assert.True(blend.Enabled); - Assert.Equal(expectedSrc, blend.SrcColor); - Assert.Equal(expectedDst, blend.DstColor); - } - - /// - /// Glow and Overlay use BlendFuncSeparate in vanilla, so their alpha factors - /// differ from their colour ones. - /// - [Fact] - public void SeparateAlphaBlendModesKeepTheirDistinctAlphaFactors() - { - var tracker = new GlStateTracker(); - - tracker.SetBlend(true, EnumBlendMode.Glow); - Assert.Equal(BlendFactor.One, tracker.BlendFor(0).SrcAlpha); - Assert.Equal(BlendFactor.Zero, tracker.BlendFor(0).DstAlpha); - - tracker.SetBlend(true, EnumBlendMode.Overlay); - Assert.Equal(BlendFactor.One, tracker.BlendFor(0).SrcAlpha); - Assert.Equal(BlendFactor.One, tracker.BlendFor(0).DstAlpha); - - tracker.SetBlend(true, EnumBlendMode.Multiply); - Assert.Equal(BlendFactor.One, tracker.BlendFor(0).SrcAlpha); - Assert.Equal(BlendFactor.OneMinusSrcAlpha, tracker.BlendFor(0).DstAlpha); - } - - /// - /// GL's colour mask is global and Vulkan's is per attachment, so setting it - /// has to reach every one of them. - /// - [Fact] - public void ColorMaskAppliesToEveryAttachment() - { - var tracker = new GlStateTracker(); - tracker.SetColorMask(true, false, true, false); - - for (int attachment = 0; attachment < GlStateTracker.MaxColorAttachments; attachment++) - { - ColorComponentFlags mask = tracker.BlendFor(attachment).WriteMask; - Assert.Equal(ColorComponentFlags.RBit | ColorComponentFlags.BBit, mask); - } - } - - /// - /// SystemRenderOITLayers sets blend per attachment. Touching one must not - /// disturb its neighbours. - /// - [Fact] - public void PerAttachmentBlendLeavesOtherAttachmentsAlone() - { - var tracker = new GlStateTracker(); - tracker.SetBlend(true, EnumBlendMode.Standard); - - // GL_ONE, GL_ONE on attachment 3, as the OIT accumulation pass sets. - tracker.SetAttachmentBlendFunc(3, 1, 1, 1, 1); - - Assert.Equal(BlendFactor.One, tracker.BlendFor(3).SrcColor); - Assert.Equal(BlendFactor.One, tracker.BlendFor(3).DstColor); - Assert.Equal(BlendFactor.SrcAlpha, tracker.BlendFor(0).SrcColor); - Assert.Equal(BlendFactor.OneMinusSrcAlpha, tracker.BlendFor(0).DstColor); - } - - [Fact] - public void BlendEnableTogglePreservesOitFactorsAndEquations() - { - var tracker = new GlStateTracker(); - tracker.SetBlend(true, EnumBlendMode.Standard); - tracker.SetAttachmentBlendFunc(0, 774, 0, 774, 0); - tracker.SetAttachmentBlendFunc(3, 1, 1, 1, 1); - tracker.SetAttachmentBlendEquation(3, 32779); // GL_FUNC_REVERSE_SUBTRACT - var reveal = tracker.BlendFor(0); - var accumulation = tracker.BlendFor(3); - int enabledId = tracker.BlendId(6); - tracker.SetBlendEnabled(false); - Assert.False(tracker.BlendFor(0).Enabled); - Assert.False(tracker.BlendFor(3).Enabled); - Assert.NotEqual(enabledId, tracker.BlendId(6)); - tracker.SetBlendEnabled(true); - Assert.Equal(reveal, tracker.BlendFor(0)); - Assert.Equal(accumulation, tracker.BlendFor(3)); - Assert.Equal(enabledId, tracker.BlendId(6)); - } - - // -------------------------------------------------------------- interning - - [Fact] - public void IdenticalBlendStateInternsToTheSameId() - { - var tracker = new GlStateTracker(); - - tracker.SetBlend(true, EnumBlendMode.Standard); - int first = tracker.BlendId(2); - - tracker.SetBlend(false, EnumBlendMode.Standard); - tracker.SetBlend(true, EnumBlendMode.Standard); - int second = tracker.BlendId(2); - - Assert.Equal(first, second); - } - - [Fact] - public void ChangingBlendStateProducesADifferentId() - { - var tracker = new GlStateTracker(); - - tracker.SetBlend(true, EnumBlendMode.Standard); - int standard = tracker.BlendId(2); - - tracker.SetBlend(true, EnumBlendMode.Glow); - int glow = tracker.BlendId(2); - - Assert.NotEqual(standard, glow); - } - - /// - /// The id is cached between changes so a run of draws sharing state pays - /// nothing, but a change has to invalidate it. Getting this wrong would pin - /// the wrong pipeline for every subsequent draw. - /// - [Fact] - public void EveryMutatorInvalidatesTheCachedBlendId() - { - var mutations = new (string Name, Action Apply)[] - { - ("SetBlend", t => t.SetBlend(true, EnumBlendMode.Glow)), - ("SetColorMask", t => t.SetColorMask(true, true, false, true)), - ("SetAttachmentBlendFunc", t => t.SetAttachmentBlendFunc(0, 1, 1, 1, 1)), - ("SetAttachmentBlendEquation", t => t.SetAttachmentBlendEquation(0, 0x800A)), - }; - - foreach ((string name, Action apply) in mutations) - { - var tracker = new GlStateTracker(); - int before = tracker.BlendId(4); - apply(tracker); - Assert.True(before != tracker.BlendId(4), $"{name} did not invalidate the cached blend id"); - } - } - - /// - /// The signature covers only the first attachmentCount attachments, - /// so the cache must key on the count too. Otherwise a one-attachment pass - /// followed by a six-attachment OIT pass hands the OIT draw the id of the - /// one-element signature, and two OIT blend sets that agree on attachment 0 - /// share a pipeline baked with the wrong factors. - /// - [Fact] - public void TheCachedBlendIdIsKeyedOnTheAttachmentCount() - { - var tracker = new GlStateTracker(); - tracker.SetBlend(true, EnumBlendMode.Standard); - tracker.SetAttachmentBlendFunc(1, 1, 1, 1, 1); - - int one = tracker.BlendId(1); - int six = tracker.BlendId(6); - Assert.NotEqual(one, six); - - // Attachment 1 changes; attachment 0 does not. The one-attachment id is - // re-cached first, and the six-attachment request must not inherit it. - tracker.SetAttachmentBlendFunc(1, 0, 0, 0, 0); - int oneAgain = tracker.BlendId(1); - int sixAgain = tracker.BlendId(6); - - Assert.Equal(one, oneAgain); - Assert.NotEqual(oneAgain, sixAgain); - Assert.NotEqual(six, sixAgain); - } - - /// - /// The packing squeezes eight fields into 32 bits. A collision there would - /// silently merge two different blend states onto one pipeline. - /// - [Fact] - public void AttachmentBlendPackingIsCollisionFreeAcrossTheUsedRange() - { - var seen = new Dictionary(); - var factors = new[] - { - BlendFactor.Zero, BlendFactor.One, BlendFactor.SrcAlpha, - BlendFactor.OneMinusSrcAlpha, BlendFactor.DstColor, BlendFactor.SrcAlphaSaturate, - }; - var ops = new[] { BlendOp.Add, BlendOp.Subtract, BlendOp.ReverseSubtract, BlendOp.Min, BlendOp.Max }; - - foreach (bool enabled in new[] { false, true }) - foreach (BlendFactor srcColor in factors) - foreach (BlendFactor dstColor in factors) - foreach (BlendOp colorOp in ops) - foreach (BlendFactor srcAlpha in factors) - { - var blend = new AttachmentBlend - { - Enabled = enabled, - SrcColor = srcColor, - DstColor = dstColor, - ColorOp = colorOp, - SrcAlpha = srcAlpha, - DstAlpha = BlendFactor.One, - AlphaOp = BlendOp.Add, - WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit - | ColorComponentFlags.BBit | ColorComponentFlags.ABit, - }; - - uint packed = blend.Pack(); - if (seen.TryGetValue(packed, out AttachmentBlend existing)) - { - Assert.True(existing.Equals(blend), $"packing collision at 0x{packed:X8}"); - } - seen[packed] = blend; - } - - Assert.True(seen.Count > 1000, "the sweep should have covered a wide range"); - } - - // ------------------------------------------------------------ pipeline key - - [Fact] - public void PipelineKeysCompareByValue() - { - var a = new PipelineKey(1, 2, 3, 4, PolygonMode.Fill, 2); - var b = new PipelineKey(1, 2, 3, 4, PolygonMode.Fill, 2); - var c = new PipelineKey(1, 2, 3, 4, PolygonMode.Line, 2); - - Assert.Equal(a, b); - Assert.Equal(a.GetHashCode(), b.GetHashCode()); - Assert.NotEqual(a, c); - } - - /// - /// Dynamic state must not reach the key: if it did, every viewport or depth - /// change would compile a new pipeline. - /// - [Fact] - public void DynamicStateDoesNotChangeThePipelineKey() - { - var tracker = new GlStateTracker(); - int target = tracker.InternTargetFormats( - new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); - - PipelineKey before = tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1); - - tracker.SetViewport(0, 0, 1920, 1080); - tracker.SetScissor(10, 10, 100, 100); - tracker.SetScissorEnabled(true); - tracker.SetDepthTest(true); - tracker.SetDepthWrite(false); - tracker.SetDepthFunc(0x0203); - tracker.SetCullEnabled(true); - tracker.SetCullBack(false); - tracker.SetStencilTest(true); - tracker.SetStencilFunc(0x0202, 1, 0xFF); - tracker.SetStencilOp(0x1E00, 0x1E00, 0x1E01); - tracker.SetLineWidth(2.5f); - - Assert.Equal(before, tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1)); - } - - [Fact] - public void PipelineStateDoesChangeThePipelineKey() - { - var tracker = new GlStateTracker(); - int target = tracker.InternTargetFormats( - new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); - - PipelineKey before = tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1); - - tracker.SetWireframe(true); - Assert.NotEqual(before, tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1)); - - tracker.SetWireframe(false); - tracker.SetProgram(42); - Assert.NotEqual(before, tracker.BuildKey(vertexLayoutId: 7, target, attachmentCount: 1)); - } - - /// - /// Topology is dynamic within a class but not across one, so lines and - /// triangles need separate pipelines while line list and line strip share. - /// - [Fact] - public void TopologyClassSeparatesLinesFromTrianglesButNotLineStrips() - { - var tracker = new GlStateTracker(); - int target = tracker.InternTargetFormats( - new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); - - tracker.SetTopology(EnumDrawMode.Triangles); - PipelineKey triangles = tracker.BuildKey(0, target, 1); - - tracker.SetTopology(EnumDrawMode.Lines); - PipelineKey lines = tracker.BuildKey(0, target, 1); - - tracker.SetTopology(EnumDrawMode.LineStrip); - PipelineKey lineStrip = tracker.BuildKey(0, target, 1); - - Assert.NotEqual(triangles, lines); - Assert.Equal(lines, lineStrip); - } - - [Fact] - public void RenderTargetFormatsInternByValue() - { - var tracker = new GlStateTracker(); - - int first = tracker.InternTargetFormats( - new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm, Format.R16G16B16A16Sfloat }, Format.D32Sfloat)); - int same = tracker.InternTargetFormats( - new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm, Format.R16G16B16A16Sfloat }, Format.D32Sfloat)); - int different = tracker.InternTargetFormats( - new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.D32Sfloat)); - - Assert.Equal(first, same); - Assert.NotEqual(first, different); - } - - // ------------------------------------------------------------- translations - - [Theory] - [InlineData(0x0200, CompareOp.Never)] - [InlineData(0x0201, CompareOp.Less)] - [InlineData(0x0202, CompareOp.Equal)] - [InlineData(0x0203, CompareOp.LessOrEqual)] - [InlineData(0x0204, CompareOp.Greater)] - [InlineData(0x0205, CompareOp.NotEqual)] - [InlineData(0x0206, CompareOp.GreaterOrEqual)] - [InlineData(0x0207, CompareOp.Always)] - public void GlComparisonConstantsMapToVulkanCompareOps(int glFunc, CompareOp expected) - { - Assert.Equal(expected, GlEnums.CompareOpFrom(glFunc)); - } - - /// - /// GL folds the mipmap mode into the minification filter constant; Vulkan - /// splits them. LINEAR_MIPMAP_LINEAR is trilinear, which the block atlas - /// relies on. - /// - [Theory] - [InlineData(0x2600, Filter.Nearest, SamplerMipmapMode.Nearest)] - [InlineData(0x2601, Filter.Linear, SamplerMipmapMode.Nearest)] - [InlineData(0x2703, Filter.Linear, SamplerMipmapMode.Linear)] - [InlineData(0x2702, Filter.Nearest, SamplerMipmapMode.Linear)] - public void GlMinificationFiltersSplitIntoFilterAndMipmapMode( - int glFilter, Filter expectedFilter, SamplerMipmapMode expectedMode) - { - (Filter filter, SamplerMipmapMode mode) = GlEnums.MinFilterFrom(glFilter); - Assert.Equal(expectedFilter, filter); - Assert.Equal(expectedMode, mode); - } - - /// - /// RGB has no guaranteed colour-attachment support in Vulkan, so the vanilla - /// RGB8 revealage target is promoted to RGBA8 rather than failing. - /// - [Fact] - public void ThreeChannelFormatsArePromotedToFourChannels() - { - Assert.Equal(Format.R8G8B8A8Unorm, GlEnums.TextureFormatFromGl(0x8051)); - Assert.Equal(Format.R8G8B8A8Unorm, GlEnums.TextureFormatFromGl(0x1907)); - } - - [Fact] - public void DepthAndFloatFormatsMapExactly() - { - Assert.Equal(Format.D32Sfloat, GlEnums.TextureFormatFromGl(0x8CAC)); - Assert.Equal(Format.R16G16B16A16Sfloat, GlEnums.TextureFormatFromGl(0x881A)); - Assert.Equal(Format.R16Sfloat, GlEnums.TextureFormatFromGl(0x822D)); - Assert.Equal(Format.R32G32B32A32Sfloat, GlEnums.TextureFormatFromGl(0x8814)); - } - - /// - /// Not a preference: GL's counter-clockwise front face, read in a Vulkan - /// framebuffer that was never flipped, is clockwise. The game never calls - /// glFrontFace, so this is a constant and flipping it would invert culling - /// everywhere. - /// - [Fact] - public void FrontFaceIsClockwiseToMatchUnflippedGlWinding() - { - Assert.Equal(FrontFace.Clockwise, GlStateTracker.FrontFace); - } - - /// - /// The game scissors dialogs that run off the top of the screen, which gives - /// glScissor a negative y. GL clips such a rectangle and keeps the visible - /// part; Vulkan rejects the negative offset and drops the draw, so the same - /// region has to be expressed without one. - /// - [Fact] - public void ANegativeScissorOriginIsClippedToTheSameVisibleRegion() - { - var tracker = new GlStateTracker(); - - tracker.SetScissor(-20, -72, 300, 200); - - Assert.Equal(0, tracker.Scissor.Offset.X); - Assert.Equal(0, tracker.Scissor.Offset.Y); - - // The rectangle still ends where it did: -20 + 300 and -72 + 200. - Assert.Equal(280u, tracker.Scissor.Extent.Width); - Assert.Equal(128u, tracker.Scissor.Extent.Height); - } - - [Fact] - public void AScissorEntirelyOffscreenBecomesEmptyRatherThanNegative() - { - var tracker = new GlStateTracker(); - - tracker.SetScissor(-50, -50, 20, 20); - - Assert.Equal(0, tracker.Scissor.Offset.X); - Assert.Equal(0, tracker.Scissor.Offset.Y); - Assert.Equal(0u, tracker.Scissor.Extent.Width); - Assert.Equal(0u, tracker.Scissor.Extent.Height); - } - - [Fact] - public void ResetRestoresTheDefaultsAFreshContextWouldHave() - { - var tracker = new GlStateTracker(); - tracker.SetDepthTest(true); - tracker.SetWireframe(true); - tracker.SetBlend(true, EnumBlendMode.Glow); - tracker.SetProgram(9); - - tracker.Reset(); - - Assert.False(tracker.DepthTest); - Assert.True(tracker.DepthWrite); - Assert.Equal(PolygonMode.Fill, tracker.PolygonMode); - Assert.Equal(CompareOp.Less, tracker.DepthCompare); - Assert.Equal(0, tracker.CurrentProgram); - Assert.False(tracker.BlendFor(0).Enabled); - } -} diff --git a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs index 158ec7c3..ce0e0e73 100644 --- a/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs +++ b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs @@ -40,7 +40,7 @@ public void PooledTopsoilUsesUnsignedNormalizedShortUvs(bool ssbo) Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var meshes = new MeshManager(context!, new GlStateTracker()); + using var meshes = new MeshManager(context!); var uv2 = new CustomMeshDataPartShort(8) { InterleaveSizes = new[] { 2 }, @@ -70,7 +70,7 @@ public void ShortSignednessMatchesTheTwoGlAllocationPaths(DataConversion convers Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) { - using var meshes = new MeshManager(context!, new GlStateTracker()); + using var meshes = new MeshManager(context!); var shorts = new CustomMeshDataPartShort(8) { InterleaveSizes = new[] { 2 }, @@ -96,8 +96,8 @@ public void AbsentPartsDoNotConsumeAttributeLocations() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); // Positions and colours only: no normals, no UVs, no flags. int mesh = meshes.CreateEmpty( @@ -130,8 +130,8 @@ public void TheChunkVertexLayoutMatchesTheShadersDeclaredLocations() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); // Count stays 0 until values are added, and AllocationSize reports // Count - so this is exactly the "declared but not yet filled" case @@ -185,8 +185,8 @@ public void TheSsboPathBindsOnlyTheColoursTheChunkShadersDeclare() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); // The pool passes its configured sizes whichever path it is on, so // normals, UVs and flags all arrive non-zero here. @@ -229,8 +229,8 @@ public void TheSsboPathDropsTheCustomIntTheFaceRecordAlreadyCarries() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); CustomMeshDataPartInt TwoPerVertex() => new(8) { @@ -278,8 +278,8 @@ public void TheSsboPathDropsASingleCustomIntPartEntirely() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); var customInts = new CustomMeshDataPartInt(4) { @@ -308,8 +308,8 @@ public void MeshIdsBehaveLikeGlNamesIncludingReuse() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); int first = meshes.CreateEmpty(48, 0, 0, 16, 0, 24, null, null, null, null, EnumDrawMode.Triangles, true, false); @@ -339,8 +339,8 @@ public void IdenticalLayoutsShareAnIdAndDifferentOnesDoNot() using (context) { - var state = new GlStateTracker(); - using var meshes = new MeshManager(context!, state); + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); int a = meshes.CreateEmpty(48, 0, 0, 16, 0, 24, null, null, null, null, EnumDrawMode.Triangles, true, false); @@ -371,16 +371,15 @@ public unsafe void AnIndexedMeshRendersWithItsVertexColours() const uint size = 16; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); - using var meshes = new MeshManager(context!, state); + using var meshes = new MeshManager(context!); using var compiler = new ShaderCompiler(); int target = textures.Create(size, size, Format.R8G8B8A8Unorm); int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, target); - targets.SetDrawBuffers(framebuffer, 0b1); // A full-target quad: positions plus colours, no normals or UVs, so // colours land at location 1. @@ -446,7 +445,7 @@ void main(void) VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); Pipeline pipeline = pipelines.Get( state.BuildKey(meshes.LayoutIdOf(mesh), formatsId, 1), @@ -495,7 +494,7 @@ void main(void) private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) { api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, false); api.CmdSetDepthWriteEnable(commandBuffer, false); diff --git a/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs index 37d2bfe4..bb5d86f3 100644 --- a/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs +++ b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs @@ -317,12 +317,10 @@ void main(void) /// /// The final composition shape: draw buffers select Primary 0 only while the - /// program samples Primary 1. The sampled slot leaves the scope, colour receives + /// program samples Primary 1. The sampled slot leaves the draw's pass, colour receives /// glow's texels exactly, glow keeps them; selecting glow again lets it rejoin and - /// a write lands. Validation stays clean. With scope inference the clear opens the - /// scope with glow in it, so the sample splits it (one feedback split); on the - /// frame graph the clear is promoted into the scope the draw opens, which already - /// leaves glow out, so nothing splits. + /// a write lands. Validation stays clean. The stated draw declares its pass without the + /// sampled slot before any scope opens, so nothing splits on either path. /// [SkippableTheory] [MemberData(nameof(TiersWithFrameGraph))] @@ -370,7 +368,7 @@ public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken seam.Present(); _output.WriteLine($"tier={tier} frameGraph={frameGraph} splits_after_compose={splitsAfterCompose} mask_restarts={maskRestarts}"); - Assert.Equal(frameGraph ? 0 : 1, splitsAfterCompose); + Assert.Equal(0, splitsAfterCompose); Assert.Equal(0, maskRestarts); AssertEveryPixel(composed, 4, new byte[] { 51, 102, 153, 255 }, "colour = sampled glow"); AssertEveryPixel(glowAfterCompose, 4, new byte[] { 51, 102, 153, 255 }, "glow untouched by composition"); diff --git a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs index deb5df49..56e692de 100644 --- a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs @@ -57,25 +57,22 @@ public override Size2i OptimumWindowClientSize() => // ------------------------------------------------------------------ the tests /// - /// The plain blit: same pixels, one declared pass, one native draw, and nothing reaching - /// the emulation layer while the pass is open. + /// The plain blit: same pixels, one declared pass, one native draw. /// [SkippableFact] - public unsafe void ThePlainBlitMatchesTheOpenGlBodyAndUsesNoEmulation() + public unsafe void ThePlainBlitMatchesTheOpenGlBodyAsOneNativeDraw() { using Session session = Open(); - byte[] emulated = RunFrame(session, native: false, debugView: 0, fsr: false); + byte[] stated = RunFrame(session, native: false, debugView: 0, fsr: false); long drawsBefore = session.Seam.NativeDrawsForTests; long passesBefore = session.Seam.NativePassesForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: false); Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated, nativeRoute); + Assert.Equal(stated, nativeRoute); GpuTest.AssertClean(session.Seam); } @@ -90,15 +87,13 @@ public unsafe void ADebugViewMatchesTheOpenGlBody(int mode) { using Session session = Open(); - byte[] emulated = RunFrame(session, native: false, debugView: mode, fsr: false); + byte[] stated = RunFrame(session, native: false, debugView: mode, fsr: false); long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] nativeRoute = RunFrame(session, native: true, debugView: mode, fsr: false); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated, nativeRoute); + Assert.Equal(stated, nativeRoute); GpuTest.AssertClean(session.Seam); } @@ -113,39 +108,35 @@ public unsafe void FsrMatchesTheOpenGlBodyWithOnePassPerWrittenTarget() { using Session session = Open(); - byte[] emulated = RunFrame(session, native: false, debugView: 0, fsr: true); + byte[] stated = RunFrame(session, native: false, debugView: 0, fsr: true); long drawsBefore = session.Seam.NativeDrawsForTests; long passesBefore = session.Seam.NativePassesForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: true); Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - int worst = WorstChannelDifference(emulated, nativeRoute); + int worst = WorstChannelDifference(stated, nativeRoute); output.WriteLine("FSR worst channel difference: " + worst); Assert.True(worst <= 1, "FSR differs from the OpenGL body by " + worst + "/255"); GpuTest.AssertClean(session.Seam); } - /// The OpenGL body on this device is the emulation layer, and the native route is not. + /// The OpenGL body on this device is the generic stated route, and the native route is not. [SkippableFact] - public unsafe void TheOpenGlBodyDrawsThroughTheEmulationLayerAndTheNativeRouteDoesNot() + public unsafe void TheOpenGlBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot() { using Session session = Open(); long nativeDrawsBefore = session.Seam.NativeDrawsForTests; - long emulatedBefore = session.Seam.EmulationCallsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; RunFrame(session, native: false, debugView: 0, fsr: false); Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); - Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; RunFrame(session, native: true, debugView: 0, fsr: false); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); GpuTest.AssertClean(session.Seam); } @@ -244,16 +235,6 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, WindowSize, WindowSize, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs index c2f0226c..46344aa0 100644 --- a/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs @@ -18,7 +18,7 @@ namespace Optimum.Render.Vulkan.Tests; /// -/// The terrain, drawn twice on one Vulkan device: through the emulated multi-draw the OpenGL +/// The terrain, drawn twice on one Vulkan device: through the stated multi-draw the OpenGL /// body takes (NativeChunksEnabled false) and through the native pass /// VulkanClientPlatform.NativeChunks.cs records inside a BeginChunkPass / EndChunkPass scope /// (docs/vulkan-native-render-systems.md, decision 5 stage 2). @@ -52,7 +52,7 @@ public ChunkPlatform() : base(null!) // ------------------------------------------------------------------------- the tests /// - /// The opaque terrain group: the native route draws what the emulated route draws, on every + /// The opaque terrain group: the native route draws what the stated route draws, on every /// attachment, under each of the blend and cull combinations ChunkRenderer's five Opaque /// groups run with. /// @@ -61,22 +61,22 @@ public ChunkPlatform() : base(null!) [InlineData("chunk-vegetation", true, false)] [InlineData("chunk-blendnocull", false, false)] [InlineData("chunk-decorative", true, true)] - public void ANativeChunkGroupDrawsWhatTheEmulatedGroupDraws(string pass, bool blend, bool cull) + public void ANativeChunkGroupDrawsWhatTheStatedGroupDraws(string pass, bool blend, bool cull) { using Session session = Open(motion: false); - byte[][] emulated = session.RunGroup(pass, native: false, blend: blend, cull: cull); + byte[][] stated = session.RunGroup(pass, native: false, blend: blend, cull: cull); byte[][] native = session.RunGroup(pass, native: true, blend: blend, cull: cull); - output.WriteLine("scene centre emulated " + Centre(emulated[0]) + " native " + Centre(native[0])); - Assert.Equal(emulated[0], native[0]); - Assert.Equal(emulated[1], native[1]); + output.WriteLine("scene centre stated " + Centre(stated[0]) + " native " + Centre(native[0])); + Assert.Equal(stated[0], native[0]); + Assert.Equal(stated[1], native[1]); GpuTest.AssertClean(session.Seam); } /// /// The native route records the group as one declared pass and one indirect multi-draw - - /// the shape the chunk path has to keep - and the emulated route records neither. + /// the shape the chunk path has to keep - and the stated route records neither. /// [SkippableFact] public void TheNativeGroupIsOneDeclaredPassAndOneIndirectMultiDraw() @@ -106,13 +106,13 @@ public void TheMotionAttachmentIsIdenticalBetweenTheRoutes() { using Session session = Open(motion: true); - byte[][] emulated = session.RunGroup("chunk-opaque", native: false, blend: true, cull: false, motion: true); + byte[][] stated = session.RunGroup("chunk-opaque", native: false, blend: true, cull: false, motion: true); byte[][] native = session.RunGroup("chunk-opaque", native: true, blend: true, cull: false, motion: true); - output.WriteLine("motion centre emulated " + Centre(emulated[2]) + " native " + Centre(native[2])); - Assert.Equal(emulated[0], native[0]); - Assert.Equal(emulated[1], native[1]); - Assert.Equal(emulated[2], native[2]); + output.WriteLine("motion centre stated " + Centre(stated[2]) + " native " + Centre(native[2])); + Assert.Equal(stated[0], native[0]); + Assert.Equal(stated[1], native[1]); + Assert.Equal(stated[2], native[2]); GpuTest.AssertClean(session.Seam); } @@ -126,14 +126,14 @@ public void TheMotionOnlyGroupWritesTheMotionAttachmentAndNothingElse() { using Session session = Open(motion: true); - byte[][] emulated = session.RunGroup("chunk-liquid-motion", native: false, blend: false, cull: false, + byte[][] stated = session.RunGroup("chunk-liquid-motion", native: false, blend: false, cull: false, motion: true, motionOnly: true); byte[][] native = session.RunGroup("chunk-liquid-motion", native: true, blend: false, cull: false, motion: true, motionOnly: true); - Assert.Equal(emulated[0], native[0]); - Assert.Equal(emulated[1], native[1]); - Assert.Equal(emulated[2], native[2]); + Assert.Equal(stated[0], native[0]); + Assert.Equal(stated[1], native[1]); + Assert.Equal(stated[2], native[2]); // And the mask really is a mask: the shaded slots still hold the clear. Assert.True(IsClear(native[0], Session.SceneClear), "the motion-only group wrote the scene attachment"); @@ -151,16 +151,16 @@ public void TheShadowCascadeMatchesBetweenTheRoutes() { using Session session = Open(motion: false); - byte[] emulated = session.RunShadowGroup(native: false); + byte[] stated = session.RunShadowGroup(native: false); byte[] native = session.RunShadowGroup(native: true); - Assert.Equal(emulated, native); + Assert.Equal(stated, native); GpuTest.AssertClean(session.Seam); } /// /// The group's pipelines are built once and kept: a frame of terrain does not rebuild a - /// pipeline per pool, which is what the emulated per-draw key resolve used to do. + /// pipeline per pool, which is what the stated per-draw key resolve used to do. /// [SkippableFact] public void TheGroupBuildsItsPipelinesOnceAndKeepsThem() @@ -244,16 +244,6 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); @@ -331,7 +321,7 @@ public byte[][] RunGroup(string pass, bool native, bool blend, bool cull, SetProgramUniforms(seam, opaque.ProgramId); // What BeginMotionWrite / BeginMotionOnlyWrite do once their guards pass: the window - // flag, the draw-buffer set the emulated route needs, and replace blending on the + // flag, the draw-buffer set the stated route needs, and replace blending on the // motion attachment. The native pass reads the flag and states the rest itself. SetMotionWriteActive(motion); if (motion) @@ -434,7 +424,7 @@ private void SetMotionWriteActive(bool active) /// The uniforms a chunk program needs to draw anything (ChunkTerrainRenderTests: without /// the view distances every fragment fades out and the pass draws nothing), plus the /// textures - bound through the platform, because that is the seam the native route - /// takes its handles from and the emulated route its units. + /// takes its handles from and the stated route its units. /// private void SetProgramUniforms(VulkanDevice seam, int programId) { diff --git a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs index 01135a60..3cedafb4 100644 --- a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs @@ -49,8 +49,7 @@ public EntityPlatform() : base(null!) /// /// Primary as it is without the SSAO G-buffer (scene, glow, motion): the native entity draw - /// puts the same pixels on all three attachments as the seam's neutral body, and records no - /// emulation inside its pass. + /// puts the same pixels on all three attachments as the seam's neutral body, and records nothing else. /// [SkippableTheory] [InlineData(false, true)] @@ -61,20 +60,18 @@ public unsafe void TheNativeEntityDrawMatchesTheSeamsNeutralBody(bool gbuffer, b { using Session session = Open(gbuffer); - byte[][] emulated = session.RunFrame(native: false, motionOpen); + byte[][] stated = session.RunFrame(native: false, motionOpen); long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, motionOpen); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - for (int slot = 0; slot < emulated.Length; slot++) + for (int slot = 0; slot < stated.Length; slot++) { - output.WriteLine("slot " + slot + " emulated " + Centre(emulated[slot]) + + output.WriteLine("slot " + slot + " stated " + Centre(stated[slot]) + " native " + Centre(native[slot])); - Assert.Equal(emulated[slot], native[slot]); + Assert.Equal(stated[slot], native[slot]); } // An identity comparison of two blank attachments proves nothing: the shape has to have // reached the scene slot. @@ -111,27 +108,27 @@ public unsafe void TheMotionAttachmentIsIdenticalBetweenTheRoutesAndUntouchedWit { using Session session = Open(gbuffer: false); - byte[] emulatedOpen = session.RunFrame(native: false, motionOpen: true)[Session.MotionSlot]; + byte[] statedOpen = session.RunFrame(native: false, motionOpen: true)[Session.MotionSlot]; byte[] nativeOpen = session.RunFrame(native: true, motionOpen: true)[Session.MotionSlot]; - Assert.Equal(emulatedOpen, nativeOpen); + Assert.Equal(statedOpen, nativeOpen); // With the window shut the attachment is out of the draw-buffer set on the old route and // masked out of the pipeline on the native one, so both leave the frame's clear standing. // That is rule 9 in its narrowest form: an attachment nothing writes must not pick up // whatever Vulkan would otherwise leave in it. - byte[] emulatedShut = session.RunFrame(native: false, motionOpen: false)[Session.MotionSlot]; + byte[] statedShut = session.RunFrame(native: false, motionOpen: false)[Session.MotionSlot]; byte[] nativeShut = session.RunFrame(native: true, motionOpen: false)[Session.MotionSlot]; - Assert.Equal(emulatedShut, nativeShut); + Assert.Equal(statedShut, nativeShut); Assert.Equal(session.ClearOf(Session.MotionSlot), Centre(nativeShut)); GpuTest.AssertClean(session.Seam); } /// - /// The seam's neutral body draws through the emulation layer and the native route does not: + /// The seam's neutral body draws through the generic stated route and the native route does not: /// the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. /// [SkippableFact] - public unsafe void TheNeutralBodyDrawsThroughTheEmulationLayerAndTheNativeRouteDoesNot() + public unsafe void TheNeutralBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot() { using Session session = Open(gbuffer: false); @@ -139,9 +136,7 @@ public unsafe void TheNeutralBodyDrawsThroughTheEmulationLayerAndTheNativeRouteD session.RunFrame(native: false, motionOpen: true); Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; session.RunFrame(native: true, motionOpen: true); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); GpuTest.AssertClean(session.Seam); } @@ -188,13 +183,13 @@ public unsafe void TheAnimationBlockIsReadTheSameWayByBothRoutes() session.PoseJoint(shiftX: 0.5f); // Warm-up: the native pipeline compiles in the background and its first draws are skipped - // until it is published, exactly as on the emulated path. + // until it is published, exactly as on the stated route. session.RunFrame(native: true, motionOpen: true); byte[] posed = session.RunFrame(native: true, motionOpen: true)[0]; - byte[] posedEmulated = session.RunFrame(native: false, motionOpen: true)[0]; + byte[] posedStated = session.RunFrame(native: false, motionOpen: true)[0]; Assert.NotEqual(session.ClearOf(0), Centre(posed)); - Assert.Equal(posed, posedEmulated); + Assert.Equal(posed, posedStated); GpuTest.AssertClean(session.Seam); } @@ -281,16 +276,6 @@ public string ClearOf(int slot) CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); @@ -426,7 +411,7 @@ public unsafe byte[][] RunFrame(bool native, bool motionOpen) entity.Uniform("renderColor", 1f, 1f, 1f, 1f); entity.Uniform("alphaTest", 0.001f); // The client's own sampler declaration: both routes see the same texture, the - // emulated one through the unit and the native one through the declared name. + // stated one through the unit and the native one through the declared name. Platform.BindProgramTexture2D(entity, "entityTex", atlas, 0); MotionWriteActive.SetValue(Platform, motionOpen); diff --git a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs index 796813c2..bf7d7013 100644 --- a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs @@ -51,7 +51,7 @@ public GuiPlatform() : base(null!) /// /// The native texture-blit pass draws what the seam's neutral body draws, with blending on /// (the alphaTest >= 0 case, which is every Cairo bake) and with it off: one declared pass, - /// one native mesh draw, no emulation inside it, and the same pixels. + /// one native mesh draw, and the same pixels. /// [SkippableTheory] [InlineData(true)] @@ -60,19 +60,17 @@ public unsafe void TheNativeTextureBlitMatchesTheSeamsNeutralBody(bool blend) { using Session session = Open(); - byte[] emulated = session.RunTextureQuad(native: false, blend); + byte[] stated = session.RunTextureQuad(native: false, blend); long passesBefore = session.Seam.NativePassesForTests; long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] native = session.RunTextureQuad(native: true, blend); Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - output.WriteLine("blit centre emulated " + Centre(emulated) + " native " + Centre(native)); - Assert.Equal(emulated, native); + output.WriteLine("blit centre stated " + Centre(stated) + " native " + Centre(native)); + Assert.Equal(stated, native); GpuTest.AssertClean(session.Seam); } @@ -89,40 +87,36 @@ public unsafe void TheNativeLineOverlayMatchesTheSeamsNeutralBody(float lineWidt { using Session session = Open(); - byte[] emulated = session.RunOverlayLines(native: false, lineWidth); + byte[] stated = session.RunOverlayLines(native: false, lineWidth); long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] native = session.RunOverlayLines(native: true, lineWidth); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - output.WriteLine("line row emulated " + Row(emulated) + " native " + Row(native)); - Assert.Equal(emulated, native); + output.WriteLine("line row stated " + Row(stated) + " native " + Row(native)); + Assert.Equal(stated, native); GpuTest.AssertClean(session.Seam); } /// - /// The seams' neutral bodies draw through the emulation layer and the native route does + /// The seams' neutral bodies draw through the generic stated route and the native route does /// not: the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. /// [SkippableFact] - public unsafe void TheNeutralBodiesDrawThroughTheEmulationLayerAndTheNativeRouteDoesNot() + public unsafe void TheNeutralBodiesDrawThroughTheStatedRouteAndTheNativeRouteDoesNot() { using Session session = Open(); long nativeDrawsBefore = session.Seam.NativeDrawsForTests; - long emulatedBefore = session.Seam.EmulationCallsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; session.RunTextureQuad(native: false, blend: true); session.RunOverlayLines(native: false, 1.0f); Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); - Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; session.RunTextureQuad(native: true, blend: true); session.RunOverlayLines(native: true, 1.0f); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); GpuTest.AssertClean(session.Seam); } @@ -179,29 +173,27 @@ public unsafe void EveryFreshTextureResolvesIntoTheFrameArenaAndBuildsNoNewPipel /// /// An atlas composition samples the texture it writes (BlendedTextureManager copies one atlas /// region into another region of the same atlas). The native pass takes the same pooled - /// ReadSelf copy the emulated route takes, instead of refusing the draw: two native mesh - /// draws, no emulation, the same pixels, validation clean. + /// ReadSelf copy the stated route takes, instead of refusing the draw: two native mesh + /// draws, the same pixels, validation clean. /// [SkippableFact] public unsafe void TheNativeTextureBlitReadsItsOwnTargetThroughACopy() { using Session session = Open(); - byte[] emulated = session.RunSelfBlit(native: false); + byte[] stated = session.RunSelfBlit(native: false); long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] native = session.RunSelfBlit(native: true); Assert.Equal(2, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - output.WriteLine("self blit row emulated " + Row(emulated) + " native " + Row(native)); - Assert.Equal(emulated, native); + output.WriteLine("self blit row stated " + Row(stated) + " native " + Row(native)); + Assert.Equal(stated, native); // The right half is the copied left half, not the clear colour. int left = (Size / 2 * Size + 1) * 4; int right = (Size / 2 * Size + Size / 2 + 1) * 4; - Assert.Equal(emulated[left + 1], emulated[right + 1]); + Assert.Equal(stated[left + 1], stated[right + 1]); GpuTest.AssertClean(session.Seam); } @@ -272,16 +264,6 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); @@ -322,7 +304,7 @@ private sealed class Session : IDisposable } /// - /// The units the client's program setters bind: what the emulated route resolves its + /// The units the client's program setters bind: what the stated route resolves its /// samplers through. The native route passes the handles instead. /// private static void BindSamplerUnits(VulkanDevice seam, ShaderProgramBase program, int texture) diff --git a/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs index 70fc2a40..dfcecbb1 100644 --- a/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs @@ -17,10 +17,10 @@ namespace Optimum.Render.Vulkan.Tests; /// tesselated face. /// /// Stage 1 recorded fullscreen draws only. These pin the four facts a world system depends on: -/// a native mesh draw puts the same pixels on the target as the emulated draw of the same mesh +/// a native mesh draw puts the same pixels on the target as the stated draw of the same mesh /// with the same state; a mesh pipeline is never the fullscreen pipeline of the same program; /// a native multi-draw takes its own region of the per-slot indirect ring, the same ring the -/// emulated multi-draw allocates from; and the stats count mesh, instanced and indirect draws +/// stated multi-draw allocates from; and the stats count mesh, instanced and indirect draws /// apart from fullscreen ones. /// public class NativeMeshDrawTests(ITestOutputHelper output) @@ -31,29 +31,27 @@ public class NativeMeshDrawTests(ITestOutputHelper output) // ------------------------------------------------------------------- the tests /// - /// The same face, drawn twice into the same target: once through the emulated - /// (the route every vanilla system still takes) and - /// once through . Both paths run the same shader + /// The same face, drawn twice into the same target: once through the tests' GL-shaped + /// DrawMesh (: the platform's generic stated draw, the route + /// every draw without a dedicated one takes) and once through . Both paths run the same shader /// over the same vertices with the same fixed state, so the pixels are bitwise equal. /// [SkippableFact] - public unsafe void ANativeMeshDrawMatchesTheEmulatedDrawOfTheSameMesh() + public unsafe void ANativeMeshDrawMatchesTheStatedDrawOfTheSameMesh() { using Session session = Open(); - byte[] emulated = session.RunEmulatedFrame(); + byte[] stated = session.RunStatedFrame(); long meshDrawsBefore = session.Device.NativeMeshDrawsForTests; long fullscreenBefore = session.Device.NativeFullscreenDrawsForTests; - long insideBefore = session.Device.EmulationCallsInNativePassesForTests; byte[] native = session.RunNativeFrame(); Assert.Equal(1, session.Device.NativeMeshDrawsForTests - meshDrawsBefore); Assert.Equal(0, session.Device.NativeFullscreenDrawsForTests - fullscreenBefore); - Assert.Equal(0, session.Device.EmulationCallsInNativePassesForTests - insideBefore); - output.WriteLine("emulated centre: " + Centre(emulated) + " native centre: " + Centre(native)); - Assert.Equal(emulated, native); + output.WriteLine("stated centre: " + Centre(stated) + " native centre: " + Centre(native)); + Assert.Equal(stated, native); GpuTest.AssertClean(session.Device); } @@ -138,7 +136,7 @@ public void APipelineCannotBothSampleAndWriteTheBoundDepth() /// /// Two native multi-draws in one frame take two regions of the slot's indirect buffer, as - /// the emulated path does: writing both at offset zero was the Phase 1B bug where every + /// the stated route does: writing both at offset zero was the Phase 1B bug where every /// multi-draw in a frame executed with the ranges of whichever was recorded last. /// [SkippableFact] @@ -382,8 +380,8 @@ public void RunFrame(Action body) Device.Present(); } - /// The emulated route: the GL-shaped state, then DrawMesh. - public unsafe byte[] RunEmulatedFrame() + /// The stated route: the GL-shaped state, then DrawMesh. + public unsafe byte[] RunStatedFrame() { byte[] pixels = new byte[Size * Size * 4]; RunFrame(() => diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs index b403dd08..19e3827a 100644 --- a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -29,7 +29,7 @@ namespace Optimum.Render.Vulkan.Tests; /// window, into the shaded image, the glow attachment and the motion attachment; /// 2. sky motion writes the same motion attachment as the OpenGL body and leaves the shaded /// image alone; -/// 3. neither native pass reaches the GL-emulation layer while its pass is open; +/// 3. each native pass records only its own draws (structural since the GL emulation went); /// 4. over several frames the chain runs its steps in the declared order and the TAA resolve /// keeps accumulating - the history parity alternates and the motion attachment the resolve /// reads was written by the two passes that run before it; @@ -125,20 +125,18 @@ public void TheOitMergeMatchesTheOpenGlBodyWithTheMotionWindowOpen() using Session session = Open(); session.EnableTaa(jitterActive: true); - Frame emulated = RunMerge(session, native: false); + Frame stated = RunMerge(session, native: false); long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; Frame nativeRoute = RunMerge(session, native: true); Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated.Scene, nativeRoute.Scene); - Assert.Equal(emulated.Glow, nativeRoute.Glow); - Assert.Equal(emulated.Motion, nativeRoute.Motion); + Assert.Equal(stated.Scene, nativeRoute.Scene); + Assert.Equal(stated.Glow, nativeRoute.Glow); + Assert.Equal(stated.Motion, nativeRoute.Motion); // The merge really did add into the reactive channel, or the comparison above would // pass on two routes that both wrote nothing. @@ -158,12 +156,12 @@ public void TheOitMergeMatchesTheOpenGlBodyWithoutTheMotionWindow() using Session session = Open(); session.EnableTaa(jitterActive: false); - Frame emulated = RunMerge(session, native: false); + Frame stated = RunMerge(session, native: false); Frame nativeRoute = RunMerge(session, native: true); - Assert.Equal(emulated.Scene, nativeRoute.Scene); - Assert.Equal(emulated.Glow, nativeRoute.Glow); - Assert.Equal(emulated.Motion, nativeRoute.Motion); + Assert.Equal(stated.Scene, nativeRoute.Scene); + Assert.Equal(stated.Glow, nativeRoute.Glow); + Assert.Equal(stated.Motion, nativeRoute.Motion); Assert.Equal(session.MotionSeed, nativeRoute.Motion); GpuTest.AssertClean(session.Seam); @@ -179,19 +177,17 @@ public void SkyMotionMatchesTheOpenGlBody() using Session session = Open(); session.EnableTaa(jitterActive: true); - Frame emulated = RunSkyMotion(session, native: false); + Frame stated = RunSkyMotion(session, native: false); long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; Frame nativeRoute = RunSkyMotion(session, native: true); Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated.Motion, nativeRoute.Motion); - Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(stated.Motion, nativeRoute.Motion); + Assert.Equal(stated.Scene, nativeRoute.Scene); Assert.Equal(session.SceneSeed, nativeRoute.Scene); // The pass covered the sky, or "the two routes agree" would be vacuous. @@ -201,22 +197,20 @@ public void SkyMotionMatchesTheOpenGlBody() GpuTest.AssertClean(session.Seam); } - /// The OpenGL body on this device is the emulation layer; the native chain is not. + /// The OpenGL body on this device is the generic stated route; the native chain is not. [SkippableFact] - public void TheOpenGlRouteDrawsThroughTheEmulationLayerAndTheNativeChainDoesNot() + public void TheOpenGlRouteDrawsThroughTheStatedRouteAndTheNativeChainDoesNot() { using Session session = Open(); session.EnableTaa(jitterActive: true); long nativeDrawsBefore = session.Seam.NativeDrawsForTests; - long emulatedBefore = session.Seam.EmulationCallsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; RunMerge(session, native: false); Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); - Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; RunMerge(session, native: true); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); GpuTest.AssertClean(session.Seam); } @@ -311,20 +305,18 @@ public void TheTaaResolveMatchesTheOpenGlBody(bool warmHistory) session.EnableTaa(jitterActive: true); session.PatternedScene = true; - Resolved emulated = RunResolve(session, native: false, warmHistory); + Resolved stated = RunResolve(session, native: false, warmHistory); long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; Resolved nativeRoute = RunResolve(session, native: true, warmHistory); Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated.Color, nativeRoute.Color); - Assert.Equal(emulated.Glow, nativeRoute.Glow); - Assert.Equal(emulated.Depth, nativeRoute.Depth); + Assert.Equal(stated.Color, nativeRoute.Color); + Assert.Equal(stated.Glow, nativeRoute.Glow); + Assert.Equal(stated.Depth, nativeRoute.Depth); // The pass wrote a resolved image over the seed, or the comparison above would hold // for two routes that both wrote nothing. @@ -376,21 +368,19 @@ public void TheTaaSharpenMatchesTheOpenGlBody(float sharpness) session.PatternedScene = true; OptimumConfig.TaaSharpness = sharpness; - byte[] emulated = RunSharpen(session, native: false, out int emulatedTexture); + byte[] stated = RunSharpen(session, native: false, out int statedTexture); long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] nativeRoute = RunSharpen(session, native: true, out int nativeTexture); // One native pass for the resolve that has to run first, one for the sharpen. Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(session.Sharpen.ColorTextureIds[0], emulatedTexture); + Assert.Equal(session.Sharpen.ColorTextureIds[0], statedTexture); Assert.Equal(session.Sharpen.ColorTextureIds[0], nativeTexture); - Assert.Equal(emulated, nativeRoute); + Assert.Equal(stated, nativeRoute); Assert.NotEqual(session.SharpenSeed, nativeRoute); GpuTest.AssertClean(session.Seam); @@ -451,8 +441,8 @@ public void TheNativeResolveKeepsAccumulatingHistoryAcrossFrames() byte[] fiveFrames = RunResolveFrames(session, native: true, frames: 5, startPhase: 0); Assert.NotEqual(lastFrameAlone, fiveFrames); - byte[] emulatedFive = RunResolveFrames(session, native: false, frames: 5, startPhase: 0); - Assert.Equal(emulatedFive, fiveFrames); + byte[] statedFive = RunResolveFrames(session, native: false, frames: 5, startPhase: 0); + Assert.Equal(statedFive, fiveFrames); GpuTest.AssertClean(session.Seam); } @@ -504,28 +494,26 @@ public void TheChainTailMatchesTheOpenGlBodyAcrossThePostSettings(string name, b session.ApplyPostSettings(bloom, godRays, fxaa, ssao, ssaa, clientSize); int aoTexture = gtao ? session.SsaoBlurTexture : 0; - TailFrame emulated = RunTail(session, native: false, aoInScene: gtao, aoTexture: aoTexture); + TailFrame stated = RunTail(session, native: false, aoInScene: gtao, aoTexture: aoTexture); long passesBefore = session.Seam.NativePassesForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; long copiesBefore = session.Seam.ReadSelfCopiesForTests.Created; TailFrame nativeRoute = RunTail(session, native: true, aoInScene: gtao, aoTexture: aoTexture); // The Luma step and the final composition always draw; bloom adds five passes, god rays // one, and TAA one more for the resolve (the sharpen declares no pass of its own). No - // native pass reached the emulation layer, and the composition's self-read took no + // native pass reached the generic stated route, and the composition's self-read took no // feedback copy - the declared attachment subset is what makes it safe. long expectedPasses = 2 + (bloom ? 5 : 0) + (godRays ? 1 : 0) + (taa ? 1 : 0); Assert.Equal(expectedPasses, session.Seam.NativePassesForTests - passesBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); Assert.Equal(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created); - Assert.Equal(emulated.FindBright, nativeRoute.FindBright); - Assert.Equal(emulated.BloomLow, nativeRoute.BloomLow); - Assert.Equal(emulated.GodRays, nativeRoute.GodRays); - Assert.Equal(emulated.Luma, nativeRoute.Luma); - Assert.Equal(emulated.Final, nativeRoute.Final); - Assert.Equal(emulated.Motion, nativeRoute.Motion); + Assert.Equal(stated.FindBright, nativeRoute.FindBright); + Assert.Equal(stated.BloomLow, nativeRoute.BloomLow); + Assert.Equal(stated.GodRays, nativeRoute.GodRays); + Assert.Equal(stated.Luma, nativeRoute.Luma); + Assert.Equal(stated.Final, nativeRoute.Final); + Assert.Equal(stated.Motion, nativeRoute.Motion); // The composition really wrote something, or "the two routes agree" would be vacuous. Assert.NotEqual(session.SceneSeed, nativeRoute.Final); @@ -555,7 +543,6 @@ public void TheFinalCompositionReadsPrimaryColourOneWithoutAFeedbackCopy() long copiesBefore = session.Seam.ReadSelfCopiesForTests.Created; long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; platform.BeginFrame(); session.SeedFrame(); @@ -570,7 +557,6 @@ public void TheFinalCompositionReadsPrimaryColourOneWithoutAFeedbackCopy() Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); Assert.Equal(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created); Assert.NotEqual(session.SceneSeed, scene); Assert.Equal(session.GlowSeed, glow); @@ -865,16 +851,6 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs index b48e5bac..16bb78aa 100644 --- a/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs @@ -45,48 +45,44 @@ public SkyPlatform() : base(null!) /// /// The native sky pass draws what the seam's neutral body draws: one declared pass, one - /// native mesh draw, no emulation inside it, and the same scene and glow pixels. + /// native mesh draw, and the same scene and glow pixels. /// [SkippableFact] public unsafe void TheNativeSkyPassMatchesTheSeamsNeutralBody() { using Session session = Open(); - (byte[] emulatedScene, byte[] emulatedGlow) = session.RunFrame(native: false); + (byte[] statedScene, byte[] statedGlow) = session.RunFrame(native: false); long passesBefore = session.Seam.NativePassesForTests; long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; (byte[] nativeScene, byte[] nativeGlow) = session.RunFrame(native: true); Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - output.WriteLine("scene centre emulated " + Centre(emulatedScene) + " native " + Centre(nativeScene)); - Assert.Equal(emulatedScene, nativeScene); - Assert.Equal(emulatedGlow, nativeGlow); + output.WriteLine("scene centre stated " + Centre(statedScene) + " native " + Centre(nativeScene)); + Assert.Equal(statedScene, nativeScene); + Assert.Equal(statedGlow, nativeGlow); GpuTest.AssertClean(session.Seam); } /// - /// The seam's neutral body draws through the emulation layer and the native route does not: + /// The seam's neutral body draws through the generic stated route and the native route does not: /// the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. /// [SkippableFact] - public unsafe void TheNeutralBodyDrawsThroughTheEmulationLayerAndTheNativeRouteDoesNot() + public unsafe void TheNeutralBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot() { using Session session = Open(); long nativeDrawsBefore = session.Seam.NativeDrawsForTests; - long emulatedBefore = session.Seam.EmulationCallsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; session.RunFrame(native: false); Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); - Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; session.RunFrame(native: true); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); GpuTest.AssertClean(session.Seam); } @@ -164,16 +160,6 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); @@ -202,7 +188,7 @@ private sealed class Session : IDisposable session.GlowTexture = Gradient(seam, 1); foreach (string name in seam.SamplerNamesOf(program.ProgramId)) { - // The units the client's ShaderProgramSky setters bind: what the emulated route + // The units the client's ShaderProgramSky setters bind: what the stated route // resolves its samplers through. The native route passes the handles instead. int unit = program.uniformLocations.Count + seam.SamplerNamesOf(program.ProgramId).IndexOf(name); seam.SetSamplerUnit(program.ProgramId, name, unit); diff --git a/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs b/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs index d8386dd8..6d1ccbc8 100644 --- a/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs @@ -55,11 +55,10 @@ public void TheVanillaSsaoStepMatchesTheOpenGlBody(int quality) using Session session = Open(quality == 1 ? "ssao-only" : "taa-with-ssao", taa: quality != 1); session.SsaoQuality = quality; - Frame emulated = session.Run(native: false); + Frame stated = session.Run(native: false); long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; Frame nativeRoute = session.Run(native: true); // The raw pass, one blur half-iteration per pass, and the composite when TAA runs. @@ -67,12 +66,11 @@ public void TheVanillaSsaoStepMatchesTheOpenGlBody(int quality) int expected = 1 + blurPasses + (quality != 1 ? 1 : 0); Assert.Equal(expected, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(expected, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated.Raw, nativeRoute.Raw); - Assert.Equal(emulated.Blurred, nativeRoute.Blurred); - Assert.Equal(emulated.Scene, nativeRoute.Scene); - Assert.Equal(emulated.SsaoInScene, nativeRoute.SsaoInScene); + Assert.Equal(stated.Raw, nativeRoute.Raw); + Assert.Equal(stated.Blurred, nativeRoute.Blurred); + Assert.Equal(stated.Scene, nativeRoute.Scene); + Assert.Equal(stated.SsaoInScene, nativeRoute.SsaoInScene); // The step really did something, or the comparison above would pass on two routes that // both wrote nothing. @@ -92,15 +90,15 @@ public void TheVanillaSsaoStepSkipsTheCompositeWithTaaOff() { using Session session = Open("ssao-only", taa: false); - Frame emulated = session.Run(native: false); + Frame stated = session.Run(native: false); Frame nativeRoute = session.Run(native: true); - Assert.Equal(emulated.Raw, nativeRoute.Raw); - Assert.Equal(emulated.Blurred, nativeRoute.Blurred); - Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(stated.Raw, nativeRoute.Raw); + Assert.Equal(stated.Blurred, nativeRoute.Blurred); + Assert.Equal(stated.Scene, nativeRoute.Scene); Assert.Equal(session.SceneSeed, nativeRoute.Scene); Assert.False(nativeRoute.SsaoInScene); - Assert.False(emulated.SsaoInScene); + Assert.False(stated.SsaoInScene); GpuTest.AssertClean(session.Seam); } @@ -119,21 +117,21 @@ public void TheVanillaSsaoStepMatchesTheOpenGlBodyBelowRenderScaleOne() using Session session = Open("taa-with-ssao", taa: true); session.SsaaLevel = 0.5f; - Frame emulatedHalf = session.Run(native: false); + Frame statedHalf = session.Run(native: false); Frame nativeHalf = session.Run(native: true); - Assert.Equal(emulatedHalf.Raw, nativeHalf.Raw); - Assert.Equal(emulatedHalf.Blurred, nativeHalf.Blurred); - Assert.Equal(emulatedHalf.Scene, nativeHalf.Scene); + Assert.Equal(statedHalf.Raw, nativeHalf.Raw); + Assert.Equal(statedHalf.Blurred, nativeHalf.Blurred); + Assert.Equal(statedHalf.Scene, nativeHalf.Scene); session.SsaaLevel = 0.75f; - Frame emulated = session.Run(native: false); + Frame stated = session.Run(native: false); Frame nativeRoute = session.Run(native: true); - Assert.Equal(emulated.Raw, nativeRoute.Raw); - Assert.Equal(emulated.Blurred, nativeRoute.Blurred); - Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(stated.Raw, nativeRoute.Raw); + Assert.Equal(stated.Blurred, nativeRoute.Blurred); + Assert.Equal(stated.Scene, nativeRoute.Scene); Assert.NotEqual(nativeHalf.Raw, nativeRoute.Raw); - Assert.NotEqual(emulatedHalf.Raw, emulated.Raw); + Assert.NotEqual(statedHalf.Raw, stated.Raw); GpuTest.AssertClean(session.Seam); } @@ -149,13 +147,13 @@ public void TheAoStepDoesNothingWhenAmbientOcclusionIsOff() using Session session = Open("ssao-only", taa: true); session.RenderSsao = false; - Frame emulated = session.Run(native: false); + Frame stated = session.Run(native: false); long passesBefore = session.Seam.NativePassesForTests; Frame nativeRoute = session.Run(native: true); Assert.Equal(0, session.Seam.NativePassesForTests - passesBefore); - Assert.Equal(emulated.Raw, nativeRoute.Raw); + Assert.Equal(stated.Raw, nativeRoute.Raw); Assert.Equal(session.RawSeed, nativeRoute.Raw); Assert.Equal(session.BlurredSeed, nativeRoute.Blurred); Assert.Equal(session.SceneSeed, nativeRoute.Scene); @@ -177,19 +175,17 @@ public void TheGtaoCompositeMatchesTheOpenGlBody() using Session session = Open("taa-with-gtao", taa: true, gtao: true); session.AmbientOcclusionTexture = session.GtaoVisibility; - Frame emulated = session.Run(native: false); + Frame stated = session.Run(native: false); long passesBefore = session.Seam.NativePassesForTests; long drawsBefore = session.Seam.NativeDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; Frame nativeRoute = session.Run(native: true); // The composite alone: vanilla SSAO and its blur stood down. Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - Assert.Equal(emulated.Scene, nativeRoute.Scene); + Assert.Equal(stated.Scene, nativeRoute.Scene); Assert.True(nativeRoute.SsaoInScene); Assert.Equal(session.RawSeed, nativeRoute.Raw); Assert.Equal(session.BlurredSeed, nativeRoute.Blurred); @@ -210,10 +206,10 @@ public void TheNativeAoStepLeavesTheGlShapedStateWhereTheBodyLeavesIt() using Session session = Open("taa-with-ssao", taa: true); session.Run(native: false); - (int Width, int Height) emulated = session.Viewport; + (int Width, int Height) stated = session.Viewport; session.Run(native: true); - Assert.Equal(emulated, session.Viewport); + Assert.Equal(stated, session.Viewport); Assert.Equal((Size, Size), session.Viewport); GpuTest.AssertClean(session.Seam); @@ -402,7 +398,7 @@ public Frame Run(bool native) Platform.BeginFrame(); SeedFrame(); Platform.RunPostStepAmbientOcclusionForTests(projection); - Viewport = ((int)Seam.NativeCurrentViewport.Extent.Width, (int)Seam.NativeCurrentViewport.Extent.Height); + Viewport = ((int)Platform.stated.Viewport.Extent.Width, (int)Platform.stated.Viewport.Extent.Height); var frame = new Frame( Decode(ssao.ColorTextureIds[0]), Decode(blurVertical.ColorTextureIds[0]), diff --git a/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs b/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs index ba1863fb..7f2b80de 100644 --- a/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Reflection; +using Optimum.Render.Vulkan.Core; using Optimum.Render.Vulkan.Platform; using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; using Vintagestory.API.Client; using Vintagestory.API.MathTools; using Vintagestory.Client; @@ -17,10 +19,11 @@ namespace Optimum.Render.Vulkan.Tests; /// -/// The generic native draw (VulkanClientPlatform.NativeStated.cs) against the emulated draw it -/// replaces, on one device: the same mesh, the same program, and fixed state set only through the -/// platform's own virtuals - which record it for the generic route and still push it into the -/// device for the emulated one. Identical pixels mean the record states what the device tracked. +/// The generic native draw (VulkanClientPlatform.NativeStated.cs, Platform/StatedDraw.cs) against a +/// native draw whose pipeline and pass are written out by hand, on one device: the same mesh, the +/// same program. The generic route gets its fixed state only through the platform's own virtuals; +/// the reference states what OpenGL would do with those calls. Identical pixels mean the record +/// states what the client said. /// /// The program is a gui program that is not the registered ShaderPrograms.Gui, so no dedicated /// route takes the draw: it is exactly the shape of a mod renderer's draw. @@ -42,8 +45,7 @@ public enum Mask { All, RedGreen } /// /// Blend off, three blend modes, a colour mask and a scissor rectangle: the generic route - /// draws what the emulated route draws, records one native draw, and runs no emulated call - /// inside its pass. + /// draws what the hand-stated reference draws, and records one native draw. /// [SkippableTheory] [InlineData(false, EnumBlendMode.Standard, Mask.All, false)] @@ -52,42 +54,63 @@ public enum Mask { All, RedGreen } [InlineData(true, EnumBlendMode.Brighten, Mask.All, false)] [InlineData(true, EnumBlendMode.Standard, Mask.RedGreen, false)] [InlineData(true, EnumBlendMode.Standard, Mask.All, true)] - public unsafe void TheStatedRouteDrawsWhatTheEmulatedRouteDraws(bool blend, EnumBlendMode mode, Mask mask, bool scissor) + public unsafe void TheStatedRouteDrawsWhatTheHandStatedReferenceDraws(bool blend, EnumBlendMode mode, Mask mask, bool scissor) { using Session session = Open(); - byte[] emulated = session.Run(stated: false, blend, mode, mask, scissor); + byte[] reference = session.Run(stated: false, blend, mode, mask, scissor); long statedBefore = session.Platform.StatedDrawsForTests; - long insideBefore = session.Seam.EmulationCallsInNativePassesForTests; byte[] native = session.Run(stated: true, blend, mode, mask, scissor); Assert.Equal(1, session.Platform.StatedDrawsForTests - statedBefore); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - insideBefore); - output.WriteLine("centre emulated " + Centre(emulated, 0) + " stated " + Centre(native, 0)); - Assert.Equal(emulated, native); + output.WriteLine("centre reference " + Centre(reference, 0) + " stated " + Centre(native, 0)); + Assert.Equal(reference, native); + Assert.NotEqual("0,51,102,153", Centre(native, 0)); GpuTest.AssertClean(session.Seam); } /// /// A target with two colour attachments and only the first selected as a draw buffer: the - /// second keeps its clear colour on both routes - the stated draw buffers are write masks. + /// second keeps its clear colour - the stated draw buffers are write masks - and the first + /// matches the reference, which writes the first slot only. /// [SkippableFact] public unsafe void AnUnselectedDrawBufferKeepsItsContentsOnBothRoutes() { using Session session = Open(); - (byte[] firstEmulated, byte[] secondEmulated) = session.RunTwoTargets(stated: false); + (byte[] firstReference, byte[] secondReference) = session.RunTwoTargets(stated: false); (byte[] firstStated, byte[] secondStated) = session.RunTwoTargets(stated: true); - Assert.Equal(firstEmulated, firstStated); - Assert.Equal(secondEmulated, secondStated); + Assert.Equal(firstReference, firstStated); + Assert.Equal(secondReference, secondStated); // The second attachment is still the clear colour (0, 51, 102, 153). Assert.Equal("0,51,102,153", Centre(secondStated, 0)); GpuTest.AssertClean(session.Seam); } + /// + /// A platform bind is the latest bind: a fork renderer's raw framebuffer bind before it no + /// longer addresses the generic draws and clears (GL has one binding point). + /// + [Fact] + public void APlatformBindReplacesAForkBind() + { + var platform = new StatedPlatform(); + var target = new FrameBufferRef { FboId = 7, Width = Size, Height = Size, ColorTextureIds = new[] { 1 } }; + + platform.NoteForkFramebuffer(12); + Assert.Equal(12, platform.CurrentTargetId); + + platform.CurrentFrameBuffer = target; + Assert.Equal(7, platform.CurrentTargetId); + + platform.NoteForkFramebuffer(12); + platform.BindCurrentFrameBufferKeepViewport(target); + Assert.Equal(7, platform.CurrentTargetId); + } + private static string Centre(byte[] pixels, int offset) { int i = offset + (Size / 2 * Size + Size / 2) * 4; @@ -177,22 +200,33 @@ public void Dispose() } } - /// One frame: the target bound and cleared, the state set through the platform, one quad. + /// + /// One frame: the target bound and cleared, one quad - through the platform with the state + /// set through its virtuals, or ( false) the hand-stated reference. + /// public unsafe byte[] Run(bool stated, bool blend, EnumBlendMode mode, Mask mask, bool scissor) { - Platform.NativeStatedEnabled = stated; Platform.BeginFrame(); Prepare(target); - Platform.GlToggleBlend(blend, mode); - if (mask == Mask.RedGreen) Platform.GlColorMask(true, true, false, false); - if (scissor) + if (stated) + { + Platform.GlToggleBlend(blend, mode); + if (mask == Mask.RedGreen) Platform.GlColorMask(true, true, false, false); + if (scissor) + { + Platform.GlScissorFlag(true); + Platform.GlScissor(4, 4, 8, 8); + } + Platform.RenderMesh(quad); + } + else { - Platform.GlScissorFlag(true); - Platform.GlScissor(4, 4, 8, 8); + AttachmentBlend attachment = AttachmentBlend.For(blend, mode); + if (mask == Mask.RedGreen) attachment.WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit; + DrawReference(target, attachment, + scissor ? new Rect2D(new Offset2D(4, 4), new Extent2D(8, 8)) : null); } - Platform.RenderMesh(quad); - Platform.GlColorMask(true, true, true, true); Platform.GlScissorFlag(false); byte[] pixels = Read(target.ColorTextureIds[0]); @@ -202,11 +236,17 @@ public unsafe byte[] Run(bool stated, bool blend, EnumBlendMode mode, Mask mask, public unsafe (byte[] First, byte[] Second) RunTwoTargets(bool stated) { - Platform.NativeStatedEnabled = stated; Platform.BeginFrame(); Prepare(twoTargets); - Platform.GlToggleBlend(false); - Platform.RenderMesh(quad); + if (stated) + { + Platform.GlToggleBlend(false); + Platform.RenderMesh(quad); + } + else + { + DrawReference(twoTargets, AttachmentBlend.For(false, EnumBlendMode.Standard), null); + } byte[] first = Read(twoTargets.ColorTextureIds[0]); byte[] second = Read(twoTargets.ColorTextureIds[1]); Platform.EndFrame(); @@ -239,6 +279,42 @@ private void Prepare(FrameBufferRef frameBuffer) Platform.BindProgramTexture2D(gui, "tex2dOverlay", 0, 1); } + /// + /// The quad drawn with everything written out: depth and cull off, slot 0 only, the + /// full-target viewport, the program's two samplers on the gradient and on nothing. + /// + private void DrawReference(FrameBufferRef frameBuffer, AttachmentBlend attachment, Rect2D? scissor) + { + VulkanDevice seam = Seam; + int meshId = ((VAO)quad).VaoId; + NativePipeline? pipeline = seam.RequestNativePipeline(new NativePipelineDescription + { + ProgramId = gui.ProgramId, + Blend = new[] { attachment }, + DepthTest = false, + DepthWrite = false, + Cull = CullModeFlags.None, + Topology = seam.NativeMeshTopology(meshId), + VertexLayoutId = seam.NativeMeshLayoutId(meshId), + Targets = seam.NativeTargetFormats(frameBuffer.FboId, 1u)!, + }, out string error); + Assert.True(pipeline != null, error); + Assert.True(seam.BeginNativePass(new NativePassDescription + { + Name = "Reference", + FramebufferId = frameBuffer.FboId, + ColorSlots = 1u, + Reads = new[] { texture }, + Scissor = scissor, + })); + Assert.True(seam.DrawNativeMesh(pipeline!, meshId, new[] + { + new NativeTexture(pipeline!.Sampler("tex2d"), texture), + new NativeTexture(pipeline.Sampler("tex2dOverlay"), 0), + })); + seam.EndNativePass(); + } + private unsafe byte[] Read(int textureId) { VulkanDevice seam = Seam; diff --git a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs index 11086929..78436fa8 100644 --- a/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs +++ b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs @@ -55,8 +55,7 @@ public WorldPlatform() : base(null!) /// /// The star box's native pass draws what its seam's neutral body draws: one declared pass, - /// one native mesh draw of the cube through the samplerCube the program declares, no - /// emulation inside the pass, and the same pixels on every attachment. + /// one native mesh draw of the cube through the samplerCube the program declares, and the same pixels on every attachment. /// [SkippableFact] public void TheNativeNightSkyPassMatchesTheSeamsNeutralBody() @@ -64,19 +63,17 @@ public void TheNativeNightSkyPassMatchesTheSeamsNeutralBody() using Session session = Open("nightsky"); int cube = session.CubeGradient(); - byte[][] emulated = session.RunFrame(native: false, blending: false, depth: false, motion: false, + byte[][] stated = session.RunFrame(native: false, blending: false, depth: false, motion: false, s => s.Platform.RenderNightSkyBox(s.Mesh, cube)); long passes = session.Seam.NativePassesForTests; long meshes = session.Seam.NativeMeshDrawsForTests; - long inside = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, blending: false, depth: false, motion: false, s => s.Platform.RenderNightSkyBox(s.Mesh, cube)); Assert.Equal(1, session.Seam.NativePassesForTests - passes); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); - AssertSameAttachments(emulated, native, "nightsky"); + AssertSameAttachments(stated, native, "nightsky"); GpuTest.AssertClean(session.Seam); } @@ -95,17 +92,15 @@ public void TheNativeCelestialPassMatchesTheSeamsNeutralBody() int sky = session.Gradient(1); int glow = session.Gradient(2); - byte[][] emulated = session.RunFrame(native: false, blending: true, depth: false, motion: false, + byte[][] stated = session.RunFrame(native: false, blending: true, depth: false, motion: false, s => s.Platform.RenderCelestialQuad(s.Mesh, body, sky, glow)); long meshes = session.Seam.NativeMeshDrawsForTests; - long inside = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, blending: true, depth: false, motion: false, s => s.Platform.RenderCelestialQuad(s.Mesh, body, sky, glow)); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); - AssertSameAttachments(emulated, native, "celestialobject"); + AssertSameAttachments(stated, native, "celestialobject"); GpuTest.AssertClean(session.Seam); } @@ -139,15 +134,13 @@ void Draw(Session s) s.Platform.RenderSunQuad(s.Mesh, sun); } - byte[][] emulated = session.RunFrame(native: false, blending: true, depth: false, motion: false, Draw); + byte[][] stated = session.RunFrame(native: false, blending: true, depth: false, motion: false, Draw); long meshes = session.Seam.NativeMeshDrawsForTests; - long inside = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, blending: true, depth: false, motion: false, Draw); Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); - AssertSameAttachments(emulated, native, "standard"); + AssertSameAttachments(stated, native, "standard"); GpuTest.AssertClean(session.Seam); } @@ -187,17 +180,15 @@ public void TheNativeParticlePassMatchesTheSeamsNeutralBody() { using Session session = Open("particlescube"); - byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: false, + byte[][] stated = session.RunFrame(native: false, blending: true, depth: true, motion: false, s => s.Platform.RenderParticles(s.Mesh, 4, 0)); long instanced = session.Seam.NativeInstancedDrawsForTests; - long inside = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: false, s => s.Platform.RenderParticles(s.Mesh, 4, 0)); Assert.Equal(1, session.Seam.NativeInstancedDrawsForTests - instanced); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); - AssertSameAttachments(emulated, native, "particlescube", mustDraw: false); + AssertSameAttachments(stated, native, "particlescube", mustDraw: false); GpuTest.AssertClean(session.Seam); } @@ -213,13 +204,13 @@ public void TheNativeParticlePassLeavesTheMotionAttachmentIdentical() using Session session = Open("particlescube"); session.OpenMotionWindow(); - byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: true, + byte[][] stated = session.RunFrame(native: false, blending: true, depth: true, motion: true, s => s.Platform.RenderParticles(s.Mesh, 4, 0)); byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: true, s => s.Platform.RenderParticles(s.Mesh, 4, 0)); - Assert.Equal(emulated[MotionSlot], native[MotionSlot]); - AssertSameAttachments(emulated, native, "particlescube (motion window)", mustDraw: false); + Assert.Equal(stated[MotionSlot], native[MotionSlot]); + AssertSameAttachments(stated, native, "particlescube (motion window)", mustDraw: false); GpuTest.AssertClean(session.Seam); } @@ -238,7 +229,7 @@ public void TheNativeDecalPassMatchesTheSeamsNeutralBody() int[] starts = { 0, 0, 3 * 4, 0 }; int[] sizes = { 3, 3 }; - byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: false, + byte[][] stated = session.RunFrame(native: false, blending: true, depth: true, motion: false, s => { // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh @@ -258,7 +249,6 @@ public void TheNativeDecalPassMatchesTheSeamsNeutralBody() }); long indirect = session.Seam.NativeIndirectDrawsForTests; - long inside = session.Seam.EmulationCallsInNativePassesForTests; byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: false, s => { @@ -279,8 +269,7 @@ public void TheNativeDecalPassMatchesTheSeamsNeutralBody() }); Assert.Equal(1, session.Seam.NativeIndirectDrawsForTests - indirect); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); - AssertSameAttachments(emulated, native, "decals"); + AssertSameAttachments(stated, native, "decals"); GpuTest.AssertClean(session.Seam); } @@ -299,7 +288,7 @@ public void TheNativeDecalPassLeavesTheMotionAttachmentIdentical() int[] starts = { 0, 0, 3 * 4, 0 }; int[] sizes = { 3, 3 }; - byte[][] emulated = session.RunFrame(native: false, blending: true, depth: true, motion: true, + byte[][] stated = session.RunFrame(native: false, blending: true, depth: true, motion: true, s => { // The lib's route: the scope opens, vanilla MeshDataPool.Draw's own RenderMesh @@ -336,44 +325,42 @@ public void TheNativeDecalPassLeavesTheMotionAttachmentIdentical() } }); - Assert.Equal(emulated[MotionSlot], native[MotionSlot]); - AssertSameAttachments(emulated, native, "decals (motion window)"); + Assert.Equal(stated[MotionSlot], native[MotionSlot]); + AssertSameAttachments(stated, native, "decals (motion window)"); GpuTest.AssertClean(session.Seam); } // ------------------------------------------------------------------- switch and slots /// - /// The neutral body draws through the emulation layer and the native route does not: the + /// The neutral body draws through the generic stated route and the native route does not: the /// switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes. /// [SkippableFact] - public void TheNeutralBodiesDrawThroughTheEmulationLayerAndTheNativeRouteDoesNot() + public void TheNeutralBodiesDrawThroughTheStatedRouteAndTheNativeRouteDoesNot() { using Session session = Open("particlescube"); long nativeBefore = session.Seam.NativeDrawsForTests; - long emulatedBefore = session.Seam.EmulationCallsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; session.RunFrame(native: false, blending: true, depth: true, motion: false, s => s.Platform.RenderParticles(s.Mesh, 2, 0)); Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeBefore); - Assert.True(session.Seam.EmulationCallsForTests - emulatedBefore > 0); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); - long inside = session.Seam.EmulationCallsInNativePassesForTests; session.RunFrame(native: true, blending: true, depth: true, motion: false, s => s.Platform.RenderParticles(s.Mesh, 2, 0)); - Assert.Equal(0, session.Seam.EmulationCallsInNativePassesForTests - inside); GpuTest.AssertClean(session.Seam); } /// - /// The colour slots a native world pass declares are the set the emulated route's + /// The colour slots a native world pass declares are the set the stated route's /// draw-buffer mask holds at the same point in the frame, derived from the platform's own /// motion-window state: Primary's default colour set, plus the motion attachment exactly /// while a window is open, and every bound slot with TAA off. /// [SkippableFact] - public void TheDeclaredColourSlotsAreTheOnesTheEmulatedMaskWouldHold() + public void TheDeclaredColourSlotsAreTheOnesTheStatedMaskWouldHold() { using Session session = Open("particlescube"); MethodInfo slots = typeof(VulkanClientPlatform).GetMethod("NativeWorldPassColorSlots", @@ -398,13 +385,13 @@ public void TheDeclaredColourSlotsAreTheOnesTheEmulatedMaskWouldHold() /// The scene slot's centre as RunFrame clears it (0.125, 0.25, 0.5). private const string ClearedSceneCentre = "32,64,127,255"; - private void AssertSameAttachments(byte[][] emulated, byte[][] native, string what, bool mustDraw = true) + private void AssertSameAttachments(byte[][] stated, byte[][] native, string what, bool mustDraw = true) { - for (int slot = 0; slot < emulated.Length; slot++) + for (int slot = 0; slot < stated.Length; slot++) { - output.WriteLine(what + " slot " + slot + " centre emulated " + Centre(emulated[slot]) + + output.WriteLine(what + " slot " + slot + " centre stated " + Centre(stated[slot]) + " native " + Centre(native[slot])); - Assert.Equal(emulated[slot], native[slot]); + Assert.Equal(stated[slot], native[slot]); } // Two untouched attachments are equal too. Until the fixture seeded the frame block and // the transforms, every comparison in this file was exactly that. @@ -480,16 +467,6 @@ private sealed class Session : IDisposable CrashMarkerDataPath = dataPath, }; - // These tests pin a dedicated native route against the emulated route its seam's neutral - - // body used to take; the fixture sets state on the device directly, so the generic stated - - // route (which reads the platform's record) stays out of the comparison until the emulated - - // route is removed. NativeStatedTests covers the generic route itself. - - platform.NativeStatedEnabled = false; - if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) { output.WriteLine("Vulkan unavailable: " + reason); diff --git a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs index 5476c73e..8c7c5e84 100644 --- a/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -138,7 +138,7 @@ public void PipelinesAreReusedForIdenticalStateAndRebuiltForDifferentState() using var program = new ShaderProgramResources(context!, programId: 3, translated); using var cache = new GraphicsPipelineCache(context!); - var tracker = new GlStateTracker(); + var tracker = new PipelineKeyState(); tracker.SetProgram(3); var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); @@ -199,7 +199,7 @@ public void FullscreenPassesShareOnePipelinePerProgram() { using var compiler = new ShaderCompiler(); using var cache = new GraphicsPipelineCache(context!); - var tracker = new GlStateTracker(); + var tracker = new PipelineKeyState(); var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); int targetId = tracker.InternTargetFormats(targets); @@ -271,7 +271,7 @@ public void ASavedPipelineCacheSeedsTheNextCacheOnTheSameDevice() Assert.True(translated.Success, string.Join("; ", translated.Errors)); using var program = new ShaderProgramResources(context!, programId: 7, translated); - var tracker = new GlStateTracker(); + var tracker = new PipelineKeyState(); tracker.SetProgram(7); var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); int targetId = tracker.InternTargetFormats(targets); @@ -398,7 +398,7 @@ public void AGrownPipelineCacheIsSavedFromAWorkerBeforeShutdown() Assert.True(translated.Success, string.Join("; ", translated.Errors)); using var program = new ShaderProgramResources(context!, programId: 9, translated); - var tracker = new GlStateTracker(); + var tracker = new PipelineKeyState(); tracker.SetProgram(9); var targets = new RenderTargetFormats(new[] { Format.R8G8B8A8Unorm }, Format.Undefined); int targetId = tracker.InternTargetFormats(targets); diff --git a/Optimum.Render.Vulkan/Core/GlStateTracker.cs b/Optimum.Render.Vulkan.Tests/PipelineKeyState.cs similarity index 63% rename from Optimum.Render.Vulkan/Core/GlStateTracker.cs rename to Optimum.Render.Vulkan.Tests/PipelineKeyState.cs index 6f64c86f..3867ea07 100644 --- a/Optimum.Render.Vulkan/Core/GlStateTracker.cs +++ b/Optimum.Render.Vulkan.Tests/PipelineKeyState.cs @@ -1,230 +1,21 @@ using System; using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; using Silk.NET.Vulkan; using Vintagestory.API.Client; -namespace Optimum.Render.Vulkan.Core; - -/// Blend configuration for one colour attachment. -internal struct AttachmentBlend : IEquatable -{ - public bool Enabled; - public BlendFactor SrcColor; - public BlendFactor DstColor; - public BlendOp ColorOp; - public BlendFactor SrcAlpha; - public BlendFactor DstAlpha; - public BlendOp AlphaOp; - public ColorComponentFlags WriteMask; - - public static AttachmentBlend Default => new() - { - Enabled = false, - SrcColor = BlendFactor.SrcAlpha, - DstColor = BlendFactor.OneMinusSrcAlpha, - ColorOp = BlendOp.Add, - SrcAlpha = BlendFactor.SrcAlpha, - DstAlpha = BlendFactor.OneMinusSrcAlpha, - AlphaOp = BlendOp.Add, - WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit - | ColorComponentFlags.BBit | ColorComponentFlags.ABit, - }; - - /// - /// The factor pairs one of the game's named blend modes means, which - /// ClientPlatformWindows.GlToggleBlend selects and - /// applies to every attachment. - /// - /// A native render system states its blend outright rather than reading the tracker's - /// (docs/vulkan-native-render-systems.md, decision 3), and its call site says "blend on, - /// standard" the same way the OpenGL body does, so it builds the attachment through here - /// instead of restating the factors and risking a pair that drifts from the tracker's. - /// - public static AttachmentBlend For(bool enabled, EnumBlendMode mode) - { - (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = FactorsFor(mode); - AttachmentBlend blend = Default; - blend.Enabled = enabled; - blend.SrcColor = srcColor; - blend.DstColor = dstColor; - blend.ColorOp = BlendOp.Add; - blend.SrcAlpha = srcAlpha; - blend.DstAlpha = dstAlpha; - blend.AlphaOp = BlendOp.Add; - return blend; - } - - /// The one table of factor pairs, shared by the tracker and by native systems. - internal static (BlendFactor SrcColor, BlendFactor DstColor, BlendFactor SrcAlpha, BlendFactor DstAlpha) - FactorsFor(EnumBlendMode mode) => mode switch - { - EnumBlendMode.Brighten => (BlendFactor.DstColor, BlendFactor.One, - BlendFactor.DstColor, BlendFactor.One), - EnumBlendMode.Multiply => (BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha, - BlendFactor.One, BlendFactor.OneMinusSrcAlpha), - EnumBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha, - BlendFactor.One, BlendFactor.OneMinusSrcAlpha), - EnumBlendMode.Glow => (BlendFactor.SrcAlpha, BlendFactor.One, - BlendFactor.One, BlendFactor.Zero), - EnumBlendMode.Overlay => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, - BlendFactor.One, BlendFactor.One), - _ => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, - BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), - }; - - /// - /// Squeezes the whole attachment state into 32 bits so a set of eight hashes - /// as cheaply as an array of ints. Every field is a small enum; the widest is - /// a blend factor at 19 values. - /// - public readonly uint Pack() - { - uint packed = Enabled ? 1u : 0u; - packed |= (uint)SrcColor << 1; - packed |= (uint)DstColor << 6; - packed |= (uint)ColorOp << 11; - packed |= (uint)SrcAlpha << 14; - packed |= (uint)DstAlpha << 19; - packed |= (uint)AlphaOp << 24; - packed |= (uint)WriteMask << 27; - return packed; - } - - public readonly bool Equals(AttachmentBlend other) => Pack() == other.Pack(); - public override readonly bool Equals(object? obj) => obj is AttachmentBlend other && Equals(other); - public override readonly int GetHashCode() => (int)Pack(); -} +namespace Optimum.Render.Vulkan.Tests; /// -/// Interns a value so it can be compared as an int. -/// -/// The pipeline key is looked up on every draw, so it has to be small and cheap -/// to hash. Interning the bulky parts - the blend set, the render target formats, -/// the vertex layout - turns each into one integer and leaves the key at six. +/// The OpenGL-shaped state record the renderer used before every draw became native, kept for the +/// component tests that drive , +/// and directly: it builds their pipeline keys, blend sets and dynamic +/// state the way the removed emulated draw did. Nothing in the renderer uses it. /// -internal sealed class Interner where T : notnull -{ - private readonly Dictionary _ids; - private readonly List _values = new(); - - public Interner(IEqualityComparer? comparer = null) => _ids = new Dictionary(comparer); - - public int Intern(T value) - { - if (_ids.TryGetValue(value, out int id)) return id; - - id = _values.Count; - _values.Add(value); - _ids[value] = id; - return id; - } - - public T Get(int id) => _values[id]; - public int Count => _values.Count; -} - -/// The attachment formats a pipeline renders into. -internal sealed class RenderTargetFormats : IEquatable +internal sealed class PipelineKeyState { - public Format[] ColorFormats { get; } - public Format DepthFormat { get; } - - public RenderTargetFormats(Format[] colorFormats, Format depthFormat) - { - ColorFormats = colorFormats; - DepthFormat = depthFormat; - } - - public bool Equals(RenderTargetFormats? other) - { - if (other is null) return false; - if (DepthFormat != other.DepthFormat) return false; - if (ColorFormats.Length != other.ColorFormats.Length) return false; - - for (int i = 0; i < ColorFormats.Length; i++) - { - if (ColorFormats[i] != other.ColorFormats[i]) return false; - } - return true; - } - - public override bool Equals(object? obj) => Equals(obj as RenderTargetFormats); - - public override int GetHashCode() - { - var hash = new HashCode(); - hash.Add(DepthFormat); - foreach (Format format in ColorFormats) hash.Add(format); - return hash.ToHashCode(); - } -} - -/// A set of per-attachment blend states, interned as a unit. -internal sealed class BlendSignature : IEquatable -{ - private readonly uint[] _packed; - private readonly int _hash; - - public BlendSignature(ReadOnlySpan attachments) - { - _packed = new uint[attachments.Length]; - var hash = new HashCode(); - for (int i = 0; i < attachments.Length; i++) - { - _packed[i] = attachments[i].Pack(); - hash.Add(_packed[i]); - } - _hash = hash.ToHashCode(); - } - - public bool Equals(BlendSignature? other) - { - if (other is null || other._hash != _hash || other._packed.Length != _packed.Length) return false; - for (int i = 0; i < _packed.Length; i++) - { - if (_packed[i] != other._packed[i]) return false; - } - return true; - } - - public override bool Equals(object? obj) => Equals(obj as BlendSignature); - public override int GetHashCode() => _hash; -} - -/// -/// Everything a graphics pipeline is built from that Vulkan cannot change -/// dynamically. -/// -/// Vulkan 1.3 makes viewport, scissor, cull mode, front face, depth test/write/ -/// compare, stencil state and line width dynamic, so none of them appear here and -/// none of them cause a pipeline to be created. What is left is the shader -/// program, the vertex layout, the attachment formats, the blend set, the fill -/// mode and the topology class - and all but the last two are interned to an int. -/// -internal readonly record struct PipelineKey( - int ProgramId, - int VertexLayoutId, - int TargetFormatsId, - int BlendId, - PolygonMode PolygonMode, - int TopologyClass); - -/// -/// The emulated OpenGL state machine. -/// -/// The game and its mods drive rendering the way GL asks them to: set a piece of -/// state, set another, bind a texture to a unit, draw. Reproducing that protocol -/// is what lets every render system and every mod keep working unchanged, so this -/// class records state rather than executing it, and a draw resolves the record -/// into a pipeline key plus a handful of dynamic-state commands. -/// -/// It is the same approach Zink and ANGLE take, narrowed to the state this one -/// game actually touches. -/// -internal sealed class GlStateTracker -{ - public const int MaxColorAttachments = 8; - public const int MaxTextureUnits = 16; + public const int MaxColorAttachments = RenderLimits.MaxColorAttachments; + public const int MaxTextureUnits = RenderLimits.MaxTextureUnits; private readonly AttachmentBlend[] _blend = new AttachmentBlend[MaxColorAttachments]; private readonly Interner _blendSignatures = new(); @@ -236,7 +27,7 @@ internal sealed class GlStateTracker ColorComponentFlags.RBit | ColorComponentFlags.GBit | ColorComponentFlags.BBit | ColorComponentFlags.ABit; - public GlStateTracker() + public PipelineKeyState() { for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; } @@ -271,12 +62,7 @@ public GlStateTracker() public PolygonMode PolygonMode { get; private set; } = PolygonMode.Fill; public int CurrentProgram { get; private set; } - /// - /// The front face is a constant, not a setting. GL's counter-clockwise - /// winding, read in a Vulkan framebuffer with no Y flip, is clockwise. The - /// game never calls glFrontFace, so nothing varies it. - /// - public const FrontFace FrontFace = Silk.NET.Vulkan.FrontFace.Clockwise; + public const FrontFace FrontFace = RenderLimits.FrontFace; // -------------------------------------------------------------------- setters diff --git a/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs b/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs index 601fe8d5..fd99a0f0 100644 --- a/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs +++ b/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs @@ -80,8 +80,8 @@ public void ARenderTargetNeverClearedOrDrawnReadsThePoisonValue() const uint size = 8; using var commands = new SetupQueue(context); using var textures = new TextureManager(context, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context, textures); int unorm = textures.Create(size, size, Format.R8G8B8A8Unorm); int srgb = textures.Create(size, size, Format.R8G8B8A8Srgb); @@ -97,7 +97,6 @@ public void ARenderTargetNeverClearedOrDrawnReadsThePoisonValue() targets.Attach(framebuffer, 3, single); targets.Attach(framebuffer, 4, integer); targets.Attach(framebuffer, -1, depth); - targets.SetDrawBuffers(framebuffer, 0b11111); commands.SubmitAndWait(commandBuffer => { @@ -155,8 +154,8 @@ public void AClearedTargetReadsItsClearValueWithPoisonOn() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); int color = textures.Create(size, size, Format.R8G8B8A8Unorm); int depth = textures.Create(size, size, Format.D32Sfloat); diff --git a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs index cc6168ec..327a7bdd 100644 --- a/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs +++ b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Runtime.InteropServices; using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; using Optimum.Render.Vulkan.Shaders; using Silk.NET.Vulkan; using Vintagestory.API.Client; @@ -41,11 +42,11 @@ void main(void) """; /// - /// The composition case: one output, two attachments, only the first - /// selected. Attachment 1 must come through untouched. + /// The composition case: one output, two attachments. The pipeline masks off the + /// attachment the program never writes, so attachment 1 must come through untouched. /// [SkippableFact] - public unsafe void AnAttachmentLeftOutOfDrawBuffersIsNotWritten() + public unsafe void AnAttachmentTheProgramDoesNotWriteKeepsItsContents() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); @@ -55,8 +56,8 @@ public unsafe void AnAttachmentLeftOutOfDrawBuffersIsNotWritten() const uint size = 16; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -70,7 +71,6 @@ public unsafe void AnAttachmentLeftOutOfDrawBuffersIsNotWritten() int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, colorTexture); targets.Attach(framebuffer, 1, glowTexture); - targets.SetDrawBuffers(framebuffer, 0b01); // attachment 0 only TranslatedProgram translated = Translate(compiler, SingleOutputVertex, """ #version 330 core @@ -115,8 +115,8 @@ public unsafe void SelectedAttachmentsAllReceiveTheirMatchingOutput() const uint size = 16; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -128,7 +128,6 @@ public unsafe void SelectedAttachmentsAllReceiveTheirMatchingOutput() int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, colorTexture); targets.Attach(framebuffer, 1, glowTexture); - targets.SetDrawBuffers(framebuffer, 0b11); TranslatedProgram translated = Translate(compiler, SingleOutputVertex, """ #version 330 core @@ -162,99 +161,22 @@ void main(void) } /// - /// Phase 2 (C4): a draw-buffer change is a write-mask change. The scope keeps - /// every bound attachment, so changing the mask never restarts it; sampling a - /// slot whose draw buffer is off takes that slot out (one feedback split), and - /// selecting it again lets it rejoin (another). + /// The attachment formats fed to the pipeline must match the attachments the scope was + /// opened with: every bound slot, except one the declared pass leaves out, which is + /// Undefined so output N stays aimed at slot N (the final composition writes Primary 0 + /// and samples Primary 1). /// [SkippableFact] - public unsafe void ChangingTheDrawBufferMaskKeepsTheRenderingScope() + public void ASlotTheDeclaredPassLeavesOutIsUndefinedInTheFormats() { var messages = new List(); Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); using (context) { - const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); - - int a = textures.Create(size, size, Format.R8G8B8A8Unorm); - int b = textures.Create(size, size, Format.R8G8B8A8Unorm); - - int framebuffer = targets.Create(size, size); - targets.Attach(framebuffer, 0, a); - targets.Attach(framebuffer, 1, b); - targets.SetDrawBuffers(framebuffer, 0b01); - - commands.SubmitAndWait(commandBuffer => - { - targets.Bind(commandBuffer, framebuffer); - targets.EnsureRendering(commandBuffer); - Assert.Equal(1, targets.ScopesOpened); - - // Same mask: the scope stands. - targets.EnsureRendering(commandBuffer); - Assert.Equal(1, targets.ScopesOpened); - - // Mask changes, back and forth: still the one scope. - targets.SetDrawBuffers(framebuffer, 0b11); - targets.EnsureRendering(commandBuffer); - targets.SetDrawBuffers(framebuffer, 0b01); - targets.EnsureRendering(commandBuffer); - targets.SetDrawBuffers(framebuffer, 0b10); - targets.EnsureRendering(commandBuffer); - Assert.Equal(1, targets.ScopesOpened); - Assert.Equal(0, targets.MaskRestarts); - Assert.Equal(0, targets.FeedbackSplits); - - // Sampling b while its draw buffer is off takes it out of the scope. - targets.SetDrawBuffers(framebuffer, 0b01); - targets.ExcludeSampledAttachment(commandBuffer, b); - Assert.False(targets.RenderingActive); - textures.TransitionTexture(commandBuffer, textures.Get(b)!, ImageLayout.ShaderReadOnlyOptimal); - targets.EnsureRendering(commandBuffer); - Assert.Equal(2, targets.ScopesOpened); - Assert.Equal(1, targets.FeedbackSplits); - Assert.Equal(1, targets.EnabledAttachmentCount(targets.Get(framebuffer)!)); - - // Selecting b again lets it rejoin. - targets.SetDrawBuffers(framebuffer, 0b11); - targets.EnsureRendering(commandBuffer); - Assert.Equal(3, targets.ScopesOpened); - Assert.Equal(2, targets.FeedbackSplits); - Assert.Equal(2, targets.EnabledAttachmentCount(targets.Get(framebuffer)!)); - Assert.Equal(0, targets.MaskRestarts); - - targets.EndRendering(commandBuffer); - }); - - ValidationAssert.NoErrors(messages); - - ValidationAssert.NoSyncHazards(messages); - } - } - - /// - /// The attachment formats fed to the pipeline must match the attachments the - /// scope was opened with. Since Phase 2 (C4) that is every bound slot whatever - /// its draw buffer, so the formats id is stable across mask toggles; only a - /// sample-excluded or unbound slot is Undefined, keeping output N aimed at slot N. - /// - [SkippableFact] - public void DrawBufferMasksDoNotChangeTheFormatsThePipelineSees() - { - var messages = new List(); - Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); - - using (context) - { - using var commands = new SetupQueue(context!); - using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + using var targets = new RenderTargetManager(context!, textures); int framebuffer = targets.Create(8, 8); for (int i = 0; i < 4; i++) @@ -263,24 +185,15 @@ public void DrawBufferMasksDoNotChangeTheFormatsThePipelineSees() } VulkanFramebuffer bound = targets.Get(framebuffer)!; - targets.SetDrawBuffers(framebuffer, 0b1111); - int allSelected = targets.FormatsIdOf(bound); - - // The OIT pass draws to 0 and 3 while leaving 1 and 2 out. - targets.SetDrawBuffers(framebuffer, 0b1001); - RenderTargetFormats formats = state.TargetFormats(targets.FormatsIdOf(bound)); - - Assert.Equal(allSelected, targets.FormatsIdOf(bound)); - Assert.Equal(4, formats.ColorFormats.Length); - Assert.All(formats.ColorFormats, format => Assert.Equal(Format.R8G8B8A8Unorm, format)); + RenderTargetFormats all = targets.FormatsOf(targets.FormatsIdOf(bound)); + Assert.Equal(4, all.ColorFormats.Length); + Assert.All(all.ColorFormats, format => Assert.Equal(Format.R8G8B8A8Unorm, format)); - // A slot a draw samples with its draw buffer off leaves the scope: Undefined. commands.SubmitAndWait(commandBuffer => { - targets.Bind(commandBuffer, framebuffer); - targets.ExcludeSampledAttachment(commandBuffer, bound.Color[2].TextureId); + targets.DeclarePass(commandBuffer, new PassDeclaration { Name = "Compose", ColorSlots = ~(1u << 2) }, framebuffer); }); - RenderTargetFormats excluded = state.TargetFormats(targets.FormatsIdOf(bound)); + RenderTargetFormats excluded = targets.FormatsOf(targets.FormatsIdOf(bound)); Assert.Equal(4, excluded.ColorFormats.Length); Assert.Equal(Format.R8G8B8A8Unorm, excluded.ColorFormats[1]); Assert.Equal(Format.Undefined, excluded.ColorFormats[2]); @@ -303,12 +216,12 @@ private static TranslatedProgram Translate(ShaderCompiler compiler, string verte private static unsafe void RenderFullscreen( VulkanContext context, SetupQueue commands, RenderTargetManager targets, - GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + GraphicsPipelineCache pipelines, PipelineKeyState state, ShaderProgramResources program, int framebuffer, uint size) { VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); int attachmentCount = targets.EnabledAttachmentCount(bound); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; @@ -340,7 +253,7 @@ private static unsafe void RenderFullscreen( api.CmdSetScissor(commandBuffer, 0, 1, &scissor); api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, false); api.CmdSetDepthWriteEnable(commandBuffer, false); diff --git a/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs index 00a89e6a..5f545d26 100644 --- a/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs @@ -335,6 +335,8 @@ private unsafe Decoded RenderMoverMotion( seam.ClearColor(0, 0f, 0f, 0f, 1f); seam.ClearColor(1, 0f, 0f, 0f, 1f); seam.ClearColor(2, 0f, 0f, 0f, 0f); + // The previous call's decode pass left depth writes off, and a depth clear honours that. + seam.SetDepthMask(true); seam.ClearDepth(1f); seam.UseProgram(program); diff --git a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs index 350b56c3..ed416807 100644 --- a/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -67,8 +67,8 @@ public unsafe void ResetHistoryIgnoresTheHistoryEntirely() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -125,8 +125,8 @@ public unsafe void StaticSceneConvergesToTheCurrentColour() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -205,8 +205,8 @@ public unsafe void UniformMotionReprojectsTheHistoryByThatOffset() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -276,8 +276,8 @@ public unsafe void AnOutlierHistoryValueIsClippedTowardTheNeighbourhood() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -353,8 +353,8 @@ public unsafe void SkyDoesNotMoveUnderCameraTranslation() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -431,8 +431,8 @@ public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -475,7 +475,7 @@ public unsafe void JitteredReconstructionMatchesTheUnjitteredStaticEdge() /// . /// private static unsafe float ResolveEdgeCentroid( - VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, PipelineKeyState state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, SharedLayoutTestBinding descriptors, (float x, float y) jitterPx, Func sceneAt) { @@ -528,8 +528,8 @@ public unsafe void LinearHistorySamplingSpreadsAOnePixelLineOverTwoColumns() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -598,8 +598,8 @@ public unsafe void NanInHistoryIsTreatedAsAReset() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -1057,8 +1057,8 @@ private sealed class TemporalRun using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -1138,7 +1138,7 @@ private static PerspectiveCamera CreatePerspective() // ------------------------------------------------------------------ setup private static ShaderProgramResources LoadProgram( - VulkanContext context, ShaderCompiler compiler, GlStateTracker state, + VulkanContext context, ShaderCompiler compiler, PipelineKeyState state, Func? fragmentTransform = null) { Dictionary files = ShaderCorpus.LoadShaderFiles(); @@ -1194,8 +1194,8 @@ public unsafe void SkyStaysPutWhenTheCameraSitsAboveTheOrigin(double eyeHeight) using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -1339,7 +1339,6 @@ private static TaaAttachmentSet CreateAttachmentSet(TextureManager textures, Ren targets.Attach(set.Framebuffer, 0, set.Color); targets.Attach(set.Framebuffer, 1, set.Glow); targets.Attach(set.Framebuffer, 2, set.Depth); - targets.SetDrawBuffers(set.Framebuffer, 0b111); return set; } @@ -1353,7 +1352,7 @@ private static TaaAttachmentSet CreateAttachmentSet(TextureManager textures, Ren /// draw path follows, scoped to a single named-uniform, named-sampler pass. /// private static unsafe void ResolveOnce( - VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, PipelineKeyState state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, SharedLayoutTestBinding descriptors, TaaInputSet inputs, TaaUniforms uniforms, TaaAttachmentSet output) { @@ -1409,7 +1408,7 @@ private static unsafe void ResolveOnce( VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); int attachmentCount = targets.EnabledAttachmentCount(bound); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; @@ -1450,7 +1449,7 @@ private static unsafe void ResolveOnce( var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(Size, Size)); api.CmdSetScissor(commandBuffer, 0, 1, &scissor); api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, false); api.CmdSetDepthWriteEnable(commandBuffer, false); diff --git a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs index 1f984082..ab9ba3fd 100644 --- a/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs +++ b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs @@ -56,8 +56,8 @@ public unsafe void SharpnessZeroIsBitForBitIdenticalToTheInput() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -95,8 +95,8 @@ public unsafe void SharpenIncreasesContrastAcrossAKnownEdge() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -157,8 +157,8 @@ public unsafe void ALonePixelIsSharpenedHalfAsMuchAsAnEdge() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -215,8 +215,8 @@ public unsafe void SharpnessScalesTheEffectMonotonically() using (var commands = new SetupQueue(context!)) using (var textures = new TextureManager(context!, commands.Uploads)) { - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); using var descriptors = new SharedLayoutTestBinding(context!, textures); @@ -244,7 +244,7 @@ public unsafe void SharpnessScalesTheEffectMonotonically() } private unsafe float EdgeStep( - VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, PipelineKeyState state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, SharedLayoutTestBinding descriptors, int input, float sharpness) { @@ -259,7 +259,7 @@ private unsafe float EdgeStep( // ------------------------------------------------------------------ setup private static ShaderProgramResources LoadProgram( - VulkanContext context, ShaderCompiler compiler, GlStateTracker state) + VulkanContext context, ShaderCompiler compiler, PipelineKeyState state) { Dictionary files = ShaderCorpus.LoadShaderFiles(); Dictionary includes = ShaderCorpus.LoadIncludes(); @@ -289,14 +289,13 @@ private static SharpenTarget CreateTarget(TextureManager textures, RenderTargetM }; set.Framebuffer = targets.Create(Size, Size); targets.Attach(set.Framebuffer, 0, set.Color); - targets.SetDrawBuffers(set.Framebuffer, 0b1); return set; } // ------------------------------------------------------------------- draw private static unsafe void SharpenOnce( - VulkanContext context, SetupQueue commands, TextureManager textures, GlStateTracker state, + VulkanContext context, SetupQueue commands, TextureManager textures, PipelineKeyState state, RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, SharedLayoutTestBinding descriptors, int input, float sharpness, SharpenTarget output) { @@ -337,7 +336,7 @@ private static unsafe void SharpenOnce( VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); int attachmentCount = targets.EnabledAttachmentCount(bound); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; @@ -375,7 +374,7 @@ private static unsafe void SharpenOnce( var scissor = new Rect2D(new Offset2D(0, 0), new Extent2D(Size, Size)); api.CmdSetScissor(commandBuffer, 0, 1, &scissor); api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, false); api.CmdSetDepthWriteEnable(commandBuffer, false); diff --git a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs index 3fb1fee7..85388e70 100644 --- a/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs +++ b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs @@ -60,8 +60,8 @@ public unsafe void EachOitAccumulationLayerIsWrittenSeparately() const uint layers = 3; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -71,7 +71,6 @@ public unsafe void EachOitAccumulationLayerIsWrittenSeparately() targets.Attach(framebuffer, 0, accumulation, 0); targets.Attach(framebuffer, 1, accumulation, 1); targets.Attach(framebuffer, 2, accumulation, 2); - targets.SetDrawBuffers(framebuffer, 0b111); TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ #version 330 core @@ -120,8 +119,8 @@ public unsafe void AttachmentsKeepIndependentBlendFactors() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -135,7 +134,6 @@ public unsafe void AttachmentsKeepIndependentBlendFactors() int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, reveal); targets.Attach(framebuffer, 1, accum); - targets.SetDrawBuffers(framebuffer, 0b11); // Attachment 0: dst * src (GL_ZERO, GL_SRC_COLOR reversed as the OIT // pass writes it - factor pair 774/0 is DST_COLOR, ZERO). @@ -193,8 +191,8 @@ public unsafe void ADepthOnlyTargetStoresWhatWasDrawn() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); @@ -202,7 +200,6 @@ public unsafe void ADepthOnlyTargetStoresWhatWasDrawn() int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, -1, depth); - targets.SetDrawBuffers(framebuffer, 0); // Draws at a fixed clip depth; after the Vulkan remap that is 0.75. TranslatedProgram translated = Translate(compiler, """ @@ -267,15 +264,14 @@ public unsafe void AnOcclusionQueryCountsTheSamplesThatPassed() const uint size = 8; using var commands = new SetupQueue(context!); using var textures = new TextureManager(context!, commands.Uploads); - var state = new GlStateTracker(); - using var targets = new RenderTargetManager(context!, textures, state); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); using var pipelines = new GraphicsPipelineCache(context!); using var compiler = new ShaderCompiler(); int color = textures.Create(size, size, Format.R8G8B8A8Unorm); int framebuffer = targets.Create(size, size); targets.Attach(framebuffer, 0, color); - targets.SetDrawBuffers(framebuffer, 0b1); TranslatedProgram translated = Translate(compiler, FullscreenVertex, """ #version 330 core @@ -331,12 +327,12 @@ private static TranslatedProgram Translate(ShaderCompiler compiler, string verte private static unsafe void RenderFullscreen( VulkanContext context, SetupQueue commands, RenderTargetManager targets, - GraphicsPipelineCache pipelines, GlStateTracker state, ShaderProgramResources program, + GraphicsPipelineCache pipelines, PipelineKeyState state, ShaderProgramResources program, int framebuffer, uint size, bool depthTest = false, QueryPool queryPool = default) { VulkanFramebuffer bound = targets.Get(framebuffer)!; int formatsId = targets.FormatsIdOf(bound); - RenderTargetFormats formats = state.TargetFormats(formatsId); + RenderTargetFormats formats = targets.FormatsOf(formatsId); int attachmentCount = targets.EnabledAttachmentCount(bound); var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; @@ -373,7 +369,7 @@ private static unsafe void RenderFullscreen( api.CmdSetScissor(commandBuffer, 0, 1, &scissor); api.CmdSetCullMode(commandBuffer, CullModeFlags.None); - api.CmdSetFrontFace(commandBuffer, GlStateTracker.FrontFace); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.FrontFace); api.CmdSetPrimitiveTopology(commandBuffer, PrimitiveTopology.TriangleList); api.CmdSetDepthTestEnable(commandBuffer, depthTest); api.CmdSetDepthWriteEnable(commandBuffer, depthTest); diff --git a/Optimum.Render.Vulkan/Core/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs index 8d84a4f0..b5692e45 100644 --- a/Optimum.Render.Vulkan/Core/MeshManager.cs +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -78,7 +78,6 @@ internal sealed unsafe class MeshManager : IDisposable private const int GlInt2101010Rev = 0x8D9F; private readonly VulkanContext _context; - private readonly GlStateTracker _state; private readonly UploadManager? _uploads; private readonly Interner _layouts = new(); private readonly List _meshes = new(); @@ -102,10 +101,9 @@ internal sealed unsafe class MeshManager : IDisposable /// internal bool DeviceLocalStaticBuffers { get; set; } = true; - public MeshManager(VulkanContext context, GlStateTracker state, UploadManager? uploads = null) + public MeshManager(VulkanContext context, UploadManager? uploads = null) { _context = context; - _state = state; _uploads = uploads; _meshes.Add(null); // 0 is never a real mesh diff --git a/Optimum.Render.Vulkan/Core/PipelineCache.cs b/Optimum.Render.Vulkan/Core/PipelineCache.cs index 43313c64..2685c1e5 100644 --- a/Optimum.Render.Vulkan/Core/PipelineCache.cs +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -13,8 +13,8 @@ namespace Optimum.Render.Vulkan.Core; /// Creates graphics pipelines on demand and remembers them. /// /// Vulkan wants pipeline state baked ahead of time; GL lets it change one call -/// before a draw. Bridging that is the job here: state changes are recorded by -/// , and the first draw that needs a given +/// before a draw. Bridging that is the job here: a draw states its fixed state +/// (NativePipelineDescription), and the first draw that needs a given /// combination compiles a pipeline for it. Because Vulkan 1.3 makes viewport, /// scissor, cull, front face, depth and stencil dynamic, the combinations that /// remain are few - roughly a few hundred across the whole game - and after the @@ -230,6 +230,25 @@ public Pipeline Get(PipelineKey key, PipelineRequest request) /// /// With off this never returns false. /// + /// + /// ahead of any draw: the compile starts (or the driver cache serves it) + /// when a native system asks for its pipeline, and no draw is counted as skipped for it. + /// + public void Prepare(PipelineKey key, PipelineRequest request) + { + _preparing = true; + try + { + TryGet(key, request, out _); + } + finally + { + _preparing = false; + } + } + + private bool _preparing; + public bool TryGet(PipelineKey key, PipelineRequest request, out Pipeline pipeline) { if (_pipelines.TryGetValue(key, out pipeline)) @@ -361,6 +380,7 @@ private bool TryTakeWarmFromQueuedPrewarm(CompileJob job, PipelineRequest reques private void NoteSkipped() { + if (_preparing) return; Interlocked.Increment(ref _drawsSkipped); VulkanStats.NotePipelineDrawSkipped(); } @@ -949,7 +969,7 @@ private Result CreatePipeline(PipelineRequest request, Silk.NET.Vulkan.PipelineC PolygonMode = request.PolygonMode, // Cull mode and front face are dynamic; these are placeholders. CullMode = CullModeFlags.None, - FrontFace = GlStateTracker.FrontFace, + FrontFace = RenderLimits.FrontFace, LineWidth = 1.0f, }; diff --git a/Optimum.Render.Vulkan/Core/PipelineState.cs b/Optimum.Render.Vulkan/Core/PipelineState.cs new file mode 100644 index 00000000..48d4dc95 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineState.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Core; + +/// Blend configuration for one colour attachment. +internal struct AttachmentBlend : IEquatable +{ + public bool Enabled; + public BlendFactor SrcColor; + public BlendFactor DstColor; + public BlendOp ColorOp; + public BlendFactor SrcAlpha; + public BlendFactor DstAlpha; + public BlendOp AlphaOp; + public ColorComponentFlags WriteMask; + + public static AttachmentBlend Default => new() + { + Enabled = false, + SrcColor = BlendFactor.SrcAlpha, + DstColor = BlendFactor.OneMinusSrcAlpha, + ColorOp = BlendOp.Add, + SrcAlpha = BlendFactor.SrcAlpha, + DstAlpha = BlendFactor.OneMinusSrcAlpha, + AlphaOp = BlendOp.Add, + WriteMask = ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit, + }; + + /// + /// The factor pairs one of the game's named blend modes means, which + /// ClientPlatformWindows.GlToggleBlend selects and + /// the platform's stated state (StatedRenderState) applies to every attachment. + /// + /// A native render system states its blend outright rather than reading the tracker's + /// (docs/vulkan-native-render-systems.md, decision 3), and its call site says "blend on, + /// standard" the same way the OpenGL body does, so it builds the attachment through here + /// instead of restating the factors and risking a pair that drifts from the table. + /// + public static AttachmentBlend For(bool enabled, EnumBlendMode mode) + { + (BlendFactor srcColor, BlendFactor dstColor, BlendFactor srcAlpha, BlendFactor dstAlpha) = FactorsFor(mode); + AttachmentBlend blend = Default; + blend.Enabled = enabled; + blend.SrcColor = srcColor; + blend.DstColor = dstColor; + blend.ColorOp = BlendOp.Add; + blend.SrcAlpha = srcAlpha; + blend.DstAlpha = dstAlpha; + blend.AlphaOp = BlendOp.Add; + return blend; + } + + /// The one table of factor pairs, shared by the stated state and by native systems. + internal static (BlendFactor SrcColor, BlendFactor DstColor, BlendFactor SrcAlpha, BlendFactor DstAlpha) + FactorsFor(EnumBlendMode mode) => mode switch + { + EnumBlendMode.Brighten => (BlendFactor.DstColor, BlendFactor.One, + BlendFactor.DstColor, BlendFactor.One), + EnumBlendMode.Multiply => (BlendFactor.Zero, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + EnumBlendMode.PremultipliedAlpha => (BlendFactor.One, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.OneMinusSrcAlpha), + EnumBlendMode.Glow => (BlendFactor.SrcAlpha, BlendFactor.One, + BlendFactor.One, BlendFactor.Zero), + EnumBlendMode.Overlay => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, + BlendFactor.One, BlendFactor.One), + _ => (BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha, + BlendFactor.SrcAlpha, BlendFactor.OneMinusSrcAlpha), + }; + + /// + /// Squeezes the whole attachment state into 32 bits so a set of eight hashes + /// as cheaply as an array of ints. Every field is a small enum; the widest is + /// a blend factor at 19 values. + /// + public readonly uint Pack() + { + uint packed = Enabled ? 1u : 0u; + packed |= (uint)SrcColor << 1; + packed |= (uint)DstColor << 6; + packed |= (uint)ColorOp << 11; + packed |= (uint)SrcAlpha << 14; + packed |= (uint)DstAlpha << 19; + packed |= (uint)AlphaOp << 24; + packed |= (uint)WriteMask << 27; + return packed; + } + + public readonly bool Equals(AttachmentBlend other) => Pack() == other.Pack(); + public override readonly bool Equals(object? obj) => obj is AttachmentBlend other && Equals(other); + public override readonly int GetHashCode() => (int)Pack(); +} + +/// +/// Interns a value so it can be compared as an int. +/// +/// The pipeline key is looked up on every draw, so it has to be small and cheap +/// to hash. Interning the bulky parts - the blend set, the render target formats, +/// the vertex layout - turns each into one integer and leaves the key at six. +/// +internal sealed class Interner where T : notnull +{ + private readonly Dictionary _ids; + private readonly List _values = new(); + + public Interner(IEqualityComparer? comparer = null) => _ids = new Dictionary(comparer); + + public int Intern(T value) + { + if (_ids.TryGetValue(value, out int id)) return id; + + id = _values.Count; + _values.Add(value); + _ids[value] = id; + return id; + } + + public T Get(int id) => _values[id]; + public int Count => _values.Count; +} + +/// The attachment formats a pipeline renders into. +internal sealed class RenderTargetFormats : IEquatable +{ + public Format[] ColorFormats { get; } + public Format DepthFormat { get; } + + public RenderTargetFormats(Format[] colorFormats, Format depthFormat) + { + ColorFormats = colorFormats; + DepthFormat = depthFormat; + } + + public bool Equals(RenderTargetFormats? other) + { + if (other is null) return false; + if (DepthFormat != other.DepthFormat) return false; + if (ColorFormats.Length != other.ColorFormats.Length) return false; + + for (int i = 0; i < ColorFormats.Length; i++) + { + if (ColorFormats[i] != other.ColorFormats[i]) return false; + } + return true; + } + + public override bool Equals(object? obj) => Equals(obj as RenderTargetFormats); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(DepthFormat); + foreach (Format format in ColorFormats) hash.Add(format); + return hash.ToHashCode(); + } +} + +/// A set of per-attachment blend states, interned as a unit. +internal sealed class BlendSignature : IEquatable +{ + private readonly uint[] _packed; + private readonly int _hash; + + public BlendSignature(ReadOnlySpan attachments) + { + _packed = new uint[attachments.Length]; + var hash = new HashCode(); + for (int i = 0; i < attachments.Length; i++) + { + _packed[i] = attachments[i].Pack(); + hash.Add(_packed[i]); + } + _hash = hash.ToHashCode(); + } + + public bool Equals(BlendSignature? other) + { + if (other is null || other._hash != _hash || other._packed.Length != _packed.Length) return false; + for (int i = 0; i < _packed.Length; i++) + { + if (_packed[i] != other._packed[i]) return false; + } + return true; + } + + public override bool Equals(object? obj) => Equals(obj as BlendSignature); + public override int GetHashCode() => _hash; +} + +/// +/// Everything a graphics pipeline is built from that Vulkan cannot change +/// dynamically. +/// +/// Vulkan 1.3 makes viewport, scissor, cull mode, front face, depth test/write/ +/// compare, stencil state and line width dynamic, so none of them appear here and +/// none of them cause a pipeline to be created. What is left is the shader +/// program, the vertex layout, the attachment formats, the blend set, the fill +/// mode and the topology class - and all but the last two are interned to an int. +/// +internal readonly record struct PipelineKey( + int ProgramId, + int VertexLayoutId, + int TargetFormatsId, + int BlendId, + PolygonMode PolygonMode, + int TopologyClass); + +/// +/// The fixed limits every target and program of this renderer is built within, and the one +/// winding the game uses. +/// +internal static class RenderLimits +{ + public const int MaxColorAttachments = 8; + public const int MaxTextureUnits = 16; + + /// + /// The front face is a constant, not a setting. GL's counter-clockwise + /// winding, read in a Vulkan framebuffer with no Y flip, is clockwise. The + /// game never calls glFrontFace, so nothing varies it. + /// + public const FrontFace FrontFace = Silk.NET.Vulkan.FrontFace.Clockwise; + + /// Bit i set when the program statically writes fragment output i. + public static uint OutputBits(HashSet writtenOutputs) + { + uint bits = 0; + for (int i = 0; i < MaxColorAttachments; i++) + { + if (writtenOutputs.Contains(i)) bits |= 1u << i; + } + return bits; + } +} diff --git a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs index 116838cd..41de6511 100644 --- a/Optimum.Render.Vulkan/Core/RenderTargetManager.cs +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -24,27 +24,12 @@ internal sealed class VulkanFramebuffer public uint Width; public uint Height; - public AttachmentSlot[] Color = new AttachmentSlot[GlStateTracker.MaxColorAttachments]; + public AttachmentSlot[] Color = new AttachmentSlot[RenderLimits.MaxColorAttachments]; public int DepthTextureId; - /// - /// Bit i set means fragment output i is written. GL's glDrawBuffers selects - /// a subset of attachments rather than merely masking writes, so a cleared - /// bit means the attachment is not part of the rendering scope at all. - /// - public uint DrawBufferMask = 1; - /// Cached interned id of the attachment formats, or -1 when stale. public int FormatsId = -1; - /// - /// Bound colour slots left out of the rendering scope because a draw samples - /// them while their draw buffer is off (the composition pass writes Primary 0 - /// and reads Primary 1). Only ever a subset of the cleared draw-buffer bits; - /// reset when the framebuffer is bound again. - /// - public uint SampledExclusion; - /// /// Bound colour slots the declared frame-graph pass leaves out of its scope (the /// final composition writes Primary 0 and samples Primary 1). Cleared when the pass ends. @@ -73,7 +58,7 @@ internal sealed unsafe class RenderTargetManager : IDisposable { private readonly VulkanContext _context; private readonly TextureManager _textures; - private readonly GlStateTracker _state; + private readonly Interner _formats = new(); /// Every attachment of a scope moves in one barrier command before vkCmdBeginRendering. private readonly BarrierBatcher _barriers; @@ -98,7 +83,7 @@ internal sealed unsafe class RenderTargetManager : IDisposable public long FeedbackSplits { get; private set; } // What the open scope was begun with, to recognise a restart that changed nothing. - private readonly ImageView[] _openViews = new ImageView[GlStateTracker.MaxColorAttachments]; + private readonly ImageView[] _openViews = new ImageView[RenderLimits.MaxColorAttachments]; private int _openCount = -1; private ImageView _openDepthView; private ImageLayout _openDepthLayout; @@ -122,12 +107,10 @@ internal sealed unsafe class RenderTargetManager : IDisposable /// Opens the one scope of each pass on the frame-graph path. private readonly PassRecorder _recorder; - public RenderTargetManager(VulkanContext context, TextureManager textures, GlStateTracker state, - FrameGraph? graph = null) + public RenderTargetManager(VulkanContext context, TextureManager textures, FrameGraph? graph = null) { _context = context; _textures = textures; - _state = state; _barriers = textures.CreateBatcher(); _graph = graph ?? new FrameGraph { Enabled = false }; _recorder = new PassRecorder(context, textures, _barriers, _graph); @@ -173,12 +156,11 @@ public void Attach(int framebufferId, int attachmentIndex, int textureId, uint l { framebuffer.DepthTextureId = textureId; } - else if (attachmentIndex < GlStateTracker.MaxColorAttachments) + else if (attachmentIndex < RenderLimits.MaxColorAttachments) { AttachmentSlot previous = framebuffer.Color[attachmentIndex]; if (previous.TextureId == textureId && previous.Layer == layer) return; framebuffer.Color[attachmentIndex] = new AttachmentSlot { TextureId = textureId, Layer = layer }; - framebuffer.SampledExclusion &= ~(1u << attachmentIndex); } framebuffer.FormatsId = -1; @@ -188,64 +170,6 @@ public void Attach(int framebufferId, int attachmentIndex, int textureId, uint l if (_bound == framebuffer) _needsRestart = true; } - /// - /// Records glDrawBuffers. The scope keeps its attachments: only the effective - /// write masks change, which the next draw emits (C4). The one restart is a - /// slot a sampling draw left out whose draw buffer is selected again: it has - /// to rejoin the scope before anything can be written into it. - /// - public void SetDrawBuffers(int framebufferId, uint mask) - { - VulkanFramebuffer? framebuffer = Get(framebufferId); - if (framebuffer == null || framebuffer.DrawBufferMask == mask) return; - - framebuffer.DrawBufferMask = mask; - - uint rejoining = framebuffer.SampledExclusion & mask; - if (rejoining == 0) return; - - framebuffer.SampledExclusion &= ~rejoining; - framebuffer.FormatsId = -1; - if (_bound == framebuffer && _renderingActive) - { - _needsRestart = true; - NoteFeedbackSplit(); - } - } - - /// - /// A draw is about to sample . If that texture is a - /// bound colour slot of the bound framebuffer whose draw buffer is off, the slot - /// leaves the scope (closing an open one that holds it) so the caller can move - /// it to a shader-readable layout. Slots whose draw buffer is on are feedback - /// the caller resolves with a snapshot instead (). - /// - public void ExcludeSampledAttachment(CommandBuffer commandBuffer, int textureId) - { - VulkanFramebuffer? framebuffer = _bound; - if (framebuffer == null || textureId <= 0) return; - - uint slots = 0; - for (int i = 0; i < framebuffer.Color.Length; i++) - { - if (framebuffer.Color[i].TextureId != textureId) continue; - if (((framebuffer.DrawBufferMask >> i) & 1) != 0) continue; - slots |= 1u << i; - } - - // A slot the declared pass already leaves out is not in the scope: nothing to exclude, no split. - uint newlyExcluded = slots & ~(framebuffer.SampledExclusion | framebuffer.PassExclusion); - if (newlyExcluded == 0) return; - - framebuffer.SampledExclusion |= newlyExcluded; - framebuffer.FormatsId = -1; - if (_renderingActive) - { - EndRendering(commandBuffer); - NoteFeedbackSplit(); - } - } - private void NoteFeedbackSplit() { FeedbackSplits++; @@ -254,8 +178,7 @@ private void NoteFeedbackSplit() /// Whether colour slot is part of the scope the framebuffer opens. private static bool InScope(VulkanFramebuffer framebuffer, int index) => - framebuffer.Color[index].IsBound && - (((framebuffer.SampledExclusion | framebuffer.PassExclusion) >> index) & 1) == 0; + framebuffer.Color[index].IsBound && ((framebuffer.PassExclusion >> index) & 1) == 0; private bool _needsRestart; @@ -301,7 +224,7 @@ public bool IsAttachmentOfBound(int textureId) if (_bound.Color[i].TextureId != textureId) continue; // Left out of the declared pass's scope: sampled directly, not feedback. if (((_bound.PassExclusion >> i) & 1) != 0) continue; - if ((_bound.DrawBufferMask & (1u << i)) != 0) return true; + return true; } return false; } @@ -321,13 +244,6 @@ public void Bind(CommandBuffer commandBuffer, int framebufferId) if (_recorder.Declared != null) ClearPassDeclaration(); _bound = framebuffer; _needsRestart = false; - - // A new bind starts a new use of the target: every bound slot is back in. - if (framebuffer != null && framebuffer.SampledExclusion != 0) - { - framebuffer.SampledExclusion = 0; - framebuffer.FormatsId = -1; - } } public void Delete(int framebufferId) @@ -354,7 +270,10 @@ public void DeclarePass(CommandBuffer commandBuffer, PassDeclaration declaration { if (!_graph.Enabled) { - if (framebufferId > 0) Bind(commandBuffer, framebufferId); + // No pass bookkeeping, but the scope still holds only the declared slots. + if (framebufferId <= 0) return; + Bind(commandBuffer, framebufferId); + ApplyPassExclusion(commandBuffer, _bound!, declaration.ColorSlots); return; } @@ -375,20 +294,25 @@ public void DeclarePass(CommandBuffer commandBuffer, PassDeclaration declaration EndPass(commandBuffer); Bind(commandBuffer, target.Id); - // A new pass is a new use of the target: every bound slot is back in, except - // the slots the pass leaves out so they can be sampled. + ApplyPassExclusion(commandBuffer, target, declaration.ColorSlots); + _recorder.Declare(declaration, target); + } + + /// + /// A new pass is a new use of the target: every bound slot is in its scope except the slots + /// the pass leaves out, which can then be sampled. A change reopens an open scope. + /// + private void ApplyPassExclusion(CommandBuffer commandBuffer, VulkanFramebuffer target, uint colorSlots) + { uint exclusion = 0; for (int i = 0; i < target.Color.Length; i++) { - if (target.Color[i].IsBound && ((declaration.ColorSlots >> i) & 1) == 0) exclusion |= 1u << i; - } - if (target.SampledExclusion != 0 || target.PassExclusion != exclusion) - { - target.SampledExclusion = 0; - target.PassExclusion = exclusion; - target.FormatsId = -1; + if (target.Color[i].IsBound && ((colorSlots >> i) & 1) == 0) exclusion |= 1u << i; } - _recorder.Declare(declaration, target); + if (target.PassExclusion == exclusion) return; + target.PassExclusion = exclusion; + target.FormatsId = -1; + if (ReferenceEquals(target, _bound) && _renderingActive) EndRendering(commandBuffer); } /// Ends the current pass, declared or not: closes its scope. No-op with the frame graph off. @@ -439,9 +363,8 @@ public void FlushAllPendingClears(CommandBuffer commandBuffer) /// /// Opens a rendering scope if one is not already open, transitioning every /// participating attachment into its attachment layout. Every bound colour - /// slot participates, whatever its draw buffer, unless a sampling draw left - /// it out (); that caller moves it to - /// a shader-readable layout itself. + /// slot participates unless the declared pass leaves it out; a pass that samples + /// such a slot moves it to a shader-readable layout itself. /// public void EnsureRendering(CommandBuffer commandBuffer) { @@ -468,7 +391,7 @@ public void EnsureRendering(CommandBuffer commandBuffer) if (!InScope(framebuffer, i)) { // A null view keeps fragment output i pointed at slot i: an - // unbound slot, or one a draw samples while its draw buffer is off. + // unbound slot, or one the declared pass leaves out. attachments[i] = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo, @@ -638,6 +561,11 @@ public void ClearPassAttachment(CommandBuffer commandBuffer, int attachment, flo _context.Api.CmdClearAttachments(commandBuffer, 1, &clear, 1, &rect); } + /// + /// A colour clear of the bound target. The caller has applied the draw buffers and colour mask + /// the client stated (VulkanClientPlatform.ClearTargetColor): glClearBuffer on a draw buffer + /// glDrawBuffers left out, or through an all-false glColorMask, never reaches here. + /// public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, float g, float b, float a) { if (_bound == null) return; @@ -647,13 +575,18 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo // primary target while only 0 and 1 are selected. Nor does GL clear // through an all-false glColorMask. A clear on an attachment whose // effective write mask is zero is a no-op on every path: GL keeps an attachment the - // shader never writes, and Vulkan would write garbage into it. + // shader never writes, and Vulkan would write garbage into it. Both rules are the + // platform's now, applied to the draw buffers and mask it stated before calling here. if ((uint)attachment >= (uint)_bound.Color.Length) return; if (!_bound.Color[attachment].IsBound) return; - if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return; - if (_state.ColorMask == 0) return; if (_graph.Enabled && !ClearColorOnGraph(commandBuffer, attachment, r, g, b, a)) return; + if (!_graph.Enabled && !InScope(_bound, attachment)) + { + // Left out of the declared pass: GL still clears the texture, outside any scope. + ClearImage(commandBuffer, _bound.Color[attachment], r, g, b, a); + return; + } if (!_graph.Enabled) EnsureRendering(commandBuffer); if (!_renderingActive) return; @@ -672,6 +605,18 @@ public void ClearColor(CommandBuffer commandBuffer, int attachment, float r, flo _context.Api.CmdClearAttachments(commandBuffer, 1, &clear, 1, &rect); } + private void ClearImage(CommandBuffer commandBuffer, AttachmentSlot slot, float r, float g, float b, float a) + { + VulkanTexture? texture = _textures.Get(slot.TextureId); + if (texture == null) return; + EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.TransferDst); + _barriers.Flush(commandBuffer); + var value = new ClearColorValue(r, g, b, a); + var range = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, slot.Layer, 1); + _context.Api.CmdClearColorImage(commandBuffer, texture.Image, ImageLayout.TransferDstOptimal, &value, 1, &range); + } + /// /// The frame-graph half of a colour clear. A slot left out by the declared pass is not in /// the scope, but its draw buffer is on, so its texture is cleared through a promoted clear. @@ -700,18 +645,11 @@ private bool ClearColorOnGraph(CommandBuffer commandBuffer, int attachment, floa if (!_renderingActive || _needsRestart) { - const ColorComponentFlags all = ColorComponentFlags.RBit | ColorComponentFlags.GBit | - ColorComponentFlags.BBit | ColorComponentFlags.ABit; - if (_state.ColorMask == all) - { - VulkanTexture? texture = _textures.Get(target.Color[attachment].TextureId); - if (texture == null) return false; - EndRendering(commandBuffer); - _graph.PromoteColorClear(texture, target.Color[attachment].Layer, r, g, b, a); - return false; - } - EnsureRendering(commandBuffer); - if (!_renderingActive) return false; + VulkanTexture? texture = _textures.Get(target.Color[attachment].TextureId); + if (texture == null) return false; + EndRendering(commandBuffer); + _graph.PromoteColorClear(texture, target.Color[attachment].Layer, r, g, b, a); + return false; } _graph.NoteInPassClear(); @@ -784,29 +722,37 @@ public int FormatsIdOf(VulkanFramebuffer framebuffer) depthFormat = _textures.Get(framebuffer.DepthTextureId)?.Format ?? Format.Undefined; } - framebuffer.FormatsId = _state.InternTargetFormats(new RenderTargetFormats(colorFormats, depthFormat)); + framebuffer.FormatsId = _formats.Intern(new RenderTargetFormats(colorFormats, depthFormat)); return framebuffer.FormatsId; } + /// The formats behind an id handed out. + public RenderTargetFormats FormatsOf(int formatsId) => _formats.Get(formatsId); + + /// + /// The attachment formats of the scope opens now, without + /// interning them: what a native draw checks its pipeline against. + /// + public RenderTargetFormats ScopeFormats(VulkanFramebuffer framebuffer) => + DeclaredFormats(framebuffer, ~framebuffer.PassExclusion); + /// - /// The attachment formats of the scope opens, without - /// interning them: what a native pipeline has to be built for, and what a native draw - /// checks its pipeline against. is the slot mask a pass - /// would leave out (bit i: slot i is not an attachment of the pass), so the formats can - /// be asked for before the pass is declared. + /// The attachment formats of the scope a pass declared with opens + /// on : every bound slot among them, whatever pass the target + /// is in now (a native system builds its pipeline before its pass is declared). /// - public RenderTargetFormats ScopeFormats(VulkanFramebuffer framebuffer, uint exclusion = 0) + public RenderTargetFormats DeclaredFormats(VulkanFramebuffer framebuffer, uint colorSlots) { int count = 0; - for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + for (int i = 0; i < RenderLimits.MaxColorAttachments; i++) { - if (InScope(framebuffer, i) && ((exclusion >> i) & 1) == 0) count = i + 1; + if (framebuffer.Color[i].IsBound && ((colorSlots >> i) & 1) != 0) count = i + 1; } var colorFormats = new Format[count]; for (int i = 0; i < count; i++) { - bool inScope = InScope(framebuffer, i) && ((exclusion >> i) & 1) == 0; + bool inScope = framebuffer.Color[i].IsBound && ((colorSlots >> i) & 1) != 0; VulkanTexture? texture = inScope ? _textures.Get(framebuffer.Color[i].TextureId) : null; colorFormats[i] = texture?.Format ?? Format.Undefined; } @@ -824,7 +770,7 @@ public int EnabledAttachmentCount(VulkanFramebuffer framebuffer) => private static int HighestScopeAttachment(VulkanFramebuffer framebuffer) { int highest = -1; - for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + for (int i = 0; i < RenderLimits.MaxColorAttachments; i++) { if (InScope(framebuffer, i)) highest = i; } diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index eefb7b62..5e71f794 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -76,8 +76,8 @@ internal sealed class VulkanCapabilities /// /// The width a line draw may actually rasterize with: the caller's, clamped to the device's - /// range, or exactly 1 on a device without wideLines. Used by the emulated draw path and by - /// a native pipeline's dynamic state, so the two routes can never disagree about it. + /// range, or exactly 1 on a device without wideLines. Applied to a native pipeline's dynamic + /// state, so every draw clamps the same way. /// public float ClampLineWidth(float width) { diff --git a/Optimum.Render.Vulkan/Platform/StatedDraw.cs b/Optimum.Render.Vulkan/Platform/StatedDraw.cs new file mode 100644 index 00000000..a516066d --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/StatedDraw.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Platform; + +/// +/// One draw of a program recorded natively from a into an +/// explicit target: the generic native draw (VulkanClientPlatform.NativeStated.cs) and the GPU +/// tests' GL-shaped helpers both record through here, so the tests exercise the route the client's +/// unrecognised draws take. +/// +/// What it states, and from where: +/// - target: ; every colour slot attached to it on the device is in +/// the pass (not the FrameBufferRef's own list: the OIT accumulation targets are attached to +/// Transparent at slots 3-5 without being in its ColorTextureIds, and a pass without them drops the +/// accumulated colour - 2026-09-17, water drew black until the slots came from the attachments); +/// the draw buffers stated for that target become per-attachment write masks (decision 4); +/// - blend, colour mask, depth, cull, line width, polygon mode, viewport and scissor: the stated state; +/// - textures: per sampler, the texture on the unit the program points it at (its SetSamplerUnit +/// mapping, else the sampler's declaration order), with the unit's standalone sampler if one is bound; +/// - the depth attachment of the target sampled with depth writes off is read in the read-only +/// layout (SamplesBoundDepth); sampled while written, the draw is refused. +/// +internal static class StatedDraw +{ + /// + /// Records the draw. 0 is the fullscreen triangle; + /// is a pool's multi-draw. False with a reason: nothing was recorded. + /// names the pass the draw belongs to (its name, slots, reads and + /// flags); without one the draw opens "Stated/<target>" over every attached slot. + /// + internal static bool Record(VulkanDevice device, StatedRenderState stated, int programId, int framebufferId, + int meshId, int instances, int[]? starts, int[]? sizes, int groupCount, out string? refusal, + PassDeclaration? declared = null) + { + refusal = null; + RenderTargetFormats? all = device.NativeTargetFormats(framebufferId, uint.MaxValue); + if (all == null) return Refused("framebuffer " + framebufferId + " does not exist", out refusal); + int attached = all.ColorFormats.Length; + uint slots = attached >= 32 ? uint.MaxValue : (1u << attached) - 1u; + if (declared != null) slots &= declared.ColorSlots; + + int layoutId = meshId > 0 ? device.NativeMeshLayoutId(meshId) : MeshManager.EmptyLayoutId; + if (layoutId < 0) return Refused("the mesh has no layout", out refusal); + + // Every sampler the program declares, from the unit it points at. + List names = device.SamplerNamesOf(programId); + int depthTexture = device.NativeFramebufferDepthTexture(framebufferId); + bool samplesBoundDepth = false; + var reads = new int[names.Count]; + var units = new int[names.Count]; + for (int i = 0; i < names.Count; i++) + { + units[i] = device.NativeSamplerUnit(programId, names[i]); + reads[i] = stated.TextureAt(units[i]); + if (reads[i] != 0 && reads[i] == depthTexture) + { + if (stated.DepthWrite && stated.DepthTest) + { + return Refused("it samples the depth attachment it writes", out refusal); + } + samplesBoundDepth = true; + } + } + + // A colour slot the draw samples while its draw buffer is off leaves the pass: GL reads it as + // any texture (the composition writes Primary 0 and reads Primary 1). With its draw buffer on + // it stays, and the device samples a copy of it (feedback). + uint drawBuffers = stated.DrawBuffers(framebufferId); + for (int slot = 0; slot < attached && slot < 32; slot++) + { + if (((drawBuffers >> slot) & 1) != 0 || ((slots >> slot) & 1) == 0) continue; + int attachment = device.NativeFramebufferColorTexture(framebufferId, slot); + if (attachment != 0 && Array.IndexOf(reads, attachment) >= 0) slots &= ~(1u << slot); + } + RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, slots); + if (formats == null) return Refused("no formats for framebuffer " + framebufferId, out refusal); + + var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; + for (int i = 0; i < blend.Length; i++) blend[i] = stated.AttachmentFor(framebufferId, i); + + var description = new NativePipelineDescription + { + ProgramId = programId, + Blend = blend, + DepthTest = stated.DepthTest, + DepthWrite = stated.DepthWrite && !samplesBoundDepth, + DepthCompare = stated.DepthCompare, + Cull = stated.CullMode, + Topology = meshId > 0 ? device.NativeMeshTopology(meshId) : PrimitiveTopology.TriangleList, + PolygonMode = stated.Wireframe ? PolygonMode.Line : PolygonMode.Fill, + LineWidth = stated.LineWidth, + VertexLayoutId = layoutId, + SamplesBoundDepth = samplesBoundDepth, + Targets = formats, + }; + NativePipeline? pipeline = device.RequestNativePipeline(description, out string error); + if (pipeline == null) return Refused(error, out refusal); + + var textures = new NativeTexture[names.Count]; + for (int i = 0; i < names.Count; i++) + { + int sampler = stated.SamplerAt(units[i]); + textures[i] = new NativeTexture(pipeline.Sampler(names[i]), reads[i], + sampler != 0 ? device.NativeStandaloneSampler(sampler) : null); + } + + int[] passReads = reads; + if (declared != null && declared.Reads.Length > 0) + { + var union = new List(declared.Reads); + foreach (int read in reads) if (!union.Contains(read)) union.Add(read); + passReads = union.ToArray(); + } + Rect2D viewport = stated.Viewport; + bool drawn = false; + if (device.BeginNativePass(new NativePassDescription + { + Name = declared?.Name ?? "Stated/" + framebufferId, + FramebufferId = framebufferId, + ColorSlots = slots, + Reads = passReads, + TransientSlots = declared?.TransientSlots ?? 0, + Flags = declared?.Flags ?? PassFlags.AllowSplit, + Generic = true, + ViewportX = viewport.Offset.X, + ViewportY = viewport.Offset.Y, + ViewportWidth = (int)viewport.Extent.Width, + ViewportHeight = (int)viewport.Extent.Height, + Scissor = stated.ScissorEnabled ? stated.Scissor : null, + })) + { + drawn = meshId <= 0 + ? device.DrawNativeFullscreen(pipeline, textures) + : starts != null + ? device.DrawNativeMeshMulti(pipeline, meshId, starts, sizes!, groupCount, textures) + : device.DrawNativeMeshInstanced(pipeline, meshId, instances, textures); + } + // The scope stays open: the next stated draw on the same target and slots coalesces into + // this pass instead of ending the rendering scope and starting another; anything else + // declares its own pass, which ends this one. + device.EndNativePass(keepScope: true); + return drawn; + } + + private static bool Refused(string reason, out string? refusal) + { + refusal = reason; + return false; + } +} diff --git a/Optimum.Render.Vulkan/Platform/StatedRenderState.cs b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs index bdd52069..2ff2c0ae 100644 --- a/Optimum.Render.Vulkan/Platform/StatedRenderState.cs +++ b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs @@ -26,8 +26,8 @@ namespace Optimum.Render.Vulkan.Platform; /// internal sealed class StatedRenderState { - public const int MaxColorAttachments = GlStateTracker.MaxColorAttachments; - public const int MaxTextureUnits = GlStateTracker.MaxTextureUnits; + public const int MaxColorAttachments = RenderLimits.MaxColorAttachments; + public const int MaxTextureUnits = RenderLimits.MaxTextureUnits; private readonly AttachmentBlend[] _blend = new AttachmentBlend[MaxColorAttachments]; private readonly Dictionary _drawBuffers = new(); @@ -90,6 +90,15 @@ public void SetSlotBlend(int slot, int glEquation, int srcColor, int dstColor, i _blend[slot].DstAlpha = GlEnums.BlendFactorFrom(dstAlpha); } + /// glBlendEquationi: one attachment's equation, its factors kept. + public void SetSlotEquation(int slot, int glEquation) + { + if ((uint)slot >= MaxColorAttachments) return; + BlendOp op = GlEnums.BlendOpFrom(glEquation); + _blend[slot].ColorOp = op; + _blend[slot].AlphaOp = op; + } + /// glBlendFuncSeparatei: one attachment's factors, its equation kept. public void SetSlotFunc(int slot, int srcColor, int dstColor, int srcAlpha, int dstAlpha) { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs index c80a3b5e..7686f2fb 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -32,7 +32,6 @@ public override bool ProbeThickLineSupport() { // GL leaves the probed width set, so the stated line width is 1.5 from here on too. stated.LineWidth = 1.5f; - device.SetLineWidth(1.5f); return device.SupportsThickLines; } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index 451f6590..34c809ab 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -480,6 +480,7 @@ public override void DisposeFrameBuffer(FrameBufferRef frameBuffer, bool dispose } } // GLDeleteTexture above already routes, so only the target itself is left. + stated.ForgetFramebuffer(frameBuffer.FboId); device.DeleteFramebuffer(frameBuffer.FboId); } @@ -498,6 +499,7 @@ public override void DisposeFrameBuffers(List buffers) { if (buffers[k] != null) { + stated.ForgetFramebuffer(buffers[k].FboId); device.DeleteFramebuffer(buffers[k].FboId); if (deletedTextures.Add(buffers[k].DepthTextureId)) { @@ -543,28 +545,16 @@ public override void GlClearColorRgbaf(float r, float g, float b, float a) /// public override void BindCurrentFrameBuffer(FrameBufferRef value) { - if (value == null) - { - device.BindDefaultFramebuffer(); - DeclareBoundPass(); - return; - } - device.BindFramebuffer(value.FboId); - NoteForkViewport(0, 0, value.Width, value.Height); - device.SetViewport(0, 0, value.Width, value.Height); - DeclareBoundPass(); + // No device bind: every draw and clear names its target (CurrentFrameBuffer). The GL body + // also sets the viewport to the whole target, which is stated here. The latest bind wins, + // so a raw fork bind before it no longer addresses the draws. + forkFramebuffer = 0; + if (value != null) NoteForkViewport(0, 0, value.Width, value.Height); } public override void BindCurrentFrameBufferKeepViewport(FrameBufferRef value) { - if (value == null) - { - device.BindDefaultFramebuffer(); - DeclareBoundPass(); - return; - } - device.BindFramebuffer(value.FboId); - DeclareBoundPass(); + forkFramebuffer = 0; } public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] clearColor, bool clearDepthBuffer, bool clearColorBuffers) @@ -573,12 +563,12 @@ public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] c { for (int k = 0; k < framebuffer.ColorTextureIds.Length; k++) { - device.ClearColor(k, clearColor[0], clearColor[1], clearColor[2], clearColor[3]); + ClearTargetColor(framebuffer.FboId, k, clearColor[0], clearColor[1], clearColor[2], clearColor[3]); } } if (clearDepthBuffer) { - device.ClearDepth(1f); + ClearTargetDepth(framebuffer.FboId, 1f); } } @@ -589,31 +579,32 @@ public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] c /// public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) { + int target = CurrentTargetId; switch (framebuffer) { case EnumFrameBuffer.Default: - device.ClearColor(0, clearR, clearG, clearB, clearA); - device.ClearDepth(1f); + ClearTargetColor(target, 0, clearR, clearG, clearB, clearA); + ClearTargetDepth(target, 1f); break; case EnumFrameBuffer.Primary: - device.ClearColor(0, 0f, 0f, 0f, 1f); - device.ClearColor(1, 0f, 0f, 0f, 1f); + ClearTargetColor(target, 0, 0f, 0f, 0f, 1f); + ClearTargetColor(target, 1, 0f, 0f, 0f, 1f); if (OptimumRenderSsao) { - device.ClearColor(2, 0f, 0f, 0f, 1f); - device.ClearColor(3, 0f, 0f, 0f, 1f); + ClearTargetColor(target, 2, 0f, 0f, 0f, 1f); + ClearTargetColor(target, 3, 0f, 0f, 0f, 1f); } if (MotionAttachmentIndex >= 0) { - // ClearColor honours the draw-buffer mask on the device too. + // A clear honours the draw buffers, as on GL. // Motion is excluded until a writer opts in, so temporarily // enable it just as the GL branch does. Otherwise stale // motion/reactivity survives and can reject all TAA history. StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); - device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f); + ClearTargetColor(target, MotionAttachmentIndex, 0f, 0f, 0f, 0f); StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); } - device.ClearDepth(1f); + ClearTargetDepth(target, 1f); break; case EnumFrameBuffer.LiquidDepth: case EnumFrameBuffer.ShadowmapFar: @@ -621,16 +612,15 @@ public override void ClearFrameBufferPass(EnumFrameBuffer framebuffer) { FrameBufferRef optimumTarget = FrameBuffers[(int)framebuffer]; NoteForkViewport(0, 0, optimumTarget.Width, optimumTarget.Height); - device.SetViewport(0, 0, optimumTarget.Width, optimumTarget.Height); - device.ClearDepth(1f); + ClearTargetDepth(target, 1f); break; } case EnumFrameBuffer.Transparent: // Weighted-blended OIT: accumulation starts at zero, revealage at // one, and the third attachment is the opaque-depth copy. - device.ClearColor(0, 0f, 0f, 0f, 0f); - device.ClearColor(1, 1f, 0f, 0f, 0f); - device.ClearColor(2, 0f, 0f, 0f, 0f); + ClearTargetColor(target, 0, 0f, 0f, 0f, 0f); + ClearTargetColor(target, 1, 1f, 0f, 0f, 0f); + ClearTargetColor(target, 2, 0f, 0f, 0f, 0f); break; } } @@ -664,7 +654,8 @@ public override void SelectBackDrawBuffer() public override void SetBlendEnabled(bool enabled) { - device.SetBlendEnabled(enabled); + stated.SetBlendEnabled(enabled); + statedBlendOn = enabled; } /// @@ -674,7 +665,7 @@ public override void SetBlendEnabled(bool enabled) /// public override void ApplyTransparentMergeBlendState() { - device.SetDepthTest(false); + GlDisableDepthTest(); StateBlend(true, EnumBlendMode.Standard); StateSlotBlendFunc(0, 770, 771, 770, 771); } @@ -706,7 +697,7 @@ internal static float[] BuildOptimumSsaoNoise(Random random, int noiseSize) public override void ClearSsaoTarget() { - device.ClearColor(0, 1f, 1f, 1f, 1f); + ClearTargetColor(CurrentTargetId, 0, 1f, 1f, 1f, 1f); } /// @@ -715,15 +706,12 @@ public override void ClearSsaoTarget() /// public override void BeginFinalCompositionDrawBuffers() { - DeclareFinalCompositionPass(); StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); - device.SetDepthTest(false); + GlDisableDepthTest(); } public override void RestoreWorldDrawBuffers(bool ssaoAttachments) { - // The attachment-subset pass ends before Primary 1 rejoins the draw buffers. - device.EndPass(); if (ssaoAttachments) { StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index 55c8e96d..aa9b0ed4 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -6,14 +6,12 @@ namespace Optimum.Render.Vulkan.Platform; -// Vulkan-native plan, Phase 2 step 2: the platform declares the frame's passes in frame order. -// A pass is (context, bound target): the context is the render stage (from the C3 bracket) or -// the post method running (OIT merge, TAA resolve and sharpen, post-processing, final -// composition, blit, sky motion, liquid motion); every bind through the CurrentFrameBuffer -// setters declares the pass for the new target. Reads are the textures the base's pass body -// binds, so the pass opens with them already shader-readable. Mod-hosted stages sample -// anything and use OpenSampling and AllowSplit. With OPTIMUM_VULKAN_FRAMEGRAPH=0 the device -// ignores declarations and the old scope inference runs. +// Vulkan-native plan, Phase 2 step 2, as it stands after the emulation layer went: every pass is +// declared by the native route that records it (BeginNativePass), in frame order. The pass +// context below is only the naming prefix and flag set of the stage or post method running - +// the render stage (from the C3 bracket), or the OIT merge, TAA resolve and sharpen, +// post-processing, final composition, blit, sky motion and liquid motion - which the entity +// route names its coalesced pass after. Binds declare nothing. public partial class VulkanClientPlatform { // ClientPlatformWindows' EnumFrameBuffer slots the post chain indexes. @@ -50,7 +48,7 @@ public void OnBeginRenderStage(EnumRenderStage stage) public void OnEndRenderStage(EnumRenderStage stage) { - platform.GraphDevice?.EndPass(); + platform.GraphDevice?.EndStagePass(); // The liquid motion pass runs right after the AfterOIT renderers (ClientMain.MainRenderLoop). platform.SetPassContext(stage == EnumRenderStage.AfterOIT ? "LiquidMotion" : "Frame", PassFlags.AllowSplit); } @@ -67,92 +65,30 @@ EnumRenderStage.ShadowNear or EnumRenderStage.ShadowNearDone or EnumRenderStage. _ => PassFlags.OpenSampling | PassFlags.AllowSplit, }; - /// Starts a context and declares its pass on the bound target. + /// Starts a context: the prefix and flags of the passes recorded under it. private void SetPassContext(string context, PassFlags flags) { passContext = context; passContextFlags = flags; - DeclareBoundPass(); } /// - /// The name gives the (context, bound target) pass. A native - /// pass that wants to be recorded inside the stage's own pass rather than one of its own - - /// the entity draws - names this, so RenderTargetManager.DeclarePass coalesces instead of - /// ending the rendering scope and starting another. + /// The (context, current target) pass name. The entity route records every entity of a stage + /// under it with the scope kept open, so RenderTargetManager.DeclarePass coalesces instead of + /// ending the rendering scope and starting another per entity. /// private string BoundPassName() { - int index = FrameBufferIndexOf(device!.BoundFramebufferId); + int id = CurrentTargetId; + int index = FrameBufferIndexOf(id); string target = index >= 0 ? index.ToString(CultureInfo.InvariantCulture) - : device.BoundFramebufferId == device.DefaultFramebufferId + : id == PassDeclaration.DefaultFramebuffer ? "Default" - : "fbo" + device.BoundFramebufferId.ToString(CultureInfo.InvariantCulture); + : "fbo" + id.ToString(CultureInfo.InvariantCulture); return passContext + "/" + target; } - /// Declares the (context, bound target) pass; a repeat of the current one changes nothing. - private void DeclareBoundPass() - { - if (device == null || !device.FrameGraphEnabled) return; - int index = FrameBufferIndexOf(device.BoundFramebufferId); - device.DeclarePass(new PassDeclaration - { - Name = BoundPassName(), - FramebufferId = PassDeclaration.BoundFramebuffer, - Reads = PassReads(passContext, index), - TransientSlots = PassTransientSlots(passContext, index), - Flags = passContextFlags, - }); - } - - /// - /// The final composition writes Primary 0 while sampling Primary 1: an attachment-subset - /// pass, Primary 1 out of the scope for the whole pass (one barrier each way per frame). - /// - private void DeclareFinalCompositionPass() - { - if (device == null || !device.FrameGraphEnabled) return; - if (passContext == "Post") - { - // The AO multiply before the TAA resolve shares the colour-0 mask, but - // samples only the blurred AO and preserves every other Primary attachment. - var reads = new List(); - if (ambientOcclusionOutput != 0) - { - // GTAO: the visibility texture, and the attenuation inputs the OPTIMUMAO compose reads. - reads.Add(ambientOcclusionOutput); - } - else - { - AddColour(reads, SsaoBlurVerticalIndex, 0); - } - if (Vintagestory.API.Config.OptimumConfig.AmbientOcclusionShadersUseGtao) - { - AddColour(reads, PrimaryIndex, 3); - AddColour(reads, TransparentIndex, 1); - } - device.DeclarePass(new PassDeclaration - { - Name = "SceneSsao/0", - FramebufferId = PassDeclaration.BoundFramebuffer, - ColorSlots = 1u, - Reads = reads.ToArray(), - Flags = PassFlags.None, - }); - return; - } - device.DeclarePass(new PassDeclaration - { - Name = "FinalComposition/0", - FramebufferId = PassDeclaration.BoundFramebuffer, - ColorSlots = ~(1u << 1), - Reads = PassReads("FinalComposition", PrimaryIndex), - Flags = PassFlags.None, - }); - } - private int FrameBufferIndexOf(int framebufferId) { List buffers = FrameBuffers; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs index f1114f6e..4744f392 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -29,7 +29,7 @@ public override void SetDepthRange(float near, float far) /// glClearBuffer clamps a depth clear value to [0, 1]; the device takes the clamped value. public override void ClearDefaultDepth(float depth) { - device.ClearDepth(Math.Clamp(depth, 0f, 1f)); + ClearTargetDepth(CurrentTargetId, Math.Clamp(depth, 0f, 1f)); } /// The shared index buffer is a device mesh handle on this path. @@ -162,11 +162,11 @@ public override void BeginOitAccumulation(FrameBufferRef transparent) NoteNativeTransparentBlend(4, 32774, 1, 1, 1, 1); NoteNativeTransparentBlend(5, 32774, 1, 1, 1, 1); nativeTransparentSlots = 0x3F; - device.ClearColor(0, 1f, 1f, 1f, 1f); - device.ClearColor(1, 1f, 1f, 1f, 1f); - device.ClearColor(3, 0f, 0f, 0f, 0f); - device.ClearColor(4, 0f, 0f, 0f, 0f); - device.ClearColor(5, 0f, 0f, 0f, 0f); + ClearTargetColor(transparent.FboId, 0, 1f, 1f, 1f, 1f); + ClearTargetColor(transparent.FboId, 1, 1f, 1f, 1f, 1f); + ClearTargetColor(transparent.FboId, 3, 0f, 0f, 0f, 0f); + ClearTargetColor(transparent.FboId, 4, 0f, 0f, 0f, 0f); + ClearTargetColor(transparent.FboId, 5, 0f, 0f, 0f, 0f); } /// Units 6 and 7; the device binds by unit whatever the texture's dimensionality. @@ -174,8 +174,6 @@ public override void BindOitTextures(int revealTexture, int accumTexture) { stated.BindTexture(6, revealTexture); stated.BindTexture(7, accumTexture); - device.BindTexture(6, revealTexture); - device.BindTexture(7, accumTexture); } public override int GenOcclusionQuery() @@ -214,9 +212,9 @@ public override void DeleteOcclusionQuery(int queryId) } /// - /// The device reads back the colour target it has bound, which is the same image GL - /// would read from the bound framebuffer and in the same orientation - the one flip - /// happens at present, after this. + /// The device reads back colour attachment 0 of the target the client has current, which is + /// the same image GL would read from the bound framebuffer and in the same orientation - the + /// one flip happens at present, after this. /// /// Channel order is converted here. The OpenGL body of this virtual is /// glReadPixels(..., GL_BGRA, ...), and its callers depend on that: @@ -235,7 +233,7 @@ public override void DeleteOcclusionQuery(int queryId) /// public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) { - device.ReadDefaultFramebuffer(x, y, width, height, destination); + device.ReadFramebufferColor(CurrentTargetId, x, y, width, height, destination); if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; if (device.DefaultColorFormat is Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb) return; PixelOrder.SwapRedAndBlue(destination, (long)width * height); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs index 95f1d75a..4a5757aa 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -80,25 +80,15 @@ public override void RenderMesh(MeshRef modelRef) return; } if (TryRenderStandardMeshNative(modelRef)) return; - if (TryDrawStated(vAO, 1, null, null, 0)) - { - RuntimeStats.drawCallsCount--; // the stated route counted it already - return; - } - device.DrawMesh(vAO.VaoId); + RuntimeStats.drawCallsCount--; // the stated route counts what it records + TryDrawStated(vAO, 1, null, null, 0); } public override void RenderFullscreenTriangle(MeshRef modelRef) { - RuntimeStats.drawCallsCount++; // The post passes generate their three vertices in the shader, so the // mesh carries no buffers and none are bound. - if (TryDrawStated(null, 1, null, null, 0)) - { - RuntimeStats.drawCallsCount--; - return; - } - device.DrawFullscreenTriangle(); + TryDrawStated(null, 1, null, null, 0); } public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSizes, int groupCount, bool useSSBOs) @@ -107,8 +97,7 @@ public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSi VAO vAO = (VAO)modelRef; // Phase 3b stage 2: inside a ChunkRenderer draw group this is a native multi-draw of // the pool, recorded by VulkanClientPlatform.NativeChunks.cs. Outside one - the decal - // pool, a mod's pool, or with NativeChunksEnabled off - it is the emulated route the - // OpenGL body takes. + // pool, a mod's pool, or with NativeChunksEnabled off - it is the generic stated draw. if (TryDrawChunkPoolNative(vAO, indices, indicesSizes, groupCount)) return; // Phase 3b stage 2: inside SystemRenderDecals' BeginDecalPass/EndDecalPass scope this is @@ -116,15 +105,10 @@ public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSi // VulkanClientPlatform.NativeWorld.cs records it as a native pass. if (TryDrawDecalPoolNative(modelRef, indices, indicesSizes, groupCount)) return; - // The chunk renderer's one multidraw per pool. GL takes byte offsets - // into the index buffer; the device converts them to index counts and - // issues a single indirect draw. - if (TryDrawStated(vAO, 1, indices, indicesSizes, groupCount)) - { - RuntimeStats.drawCallsCount--; - return; - } - device.DrawMeshMulti(vAO.VaoId, indices, indicesSizes, groupCount, useSSBOs); + // Any other pool: one indirect multi-draw from the stated state. GL takes byte offsets + // into the index buffer; the device converts them to index counts. + RuntimeStats.drawCallsCount--; + TryDrawStated(vAO, 1, indices, indicesSizes, groupCount); } public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) @@ -132,8 +116,8 @@ public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) RuntimeStats.drawCallsCount++; VAO vAO = (VAO)modelRef; if (TryRenderParticles2dNative(modelRef, quantity)) { RuntimeStats.drawCallsCount--; return; } - if (quantity > 0 && TryDrawStated(vAO, quantity, null, null, 0)) { RuntimeStats.drawCallsCount--; return; } - device.DrawMeshInstanced(vAO.VaoId, quantity); + RuntimeStats.drawCallsCount--; + if (quantity > 0) TryDrawStated(vAO, quantity, null, null, 0); } public override void UpdateMesh(MeshRef modelRef, MeshData data) diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs index 5554491f..6c0ee5c3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs @@ -88,7 +88,6 @@ internal void RunModPasses(EnumRenderStage stage) } passContext = outer; passContextFlags = outerFlags; - // Rebinding re-declares the stage's pass on the target the renderers left bound. CurrentFrameBuffer = saved; } @@ -106,13 +105,17 @@ private void RunModPass(OptimumPassRegistration registration) passContext = "Mod/" + registration.ModId + "/" + decl.Name; passContextFlags = ModPassFlags; - // The platform setter binds and declares the context's pass on the target; the declaration - // below narrows it to the declared slots and reads under the same name. + // The declared slots are the draw buffers the mod's draws write, for this pass only; every + // draw inside it is recorded under the declaration (name, slots, reads, flags) on the + // plan's target by the generic stated route. + int targetId = plan.Target?.FboId ?? PassDeclaration.DefaultFramebuffer; + uint savedDrawBuffers = stated.DrawBuffers(targetId); CurrentFrameBuffer = plan.Target!; - device.DeclarePass(plan.Declaration); + stated.SetDrawBuffers(targetId, plan.Declaration.ColorSlots); bool motion = false; CurrentModPass = plan.Declaration.Name; + statedPass = plan.Declaration; try { if (decl.MotionWriter != null) @@ -132,7 +135,8 @@ private void RunModPass(OptimumPassRegistration registration) { if (motion) EndMotionWrite(); CurrentModPass = null; - device.EndPass(); + statedPass = null; + stated.SetDrawBuffers(targetId, savedDrawBuffers); } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs index 896c8eba..6e5d33ed 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs @@ -144,7 +144,7 @@ private bool BeginNativeBlitPass(string name, int framebufferId, int width, int }); /// - /// Leaves the GL-emulation state where the OpenGL body leaves it, so everything the + /// Leaves the stated state where the OpenGL body leaves it, so everything the /// client draws after the blit (the ortho GUI pass) sees what it always saw: the Default /// target bound, the viewport on the window, and blending back on where the body turned /// it off. Outside every native pass. diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs index b18e4f06..c8d5c1dc 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs @@ -59,9 +59,9 @@ namespace Optimum.Render.Vulkan.Platform; public partial class VulkanClientPlatform { /// - /// False runs the chunk groups through the emulated multi-draw on the Vulkan device - - /// the route the OpenGL body takes - instead of the native pass: the old route the - /// differential test compares against, in the pattern of . + /// False runs the chunk groups through the generic stated multi-draw instead of the native + /// pass: the route the differential test compares against, in the pattern of + /// . /// internal bool NativeChunksEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_CHUNKS") != "0"; @@ -150,10 +150,10 @@ internal void NoteNativeProgramTexture(int programId, string samplerName, int te internal void NoteNativeTransparentBlend(int slot, int glEquation, int srcColor, int dstColor, int srcAlpha, int dstAlpha) { - if ((uint)slot >= GlStateTracker.MaxColorAttachments) return; + if ((uint)slot >= RenderLimits.MaxColorAttachments) return; if (nativeTransparentBlend == null) { - nativeTransparentBlend = new AttachmentBlend[GlStateTracker.MaxColorAttachments]; + nativeTransparentBlend = new AttachmentBlend[RenderLimits.MaxColorAttachments]; for (int i = 0; i < nativeTransparentBlend.Length; i++) { nativeTransparentBlend[i] = AttachmentBlend.Default; @@ -213,10 +213,6 @@ public override void EndChunkPass() { chunkScopePassOpen = false; device.EndNativePass(); - // Every renderer after this group draws into the same target through the emulated - // path, so the stage's own pass context is declared again - the chunk pass replaced - // it, exactly as the sky pass does with the context it interrupts. - if (chunkScopeTarget != null) device.BindFramebuffer(chunkScopeTarget.FboId); SetPassContext(chunkScopeOuterContext, chunkScopeOuterFlags); } chunkScopeTarget = null; @@ -224,10 +220,9 @@ public override void EndChunkPass() /// /// One chunk pool's multi-draw, recorded natively. False means the group is not in a native - /// scope, or the first draw of one could not be recorded, and the caller takes the emulated - /// route. Once the scope's pass is open the native route owns the group: a draw the device - /// skips (a pipeline still compiling) is the same skip the emulated path makes, and an - /// emulated draw inside an open native pass is not a thing this backend allows. + /// scope, or the first draw of one could not be recorded, and the caller takes the generic + /// stated route. Once the scope's pass is open the native route owns the group: a draw the + /// device skips (a pipeline still compiling) is skipped, not moved to another route. /// internal bool TryDrawChunkPoolNative(VAO vao, int[] indicesStarts, int[] indicesSizes, int groupCount) { @@ -266,7 +261,7 @@ internal bool TryDrawChunkPoolNative(VAO vao, int[] indicesStarts, int[] indices /// private bool OpenChunkPass(FrameBufferRef target, int textureCount) { - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); chunkScopeOuterContext = passContext; chunkScopeOuterFlags = passContextFlags; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs index 44d4b04d..be1d988e 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs @@ -26,7 +26,7 @@ namespace Optimum.Render.Vulkan.Platform; // Pinned by Optimum.Tests/native-world-systems-coverage-tests.cs. public partial class VulkanClientPlatform { - /// False keeps both cloud draws on the emulated route (OPTIMUM_VK_NATIVE_CLOUDS=0). + /// False sends both cloud draws to the generic stated route (OPTIMUM_VK_NATIVE_CLOUDS=0). internal bool NativeCloudsEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_CLOUDS") != "0"; private readonly NativeMeshPass nativeCloudMap = @@ -60,7 +60,7 @@ internal void NoteForkBlend(bool enabled) stated.SetBlendEnabled(enabled); } - /// A cloud renderer's RenderMesh: the native pass, or false for the emulated draw. + /// A cloud renderer's RenderMesh: the native pass, or false for the generic stated draw. private bool TryRenderCloudsNative(MeshRef mesh) { ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; @@ -108,7 +108,7 @@ private bool DrawCloudMapNative(ShaderProgramBase program, MeshRef mesh) RuntimeStats.drawCallsCount++; string outer = passContext; PassFlags outerFlags = passContextFlags; - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); bool drawn = false; if (device.BeginNativePass(new NativePassDescription { @@ -126,8 +126,6 @@ private bool DrawCloudMapNative(ShaderProgramBase program, MeshRef mesh) drawn = device.DrawNativeMesh(pipeline, vao.VaoId, textures); } device.EndNativePass(); - // The fork still considers its framebuffer bound until its own restore. - device.BindFramebuffer(framebufferId); SetPassContext(outer, outerFlags); return drawn; } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index ff567303..47f05521 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -13,7 +13,7 @@ namespace Optimum.Render.Vulkan.Platform; // // Two of the systems in that group draw through the native device API here. The rest of the // group - Render2DTexture's gui quads, guigear, the block highlights, the wireframe cube and -// the camera path - stay on the emulated route for now, and the reason is written down in +// the camera path - take the generic stated route (NativeStated.cs), and the reason is written down in // docs/vulkan-native-render-systems.md section 3c: their blend and depth state is not the // caller's, it is whatever the frame left on the tracker, and the same Render2DTexture call is // reached both with standard alpha and with premultiplied alpha (RenderAPIGame's @@ -194,7 +194,7 @@ private bool TryRenderMinimalGuiNative(MeshRef mesh) /// /// A plain RenderMesh under the vanilla gui program, recorded natively under the state the /// client stated: blend, depth, depth function, scissor, cull, line width. The sampled - /// textures are the program's declared ones. False: the caller runs the emulated draw. + /// textures are the program's declared ones. False: the caller runs the generic stated draw. /// Called from VulkanClientPlatform.RenderMesh; the seams' neutral bodies reach it too, which /// is why it honours itself. /// @@ -299,7 +299,7 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, RuntimeStats.drawCallsCount++; string outer = passContext; PassFlags outerFlags = passContextFlags; - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); bool recorded = false; if (device.BeginNativePass(new NativePassDescription { @@ -325,9 +325,8 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, device.EndNativePass(); // Whatever the stage was drawing into before this pass keeps drawing into it through - // the emulated route, so its own pass context is declared again - the same restore the + // the generic stated route, so its own pass context is restored - the same restore the // sky pass and the TAA resolve do. - if (target != null) device.BindFramebuffer(target.FboId); SetPassContext(outer, outerFlags); return recorded; } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs index c83f71fc..069cfd2a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -578,7 +578,7 @@ private bool NativeMotionAttachmentWritable(FrameBufferRef primary) private static int NativeSlotCount(uint slots) { int count = 0; - for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) + for (int i = 0; i < RenderLimits.MaxColorAttachments; i++) { if (((slots >> i) & 1) != 0) count = i + 1; } @@ -591,7 +591,7 @@ private static int NativeSlotCount(uint slots) /// private bool BeginNativeKeepViewportPass(string name, int framebufferId, uint colorSlots, int[] reads) { - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); return device.BeginNativePass(new NativePassDescription { Name = name, diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs index f812bba2..38bb38a6 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs @@ -231,7 +231,7 @@ public override void RenderSkyDome(MeshRef skyDome, int skyTextureId, int glowTe RuntimeStats.drawCallsCount++; string outer = passContext; PassFlags outerFlags = passContextFlags; - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); if (device.BeginNativePass(new NativePassDescription { Name = "Sky/" + target.FboId, @@ -260,11 +260,6 @@ public override void RenderSkyDome(MeshRef skyDome, int skyTextureId, int glowTe } device.EndNativePass(); - // Every renderer after this one in the Opaque stage draws into the same target through - // the emulated path, so the stage's own pass context is declared again - the sky pass - // replaced it, exactly as the TAA resolve's native pass does with the context it - // interrupts. - device.BindFramebuffer(target.FboId); SetPassContext(outer, outerFlags); } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs index 47da802a..51dd0e23 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs @@ -10,44 +10,34 @@ namespace Optimum.Render.Vulkan.Platform; // The generic native draw: any program, drawn with the state the client stated through this -// platform's virtuals (StatedRenderState) - the last route before the emulated one. The dedicated +// platform's virtuals (StatedRenderState) - the last route of every draw virtual. The dedicated // routes (chunks, entities, sky, particles, GUI, clouds, the post chain) keep their own contracts // and run first; this one takes everything they do not recognise: mod renderers with their own // programs, the vanilla programs without a dedicated route (aurora, block highlights, held item, // lines, wireframe, the debug views) and the seams' neutral bodies behind the route switches. // -// What it states, and from where (all client statements, none read back from the device): -// - target: the framebuffer a fork renderer bound by id, else CurrentFrameBuffer, else the default; -// every colour slot of the target is in the pass, and the draw buffers the client selected for -// that framebuffer become per-attachment write masks (draw buffers are write masks, decision 4); -// - blend, colour mask, depth, cull, line width, polygon mode, viewport and scissor: StatedRenderState, -// with OpenGL's semantics (a disabled blend keeps its functions, glBlendFunc sets every draw buffer); -// - textures: per sampler, the texture on the unit the program points it at (its SetSamplerUnit -// mapping, else the sampler's declaration order - the same resolution the emulated draw makes), -// with the unit's standalone sampler override if one is bound; -// - the depth attachment of the target sampled with depth writes off is read in the read-only -// layout (SamplesBoundDepth), as the emulated draw does. -// Stencil is not applied: no framebuffer of this client has a stencil attachment, so a stencil -// test passes on either path (StatedRenderState). -// -// OPTIMUM_VK_NATIVE_STATED=0 sends these draws to the emulated route; OPTIMUM_VK_STATED_CHECK=1 -// compares every stated value against the device's tracked state at each draw and logs each -// distinct mismatch once - the evidence that the device state can go. +// What it states, and from where: StatedDraw (the target the client addressed, its attached colour +// slots, the stated fixed-function state and texture units). All of it is client statements, none +// read back from the device. +// The clears (ClearTargetColor, ClearTargetDepth) are stated the same way: an explicit target, and +// OpenGL's rules - a clear writes only a selected draw buffer, not through an all-false colour +// mask, and a depth clear honours the depth mask. No state reaches the device any more; a draw +// this route cannot record is dropped and reported once. // Pinned by Optimum.Tests/native-world-systems-coverage-tests.cs. public partial class VulkanClientPlatform { /// The fixed-function state the client stated, with OpenGL's semantics. internal readonly StatedRenderState stated = new(); - internal bool NativeStatedEnabled { get; set; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_STATED") != "0"; - - private static readonly bool StatedCheck = Environment.GetEnvironmentVariable("OPTIMUM_VK_STATED_CHECK") == "1"; + private readonly HashSet statedRefusalReported = new(); - private readonly HashSet statedCheckReported = new(StringComparer.Ordinal); + /// The program the client last used (glUseProgram); 0: none. + internal int statedProgram; - private readonly HashSet statedRefusalReported = new(); + /// The pass a running mod pass declared: the generic draws inside it are recorded under it. + internal PassDeclaration? statedPass; - /// Draws the generic route recorded, and draws it handed back to the emulated route. Tests and the trace read them. + /// Draws the generic route recorded, and draws it could not record. Tests read them. internal long StatedDrawsForTests { get; private set; } internal long StatedRefusalsForTests { get; private set; } @@ -58,22 +48,18 @@ public partial class VulkanClientPlatform internal void StateDrawBuffers(int framebufferId, int mask) { stated.SetDrawBuffers(framebufferId == 0 ? PassDeclaration.DefaultFramebuffer : framebufferId, (uint)mask); - device.SetDrawBuffers(framebufferId, mask); } /// glBlendEquationi + glBlendFuncSeparatei. internal void StateSlotBlend(int slot, int equation, int srcColor, int dstColor, int srcAlpha, int dstAlpha) { stated.SetSlotBlend(slot, equation, srcColor, dstColor, srcAlpha, dstAlpha); - device.SetBlendEquation(slot, equation); - device.SetBlendFuncSeparate(slot, srcColor, dstColor, srcAlpha, dstAlpha); } /// glBlendFuncSeparatei alone: the attachment's equation stays. internal void StateSlotBlendFunc(int slot, int srcColor, int dstColor, int srcAlpha, int dstAlpha) { stated.SetSlotFunc(slot, srcColor, dstColor, srcAlpha, dstAlpha); - device.SetBlendFuncSeparate(slot, srcColor, dstColor, srcAlpha, dstAlpha); } /// Blend on with a mode's functions on every attachment, or off with the functions kept. @@ -81,7 +67,6 @@ internal void StateBlend(bool on, EnumBlendMode mode) { stated.SetBlendEnabled(on); if (on) stated.SetBlendMode(mode); - device.SetBlend(on, mode); } /// A viewport the client states (the fork bridge, and the platform's own full-target binds). @@ -93,143 +78,76 @@ internal void NoteForkViewport(int x, int y, int width, int height) => internal void NoteForkDrawBuffers(int framebufferId, int mask) => stated.SetDrawBuffers(framebufferId == 0 ? PassDeclaration.DefaultFramebuffer : framebufferId, (uint)mask); + /// The viewport the client last stated: what every native pass that keeps the viewport draws with. + private Rect2D StatedViewport() => stated.Viewport; + + /// The target the client's next draw or clear addresses: a fork's bound id, else CurrentFrameBuffer, else the default. + internal int CurrentTargetId => forkFramebuffer > 0 + ? forkFramebuffer + : CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : PassDeclaration.DefaultFramebuffer; + + /// A colour clear as OpenGL does it: only a selected draw buffer, never through an all-false colour mask. + internal void ClearTargetColor(int framebufferId, int slot, float r, float g, float b, float a) + { + if (framebufferId == 0) framebufferId = PassDeclaration.DefaultFramebuffer; + if (((stated.DrawBuffers(framebufferId) >> slot) & 1) == 0 || stated.ColorMask == 0) return; + device.ClearNativeColor(framebufferId, slot, r, g, b, a); + } + + /// A depth clear as OpenGL does it: not with depth writes off. + internal void ClearTargetDepth(int framebufferId, float depth) + { + if (framebufferId == 0) framebufferId = PassDeclaration.DefaultFramebuffer; + if (!stated.DepthWrite) return; + device.ClearNativeDepth(framebufferId, depth); + } + // ------------------------------------------------------------------------- the route /// /// Records one draw of the current program natively from the stated state. A null /// is the fullscreen triangle; is a - /// pool's multi-draw. False: nothing was recorded and the caller runs the emulated draw. + /// pool's multi-draw. False: nothing was recorded (reported once per program). /// private bool TryDrawStated(VAO? vao, int instances, int[]? starts, int[]? sizes, int groupCount) { - if (!NativeStatedEnabled || device == null) return false; - ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; - if (program == null || program.ProgramId <= 0) return false; if (vao != null && (vao.VaoId == 0 || vao.Disposed)) return false; + return RecordStatedDraw(vao?.VaoId ?? 0, instances, starts, sizes, groupCount); + } - // The target and every colour slot attached to it on the device. Not the FrameBufferRef's own - // list: the OIT accumulation targets are attached to Transparent at slots 3-5 without being in - // its ColorTextureIds, and a pass without them drops the accumulated colour (2026-09-17: water - // drew black through this route until the slots came from the attachments). - FrameBufferRef? target = forkFramebuffer > 0 ? null : CurrentFrameBuffer; - int framebufferId = forkFramebuffer > 0 - ? forkFramebuffer - : target != null ? target.FboId : PassDeclaration.DefaultFramebuffer; - RenderTargetFormats? all = device.NativeTargetFormats(framebufferId, uint.MaxValue); - if (all == null) return Refuse(program, "framebuffer " + framebufferId + " does not exist"); - int attached = all.ColorFormats.Length; - uint slots = attached >= 32 ? uint.MaxValue : (1u << attached) - 1u; - RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, slots); - if (formats == null) return Refuse(program, "no formats for framebuffer " + framebufferId); - - int layoutId = vao != null ? device.NativeMeshLayoutId(vao.VaoId) : MeshManager.EmptyLayoutId; - if (layoutId < 0) return Refuse(program, "the mesh has no layout"); - - // Every sampler the program declares, from the unit it points at. - List names = device.SamplerNamesOf(program.ProgramId); - int depthTexture = device.NativeFramebufferDepthTexture(framebufferId); - bool samplesBoundDepth = false; - var reads = new int[names.Count]; - var units = new int[names.Count]; - for (int i = 0; i < names.Count; i++) - { - units[i] = device.NativeSamplerUnit(program.ProgramId, names[i]); - reads[i] = stated.TextureAt(units[i]); - if (reads[i] != 0 && reads[i] == depthTexture) - { - if (stated.DepthWrite && stated.DepthTest) - { - return Refuse(program, "it samples the depth attachment it writes"); - } - samplesBoundDepth = true; - } - } - - var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; - for (int i = 0; i < blend.Length; i++) blend[i] = stated.AttachmentFor(framebufferId, i); - - var description = new NativePipelineDescription - { - ProgramId = program.ProgramId, - Blend = blend, - DepthTest = stated.DepthTest, - DepthWrite = stated.DepthWrite && !samplesBoundDepth, - DepthCompare = stated.DepthCompare, - Cull = stated.CullMode, - Topology = vao != null ? device.NativeMeshTopology(vao.VaoId) : PrimitiveTopology.TriangleList, - PolygonMode = stated.Wireframe ? PolygonMode.Line : PolygonMode.Fill, - LineWidth = stated.LineWidth, - VertexLayoutId = layoutId, - SamplesBoundDepth = samplesBoundDepth, - Targets = formats, - }; - NativePipeline? pipeline = device.RequestNativePipeline(description, out string error); - if (pipeline == null) return Refuse(program, error); - - var textures = new NativeTexture[names.Count]; - for (int i = 0; i < names.Count; i++) - { - int sampler = stated.SamplerAt(units[i]); - textures[i] = new NativeTexture(pipeline.Sampler(names[i]), reads[i], - sampler != 0 ? device.NativeStandaloneSampler(sampler) : null); - } - - if (StatedCheck) CheckStatedAgainstDevice(program, framebufferId, blend, description, textures); + /// The generic draw of a device mesh (0: the fullscreen triangle) into the current target. + internal bool RecordStatedDraw(int meshId, int instances, int[]? starts, int[]? sizes, int groupCount) + { + if (device == null || statedProgram <= 0) return false; + int programId = statedProgram; + int framebufferId = CurrentTargetId; RuntimeStats.drawCallsCount++; string outer = passContext; PassFlags outerFlags = passContextFlags; - Rect2D viewport = stated.Viewport; - bool drawn = false; - if (device.BeginNativePass(new NativePassDescription - { - Name = "Stated/" + framebufferId, - FramebufferId = framebufferId, - ColorSlots = slots, - Reads = reads, - Flags = PassFlags.AllowSplit, - ViewportX = viewport.Offset.X, - ViewportY = viewport.Offset.Y, - ViewportWidth = (int)viewport.Extent.Width, - ViewportHeight = (int)viewport.Extent.Height, - Scissor = stated.ScissorEnabled ? stated.Scissor : null, - })) + PassDeclaration? declared = statedPass != null && statedPass.FramebufferId == framebufferId ? statedPass : null; + bool drawn = StatedDraw.Record(device, stated, programId, framebufferId, + meshId, instances, starts, sizes, groupCount, out string? refusal, declared); + SetPassContext(outer, outerFlags); + if (drawn) { - drawn = vao == null - ? device.DrawNativeFullscreen(pipeline, textures) - : starts != null - ? device.DrawNativeMeshMulti(pipeline, vao.VaoId, starts, sizes!, groupCount, textures) - : device.DrawNativeMeshInstanced(pipeline, vao.VaoId, instances, textures); + StatedDrawsForTests++; + return true; } - device.EndNativePass(); - // The emulated calls a system makes between its draws still address the target it bound. - if (framebufferId == PassDeclaration.DefaultFramebuffer) device.BindDefaultFramebuffer(); - else device.BindFramebuffer(framebufferId); - SetPassContext(outer, outerFlags); - if (drawn) StatedDrawsForTests++; - else RuntimeStats.drawCallsCount--; - return drawn; + RuntimeStats.drawCallsCount--; + return refusal == null ? false : Refuse(programId, refusal); } - private bool Refuse(ShaderProgramBase program, string reason) + private bool Refuse(int programId, string reason) { StatedRefusalsForTests++; - if (statedRefusalReported.Add(program.ProgramId)) + if (statedRefusalReported.Add(programId)) { - Logger.Warning("Optimum: program '{0}' draws through the emulated route: {1}", program.PassName ?? "", reason); + ShaderProgramBase? current = ShaderProgramBase.CurrentShaderProgram; + string name = current != null && current.ProgramId == programId ? current.PassName ?? "" : "#" + programId; + Logger.Warning("Optimum: a draw of program '{0}' was dropped: {1}", name, reason); } return false; } - private void CheckStatedAgainstDevice(ShaderProgramBase program, int framebufferId, AttachmentBlend[] blend, - NativePipelineDescription description, NativeTexture[] textures) - { - List mismatches = device.DebugStatedMismatches(program.ProgramId, framebufferId, blend, description, - stated.Viewport, stated.ScissorEnabled, stated.Scissor, textures); - foreach (string mismatch in mismatches) - { - string key = (program.PassName ?? "") + ": " + mismatch; - if (statedCheckReported.Add(key)) Logger.Warning("Optimum stated check: {0}", key); - } - } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index 85434b2b..f87c9135 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -233,7 +233,7 @@ private bool NativeWorldPrepare(NativeMeshPass pass, MeshRef mesh, bool blending /// private bool NativeWorldBeginPass(string name, FrameBufferRef target, uint slots, int[] reads) { - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); return device.BeginNativePass(new NativePassDescription { Name = name + "/" + target.FboId, @@ -250,13 +250,12 @@ private bool NativeWorldBeginPass(string name, FrameBufferRef target, uint slots /// /// Closes the native pass and declares the stage's own pass context again, because every - /// renderer after this one draws into the same target through the emulated path - the same + /// renderer after this one draws into the same target through the generic stated route - the same /// restoration the sky dome's pass and the TAA resolve's do. /// private void NativeWorldEndPass(FrameBufferRef target, string outer, PassFlags outerFlags) { device.EndNativePass(); - device.BindFramebuffer(target.FboId); SetPassContext(outer, outerFlags); } @@ -599,7 +598,7 @@ private bool TryRenderStandardMeshToDefault(ShaderProgramBase program, MeshRef m string outer = passContext; PassFlags outerFlags = passContextFlags; - Rect2D viewport = device.NativeCurrentViewport; + Rect2D viewport = StatedViewport(); bool drawn = false; if (device.BeginNativePass(new NativePassDescription { @@ -680,7 +679,7 @@ public override void EndDecalPass() /// The decal pool's multi-draw, recorded natively, when it arrives through /// inside an open decal scope. /// False means the scope is closed, the native route is off, or the pass could not be - /// prepared, and the caller takes the emulated multi-draw the OpenGL body takes. + /// prepared, and the caller takes the generic stated multi-draw. /// internal bool TryDrawDecalPoolNative(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount) { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs index ffd43c9e..a394670d 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs @@ -58,9 +58,14 @@ public override int GetUniformLocation(ShaderProgram program, string name) return device.GetUniformLocation(program.ProgramId, name); } + /// + /// glUseProgram, recorded: the generic stated draw draws this program. The dedicated native + /// routes read ShaderProgramBase.CurrentShaderProgram, which ShaderProgramBase.Use and Stop set + /// right after this call, so the two never disagree in the client. + /// public override void UseShaderProgram(int programId) { - device.UseProgram(programId); + statedProgram = programId; } /// @@ -80,7 +85,6 @@ public override void DisposeShaderProgram(ShaderProgramBase program) public override void BindSampler(int unit, int samplerId) { stated.BindSampler(unit, samplerId); - device.BindSampler(unit, samplerId); } public override void SetUniform(int programId, int location, float value) @@ -178,24 +182,20 @@ public override void BindProgramTexture2D(ShaderProgramBase program, string samp // Phase 3b stage 2: this is where the client states which texture a sampler reads - // "this program's sampler is this texture" - so it is where a native pass takes // the handle from (VulkanClientPlatform.NativeChunks.cs, .NativeEntities.cs). It is not - // the texture-unit table: no unit is involved, and the native path never reads one - // (docs/vulkan-native-render-systems.md, decision 3). The unit binding below still - // happens, so the emulated route is unchanged. + // the device's texture-unit table: the unit binding is recorded in the platform's stated + // state, which the generic native draw resolves samplers through. NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); stated.BindTexture(textureNumber, textureId); - device.BindTexture(textureNumber, textureId); if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) { stated.BindSampler(textureNumber, optimumSampler); - device.BindSampler(textureNumber, optimumSampler); } else { // Clear any override left on this unit, or the texture's own // filtering would be silently ignored. stated.BindSampler(textureNumber, 0); - device.BindSampler(textureNumber, 0); } if (program.clampTToEdge) { @@ -210,7 +210,6 @@ public override void BindProgramTextureCube(ShaderProgramBase program, string sa NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); stated.BindTexture(textureNumber, textureId); - device.BindTextureCube(textureNumber, textureId); if (program.clampTToEdge) { device.SetTextureParameter(textureId, diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs index 8d0325f6..26b3af66 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -127,24 +127,22 @@ public override int GenSampler(bool linear) public override void GLWireframes(bool toggle) { stated.Wireframe = toggle; - device.SetWireframe(toggle); } public override void GlViewport(int x, int y, int width, int height) { stated.Viewport = new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); - device.SetViewport(x, y, width, height); } public override void GlScissor(int x, int y, int width, int height) { - // Clipped the way GlStateTracker.SetScissor clips, kept as client state for native passes. + // Clipped to the positive quadrant (Vulkan rejects a negative offset; GL keeps the visible + // remainder), kept as client state for native passes. int clippedX = Math.Max(0, x); int clippedY = Math.Max(0, y); statedScissor = new Rect2D(new Offset2D(clippedX, clippedY), new Extent2D((uint)Math.Max(0, width - (clippedX - x)), (uint)Math.Max(0, height - (clippedY - y)))); stated.Scissor = statedScissor; - device.SetScissor(x, y, width, height); } // The fixed state the client last stated through this platform's own virtuals. A native pass @@ -164,28 +162,24 @@ public override void GlScissorFlag(bool enable) { scissorEnabled = enable; stated.ScissorEnabled = enable; - device.SetScissorEnabled(enable); } public override void GlEnableDepthTest() { statedDepthTest = true; stated.DepthTest = true; - device.SetDepthTest(true); } public override void GlDisableDepthTest() { statedDepthTest = false; stated.DepthTest = false; - device.SetDepthTest(false); } public override void BindTexture2d(int texture) { // The GL body activates unit 0 first, so this binds to unit 0 too. stated.BindTexture(0, texture); - device.BindTexture(0, texture); // Remembered for GlGenerateTex2DMipmaps, whose GL form acts on // whatever is bound and so has no argument to route. boundTexture2d = texture; @@ -194,14 +188,12 @@ public override void BindTexture2d(int texture) public override void BindTextureCubeMap(int texture) { stated.BindTexture(0, texture); - device.BindTextureCube(0, texture); } public override void UnBindTextureCubeMap() { // Mirrors BindTextureCubeMap above, which binds to unit 0. stated.BindTexture(0, 0); - device.BindTextureCube(0, 0); } public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendMode.Standard) @@ -220,17 +212,6 @@ public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendM stated.SetSlotBlend(3, 32774, 1, 0, 1, 0); } } - device.SetBlend(on, blendMode); - if (on && OptimumRenderSsao) - { - // SSAO writes its position and normal attachments unblended, and - // the GL path expresses that by overriding attachments 2 and 3 - // after the global mode is set. - device.SetBlendEquation(2, 32774); - device.SetBlendFuncSeparate(2, 1, 0, 1, 0); - device.SetBlendEquation(3, 32774); - device.SetBlendFuncSeparate(3, 1, 0, 1, 0); - } // Optimum TAA (P3): the motion attachment never blends. A blended // motion vector averages two surfaces' displacements and belongs to // neither; the per-attachment override has to be re-applied after @@ -245,21 +226,18 @@ public override void GlDisableCullFace() { statedCull = false; stated.CullEnabled = false; - device.SetCullFace(false); } public override void GlEnableCullFace() { statedCull = true; stated.CullEnabled = true; - device.SetCullFace(true); } public override void GLLineWidth(float width) { statedLineWidth = width; stated.LineWidth = width; - device.SetLineWidth(width); } /// @@ -275,7 +253,6 @@ public override void GlDepthMask(bool flag) { statedDepthWrite = flag; stated.DepthWrite = flag; - device.SetDepthMask(flag); } public override void GlDepthFunc(EnumDepthFunction depthFunc) @@ -285,48 +262,40 @@ public override void GlDepthFunc(EnumDepthFunction depthFunc) // VintagestoryLib and the contracts assembly does not depend on it. statedDepthFunc = (int)depthFunc; stated.DepthCompare = GlEnums.CompareOpFrom((int)depthFunc); - device.SetDepthFunc((int)depthFunc); } public override void GlCullFaceBack() { statedCullBack = true; stated.CullBack = true; - device.SetCullFaceMode(true); } public override void GlCullFaceFront() { statedCullBack = false; stated.CullBack = false; - device.SetCullFaceMode(false); } public override void GlEnableStencilTest() { stated.StencilTest = true; - device.SetStencilTest(true); } public override void GlDisableStencilTest() { stated.StencilTest = false; - device.SetStencilTest(false); } public override void GlStencilMask(int mask) { - device.SetStencilMask(mask); } public override void GlStencilFunc(int func, int refVal, int mask) { - device.SetStencilFunc(func, refVal, mask); } public override void GlStencilOp(int sfail, int dpfail, int dppass) { - device.SetStencilOp(sfail, dpfail, dppass); } /// @@ -340,12 +309,10 @@ public override void GlColorMask(bool r, bool g, bool b, bool a) statedColorMaskOff = (r ? 0 : ColorComponentFlags.RBit) | (g ? 0 : ColorComponentFlags.GBit) | (b ? 0 : ColorComponentFlags.BBit) | (a ? 0 : ColorComponentFlags.ABit); stated.SetColorMask(r, g, b, a); - device.SetColorMask(r, g, b, a); } public override void GlClearStencil() { - device.ClearStencil(); } public override void GlGenerateTex2DMipmaps() diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index f478d4e9..03935af2 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -319,6 +319,7 @@ public override bool InitializeGraphics(IntPtr windowHandle, int width, int heig } this.device = device; + device.OwnerPlatform = this; // Phase 2 step 2: the stage bracket drives the frame graph's pass declarations. RenderStageListener = new FrameGraphStageListener(this); // Phase 5: registered mod motion writers reach this platform's motion window. diff --git a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs index fc5c900c..763bc822 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs @@ -11,10 +11,10 @@ namespace Optimum.Render.Vulkan.Platform; /// reference only the API and the contracts, so this is how they reach the device until /// Phase 5 ports them. /// -/// The state operations also record what the fork stated on the platform, as the platform's -/// own state virtuals do (VulkanClientPlatform.State.cs), so a native route that draws the -/// fork's RenderMesh (the cloud renderers, VulkanClientPlatform.NativeClouds.cs) runs with the -/// state the fork set and the target it bound, never with the GL state tracker's. +/// The state operations (binds, viewport, draw buffers, depth test, blend, texture units) are +/// recorded on the platform only, as the platform's own state virtuals are +/// (VulkanClientPlatform.State.cs): the native draws of the fork's RenderMesh read the state +/// the fork set and the target it bound from there. /// internal sealed class VulkanForkGraphics : OptimumForkGraphics { @@ -46,7 +46,6 @@ public override void SetTextureParameter(int textureId, int parameterName, int v public override void BindTexture(int unit, int textureId) { platform.NoteForkTexture(unit, textureId); - device.BindTexture(unit, textureId); } public override void DeleteTexture(int textureId) => device.DeleteTexture(textureId); @@ -59,39 +58,37 @@ public override void AttachTexture(int framebufferId, EnumFramebufferAttachment public override void SetDrawBuffers(int framebufferId, int attachmentMask) { platform.NoteForkDrawBuffers(framebufferId, attachmentMask); - device.SetDrawBuffers(framebufferId, attachmentMask); } public override void BindFramebuffer(int framebufferId) { platform.NoteForkFramebuffer(framebufferId); - device.BindFramebuffer(framebufferId); } public override void BindDefaultFramebuffer() { platform.NoteForkFramebuffer(0); - device.BindDefaultFramebuffer(); } - public override void DeleteFramebuffer(int framebufferId) => device.DeleteFramebuffer(framebufferId); + public override void DeleteFramebuffer(int framebufferId) + { + platform.stated.ForgetFramebuffer(framebufferId); + device.DeleteFramebuffer(framebufferId); + } public override void SetViewport(int x, int y, int width, int height) { platform.NoteForkViewport(x, y, width, height); - device.SetViewport(x, y, width, height); } public override void SetDepthTest(bool enabled) { platform.NoteForkDepthTest(enabled); - device.SetDepthTest(enabled); } public override void SetBlendEnabled(bool enabled) { platform.NoteForkBlend(enabled); - device.SetBlendEnabled(enabled); } public override int GetUniformLocation(int programId, string name) => device.GetUniformLocation(programId, name); diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index e6a050bd..81ebc428 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -46,8 +46,7 @@ internal readonly record struct NativeSamplerSlot(int Index, int PushOffset, int /// /// The fixed state a native pipeline is built for (docs/vulkan-native-render-systems.md, -/// decision 4). It is 's shape stated outright instead of read -/// back out of : per-attachment blend and colour write mask, +/// decision 4). It is 's shape stated outright: per-attachment blend and colour write mask, /// depth test/write/compare, cull, topology and the target's formats. /// internal sealed class NativePipelineDescription @@ -71,10 +70,10 @@ internal sealed class NativePipelineDescription /// /// The winding a front face has. The game never calls glFrontFace, so every vanilla system - /// states ; a native system that needs the other one + /// states ; a native system that needs the other one /// says so here rather than through a tracked toggle. /// - public FrontFace FrontFace = GlStateTracker.FrontFace; + public FrontFace FrontFace = RenderLimits.FrontFace; public PrimitiveTopology Topology = PrimitiveTopology.TriangleList; @@ -218,6 +217,12 @@ internal sealed class NativePassDescription /// public Rect2D? Scissor; + /// + /// A pass of the generic stated route (Platform/StatedDraw.cs) rather than of a dedicated + /// native system. It records the same way; only the test counters keep it apart. + /// + public bool Generic; + /// Negative: the full target. public int ViewportWidth = -1; public int ViewportHeight = -1; @@ -229,9 +234,9 @@ internal sealed class NativePassDescription /// /// A native system asks for a pipeline by program and fixed state, declares a pass with its /// target, its colour slots and the textures it reads, writes its uniforms by placement and -/// records draws. None of it consults , the texture-unit tables -/// or a draw-buffer mask; the emulation layer stays for mod renderers and for the vanilla -/// systems that have not moved yet. +/// records draws. Nothing else reaches the GPU: the device has no GL state machine, no +/// texture-unit tables and no draw-buffer mask. Mod renderers and the vanilla systems without a +/// dedicated route draw through the platform's generic native draw (Platform/StatedDraw.cs). /// public sealed unsafe partial class VulkanDevice { @@ -244,10 +249,10 @@ public sealed unsafe partial class VulkanDevice private NativePassDescription? _nativePass; private VulkanFramebuffer? _nativeTarget; - private long _emulationCalls; - private long _emulationCallsInNativePasses; private long _nativePasses; private long _nativeDraws; + private long _genericPasses; + private long _genericDraws; private long _nativeFullscreenDraws; private long _nativeMeshDraws; private long _nativeInstancedDraws; @@ -266,16 +271,14 @@ private readonly record struct NativePipelineCacheKey( int VertexLayoutId, PolygonMode PolygonMode, FrontFace FrontFace, float LineWidth, bool SamplesBoundDepth); - /// Calls into the GL-emulation layer (state, units, uniforms by location, draws). Tests only. - internal long EmulationCallsForTests => _emulationCalls; - - /// Calls into the GL-emulation layer while a native pass was open: must stay 0. Tests only. - internal long EmulationCallsInNativePassesForTests => _emulationCallsInNativePasses; - /// Native passes declared and native draws recorded (every kind). Tests only. internal long NativePassesForTests => _nativePasses; internal long NativeDrawsForTests => _nativeDraws; + /// Passes and draws of the generic stated route (Platform/StatedDraw.cs), counted apart from the above. Tests only. + internal long GenericPassesForTests => _genericPasses; + internal long GenericDrawsForTests => _genericDraws; + /// Native draws by kind: the fullscreen triangle, a mesh, an instanced mesh, a multi-draw. Tests only. internal long NativeFullscreenDrawsForTests => _nativeFullscreenDraws; internal long NativeMeshDrawsForTests => _nativeMeshDraws; @@ -285,13 +288,6 @@ private readonly record struct NativePipelineCacheKey( /// Distinct native pipelines this device holds. Tests only. internal int NativePipelinesForTests => _nativePipelines.Count; - /// Counts one entry into the GL-emulation layer, and whether it happened inside a native pass. - private void NoteEmulation() - { - _emulationCalls++; - if (_nativePass != null) _emulationCallsInNativePasses++; - } - /// Set 1's placeholders are written shader-read-only and never used any other way: put them there once. private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) { @@ -318,12 +314,7 @@ private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) VulkanFramebuffer? target = _targets.Get(ResolveNativeFramebuffer(framebufferId)); if (target == null) return null; - uint exclusion = 0; - for (int i = 0; i < GlStateTracker.MaxColorAttachments; i++) - { - if (((colorSlots >> i) & 1) == 0) exclusion |= 1u << i; - } - return _targets.ScopeFormats(target, exclusion); + return _targets.DeclaredFormats(target, colorSlots); } /// @@ -331,11 +322,9 @@ private void EnsureBindlessPlaceholdersReadable(CommandBuffer commandBuffer) /// OIT merge and sky motion bind their target without touching it, as the OpenGL body's /// bind-only setter does - states this as its own. /// - internal Rect2D NativeCurrentViewport => _state.Viewport; - /// /// The unit a program's sampler reads: the client's SetSamplerUnit mapping, else the sampler's - /// declaration order - the resolution the emulated draw makes. -1 for an unknown program or name. + /// declaration order - the resolution the removed emulated draw made. -1 for an unknown program or name. /// internal int NativeSamplerUnit(int programId, string samplerName) { @@ -352,77 +341,17 @@ internal int NativeSamplerUnit(int programId, string samplerName) internal SamplerState? NativeStandaloneSampler(int samplerId) => _standaloneSamplers.TryGetValue(samplerId, out SamplerState state) ? state : null; + /// The texture attached at a framebuffer's colour slot, 0 without one. + internal int NativeFramebufferColorTexture(int framebufferId, int slot) + { + VulkanFramebuffer? target = _targets.Get(ResolveNativeFramebuffer(framebufferId)); + return target != null && (uint)slot < (uint)target.Color.Length ? target.Color[slot].TextureId : 0; + } + /// The depth texture attached to a framebuffer, 0 without one. internal int NativeFramebufferDepthTexture(int framebufferId) => _targets.Get(ResolveNativeFramebuffer(framebufferId))?.DepthTextureId ?? 0; - /// - /// Temporary (removal of the emulation layer, step 1): every difference between what the platform - /// stated for a generic native draw and what the device's tracked GL state holds at that moment. - /// Deleted with the tracker. - /// - internal List DebugStatedMismatches(int programId, int framebufferId, AttachmentBlend[] blend, - NativePipelineDescription description, Rect2D viewport, bool scissorEnabled, Rect2D scissor, - NativeTexture[] textures) - { - var result = new List(); - if (_state.CurrentProgram != programId) result.Add("program: device " + _state.CurrentProgram + ", stated " + programId); - int resolved = ResolveNativeFramebuffer(framebufferId); - VulkanFramebuffer? bound = _targets.Bound; - if (bound == null || bound.Id != resolved) result.Add("target: device " + (bound?.Id ?? 0) + ", stated " + resolved); - uint drawBuffers = bound?.DrawBufferMask ?? 0; - for (int i = 0; i < blend.Length; i++) - { - AttachmentBlend device = _state.BlendFor(i); - ColorComponentFlags deviceMask = ((drawBuffers >> i) & 1) != 0 ? _state.ColorMask : 0; - if (deviceMask != blend[i].WriteMask) result.Add("slot " + i + " write mask: device " + deviceMask + ", stated " + blend[i].WriteMask); - if (device.Enabled != blend[i].Enabled) result.Add("slot " + i + " blend enable: device " + device.Enabled + ", stated " + blend[i].Enabled); - else if (device.Enabled && (device.SrcColor != blend[i].SrcColor || device.DstColor != blend[i].DstColor || - device.SrcAlpha != blend[i].SrcAlpha || device.DstAlpha != blend[i].DstAlpha || - device.ColorOp != blend[i].ColorOp || device.AlphaOp != blend[i].AlphaOp)) - { - result.Add("slot " + i + " blend: device " + device.SrcColor + "/" + device.DstColor + " " + device.ColorOp + - ", stated " + blend[i].SrcColor + "/" + blend[i].DstColor + " " + blend[i].ColorOp); - } - } - if (_state.DepthTest != description.DepthTest) result.Add("depth test: device " + _state.DepthTest + ", stated " + description.DepthTest); - if (_state.DepthWrite != description.DepthWrite && !description.SamplesBoundDepth) result.Add("depth write: device " + _state.DepthWrite + ", stated " + description.DepthWrite); - if (_state.DepthCompare != description.DepthCompare) result.Add("depth compare: device " + _state.DepthCompare + ", stated " + description.DepthCompare); - CullModeFlags deviceCull = _state.CullEnabled ? _state.CullMode : CullModeFlags.None; - if (deviceCull != description.Cull) result.Add("cull: device " + deviceCull + ", stated " + description.Cull); - if (!_state.LineWidth.Equals(description.LineWidth)) result.Add("line width: device " + _state.LineWidth + ", stated " + description.LineWidth); - if (_state.PolygonMode != description.PolygonMode) result.Add("polygon mode: device " + _state.PolygonMode + ", stated " + description.PolygonMode); - Rect2D deviceViewport = _state.Viewport; - if (deviceViewport.Offset.X != viewport.Offset.X || deviceViewport.Offset.Y != viewport.Offset.Y || - deviceViewport.Extent.Width != viewport.Extent.Width || deviceViewport.Extent.Height != viewport.Extent.Height) - { - result.Add("viewport: device " + deviceViewport.Offset.X + "," + deviceViewport.Offset.Y + " " + deviceViewport.Extent.Width + "x" + deviceViewport.Extent.Height + - ", stated " + viewport.Offset.X + "," + viewport.Offset.Y + " " + viewport.Extent.Width + "x" + viewport.Extent.Height); - } - if (_state.ScissorEnabled != scissorEnabled) result.Add("scissor enable: device " + _state.ScissorEnabled + ", stated " + scissorEnabled); - else if (scissorEnabled && (_state.Scissor.Offset.X != scissor.Offset.X || _state.Scissor.Offset.Y != scissor.Offset.Y || - _state.Scissor.Extent.Width != scissor.Extent.Width || _state.Scissor.Extent.Height != scissor.Extent.Height)) - { - result.Add("scissor rect differs"); - } - if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) - { - foreach (SamplerBinding declared in program.Interface.Samplers) - { - int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Order; - int deviceTexture = (uint)unit < GlStateTracker.MaxTextureUnits ? _boundTextures[unit] : 0; - foreach (NativeTexture texture in textures) - { - if (texture.Sampler.IsPresent && texture.Sampler.Index == program.Interface.Samplers.IndexOf(declared) && - texture.TextureId != deviceTexture) - { - result.Add("sampler " + declared.Name + " (unit " + unit + "): device " + deviceTexture + ", stated " + texture.TextureId); - } - } - } - } - return result; - } /// The manifest variant a program was linked for; "" for a program the rewriter linked. internal string NativeVariantOf(int programId) => @@ -494,8 +423,7 @@ private int ResolveNativeFramebuffer(int framebufferId) => bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; int count = description.Targets.ColorFormats.Length; - // The blend set as the pipeline bakes it under the colour write tier, the way - // GlStateTracker.PipelineBlendFor does for an emulated draw - without the tracker. + // The blend set as the pipeline bakes it under the colour write tier. var baked = new AttachmentBlend[Math.Max(count, 1)]; for (int i = 0; i < baked.Length; i++) { @@ -518,7 +446,10 @@ private int ResolveNativeFramebuffer(int framebufferId) => int bakedBlendId = _nativeBlends.Intern(new BlendSignature(baked.AsSpan(0, Math.Max(count, 0)))); int formatsId = _nativeFormats.Intern(description.Targets); - var cacheKey = new NativePipelineCacheKey(description.ProgramId, formatsId, bakedBlendId, + // Keyed on the blend set as described, not as baked: the draw emits its dynamic blend and + // write masks from the cached description, so two descriptions that bake to one Vulkan + // pipeline under a dynamic tier are still two entries here (they share the PipelineKey below). + var cacheKey = new NativePipelineCacheKey(description.ProgramId, formatsId, rawBlendId, description.DepthTest, description.DepthWrite, description.DepthCompare, description.Cull, description.Topology, description.VertexLayoutId, description.PolygonMode, description.FrontFace, description.LineWidth, description.SamplesBoundDepth); @@ -534,9 +465,9 @@ private int ResolveNativeFramebuffer(int framebufferId) => VertexLayoutDescription vertexLayout = _meshes.LayoutOf(description.VertexLayoutId) .WithDefaultsFor(program.Interface.VertexInputs); - // The blend id is negative so a native key can never collide with an emulated one, - // whose ids come from the tracker's interners. The vertex layout is in the key, so a - // mesh pipeline and a fullscreen pipeline of the same program are never the same entry. + // The blend id is negative, a range of its own in the pipeline cache's key space. The + // vertex layout is in the key, so a mesh pipeline and a fullscreen pipeline of the same + // program are never the same entry. var key = new PipelineKey( ProgramId: description.ProgramId, VertexLayoutId: description.VertexLayoutId, @@ -559,9 +490,8 @@ private int ResolveNativeFramebuffer(int framebufferId) => _nativePipelines[cacheKey] = pipeline; // Created here rather than at the first draw where it can be; an async cache queues - // the compile and the first draws are skipped until it is published, as they are on - // the emulated path. - _pipelines.TryGet(key, request, out _); + // the compile and the first draws are skipped until it is published. + _pipelines.Prepare(key, request); return pipeline; } @@ -624,7 +554,7 @@ internal bool BeginNativePass(NativePassDescription pass) }, id); if (!ReferenceEquals(_targets.Bound, target)) _targets.Bind(commandBuffer, id); - for (int slot = 0; slot < GlStateTracker.MaxColorAttachments && pass.ClearSlots != 0; slot++) + for (int slot = 0; slot < RenderLimits.MaxColorAttachments && pass.ClearSlots != 0; slot++) { if (((pass.ClearSlots >> slot) & 1) == 0) continue; _targets.ClearPassAttachment(commandBuffer, slot, @@ -633,7 +563,8 @@ internal bool BeginNativePass(NativePassDescription pass) _nativePass = pass; _nativeTarget = target; - _nativePasses++; + if (pass.Generic) _genericPasses++; + else _nativePasses++; VulkanStats.NoteNativePass(); if (RenderTrace.Enabled) { @@ -655,8 +586,8 @@ internal bool BeginNativePass(NativePassDescription pass) /// same declaration, so coalesced into it rather than opening /// one of its own; a pass with its own name, slots or clears must be closed the normal way. /// - /// The native-pass bookkeeping is cleared either way, so the emulated calls a render system - /// makes between its draws (its uniforms by name) still count as outside a native pass. + /// The native-pass bookkeeping is cleared either way, so what a render system does between + /// its draws (its uniforms by name) happens outside a native pass. /// internal void EndNativePass(bool keepScope) { @@ -805,7 +736,7 @@ private bool BeginNativeDraw(NativePipeline pipeline, ReadOnlySpan + /// A frame texture the program reads (set 0) that this draw does not name keeps the value the + /// last draw that named it left - GL's "whatever the unit still holds". That texture may have + /// been written since (the liquid depth pass renders into liquidDepth's image before the sky + /// dome, whose route names only sky and glow): it is put back into the read layout here, and + /// replaced by the placeholder when it is an attachment of this draw's own target, which a + /// shader cannot read. + /// + private void PrepareUnnamedFrameTextures(CommandBuffer commandBuffer, ShaderProgramResources program, + ReadOnlySpan textures) + { + if (!program.Interface.UsesFrameTextures) return; + foreach (SamplerBinding declared in program.Interface.Samplers) + { + if (!declared.IsFrameTexture) continue; + bool named = false; + for (int i = 0; i < textures.Length && !named; i++) + { + named = textures[i].Sampler.IsPresent && textures[i].Sampler.FrameBinding == declared.FrameBinding; + } + if (named) continue; + + int index = FrameTextureIndex(declared.FrameBinding); + SamplerBindingValue stale; + int textureId; + lock (_frameTextureLock) + { + stale = _frameTextureValues[index]; + textureId = _frameTextureIds[index]; + } + if (stale.View.Handle == 0) continue; + + // Gone, recreated, a ReadSelf copy of an attachment, or an attachment of this draw's own + // target: nothing the shader may read any more, so the placeholder stands in. + VulkanTexture? texture = _textures.Get(textureId); + if (texture == null || texture.Id != stale.Resource || _targets.IsAttachmentOfBound(textureId)) + { + lock (_frameTextureLock) + { + _frameTextureValues[index] = default; + _frameTextureIds[index] = 0; + } + continue; + } + if (stale.Layout != ImageLayout.ShaderReadOnlyOptimal) + { + lock (_frameTextureLock) _frameTextureValues[index] = stale with { Layout = ImageLayout.ShaderReadOnlyOptimal }; + } + _targets.FlushPendingClears(commandBuffer, texture); + if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) + { + _uploads.NoteUse(commandBuffer, texture); + continue; + } + _targets.EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.SampleFragment); + } + } + /// The dynamic state of a native draw: the pipeline's fixed state and the pass's viewport, never the tracker's. private void EmitNativeDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target, NativePassDescription pass, NativePipeline pipeline) { ColorWriteTier tier = _context.Capabilities.ColorWriteTier; bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; - int colorStates = (int)Math.Min(_context.Capabilities.MaxColorAttachments, (uint)GlStateTracker.MaxColorAttachments); + int colorStates = (int)Math.Min(_context.Capabilities.MaxColorAttachments, (uint)RenderLimits.MaxColorAttachments); NativePipelineDescription description = pipeline.Description; uint colorWrite = 0; diff --git a/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs index 5064b172..ae121a2e 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs @@ -34,8 +34,7 @@ internal enum NativeDrawKind : byte /// storage-buffer vertex fetch and an entity's Animation block resolve per draw /// instead of against the fullscreen path's hardcoded 0; /// - multi-draw through the existing per-slot indirect ring (AllocateIndirect), the same -/// regions the emulated allocates, so the two paths cannot -/// disagree about the ring's bookkeeping. +/// regions every multi-draw allocates, so the ring's bookkeeping has one owner. /// /// What pins it: NativeMeshDrawTests (the ring's use and the pipeline-key dimensions) and /// NativeSkyTests (the first ported system, old route against native route). @@ -46,8 +45,7 @@ public sealed unsafe partial class VulkanDevice /// The topology a mesh was uploaded with, as the primitive a native pipeline rasterizes it /// as. A mesh carries its own EnumDrawMode from the tesselator (triangles for most /// geometry, lines for the aiming reticle, a line strip for the camera path), so a native - /// system states it from the mesh rather than from the tracker's topology, which the - /// emulated draw sets per draw in . Triangles for a mesh that + /// system states it from the mesh, as GL takes it from the VAO. Triangles for a mesh that /// does not exist, so a caller that has already been refused a pipeline sees no surprise. /// internal PrimitiveTopology NativeMeshTopology(int meshId) => @@ -56,8 +54,15 @@ internal PrimitiveTopology NativeMeshTopology(int meshId) => /// Counts one native draw, once in the total and once in its own kind. private void NoteNativeDraw(NativeDrawKind kind) { - _nativeDraws++; VulkanStats.NoteNativeDraw(); + // The generic stated route is counted apart, so the counters below say what the dedicated + // routes recorded (the differential tests compare the two). + if (_nativePass is { Generic: true }) + { + _genericDraws++; + return; + } + _nativeDraws++; switch (kind) { case NativeDrawKind.Fullscreen: @@ -80,9 +85,8 @@ private void NoteNativeDraw(NativeDrawKind kind) } /// - /// One indexed draw of one mesh: the sky dome, an entity shape, a GUI quad. The emulated - /// twin is , whose OpenGL body is - /// ClientPlatformWindows.RenderMesh(MeshRef). + /// One indexed draw of one mesh: the sky dome, an entity shape, a GUI quad. The OpenGL body + /// is ClientPlatformWindows.RenderMesh(MeshRef). /// internal bool DrawNativeMesh(NativePipeline pipeline, int meshId, ReadOnlySpan textures) => DrawNativeMeshInstanced(pipeline, meshId, 1, textures); @@ -90,8 +94,7 @@ internal bool DrawNativeMesh(NativePipeline pipeline, int meshId, ReadOnlySpan /// One indexed draw of one mesh with instances, the /// per-instance attributes coming from the mesh's own instanced bindings (the particle - /// pools). The emulated twin is , whose OpenGL body is - /// ClientPlatformWindows.RenderMeshInstanced. + /// pools). The OpenGL body is ClientPlatformWindows.RenderMeshInstanced. /// internal bool DrawNativeMeshInstanced(NativePipeline pipeline, int meshId, int instanceCount, ReadOnlySpan textures) @@ -150,9 +153,8 @@ internal bool DrawNativeMeshArrays(NativePipeline pipeline, int meshId, int vert /// /// The multi-draw one mesh pool issues per pass - every surviving range of a chunk pool or - /// the decal pool in one command - through the existing per-slot indirect ring. The emulated - /// twin is , whose OpenGL body is - /// ClientPlatformWindows.RenderMesh(MeshRef, int[], int[], int) (glMultiDrawElements). + /// the decal pool in one command - through the existing per-slot indirect ring. The OpenGL + /// body is ClientPlatformWindows.RenderMesh(MeshRef, int[], int[], int) (glMultiDrawElements). /// /// holds GL's 64-bit byte offsets as pairs of ints, as /// MeshDataPool passes them; is the diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 12552aff..29b0bf7e 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -28,7 +28,6 @@ public sealed unsafe partial class VulkanDevice : IDisposable { private VulkanContext _context = null!; private UploadManager _uploads = null!; - private GlStateTracker _state = null!; private TextureManager _textures = null!; private MeshManager _meshes = null!; private RenderTargetManager _targets = null!; @@ -185,13 +184,6 @@ private static bool NamesCaptureDirectory(string? value) => private long _indirectOverflows; private long _indirectGrowths; - /// - /// Whether the last draw got its own slice of the frame's uniform ring. A - /// failure means the draw fell back to whatever is at offset 0, which is a - /// silently wrong frame rather than a crash - worth being able to see. - /// - private bool _lastUniformAllocationOk = true; - /// /// Decision 9's set 1 and the one shared pipeline layout every program's pipelines /// are built against. Created at bring-up and kept current (slots retire with their @@ -222,10 +214,6 @@ private static bool NamesCaptureDirectory(string? value) => /// private VulkanBuffer? _placeholderUniforms; - /// Texture bound to each unit, and any sampler overriding the texture's own state. - private readonly int[] _boundTextures = new int[GlStateTracker.MaxTextureUnits]; - private readonly int[] _unitSamplerOverrides = new int[GlStateTracker.MaxTextureUnits]; - // Atlas composition reads one tile while writing another in the same image. // Each such draw takes a pooled ReadSelf copy, refreshed before the draw and // released when the next draw's samplers are resolved (Phase 2 step 4). @@ -438,8 +426,7 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa AddDiagnostic(SanitiseForClientLog(message)); MirrorValidationMessage(message); if (RenderTrace.Enabled) - RenderTrace.Write("validation: program=" + (_state?.CurrentProgram ?? 0) + - " target=" + (_targets?.Bound?.Id ?? -1) + " " + message); + RenderTrace.Write("validation: target=" + (_targets?.Bound?.Id ?? -1) + " " + message); }, // Surface extensions have to be enabled at instance creation, before // any surface can exist, so the window system is asked first. @@ -483,15 +470,14 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // trace, never GetError. The stats sample reads this allocator's heaps. _context.Allocator.Log = MirrorValidationMessage; VulkanStats.MemorySource = _context.Allocator; - _state = new GlStateTracker(); // Uploads never wait: they ride the next frame submission, recorded from // any thread into the ring's upload batch (or inline into the frame when // it already used the destination; see UploadManager). _frames = new FrameRing(_context); _uploads = _frames.Uploads; _textures = new TextureManager(_context, _uploads); - _meshes = new MeshManager(_context, _state, _uploads); - _targets = new RenderTargetManager(_context, _textures, _state, _graph); + _meshes = new MeshManager(_context, _uploads); + _targets = new RenderTargetManager(_context, _textures, _graph); // An inline upload records transfer commands into the frame command // buffer, which no rendering scope may enclose. _uploads.CloseRenderingScope = commandBuffer => _targets.EndRendering(commandBuffer); @@ -505,9 +491,6 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa _transients = new Graph.TransientAllocator(new Graph.TextureTransientBacking(_textures, ReleaseTexture), TransientAliasingOverride ?? Graph.TransientAllocator.AliasingFromEnvironment()); _readSelfCopies = new Graph.FeedbackCopyPool(_frames.Timeline, CreateReadSelfCopy, ReleaseTexture); - // Colour write tier (C4): draw buffers and motion windows are write masks. - _state.ColorWriteTier = _context.Capabilities.ColorWriteTier; - _state.DynamicBlend = _context.Capabilities.DynamicColorBlend; string? cacheRoot = ResolveShaderCacheRoot(ShaderCacheDirectory, Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_SHADER_CACHE")); byte[]? pipelineSeed = null; @@ -630,7 +613,6 @@ private void CreateDefaultFramebuffer(uint width, uint height) _defaultFramebuffer = _targets.Create(_windowWidth, _windowHeight); _targets.Attach(_defaultFramebuffer, 0, _defaultColor); _targets.Attach(_defaultFramebuffer, -1, _defaultDepth); - _targets.SetDrawBuffers(_defaultFramebuffer, 0b1); if (RenderTrace.Enabled) { @@ -768,7 +750,12 @@ public string GetError() /// private void AddDiagnostic(string message) { - if (!message.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) return; + if (!message.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) + { + // A native route's refusal is not raised anywhere else: the trace carries it. + if (RenderTrace.Enabled && !message.StartsWith("[", StringComparison.Ordinal)) RenderTrace.Write("diagnostic: " + message); + return; + } lock (_errors) { @@ -1314,60 +1301,6 @@ public void SetVSync(bool enabled) private CommandBuffer Commands => _frames.Current.CommandBuffer; - // ------------------------------------------------------------------ raw state - - public void SetViewport(int x, int y, int width, int height) - { - NoteEmulation(); - _state.SetViewport(x, y, width, height); - } - public void SetScissor(int x, int y, int width, int height) => _state.SetScissor(x, y, width, height); - public void SetScissorEnabled(bool enabled) => _state.SetScissorEnabled(enabled); - public bool ScissorEnabled => _state.ScissorEnabled; - - public void SetDepthTest(bool enabled) - { - NoteEmulation(); - _state.SetDepthTest(enabled); - } - public void SetDepthMask(bool enabled) => _state.SetDepthWrite(enabled); - public void SetDepthFunc(int func) => _state.SetDepthFunc(func); - - public void SetCullFace(bool enabled) - { - NoteEmulation(); - _state.SetCullEnabled(enabled); - } - public void SetCullFaceMode(bool back) => _state.SetCullBack(back); - - public void SetBlend(bool enabled, EnumBlendMode mode) - { - NoteEmulation(); - _state.SetBlend(enabled, mode); - } - - public void SetBlendEnabled(bool enabled) - { - NoteEmulation(); - _state.SetBlendEnabled(enabled); - } - - public void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) => - _state.SetAttachmentBlendFunc(attachment, srcColor, dstColor, srcAlpha, dstAlpha); - - public void SetBlendEquation(int attachment, int mode) => - _state.SetAttachmentBlendEquation(attachment, mode); - - public void SetColorMask(bool r, bool g, bool b, bool a) => _state.SetColorMask(r, g, b, a); - - public void SetStencilTest(bool enabled) => _state.SetStencilTest(enabled); - public void SetStencilMask(int mask) => _state.SetStencilMask(mask); - public void SetStencilFunc(int func, int refValue, int mask) => _state.SetStencilFunc(func, refValue, mask); - public void SetStencilOp(int sfail, int dpfail, int dppass) => _state.SetStencilOp(sfail, dpfail, dppass); - - public void SetWireframe(bool enabled) => _state.SetWireframe(enabled); - public void SetLineWidth(float width) => _state.SetLineWidth(width); - // -------------------------------------------------------------------- shaders /// @@ -1639,12 +1572,6 @@ public void DeleteProgram(int programId) _frames.DeferDeletion(program); } - public void UseProgram(int programId) - { - NoteEmulation(); - _state.SetProgram(programId); - } - public int GetUniformLocation(int programId, string name) => _programs.TryGetValue(programId, out ShaderProgramResources? program) ? program.LocationOf(name) : -1; @@ -1652,7 +1579,6 @@ public int GetUniformLocation(int programId, string name) => private void Write(int programId, int location, ReadOnlySpan data) { - NoteEmulation(); // A member of the shared frame block: one shadow for every program. if (ShaderProgramResources.IsFrameLocation(location)) { @@ -1791,7 +1717,6 @@ internal List SamplerNamesOf(int programId) public void SetSamplerUnit(int programId, string samplerName, int unit) { - NoteEmulation(); if (_programs.TryGetValue(programId, out ShaderProgramResources? program)) { program.SamplerUnits[samplerName] = unit; @@ -2127,14 +2052,6 @@ public int GetTextureParameter(int textureId, int parameterName) : 0; } - public void BindTexture(int unit, int textureId) - { - NoteEmulation(); - if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; - _boundTextures[unit] = textureId; - if (RenderTrace.Enabled) RenderTrace.Write("bind unit=" + unit + " texture=" + textureId); - } - public void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, int width, int height, IntPtr pixels) { @@ -2149,8 +2066,6 @@ public void UploadTexture2DNormalizedShorts(int textureId, int level, int x, int _textures.UploadNormalizedShorts(textureId, level, x, y, width, height, pixels); } - public void BindTextureCube(int unit, int textureId) => BindTexture(unit, textureId); - private readonly Dictionary _standaloneSamplers = new(); private int _nextSamplerId = 1; @@ -2179,14 +2094,6 @@ public void SetSamplerParameter(int samplerId, int parameterName, float value) : state; } - public void BindSampler(int unit, int samplerId) - { - NoteEmulation(); - if ((uint)unit >= GlStateTracker.MaxTextureUnits) return; - - _unitSamplerOverrides[unit] = _standaloneSamplers.ContainsKey(samplerId) ? samplerId : 0; - } - public void DeleteSampler(int samplerId) => _standaloneSamplers.Remove(samplerId); private static int BytesPerPixel(EnumTextureInternalFormat format) => format switch @@ -2211,12 +2118,6 @@ public void AttachTexture(int framebufferId, EnumFramebufferAttachment attachmen _targets.Attach(framebufferId, index, textureId, (uint)layer); } - public void SetDrawBuffers(int framebufferId, int attachmentMask) - { - NoteEmulation(); - _targets.SetDrawBuffers(framebufferId, (uint)attachmentMask); - } - public bool CheckFramebufferComplete(int framebufferId, out string status) { // Dynamic rendering has no framebuffer object to validate, so @@ -2232,19 +2133,20 @@ public bool CheckFramebufferComplete(int framebufferId, out string status) return true; } - public void BindFramebuffer(int framebufferId) + public void DeleteFramebuffer(int framebufferId) { - NoteEmulation(); - if (_frameActive) _targets.Bind(Commands, framebufferId); + _targets.Delete(framebufferId); + FramebufferDeleted?.Invoke(framebufferId); } - public void BindDefaultFramebuffer() - { - NoteEmulation(); - if (_frameActive) _targets.Bind(Commands, _defaultFramebuffer); - } + /// The platform whose graphics this device is; null for a bare device (the GPU tests). + internal Platform.VulkanClientPlatform? OwnerPlatform { get; set; } - public void DeleteFramebuffer(int framebufferId) => _targets.Delete(framebufferId); + /// + /// Raised after a framebuffer is deleted. Its id is reused by the next one created, so a + /// record keyed on it (the stated draw buffers) has to forget it here. + /// + internal Action? FramebufferDeleted; /// Whether the frame graph records this device's frames. Change only between frames. internal bool FrameGraphEnabled @@ -2263,26 +2165,10 @@ internal bool FrameGraphEnabled internal int DefaultFramebufferId => _defaultFramebuffer; /// - /// Declares the next frame-graph pass and binds its target - /// (: 0 the bound target, -1 the - /// default one). With the frame graph off it only binds. + /// Ends the pass a render stage left open (closes its scope): a native draw recorded with + /// keepScope stays in the stage's declaration until here. No-op with the frame graph off. /// - internal void DeclarePass(Graph.PassDeclaration declaration) - { - if (!_frameActive) return; - int id = declaration.FramebufferId == Graph.PassDeclaration.DefaultFramebuffer - ? _defaultFramebuffer - : declaration.FramebufferId; - if (id == 0 && declaration.FramebufferId == Graph.PassDeclaration.DefaultFramebuffer) - { - _targets.EndPass(Commands); - return; - } - _targets.DeclarePass(Commands, declaration, id); - } - - /// Ends the current pass (closes its scope). No-op with the frame graph off. - internal void EndPass() + internal void EndStagePass() { if (_frameActive) _targets.EndPass(Commands); } @@ -2295,26 +2181,40 @@ private void FlushPendingClears(int textureId) if (texture != null) _targets.FlushPendingClears(Commands, texture); } - public void ClearColor(int attachment, float r, float g, float b, float a) + /// + /// A colour clear of one attachment of an explicit target, outside every native pass: the + /// promoted LOAD_OP_CLEAR of the next pass on it, or an attachment clear inside an open scope. + /// The caller has applied the draw buffers and colour mask it stated (VulkanClientPlatform). + /// + internal void ClearNativeColor(int framebufferId, int attachment, float r, float g, float b, float a) { + if (!BindForNativeClear(framebufferId)) return; if (RenderTrace.Enabled) { - RenderTrace.Write("clearColor attachment=" + attachment + " target=" + - (_targets.Bound?.Id ?? -1) + " rgba=" + r + "," + g + "," + b + "," + a); + RenderTrace.Write("clearColor attachment=" + attachment + " target=" + _targets.Bound!.Id + + " rgba=" + r + "," + g + "," + b + "," + a); } - if (_frameActive) _targets.ClearColor(Commands, attachment, r, g, b, a); + _targets.ClearColor(Commands, attachment, r, g, b, a); } - public void ClearDepth(float depth) + /// The depth clear of an explicit target; the caller has applied the stated depth mask. + internal void ClearNativeDepth(int framebufferId, float depth) { - if (RenderTrace.Enabled) - { - RenderTrace.Write("clearDepth target=" + (_targets.Bound?.Id ?? -1) + " depth=" + depth); - } - if (_frameActive) _targets.ClearDepth(Commands, depth); + if (!BindForNativeClear(framebufferId)) return; + if (RenderTrace.Enabled) RenderTrace.Write("clearDepth target=" + _targets.Bound!.Id + " depth=" + depth); + _targets.ClearDepth(Commands, depth); } - public void ClearStencil() { } + private bool BindForNativeClear(int framebufferId) + { + EndNativePass(); + if (!_frameActive) return false; + int id = ResolveNativeFramebuffer(framebufferId); + VulkanFramebuffer? target = _targets.Get(id); + if (target == null) return false; + if (!ReferenceEquals(_targets.Bound, target)) _targets.Bind(Commands, id); + return true; + } // --------------------------------------------------------------------- meshes @@ -2516,219 +2416,7 @@ public void UpdateMeshStorageBuffer(int meshId, IntPtr data, int byteOffset, int public void DeleteMesh(int meshId) => _meshes.Delete(meshId, _frames); - // ---------------------------------------------------------------------- draws - - public void DrawMesh(int meshId) => DrawMeshInstanced(meshId, 1); - - public void DrawMeshInstanced(int meshId, int instanceCount) - { - if (instanceCount <= 0) return; - if (!PrepareDraw(_meshes.LayoutIdOf(meshId), meshId, out CommandBuffer commandBuffer)) return; - Checkpoint(commandBuffer, - CheckpointMarker.Draw(CheckpointKind.Draw, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); - if (RenderTrace.Enabled) - { - RenderTrace.Write("draw mesh=" + meshId + " program=" + _state.CurrentProgram + - " indices=" + (_meshes.Get(meshId)?.IndexCount ?? -1) + - " tex0=" + _boundTextures[0] + - " target=" + (_targets.Bound?.Id ?? -1) + - " depthTest=" + _state.DepthTest + " depthWrite=" + _state.DepthWrite + - " depthFunc=" + _state.DepthCompare + " blend=" + _state.BlendFor(0).Enabled + - " cull=" + _state.CullEnabled + "/" + _state.CullMode + - " scissor=" + _state.ScissorEnabled + - " viewport=" + _state.Viewport.Offset.X + "," + _state.Viewport.Offset.Y + " " + - _state.Viewport.Extent.Width + "x" + _state.Viewport.Extent.Height + - " blendSrc=" + _state.BlendFor(0).SrcColor + " blendDst=" + _state.BlendFor(0).DstColor + - " uniforms=" + _lastUniformAllocationOk); - } - _meshes.Draw(commandBuffer, meshId, instanceCount); - } - - public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo) - { - if (!PrepareDraw(_meshes.LayoutIdOf(meshId), meshId, out CommandBuffer commandBuffer)) return; - Checkpoint(commandBuffer, - CheckpointMarker.Draw(CheckpointKind.DrawMulti, _state.CurrentProgram, _targets.Bound?.Id ?? 0, meshId)); - - VulkanBuffer indirect = AllocateIndirect(groupCount, out ulong indirectOffset); - - // The chunk pass is the only storage-buffer multi-draw, and units 0 and - // 1 are terrainTex and terrainTexLinear, so this is the block atlas. - if (ssbo && TextureDump.WantsTerrain) - { - TextureDump.RequestTerrain(_boundTextures[0], _boundTextures[1]); - } - if (RenderTrace.Enabled) - { - RenderTrace.Write("multidraw mesh=" + meshId + " program=" + _state.CurrentProgram + - " groups=" + groupCount + " first=" + (groupCount > 0 ? indicesStarts[0] + "/" + indicesSizes[0] : "-") + - " target=" + (_targets.Bound?.Id ?? -1) + " cull=" + _state.CullEnabled + "/" + _state.CullMode + - " depthTest=" + _state.DepthTest + " uniforms=" + _lastUniformAllocationOk + - " indirectOffset=" + indirectOffset); - } - _meshes.DrawMulti(commandBuffer, meshId, indicesStarts, indicesSizes, groupCount, indirect, indirectOffset); - } - - public void DrawFullscreenTriangle() - { - if (!PrepareDraw(MeshManager.EmptyLayoutId, 0, out CommandBuffer commandBuffer)) return; - Checkpoint(commandBuffer, - CheckpointMarker.Draw(CheckpointKind.Fullscreen, _state.CurrentProgram, _targets.Bound?.Id ?? 0, 0)); - if (RenderTrace.Enabled) - { - RenderTrace.Write("fullscreen program=" + _state.CurrentProgram + - " tex0=" + _boundTextures[0] + " target=" + (_targets.Bound?.Id ?? -1)); - } - _context.Api.CmdDraw(commandBuffer, 3, 1, 0, 0); - } - - /// - /// Resolves everything a draw needs: the rendering scope, the pipeline for - /// the current state, the descriptor sets for the bound textures, the uniform - /// upload, and the dynamic state. This is where the recorded GL state finally - /// becomes Vulkan commands. - /// - private bool PrepareDraw(int vertexLayoutId, int meshId, out CommandBuffer commandBuffer) - { - NoteEmulation(); - commandBuffer = default; - if (!_frameActive) - { - if (RenderTrace.Enabled) RenderTrace.Write("draw skipped: no active frame"); - return false; - } - - VulkanFramebuffer? target = _targets.Bound; - if (target == null) - { - if (RenderTrace.Enabled) RenderTrace.Write("draw skipped: no bound render target"); - return false; - } - - if (!_programs.TryGetValue(_state.CurrentProgram, out ShaderProgramResources? program)) - { - if (RenderTrace.Enabled) - { - RenderTrace.Write("draw skipped: program " + _state.CurrentProgram + " not resident"); - } - return false; - } - - commandBuffer = Commands; - - // Primitive mode belongs to the mesh, just as it does to GL's VAO. - // Apply it before both pipeline selection and dynamic state emission. - // Fullscreen draws have no mesh and must reset a preceding line draw. - _state.SetTopology(_meshes.Get(meshId)?.DrawMode ?? EnumDrawMode.Triangles); - - // A draw that samples the bound depth attachment with depth writes off is - // GL's way of reading scene depth mid-pass; the scope holds depth - // read-only for it, and returns to writable for the next draw that needs - // to write. Decided before the scope opens, since it decides the layout. - _targets.SetDepthReadOnly(SamplesBoundDepthWithoutWriting(program)); - - // Before the scope opens, not after: a layout transition is illegal - // inside one, so anything this draw samples has to be put right first. - TransitionSampledTextures(commandBuffer, program); - _targets.EnsureRendering(commandBuffer); - - int formatsId = _targets.FormatsIdOf(target); - RenderTargetFormats formats = _state.TargetFormats(formatsId); - int attachmentCount = _targets.EnabledAttachmentCount(target); - - // Draw buffers are write masks (C4): the tier decides whether they reach - // the pipeline key, a dynamic enable or a dynamic mask. - uint drawBuffers = target.DrawBufferMask; - var blend = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; - for (int i = 0; i < blend.Length; i++) blend[i] = _state.PipelineBlendFor(i, drawBuffers); - - // A mesh that no longer exists reports -1; falling back to the reserved - // empty layout keeps the key valid rather than indexing past the interner. - int layoutId = vertexLayoutId >= 0 ? vertexLayoutId : MeshManager.EmptyLayoutId; - - // GL supplies a constant for any attribute the mesh does not carry; this - // is where that promise is kept. The pipeline key already names both the - // program and the mesh layout, so the merged result is stable per entry. - VertexLayoutDescription meshLayout = _meshes.LayoutOf(layoutId); - VertexLayoutDescription vertexLayout = meshLayout.WithDefaultsFor(program.Interface.VertexInputs); - if (RenderTrace.Enabled && !ReferenceEquals(meshLayout, vertexLayout)) - { - RenderTrace.Write(" defaults added: mesh had " + meshLayout.Attributes.Length + - " attributes, program declares " + program.Interface.VertexInputs.Count + - ", merged " + vertexLayout.Attributes.Length); - } - - if (!_pipelines.TryGet( - _state.BuildKey(layoutId, formatsId, attachmentCount, drawBuffers), - new GraphicsPipelineCache.PipelineRequest - { - Program = program, - VertexLayout = vertexLayout, - Targets = formats, - Blend = blend, - PolygonMode = _state.PolygonMode, - Topology = _state.Topology, - }, - out Pipeline pipeline)) - { - // Compiling on the background worker: this draw is skipped (counted as - // draws_skipped on stats.pipelines) and the pipeline is published at a frame start. - if (RenderTrace.Enabled) - { - RenderTrace.Write("draw skipped: pipeline for program " + _state.CurrentProgram + " still compiling"); - } - return false; - } - - Vk api = _context.Api; - api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); - - // The mesh binds its own buffers from zero; the defaults sit above them - // and are bound whenever the pipeline actually declares that binding. - if (vertexLayout.Bindings.Length > 0 && - vertexLayout.Bindings[^1].Binding == VertexLayoutDescription.DefaultAttributeBinding && - _defaultAttributes != null) - { - Buffer defaults = _defaultAttributes.Handle; - ulong offset = 0; - api.CmdBindVertexBuffers(commandBuffer, - VertexLayoutDescription.DefaultAttributeBinding, 1, &defaults, &offset); - } - - BindDescriptors(commandBuffer, program, meshId); - ApplyDynamicState(commandBuffer, target, program); - return true; - } - - /// - /// Puts every texture this draw samples into the layout a shader read needs. - /// - /// GL has no notion of image layout: a texture uploaded a moment ago, or one - /// an earlier pass rendered into, can be sampled straight away. Vulkan wants - /// it in SHADER_READ_ONLY_OPTIMAL at the point the descriptor is accessed and - /// rejects the draw otherwise, and a transition cannot be recorded inside a - /// rendering scope - so a texture found in the wrong layout closes the scope, - /// transitions, and the scope reopens around the draw. - /// - /// A sampled colour attachment is snapshotted first: atlas composition reads - /// an existing tile while drawing into another tile of the same texture. - /// - /// - /// Whether any sampler this program reads through is bound to the depth - /// attachment of the current framebuffer while depth writes are off. - /// - private bool SamplesBoundDepthWithoutWriting(ShaderProgramResources program) - { - if (_state.DepthWrite || program.Interface.Samplers.Count == 0) return false; - - foreach (SamplerBinding declared in program.Interface.Samplers) - { - int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Order; - if ((uint)unit >= GlStateTracker.MaxTextureUnits) continue; - if (_targets.IsBoundDepth(_boundTextures[unit])) return true; - } - return false; - } + // ------------------------------------------------------------------- barriers /// /// The frame thread's barriers for sampled textures and feedback snapshots: @@ -2736,68 +2424,6 @@ private bool SamplesBoundDepthWithoutWriting(ShaderProgramResources program) /// private Graph.BarrierBatcher _barriers = null!; - private void TransitionSampledTextures(CommandBuffer commandBuffer, ShaderProgramResources program) - { - ReleaseReadSelfCopies(); - if (program.Interface.Samplers.Count == 0) return; - - EnsureBindlessPlaceholdersReadable(commandBuffer); - - for (int i = 0; i < program.Interface.Samplers.Count; i++) - { - SamplerBinding declared = program.Interface.Samplers[i]; - int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) - ? mapped - : declared.Order; - - VulkanTexture? texture = (uint)unit < GlStateTracker.MaxTextureUnits - ? _textures.Get(_boundTextures[unit]) - : null; - - // Nothing bound: the draw reads a placeholder, readable since above. - if (texture == null) continue; - - // A clear promoted into it has to land before the read (frame graph). - _targets.FlushPendingClears(commandBuffer, texture); - - // Sampled by this frame command buffer: a later upload to it this - // frame must go inline, after this draw, as it would on GL. - _uploads.NoteUse(commandBuffer, texture); - - // The bound depth attachment read with writes off: EnsureRendering - // puts it in the read-only layout, which serves both uses at once. - if (_targets.DepthReadOnly && _targets.IsBoundDepth(_boundTextures[unit])) continue; - - // A bound slot whose draw buffer is off is in the scope with a zero - // write mask (C4); sampling it takes it out, so it can be read below. - _targets.ExcludeSampledAttachment(commandBuffer, _boundTextures[unit]); - - if (_targets.IsAttachmentOfBound(_boundTextures[unit])) - { - if (texture.Aspect == ImageAspectFlags.ColorBit) - { - SnapshotColorAttachment(commandBuffer, _boundTextures[unit], texture); - continue; - } - if (RenderTrace.Enabled) - { - RenderTrace.Write("feedback: program " + program.ProgramId + " '" + - ProgramNameOf(program.ProgramId) + "' samples texture " + _boundTextures[unit] + - " (layout " + texture.Layout + ") which is a written attachment of framebuffer " + - (_targets.Bound?.Id ?? -1)); - } - continue; - } - - if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) continue; - - _targets.EndRendering(commandBuffer); - _textures.Require(_barriers, commandBuffer, texture, Graph.ResourceUsage.SampleFragment); - } - - _barriers.Flush(commandBuffer); - } - private void SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, VulkanTexture source) { if (_sampledTextureOverrides.ContainsKey(textureId)) return; @@ -2919,6 +2545,9 @@ private void ReportUniformExhaustion(ShaderProgramResources program, string what /// A texture's deletion clears its values (any thread, hence the lock). /// private readonly SamplerBindingValue[] _frameTextureValues = new SamplerBindingValue[SetConvention.FrameTextures.Length]; + + /// The client texture id each entry was resolved from. + private readonly int[] _frameTextureIds = new int[SetConvention.FrameTextures.Length]; private readonly object _frameTextureLock = new(); /// Whether set 1's placeholders have been put in the layout their descriptors name. @@ -2952,7 +2581,9 @@ private void ForgetFrameTexture(ulong textureId) { for (int i = 0; i < _frameTextureValues.Length; i++) { - if (_frameTextureValues[i].Resource == textureId) _frameTextureValues[i] = default; + if (_frameTextureValues[i].Resource != textureId) continue; + _frameTextureValues[i] = default; + _frameTextureIds[i] = 0; } } } @@ -3009,19 +2640,10 @@ private uint SnapshotFrameGlobals(ShaderProgramResources program) return allocation.Offset; } - private void BindDescriptors(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) - { - - // A native push block's members persist per program; the slots are resolved over them. - if (program.PushShadow != null) program.PushShadow.CopyTo(_pushShadow, 0); - ResolveSamplers(program); - BindProgramSets(commandBuffer, program, meshId); - } - /// /// Binds the three sets of the shared layout for a draw whose push shadow already holds /// its sampler slots: the frame set, the texture set with the push block, and the storage - /// set. Shared by the GL-emulation path and the native one. + /// set. Every native draw binds through here. /// private void BindProgramSets(CommandBuffer commandBuffer, ShaderProgramResources program, int meshId) { @@ -3089,71 +2711,6 @@ private void BindProgramSets(CommandBuffer commandBuffer, ShaderProgramResources } } - /// - /// Resolves every sampler the program declares through the unit its uniform points - /// at, to the texture bound there - a feedback draw's ReadSelf copy in place of the - /// attachment it copies, and through the physical - /// texture behind a transient rebind - with the unit's sampler object overriding the - /// texture's own state, as glBindSampler does. A frame texture's value goes to set 0; - /// every other sampler gets the bindless slot of its kind, keyed on the read-only - /// depth layout when it samples the bound depth attachment with writes off, and its - /// index lands in the push shadow. A texture that cannot sit behind the declared kind - /// reads that kind's placeholder, as an unbound unit does. - /// - private void ResolveSamplers(ShaderProgramResources program) - { - foreach (SamplerBinding declared in program.Interface.Samplers) - { - int unit = program.SamplerUnits.TryGetValue(declared.Name, out int mapped) ? mapped : declared.Order; - TextureKind kind = KindOf(declared); - VulkanTexture? texture = null; - SamplerState sampling = SamplerState.Default; - ImageLayout layout = ImageLayout.ShaderReadOnlyOptimal; - - if ((uint)unit < GlStateTracker.MaxTextureUnits) - { - int bound = _boundTextures[unit]; - int textureId = _sampledTextureOverrides.TryGetValue(bound, out int copy) ? copy : bound; - texture = _textures.Get(textureId); - if (texture != null && !BindlessKinds.Suits(TextureShape.Of(texture), kind)) - { - if (RenderTrace.Enabled) - { - RenderTrace.Write("sampler '" + declared.Name + "' (" + declared.TypeName + - ") on program " + program.ProgramId + " has texture " + bound + - " of format " + texture.Format + " bound, which it cannot sample; using a placeholder"); - } - texture = null; - } - if (texture != null) - { - // MAX_LEVEL belongs to the texture, even when a sampler overrides its filters. - sampling = _standaloneSamplers.TryGetValue(_unitSamplerOverrides[unit], out SamplerState custom) - ? custom with { MaxLevel = texture.State.MaxLevel } - : texture.State; - // The bound depth attachment, sampled with writes off, is read in the - // layout the scope holds it in rather than shader-read-only. - if (_targets.DepthReadOnly && _targets.IsBoundDepth(bound)) layout = ImageLayout.DepthReadOnlyOptimal; - } - } - - if (declared.IsFrameTexture) - { - SamplerBindingValue value = texture == null - ? default - : new SamplerBindingValue((uint)declared.FrameBinding, texture.View, - _textures.Samplers.Get(BindlessKinds.EffectiveState(sampling, kind)), texture.Id, layout); - if (texture == null) VulkanStats.NoteSamplerPlaceholder(); - lock (_frameTextureLock) _frameTextureValues[FrameTextureIndex(declared.FrameBinding)] = value; - continue; - } - - uint slot = _bindless!.Resolve(texture, kind, sampling, layout); - VulkanStats.NoteBindlessSlotResolution(); - BitConverter.TryWriteBytes(_pushShadow.AsSpan(declared.PushOffset, ProgramInterfaceLayout.SlotBytes), slot); - } - } - /// /// Set 2, built per draw against the shared layout: the program record at its /// dynamic binding (a ring snapshot when the shadow changed), each named block from @@ -3172,7 +2729,6 @@ private void BindStorageSet(CommandBuffer commandBuffer, ShaderProgramResources buffers[binding] = new BufferBindingValue((uint)binding, placeholder.Handle, 0, placeholder.Size, placeholder.Id); } - bool allocationOk = true; bool namesRingOffset = false; uint recordOffset = 0; const int record = SetConvention.ProgramRecordBinding; @@ -3198,7 +2754,6 @@ private void BindStorageSet(CommandBuffer commandBuffer, ShaderProgramResources { // The draw reads offset zero of the ring, which is some other draw's record: // wrong, and for a shader that loops on a uniform count, possibly fatal. - allocationOk = false; ReportUniformExhaustion(program, "its program record"); } buffers[record] = new BufferBindingValue(record, _frames.UniformBuffer, 0, (ulong)program.UniformShadow.Length); @@ -3223,7 +2778,6 @@ private void BindStorageSet(CommandBuffer commandBuffer, ShaderProgramResources // No room left in the ring. Rather than aliasing a buffer every remaining draw // would share - the exact bug the ring exists to fix - this draw gets its own // transient copy. Counted, so a scene that lives in this path shows in the stats. - allocationOk = false; VulkanStats.NoteUniformOverflow(); var overflow = new VulkanBuffer(_context, (ulong)ubo.Shadow.Length, BufferUsageFlags.UniformBufferBit | BufferUsageFlags.StorageBufferBit, @@ -3299,8 +2853,6 @@ static ulong Fnv(ReadOnlySpan bytes) RenderTrace.Write(trace.ToString()); } - _lastUniformAllocationOk = allocationOk; - var contents = new DescriptorSetContents(0, SetConvention.StorageSet, Array.Empty(), buffers); DescriptorSet storageSet = namesRingOffset ? _descriptorArenas[_frames.Current.Index].Get(contents, shared.StorageSetLayout) @@ -3314,76 +2866,9 @@ static ulong Fnv(ReadOnlySpan bytes) VulkanStats.NoteStorageSetBind(); } - private void ApplyDynamicState(CommandBuffer commandBuffer, VulkanFramebuffer target, ShaderProgramResources program) - { - Vk api = _context.Api; - ColorWriteTier tier = _context.Capabilities.ColorWriteTier; - bool dynamicBlend = tier == ColorWriteTier.DynamicMask && _context.Capabilities.DynamicColorBlend; - int colorStates = (int)Math.Min(_context.Capabilities.MaxColorAttachments, (uint)GlStateTracker.MaxColorAttachments); - - // The colour write state the tier makes dynamic, folded into one value so - // the cache can tell whether it changed. - uint colorWrite = 0; - if (tier == ColorWriteTier.DynamicEnable) - { - colorWrite = target.DrawBufferMask & ((1u << colorStates) - 1); - } - else if (tier == ColorWriteTier.DynamicMask) - { - uint written = GlStateTracker.OutputBits(program.Interface.WrittenFragmentOutputs); - for (int i = 0; i < colorStates; i++) - { - colorWrite |= (uint)_state.EffectiveWriteMask(i, target.DrawBufferMask, written) << (i * 4); - } - } - - Rect2D viewport = _state.Viewport; - var values = new DynamicStateValues - { - Viewport = new Viewport( - viewport.Offset.X, viewport.Offset.Y, - viewport.Extent.Width, viewport.Extent.Height, 0f, 1f), - // GL leaves the whole target writable when the scissor test is off; - // Vulkan always has a scissor, so "off" becomes the full target. - Scissor = _state.ScissorEnabled - ? _state.Scissor - : new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), - CullMode = _state.CullEnabled ? _state.CullMode : CullModeFlags.None, - FrontFace = GlStateTracker.FrontFace, - Topology = _state.Topology, - DepthTest = _state.DepthTest, - DepthWrite = _state.DepthWrite, - DepthCompare = _state.DepthCompare, - StencilTest = _state.StencilTest, - StencilFail = _state.StencilFail, - StencilPass = _state.StencilPass, - StencilDepthFail = _state.StencilDepthFail, - StencilCompare = _state.StencilCompare, - StencilCompareMask = _state.StencilCompareMask, - StencilWriteMask = _state.StencilWriteMask, - StencilReference = _state.StencilReference, - // glLineWidth clamps to GL's own range; vkCmdSetLineWidth makes an out-of-range - // width a validation error, so the device's lineWidthRange decides (the game asks - // for 0.5 on the aiming reticle, which is below several drivers' minimum). - LineWidth = _context.Capabilities.ClampLineWidth(_state.LineWidth), - ColorWrite = colorWrite, - BlendStateId = dynamicBlend ? _state.BlendId(GlStateTracker.MaxColorAttachments) : 0, - }; - - // Dirty-masked (Phase 1B step 6): the cache knows what this recording of - // the command buffer already holds. A command buffer that is not the - // slot's current one is never trusted. - FrameSlot slot = _frames.Current; - ulong serial = slot.CommandBuffer.Handle == commandBuffer.Handle ? slot.RecordingSerial : 0; - Span blendStates = stackalloc AttachmentBlend[dynamicBlend ? colorStates : 0]; - for (int i = 0; i < blendStates.Length; i++) blendStates[i] = _state.BlendFor(i); - EmitDynamicState(commandBuffer, values, serial, tier, dynamicBlend, colorStates, blendStates); - } - /// /// Records the dynamic state a draw needs and the recording does not already hold. The - /// values come from the GL state tracker on the emulation path and from a native - /// pipeline's fixed state on the native one. + /// values come from the native pipeline's fixed state and the pass's viewport and scissor. /// private void EmitDynamicState(CommandBuffer commandBuffer, DynamicStateValues values, ulong serial, ColorWriteTier tier, bool dynamicBlend, int colorStates, ReadOnlySpan blendStates) @@ -3645,7 +3130,8 @@ public void BeginOcclusionQuery(int queryId) _targets.EndRendering(commandBuffer); _queryRing.AddPool(commandBuffer); } - _targets.EnsureRendering(commandBuffer); + // No scope is opened for it: a query begun outside one is suspended and starts in the next + // scope that opens - the native pass of the draw it covers (QueryRing.OnScopeOpened). _queryRing.Begin(queryId, commandBuffer, _targets.RenderingActive); } @@ -3855,31 +3341,16 @@ private void RecordGlInternalFormat(int textureId, int glInternalFormat) /// private static int BytesPerPixel(Format format) => TextureDump.BytesPerTexel(format); - /// - /// Reads back the bound target's first colour attachment, four bytes per - /// pixel, rows bottom-up, in the target's own channel order. - /// - /// Bottom-up is not an accident: it is what glReadPixels produces, and - /// the existing screenshot and AVI paths already expect it. Because the - /// backend never flips Y, the image in memory is laid out exactly as GL laid - /// it out, so those paths keep working untouched. The game reads pixels - /// mid-frame and carries on drawing; keeps the frame open. - /// - /// Channels are not converted here. The texels come back in the - /// target's own order, which for the default colour target is R G B A. The - /// client's seam is one level up: the OpenGL body of - /// ClientPlatformAbstract.ReadDefaultFramebuffer reads - /// GL_BGRA, so VulkanClientPlatform.ReadDefaultFramebuffer - /// converts (see ) and everything that speaks to - /// the platform - the screenshot key, the AVI recorder, the headless harness - - /// gets one answer. Callers of this method, the GPU tests among them, read the - /// bound target as it is stored. - /// - public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination) + /// Colour attachment 0 of an explicit target (the default one for ). + internal void ReadFramebufferColor(int framebufferId, int x, int y, int width, int height, IntPtr destination) { - if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; + EndNativePass(); + ReadFramebufferColor(_targets.Get(ResolveNativeFramebuffer(framebufferId)), x, y, width, height, destination); + } - VulkanFramebuffer? target = _targets.Bound; + private void ReadFramebufferColor(VulkanFramebuffer? target, int x, int y, int width, int height, IntPtr destination) + { + if (destination == IntPtr.Zero || width <= 0 || height <= 0) return; if (target == null) return; VulkanTexture? texture = _textures.Get(target.Color[0].TextureId); diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs index 672fbcd8..99ea610e 100644 --- a/Optimum.Tests/ambient-occlusion-coverage-tests.cs +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -123,12 +123,12 @@ public void ThePlatformReplacesVanillaSsaoAndComposesBeforeTheResolve() Assert.Contains("optimumSsaoInScene = true;", apply); // Never on glow: the pass keeps colour 0 alone and reads the AO and attenuation inputs. - string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); - string declaration = Between(graph, "private void DeclareFinalCompositionPass()", "private int FrameBufferIndexOf"); - Assert.Contains("ColorSlots = 1u", declaration); - Assert.Contains("reads.Add(ambientOcclusionOutput);", declaration); - Assert.Contains("AddColour(reads, PrimaryIndex, 3);", declaration); - Assert.Contains("AddColour(reads, TransparentIndex, 1);", declaration); + string ssaoPass = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs"); + string declaration = Between(ssaoPass, "private void NativeSceneSsaoPass()", "reads.ToArray(), clearWhite: false))"); + Assert.Contains("NativePostPipeline(nativeSceneSsao, composite, primary.FboId, 1u,", declaration); + Assert.Contains("var reads = new List { aoTexture };", declaration); + Assert.Contains("int gPosition = gtao ? primary.ColorTextureIds[3] : 0;", declaration); + Assert.Contains("int revealage = gtao ? transparent.ColorTextureIds[1] : 0;", declaration); // The Vulkan platform runs GTAO only for shaders built with it, never a half-res min hack. string vulkan = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.AmbientOcclusion.cs"); diff --git a/Optimum.Tests/color-write-tier-coverage-tests.cs b/Optimum.Tests/color-write-tier-coverage-tests.cs index 417f6483..a411bf61 100644 --- a/Optimum.Tests/color-write-tier-coverage-tests.cs +++ b/Optimum.Tests/color-write-tier-coverage-tests.cs @@ -33,8 +33,6 @@ public void DrawsEmitTheTiersColourWriteState() Assert.Contains("CmdSetColorWriteEnable(commandBuffer,", device); Assert.Contains("CmdSetColorWriteMask(commandBuffer, 0,", device); Assert.Contains("CmdSetColorBlendEquation(commandBuffer, 0,", device); - Assert.Contains("_state.BuildKey(layoutId, formatsId, attachmentCount, drawBuffers)", device); - Assert.Contains("_targets.ExcludeSampledAttachment(commandBuffer, _boundTextures[unit]);", device); string cache = Read("Optimum.Render.Vulkan/Core/PipelineCache.cs"); Assert.Contains("DynamicState.ColorWriteEnableExt", cache); @@ -46,18 +44,22 @@ public void DrawsEmitTheTiersColourWriteState() [Fact] public void DrawBufferChangesNeverRestartTheScope() { + // Draw buffers are the stated route's per-attachment write masks; the render-target + // manager has no draw-buffer state at all, so a change cannot restart a scope there. string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); - int start = targets.IndexOf("public void SetDrawBuffers(int framebufferId, uint mask)", StringComparison.Ordinal); - int end = targets.IndexOf("public void ExcludeSampledAttachment(", start, StringComparison.Ordinal); - Assert.True(start >= 0 && end > start); - string setDrawBuffers = targets.Substring(start, end - start); - // The only restart is a sample-excluded slot rejoining. - Assert.Contains("uint rejoining = framebuffer.SampledExclusion & mask;", setDrawBuffers); - Assert.Contains("if (rejoining == 0) return;", setDrawBuffers); - + Assert.DoesNotContain("DrawBufferMask", targets); + Assert.DoesNotContain("SampledExclusion", targets); Assert.Contains("VulkanStats.NoteMaskRestart();", targets); - Assert.Contains("if ((_bound.DrawBufferMask & (1u << attachment)) == 0) return;", targets); - Assert.Contains("if (_state.ColorMask == 0) return;", targets); + + string state = Read("Optimum.Render.Vulkan/Platform/StatedRenderState.cs"); + Assert.Contains("blend.WriteMask = ((DrawBuffers(framebufferId) >> slot) & 1) != 0 ? ColorMask : 0;", state); + // A stated draw on the same target and slots coalesces into the open pass. + Assert.Contains("device.EndNativePass(keepScope: true);", Read("Optimum.Render.Vulkan/Platform/StatedDraw.cs")); + + // A clear on a draw buffer that is off, or through an all-false colour mask, is dropped by + // the platform before it reaches the device (GL's rule on the stated draw buffers). + string stated = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs"); + Assert.Contains("if (((stated.DrawBuffers(framebufferId) >> slot) & 1) == 0 || stated.ColorMask == 0) return;", stated); string stats = Read("Optimum.Render.Vulkan/Core/VulkanStats.cs"); Assert.Contains("mask_restarts={10} feedback_splits={11}", stats); diff --git a/Optimum.Tests/frame-graph-coverage-tests.cs b/Optimum.Tests/frame-graph-coverage-tests.cs index ecfa4aac..2b801b06 100644 --- a/Optimum.Tests/frame-graph-coverage-tests.cs +++ b/Optimum.Tests/frame-graph-coverage-tests.cs @@ -38,13 +38,14 @@ public void ScopesOpenThroughThePassRecorderAndClearsArePromoted() Assert.Contains("_graph.PromoteColorClear(texture, target.Color[attachment].Layer, r, g, b, a);", targets); Assert.Contains("_graph.PromoteDepthClear(texture, depth);", targets); Assert.Contains("_graph.NoteInPassClear();", targets); - // The masked-out clear stays a no-op before either path. - Assert.Contains("if (_state.ColorMask == 0) return;", targets); + // The masked-out clear stays a no-op before either path, dropped where the mask is stated. + Assert.Contains("stated.ColorMask == 0) return;", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs")); string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); Assert.Contains("_targets.FlushAllPendingClears(_frames.Current.CommandBuffer);", device); Assert.Contains("if (_graph.Enabled) _graph.EndFrame();", device); - Assert.Contains("_targets.FlushPendingClears(commandBuffer, texture);", device); + Assert.Contains("_targets.FlushPendingClears(commandBuffer, texture);", Read("Optimum.Render.Vulkan/VulkanDevice.Native.cs")); Assert.Contains("_targets.FlushPendingClears(Commands, texture);", device); } @@ -55,7 +56,7 @@ public void ASlotTheDeclaredPassLeavesOutIsTreatedAsOutsideTheScope() // and a clear on it (draw buffer on) is promoted instead of dropped. // GPU proof: Optimum.Render.Vulkan.Tests/PassExclusionTests.cs. string targets = Read("Optimum.Render.Vulkan/Core/RenderTargetManager.cs"); - Assert.Contains("uint newlyExcluded = slots & ~(framebuffer.SampledExclusion | framebuffer.PassExclusion);", targets); + Assert.Contains("private void ApplyPassExclusion(CommandBuffer commandBuffer, VulkanFramebuffer target, uint colorSlots)", targets); Assert.Contains("if (((_bound.PassExclusion >> i) & 1) != 0) continue;", targets); Assert.Contains("if (((target.PassExclusion >> attachment) & 1) != 0)", targets); } @@ -77,13 +78,13 @@ public void ThePlatformDeclaresThePassesOfTheFrame() { Assert.Contains(member, graph); } - Assert.Contains("ColorSlots = ~(1u << 1),", graph); Assert.Contains("PassFlags.OpenSampling | PassFlags.AllowSplit", graph); - - string buffers = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs"); - Assert.Contains("DeclareBoundPass();", buffers); - Assert.Contains("DeclareFinalCompositionPass();", buffers); - Assert.Contains("device.EndPass();", buffers); + // Every pass is declared by the native route that records it; the stage bracket ends + // whatever pass a stage left open. + Assert.Contains("platform.GraphDevice?.EndStagePass();", graph); + Assert.Contains("const uint slots = ~(1u << 1);", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostFinal.cs")); + Assert.Contains("Name = declared?.Name ?? \"Stated/\" + framebufferId,", Read("Optimum.Render.Vulkan/Platform/StatedDraw.cs")); string main = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); Assert.Contains("RenderStageListener = new FrameGraphStageListener(this);", main); diff --git a/Optimum.Tests/headless-harness-coverage-tests.cs b/Optimum.Tests/headless-harness-coverage-tests.cs index 461e6a31..f76bf90e 100644 --- a/Optimum.Tests/headless-harness-coverage-tests.cs +++ b/Optimum.Tests/headless-harness-coverage-tests.cs @@ -199,7 +199,7 @@ public void BothBackendsAnswerTheChannelOrderAndTheDeviceConvertsToIt() StringComparison.Ordinal); Assert.True(at > 0, "VulkanClientPlatform.ReadDefaultFramebuffer is gone"); string body = leaf[at..leaf.IndexOf("\n }", at, StringComparison.Ordinal)]; - Assert.Contains("device.ReadDefaultFramebuffer(x, y, width, height, destination);", body); + Assert.Contains("device.ReadFramebufferColor(CurrentTargetId, x, y, width, height, destination);", body); Assert.Contains("PixelOrder.SwapRedAndBlue(destination, (long)width * height);", body); // A target that is already BGRA is left alone, so the format is asked. Assert.Contains("device.DefaultColorFormat is Format.B8G8R8A8Unorm", body); @@ -209,13 +209,13 @@ public void BothBackendsAnswerTheChannelOrderAndTheDeviceConvertsToIt() Assert.Contains("texel[0] = texel[2];", pixelOrder); Assert.Contains("texel[2] = first;", pixelOrder); - // The device stays untouched: it is the general "read the bound target" + // The device stays untouched: it is the general "read a target's colour 0" // operation the GPU tests inspect attachments with, in their stored order. string deviceFile = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); int deviceAt = deviceFile.IndexOf( - "public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)", + "private void ReadFramebufferColor(VulkanFramebuffer? target, int x, int y, int width, int height, IntPtr destination)", StringComparison.Ordinal); - Assert.True(deviceAt > 0, "VulkanDevice.ReadDefaultFramebuffer is gone"); + Assert.True(deviceAt > 0, "VulkanDevice.ReadFramebufferColor is gone"); Assert.DoesNotContain("SwapRedAndBlue", deviceFile[deviceAt..deviceFile.IndexOf("\n }", deviceAt, StringComparison.Ordinal)]); } diff --git a/Optimum.Tests/mod-pass-api-coverage-tests.cs b/Optimum.Tests/mod-pass-api-coverage-tests.cs index 32be3adf..d176bcc8 100644 --- a/Optimum.Tests/mod-pass-api-coverage-tests.cs +++ b/Optimum.Tests/mod-pass-api-coverage-tests.cs @@ -232,7 +232,10 @@ public void OnlyTheVulkanPlatformHostsModPassesFromTheStageBracket() Assert.Contains("Flags = ModPassFlags,", host); Assert.Contains("BeginMotionOnlyWrite() : BeginMotionWrite()", host); Assert.Contains("if (motion) EndMotionWrite();", host); - Assert.Contains("device.EndPass();", host); + Assert.Contains("statedPass = plan.Declaration;", host); + Assert.Contains("statedPass = null;", host); + Assert.Contains("out string? refusal, declared);", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs")); string platform = VulkanPlatformSource.Read(); Assert.Contains("InstallModPassHooks();", platform); diff --git a/Optimum.Tests/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs index 7356f263..e5fcc06f 100644 --- a/Optimum.Tests/native-world-systems-coverage-tests.cs +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -168,7 +168,7 @@ public void ThePipelineDescriptionAndKeyCarryTheMeshDrawState() foreach (string field in new[] { - "public FrontFace FrontFace = GlStateTracker.FrontFace;", + "public FrontFace FrontFace = RenderLimits.FrontFace;", "public PolygonMode PolygonMode = PolygonMode.Fill;", "public float LineWidth = 1.0f;", "public int VertexLayoutId = MeshManager.EmptyLayoutId;", @@ -305,7 +305,7 @@ public void TheVulkanPlatformRecordsTheChunkGroupsNativelyAndKeepsTheOldRoute() // The route in: the pool's multi-draw seam takes the native path only inside a scope. string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); Assert.Contains("if (TryDrawChunkPoolNative(vAO, indices, indicesSizes, groupCount)) return;", meshes); - Assert.Contains("device.DrawMeshMulti(vAO.VaoId, indices, indicesSizes, groupCount, useSSBOs);", meshes); + Assert.Contains("TryDrawStated(vAO, 1, indices, indicesSizes, groupCount);", meshes); } /// @@ -776,17 +776,18 @@ public void TheVulkanPlatformRecordsTheGuiSystemsNativelyAndKeepsTheOldRoute() } /// - /// The named blend modes have exactly one factor table, which the tracker and every native + /// The named blend modes have exactly one factor table, which the stated state and every native /// system that states "blend on, standard" both read - so the two can never drift. /// [Fact] public void TheNamedBlendModesHaveOneFactorTable() { - string tracker = Read("Optimum.Render.Vulkan/Core/GlStateTracker.cs"); + string tracker = Read("Optimum.Render.Vulkan/Core/PipelineState.cs"); Assert.Contains("public static AttachmentBlend For(bool enabled, EnumBlendMode mode)", tracker); Assert.Contains("FactorsFor(EnumBlendMode mode) => mode switch", tracker); - Assert.Contains("AttachmentBlend.FactorsFor(mode);", tracker); + Assert.Contains("= FactorsFor(mode);", tracker); + Assert.Contains("AttachmentBlend.FactorsFor(mode);", Read("Optimum.Render.Vulkan/Platform/StatedRenderState.cs")); // One table only: the premultiplied-alpha pair appears once in the file. int first = tracker.IndexOf("EnumBlendMode.PremultipliedAlpha =>", StringComparison.Ordinal); @@ -970,30 +971,44 @@ public void TheForkCloudRenderersDrawNativelyUnderTheStateTheForkStated() Assert.Contains("OPTIMUM_VK_NATIVE_CLOUDS", clouds); } /// - /// The generic native draw (removal of the emulation layer, step 1): every mesh, instanced, - /// multi-draw and fullscreen draw the dedicated routes do not take is recorded natively from - /// the state the client stated, before the emulated draw is reached. The state is recorded with - /// OpenGL's semantics at the platform's own virtuals, the fork bridge's included, and the pass - /// declares every colour slot attached on the device - the OIT accumulation slots included. + /// The generic native draw (removal of the emulation layer): every mesh, instanced, multi-draw + /// and fullscreen draw the dedicated routes do not take is recorded natively from the state + /// the client stated - there is no other route left. The state is recorded with OpenGL's + /// semantics at the platform's own virtuals, the fork bridge's included, and the pass declares + /// every colour slot attached on the device - the OIT accumulation slots included. /// [Fact] - public void EveryRemainingDrawTakesTheGenericStatedRouteFirst() + public void EveryRemainingDrawTakesTheGenericStatedRoute() { string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); - Assert.Contains("if (TryDrawStated(vAO, 1, null, null, 0))", meshes); - Assert.Contains("if (TryDrawStated(null, 1, null, null, 0))", meshes); - Assert.Contains("if (TryDrawStated(vAO, 1, indices, indicesSizes, groupCount))", meshes); + Assert.Contains("TryDrawStated(vAO, 1, null, null, 0);", meshes); + Assert.Contains("TryDrawStated(null, 1, null, null, 0);", meshes); + Assert.Contains("TryDrawStated(vAO, 1, indices, indicesSizes, groupCount);", meshes); Assert.Contains("TryDrawStated(vAO, quantity, null, null, 0)", meshes); - // Each stated route sits before its emulated draw. - Assert.True(meshes.IndexOf("if (TryDrawStated(vAO, 1, null, null, 0))", StringComparison.Ordinal) < - meshes.IndexOf("device.DrawMesh(vAO.VaoId);", StringComparison.Ordinal)); - string route = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs"); + string route = Read("Optimum.Render.Vulkan/Platform/StatedDraw.cs"); Assert.Contains("RenderTargetFormats? all = device.NativeTargetFormats(framebufferId, uint.MaxValue);", route); - Assert.Contains("units[i] = device.NativeSamplerUnit(program.ProgramId, names[i]);", route); + Assert.Contains("units[i] = device.NativeSamplerUnit(programId, names[i]);", route); Assert.Contains("reads[i] = stated.TextureAt(units[i]);", route); - Assert.Contains("OPTIMUM_VK_NATIVE_STATED", route); - Assert.Contains("OPTIMUM_VK_STATED_CHECK", route); + Assert.Contains("StatedDraw.Record(device, stated, programId, framebufferId,", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs")); + + // The GL state machine is gone from the device: no tracker, no state setters, no bound + // target, no unit tables, no draw that is not a native one. + Assert.False(File.Exists(Path.Combine(Path.GetDirectoryName(PatchReader.FindRepositoryFile("VintageStory.slnx"))!, + "Optimum.Render.Vulkan", "Core", "GlStateTracker.cs"))); + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + foreach (string removed in new[] + { + "public void UseProgram(", "public void SetBlend(", "public void SetDepthTest(", "public void SetViewport(", + "public void BindTexture(", "public void BindSampler(", "public void SetDrawBuffers(", + "public void BindFramebuffer(", "public void ClearColor(", "public void ClearDepth(", + "public void DrawMesh(", "public void DrawMeshMulti(", "public void DrawFullscreenTriangle(", + "private bool PrepareDraw(", "_boundTextures", "_unitSamplerOverrides", "GlStateTracker", + }) + { + Assert.DoesNotContain(removed, device); + } string state = Read("Optimum.Render.Vulkan/Platform/StatedRenderState.cs"); Assert.Contains("public void SetBlendEnabled(bool enabled) => BlendEnabled = enabled;", state); diff --git a/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs b/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs index 287a2bcf..278dec0e 100644 --- a/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs +++ b/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs @@ -23,9 +23,9 @@ public class PlatformProgramUboVirtualsCoverageTests /// The member, its parameter list, the device call and the GL call its override must hold. private static readonly (string Name, string Parameters, string Device, string Gl)[] Members = { - ("UseShaderProgram", "int programId", "optimumDevice.UseProgram(programId);", "GL.UseProgram(programId);"), + ("UseShaderProgram", "int programId", "", "GL.UseProgram(programId);"), ("DisposeShaderProgram", "ShaderProgramBase program", "optimumDevice.DeleteProgram(program.ProgramId);", "GL.DeleteProgram(program.ProgramId);"), - ("BindSampler", "int unit, int samplerId", "optimumDevice.BindSampler(unit, samplerId);", "GL.BindSampler(unit, samplerId);"), + ("BindSampler", "int unit, int samplerId", "stated.BindSampler(unit, samplerId);", "GL.BindSampler(unit, samplerId);"), ("SetUniform", "int programId, int location, float value", "optimumDevice.SetUniform(programId, location, value);", "GL.Uniform1(location, value);"), ("SetUniform", "int programId, int location, int value", "optimumDevice.SetUniform(programId, location, value);", "GL.Uniform1(location, value);"), ("SetUniform", "int programId, int location, float x, float y", "optimumDevice.SetUniform(programId, location, x, y);", "GL.Uniform2(location, x, y);"), @@ -40,8 +40,8 @@ private static readonly (string Name, string Parameters, string Device, string G ("SetUniformMatrix", "int programId, int location, ref Matrix4 matrix", "optimumDevice.SetUniformMatrix(programId, location, optimumMatrix);", "GL.UniformMatrix4(location, false, ref matrix);"), ("SetUniformMatrices", "int programId, int location, int count, float[] matrices", "optimumDevice.SetUniformMatrices(programId, location, count, matrices);", "GL.UniformMatrix4(location, count, false, matrices);"), ("SetUniformMatrices4x3", "int programId, int location, int count, float[] matrices", "optimumDevice.SetUniformMatrices4x3(programId, location, count, matrices);", "GL.UniformMatrix4x3(location, count, false, matrices);"), - ("BindProgramTexture2D", "ShaderProgramBase program, string samplerName, int textureId, int textureNumber", "optimumDevice.BindTexture(textureNumber, textureId);", "GL.BindTexture((TextureTarget)3553, textureId);"), - ("BindProgramTextureCube", "ShaderProgramBase program, string samplerName, int textureId, int textureNumber", "optimumDevice.BindTextureCube(textureNumber, textureId);", "GL.BindTexture((TextureTarget)34067, textureId);"), + ("BindProgramTexture2D", "ShaderProgramBase program, string samplerName, int textureId, int textureNumber", "stated.BindTexture(textureNumber, textureId);", "GL.BindTexture((TextureTarget)3553, textureId);"), + ("BindProgramTextureCube", "ShaderProgramBase program, string samplerName, int textureId, int textureNumber", "stated.BindTexture(textureNumber, textureId);", "GL.BindTexture((TextureTarget)34067, textureId);"), ("BindUBO", "UBO ubo", "optimumDevice.BindUniformBuffer(ubo.Handle);", "GL.BindBufferBase((BufferRangeTarget)35345, ubo.BindingPoint, ubo.Handle);"), ("UnbindUBO", "UBO ubo", "optimumDevice.UnbindUniformBuffer(ubo.Handle);", "GL.BindBuffer((BufferTarget)35345, 0);"), ("UpdateUBO", "UBO ubo, IntPtr data, int offset, int size, bool reallocate", "optimumDevice.UpdateUniformBuffer(ubo.Handle, data, offset, size);", "GL.BufferSubData((BufferTarget)35345, (IntPtr)offset, size, data);"), @@ -162,8 +162,10 @@ public void ClientPlatformWindowsOverridesEveryOperationWithTheGlLinesAndVulkanC // The whole-buffer update keeps glBufferData on GL. Assert.Contains("GL.BufferData((BufferTarget)35345, size, data, (BufferUsageHint)35048);", Body(platform, "public override void UpdateUBO(UBO ubo, IntPtr data, int offset, int size, bool reallocate)")); - // A unit with no custom sampler has any override cleared on the device path. - Assert.Contains("device.BindSampler(textureNumber, 0);", + // The current program is the client's (ShaderProgramBase.CurrentShaderProgram): nothing to record. + Assert.DoesNotContain("device.", Body(vulkan, "public override void UseShaderProgram(int programId)")); + // A unit with no custom sampler has any override cleared in the stated state. + Assert.Contains("stated.BindSampler(textureNumber, 0);", Body(vulkan, "public override void BindProgramTexture2D(ShaderProgramBase program, string samplerName, int textureId, int textureNumber)")); } diff --git a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs index 47fb84d8..30cced59 100644 --- a/Optimum.Tests/platform-seam-deletion-coverage-tests.cs +++ b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs @@ -172,7 +172,7 @@ public void TheOitLayersKeepOnlyTheirFailurePathUnitReset() public static IEnumerable LeafVirtuals() { yield return new object?[] { "SetDepthRange", "public override void SetDepthRange(float near, float far)", "GL.DepthRange(near, far);", null }; - yield return new object?[] { "ClearDefaultDepth", "public override void ClearDefaultDepth(float depth)", "GL.ClearBuffer((ClearBuffer)6145, 0, ref depth);", "device.ClearDepth(Math.Clamp(depth, 0f, 1f));" }; + yield return new object?[] { "ClearDefaultDepth", "public override void ClearDefaultDepth(float depth)", "GL.ClearBuffer((ClearBuffer)6145, 0, ref depth);", "ClearTargetDepth(CurrentTargetId, Math.Clamp(depth, 0f, 1f));" }; yield return new object?[] { "DeleteMeshHandle", "public override void DeleteMeshHandle(int bufferId)", "GL.DeleteBuffer(bufferId);", "device.DeleteMesh(bufferId);" }; yield return new object?[] { "DeleteVertexArrayHandles", "public override void DeleteVertexArrayHandles(VAO vao)", "GL.DeleteVertexArray(vao.VaoId);", "device.DeleteMesh(vao.VaoId);" }; yield return new object?[] { "SetTextureLodBias", "public override void SetTextureLodBias(int[] textureIds, float bias)", "GL.TexParameter((TextureTarget)3553, (TextureParameterName)34049, bias);", "device.SetTextureParameter(textureIds[k], OptimumGlConstants.TextureLodBias, bias);" }; @@ -183,13 +183,13 @@ public void TheOitLayersKeepOnlyTheirFailurePathUnitReset() yield return new object?[] { "SetProgramSamplerUnit", "public override void SetProgramSamplerUnit(int programId, string samplerName, int unit)", "GL.Uniform1(GL.GetUniformLocation(programId, samplerName), unit);", "device.SetSamplerUnit(programId, samplerName, unit);" }; yield return new object?[] { "CreateOitTargets", "public override void CreateOitTargets(FrameBufferRef transparent, int layers, out int revealTexture, out int accumTexture)", "GL.FramebufferTextureLayer((FramebufferTarget)36160, (FramebufferAttachment)36069, accumTexture, 0, 2);", "device.AttachTexture(transparent.FboId, (EnumFramebufferAttachment)36069, accumTexture, 2);" }; yield return new object?[] { "BeginOitAccumulation", "public override void BeginOitAccumulation(FrameBufferRef transparent)", "GL.ClearBuffer((ClearBuffer)6144, 5, array3);", "StateDrawBuffers(transparent.FboId, 0x3F);" }; - yield return new object?[] { "BindOitTextures", "public override void BindOitTextures(int revealTexture, int accumTexture)", "GL.BindTexture((TextureTarget)35866, accumTexture);", "device.BindTexture(7, accumTexture);" }; + yield return new object?[] { "BindOitTextures", "public override void BindOitTextures(int revealTexture, int accumTexture)", "GL.BindTexture((TextureTarget)35866, accumTexture);", "stated.BindTexture(7, accumTexture);" }; yield return new object?[] { "GenOcclusionQuery", "public override int GenOcclusionQuery()", "GL.GenQueries(1, out queryId);", "return device.CreateOcclusionQuery();" }; yield return new object?[] { "BeginOcclusionQuery", "public override void BeginOcclusionQuery(int queryId)", "GL.BeginQuery((QueryTarget)35092, queryId);", "device.BeginOcclusionQuery(queryId);" }; yield return new object?[] { "EndOcclusionQuery", "public override void EndOcclusionQuery(int queryId)", "GL.EndQuery((QueryTarget)35092);", "device.EndOcclusionQuery(queryId);" }; yield return new object?[] { "TryGetOcclusionQueryResult", "public override bool TryGetOcclusionQueryResult(int queryId, out int samples)", "GL.GetQueryObject(queryId, (GetQueryObjectParam)34918, out samples);", "samples = device.GetQueryResult(queryId);" }; yield return new object?[] { "DeleteOcclusionQuery", "public override void DeleteOcclusionQuery(int queryId)", "GL.DeleteQuery(queryId);", "device.DeleteQuery(queryId);" }; - yield return new object?[] { "ReadDefaultFramebuffer", "public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)", "GL.ReadPixels(x, y, width, height, (PixelFormat)32993, (PixelType)5121, destination);", "device.ReadDefaultFramebuffer(x, y, width, height, destination);" }; + yield return new object?[] { "ReadDefaultFramebuffer", "public override void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)", "GL.ReadPixels(x, y, width, height, (PixelFormat)32993, (PixelType)5121, destination);", "device.ReadFramebufferColor(CurrentTargetId, x, y, width, height, destination);" }; yield return new object?[] { "GraphicsBackendName", "public override string GraphicsBackendName", "return \"OpenGL\";", "public override string GraphicsBackendName => device.BackendName;" }; } diff --git a/Optimum.Tests/scene-ssao-coverage-tests.cs b/Optimum.Tests/scene-ssao-coverage-tests.cs index 9dc8ab96..9fd306fe 100644 --- a/Optimum.Tests/scene-ssao-coverage-tests.cs +++ b/Optimum.Tests/scene-ssao-coverage-tests.cs @@ -50,9 +50,9 @@ public void JitteredAoIsComposedBeforeTheResolveAndIsNotAppliedTwice() Assert.Contains("RegisterOptimumShaderProgram(\"scene-ssao\"", registry); Assert.Contains("shaderProgram == ShaderPrograms.SceneSsao", registry); - string graph = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"); - Assert.Contains("Name = \"SceneSsao/0\"", graph); - Assert.Contains("ColorSlots = 1u", graph); + string ssaoPass = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSsao.cs"); + Assert.Contains("BeginNativeAoPass(\"SceneSsao/\" + primary.FboId, primary.FboId,", ssaoPass); + Assert.Contains("NativePostPipeline(nativeSceneSsao, composite, primary.FboId, 1u,", ssaoPass); } [Fact] diff --git a/Optimum.Tests/taa-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs index ea2edacc..477f90d9 100644 --- a/Optimum.Tests/taa-pipeline-coverage-tests.cs +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -85,11 +85,11 @@ public void ClearFrameBufferClearsTheMotionAttachmentOnBothPaths() // Device path (VulkanClientPlatform.ClearFrameBufferPass since Phase 1A step 4). string vulkan = VulkanPlatformSource.Read(); - Assert.Contains("device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", vulkan); + Assert.Contains("ClearTargetColor(target, MotionAttachmentIndex, 0f, 0f, 0f, 0f);", vulkan); // An excluded attachment is not cleared on either backend. Checking // only that ClearColor exists missed Vulkan's silent masked-out no-op. int enable = vulkan.IndexOf("StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", StringComparison.Ordinal); - int clear = vulkan.IndexOf("device.ClearColor(MotionAttachmentIndex, 0f, 0f, 0f, 0f);", StringComparison.Ordinal); + int clear = vulkan.IndexOf("ClearTargetColor(target, MotionAttachmentIndex, 0f, 0f, 0f, 0f);", StringComparison.Ordinal); int restore = vulkan.IndexOf("StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1);", clear, StringComparison.Ordinal); Assert.True(enable >= 0 && enable < clear && restore > clear); Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"ClearFrameBuffer\", 1", Read("Optimum.Patcher/Program.cs")); diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 65cf7416..8d4100dc 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -161,149 +161,28 @@ public void ClientProgramRemainsCecilOwned() /// untouched vanilla and an OpenGL session pays nothing at all. /// /// Checked for a representative spread of the routed methods: the vanilla GL call is - /// in ClientPlatformWindows, the device call in VulkanClientPlatform. + /// in ClientPlatformWindows, the Vulkan body in VulkanClientPlatform - a device call, or for + /// fixed-function state the statement it records (StatedRenderState); glStencilMask records + /// nothing, since no target of this client has a stencil attachment. /// [Theory] - [InlineData("SetViewport", "GL.Viewport(x, y, width, height);")] - [InlineData("SetScissor", "GL.Scissor(x, y, width, height);")] - [InlineData("SetDepthMask", "GL.DepthMask(flag);")] - [InlineData("SetStencilMask", "GL.StencilMask(mask);")] - [InlineData("SetColorMask", "GL.ColorMask(r, g, b, a);")] - [InlineData("SetCullFaceMode", "GL.CullFace((TriangleFace)1029);")] - [InlineData("DeleteTexture", "GL.DeleteTexture(id);")] - public void RoutedMethodsKeepTheirVanillaOpenGlBody(string deviceCall, string vanillaCall) + [InlineData("stated.Viewport = new Rect2D(", "GL.Viewport(x, y, width, height);")] + [InlineData("stated.Scissor = statedScissor;", "GL.Scissor(x, y, width, height);")] + [InlineData("stated.DepthWrite = flag;", "GL.DepthMask(flag);")] + [InlineData("public override void GlStencilMask(int mask)", "GL.StencilMask(mask);")] + [InlineData("stated.SetColorMask(r, g, b, a);", "GL.ColorMask(r, g, b, a);")] + [InlineData("stated.CullBack = true;", "GL.CullFace((TriangleFace)1029);")] + [InlineData("device.DeleteTexture(id);", "GL.DeleteTexture(id);")] + public void RoutedMethodsKeepTheirVanillaOpenGlBody(string vulkanCall, string vanillaCall) { - Assert.Contains("device." + deviceCall + "(", VulkanPlatformSource.Read()); + Assert.Contains(vulkanCall, VulkanPlatformSource.Read()); Assert.Contains(vanillaCall, VulkanPlatformSource.ReadClientPlatformWindows()); } - /// - /// Phase 1A step 4: the fixed-function methods VulkanClientPlatform overrides have - /// vanilla bodies again, so they are no longer transplant targets - only GlToggleBlend - /// (TAA's motion-attachment blend override) still is. Each must be overridden, or a - /// Vulkan session would reach a GL call with no context. - /// - [Fact] - public void EveryRoutedPlatformMethodIsOverriddenByTheVulkanPlatform() - { - string patcher = Read("Optimum.Patcher/Program.cs"); - string vulkan = VulkanPlatformSource.Read(); - - string[] routed = - { - "GlViewport", "GlScissor", "GlScissorFlag", - "GlEnableDepthTest", "GlDisableDepthTest", "GlDepthMask", "GlDepthFunc", - "GlEnableCullFace", "GlDisableCullFace", "GlCullFaceBack", "GlCullFaceFront", - "GlToggleBlend", "GlColorMask", "GLWireframes", "GLLineWidth", - "GlEnableStencilTest", "GlDisableStencilTest", "GlStencilMask", - "GlStencilFunc", "GlStencilOp", "GlClearStencil", - "GetGLShaderVersionString", "GenSampler", "BindTexture2d", "BindTextureCubeMap", - "GLDeleteTexture", "GlGetMaxTextureSize", "GetGraphicsCardRenderer", - }; - - foreach (string method in routed) - { - Assert.True( - System.Text.RegularExpressions.Regex.IsMatch(vulkan, @"public override \w+ " + method + @"\("), - $"{method} is not overridden by VulkanClientPlatform"); - bool target = patcher.Contains($"new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"{method}\"", StringComparison.Ordinal); - Assert.True(target == (method == "GlToggleBlend") || method == "GetGraphicsCardRenderer", - $"{method}: only GlToggleBlend keeps a non-vanilla body and a transplant target"); - } - // GetGraphicsCardRenderer is virtualized in place, not transplanted. - Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"GlToggleBlend\", 2)", patcher); - } - - /// - /// The frame is bracketed by the platform, and the OpenGL path still reaches - /// SwapBuffers. Losing either end would either never present or present twice. - /// - [Fact] - public void TheDeviceBracketsTheFrameAndOpenGlStillSwaps() - { - string platform = VulkanPlatformSource.ReadClientPlatformWindows(); - int frame = platform.IndexOf("private void window_RenderFrame(FrameEventArgs e)", StringComparison.Ordinal); - Assert.True(frame >= 0); - int begin = platform.IndexOf("BeginFrame();", frame, StringComparison.Ordinal); - int onNewFrame = platform.IndexOf("frameHandler.OnNewFrame(dt);", frame, StringComparison.Ordinal); - int end = platform.IndexOf("EndFrame();", frame, StringComparison.Ordinal); - Assert.True(begin > frame && onNewFrame > begin && end > onNewFrame, - "the frame must be opened before the frame handler runs and ended after it"); - - // The vanilla swap survives for the OpenGL path, as the EndFrame override. - int swapOverride = platform.IndexOf("public override void EndFrame()", StringComparison.Ordinal); - Assert.True(swapOverride >= 0); - Assert.Contains("((GameWindow)window).SwapBuffers();", platform.Substring(swapOverride, 200)); - - string vulkan = VulkanPlatformSource.Read(); - Assert.Contains("device.BeginFrame();", vulkan); - Assert.Contains("device.Present();", vulkan); - } - - /// - /// ClientPlatformWindows bodies are transplant targets too, so the branches - /// added to them must stay lambda-free for the same reason ClientProgram's do. - /// - [Fact] - public void ThePlatformBranchesStayLambdaFree() - { - string added = AddedLines(Read(PlatformPatch)); - - foreach (string line in added.Split('\n')) - { - if (!line.Contains("optimumDevice", StringComparison.Ordinal)) continue; - Assert.DoesNotContain("=>", line); - } - } - - private const string ShaderProgramBasePatch = - "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch"; - - /// - /// A uniform location on the device path is a byte offset into the generated - /// block, not a GL location. That works only because the setters read the - /// same uniformLocations dictionary the routed GetUniformLocation - /// filled, so the two must stay in agreement. - /// - [Fact] - public void UniformSettersUseTheLocationTheDeviceHandedOut() - { - // Phase 1A step 3: the program passes the location it looked up to the - // platform, whose override hands it to the device unchanged. - string added = AddedLines(Read(ShaderProgramBasePatch)); - - Assert.Contains("ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName]", added); - Assert.Contains("ScreenManager.Platform.SetUniformArray1(ProgramId, uniformLocations[uniformName]", added); - Assert.Contains("ScreenManager.Platform.SetUniformMatrix(ProgramId, uniformLocations[uniformName]", added); - - // Phase 1A step 4: the device calls are VulkanClientPlatform's overrides. - string platform = VulkanPlatformSource.Read(); - Assert.Contains("device.SetUniform(programId, location, value)", platform); - Assert.Contains("device.SetUniformArray1(programId, location, count, values)", platform); - Assert.Contains("device.SetUniformMatrix(programId, location, matrix)", platform); - Assert.Contains("device.GetUniformLocation(program.ProgramId, name)", platform); - } - - /// - /// The Vec2i overload casts to float in the GL body, so the shader sees a - /// vec2; the Vec3i overload does not, so it sees an ivec3. Scalar layout - /// stores that as three consecutive ints, which is why the components are - /// written at separate offsets rather than through the float path. - /// - [Fact] - public void IntegerVectorUniformsKeepTheirIntegerRepresentation() - { - string added = AddedLines(Read(ShaderProgramBasePatch)); - - // The device lays the three components out itself; the location is opaque here. - Assert.Contains("value.X, value.Y, value.Z)", added); - // The Vec2i overload keeps the cast the GL body performs. - Assert.Contains("(float)value.X, (float)value.Y", added); - } - /// /// Binding a texture is three separate operations in GL - aim the sampler at - /// a unit, activate it, bind the texture - and the device keeps that split. + /// a unit, activate it, bind the texture - and the platform keeps that split: the device + /// holds the program's sampler-to-unit map, the stated state the unit's texture and sampler. /// A unit with a stale sampler override would silently ignore the texture's /// own filtering, so the override is cleared when there is no custom sampler. /// @@ -314,8 +193,8 @@ public void TextureBindingAimsTheSamplerAndClearsAnyStaleOverride() string added = VulkanPlatformSource.Read(); Assert.Contains("device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber)", added); - Assert.Contains("device.BindTexture(textureNumber, textureId)", added); - Assert.Contains("device.BindSampler(textureNumber, 0)", added); + Assert.Contains("stated.BindTexture(textureNumber, textureId)", added); + Assert.Contains("stated.BindSampler(textureNumber, 0)", added); } /// @@ -665,8 +544,8 @@ public void UnwrittenFragmentOutputsAreMaskedOffInThePipeline() Assert.Contains("ColorWriteMask = writeMask,", cache); string layout = Read("Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs"); Assert.Contains("internal static bool FragmentOutputIsAssigned(string source, string name)", layout); - string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); - Assert.Contains("if (instanceCount <= 0) return;", device); + string mesh = Read("Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"); + Assert.Contains("if (instanceCount <= 0) return false;", mesh); } /// diff --git a/VULKAN-BACKEND-PLAN.md b/VULKAN-BACKEND-PLAN.md index fa7582db..188a60ca 100644 --- a/VULKAN-BACKEND-PLAN.md +++ b/VULKAN-BACKEND-PLAN.md @@ -181,7 +181,8 @@ Shipped so far: | Instance, device selection, feature negotiation | `Optimum.Render.Vulkan/Core/VulkanContext.cs` | | Buffers, images, memory, commands, barriers | `Optimum.Render.Vulkan/Core/VulkanResources.cs` | | GL constant translation | `Optimum.Render.Vulkan/Core/GlEnums.cs` | -| Emulated GL state machine, pipeline key, interning | `Optimum.Render.Vulkan/Core/GlStateTracker.cs` | +| Pipeline key, blend and format sets, interning, render limits | `Optimum.Render.Vulkan/Core/PipelineState.cs` | +| Client-stated fixed-function state and the generic native draw | `Optimum.Render.Vulkan/Platform/StatedRenderState.cs`, `Platform/StatedDraw.cs` | | Vertex layouts and attribute format mapping | `Optimum.Render.Vulkan/Core/VertexLayout.cs` | | Per-program modules, descriptor layouts, uniform shadow | `Optimum.Render.Vulkan/Core/ShaderProgramResources.cs` | | Pipeline cache with on-disk driver blob | `Optimum.Render.Vulkan/Core/PipelineCache.cs` | diff --git a/docs/vulkan-native-render-systems.md b/docs/vulkan-native-render-systems.md index 367591ce..73e9a420 100644 --- a/docs/vulkan-native-render-systems.md +++ b/docs/vulkan-native-render-systems.md @@ -350,6 +350,39 @@ Tests: `NativeGuiTests` (old route against native route for both systems, blendi line widths, the pipeline identity across line widths, and twenty fresh textures through one pipeline) and the GUI section of `Optimum.Tests/native-world-systems-coverage-tests.cs` for the lib seams. +## 3e. Stage 3: the emulation layer is gone + +Every draw is native. The dedicated routes (chunks, entities, sky, clouds, particles, decals, GUI, +the post and TAA chain) run first; everything else - mod renderers, the vanilla programs without a +route, the seams' neutral bodies behind the route switches - takes the generic stated draw. + +- **State:** the platform records what the client states, with OpenGL's semantics, in + `Platform/StatedRenderState.cs`: blend (a disabled blend keeps its functions, `glBlendFunc` sets + every draw buffer), the global colour mask, draw buffers per framebuffer as write masks, one + texture and one sampler object per unit, depth, cull, scissor, viewport, line width, and the + program `glUseProgram` named. Nothing reaches the device as state. +- **Draw:** `Platform/StatedDraw.cs` builds a native pipeline from that record and opens a pass on + the target the client addressed (a fork's raw bind, else `CurrentFrameBuffer`, else the default), + with every colour slot attached on the device. A slot the draw samples while its draw buffer is + off leaves the pass; with its draw buffer on the device samples a ReadSelf copy. The scope is + kept, so consecutive stated draws on one target coalesce into one pass. A mod pass hands its + declaration to the draws inside it. +- **Clears:** a colour clear honours the stated draw buffers and colour mask, a depth clear the + depth mask, and both name their target. +- **Device:** `VulkanDevice` has no GL state machine, no bound target, no texture-unit tables and + no draw that is not a native one. `RenderTargetManager` has no draw-buffer mask and no + sampled-slot exclusion; a declared pass's slots are the only exclusion, applied with the frame + graph on or off. The shared pipeline types live in `Core/PipelineState.cs`. +- **Frame textures:** a program's set-0 texture that a draw does not name keeps the value the last + draw left (GL's "whatever the unit holds"); the draw puts it back into the read layout, or uses + the placeholder when it is gone or an attachment of the draw's own target. +- **Tests:** the GPU tests keep their GL-shaped calls through `Optimum.Render.Vulkan.Tests/GlShapedDevice.cs`, + which records into a `StatedRenderState` and draws through `StatedDraw` - on a platform's device, + through the platform's own record and target. The component tests that drive the pipeline cache + directly build their keys with `PipelineKeyState.cs`. The differential tests compare each + dedicated route with the stated route; `NativeStatedTests` compares the stated route with a + hand-written native draw. + ## 4. Documentation that makes a map of the tree unnecessary Every piece of work on this backend has started by rediscovering where things are, and the result was From adffd43a0ac6b0689b8abaa922f579e3ea734f37 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 19:12:36 +0200 Subject: [PATCH 225/226] feat(frame): frame identity, phase markers and present ids (Foundation A) The frame-marking half of the latency seams, ported from feat/latency without its pacing backends. One latency frame id per rendered frame, allocated by the pre-input LatencySleep lib seam (S3) or at BeginFrame; InputSample and SimulationStart at the sleep, SimulationEnd and RenderSubmitStart at the first render stage, RenderSubmitEnd after Submit A, PresentStart/PresentEnd around vkQueuePresentKHR. Every frame submit is tagged through FrameSlot.Submit, every present gets a process-wide present id, chained as VkPresentIdKHR where the device supports it, and the swapchain tells the backend about each creation and retirement. Device requirements go through IDeviceRequirementContributor and one VkDeviceCreateInfo pNext chain. The stats sample gains stats.latency and the latency_sleep wait site. None is the only backend here: it never sleeps and owns no frame cap, so the lib keeps its limiter and pacing is unchanged. Native, NV, AMD, LatencyMode and OPTIMUM_VULKAN_LATENCY stay on feat/latency. Unlike there, VK_KHR_present_id is enabled on its own merit, not only for NV. Device-side code is in VulkanDevice.Latency.cs. Verified: solution builds; make patch-il 224/224; Optimum.Tests latency, integration and Cecil tests 109 passed; GPU latency, present identity, pacing stats, swapchain and colour-write tests passed with sync,best validation and the implicit layers off (RTX 4070, driver 615.71.09: present id ON, low_latency2 rev 2 detected only); headless "serene cave world" on Vulkan (0 validation errors, stats.latency ~59 reports/s, sleep_n 0, closed itself) and OpenGL (closed itself), frames identical by eye. --- Optimum.Patcher/Program.cs | 5 + .../LatencyContractTests.cs | 285 ++++++++++++++++++ .../LatencyHookTests.cs | 243 +++++++++++++++ .../LatencyMarkerOrderTests.cs | 187 ++++++++++++ .../LatencyReportBufferTests.cs | 73 +++++ .../LatencySwapchainLifetimeTests.cs | 130 ++++++++ .../PacingStatsTests.cs | 47 ++- .../PresentIdentityTests.cs | 170 +++++++++++ .../RecordingLatencyBackend.cs | 115 +++++++ Optimum.Render.Vulkan/Core/FrameRing.cs | 44 ++- Optimum.Render.Vulkan/Core/VulkanContext.cs | 152 ++++++++-- Optimum.Render.Vulkan/Core/VulkanStats.cs | 147 ++++++++- .../Latency/IDeviceRequirementContributor.cs | 194 ++++++++++++ .../Latency/ILatencyBackend.cs | 122 ++++++++ .../Latency/LatencyBackendKind.cs | 38 +++ .../Latency/LatencyDeviceRequirements.cs | 148 +++++++++ .../Latency/LatencyFrameReport.cs | 71 +++++ .../Latency/LatencyMarker.cs | 66 ++++ .../Latency/LatencyPhaseTracker.cs | 146 +++++++++ .../Latency/LatencyReportBuffer.cs | 101 +++++++ .../Latency/LatencySettings.cs | 55 ++++ .../Latency/NoneLatencyBackend.cs | 67 ++++ .../Platform/VulkanClientPlatform.Frame.cs | 95 ++++++ .../Platform/VulkanClientPlatform.Stages.cs | 33 ++ .../Platform/VulkanClientPlatform.cs | 4 + Optimum.Render.Vulkan/Present/Swapchain.cs | 160 +++++++++- Optimum.Render.Vulkan/VulkanDevice.Latency.cs | 146 +++++++++ Optimum.Render.Vulkan/VulkanDevice.cs | 29 +- Optimum.Tests/latency-hooks-coverage-tests.cs | 200 ++++++++++++ .../latency-renderer-coverage-tests.cs | 238 +++++++++++++++ .../vulkan-backend-integration-tests.cs | 3 +- docs/taa-acceptance.md | 22 +- .../ClientPlatformAbstract.cs.patch | 24 +- .../ClientPlatformWindows.cs.patch | 138 +++++---- 34 files changed, 3593 insertions(+), 105 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/LatencyContractTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/LatencyHookTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/LatencyMarkerOrderTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/LatencyReportBufferTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/LatencySwapchainLifetimeTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/PresentIdentityTests.cs create mode 100644 Optimum.Render.Vulkan.Tests/RecordingLatencyBackend.cs create mode 100644 Optimum.Render.Vulkan/Latency/IDeviceRequirementContributor.cs create mode 100644 Optimum.Render.Vulkan/Latency/ILatencyBackend.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencyBackendKind.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencyDeviceRequirements.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencyFrameReport.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencyMarker.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencyPhaseTracker.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencyReportBuffer.cs create mode 100644 Optimum.Render.Vulkan/Latency/LatencySettings.cs create mode 100644 Optimum.Render.Vulkan/Latency/NoneLatencyBackend.cs create mode 100644 Optimum.Render.Vulkan/VulkanDevice.Latency.cs create mode 100644 Optimum.Tests/latency-hooks-coverage-tests.cs create mode 100644 Optimum.Tests/latency-renderer-coverage-tests.cs diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index e1638e66..5c045c3a 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -201,6 +201,11 @@ // Phase 2 (contract C3): the render-stage bracket ClientMain.TriggerRenderStage calls. "BeginRenderStage", "EndRenderStage", + // "Latency seams" S3: the pre-input sleep, the frame-cap ownership flag and the + // effective frame cap (background reduction included) window_RenderFrame calls. + "LatencySleep", + "LatencyOwnsFrameCap", + "SetLatencyFrameCap", }, ["Vintagestory.Client.ClientProgram"] = new() { diff --git a/Optimum.Render.Vulkan.Tests/LatencyContractTests.cs b/Optimum.Render.Vulkan.Tests/LatencyContractTests.cs new file mode 100644 index 00000000..130e2282 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/LatencyContractTests.cs @@ -0,0 +1,285 @@ +using System; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The latency contracts of seam L0, all without a device: the marker enum +/// against Silk.NET's VkLatencyMarkerNV, the settings, the report builder, the +/// OPTIMUM_VULKAN_LATENCY parse and selection table, the self-healing phase +/// tracker, and the None backend's behaviour. +/// +public class LatencyContractTests +{ + // vkSetLatencyMarkerNV takes the NV enum directly, so a mismatch here would + // be a silently mis-attributed phase in every NV report. The markers travel + // as ints: the enum is internal to the backend, and xunit needs public + // signatures. + [Fact] + public void EveryMarkerHasTheValueOfItsNvCounterpart() + { + Assert.Equal((int)LatencyMarkerNV.SimulationStartNV, (int)LatencyMarker.SimulationStart); + Assert.Equal((int)LatencyMarkerNV.SimulationEndNV, (int)LatencyMarker.SimulationEnd); + Assert.Equal((int)LatencyMarkerNV.RendersubmitStartNV, (int)LatencyMarker.RenderSubmitStart); + Assert.Equal((int)LatencyMarkerNV.RendersubmitEndNV, (int)LatencyMarker.RenderSubmitEnd); + Assert.Equal((int)LatencyMarkerNV.PresentStartNV, (int)LatencyMarker.PresentStart); + Assert.Equal((int)LatencyMarkerNV.PresentEndNV, (int)LatencyMarker.PresentEnd); + Assert.Equal((int)LatencyMarkerNV.InputSampleNV, (int)LatencyMarker.InputSample); + Assert.Equal((int)LatencyMarkerNV.TriggerFlashNV, (int)LatencyMarker.TriggerFlash); + Assert.Equal((int)LatencyMarkerNV.OutOfBandRendersubmitStartNV, (int)LatencyMarker.OutOfBandRenderSubmitStart); + Assert.Equal((int)LatencyMarkerNV.OutOfBandRendersubmitEndNV, (int)LatencyMarker.OutOfBandRenderSubmitEnd); + Assert.Equal((int)LatencyMarkerNV.OutOfBandPresentStartNV, (int)LatencyMarker.OutOfBandPresentStart); + Assert.Equal((int)LatencyMarkerNV.OutOfBandPresentEndNV, (int)LatencyMarker.OutOfBandPresentEnd); + } + + [Fact] + public void TheMarkerSetIsExactlyTheNvSetWithNoGaps() + { + Array values = Enum.GetValues(typeof(LatencyMarker)); + Assert.Equal(12, values.Length); + for (int i = 0; i < values.Length; i++) + { + Assert.Equal(i, (int)(LatencyMarker)values.GetValue(i)!); + } + } + + [Fact] + public void TheDefaultSettingsAreOffAndUncapped() + { + LatencySettings settings = LatencySettings.Disabled; + Assert.Equal(LatencyMode.Off, settings.Mode); + Assert.Equal(0UL, settings.MinimumIntervalUs); + Assert.False(settings.Enabled); + Assert.False(settings.Boost); + Assert.Equal(0u, settings.MaxFps); + } + + [Theory] + [InlineData((int)LatencyMode.Off, false, false)] + [InlineData((int)LatencyMode.On, true, false)] + [InlineData((int)LatencyMode.Boost, true, true)] + public void EnabledAndBoostFollowTheMode(int mode, bool enabled, bool boost) + { + var settings = new LatencySettings((LatencyMode)mode, 0); + Assert.Equal(enabled, settings.Enabled); + Assert.Equal(boost, settings.Boost); + } + + [Fact] + public void TheFrameCapConvertsBothWays() + { + ulong interval = LatencySettings.IntervalUsForFps(60); + Assert.Equal(16666UL, interval); + Assert.Equal(60u, new LatencySettings(LatencyMode.On, interval).MaxFps); + Assert.Equal(0UL, LatencySettings.IntervalUsForFps(0)); + } + + [Fact] + public void ACpuReportIsTheDifferenceOfItsMarkersAndLeavesTheDriverFieldsAtZero() + { + LatencyFrameReport report = LatencyFrameReport.FromCpuTimestamps( + frameId: 7, presentId: 5, + inputSampleUs: 1000, + simulationStartUs: 1100, + simulationEndUs: 3100, + renderSubmitStartUs: 3100, + renderSubmitEndUs: 4600, + presentStartUs: 4600, + presentEndUs: 4900); + + Assert.Equal(7UL, report.FrameId); + Assert.Equal(5UL, report.PresentId); + Assert.Equal(100UL, report.InputUs); + Assert.Equal(2000UL, report.SimulationUs); + Assert.Equal(1500UL, report.RenderSubmitUs); + Assert.Equal(300UL, report.PresentUs); + Assert.Equal(0UL, report.DriverUs); + Assert.Equal(0UL, report.OsRenderQueueUs); + Assert.Equal(0UL, report.GpuUs); + Assert.Equal(3900UL, report.TotalUs); + } + + [Fact] + public void AMissingOrOutOfOrderMarkerMakesItsIntervalZeroInsteadOfWrapping() + { + LatencyFrameReport report = LatencyFrameReport.FromCpuTimestamps( + frameId: 1, presentId: 0, + inputSampleUs: 0, // never stamped + simulationStartUs: 2000, + simulationEndUs: 1000, // out of order + renderSubmitStartUs: 2500, + renderSubmitEndUs: 0, // never stamped + presentStartUs: 3000, + presentEndUs: 3200); + + Assert.Equal(0UL, report.InputUs); + Assert.Equal(0UL, report.SimulationUs); + Assert.Equal(0UL, report.RenderSubmitUs); + Assert.Equal(200UL, report.PresentUs); + // With no input sample the total runs from simulation start. + Assert.Equal(1200UL, report.TotalUs); + } + + [Fact] + public void AWholeFrameOfMarkersBecomesOneReport() + { + var tracker = new LatencyPhaseTracker(); + tracker.Mark(4, LatencyMarker.InputSample, 1000); + tracker.Mark(4, LatencyMarker.SimulationStart, 1050); + tracker.Mark(4, LatencyMarker.SimulationEnd, 2050); + tracker.Mark(4, LatencyMarker.RenderSubmitStart, 2050); + tracker.Mark(4, LatencyMarker.RenderSubmitEnd, 3050); + tracker.Mark(4, LatencyMarker.PresentStart, 3060); + tracker.Mark(4, LatencyMarker.PresentEnd, 3160); + + Assert.False(tracker.HasOpenPhase); + Assert.True(tracker.TryComplete(4, presentId: 9, out LatencyFrameReport report)); + Assert.Equal(50UL, report.InputUs); + Assert.Equal(1000UL, report.SimulationUs); + Assert.Equal(1000UL, report.RenderSubmitUs); + Assert.Equal(100UL, report.PresentUs); + Assert.Equal(2160UL, report.TotalUs); + Assert.Equal(0, tracker.SelfHealCount); + } + + [Fact] + public void APresentOfAnotherFrameIsNotCompleted() + { + var tracker = new LatencyPhaseTracker(); + tracker.Mark(4, LatencyMarker.SimulationStart, 1000); + Assert.False(tracker.TryComplete(5, 0, out _)); + Assert.True(tracker.TryComplete(4, 0, out _)); + // The frame is closed: completing it twice reports once. + Assert.False(tracker.TryComplete(4, 0, out _)); + } + + [Fact] + public void APhaseStillOpenWhenTheNextFrameStartsIsClosedAndLoggedExactlyOnce() + { + int notes = 0; + var tracker = new LatencyPhaseTracker(_ => notes++); + + for (ulong frame = 1; frame <= 5; frame++) + { + long baseUs = (long)frame * 10_000; + tracker.Mark(frame, LatencyMarker.InputSample, baseUs); + tracker.Mark(frame, LatencyMarker.SimulationStart, baseUs + 10); + // SimulationEnd never arrives: the phase is open when the next frame starts. + tracker.Mark(frame, LatencyMarker.RenderSubmitStart, baseUs + 500); + tracker.Mark(frame, LatencyMarker.RenderSubmitEnd, baseUs + 900); + tracker.Mark(frame, LatencyMarker.PresentStart, baseUs + 910); + tracker.Mark(frame, LatencyMarker.PresentEnd, baseUs + 950); + } + + // Four frame starts saw the previous frame's simulation phase open... + Assert.Equal(4, tracker.SelfHealCount); + // ...and the log heard about it once, not every frame. + Assert.Equal(1, notes); + Assert.True(tracker.SelfHealLogged); + } + + [Fact] + public void ClosingAnOpenPhaseDoesNotLeakIntoTheNextFramesReport() + { + var tracker = new LatencyPhaseTracker(); + tracker.Mark(1, LatencyMarker.InputSample, 1000); + tracker.Mark(1, LatencyMarker.SimulationStart, 1010); + // frame 1's simulation stays open. + tracker.Mark(2, LatencyMarker.InputSample, 2000); + tracker.Mark(2, LatencyMarker.SimulationStart, 2010); + tracker.Mark(2, LatencyMarker.SimulationEnd, 2210); + tracker.Mark(2, LatencyMarker.PresentStart, 2300); + tracker.Mark(2, LatencyMarker.PresentEnd, 2400); + + Assert.Equal(1, tracker.SelfHealCount); + Assert.True(tracker.TryComplete(2, 0, out LatencyFrameReport report)); + Assert.Equal(2UL, report.FrameId); + Assert.Equal(200UL, report.SimulationUs); + Assert.Equal(400UL, report.TotalUs); + } + + [Fact] + public void OutOfBandMarkersDoNotDisturbTheFrameBeingCollected() + { + var tracker = new LatencyPhaseTracker(); + tracker.Mark(3, LatencyMarker.InputSample, 1000); + tracker.Mark(3, LatencyMarker.SimulationStart, 1010); + tracker.Mark(99, LatencyMarker.OutOfBandRenderSubmitStart, 1020); + tracker.Mark(99, LatencyMarker.OutOfBandRenderSubmitEnd, 1030); + tracker.Mark(3, LatencyMarker.SimulationEnd, 1210); + tracker.Mark(3, LatencyMarker.PresentEnd, 1300); + + Assert.Equal(3UL, tracker.CurrentFrameId); + Assert.Equal(0, tracker.SelfHealCount); + Assert.True(tracker.TryComplete(3, 0, out LatencyFrameReport report)); + Assert.Equal(200UL, report.SimulationUs); + } + + [Fact] + public void TheNoneBackendNeverSleepsAndNeverOwnsTheFrameCap() + { + using var backend = new NoneLatencyBackend(); + Assert.Equal(LatencyBackendKind.None, backend.Kind); + Assert.False(backend.OwnsFrameCap); + Assert.Equal(0UL, backend.Sleep(1)); + + backend.Apply(new LatencySettings(LatencyMode.Boost, 16666)); + Assert.Equal(LatencyMode.Boost, backend.Settings.Mode); + // Applying changes nothing about the pacing: this backend paces nothing. + Assert.False(backend.OwnsFrameCap); + } + + [Fact] + public void TheNoneBackendReportsOneFramePerPresentAndDrains() + { + using var backend = new NoneLatencyBackend(); + backend.Marker(1, LatencyMarker.InputSample); + backend.Marker(1, LatencyMarker.SimulationStart); + backend.Marker(1, LatencyMarker.SimulationEnd); + backend.Marker(1, LatencyMarker.PresentStart); + backend.Marker(1, LatencyMarker.PresentEnd); + backend.OnPresent(1, presentId: 11); + + LatencyFrameReport[] reports = backend.TakeReports(); + LatencyFrameReport report = Assert.Single(reports); + Assert.Equal(1UL, report.FrameId); + Assert.Equal(11UL, report.PresentId); + Assert.Empty(backend.TakeReports()); + } + + [Fact] + public unsafe void TheNoneBackendAddsNothingToTheSubmitChain() + { + using var backend = new NoneLatencyBackend(); + int chain = 42; + void* pNext = &chain; + Assert.True(pNext == backend.TagSubmit(1, pNext)); + Assert.True(null == backend.TagSubmit(1, null)); + backend.OnSwapchainCreated(default); + } + + [Fact] + public void TheRecordingFakeKeepsMarkerOrderAndCounts() + { + var backend = new RecordingLatencyBackend { SleepDurationUs = 1234, OwnsFrameCapValue = true }; + Assert.Equal(1234UL, backend.Sleep(1)); + backend.Marker(1, LatencyMarker.InputSample); + backend.Marker(1, LatencyMarker.SimulationStart); + backend.OnSwapchainCreated(default); + backend.SleepDurationUs = 0; + Assert.Equal(0UL, backend.Sleep(2)); + backend.Marker(2, LatencyMarker.InputSample); + backend.OnPresent(2, 7); + + Assert.Equal(new[] { LatencyMarker.InputSample, LatencyMarker.SimulationStart }, backend.MarkersOf(1)); + Assert.Equal(new[] { LatencyMarker.InputSample }, backend.MarkersOf(2)); + Assert.Equal(2, backend.SleepCount); + Assert.Equal(1, backend.SwapchainCount); + Assert.True(backend.OwnsFrameCap); + Assert.Equal((2UL, 7UL), Assert.Single(backend.Presents)); + Assert.Single(backend.TakeReports()); + Assert.Empty(backend.TakeReports()); + } +} diff --git a/Optimum.Render.Vulkan.Tests/LatencyHookTests.cs b/Optimum.Render.Vulkan.Tests/LatencyHookTests.cs new file mode 100644 index 00000000..63909fc1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/LatencyHookTests.cs @@ -0,0 +1,243 @@ +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Platform; +using Vintagestory.Client.NoObf; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Vulkan-native plan, "Latency seams" S3: the lib calls LatencySleep() immediately +/// before it samples input, and the Vulkan platform turns that into the backend's sleep +/// followed by the two markers this site owns - InputSample then SimulationStart - against +/// one strictly increasing frame id. The OpenGL platform declares neither member, so the +/// vanilla frame is the vanilla frame plus one neutral virtual call. +/// +/// Headless: no device and no window, so nothing here touches Vulkan or GL. The backend is +/// the recording fake the L0 stage shipped, which is the point of it. +/// +public class LatencyHookTests +{ + private static VulkanClientPlatform PlatformWith(RecordingLatencyBackend backend) + { + var platform = new VulkanClientPlatform(null!); + platform.LatencyBackendOverride = backend; + return platform; + } + + [Fact] + public void TheSleepRunsBeforeTheInputAndSimulationMarkers() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + + platform.LatencySleep(); + + Assert.Equal(new ulong[] { 1 }, backend.Sleeps.ToArray()); + Assert.Equal( + new List<(ulong, LatencyMarker)> + { + (1UL, LatencyMarker.InputSample), + (1UL, LatencyMarker.SimulationStart), + }, + backend.Markers); + } + + [Fact] + public void EveryFrameGetsItsOwnStrictlyIncreasingId() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + + for (int frame = 0; frame < 8; frame++) platform.LatencySleep(); + + Assert.Equal(8, backend.SleepCount); + for (int i = 0; i < backend.Sleeps.Count; i++) + { + Assert.Equal((ulong)(i + 1), backend.Sleeps[i]); + // The two markers of frame i carry that frame's id and no other. + Assert.Equal(backend.Sleeps[i], backend.Markers[i * 2].FrameId); + Assert.Equal(backend.Sleeps[i], backend.Markers[i * 2 + 1].FrameId); + } + Assert.Equal(16, backend.Markers.Count); + } + + [Fact] + public void TheFrameCapFollowsTheBackend() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + + Assert.False(backend.OwnsFrameCap); + Assert.False(platform.LatencyOwnsFrameCap); + + backend.OwnsFrameCapValue = true; + Assert.True(platform.LatencyOwnsFrameCap); + + backend.OwnsFrameCapValue = false; + Assert.False(platform.LatencyOwnsFrameCap); + } + + /// + /// Latency review 2026-09-12: every backend answers OwnsFrameCap true + /// as soon as its mode is not Off, which stands the lib's own FPS limiter + /// down (seam S3). The cap therefore has to reach the backend, or turning + /// LatencyMode on would silently uncap the client. The lib hands it over with + /// SetLatencyFrameCap immediately before the sleep, and the platform + /// applies it only when it changed - an Apply per frame would re-arm the + /// driver's heuristic every frame. + /// + [Fact] + public void TheClientsFrameCapReachesAPacingBackendOnceAndOnlyWhenItChanges() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + // A cap no client setting produces, so the first hand-over always changes it. + backend.Apply(new LatencySettings(LatencyMode.On, 999_999)); + backend.Applied.Clear(); + + for (int frame = 0; frame < 3; frame++) + { + platform.SetLatencyFrameCap(60); + platform.LatencySleep(); + } + + Assert.Equal( + new List { new(LatencyMode.On, LatencySettings.IntervalUsForFps(60)) }, + backend.Applied); + // The mode is the device's business; this site only ever sets the cap. + Assert.Equal(LatencyMode.On, backend.Settings.Mode); + } + + /// + /// The behaviour the review left open and this change closes: the lib's + /// background-window cap (30 fps after sustained focus loss) is folded into + /// the number window_RenderFrame hands over, so an unfocused window + /// paces to it even though the lib's own limiter has stood down. Coming back + /// into focus restores the foreground cap, and a repeated cap applies nothing. + /// + [Fact] + public void TheBackgroundWindowCapReachesTheBackendAsItsMinimumInterval() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + backend.Apply(new LatencySettings(LatencyMode.On, LatencySettings.IntervalUsForFps(144))); + backend.Applied.Clear(); + + // Focus lost: window_RenderFrame's effective cap is OptimumBgMaxFps (30). + platform.SetLatencyFrameCap(30); + Assert.Equal(LatencySettings.IntervalUsForFps(30), backend.Settings.MinimumIntervalUs); + Assert.Equal(33333UL, backend.Settings.MinimumIntervalUs); + Assert.Equal(30u, backend.Settings.MaxFps); + + // Still unfocused: nothing changed, so nothing is applied. + for (int frame = 0; frame < 5; frame++) platform.SetLatencyFrameCap(30); + Assert.Single(backend.Applied); + + // Focused again: back to the foreground cap, one more Apply. + platform.SetLatencyFrameCap(144); + Assert.Equal(2, backend.Applied.Count); + Assert.Equal(LatencySettings.IntervalUsForFps(144), backend.Settings.MinimumIntervalUs); + Assert.Equal(LatencyMode.On, backend.Settings.Mode); + } + + /// + /// Off is off: a backend whose mode is Off is never applied to, so the frame + /// with LatencyMode off is the frame Milestone 1 delivered. + /// + [Fact] + public void ADisabledBackendIsNeverTouchedByTheFrameCap() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + + for (int frame = 0; frame < 4; frame++) + { + platform.SetLatencyFrameCap(60); + platform.LatencySleep(); + } + + Assert.Empty(backend.Applied); + Assert.Equal(LatencySettings.Disabled, backend.Settings); + } + + /// + /// The conversion itself. The lib decides when a cap applies at all (vsync + /// off, MaxFps in the 10..241 window) and hands 0 over when it does not; 0 is + /// uncapped for every backend, and so is any nonsense below it. + /// + [Theory] + [InlineData(60, 16666UL)] + [InlineData(120, 8333UL)] + [InlineData(240, 4166UL)] + [InlineData(30, 33333UL)] + [InlineData(0, 0UL)] + [InlineData(-1, 0UL)] + public void TheFrameCapConvertsFpsToAMinimumInterval(int maxFps, ulong expectedUs) + { + Assert.Equal(expectedUs, VulkanClientPlatform.FrameCapIntervalUs(maxFps)); + } + + [Fact] + public void AnUncappedClientLeavesThePacingBackendUncapped() + { + var backend = new RecordingLatencyBackend(); + VulkanClientPlatform platform = PlatformWith(backend); + backend.Apply(new LatencySettings(LatencyMode.On, LatencySettings.IntervalUsForFps(60))); + backend.Applied.Clear(); + + // What the lib hands over with vsync on, or MaxFps at the "unlimited" end. + platform.SetLatencyFrameCap(0); + + Assert.Equal(new List { new(LatencyMode.On, 0) }, backend.Applied); + } + + [Fact] + public void WithNoBackendTheFrameCapIsANoOp() + { + var platform = new VulkanClientPlatform(null!); + Assert.Null(platform.LatencyBackend); + + platform.SetLatencyFrameCap(30); + } + + [Fact] + public void WithNoBackendAndNoDeviceTheSleepIsANoOp() + { + var platform = new VulkanClientPlatform(null!); + Assert.Null(platform.LatencyBackend); + + platform.LatencySleep(); + + Assert.False(platform.LatencyOwnsFrameCap); + } + + [Fact] + public void TheOpenGlPlatformKeepsTheNeutralMembers() + { + var platform = new ClientPlatformWindows(null!); + + // Neutral: calling them on the base platform does nothing and never throws. + platform.LatencySleep(); + platform.SetLatencyFrameCap(30); + Assert.False(platform.LatencyOwnsFrameCap); + + Assert.Equal(typeof(ClientPlatformAbstract), + typeof(ClientPlatformWindows).GetMethod(nameof(ClientPlatformAbstract.LatencySleep))!.DeclaringType); + Assert.Equal(typeof(ClientPlatformAbstract), + typeof(ClientPlatformWindows).GetProperty(nameof(ClientPlatformAbstract.LatencyOwnsFrameCap))!.DeclaringType); + Assert.Equal(typeof(ClientPlatformAbstract), + typeof(ClientPlatformWindows).GetMethod(nameof(ClientPlatformAbstract.SetLatencyFrameCap))!.DeclaringType); + } + + [Fact] + public void TheVulkanPlatformOverridesBothMembers() + { + Assert.Equal(typeof(VulkanClientPlatform), + typeof(VulkanClientPlatform).GetMethod(nameof(ClientPlatformAbstract.LatencySleep))!.DeclaringType); + Assert.Equal(typeof(VulkanClientPlatform), + typeof(VulkanClientPlatform).GetProperty(nameof(ClientPlatformAbstract.LatencyOwnsFrameCap))!.DeclaringType); + Assert.Equal(typeof(VulkanClientPlatform), + typeof(VulkanClientPlatform).GetMethod(nameof(ClientPlatformAbstract.SetLatencyFrameCap))!.DeclaringType); + } +} diff --git a/Optimum.Render.Vulkan.Tests/LatencyMarkerOrderTests.cs b/Optimum.Render.Vulkan.Tests/LatencyMarkerOrderTests.cs new file mode 100644 index 00000000..8af2421c --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/LatencyMarkerOrderTests.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Optimum.Render.Vulkan.Core; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Latency seams S2 and S4, against a real (hidden) window: several presented +/// frames through the recording backend must stamp the renderer's markers in one +/// order, once each, under one frame id per frame that increases by exactly one. +/// +/// The two markers the lib hook owns (InputSample and SimulationStart, stamped in +/// VulkanClientPlatform.LatencySleep) are not the renderer's, so they are +/// not asserted here; what is asserted is that the renderer stamps its own five +/// and never stamps one twice, however often the client brackets a render stage. +/// +public class LatencyMarkerOrderTests +{ + private const int Width = 256; + private const int Height = 192; + private const int Frames = 6; + + private readonly ITestOutputHelper _output; + + public LatencyMarkerOrderTests(ITestOutputHelper output) => _output = output; + + /// What the renderer owns, in the order one frame must produce it. + private static readonly LatencyMarker[] ExpectedPerFrame = + { + LatencyMarker.SimulationEnd, + LatencyMarker.RenderSubmitStart, + LatencyMarker.RenderSubmitEnd, + LatencyMarker.PresentStart, + LatencyMarker.PresentEnd, + }; + + [SkippableFact] + public unsafe void EveryFrameStampsTheRenderersMarkersOnceInOrderUnderItsOwnId() + { + Skip.IfNot(SwapchainTests.TryCreateWindow(_output, Width, Height, out Window* window), "No usable window system."); + + try + { + VulkanDevice device = GpuTest.NewDevice(); + var latency = new RecordingLatencyBackend(); + // Before Initialize: the backend has to be the one the first + // swapchain, the frame ring and the stats source see. + device.SetLatencyBackend(latency); + + if (!device.Initialize((IntPtr)window, Width, Height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + using (device) + { + VulkanDevice seam = device; + int programId = SwapchainTests.LinkFullscreenProgram(seam); + var frameIds = new List(); + + for (int frame = 0; frame < Frames; frame++) + { + // What the lib hook does before input is sampled (seam S3). + ulong frameId = seam.BeginLatencyFrame(); + frameIds.Add(frameId); + + seam.BeginFrame(); + Assert.Equal(frameId, seam.LatencyFrameId); + + // The client brackets many render stages per frame; only the + // first may stamp the pair. + seam.NoteRenderStageStarted(); + seam.NoteRenderStageStarted(); + seam.NoteRenderStageStarted(); + + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.1f, 0.3f, 0.5f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, Width, Height); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + + // One id per frame, increasing by exactly one. + for (int i = 1; i < frameIds.Count; i++) + { + Assert.Equal(frameIds[i - 1] + 1, frameIds[i]); + } + + foreach (ulong id in frameIds) + { + LatencyMarker[] markers = latency.MarkersOf(id); + _output.WriteLine($"frame {id}: {string.Join(", ", markers)}"); + Assert.Equal(ExpectedPerFrame, markers); + } + + // Every frame presented, each present paired with its own frame + // id, and the present ids increase. + Assert.Equal(frameIds.Count, latency.Presents.Count); + for (int i = 0; i < frameIds.Count; i++) + { + Assert.Equal(frameIds[i], latency.Presents[i].FrameId); + if (i > 0) Assert.True(latency.Presents[i].PresentId > latency.Presents[i - 1].PresentId); + } + + // Seam S4: every submit of the frame passed the tag hook. Two + // submits a frame at least (Submit A and Submit B). + Assert.True(latency.TagSubmitCount >= 2 * Frames, + $"{latency.TagSubmitCount} tagged submits over {Frames} frames"); + + GpuTest.AssertClean(seam); + } + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + /// + /// A headless device (no swapchain) still owns the frame identity and the + /// submit markers: nothing presents, so PresentStart/End and OnPresent stay + /// absent rather than being stamped against a present that never happened. + /// + [SkippableFact] + public void AHeadlessFrameStampsRenderSubmitEndAndNoPresentMarkers() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? created), "No Vulkan device."); + using VulkanDevice device = created!; + + var latency = new RecordingLatencyBackend(); + device.SetLatencyBackend(latency); + + VulkanDevice seam = device; + for (int frame = 0; frame < 3; frame++) + { + seam.BeginFrame(); + seam.NoteRenderStageStarted(); + seam.Present(); + } + + Assert.Empty(latency.Presents); + // VK_KHR_present_id hangs off the swapchain: a headless device detects it and enables nothing. + Assert.False(device.ContextForTests.Capabilities.PresentIdEnabled); + Assert.DoesNotContain("VK_KHR_present_id", device.ContextForTests.EnabledDeviceExtensions); + for (ulong id = 1; id <= 3; id++) + { + Assert.Equal( + new[] { LatencyMarker.SimulationEnd, LatencyMarker.RenderSubmitStart, LatencyMarker.RenderSubmitEnd }, + latency.MarkersOf(id)); + } + + GpuTest.AssertClean(seam); + } + + /// + /// A frame that reaches BeginFrame without the lib hook (headless, or any + /// path with no platform) allocates its own id, so the identity exists + /// exactly once either way and the ids still increase by one. + /// + [SkippableFact] + public void AFrameWithoutTheHookAllocatesItsOwnIdExactlyOnce() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? created), "No Vulkan device."); + using VulkanDevice device = created!; + + VulkanDevice seam = device; + var ids = new List(); + for (int frame = 0; frame < 4; frame++) + { + if (frame % 2 == 0) seam.BeginLatencyFrame(); + seam.BeginFrame(); + ids.Add(seam.LatencyFrameId); + seam.Present(); + } + + Assert.Equal(new ulong[] { 1, 2, 3, 4 }, ids); + } +} diff --git a/Optimum.Render.Vulkan.Tests/LatencyReportBufferTests.cs b/Optimum.Render.Vulkan.Tests/LatencyReportBufferTests.cs new file mode 100644 index 00000000..e3ff8b69 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/LatencyReportBufferTests.cs @@ -0,0 +1,73 @@ +using Optimum.Render.Vulkan.Core; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The report buffer every CPU-timestamp backend keeps (None, Native, AMD), +/// added by the latency review of 2026-09-12: the backends used a +/// List with RemoveAt(0), so once the buffer was full - which it is +/// for the whole session whenever nothing drains it, and nothing does unless +/// OPTIMUM_VULKAN_STATS is set - every present shifted the entire array down by +/// one inside the present path. The ring must drop the oldest entry without +/// moving anything and must keep the order the interface promises (oldest +/// first). +/// +public class LatencyReportBufferTests +{ + private static LatencyFrameReport ReportOf(ulong frameId) => + new(frameId, frameId + 100, 1, 2, 3, 4, 0, 0, 0, 10); + + [Fact] + public void ItKeepsTheNewestReportsInOrderAndDropsTheOldestWhenFull() + { + var buffer = new LatencyReportBuffer(4); + Assert.Equal(0, buffer.Count); + Assert.Empty(buffer.Take()); + + for (ulong frame = 1; frame <= 6; frame++) buffer.Add(ReportOf(frame)); + + Assert.Equal(4, buffer.Count); + LatencyFrameReport[] taken = buffer.Take(); + Assert.Equal(new ulong[] { 3, 4, 5, 6 }, System.Array.ConvertAll(taken, r => r.FrameId)); + + // Taking clears: the same reports are never handed out twice. + Assert.Equal(0, buffer.Count); + Assert.Empty(buffer.Take()); + } + + [Fact] + public void TakingAndAddingAcrossTheWrapKeepsTheOrder() + { + var buffer = new LatencyReportBuffer(4); + for (ulong frame = 1; frame <= 3; frame++) buffer.Add(ReportOf(frame)); + Assert.Equal(new ulong[] { 1, 2, 3 }, System.Array.ConvertAll(buffer.Take(), r => r.FrameId)); + + for (ulong frame = 4; frame <= 9; frame++) buffer.Add(ReportOf(frame)); + Assert.Equal(new ulong[] { 6, 7, 8, 9 }, System.Array.ConvertAll(buffer.Take(), r => r.FrameId)); + } + + /// + /// The Native backend fills a frame's GPU interval in after the fact, when + /// the next sleep observes that frame's present submission completed. It must + /// find the newest report of that frame, and must not guess once the stats + /// sample has taken it. + /// + [Fact] + public void TheGpuIntervalIsAmendedOnTheWaitingReportOnly() + { + var buffer = new LatencyReportBuffer(4); + buffer.Add(ReportOf(1)); + buffer.Add(ReportOf(2)); + + Assert.True(buffer.AmendGpuUs(1, 4242)); + Assert.False(buffer.AmendGpuUs(99, 1)); + + LatencyFrameReport[] taken = buffer.Take(); + Assert.Equal(4242UL, taken[0].GpuUs); + Assert.Equal(0UL, taken[1].GpuUs); + + // Already drained: the interval stays 0 rather than landing on a later frame. + Assert.False(buffer.AmendGpuUs(1, 5)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/LatencySwapchainLifetimeTests.cs b/Optimum.Render.Vulkan.Tests/LatencySwapchainLifetimeTests.cs new file mode 100644 index 00000000..d82ad21d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/LatencySwapchainLifetimeTests.cs @@ -0,0 +1,130 @@ +using System; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Latency review 2026-09-12, seam S5: the backend has to be told when the +/// swapchain handle it holds goes away, not only when a new one appears. +/// +/// Swapchain.Build hands the current slot to the retirement queue before +/// it creates the replacement, and does so even when that creation fails, after +/// which the client keeps rendering frames on a chain that no longer exists. +/// VK_NV_low_latency2 keys every one of its calls on a live +/// VkSwapchainKHR (vkLatencySleepNV, vkSetLatencyMarkerNV, +/// vkGetLatencyTimingsNV, vkSetLatencySleepModeNV), so without a retirement +/// notice it would keep calling into a handle the retirement queue is about to +/// destroy. Before the fix there was no notice at all. +/// +public class LatencySwapchainLifetimeTests +{ + private const int Width = 256; + private const int Height = 192; + + private readonly ITestOutputHelper _output; + + public LatencySwapchainLifetimeTests(ITestOutputHelper output) => _output = output; + + [SkippableFact] + public unsafe void EveryRetiredSwapchainIsAnnouncedBeforeTheOneThatReplacesIt() + { + Skip.IfNot(SwapchainTests.TryCreateWindow(_output, Width, Height, out Window* window), "No usable window system."); + + var latency = new RecordingLatencyBackend(); + + try + { + VulkanDevice device = GpuTest.NewDevice(); + device.SetLatencyBackend(latency); + + if (!device.Initialize((IntPtr)window, Width, Height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + int creations; + using (device) + { + VulkanDevice seam = device; + Swapchain swapchain = device.SwapchainForTests!; + int programId = SwapchainTests.LinkFullscreenProgram(seam); + + void RenderFrames(int count, int w, int h) + { + for (int frame = 0; frame < count; frame++) + { + seam.BeginLatencyFrame(); + seam.BeginFrame(); + seam.NoteRenderStageStarted(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, w, h); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.DrawFullscreenTriangle(); + seam.Present(); + } + } + + // The first swapchain exists without anything having been retired. + Assert.Single(latency.Swapchains); + Assert.Equal(0, latency.SwapchainRetirements); + + RenderFrames(2, Width, Height); + + (int W, int H)[] sizes = { (320, 240), (200, 150), (256, 192) }; + foreach ((int w, int h) in sizes) + { + GLFW.SetWindowSize(window, w, h); + GLFW.PollEvents(); + seam.Resize(w, h); + RenderFrames(2, w, h); + } + + creations = swapchain.Creations; + Assert.True(creations > 1, creations + " swapchain creations"); + GpuTest.AssertClean(seam); + } + + // In order: handle, then (retired, handle) for every rebuild, and a + // final retirement when the device disposed the swapchain. A new + // handle is never announced while the old one is still the backend's. + _output.WriteLine(creations + " creations, " + latency.SwapchainRetirements + " retirements, events: " + + string.Join(", ", System.Array.ConvertAll(latency.SwapchainEvents.ToArray(), Describe))); + + Assert.Equal(creations, latency.Swapchains.Count); + Assert.Equal(creations, latency.SwapchainRetirements); + + bool holdsOne = false; + foreach (SwapchainKHR handle in latency.SwapchainEvents) + { + if (handle.Handle != 0) + { + Assert.False(holdsOne, "a new swapchain was announced while the old one was still live"); + holdsOne = true; + } + else + { + holdsOne = false; + } + } + + // Disposal is the last word: the backend holds nothing afterwards. + Assert.False(holdsOne, "the backend still holds a swapchain after the device was disposed"); + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + private static string Describe(SwapchainKHR handle) => handle.Handle == 0 ? "retired" : "created"; +} diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs index a28277c8..97a43af7 100644 --- a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -119,17 +119,41 @@ public void NewStatsLinesCarryStableKeyValueTokens() "stats.pacing samples=512 p50_ms=16.667 p95_ms=17.100 p99_ms=18.300 stddev_ms=0.420 stutters=3", VulkanStats.FormatPacingLine(new FramePacingSnapshot(512, 16.66666, 17.1, 18.3, 0.42, 3))); - var counts = new long[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; + var counts = new long[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; // No midpoints: F1 rounding of an exact x.x5 is not something to pin. - var ms = new double[] { 1.21, 2.4, 3.6, 4.8, 6.0, 7.2, 8.4, 9.66, 10.83 }; + var ms = new double[] { 1.21, 2.4, 3.6, 4.8, 6.0, 7.2, 8.4, 9.66, 10.83, 12.01 }; Assert.Equal( "stats.waits frame_pacing_n=1 frame_pacing_ms=1.2 upload_submit_n=2 upload_submit_ms=2.4 " + "flush_frame_n=3 flush_frame_ms=3.6 device_wait_idle_n=4 device_wait_idle_ms=4.8 " + "readback_n=5 readback_ms=6.0 occlusion_query_n=6 occlusion_query_ms=7.2 " + "swapchain_acquire_n=7 swapchain_acquire_ms=8.4 present_n=8 present_ms=9.7 " + - "queue_submit_n=9 queue_submit_ms=10.8", + "queue_submit_n=9 queue_submit_ms=10.8 latency_sleep_n=10 latency_sleep_ms=12.0", VulkanStats.FormatWaitsLine(counts, ms)); + // The latency line (seam S7) is always present: backend, mode, the sleep + // of the interval and each report interval as a mean and a p99. + var mean = new double[VulkanStats.LatencyIntervalCount]; + var p99 = new double[VulkanStats.LatencyIntervalCount]; + VulkanStats.ReduceReports( + new[] + { + new LatencyFrameReport(1, 1, 1000, 2000, 3000, 400, 0, 0, 0, 16000), + new LatencyFrameReport(2, 2, 3000, 4000, 5000, 600, 0, 0, 0, 20000), + }, mean, p99); + Assert.Equal(2.0, mean[0], 3); + Assert.Equal(3.0, p99[0], 3); + Assert.Equal(18.0, mean[7], 3); + Assert.Equal(20.0, p99[7], 3); + Assert.Equal( + "stats.latency backend=native mode=boost rev=2 sleep_n=10 sleep_ms=12.0 frames=2 " + + "input_mean_ms=2.00 input_p99_ms=3.00 sim_mean_ms=3.00 sim_p99_ms=4.00 " + + "render_submit_mean_ms=4.00 render_submit_p99_ms=5.00 present_mean_ms=0.50 present_p99_ms=0.60 " + + "driver_mean_ms=0.00 driver_p99_ms=0.00 os_queue_mean_ms=0.00 os_queue_p99_ms=0.00 " + + "gpu_mean_ms=0.00 gpu_p99_ms=0.00 total_mean_ms=18.00 total_p99_ms=20.00", + VulkanStats.FormatLatencyLine("native", "boost", 2, 10, 12.01, 2, mean, p99)); + + Assert.Equal(VulkanStats.LatencyIntervalCount, VulkanStats.LatencyIntervalTokens.Length); + Assert.Equal( "stats.counters blocking_uploads=1 uploads=2 scopes=3 barriers=4 rebar_fallbacks=5 " + "dynamic_state=6 uniform_ring_used=7 uniform_ring_capacity=8 barrier_commands=9 barriers_per_frame=2.0 " + @@ -164,15 +188,17 @@ public void SampleIsTheOriginalLineFollowedByTheTokenLines() Assert.NotNull(sample); string[] lines = sample!.Split('\n'); - Assert.Equal(8, lines.Length); + Assert.Equal(9, lines.Length); // Native shader runtime seam: the latest shader load's native, rewritten and failed programs. - Assert.StartsWith("stats.shaders native=", lines[7]); + Assert.StartsWith("stats.shaders native=", lines[8]); // Caching follow-ups: pipelines compiled blocking/async/prewarmed, skipped draws, cache bytes, saves. - Assert.StartsWith("stats.pipelines compiled_sync=", lines[6]); + Assert.StartsWith("stats.pipelines compiled_sync=", lines[7]); // Phase 2 step 4: transient and aliased MiB, the Transient pool's heap peak, ReadSelf copies. - Assert.StartsWith("stats.transients transient_mib=", lines[5]); + Assert.StartsWith("stats.transients transient_mib=", lines[6]); // Phase 1B step 5: pool classes, ReBAR use and misses, used/budget per heap. - Assert.StartsWith("stats.memory blocks=", lines[4]); + Assert.StartsWith("stats.memory blocks=", lines[5]); + // Latency seams S7: always emitted, with no backend installed too. + Assert.StartsWith("stats.latency backend=", lines[4]); Assert.Matches(new Regex( @"^stats [\d.]+s: \d+ frames \([\d.]+ ms/frame\), \d+ allocations \(\d+ live\), " + @"\d+ blocking uploads costing \d+ ms \(\S+% of the interval\), textures \+\d+/-\d+, " + @@ -194,6 +220,8 @@ public void AcceptanceDocumentNamesEveryStatsToken() VulkanAllocator.FormatMemoryLine(default), VulkanStats.FormatTransientsLine(default), VulkanStats.FormatPipelinesLine(default), + VulkanStats.FormatLatencyLine("off", "off", 0, 0, 0, + 0, new double[VulkanStats.LatencyIntervalCount], new double[VulkanStats.LatencyIntervalCount]), }) { foreach (Match token in Regex.Matches(line, @"([a-z0-9_]+)=")) @@ -210,6 +238,7 @@ public void AcceptanceDocumentNamesEveryStatsToken() Assert.Contains("stats.counters", doc); Assert.Contains("stats.memory", doc); Assert.Contains("stats.transients", doc); + Assert.Contains("stats.latency", doc); } [Fact] @@ -356,7 +385,7 @@ public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() string swapchain = Source("Present/Swapchain.cs"); Assert.Contains("WaitSite.SwapchainAcquire", Body(swapchain, "public bool TryAcquire(")); - Assert.Contains("WaitSite.Present", Body(swapchain, "public void Present(")); + Assert.Contains("WaitSite.Present", Body(swapchain, "public ulong Present(")); string device = Source("VulkanDevice.cs"); Assert.Contains("FrameSlot slot = _frames.BeginFrame();", Body(device, "public void BeginFrame()")); diff --git a/Optimum.Render.Vulkan.Tests/PresentIdentityTests.cs b/Optimum.Render.Vulkan.Tests/PresentIdentityTests.cs new file mode 100644 index 00000000..b468adc5 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PresentIdentityTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using OpenTK.Windowing.GraphicsLibraryFramework; +using Optimum.Render.Vulkan.Core; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Latency seams S2 and S5, against a real (hidden) window: the present id is +/// allocated once per vkQueuePresentKHR, strictly increases, survives every +/// swapchain recreation a resize or a vsync toggle causes, and each creation +/// tells the latency backend exactly once so a backend can re-apply its +/// per-swapchain state. +/// +public class PresentIdentityTests +{ + private const int Width = 256; + private const int Height = 192; + + private readonly ITestOutputHelper _output; + + public PresentIdentityTests(ITestOutputHelper output) => _output = output; + + [SkippableFact] + public unsafe void PresentIdsIncreaseAcrossResizesAndEachSwapchainIsAnnouncedOnce() + { + Skip.IfNot(SwapchainTests.TryCreateWindow(_output, Width, Height, out Window* window), "No usable window system."); + + try + { + VulkanDevice device = GpuTest.NewDevice(); + var latency = new RecordingLatencyBackend(); + device.SetLatencyBackend(latency); + + if (!device.Initialize((IntPtr)window, Width, Height, out string failureReason)) + { + device.Dispose(); + Skip.If(true, "Vulkan presentation unavailable: " + failureReason); + return; + } + + using (device) + { + VulkanDevice seam = device; + Swapchain swapchain = device.SwapchainForTests!; + int programId = SwapchainTests.LinkFullscreenProgram(seam); + var presentIds = new List(); + + void RenderFrames(int count, int w, int h) + { + for (int frame = 0; frame < count; frame++) + { + seam.BeginLatencyFrame(); + seam.BeginFrame(); + seam.NoteRenderStageStarted(); + seam.BindDefaultFramebuffer(); + seam.ClearColor(0, 0.2f, 0.4f, 0.6f, 1f); + seam.UseProgram(programId); + seam.SetViewport(0, 0, w, h); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.DrawFullscreenTriangle(); + seam.Present(); + if (device.LastPresentTimingsForTests.Presented) + { + presentIds.Add(device.LastPresentIdForTests); + // The map keeps the frame that produced it (1:1 until + // frame generation presents one frame twice). + Assert.True(swapchain.PresentIds.TryGetFrameId( + device.LastPresentIdForTests, out ulong mapped)); + Assert.Equal(seam.LatencyFrameId, mapped); + } + } + } + + // Present ids ride VkPresentIdKHR whenever the device offers the extension + // and its feature on a presentable device - the frame identity reaches the + // display without any pacing backend - and never otherwise, which would be a + // validation error. AssertClean below covers the chained presents. + VulkanCapabilities caps = device.ContextForTests.Capabilities; + _output.WriteLine(caps.LatencySummary); + Assert.Equal(caps.LatencySupport.PresentId, caps.PresentIdEnabled); + Assert.Equal(caps.PresentIdEnabled, swapchain.PresentIdEnabled); + Assert.Equal(caps.PresentIdEnabled, + Array.IndexOf(device.ContextForTests.EnabledDeviceExtensions, "VK_KHR_present_id") >= 0); + Assert.Equal(LatencyBackendKind.None, caps.LatencyBackend); + + // The first swapchain is announced like every later one. + Assert.Equal(swapchain.Creations, latency.SwapchainCount); + Assert.Equal(1, latency.SwapchainCount); + + RenderFrames(3, Width, Height); + + (int W, int H)[] sizes = { (320, 240), (200, 150), (512, 384), (256, 192) }; + int iterations = 0; + foreach ((int w, int h) in sizes) + { + GLFW.SetWindowSize(window, w, h); + GLFW.PollEvents(); + seam.Resize(w, h); + if (iterations % 2 == 1) seam.SetVSync(iterations % 4 == 1); + RenderFrames(3, w, h); + iterations++; + } + + _output.WriteLine($"{presentIds.Count} presents, ids {presentIds[0]}..{presentIds[^1]}, " + + $"{swapchain.Creations} swapchains created, {latency.SwapchainCount} announced"); + + // Strictly increasing, recreation included: the counter is global + // and is never reset by a rebuild. + Assert.True(presentIds.Count >= 12, $"only {presentIds.Count} presents"); + for (int i = 1; i < presentIds.Count; i++) + { + Assert.True(presentIds[i] > presentIds[i - 1], + $"present id {presentIds[i]} did not exceed {presentIds[i - 1]} at index {i}"); + } + + // Exactly one announcement per creation, and more than one + // creation happened (the resizes rebuilt the chain). + Assert.True(swapchain.Creations > 1, $"{swapchain.Creations} swapchain creations"); + Assert.Equal(swapchain.Creations, latency.SwapchainCount); + + // Distinct handles: a re-announced old handle would let a backend + // re-apply state to a chain that is already retired. + Assert.Equal(latency.Swapchains.Count, new HashSet(HandlesOf(latency)).Count); + + GpuTest.AssertClean(seam); + } + } + finally + { + GLFW.DestroyWindow(window); + GLFW.Terminate(); + } + } + + private static IEnumerable HandlesOf(RecordingLatencyBackend latency) + { + foreach (Silk.NET.Vulkan.SwapchainKHR handle in latency.Swapchains) yield return handle.Handle; + } + + /// + /// The counter itself, without a GPU: one value per present, never reused, + /// and the map answers with the frame that produced each id while it holds it. + /// + [Fact] + public void ThePresentIdCounterAndMapArePlainMonotonicBookkeeping() + { + ulong first = PresentIdCounter.Next(); + ulong second = PresentIdCounter.Next(); + Assert.True(second > first); + Assert.Equal(second, PresentIdCounter.Current); + + var map = new PresentIdMap(4); + Assert.False(map.TryGetFrameId(1, out _)); + for (ulong i = 1; i <= 4; i++) map.Record(i, 100 + i); + Assert.True(map.TryGetFrameId(3, out ulong frame)); + Assert.Equal(103UL, frame); + Assert.Equal(4UL, map.LastPresentId); + Assert.Equal(104UL, map.LastFrameId); + + // It wraps rather than growing; the oldest entry is the one that goes. + map.Record(5, 105); + Assert.False(map.TryGetFrameId(1, out _)); + Assert.True(map.TryGetFrameId(5, out frame)); + Assert.Equal(105UL, frame); + } +} diff --git a/Optimum.Render.Vulkan.Tests/RecordingLatencyBackend.cs b/Optimum.Render.Vulkan.Tests/RecordingLatencyBackend.cs new file mode 100644 index 00000000..27968193 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/RecordingLatencyBackend.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// A latency backend that only records. Later stages hand it to a device and +/// assert the marker order of a real frame against it, which is the whole point: +/// the seams can be tested without an NVIDIA or AMD GPU present. +/// +internal sealed class RecordingLatencyBackend : ILatencyBackend +{ + private readonly List _reports = new(); + + /// Every (frameId, marker) in the order it was stamped. + public List<(ulong FrameId, LatencyMarker Marker)> Markers { get; } = new(); + + /// The frame ids was called with, in order. + public List Sleeps { get; } = new(); + + /// The frame/present id pairs was called with. + public List<(ulong FrameId, ulong PresentId)> Presents { get; } = new(); + + /// The swapchains handed to . + public List Swapchains { get; } = new(); + + /// Every settings object handed to , in order. + public List Applied { get; } = new(); + + public int SleepCount => Sleeps.Count; + + public int SwapchainCount => Swapchains.Count; + + public int TagSubmitCount { get; private set; } + + /// What claims to have waited, in microseconds. + public ulong SleepDurationUs { get; set; } + + /// What answers. + public bool OwnsFrameCapValue { get; set; } + + public LatencyBackendKind Kind { get; set; } = LatencyBackendKind.Native; + + public LatencySettings Settings { get; private set; } = LatencySettings.Disabled; + + public void Apply(in LatencySettings settings) + { + Settings = settings; + Applied.Add(settings); + } + + public bool OwnsFrameCap => OwnsFrameCapValue; + + public ulong Sleep(ulong frameId) + { + Sleeps.Add(frameId); + return SleepDurationUs; + } + + public void Marker(ulong frameId, LatencyMarker marker) => Markers.Add((frameId, marker)); + + public void OnSwapchainCreated(SwapchainKHR swapchain) + { + Swapchains.Add(swapchain); + SwapchainEvents.Add(swapchain); + } + + /// The handles announced, plus a null entry for every retirement, in order. + public List SwapchainEvents { get; } = new(); + + /// How often the swapchain the backend holds was retired or destroyed. + public int SwapchainRetirements { get; private set; } + + public void OnSwapchainRetired() + { + SwapchainRetirements++; + SwapchainEvents.Add(default); + } + + public unsafe void* TagSubmit(ulong frameId, void* pNext) + { + TagSubmitCount++; + return pNext; + } + + public void OnPresent(ulong frameId, ulong presentId) + { + Presents.Add((frameId, presentId)); + _reports.Add(new LatencyFrameReport(frameId, presentId, 0, 0, 0, 0, 0, 0, 0, 0)); + } + + public LatencyFrameReport[] TakeReports() + { + LatencyFrameReport[] taken = _reports.ToArray(); + _reports.Clear(); + return taken; + } + + /// The markers of one frame, in order. + public LatencyMarker[] MarkersOf(ulong frameId) + { + var markers = new List(); + foreach ((ulong id, LatencyMarker marker) in Markers) + { + if (id == frameId) markers.Add(marker); + } + return markers.ToArray(); + } + + public void Dispose() + { + } +} diff --git a/Optimum.Render.Vulkan/Core/FrameRing.cs b/Optimum.Render.Vulkan/Core/FrameRing.cs index 72e4b2ab..6e1c0f96 100644 --- a/Optimum.Render.Vulkan/Core/FrameRing.cs +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -10,6 +10,28 @@ namespace Optimum.Render.Vulkan.Core; /// Where a uniform upload landed in the ring buffer. internal readonly record struct RingAllocation(Buffer Buffer, uint Offset, IntPtr Pointer); +/// +/// What a frame's submissions are tagged with (plan section "Latency seams", +/// seam S4): the active latency backend and the latency frame id the renderer +/// allocated for the frame being recorded. +/// +/// One mutable holder rather than a parameter on every submit, because the tag +/// has to reach the shared that Submit A, +/// Submit B and SubmitPartial all pass through, and because the backend and the +/// frame id change at different moments (the backend once at device setup, the +/// id once per frame). UploadManager.SubmitStandalone does not go through +/// that path and stays untagged, which is the rule for NV's revision-3 tagging: +/// a frame's submits are all tagged or none of them are. +/// +internal sealed class LatencySubmitTag +{ + /// The active backend; the None backend adds nothing to any chain. + public ILatencyBackend Backend = new NoneLatencyBackend(); + + /// The latency frame id of the frame being recorded; 0 before the first. + public ulong FrameId; +} + /// /// One frame's worth of transient GPU state. /// @@ -38,6 +60,7 @@ internal sealed unsafe class FrameSlot : IDisposable private readonly ulong _regionStart; private readonly ulong _regionSize; private readonly VulkanBuffer _uniformRing; + private readonly LatencySubmitTag _latency; // Allocated once and recycled: resetting the pool returns every one of them // to the initial state, where it can be begun again. private readonly List _commandBuffers = new(); @@ -64,8 +87,9 @@ internal sealed unsafe class FrameSlot : IDisposable public int PartialSubmits { get; private set; } public FrameSlot(VulkanContext context, FrameTimeline timeline, UploadManager uploads, VulkanBuffer uniformRing, - ulong regionStart, ulong regionSize, int index = 0) + ulong regionStart, ulong regionSize, int index = 0, LatencySubmitTag? latency = null) { + _latency = latency ?? new LatencySubmitTag(); _context = context; _timeline = timeline; _uploads = uploads; @@ -304,10 +328,16 @@ private void Submit(Semaphore waitSemaphore, PipelineStageFlags waitStage, Semap PSignalSemaphoreValues = signalValues, }; + // Seam S4: every submit of the frame passes through here, so the + // backend chains its per-submit struct (NV's VkLatencySubmissionPresentIdNV + // at extension revision 3 and up) onto the chain the frame already + // built. The None backend returns it unchanged, so nothing branches. + void* chain = _latency.Backend.TagSubmit(_latency.FrameId, &timelineInfo); + var submit = new SubmitInfo { SType = StructureType.SubmitInfo, - PNext = &timelineInfo, + PNext = chain, CommandBufferCount = commandBufferCount, PCommandBuffers = commandBuffers, WaitSemaphoreCount = waitCount, @@ -373,6 +403,7 @@ internal sealed class FrameRing : IDisposable private readonly RetireQueue _retired; private readonly UploadManager _uploads; private readonly VulkanAllocator _allocator; + private readonly LatencySubmitTag _latency = new(); private int _index = -1; private bool _disposed; @@ -399,12 +430,19 @@ public FrameRing(VulkanContext context, int framesInFlight = 2, ulong uniformRin _slots = new FrameSlot[framesInFlight]; for (int i = 0; i < framesInFlight; i++) { - _slots[i] = new FrameSlot(context, _timeline, _uploads, _uniformRing, regionSize * (ulong)i, regionSize, i); + _slots[i] = new FrameSlot(context, _timeline, _uploads, _uniformRing, regionSize * (ulong)i, regionSize, i, + _latency); } } public int FramesInFlight => _slots.Length; + /// + /// What every submit of this ring is tagged with (seam S4). The device sets + /// the backend once and the frame id once per frame; the slots read it. + /// + public LatencySubmitTag Latency => _latency; + /// /// Every ring offset is a legal uniform and storage buffer offset: both limits are /// powers of two, so the larger is a multiple of the smaller. diff --git a/Optimum.Render.Vulkan/Core/VulkanContext.cs b/Optimum.Render.Vulkan/Core/VulkanContext.cs index 5e71f794..89da4795 100644 --- a/Optimum.Render.Vulkan/Core/VulkanContext.cs +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -42,6 +42,13 @@ internal sealed class VulkanContextOptions /// public ColorWriteTier? ColorWriteTier; + /// + /// Subsystems that need instance or device extensions and feature structs + /// (plan seam S1). The latency requirements are added by the context itself; + /// this is where a test or a later vendor SDK adds its own. + /// + public List RequirementContributors = new(); + /// /// Tests only: sleeps this long before every vkAcquireNextImageKHR, standing /// in for a compositor that holds images back (PresentDecouplingTests). @@ -109,6 +116,22 @@ public float ClampLineWidth(float width) public bool DynamicColorBlend; /// The tier draws use; see . public ColorWriteTier ColorWriteTier = ColorWriteTier.PipelineKey; + + // ------------------------------------------------------------------ latency + // Plan seam S1. Detection is recorded whether or not anything was enabled: + // the "device up" line reports what the driver offered, not only what was taken. + + /// What the driver advertises for latency work. + public LatencyDeviceSupport LatencySupport; + + /// The backend selected for this device; None on this branch. + public LatencyBackendKind LatencyBackend = LatencyBackendKind.None; + + /// VK_KHR_present_id is enabled with its feature, so Swapchain.Present may chain VkPresentIdKHR. + public bool PresentIdEnabled; + + /// The latency part of the "device up" log line. + public string LatencySummary = "latency backend off"; /// /// pipelineCreationCacheControl (core in 1.3, optional to support) enabled: pipelines can be /// created with FAIL_ON_PIPELINE_COMPILE_REQUIRED, which the background compile path needs. @@ -220,6 +243,16 @@ internal static bool PoisonRequested(string? setting) => private nint _getQueueCheckpointData; private ExtDeviceFault? _deviceFault; + /// The instance extensions actually named in VkInstanceCreateInfo. + public string[] EnabledInstanceExtensions { get; private set; } = Array.Empty(); + + /// The device extensions actually named in VkDeviceCreateInfo. + public string[] EnabledDeviceExtensions { get; private set; } = Array.Empty(); + + /// The latency contributor, which also holds the detection results. + private LatencyDeviceRequirements? _latencyRequirements; + private List? _contributors; + private ExtDebugUtils? _debugUtils; private DebugUtilsMessengerEXT _debugMessenger; private Action? _debugCallback; @@ -326,6 +359,20 @@ private bool CreateInstance(VulkanContextOptions options, out string? failureRea ? "extra checks NOT APPLIED (the layer has neither VK_EXT_layer_settings nor VK_EXT_validation_features)" : ""; + // Seam S1: subsystems ask for what they need. A request the loader cannot + // satisfy is refused and logged, never named in the create info - naming + // an absent extension fails vkCreateInstance, which would turn an + // optional feature into a silent fall back to OpenGL (rule 1). + var instanceRequirements = new InstanceRequirements(EnumerateInstanceExtensions(), extensions) + { + Log = options.DebugCallback, + }; + foreach (IDeviceRequirementContributor contributor in Contributors(options)) + { + contributor.ContributeInstanceExtensions(instanceRequirements); + } + EnabledInstanceExtensions = extensions.ToArray(); + byte* applicationName = (byte*)SilkMarshal.StringToPtr("Optimum"); byte* engineName = (byte*)SilkMarshal.StringToPtr("Optimum.Render.Vulkan"); nint extensionsPtr = SilkMarshal.StringArrayToPtr(extensions); @@ -873,18 +920,18 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso // Diagnostics for a lost device. Both are optional and cost nothing when // the GPU is healthy, so they are taken wherever the driver offers them. - HashSet deviceExtensionsAvailable = EnumerateDeviceExtensions(); + Dictionary deviceExtensionsAvailable = EnumerateDeviceExtensions(); bool checkpointsDisabled = Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_CHECKPOINTS") is "0" or "off" or "false"; bool wantCheckpoints = !checkpointsDisabled && IntPtr.Size == 8 - && deviceExtensionsAvailable.Contains("VK_NV_device_diagnostic_checkpoints"); + && deviceExtensionsAvailable.ContainsKey("VK_NV_device_diagnostic_checkpoints"); var faultFeatures = new PhysicalDeviceFaultFeaturesEXT { SType = StructureType.PhysicalDeviceFaultFeaturesExt, }; bool wantDeviceFault = false; - if (deviceExtensionsAvailable.Contains("VK_EXT_device_fault")) + if (deviceExtensionsAvailable.ContainsKey("VK_EXT_device_fault")) { var query = new PhysicalDeviceFeatures2 { @@ -929,8 +976,8 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso { SType = StructureType.PhysicalDeviceExtendedDynamicState3FeaturesExt, }; - bool hasColorWriteEnable = deviceExtensionsAvailable.Contains("VK_EXT_color_write_enable"); - bool hasDynamicState3 = deviceExtensionsAvailable.Contains("VK_EXT_extended_dynamic_state3"); + bool hasColorWriteEnable = deviceExtensionsAvailable.ContainsKey("VK_EXT_color_write_enable"); + bool hasDynamicState3 = deviceExtensionsAvailable.ContainsKey("VK_EXT_extended_dynamic_state3"); if (hasColorWriteEnable || hasDynamicState3) { colorWriteFeatures.PNext = hasDynamicState3 ? &dynamicState3Features : null; @@ -952,23 +999,15 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso colorWriteFeatures = new PhysicalDeviceColorWriteEnableFeaturesEXT { SType = StructureType.PhysicalDeviceColorWriteEnableFeaturesExt, - PNext = wantDeviceFault ? &faultFeatures : null, ColorWriteEnable = true, }; dynamicState3Features = new PhysicalDeviceExtendedDynamicState3FeaturesEXT { SType = StructureType.PhysicalDeviceExtendedDynamicState3FeaturesExt, - PNext = wantDeviceFault ? &faultFeatures : null, ExtendedDynamicState3ColorWriteMask = true, ExtendedDynamicState3ColorBlendEnable = canBlend, ExtendedDynamicState3ColorBlendEquation = canBlend, }; - void* optionalFeatures = colorWriteTier switch - { - ColorWriteTier.DynamicEnable => &colorWriteFeatures, - ColorWriteTier.DynamicMask => &dynamicState3Features, - _ => wantDeviceFault ? &faultFeatures : null, - }; // Optional: FAIL_ON_PIPELINE_COMPILE_REQUIRED for the background compile path // (docs/research/vulkan-caching.md §2). Without it every pipeline compiles blocking. @@ -984,7 +1023,6 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso var vulkan13 = new PhysicalDeviceVulkan13Features { SType = StructureType.PhysicalDeviceVulkan13Features, - PNext = optionalFeatures, DynamicRendering = true, Synchronization2 = true, PipelineCreationCacheControl = pipelineCacheControl, @@ -1015,12 +1053,32 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso // Optional tier: per-heap budgets from the driver; without it the // allocator budgets heap x 0.7. The env override forces the fallback. - bool wantMemoryBudget = deviceExtensionsAvailable.Contains("VK_EXT_memory_budget") + bool wantMemoryBudget = deviceExtensionsAvailable.ContainsKey("VK_EXT_memory_budget") && Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_NO_MEMORY_BUDGET") != "1"; if (wantMemoryBudget) deviceExtensions.Add("VK_EXT_memory_budget"); if (colorWriteTier == ColorWriteTier.DynamicEnable) deviceExtensions.Add("VK_EXT_color_write_enable"); if (colorWriteTier == ColorWriteTier.DynamicMask) deviceExtensions.Add("VK_EXT_extended_dynamic_state3"); + // One pNext chain instead of a single optional-features slot: device fault, + // the colour-write tier and every contributor's feature struct link together, + // so two optional subsystems can be on at once (seam S1). What each of them + // asks for is unchanged, and the tier still enables exactly one of its two + // extensions. + using var requirements = new DeviceRequirements( + Api, PhysicalDevice, deviceExtensionsAvailable, deviceExtensions) + { + Log = options.DebugCallback, + }; + if (wantDeviceFault) requirements.ChainFeature(&faultFeatures); + if (colorWriteTier == ColorWriteTier.DynamicEnable) requirements.ChainFeature(&colorWriteFeatures); + if (colorWriteTier == ColorWriteTier.DynamicMask) requirements.ChainFeature(&dynamicState3Features); + + foreach (IDeviceRequirementContributor contributor in Contributors(options)) + { + contributor.ContributeDeviceRequirements(requirements); + } + vulkan13.PNext = requirements.Chain; + nint extensionsPtr = deviceExtensions.Count > 0 ? SilkMarshal.StringArrayToPtr(deviceExtensions) : 0; @@ -1067,14 +1125,74 @@ private bool CreateDevice(VulkanContextOptions options, out string? failureReaso DynamicState3Api = state3; } MemoryBudgetAvailable = wantMemoryBudget; + EnabledDeviceExtensions = deviceExtensions.ToArray(); + RecordLatencyCapabilities(); Allocator = new VulkanAllocator(this); return true; } - private HashSet EnumerateDeviceExtensions() + /// + /// The requirement contributors for this context, built once: the latency + /// requirements (always present, because detection is reported even when + /// nothing is enabled) followed by whatever the caller added. + /// + private List Contributors(VulkanContextOptions options) + { + if (_contributors != null) return _contributors; + + _latencyRequirements = new LatencyDeviceRequirements(); + _contributors = new List { _latencyRequirements }; + if (options.RequirementContributors != null) + { + _contributors.AddRange(options.RequirementContributors); + } + return _contributors; + } + + /// Copies the latency contributor's findings into the capabilities (seam S1). + private void RecordLatencyCapabilities() + { + if (_latencyRequirements == null) return; + + Capabilities.LatencySupport = _latencyRequirements.Support; + Capabilities.LatencyBackend = _latencyRequirements.Selected; + Capabilities.PresentIdEnabled = _latencyRequirements.PresentIdEnabled; + Capabilities.LatencySummary = _latencyRequirements.Summary(); + } + + /// The instance extensions the loader advertises (no layer named). + private HashSet EnumerateInstanceExtensions() { var names = new HashSet(StringComparer.Ordinal); + uint count = 0; + if (Api.EnumerateInstanceExtensionProperties((byte*)null, &count, null) != Result.Success || count == 0) + { + return names; + } + + var properties = new ExtensionProperties[count]; + fixed (ExtensionProperties* propertiesPtr = properties) + { + if (Api.EnumerateInstanceExtensionProperties((byte*)null, &count, propertiesPtr) != Result.Success) + { + return names; + } + // The name is a fixed-size buffer, readable only through a pointer. + for (int i = 0; i < count; i++) + { + string? name = SilkMarshal.PtrToString((nint)propertiesPtr[i].ExtensionName); + if (name != null) names.Add(name); + } + } + return names; + } + + /// Every device extension the driver advertises, with its revision. + private Dictionary EnumerateDeviceExtensions() + { + var names = new Dictionary(StringComparer.Ordinal); + uint count = 0; Result result = Api.EnumerateDeviceExtensionProperties(PhysicalDevice, (byte*)null, &count, null); if (result != Result.Success || count == 0) return names; @@ -1088,7 +1206,7 @@ private HashSet EnumerateDeviceExtensions() for (int i = 0; i < count; i++) { string? name = SilkMarshal.PtrToString((nint)propertiesPtr[i].ExtensionName); - if (name != null) names.Add(name); + if (name != null) names[name] = propertiesPtr[i].SpecVersion; } } return names; diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs index 534339af..ab1dd37a 100644 --- a/Optimum.Render.Vulkan/Core/VulkanStats.cs +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -53,6 +53,13 @@ internal enum WaitSite /// thread can stall here on GPU work it did not issue. /// QueueSubmit = 8, + /// + /// The latency backend's one sleep before the frame's input is sampled + /// (plan section "Latency seams", seam S6). Zero with the None backend, which + /// never sleeps; with a pacing backend active this is where the frame waits, and + /// should find its value already signalled. + /// + LatencySleep = 9, } /// @@ -77,6 +84,7 @@ internal enum WaitSite /// stats.pacing samples=512 p50_ms=16.667 p95_ms=17.100 p99_ms=18.300 stddev_ms=0.420 stutters=0 /// stats.waits frame_pacing_n=60 frame_pacing_ms=812.4 upload_submit_n=0 upload_submit_ms=0.0 ... queue_submit_n=60 queue_submit_ms=1.9 /// stats.counters blocking_uploads=0 uploads=0 scopes=900 barriers=12 rebar_fallbacks=0 dynamic_state=12600 uniform_ring_used=412800 uniform_ring_capacity=16777216 +/// stats.latency backend=off mode=off rev=0 sleep_n=0 sleep_ms=0.0 frames=60 input_mean_ms=0.02 input_p99_ms=0.04 ... total_mean_ms=16.60 total_p99_ms=18.20 /// /// internal static class VulkanStats @@ -93,9 +101,10 @@ internal static class VulkanStats "swapchain_acquire", "present", "queue_submit", + "latency_sleep", }; - public const int WaitSiteCount = 9; + public const int WaitSiteCount = 10; /// /// Dynamic-state commands VulkanDevice.ApplyDynamicState can record for @@ -568,6 +577,7 @@ public static Result WaitDeviceIdle(Vk api, Device device) FormatPacingLine(FrameIntervals.Snapshot()) + "\n" + FormatWaitsLine(waitCounts, waitMs) + "\n" + FormatCountersLine(counters) + "\n" + + LatencyLine(waitCounts[(int)WaitSite.LatencySleep], waitMs[(int)WaitSite.LatencySleep]) + "\n" + VulkanAllocator.FormatMemoryLine(memorySnapshot) + "\n" + FormatTransientsLine(new TransientSample( TransientBytes: (ulong)Interlocked.Read(ref _transientBytes), @@ -622,6 +632,141 @@ public static string FormatTransientsLine(TransientSample sample) => /// public static volatile VulkanAllocator? MemorySource; + /// + /// The latency backend the stats.latency line reports (seam S7); the + /// device sets it whenever the active backend changes and clears it at + /// dispose. Null means the line still appears, with the "off" backend and no + /// frames - the line is always present so a parser never has to branch. + /// + public static volatile ILatencyBackend? LatencySource; + + /// + /// The vendor extension revision behind the active backend, for the + /// rev= token of the line (seam S7): VK_NV_low_latency2's specVersion, + /// which decides whether submits carry per-submit attribution. 0 for the None + /// backend, which is the only one on this branch. + /// + public static volatile uint LatencyRevision; + + /// + /// The eight intervals of , in the order they + /// appear on the stats.latency line. + /// + public static readonly string[] LatencyIntervalTokens = + { + "input", + "sim", + "render_submit", + "present", + "driver", + "os_queue", + "gpu", + "total", + }; + + public const int LatencyIntervalCount = 8; + + /// The intervals of one report, in order, in microseconds. + private static void IntervalsOf(in LatencyFrameReport report, ulong[] into) + { + into[0] = report.InputUs; + into[1] = report.SimulationUs; + into[2] = report.RenderSubmitUs; + into[3] = report.PresentUs; + into[4] = report.DriverUs; + into[5] = report.OsRenderQueueUs; + into[6] = report.GpuUs; + into[7] = report.TotalUs; + } + + /// + /// Reduces the frame reports of one interval to a mean and a p99 per interval, + /// in milliseconds - the same reduction + /// applies to frame intervals, and the same nearest-rank percentile + /// (index ceil(0.99 * n) - 1), so the two lines are read the same way. + /// + public static void ReduceReports(LatencyFrameReport[] reports, double[] meanMs, double[] p99Ms) + { + for (int i = 0; i < LatencyIntervalCount; i++) + { + meanMs[i] = 0; + p99Ms[i] = 0; + } + if (reports.Length == 0) return; + + var values = new double[LatencyIntervalCount][]; + for (int i = 0; i < LatencyIntervalCount; i++) values[i] = new double[reports.Length]; + + var scratch = new ulong[LatencyIntervalCount]; + for (int r = 0; r < reports.Length; r++) + { + IntervalsOf(reports[r], scratch); + for (int i = 0; i < LatencyIntervalCount; i++) values[i][r] = scratch[i] / 1000.0; + } + + for (int i = 0; i < LatencyIntervalCount; i++) + { + double sum = 0; + for (int r = 0; r < reports.Length; r++) sum += values[i][r]; + meanMs[i] = sum / reports.Length; + + Array.Sort(values[i]); + int index = (int)Math.Ceiling(0.99 * reports.Length) - 1; + if (index < 0) index = 0; + if (index > reports.Length - 1) index = reports.Length - 1; + p99Ms[i] = values[i][index]; + } + } + + /// + /// stats.latency (seam S7): which backend is active, the mode asked of + /// it, the sleep it made in the interval, and each report interval reduced to + /// a mean and a p99. Always emitted, so "off" is as visible as "on". + /// + public static string FormatLatencyLine(string backend, string mode, uint rev, long sleepCount, double sleepMs, + int frames, double[] meanMs, double[] p99Ms) + { + var line = new StringBuilder("stats.latency backend="); + line.Append(backend).Append(" mode=").Append(mode); + line.Append(" rev=").Append(rev.ToString(CultureInfo.InvariantCulture)); + line.Append(" sleep_n=").Append(sleepCount.ToString(CultureInfo.InvariantCulture)); + line.Append(" sleep_ms=").Append(sleepMs.ToString("F1", CultureInfo.InvariantCulture)); + line.Append(" frames=").Append(frames.ToString(CultureInfo.InvariantCulture)); + for (int i = 0; i < LatencyIntervalCount; i++) + { + line.Append(' ').Append(LatencyIntervalTokens[i]).Append("_mean_ms=") + .Append(meanMs[i].ToString("F2", CultureInfo.InvariantCulture)); + line.Append(' ').Append(LatencyIntervalTokens[i]).Append("_p99_ms=") + .Append(p99Ms[i].ToString("F2", CultureInfo.InvariantCulture)); + } + return line.ToString(); + } + + /// The stats.latency line for the backend currently set as . + private static string LatencyLine(long sleepCount, double sleepMs) + { + ILatencyBackend? latency = LatencySource; + LatencyFrameReport[] reports = latency == null + ? Array.Empty() + : latency.TakeReports(); + + var meanMs = new double[LatencyIntervalCount]; + var p99Ms = new double[LatencyIntervalCount]; + ReduceReports(reports, meanMs, p99Ms); + + string backend = LatencyBackends.Token(latency == null ? LatencyBackendKind.None : latency.Kind); + string mode = latency == null ? "off" : ModeToken(latency.Settings.Mode); + return FormatLatencyLine(backend, mode, LatencyRevision, sleepCount, sleepMs, reports.Length, meanMs, p99Ms); + } + + /// The token of one on the stats line. + public static string ModeToken(LatencyMode mode) => mode switch + { + LatencyMode.On => "on", + LatencyMode.Boost => "boost", + _ => "off", + }; + /// The original stats line. Its format must not change. public static string FormatIntervalLine(double elapsed, long frames, long allocations, int liveAllocations, long uploads, double uploadMs, long created, long deleted, long dropped, long overflows) diff --git a/Optimum.Render.Vulkan/Latency/IDeviceRequirementContributor.cs b/Optimum.Render.Vulkan/Latency/IDeviceRequirementContributor.cs new file mode 100644 index 00000000..f73f17ae --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/IDeviceRequirementContributor.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// A subsystem that needs something from the instance or the device (plan seam +/// S1). The latency backends are the first of these; NGX/Streamline's +/// GetFeatureInstance/DeviceExtensionRequirements plug in the same way. +/// +/// Contributors are consulted inside +/// and , before the create call, and +/// may only ever ask: an extension the loader or the driver does not +/// advertise is refused by / +/// and reported back, never named in +/// the create info. Naming an absent extension fails creation outright, which +/// would turn an optional feature into a silent fall back to OpenGL (rule 1). +/// +internal interface IDeviceRequirementContributor +{ + /// Short name for the log line when a request is refused. + string Name { get; } + + /// Called once before vkCreateInstance. + void ContributeInstanceExtensions(InstanceRequirements requirements); + + /// + /// Called once before vkCreateDevice, with the physical device already + /// chosen, so a contributor can query features before deciding what to ask + /// for. Anything chained here is part of the VkDeviceCreateInfo pNext chain. + /// + void ContributeDeviceRequirements(DeviceRequirements requirements); +} + +/// What the loader advertises, and what the instance will enable. +internal sealed class InstanceRequirements +{ + private readonly HashSet _available; + private readonly List _enabled; + + public InstanceRequirements(HashSet available, List enabled) + { + _available = available; + _enabled = enabled; + } + + /// Notes about refused requests; never an error. + public Action? Log; + + public bool Has(string name) => _available.Contains(name); + + public bool IsEnabled(string name) => _enabled.Contains(name); + + /// + /// Enables when the loader has it. Returns whether + /// the instance will have it; asking twice is harmless. + /// + public bool Request(string name, string? requestedBy = null) + { + if (_enabled.Contains(name)) return true; + if (!_available.Contains(name)) + { + Log?.Invoke((requestedBy ?? "a contributor") + " asked for instance extension " + name + + ", which the loader does not advertise; continuing without it"); + return false; + } + _enabled.Add(name); + return true; + } + + public IReadOnlyList Enabled => _enabled; +} + +/// +/// What the physical device advertises, what the logical device will enable, and +/// the one pNext chain of VkDeviceCreateInfo. +/// +/// The chain replaces the single-slot "optionalFeatures" of the pre-latency +/// context: colour write, device fault and every contributor's feature struct +/// now link into one list, so two optional tiers can be on at the same time. +/// Structs handed to are copied into unmanaged +/// scratch owned here and freed by after vkCreateDevice +/// has returned, so a contributor never has to keep memory pinned itself. +/// +internal sealed unsafe class DeviceRequirements : IDisposable +{ + private readonly Dictionary _available; + private readonly List _enabled; + private readonly List _scratch = new(); + private void* _chain; + private bool _disposed; + + public DeviceRequirements( + Vk api, PhysicalDevice physicalDevice, Dictionary available, List enabled) + { + Api = api; + PhysicalDevice = physicalDevice; + _available = available; + _enabled = enabled; + } + + public Vk Api { get; } + public PhysicalDevice PhysicalDevice { get; } + + /// Notes about refused requests; never an error. + public Action? Log; + + /// Whether the device advertises the extension at or above a revision. + public bool Has(string name, uint minimumSpecVersion = 0) => + _available.TryGetValue(name, out uint version) && version >= minimumSpecVersion; + + /// The advertised revision, 0 when the device does not have the extension. + public uint SpecVersion(string name) => _available.TryGetValue(name, out uint version) ? version : 0; + + public bool IsEnabled(string name) => _enabled.Contains(name); + + /// + /// Enables when the device advertises it at or above + /// . Returns whether the device will + /// have it; asking twice is harmless. + /// + public bool Request(string name, uint minimumSpecVersion = 0, string? requestedBy = null) + { + if (_enabled.Contains(name)) return true; + if (!Has(name, minimumSpecVersion)) + { + Log?.Invoke((requestedBy ?? "a contributor") + " asked for device extension " + name + + (minimumSpecVersion > 0 ? " revision >= " + minimumSpecVersion : "") + + ", which this device does not advertise (" + + (_available.ContainsKey(name) ? "revision " + SpecVersion(name) : "absent") + + "); continuing without it"); + return false; + } + _enabled.Add(name); + return true; + } + + /// + /// Queries physical-device features through a caller-built pNext chain. The + /// two-step shape the colour-write probe uses: query what is supported, then + /// re-request only what is actually going to be used. + /// + public void QueryFeatures(void* chainHead) + { + var query = new PhysicalDeviceFeatures2 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = chainHead, + }; + Api.GetPhysicalDeviceFeatures2(PhysicalDevice, &query); + } + + /// + /// Links a struct the caller keeps alive (a stack local of the creating + /// method) into the chain. Its own pNext is overwritten. + /// + public void ChainFeature(void* feature) + { + if (feature == null) return; + // Every Vulkan structure begins with VkStructureType sType; void* pNext, + // so pNext sits one pointer in on both 32- and 64-bit ABIs. + *(void**)((byte*)feature + IntPtr.Size) = _chain; + _chain = feature; + } + + /// + /// Copies into scratch memory owned here and + /// links it in. The copy lives until , which the + /// context calls after vkCreateDevice. + /// + public void ChainFeature(T feature) where T : unmanaged + { + nint memory = Marshal.AllocHGlobal(sizeof(T)); + _scratch.Add(memory); + *(T*)memory = feature; + ChainFeature((void*)memory); + } + + /// The head of the chain, null when nothing was chained. + public void* Chain => _chain; + + public IReadOnlyList Enabled => _enabled; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _chain = null; + for (int i = 0; i < _scratch.Count; i++) Marshal.FreeHGlobal(_scratch[i]); + _scratch.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Latency/ILatencyBackend.cs b/Optimum.Render.Vulkan/Latency/ILatencyBackend.cs new file mode 100644 index 00000000..0ffe49a8 --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/ILatencyBackend.cs @@ -0,0 +1,122 @@ +using System; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// One latency implementation behind the seams of the frame (plan section +/// "Latency seams", S2-S8). Exactly one instance is live on a device, reachable +/// as VulkanDevice.Latency; the default is . +/// +/// The frame, with the call sites of every member: +/// +/// window_RenderFrame (lib) +/// if (!LatencyOwnsFrameCap) the client's FPS cap runs <- OwnsFrameCap +/// LatencySleep() <- Sleep(frameId) +/// Marker(InputSample), Marker(SimulationStart) +/// UpdateMousePosition(); OnNewFrame(dt) +/// first BeginRenderStage(Before) <- Marker(SimulationEnd), Marker(RenderSubmitStart) +/// EndFrame() -> VulkanDevice.Present +/// FrameSlot.Submit (A, B, partial) <- TagSubmit +/// after Submit A <- Marker(RenderSubmitEnd) +/// around Swapchain.Present <- Marker(PresentStart/PresentEnd), OnPresent +/// Swapchain.Build <- OnSwapchainCreated +/// VulkanStats sample <- TakeReports +/// +/// +/// Implementations are used from the one client thread for everything except +/// , which the upload path can reach under the ring's +/// submit lock, and , which the stats sample calls. +/// +internal interface ILatencyBackend : IDisposable +{ + /// Which implementation this is; goes into the "device up" log line and the stats. + LatencyBackendKind Kind { get; } + + /// The settings last handed to . + LatencySettings Settings { get; } + + /// + /// Sets mode and frame cap. Called once when the device comes up, again + /// whenever the client's setting changes, and again from + /// , because a new swapchain drops the + /// driver's sleep mode. + /// + void Apply(in LatencySettings settings); + + /// + /// True when this backend paces the frame itself, so the client's own FPS + /// limiter must stand down (lib seam S3: the cap block in + /// window_RenderFrame runs only when this is false). False for + /// and for any backend whose mode is Off. + /// + bool OwnsFrameCap { get; } + + /// + /// The frame's one sleep, called immediately before input is sampled, exactly + /// once per frame. is the latency frame id + /// allocated for this frame (seam S2). + /// + /// Microseconds actually waited; 0 when the backend did not sleep. + ulong Sleep(ulong frameId); + + /// + /// Stamps one phase marker of the frame. Called from the sites listed on this + /// interface and nowhere else: every marker has exactly one owner, so no + /// phase is ever stamped twice. + /// + void Marker(ulong frameId, LatencyMarker marker); + + /// + /// A new swapchain exists (resize, vsync toggle, OUT_OF_DATE, present-mode + /// promotion). Called from Swapchain.Build after the slot is live, so + /// the backend can re-apply its sleep mode to the new handle. + /// + void OnSwapchainCreated(SwapchainKHR swapchain); + + /// + /// The swapchain the backend was last told about has been retired (a rebuild + /// passed it as oldSwapchain) or destroyed, and nothing must be called + /// against that handle any more. Called from Swapchain.Build the + /// moment the old slot is handed to the retirement queue - which happens even + /// when the creation that replaces it fails, so the frames that keep running + /// on a chain that could not be rebuilt make no vendor call at all - and from + /// Swapchain.Dispose. + /// + /// A backend with no per-swapchain state does nothing; NV drops the handle, + /// so its sleep, its markers and its timing query all stand down until the + /// next . + /// + void OnSwapchainRetired(); + + /// + /// Offers a pNext struct for one vkQueueSubmit of the frame (NV's + /// VkLatencySubmissionPresentIdNV at extension revision 3 and up, + /// where tagging is all-or-nothing across a frame's submits). + /// + /// Called from the shared FrameSlot.Submit, which Submit A, Submit B + /// and SubmitPartial all pass through, with the chain the caller has already + /// built; the return value becomes SubmitInfo.PNext. A backend that + /// has nothing to add returns unchanged - which is + /// why the caller needs to know nothing about the backend. + /// + /// Whatever is returned must stay valid until that submit has been made; a + /// backend storing the struct keeps it in stable native memory, one slot per + /// frame in flight. + /// + unsafe void* TagSubmit(ulong frameId, void* pNext); + + /// + /// The frame has been presented: is the value + /// chained as VkPresentIdKHR (0 when present ids are off). Called + /// right after vkQueuePresentKHR returns, after the PresentEnd marker, + /// and is where a CPU-timestamp backend closes the frame's report. + /// + void OnPresent(ulong frameId, ulong presentId); + + /// + /// The reports finished since the last call, oldest first, and clears them. + /// Called by the stats sample (seam S7); an empty array when nothing closed. + /// + LatencyFrameReport[] TakeReports(); +} diff --git a/Optimum.Render.Vulkan/Latency/LatencyBackendKind.cs b/Optimum.Render.Vulkan/Latency/LatencyBackendKind.cs new file mode 100644 index 00000000..17fe67a3 --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencyBackendKind.cs @@ -0,0 +1,38 @@ +namespace Optimum.Render.Vulkan.Core; + +/// +/// Which latency implementation is active. Exactly one at a time, chosen per +/// present path (plan section "Latency seams"). +/// +/// This branch carries the frame-marking foundation only (frame identity, markers, +/// present ids, the stats line), so is the one implementation +/// that exists here. The other kinds are the pacing backends of feat/latency; +/// they are named so the stats and log tokens, and the selection that lands with +/// them, keep one vocabulary across both branches. +/// +internal enum LatencyBackendKind +{ + /// No sleeping, no vendor calls; CPU timestamps only. The default. + None = 0, + + /// Completion pacing done in the renderer (feat/latency). + Native = 1, + + /// VK_NV_low_latency2 (feat/latency). + NvLowLatency2 = 2, + + /// VK_AMD_anti_lag (feat/latency). + AmdAntiLag = 3, +} + +/// The tokens a backend kind is written as, in the "device up" line and the stats sample. +internal static class LatencyBackends +{ + public static string Token(LatencyBackendKind kind) => kind switch + { + LatencyBackendKind.Native => "native", + LatencyBackendKind.NvLowLatency2 => "nv", + LatencyBackendKind.AmdAntiLag => "amd", + _ => "off", + }; +} diff --git a/Optimum.Render.Vulkan/Latency/LatencyDeviceRequirements.cs b/Optimum.Render.Vulkan/Latency/LatencyDeviceRequirements.cs new file mode 100644 index 00000000..69d095de --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencyDeviceRequirements.cs @@ -0,0 +1,148 @@ +using System; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// What the physical device advertises for latency work, read once at device +/// creation (plan seam S1, "detect without enabling anything that is not used"). +/// +internal readonly struct LatencyDeviceSupport +{ + public LatencyDeviceSupport( + bool nvLowLatency2, uint nvLowLatency2SpecVersion, bool amdAntiLag, bool presentId, bool presentId2) + { + NvLowLatency2 = nvLowLatency2; + NvLowLatency2SpecVersion = nvLowLatency2SpecVersion; + AmdAntiLag = amdAntiLag; + PresentId = presentId; + PresentId2 = presentId2; + } + + /// VK_NV_low_latency2 is advertised. + public bool NvLowLatency2 { get; } + + /// Its advertised revision; 0 when absent. + public uint NvLowLatency2SpecVersion { get; } + + /// VK_AMD_anti_lag is advertised and VkPhysicalDeviceAntiLagFeaturesAMD.antiLag is supported. + public bool AmdAntiLag { get; } + + /// VK_KHR_present_id with its presentId feature. + public bool PresentId { get; } + + /// VK_KHR_present_id2 with its presentId2 feature. + public bool PresentId2 { get; } + + public override string ToString() => + "nv_low_latency2=" + (NvLowLatency2 ? "rev " + NvLowLatency2SpecVersion : "no") + + " amd_anti_lag=" + (AmdAntiLag ? "yes" : "no") + + " present_id=" + (PresentId ? "yes" : "no") + + " present_id2=" + (PresentId2 ? "yes" : "no"); +} + +/// +/// The latency subsystem's device requirements (plan seam S1): detect what the +/// driver offers and enable what the frame-marking foundation uses. +/// +/// That is VK_KHR_present_id alone, and only on a presentable device that +/// supports its feature: one id per present, chained as VkPresentIdKHR, is +/// the frame identity carried through to the display, and chaining it is a +/// validation error unless the extension and the feature are both on. The vendor +/// extensions are detected and reported, never enabled - their backends live on +/// feat/latency, which replaces the selection here with its ranked one. +/// +/// Extension revisions and feature bits are recorded either way, because the +/// "device up" line reports what the driver offered, not only what was taken. +/// +internal sealed unsafe class LatencyDeviceRequirements : IDeviceRequirementContributor +{ + public const string NvLowLatency2ExtensionName = "VK_NV_low_latency2"; + public const string AmdAntiLagExtensionName = "VK_AMD_anti_lag"; + public const string PresentIdExtensionName = "VK_KHR_present_id"; + public const string PresentId2ExtensionName = "VK_KHR_present_id2"; + public const string SwapchainExtensionName = "VK_KHR_swapchain"; + + public string Name => "latency"; + + /// What the driver advertises, filled in by . + public LatencyDeviceSupport Support { get; private set; } + + /// The backend chosen for this device; always None on this branch. + public LatencyBackendKind Selected => LatencyBackendKind.None; + + /// VK_KHR_present_id and its feature are enabled. + public bool PresentIdEnabled { get; private set; } + + /// Nothing on the instance: the foundation needs no instance extension. + public void ContributeInstanceExtensions(InstanceRequirements requirements) + { + } + + public void ContributeDeviceRequirements(DeviceRequirements requirements) + { + uint nvRevision = requirements.SpecVersion(NvLowLatency2ExtensionName); + bool hasAmd = requirements.Has(AmdAntiLagExtensionName); + bool hasPresentId = requirements.Has(PresentIdExtensionName); + bool hasPresentId2 = requirements.Has(PresentId2ExtensionName); + + // The feature bits behind those extensions, queried the way the colour-write + // probe does: one GetPhysicalDeviceFeatures2 over a chain of only the structs + // whose extension is present, then re-request just what is used. + var antiLag = new PhysicalDeviceAntiLagFeaturesAMD + { + SType = StructureType.PhysicalDeviceAntiLagFeaturesAmd, + }; + var presentId = new PhysicalDevicePresentIdFeaturesKHR + { + SType = StructureType.PhysicalDevicePresentIDFeaturesKhr, + }; + var presentId2 = new PhysicalDevicePresentId2FeaturesKHR + { + SType = StructureType.PhysicalDevicePresentID2FeaturesKhr, + }; + + void* query = null; + if (hasAmd) { antiLag.PNext = query; query = &antiLag; } + if (hasPresentId) { presentId.PNext = query; query = &presentId; } + if (hasPresentId2) { presentId2.PNext = query; query = &presentId2; } + if (query != null) requirements.QueryFeatures(query); + + Support = new LatencyDeviceSupport( + nvRevision > 0, nvRevision, + hasAmd && antiLag.AntiLag, + hasPresentId && presentId.PresentId, + hasPresentId2 && presentId2.PresentId2); + + // VK_KHR_present_id hangs off VK_KHR_swapchain: a headless device reports + // what it found and enables nothing. + bool presentable = requirements.IsEnabled(SwapchainExtensionName); + PresentIdEnabled = presentable && Support.PresentId + && requirements.Request(PresentIdExtensionName, 0, Name); + if (PresentIdEnabled) + { + requirements.ChainFeature(new PhysicalDevicePresentIdFeaturesKHR + { + SType = StructureType.PhysicalDevicePresentIDFeaturesKhr, + PresentId = true, + }); + } + } + + /// The latency token of the "device up" log line. + public string Summary() => Summary(Selected, Support, PresentIdEnabled); + + /// What runs, what the driver offered, and whether present ids are on. + public static string Summary(LatencyBackendKind kind, in LatencyDeviceSupport support, bool presentIdEnabled) + { + string text = "latency backend " + LatencyBackends.Token(kind); + if (support.NvLowLatency2) text += " (low_latency2 rev " + support.NvLowLatency2SpecVersion + " available)"; + if (support.AmdAntiLag) text += " (anti_lag available)"; + if (support.PresentId || support.PresentId2) + { + text += ", present id " + (presentIdEnabled ? "ON" : "available") + + (support.PresentId2 ? " (+id2)" : ""); + } + return text; + } +} diff --git a/Optimum.Render.Vulkan/Latency/LatencyFrameReport.cs b/Optimum.Render.Vulkan/Latency/LatencyFrameReport.cs new file mode 100644 index 00000000..7eece06a --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencyFrameReport.cs @@ -0,0 +1,71 @@ +namespace Optimum.Render.Vulkan.Core; + +/// +/// One frame's latency breakdown, in microseconds: the eight intervals every +/// vendor tool reports (NV's VkLatencyTimingsFrameReportNV is the widest +/// of them, and this is its shape). +/// +/// Backends with a driver report (NV) fill every field from +/// vkGetLatencyTimingsNV. Backends without one (None, Native, AMD) fill +/// the CPU-observable intervals from their own marker timestamps through +/// and leave , +/// and at zero; the stats +/// line (seam S7) prints what is there. +/// +/// The latency frame id allocated at the sleep (seam S2). +/// The present id chained as VkPresentIdKHR, or 0 when the frame never presented. +/// Input sample to simulation start. +/// Simulation start to simulation end. +/// Render submit start to render submit end. +/// Present start to present end (the vkQueuePresentKHR call itself). +/// Driver start to driver end; 0 without a driver report. +/// OS render queue start to end; 0 without a driver report. +/// GPU render start to end; 0 without a driver report. +/// Input sample to present end: the whole frame as the player feels it. +internal readonly record struct LatencyFrameReport( + ulong FrameId, + ulong PresentId, + ulong InputUs, + ulong SimulationUs, + ulong RenderSubmitUs, + ulong PresentUs, + ulong DriverUs, + ulong OsRenderQueueUs, + ulong GpuUs, + ulong TotalUs) +{ + /// + /// Builds a report from the CPU timestamps a backend without a driver report + /// collected, all on one monotonic clock in microseconds (see + /// ). A missing marker is passed as 0 and makes the + /// intervals that need it 0; intervals never go negative. + /// + public static LatencyFrameReport FromCpuTimestamps( + ulong frameId, + ulong presentId, + long inputSampleUs, + long simulationStartUs, + long simulationEndUs, + long renderSubmitStartUs, + long renderSubmitEndUs, + long presentStartUs, + long presentEndUs) + { + long start = inputSampleUs != 0 ? inputSampleUs : simulationStartUs; + return new LatencyFrameReport( + frameId, + presentId, + Span(inputSampleUs, simulationStartUs), + Span(simulationStartUs, simulationEndUs), + Span(renderSubmitStartUs, renderSubmitEndUs), + Span(presentStartUs, presentEndUs), + 0, + 0, + 0, + Span(start, presentEndUs)); + } + + /// 0 when either end is missing or the pair is out of order; the difference otherwise. + private static ulong Span(long fromUs, long toUs) => + fromUs <= 0 || toUs <= 0 || toUs <= fromUs ? 0UL : (ulong)(toUs - fromUs); +} diff --git a/Optimum.Render.Vulkan/Latency/LatencyMarker.cs b/Optimum.Render.Vulkan/Latency/LatencyMarker.cs new file mode 100644 index 00000000..535896b0 --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencyMarker.cs @@ -0,0 +1,66 @@ +namespace Optimum.Render.Vulkan.Core; + +/// +/// The frame phases every vendor latency tool knows about. +/// +/// The values are the values of VkLatencyMarkerNV (Silk.NET's +/// LatencyMarkerNV), so the NV backend can cast this straight into +/// vkSetLatencyMarkerNV without a translation table; a unit test pins +/// every one of them against Silk.NET. The other backends use the same set: +/// AMD's anti-lag has only INPUT and PRESENT, and the None backend records all +/// of them as CPU timestamps. +/// +/// Where each one is stamped in an Optimum frame (plan section "Latency seams", +/// seams S3 and S4; the renderer owns all of them, never double-stamped): +/// +/// and : +/// in VulkanClientPlatform.LatencySleep, right after the sleep returns and +/// immediately before the client gathers the mouse delta. +/// and : +/// on the first BeginRenderStage(Before) of the frame. +/// : after Submit A in +/// VulkanDevice.Present. +/// / : +/// around Swapchain.Present. +/// The OutOfBand markers: reserved for submissions outside the +/// frame loop (standalone uploads, async present paths). Nothing stamps them yet. +/// +/// +internal enum LatencyMarker +{ + /// The client's simulation tick starts (after the sleep and the input sample). + SimulationStart = 0, + + /// The simulation tick ends; rendering begins. + SimulationEnd = 1, + + /// The renderer starts recording and submitting the frame's work. + RenderSubmitStart = 2, + + /// The frame's last work submission has been queued (Submit A). + RenderSubmitEnd = 3, + + /// Immediately before vkQueuePresentKHR. + PresentStart = 4, + + /// Immediately after vkQueuePresentKHR returns. + PresentEnd = 5, + + /// The frame's input is sampled; this is the point latency is measured from. + InputSample = 6, + + /// A latency-measurement flash was triggered (tooling only). + TriggerFlash = 7, + + /// A submission outside the frame loop starts. + OutOfBandRenderSubmitStart = 8, + + /// A submission outside the frame loop has been queued. + OutOfBandRenderSubmitEnd = 9, + + /// A present outside the frame loop starts. + OutOfBandPresentStart = 10, + + /// A present outside the frame loop has returned. + OutOfBandPresentEnd = 11, +} diff --git a/Optimum.Render.Vulkan/Latency/LatencyPhaseTracker.cs b/Optimum.Render.Vulkan/Latency/LatencyPhaseTracker.cs new file mode 100644 index 00000000..a8388757 --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencyPhaseTracker.cs @@ -0,0 +1,146 @@ +using System; +using System.Diagnostics; + +namespace Optimum.Render.Vulkan.Core; + +/// One monotonic microsecond clock for every latency timestamp. +internal static class LatencyClock +{ + private static readonly double TicksToUs = 1_000_000.0 / Stopwatch.Frequency; + + /// Microseconds since an arbitrary origin; never 0, so 0 stays "no timestamp". + public static long NowUs() + { + long us = (long)(Stopwatch.GetTimestamp() * TicksToUs); + return us > 0 ? us : 1; + } +} + +/// +/// Turns the marker stream of a frame into one , +/// and applies the self-healing rule from the plan: a phase still open when the +/// next frame starts is closed at the new frame's first marker and logged - once, +/// never every frame, because a dropped marker repeats and would otherwise fill +/// the log. +/// +/// Pure logic, no Vulkan: the None backend uses it, the Native and AMD backends +/// will, and it is unit-tested on its own. +/// +internal sealed class LatencyPhaseTracker +{ + private readonly Action? _log; + + private ulong _frameId; + private bool _frameOpen; + private bool _logged; + + private long _inputSample; + private long _simStart; + private long _simEnd; + private long _renderSubmitStart; + private long _renderSubmitEnd; + private long _presentStart; + private long _presentEnd; + + public LatencyPhaseTracker(Action? log = null) => _log = log; + + /// How often a phase was still open when the next frame started. + public int SelfHealCount { get; private set; } + + /// Whether the self-heal note has already gone to the log. + public bool SelfHealLogged => _logged; + + /// The frame currently collecting markers; 0 before the first one. + public ulong CurrentFrameId => _frameId; + + /// True while some Start has no matching End in the current frame. + public bool HasOpenPhase => _frameOpen && + ((_simStart != 0 && _simEnd == 0) || + (_renderSubmitStart != 0 && _renderSubmitEnd == 0) || + (_presentStart != 0 && _presentEnd == 0)); + + /// + /// Stamps one marker. A marker of a frame id other than the current one + /// starts a new frame first, closing whatever the old one left open. The + /// OutOfBand markers belong to submissions outside the frame loop and are + /// ignored here. + /// + public void Mark(ulong frameId, LatencyMarker marker, long timestampUs) + { + if (marker >= LatencyMarker.OutOfBandRenderSubmitStart) return; + + if (!_frameOpen || frameId != _frameId) BeginFrame(frameId, timestampUs); + + switch (marker) + { + case LatencyMarker.InputSample: _inputSample = timestampUs; break; + case LatencyMarker.SimulationStart: _simStart = timestampUs; break; + case LatencyMarker.SimulationEnd: _simEnd = timestampUs; break; + case LatencyMarker.RenderSubmitStart: _renderSubmitStart = timestampUs; break; + case LatencyMarker.RenderSubmitEnd: _renderSubmitEnd = timestampUs; break; + case LatencyMarker.PresentStart: _presentStart = timestampUs; break; + case LatencyMarker.PresentEnd: _presentEnd = timestampUs; break; + } + } + + /// + /// Starts a frame: closes every phase the previous frame left open (counted, + /// and logged the first time only) and clears the timestamps. + /// + public void BeginFrame(ulong frameId, long timestampUs) + { + if (_frameOpen && HasOpenPhase) + { + SelfHealCount++; + if (!_logged) + { + _logged = true; + _log?.Invoke("latency: a phase of frame " + _frameId + + " was still open when frame " + frameId + + " started; closing it. Reported once, however often it happens."); + } + // Closing means exactly that: the open phases end here, so the frame + // that follows starts from a clean slate. + CloseOpenPhases(timestampUs); + } + + _frameId = frameId; + _frameOpen = true; + _inputSample = 0; + _simStart = 0; + _simEnd = 0; + _renderSubmitStart = 0; + _renderSubmitEnd = 0; + _presentStart = 0; + _presentEnd = 0; + } + + private void CloseOpenPhases(long timestampUs) + { + if (_simStart != 0 && _simEnd == 0) _simEnd = timestampUs; + if (_renderSubmitStart != 0 && _renderSubmitEnd == 0) _renderSubmitEnd = timestampUs; + if (_presentStart != 0 && _presentEnd == 0) _presentEnd = timestampUs; + } + + /// + /// Closes the frame and builds its report. False when + /// is not the frame being collected (a present of + /// a frame whose markers never arrived), leaving the state untouched. + /// + public bool TryComplete(ulong frameId, ulong presentId, out LatencyFrameReport report) + { + if (!_frameOpen || frameId != _frameId) + { + report = default; + return false; + } + + report = LatencyFrameReport.FromCpuTimestamps( + frameId, presentId, + _inputSample, _simStart, _simEnd, + _renderSubmitStart, _renderSubmitEnd, + _presentStart, _presentEnd); + _frameOpen = false; + return true; + } +} diff --git a/Optimum.Render.Vulkan/Latency/LatencyReportBuffer.cs b/Optimum.Render.Vulkan/Latency/LatencyReportBuffer.cs new file mode 100644 index 00000000..8c8c7ece --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencyReportBuffer.cs @@ -0,0 +1,101 @@ +using System; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The finished frame reports a CPU-timestamp backend keeps until the stats +/// sample drains them (seam S7), as a fixed ring. +/// +/// A ring rather than a List with RemoveAt(0), which is what the +/// backends used before the review of 2026-09-12: nothing guarantees the stats +/// sample ever runs (OPTIMUM_VULKAN_STATS is normally unset), so the +/// buffer sits full for the whole session and every present shifted the whole +/// array down by one - about 20 KB of memmove per frame, under a lock, in the +/// present path, with the default None backend. The ring drops the oldest entry +/// by moving one index instead. +/// +/// Every member takes the lock: the frame thread adds and amends, the stats +/// sample takes. +/// +internal sealed class LatencyReportBuffer +{ + /// A few seconds of frames; a client that never samples must not grow this. + public const int DefaultCapacity = 256; + + private readonly LatencyFrameReport[] _reports; + private readonly object _lock = new(); + + /// Where the next report is written. + private int _next; + + /// How many of the slots hold a report that has not been taken. + private int _count; + + public LatencyReportBuffer(int capacity = DefaultCapacity) + { + if (capacity < 1) capacity = 1; + _reports = new LatencyFrameReport[capacity]; + } + + public int Capacity => _reports.Length; + + /// How many reports are waiting to be taken. + public int Count + { + get { lock (_lock) return _count; } + } + + /// Adds one report, dropping the oldest when the ring is full. + public void Add(in LatencyFrameReport report) + { + lock (_lock) + { + _reports[_next] = report; + _next = _next + 1 == _reports.Length ? 0 : _next + 1; + if (_count < _reports.Length) _count++; + } + } + + /// + /// Fills in the GPU interval of a report that is still waiting, newest first. + /// False when that frame's report has already been taken (the Native backend + /// observes the completion after the fact and never guesses). + /// + public bool AmendGpuUs(ulong frameId, ulong gpuUs) + { + lock (_lock) + { + for (int i = 1; i <= _count; i++) + { + int index = _next - i; + if (index < 0) index += _reports.Length; + if (_reports[index].FrameId != frameId) continue; + _reports[index] = _reports[index] with { GpuUs = gpuUs }; + return true; + } + return false; + } + } + + /// The reports since the last call, oldest first, and clears them. + public LatencyFrameReport[] Take() + { + lock (_lock) + { + if (_count == 0) return Array.Empty(); + + var taken = new LatencyFrameReport[_count]; + int index = _next - _count; + if (index < 0) index += _reports.Length; + for (int i = 0; i < _count; i++) + { + taken[i] = _reports[index]; + index = index + 1 == _reports.Length ? 0 : index + 1; + } + + _count = 0; + _next = 0; + return taken; + } + } +} diff --git a/Optimum.Render.Vulkan/Latency/LatencySettings.cs b/Optimum.Render.Vulkan/Latency/LatencySettings.cs new file mode 100644 index 00000000..a43c1dae --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/LatencySettings.cs @@ -0,0 +1,55 @@ +namespace Optimum.Render.Vulkan.Core; + +/// +/// How hard the active latency backend works. The persisted setting that drives it +/// (LatencyMode, off|on|boost) arrives with the pacing backends on +/// feat/latency; on this branch every backend stays . +/// +internal enum LatencyMode +{ + /// No sleeping and no markers beyond the CPU timestamps the reports need. + Off = 0, + + /// The backend paces the frame (NV: lowLatencyMode; AMD: anti-lag on; Native: completion pacing). + On = 1, + + /// + /// As , plus the vendor's clock boost while the CPU is the + /// bottleneck (NV: lowLatencyBoost). Backends without a boost treat it as + /// . + /// + Boost = 2, +} + +/// +/// What the client asks of the latency backend: one mode and one frame cap. +/// +/// The cap is named after VkLatencySleepModeInfoNV.minimumIntervalUs - the +/// minimum interval between two frame starts - because every backend expresses +/// the same thing: NV passes it through, AMD converts it to maxFPS, the +/// Native backend paces release to release, and XeLL later takes it in +/// xellSetSleepMode. One cap for every backend, so the client's own FPS +/// limiter can stand down whenever ILatencyBackend.OwnsFrameCap is true. +/// +/// Off, On or Boost. +/// +/// Minimum microseconds between two frame starts; 0 is uncapped. 60 fps is 16666. +/// +internal readonly record struct LatencySettings(LatencyMode Mode, ulong MinimumIntervalUs) +{ + /// The default: no latency work at all. + public static LatencySettings Disabled => new(LatencyMode.Off, 0); + + /// True when the backend should sleep and stamp markers. + public bool Enabled => Mode != LatencyMode.Off; + + /// True when the vendor's clock boost is asked for. + public bool Boost => Mode == LatencyMode.Boost; + + /// 0 when uncapped, else the cap expressed as frames per second (rounded down). + public uint MaxFps => MinimumIntervalUs == 0 ? 0u : (uint)(1_000_000UL / MinimumIntervalUs); + + /// The cap that gives at most frames a second; 0 is uncapped. + public static ulong IntervalUsForFps(double fps) => + fps <= 0 ? 0UL : (ulong)(1_000_000.0 / fps); +} diff --git a/Optimum.Render.Vulkan/Latency/NoneLatencyBackend.cs b/Optimum.Render.Vulkan/Latency/NoneLatencyBackend.cs new file mode 100644 index 00000000..56f991ca --- /dev/null +++ b/Optimum.Render.Vulkan/Latency/NoneLatencyBackend.cs @@ -0,0 +1,67 @@ +using System; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The default backend: no sleeping, no vendor calls, no swapchain pNext. It +/// only records the markers as CPU timestamps, so the stats line has the frame +/// breakdown even with latency reduction off, and so "off is off" is checkable - +/// the OpenGL path and this one add exactly the same amount of pacing, none. +/// +/// is false, so the client's own FPS limiter keeps +/// running (lib seam S3). +/// +internal sealed class NoneLatencyBackend : ILatencyBackend +{ + private readonly LatencyPhaseTracker _tracker; + + /// + /// The finished reports, in a fixed ring: the stats sample may never run, and + /// a full buffer must not cost the present path anything (see + /// ). + /// + private readonly LatencyReportBuffer _reports = new(); + + public NoneLatencyBackend(Action? log = null) => _tracker = new LatencyPhaseTracker(log); + + public LatencyBackendKind Kind => LatencyBackendKind.None; + + public LatencySettings Settings { get; private set; } = LatencySettings.Disabled; + + /// Remembered only so the stats line can print what was asked for; nothing acts on it. + public void Apply(in LatencySettings settings) => Settings = settings; + + public bool OwnsFrameCap => false; + + /// Never sleeps. + public ulong Sleep(ulong frameId) => 0; + + public void Marker(ulong frameId, LatencyMarker marker) => + _tracker.Mark(frameId, marker, LatencyClock.NowUs()); + + /// Nothing to re-apply. + public void OnSwapchainCreated(SwapchainKHR swapchain) + { + } + + /// Nothing was bound to the swapchain, so nothing is dropped with it. + public void OnSwapchainRetired() + { + } + + /// Adds nothing to the submit chain. + public unsafe void* TagSubmit(ulong frameId, void* pNext) => pNext; + + public void OnPresent(ulong frameId, ulong presentId) + { + if (!_tracker.TryComplete(frameId, presentId, out LatencyFrameReport report)) return; + _reports.Add(report); + } + + public LatencyFrameReport[] TakeReports() => _reports.Take(); + + public void Dispose() + { + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs index 7686f2fb..9c2c0435 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -1,3 +1,4 @@ +using Optimum.Render.Vulkan.Core; using Vintagestory.API.Config; namespace Optimum.Render.Vulkan.Platform; @@ -9,6 +10,100 @@ namespace Optimum.Render.Vulkan.Platform; // the parity dump itself. public partial class VulkanClientPlatform { + /// + /// Test seam: the backend the latency overrides drive. Null uses the device's, which is + /// where it comes from in the client; a headless test sets it with no device present. + /// + internal ILatencyBackend? LatencyBackendOverride; + + /// The backend drives; null before the device is up. + internal ILatencyBackend? LatencyBackend => LatencyBackendOverride ?? device?.Latency; + + /// Frame ids while no device owns the counter (headless tests). + private ulong latencyFrameIdFallback; + + /// + /// "Latency seams" S3: true when the active backend paces the frame itself, so the lib's + /// own FPS limiter in window_RenderFrame stands down. Always false on this branch, + /// whose only backend is ; the pacing backends live on + /// feat/latency. + /// + public override bool LatencyOwnsFrameCap + { + get + { + ILatencyBackend? backend = LatencyBackend; + return backend != null && backend.OwnsFrameCap; + } + } + + /// + /// "Latency seams" S3: the frame's one wait, called by the lib immediately before it + /// samples the mouse. Allocates the frame's latency id, sleeps, and stamps the two + /// markers this site owns - InputSample (the point latency is measured from) and + /// SimulationStart. Every other marker belongs to a site further down the frame + /// (see for the full map). + /// + public override void LatencySleep() + { + ILatencyBackend? backend = LatencyBackend; + if (backend == null) return; + + ulong frameId = NextLatencyFrameId(); + // Counted only when the backend actually waited, so the None backend's sleep_n stays 0. + long sleepStart = VulkanStats.WaitStart(); + if (backend.Sleep(frameId) > 0) VulkanStats.NoteWait(WaitSite.LatencySleep, sleepStart); + backend.Marker(frameId, LatencyMarker.InputSample); + backend.Marker(frameId, LatencyMarker.SimulationStart); + } + + /// + /// "Latency seams" S3: the client's effective frame cap for this frame, handed over by + /// window_RenderFrame immediately before . The lib + /// decides when a cap applies at all (vsync off, MaxFps in the 10..241 window) and folds + /// in the background-window reduction, so a backend that owns the cap paces an unfocused + /// window to the reduced number too. + /// + /// Applied only when it changed: an Apply per frame would re-arm a driver heuristic + /// every frame. Off is off: a backend whose mode is Off is never touched, so the frame + /// with latency work off is the frame Milestone 1 delivered. + /// + /// Frames per second, or 0 for uncapped. + public override void SetLatencyFrameCap(int maxFps) + { + ILatencyBackend? backend = LatencyBackend; + if (backend == null) return; + + LatencySettings current = backend.Settings; + if (current.Mode == LatencyMode.Off) return; + + ulong interval = FrameCapIntervalUs(maxFps); + if (current.MinimumIntervalUs == interval) return; + backend.Apply(new LatencySettings(current.Mode, interval)); + } + + /// + /// The cap as a minimum frame interval in microseconds; 0 in, 0 out, which is what every + /// backend reads as "do not pace to an interval". Negative values mean the same, so a + /// garbage cap can never turn into a pacing interval. + /// + internal static ulong FrameCapIntervalUs(int maxFps) + { + if (maxFps <= 0) return 0; + return LatencySettings.IntervalUsForFps(maxFps); + } + + /// + /// The frame id for the frame about to start. The device owns the counter (seam S2); + /// the fallback only runs headless, where there is no device to own it. + /// + private ulong NextLatencyFrameId() + { + VulkanDevice? owner = device; + if (owner != null) return owner.BeginLatencyFrame(); + return ++latencyFrameIdFallback; + } + /// Recycles the frame slot and opens a command buffer. public override void BeginFrame() { diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs index 5908cc0b..9d939407 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs @@ -3,6 +3,22 @@ namespace Optimum.Render.Vulkan.Platform; +/// +/// The first render stage of a frame, as the latency seams see it (seam S4): simulation is +/// over and the renderer starts recording. The bracket fires for every stage; the listener +/// decides which one is the frame's first, keyed on the latency frame id, so no marker of a +/// frame is ever stamped twice. +/// +/// A second listener beside rather +/// than another call inside the frame graph's listener: the two have different lifetimes +/// (the graph listener exists only while a device is installed and is replaced with the +/// graph) and a test drives either on its own. +/// +internal interface ILatencyStageListener +{ + void OnFrameRenderStart(); +} + // Vulkan-native plan, Phase 2 (contract C3): the render-stage bracket. ClientMain.TriggerRenderStage // calls BeginRenderStage before the stage's renderers and EndRenderStage after them; the // platform records the stage and forwards both to the frame graph once it listens. @@ -20,8 +36,25 @@ public partial class VulkanClientPlatform /// True between a stage's Begin and End. internal bool InRenderStage { get; private set; } + /// + /// The latency hook (seam S4), told at the first stage of each frame. Null means the + /// installed device is used, which is what the client does; a test sets this to watch + /// the bracket without a device. + /// + internal ILatencyStageListener? LatencyStageListener; + + /// + /// The explicit listener, or the installed device, which is the latency listener in the + /// client: it owns the frame id the markers belong to. + /// + private ILatencyStageListener? ActiveLatencyStageListener() => LatencyStageListener ?? device; + public override void BeginRenderStage(EnumRenderStage stage) { + // Before the stage's own work: the markers say where rendering began, and every + // stage asks, because which stage comes first is the client's business, not the + // renderer's. + ActiveLatencyStageListener()?.OnFrameRenderStart(); CurrentRenderStage = stage; InRenderStage = true; RenderStageListener?.OnBeginRenderStage(stage); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 03935af2..5ddfe86c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -135,6 +135,10 @@ public partial class VulkanClientPlatform : ClientPlatformWindows // Phase 2: render-stage bracket from ClientMain.TriggerRenderStage (contract C3). new(true, "BeginRenderStage", new[] { "EnumRenderStage" }), new(true, "EndRenderStage", new[] { "EnumRenderStage" }), + // "Latency seams" S3: the pre-input sleep and the frame-cap ownership flag. + new(true, "LatencySleep", Array.Empty()), + new(true, "get_LatencyOwnsFrameCap", Array.Empty()), + new(true, "SetLatencyFrameCap", new[] { "Int32" }), // Phase 2 step 2: the TAA post methods declare their frame-graph passes. new(true, "RenderOptimumSkyMotion", Array.Empty()), // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. diff --git a/Optimum.Render.Vulkan/Present/Swapchain.cs b/Optimum.Render.Vulkan/Present/Swapchain.cs index 19043d85..20caba5a 100644 --- a/Optimum.Render.Vulkan/Present/Swapchain.cs +++ b/Optimum.Render.Vulkan/Present/Swapchain.cs @@ -9,6 +9,91 @@ // the renderer is reorganised, like Frame/. namespace Optimum.Render.Vulkan.Core; +/// +/// The process-wide present id (plan section "Latency seams", seam S2): one +/// value per vkQueuePresentKHR, monotonically increasing and never reset. +/// +/// It is deliberately not the latency frame id and not a Frame timeline value. +/// The Frame timeline advances two or three times per frame, and the frame id is +/// allocated once per rendered frame; the present id counts presents, which is +/// what VK_KHR_present_id and every vendor's present-timing query mean by +/// it. One frame maps to one present today, and the map below keeps that pairing +/// explicit, because frame generation will present a frame more than once. +/// +/// Global rather than per swapchain so the sequence survives recreation: a +/// resize, a vsync toggle or an OUT_OF_DATE rebuild must not restart it. +/// +internal static class PresentIdCounter +{ + private static long _next; + + /// The next present id; the first is 1. + public static ulong Next() => (ulong)System.Threading.Interlocked.Increment(ref _next); + + /// The last id handed out, 0 before the first present. + public static ulong Current => (ulong)System.Threading.Interlocked.Read(ref _next); +} + +/// +/// The last presents' frame id per present id, kept small and wrapping: enough +/// to answer "which frame was present id N" for the frames a driver report can +/// still be about, never a growing map. +/// +internal sealed class PresentIdMap +{ + public const int DefaultCapacity = 64; + + private readonly ulong[] _presentIds; + private readonly ulong[] _frameIds; + private int _next; + + public PresentIdMap(int capacity = DefaultCapacity) + { + _presentIds = new ulong[capacity]; + _frameIds = new ulong[capacity]; + } + + /// The newest present id recorded, 0 before the first. + public ulong LastPresentId { get; private set; } + + /// The frame id of the newest present recorded, 0 before the first. + public ulong LastFrameId { get; private set; } + + public void Record(ulong presentId, ulong frameId) + { + _presentIds[_next] = presentId; + _frameIds[_next] = frameId; + _next = (_next + 1) % _presentIds.Length; + LastPresentId = presentId; + LastFrameId = frameId; + } + + /// The frame that produced , while it is still remembered. + public bool TryGetFrameId(ulong presentId, out ulong frameId) + { + for (int i = 0; i < _presentIds.Length; i++) + { + if (_presentIds[i] == presentId && presentId != 0) + { + frameId = _frameIds[i]; + return true; + } + } + frameId = 0; + return false; + } +} + +/// +/// Fills the pNext chain of VkSwapchainCreateInfoKHR (seam S5). A latency +/// backend that needs per-swapchain state - NV's +/// VkSwapchainLatencyCreateInfoNV - hands one of these to the swapchain; +/// it is called on every creation and recreation, with the chain built so far, +/// and returns the chain to use. Whatever it returns must stay valid until +/// vkCreateSwapchainKHR returns. +/// +internal unsafe delegate void* SwapchainCreateChain(void* pNext); + /// An acquired swapchain image and the semaphores its present submission uses. internal readonly struct PresentTarget { @@ -219,6 +304,32 @@ internal sealed unsafe class Swapchain : IDisposable /// The slot being acquired from. Tests only. internal SwapchainSlot? CurrentSlotForTests => _current; + /// + /// The active latency backend (seam S5): every swapchain creation tells it, + /// so a backend can re-apply the per-swapchain sleep mode a resize, a vsync + /// toggle, an OUT_OF_DATE rebuild or the FIFO_RELAXED promotion dropped. The + /// None backend does nothing with it. + /// + internal ILatencyBackend Latency { get; set; } = new NoneLatencyBackend(); + + /// + /// The pNext chain the latency backend adds to VkSwapchainCreateInfoKHR; + /// null when it needs none. See . + /// + internal SwapchainCreateChain? CreateChain { get; set; } + + /// + /// Whether VkPresentIdKHR may be chained onto the present (seam S2). + /// Set from the capabilities when VK_KHR_present_id is enabled AND its feature + /// was turned on; chaining it otherwise is a validation error, so it stays off + /// by default. The id itself is allocated either way, so the frame to present + /// mapping does not depend on the extension. + /// + internal bool PresentIdEnabled { get; set; } + + /// The frame id of each of the last presents, by present id (seam S2). + internal PresentIdMap PresentIds { get; } = new(); + private Swapchain(VulkanContext context, KhrSurface surfaceApi, KhrSwapchain swapchainApi, SurfaceKHR surface, ITimelineClock clock) { @@ -238,7 +349,8 @@ private Swapchain(VulkanContext context, KhrSurface surfaceApi, KhrSwapchain swa /// public static bool TryCreate( VulkanContext context, SurfaceKHR surface, uint width, uint height, bool vsync, ITimelineClock clock, - out Swapchain? swapchain, out string? failureReason) + out Swapchain? swapchain, out string? failureReason, ILatencyBackend? latency = null, + SwapchainCreateChain? createChain = null) { swapchain = null; failureReason = null; @@ -273,6 +385,10 @@ public static bool TryCreate( } var created = new Swapchain(context, surfaceApi, swapchainApi, surface, clock); + // Before the first Build, so the backend is told about the first + // swapchain exactly as it is told about every later one. + if (latency != null) created.Latency = latency; + created.CreateChain = createChain; created._width = width; created._height = height; created._vsync = vsync; @@ -339,6 +455,12 @@ private bool Build(out string? failureReason) OldSwapchain = old?.Handle ?? default, }; + // Seam S5: the latency backend's per-swapchain create struct, if it has + // one. Build is the single creation and recreation path, so a backend + // that needs one gets it on every resize, vsync toggle, OUT_OF_DATE + // rebuild and FIFO_RELAXED promotion. + if (CreateChain != null) createInfo.PNext = CreateChain(createInfo.PNext); + Result result = _swapchainApi.CreateSwapchain(_context.Device, &createInfo, null, out SwapchainKHR handle); // Passing oldSwapchain retires it even when creation fails. @@ -346,6 +468,11 @@ private bool Build(out string? failureReason) { _retirement.Retire(old, SwapchainPolicy.RetireAfter(old.LastPresentValue)); _current = null; + // Seam S5: the handle the backend holds is now the retired one, and + // the retirement queue will destroy it. Told before the new handle is + // announced, so a creation that fails below leaves the backend with + // no swapchain at all rather than with a dead one. + Latency.OnSwapchainRetired(); } if (result != Result.Success) @@ -360,6 +487,10 @@ private bool Build(out string? failureReason) Extent = extent; PresentMode = presentMode; Creations++; + // Exactly once per created swapchain, and only for one that exists: a + // failed creation returned above. The sleep mode a backend set on the old + // handle does not carry over, so this is where it is re-applied. + Latency.OnSwapchainCreated(handle); NeedsRecreation = false; RebuildFailure = null; return true; @@ -530,15 +661,35 @@ public void NotePresentSubmitted(in PresentTarget target, ulong frameValue) target.Slot.NotePresentSubmitted(frameValue); } - public void Present(in PresentTarget target) + /// + /// Presents and returns the present id this present + /// was given (seam S2): one per call, increasing across swapchain recreation, + /// chained as VkPresentIdKHR when . + /// is the latency frame id that produced it, kept + /// in . + /// + public ulong Present(in PresentTarget target, ulong frameId = 0) { SwapchainKHR handle = target.Slot.Handle; Semaphore wait = target.PresentSemaphore; uint index = target.ImageIndex; + // Allocated for every present, whether or not the extension carries it, + // so the frame to present mapping is the same on every driver. + ulong presentId = PresentIdCounter.Next(); + PresentIds.Record(presentId, frameId); + + var presentIdInfo = new PresentIdKHR + { + SType = StructureType.PresentIDKhr, + SwapchainCount = 1, + PPresentIds = &presentId, + }; + var presentInfo = new PresentInfoKHR { SType = StructureType.PresentInfoKhr, + PNext = PresentIdEnabled ? &presentIdInfo : null, WaitSemaphoreCount = 1, PWaitSemaphores = &wait, SwapchainCount = 1, @@ -563,6 +714,8 @@ public void Present(in PresentTarget target) { VulkanResult.Check(result, "vkQueuePresentKHR"); } + + return presentId; } public void Dispose() @@ -576,6 +729,9 @@ public void Dispose() _retirement.DisposeAll(); _current?.Dispose(); _current = null; + // Nothing may be called against these handles again; the backend outlives + // the swapchain (the device disposes it last). + Latency.OnSwapchainRetired(); if (_surface.Handle != 0) { diff --git a/Optimum.Render.Vulkan/VulkanDevice.Latency.cs b/Optimum.Render.Vulkan/VulkanDevice.Latency.cs new file mode 100644 index 00000000..e5ca5847 --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.Latency.cs @@ -0,0 +1,146 @@ +using System; +using Optimum.Render.Vulkan.Core; + +namespace Optimum.Render.Vulkan; + +/// +/// The frame-marking foundation (plan section "Latency seams", S1-S7): one latency +/// frame id per rendered frame, the phase markers around simulation, render submit +/// and present, the present id per present, and the backend the markers go to. +/// +/// Where each piece is stamped: +/// +/// Frame id and InputSample/SimulationStart: +/// VulkanClientPlatform.LatencySleep, before the client samples input. +/// SimulationEnd/RenderSubmitStart: the frame's first +/// BeginRenderStage, through . +/// Submit tag: every FrameSlot.Submit of the frame. +/// RenderSubmitEnd, PresentStart/PresentEnd, the present id and the +/// frame report: . +/// +/// +/// Only the None backend exists on this branch: it never sleeps and owns no frame cap, +/// so the frame is paced exactly as before and the markers become CPU-timestamp reports +/// on the stats.latency line. Pinned by LatencyMarkerOrderTests, +/// PresentIdentityTests and latency-foundation-coverage-tests.cs. +/// +public sealed partial class VulkanDevice : Platform.ILatencyStageListener +{ + /// The platform's stage bracket reaches the latency markers here (seam S4). + void Platform.ILatencyStageListener.OnFrameRenderStart() => NoteRenderStageStarted(); + + /// + /// The active latency backend. Never null, so every call site is a plain virtual + /// call with no branch; the None backend unless a test installed its own. + /// + internal ILatencyBackend Latency { get; private set; } = new NoneLatencyBackend(); + + /// Someone has installed a backend, so device setup must not overwrite it. + private bool _latencyBackendInstalled; + + /// + /// Installs the latency backend every marker, tag and swapchain callback goes to + /// (seams S2-S5). A test calls it before to watch the + /// frame; the stats line reports whichever is installed. + /// + internal void SetLatencyBackend(ILatencyBackend backend) + { + Latency = backend ?? throw new ArgumentNullException(nameof(backend)); + _latencyBackendInstalled = true; + VulkanStats.LatencySource = Latency; + if (_frames != null) _frames.Latency.Backend = Latency; + if (_swapchain != null) _swapchain.Latency = Latency; + } + + /// + /// Installs the backend the capabilities selected (None on this branch) unless one + /// was installed before the device came up. Called once, right after the context + /// exists and before the frame ring and the first swapchain, so the ring's submit + /// tag, the stats source and every swapchain creation see the same instance. + /// + private void InstallSelectedLatencyBackend() + { + VulkanStats.LatencyRevision = 0; + if (!_latencyBackendInstalled) + { + Latency = new NoneLatencyBackend(MirrorValidationMessage); + } + VulkanStats.LatencySource = Latency; + } + + /// + /// The latency identity of the frame being recorded (seam S2): one monotonic value + /// per rendered frame, allocated by before the + /// client samples input, and used by every marker, every submit tag and the present + /// map of that frame. + /// + /// Deliberately not the Frame timeline value, which advances two or three times per + /// frame (Submit A, Submit B, any partial submit), and deliberately not + /// _frameCounter, which stays a 32-bit counter because the GPU checkpoint + /// markers pack it into a pointer-sized word. + /// + internal ulong LatencyFrameId => _latencyFrameId; + + private ulong _latencyFrameId; + + /// An id was allocated by the platform hook and no frame has consumed it yet. + private bool _latencyFrameIdPending; + + /// The frame whose render start has already been stamped; 0 before the first. + private ulong _latencyRenderStartFrame; + + /// + /// Allocates the next latency frame id. The lib hook calls this from + /// VulkanClientPlatform.LatencySleep, before input is sampled and therefore + /// before ; a frame that starts without it (a headless test, + /// a path with no platform) allocates its own id in , so the + /// identity exists exactly once either way. + /// + public ulong BeginLatencyFrame() + { + _latencyFrameId++; + _latencyFrameIdPending = true; + return _latencyFrameId; + } + + /// + /// Seam S2 at the frame start: takes the id the platform hook allocated, or + /// allocates one, and tags every submit of the frame with it (seam S4). + /// + private void BeginLatencyFrameIdentity() + { + if (!_latencyFrameIdPending) BeginLatencyFrame(); + _latencyFrameIdPending = false; + _frames.Latency.FrameId = _latencyFrameId; + } + + /// + /// The frame's first render stage has begun: simulation is over and the renderer + /// starts recording (seam S4). Called from VulkanClientPlatform.BeginRenderStage + /// on every stage; only the first of a frame stamps anything, so no marker of a + /// frame is ever stamped twice. + /// + internal void NoteRenderStageStarted() + { + if (_latencyRenderStartFrame == _latencyFrameId) return; + _latencyRenderStartFrame = _latencyFrameId; + Latency.Marker(_latencyFrameId, LatencyMarker.SimulationEnd); + Latency.Marker(_latencyFrameId, LatencyMarker.RenderSubmitStart); + } + + /// The present id of the last present, 0 before the first. Tests only. + internal ulong LastPresentIdForTests { get; private set; } + + /// Clears the stats source if it is this device's backend, then ends the backend. + private void DisposeLatency() + { + if (ReferenceEquals(VulkanStats.LatencySource, Latency)) + { + VulkanStats.LatencySource = null; + VulkanStats.LatencyRevision = 0; + } + // The device installed it, so the device ends it; the None backend and the + // test fake have nothing to release. + Latency.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs index 29b0bf7e..7920d7ff 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -465,7 +465,11 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa "; bindless sampled images per stage " + _context.Capabilities.DescriptorIndexing.MaxPerStageDescriptorUpdateAfterBindSampledImages + " (needs " + DescriptorIndexingFloor.RequiredSampledImages + ")" + - "; push constants " + _context.Capabilities.DescriptorIndexing.MaxPushConstantsSize + " B"); + "; push constants " + _context.Capabilities.DescriptorIndexing.MaxPushConstantsSize + " B" + + "; " + _context.Capabilities.LatencySummary); + // Seams S1-S5: the backend is installed before the frame ring and the first + // swapchain exist, so nothing in the frame ever sees a different instance. + InstallSelectedLatencyBackend(); // A ReBAR miss is logged, not an error: the validation mirror and the // trace, never GetError. The stats sample reads this allocator's heaps. _context.Allocator.Log = MirrorValidationMessage; @@ -474,6 +478,8 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa // any thread into the ring's upload batch (or inline into the frame when // it already used the destination; see UploadManager). _frames = new FrameRing(_context); + // Seam S4: the installed backend tags this ring's submits. + _frames.Latency.Backend = Latency; _uploads = _frames.Uploads; _textures = new TextureManager(_context, _uploads); _meshes = new MeshManager(_context, _uploads); @@ -565,13 +571,16 @@ public bool Initialize(IntPtr windowHandle, int width, int height, out string fa } if (!Swapchain.TryCreate(_context, surface, (uint)width, (uint)height, _vsync, _frames.Timeline, - out Swapchain? swapchain, out string? swapchainError)) + out Swapchain? swapchain, out string? swapchainError, Latency)) { failureReason = swapchainError ?? "could not create a swapchain"; return false; } _swapchain = swapchain; + // Seam S2: VkPresentIdKHR may only be chained when VK_KHR_present_id and its + // feature were actually enabled; chaining it otherwise is a validation error. + _swapchain!.PresentIdEnabled = _context.Capabilities.PresentIdEnabled; _presentPath = new BlitPresentPath(_context, _textures, DefaultColorTexture); } @@ -801,6 +810,9 @@ public void BeginFrame() _transients.AliasedBytes, _transients.Leases.Count, _transients.AliasedLeaseCount, _readSelfCopies.Live); _transients.BeginFrame(); + // Seam S2: the frame's latency identity, before the ring hands out the slot. + BeginLatencyFrameIdentity(); + FrameSlot slot = _frames.BeginFrame(); // After the ring's wait and collection, before anything is recorded: freed // bindless slots get their placeholder back and queued writes land. @@ -1214,6 +1226,10 @@ public void Present() _bindless?.Flush(); ulong renderValue = _frames.EndFrame(); _frameActive = false; + // Seam S4: the frame's work is queued (Submit A). Stamped before the acquire, + // which is where the CPU may block, so the render-submit interval is recording + // time and nothing else. + Latency.Marker(_latencyFrameId, LatencyMarker.RenderSubmitEnd); long frameSubmitted = System.Diagnostics.Stopwatch.GetTimestamp(); // Headless: nothing to present; the frame is submitted all the same. @@ -1238,7 +1254,13 @@ public void Present() _swapchain.NotePresentSubmitted(target, presentValue); long presentSubmitted = System.Diagnostics.Stopwatch.GetTimestamp(); - _swapchain.Present(target); + // Seam S4: PresentStart and PresentEnd bracket vkQueuePresentKHR itself, and the + // present id the call was given closes the frame's report. + Latency.Marker(_latencyFrameId, LatencyMarker.PresentStart); + ulong presentId = _swapchain.Present(target, _latencyFrameId); + Latency.Marker(_latencyFrameId, LatencyMarker.PresentEnd); + Latency.OnPresent(_latencyFrameId, presentId); + LastPresentIdForTests = presentId; LastPresentTimingsForTests = new PresentTimings(presentEntry, frameSubmitted, acquireReturned, presentSubmitted, renderValue, presentValue, renderCompletedAtAcquire, true); @@ -3428,6 +3450,7 @@ public void Dispose() { VulkanStats.MemorySource = null; } + DisposeLatency(); _context?.Dispose(); } } diff --git a/Optimum.Tests/latency-hooks-coverage-tests.cs b/Optimum.Tests/latency-hooks-coverage-tests.cs new file mode 100644 index 00000000..d8dec4de --- /dev/null +++ b/Optimum.Tests/latency-hooks-coverage-tests.cs @@ -0,0 +1,200 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, "Latency seams" S3: the sleep happens before the client samples input. +/// +/// ClientPlatformAbstract declares two neutral virtuals, window_RenderFrame runs +/// the client's own FPS cap only while no backend owns it and calls LatencySleep() +/// immediately before UpdateMousePosition(), the patcher ships both members into the +/// shipped DLL, the OpenGL path keeps the neutral bodies, and VulkanClientPlatform overrides +/// both and self-checks them against the loaded lib. +/// +public class LatencyHooksCoverageTests +{ + private const string AbstractPath = "Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"; + private const string RenderFrameSignature = "private void window_RenderFrame(FrameEventArgs e)"; + + [Fact] + public void TheFrameCapBlockIsGuardedByLatencyOwnsFrameCap() + { + string body = StripComments(Body(VulkanPlatformSource.ReadClientPlatformWindows(), RenderFrameSignature)); + + // The effective cap is computed once, under the vanilla limiter's own conditions, + // and 0 means uncapped - the number both pacing sides read. + Match compute = Regex.Match( + body, @"if \(ClientSettings\.VsyncMode != 1 && effectiveMaxFps > 10f && effectiveMaxFps < 241f\)"); + Assert.True(compute.Success, "the effective cap is no longer computed once:\n" + body); + Assert.Contains("int latencyFrameCap = 0;", body); + Assert.Contains("latencyFrameCap = (int)effectiveMaxFps;", body); + + // The one pacing block in the frame: the vanilla FPS limiter, now conditional. + Match cap = Regex.Match(body, @"if \(!LatencyOwnsFrameCap && latencyFrameCap > 0\)"); + Assert.True(cap.Success, "the FPS cap block is gone or was reshaped:\n" + body); + + // Exactly one pacing block and exactly one read of the flag: a second one would + // mean a second place that sleeps, which is the bug this seam exists to prevent. + Assert.Single(Regex.Matches(body, @"ClientSettings\.VsyncMode != 1")); + Assert.Single(Regex.Matches(body, @"LatencyOwnsFrameCap")); + } + + /// + /// Once a pacing backend owns the cap the lib's own limiter stands down, so the cap it + /// computed - the background-window reduction included - has to reach the backend, + /// exactly once, before the sleep it paces. Without it an unfocused window would run + /// uncapped and burn the GPU in the background. + /// + [Fact] + public void TheEffectiveFrameCapIsHandedOverOnceBeforeTheSleep() + { + string body = StripComments(Body(VulkanPlatformSource.ReadClientPlatformWindows(), RenderFrameSignature)); + + int background = body.IndexOf("effectiveMaxFps = OptimumBgMaxFps;", StringComparison.Ordinal); + int computed = body.IndexOf("int latencyFrameCap = 0;", StringComparison.Ordinal); + int handOver = body.IndexOf("SetLatencyFrameCap(latencyFrameCap);", StringComparison.Ordinal); + int limiter = body.IndexOf("!LatencyOwnsFrameCap", StringComparison.Ordinal); + int sleep = body.IndexOf("LatencySleep();", StringComparison.Ordinal); + + Assert.True(background >= 0, "the background-window cap is gone:\n" + body); + Assert.True(computed > background, "the cap is computed before the background reduction:\n" + body); + Assert.True(handOver > computed, "the backend is handed a cap that was never computed:\n" + body); + Assert.True(handOver < limiter, "the hand-over is not before the limiter block:\n" + body); + Assert.True(sleep > handOver, "the cap reaches the backend after the sleep it paces:\n" + body); + Assert.Single(Regex.Matches(body, @"SetLatencyFrameCap\(")); + } + + [Fact] + public void LatencySleepIsCalledOnceAndBeforeTheInputSample() + { + string body = StripComments(Body(VulkanPlatformSource.ReadClientPlatformWindows(), RenderFrameSignature)); + + int sleep = body.IndexOf("LatencySleep();", StringComparison.Ordinal); + int mouse = body.IndexOf("UpdateMousePosition();", StringComparison.Ordinal); + int cap = body.IndexOf("ClientSettings.VsyncMode != 1", StringComparison.Ordinal); + int beginFrame = body.IndexOf("BeginFrame();", StringComparison.Ordinal); + + Assert.True(sleep >= 0, "LatencySleep() is not called in window_RenderFrame:\n" + body); + Assert.True(mouse > sleep, "the input sample does not follow the sleep:\n" + body); + Assert.True(sleep > cap, "the sleep runs before the frame cap block:\n" + body); + Assert.True(beginFrame > mouse, "BeginFrame moved before the input sample:\n" + body); + Assert.Single(Regex.Matches(body, @"LatencySleep\(\)")); + Assert.Single(Regex.Matches(body, @"UpdateMousePosition\(\)")); + } + + [Fact] + public void WindowRenderFrameStaysCecilSafe() + { + string body = StripComments(Body(VulkanPlatformSource.ReadClientPlatformWindows(), RenderFrameSignature)); + Assert.DoesNotContain("=>", body); + Assert.DoesNotContain("delegate", body); + Assert.False(Regex.IsMatch(body, @"\.(All|Any|Where|Select|First|Count)\s*\("), + "LINQ in a transplanted method:\n" + body); + } + + [Fact] + public void TheAbstractPlatformDeclaresNeutralVirtualsAndOpenGlDoesNotOverrideThem() + { + string platform = ReadLib(AbstractPath); + Assert.Equal("{ }", Regex.Replace(Body(platform, "public virtual void LatencySleep()"), @"\s+", " ").Trim()); + Assert.Contains("public virtual bool LatencyOwnsFrameCap => false;", platform); + Assert.Equal("{ }", Regex.Replace( + Body(platform, "public virtual void SetLatencyFrameCap(int maxFps)"), @"\s+", " ").Trim()); + + // The OpenGL platform calls all three but declares none, so vanilla pacing is untouched. + string windows = VulkanPlatformSource.ReadClientPlatformWindows(); + Assert.DoesNotContain("override void LatencySleep", windows); + Assert.DoesNotContain("override bool LatencyOwnsFrameCap", windows); + Assert.DoesNotContain("override void SetLatencyFrameCap", windows); + } + + [Fact] + public void ThePatcherShipsBothMembers() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + string injected = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()", "},"); + Assert.Contains("\"LatencySleep\",", injected); + Assert.Contains("\"LatencyOwnsFrameCap\",", injected); + Assert.Contains("\"SetLatencyFrameCap\",", injected); + + // The changed frame body has to be transplanted too, or the calls never ship. + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"window_RenderFrame\", 1),", patcher); + } + + [Fact] + public void TheVulkanPlatformSelfChecksAndOverridesBoth() + { + string selfCheck = Block(Read(VulkanPlatformSource.MainFile), + "internal static readonly ExpectedVirtual[] ExpectedVirtuals", "};"); + Assert.Contains("new(true, \"LatencySleep\", Array.Empty()),", selfCheck); + Assert.Contains("new(true, \"get_LatencyOwnsFrameCap\", Array.Empty()),", selfCheck); + Assert.Contains("new(true, \"SetLatencyFrameCap\", new[] { \"Int32\" }),", selfCheck); + + string frame = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs"); + string sleep = Body(frame, "public override void LatencySleep()"); + int sleepCall = sleep.IndexOf("backend.Sleep(frameId)", StringComparison.Ordinal); + int input = sleep.IndexOf("LatencyMarker.InputSample", StringComparison.Ordinal); + int simulation = sleep.IndexOf("LatencyMarker.SimulationStart", StringComparison.Ordinal); + Assert.True(sleepCall >= 0, "the override does not call the backend's sleep:\n" + sleep); + Assert.True(input > sleepCall, "InputSample is not stamped after the sleep:\n" + sleep); + Assert.True(simulation > input, "SimulationStart is not stamped after InputSample:\n" + sleep); + Assert.Contains("backend.OwnsFrameCap", Body(frame, "public override bool LatencyOwnsFrameCap")); + + // The cap hand-over: FPS in, the backend's MinimumIntervalUs out, applied only + // when it changed, and never to a backend whose mode is Off. + string cap = Body(frame, "public override void SetLatencyFrameCap(int maxFps)"); + Assert.Contains("if (current.Mode == LatencyMode.Off) return;", cap); + Assert.Contains("ulong interval = FrameCapIntervalUs(maxFps);", cap); + Assert.Contains("if (current.MinimumIntervalUs == interval) return;", cap); + Assert.Contains("backend.Apply(new LatencySettings(current.Mode, interval));", cap); + // The device owns the counter and it is public, because the lib hook is the one + // site outside the renderer that opens a frame (seam S2). + Assert.Contains("public ulong BeginLatencyFrame()", Read("Optimum.Render.Vulkan/VulkanDevice.Latency.cs")); + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + private static string ReadLib(string relativePath) + { + try + { + return File.ReadAllText(PatchReader.FindRepositoryFile("build/VintagestoryLib/" + relativePath)); + } + catch (FileNotFoundException) + { + return PatchReader.ReadPatchedContent(PatchReader.FindRepositoryFile( + "patches/VintagestoryLib/" + relativePath + ".patch")); + } + } + + private static string StripComments(string source) => + Regex.Replace(source, @"//[^\n]*", string.Empty); + + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + signature); + int open = source.IndexOf('{', start + signature.Length); + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced body: " + signature); + } + + private static string Block(string source, string header, string terminator) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf(terminator, start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } +} diff --git a/Optimum.Tests/latency-renderer-coverage-tests.cs b/Optimum.Tests/latency-renderer-coverage-tests.cs new file mode 100644 index 00000000..05cee10c --- /dev/null +++ b/Optimum.Tests/latency-renderer-coverage-tests.cs @@ -0,0 +1,238 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Latency seams, the frame-marking foundation (plan section "Latency seams", S1, S2, +/// S4, S5, S7): the +/// renderer's side of the seams, checked in source because the placement is the +/// point - a marker one line later than the call it brackets measures something +/// else, and a second stamp of the same marker corrupts the frame's report. +/// +/// The GPU tests (LatencyMarkerOrderTests, PresentIdentityTests) prove the +/// behaviour on a device; these pin where it lives, so a refactor cannot quietly +/// move a marker across the call it belongs to. +/// +public class LatencyRendererCoverageTests +{ + private const string DevicePath = "Optimum.Render.Vulkan/VulkanDevice.cs"; + private const string DeviceLatencyPath = "Optimum.Render.Vulkan/VulkanDevice.Latency.cs"; + private const string RequirementsPath = "Optimum.Render.Vulkan/Latency/LatencyDeviceRequirements.cs"; + private const string ContextPath = "Optimum.Render.Vulkan/Core/VulkanContext.cs"; + private const string RingPath = "Optimum.Render.Vulkan/Core/FrameRing.cs"; + private const string SwapchainPath = "Optimum.Render.Vulkan/Present/Swapchain.cs"; + private const string StagesPath = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs"; + private const string StatsPath = "Optimum.Render.Vulkan/Core/VulkanStats.cs"; + + [Fact] + public void TheFrameIdIsAllocatedOncePerFrameAndIsNotTheCheckpointCounter() + { + string device = Read(DevicePath) + Read(DeviceLatencyPath); + + // Seam S2: one allocator, and the checkpoint counter is untouched by it. + Assert.Single(Regex.Matches(device, @"public ulong BeginLatencyFrame\(\)")); + Assert.Contains("_latencyFrameId++;", device); + Assert.Single(Regex.Matches(device, @"_latencyFrameId\+\+;")); + Assert.Contains("private uint _frameCounter;", device); + Assert.Contains("Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter));", device); + + // A frame without the lib hook still gets exactly one id, before the ring hands out the slot. + string beginFrame = Body(Read(DevicePath), " public void BeginFrame()"); + int identity = beginFrame.IndexOf("BeginLatencyFrameIdentity();", StringComparison.Ordinal); + int slot = beginFrame.IndexOf("FrameSlot slot = _frames.BeginFrame();", StringComparison.Ordinal); + Assert.True(identity >= 0 && slot > identity, "the frame id is not taken before the slot:\n" + beginFrame); + string take = Body(Read(DeviceLatencyPath), " private void BeginLatencyFrameIdentity()"); + Assert.Contains("if (!_latencyFrameIdPending) BeginLatencyFrame();", take); + Assert.Contains("_latencyFrameIdPending = false;", take); + Assert.Contains("_frames.Latency.FrameId = _latencyFrameId;", take); + } + + [Fact] + public void EveryMarkerIsStampedOnceAtItsCallSite() + { + string device = Read(DevicePath) + Read(DeviceLatencyPath); + string present = Body(Read(DevicePath), " public void Present()"); + + int submit = present.IndexOf("ulong renderValue = _frames.EndFrame();", StringComparison.Ordinal); + int submitEnd = present.IndexOf("LatencyMarker.RenderSubmitEnd", StringComparison.Ordinal); + int presentStart = present.IndexOf("LatencyMarker.PresentStart", StringComparison.Ordinal); + int queuePresent = present.IndexOf("_swapchain.Present(target, _latencyFrameId);", StringComparison.Ordinal); + int presentEnd = present.IndexOf("LatencyMarker.PresentEnd", StringComparison.Ordinal); + int onPresent = present.IndexOf("Latency.OnPresent(_latencyFrameId, presentId);", StringComparison.Ordinal); + + Assert.True(submit >= 0 && submitEnd > submit, "RenderSubmitEnd must follow Submit A:\n" + present); + Assert.True(presentStart > submitEnd && queuePresent > presentStart && presentEnd > queuePresent && + onPresent > presentEnd, + "PresentStart/End must bracket vkQueuePresentKHR, with OnPresent after them:\n" + present); + + // No marker twice, anywhere in the device. + foreach (string marker in new[] + { + "LatencyMarker.RenderSubmitEnd", "LatencyMarker.PresentStart", "LatencyMarker.PresentEnd", + "LatencyMarker.SimulationEnd", "LatencyMarker.RenderSubmitStart", + }) + { + Assert.Single(Regex.Matches(device, Regex.Escape(marker))); + } + + // Seam S4: the first render stage of the frame stamps the pair, and only + // the first, because the guard is the frame id itself. + string renderStage = Body(Read(DeviceLatencyPath), " internal void NoteRenderStageStarted()"); + Assert.Contains("if (_latencyRenderStartFrame == _latencyFrameId) return;", renderStage); + Assert.Contains("_latencyRenderStartFrame = _latencyFrameId;", renderStage); + + // The platform bracket asks on every stage; the listener decides. + string stages = Read(StagesPath); + Assert.Contains("ActiveLatencyStageListener()?.OnFrameRenderStart();", stages); + Assert.Contains("internal ILatencyStageListener? LatencyStageListener;", stages); + Assert.Single(Regex.Matches(stages, @"ActiveLatencyStageListener\(\)\?\.OnFrameRenderStart\(\);")); + } + + [Fact] + public void EveryFrameSubmitIsTaggedAndStandaloneUploadsAreNot() + { + string ring = Read(RingPath); + string submit = Body(ring, " private void Submit("); + Assert.Contains("void* chain = _latency.Backend.TagSubmit(_latency.FrameId, &timelineInfo);", submit); + Assert.Contains("PNext = chain,", submit); + + // Submit A, Submit B and SubmitPartial all pass through that one method. + foreach (string caller in new[] + { + " public ulong SubmitPartial()", + " public ulong EndFrameAndSubmit()", + " public ulong SubmitPresent(", + }) + { + Assert.Contains("Submit(", Body(ring, caller)); + } + + string uploads = Read("Optimum.Render.Vulkan/Transfer/UploadManager.cs"); + Assert.DoesNotContain("TagSubmit", uploads); + } + + [Fact] + public void EverySwapchainCreationTellsTheBackendOnceAndOffersAPNextHook() + { + string swapchain = Read(SwapchainPath); + string build = Body(swapchain, " private bool Build(out string? failureReason)"); + + int created = build.IndexOf("_current = new SwapchainSlot(", StringComparison.Ordinal); + int announced = build.IndexOf("Latency.OnSwapchainCreated(handle);", StringComparison.Ordinal); + Assert.True(created >= 0 && announced > created, + "the backend must be told after the slot exists:\n" + build); + Assert.Contains("if (CreateChain != null) createInfo.PNext = CreateChain(createInfo.PNext);", build); + + // Once per creation: Build is the one creation path and the one caller. + Assert.Single(Regex.Matches(swapchain, @"OnSwapchainCreated\(handle\);")); + Assert.Single(Regex.Matches(swapchain, @"_current = new SwapchainSlot\(")); + + // Seam S2: one present id per present, global so recreation cannot reset it. + Assert.Contains("ulong presentId = PresentIdCounter.Next();", swapchain); + Assert.Contains("PresentIds.Record(presentId, frameId);", swapchain); + Assert.Contains("PNext = PresentIdEnabled ? &presentIdInfo : null,", swapchain); + Assert.Single(Regex.Matches(swapchain, @"PresentIdCounter\.Next\(\)")); + } + + [Fact] + public void TheStatsSampleAlwaysCarriesTheLatencyLineAndTheSleepSite() + { + string stats = Read(StatsPath); + Assert.Contains("LatencySleep = 9,", stats); + Assert.Contains("\"latency_sleep\",", stats); + Assert.Contains("public const int WaitSiteCount = 10;", stats); + // Unconditional: no branch decides whether the line is written. + Assert.Contains("LatencyLine(waitCounts[(int)WaitSite.LatencySleep], waitMs[(int)WaitSite.LatencySleep]) + \"\\n\" +", stats); + + string doc = File.ReadAllText(PatchReader.FindRepositoryFile("docs/taa-acceptance.md")); + Assert.Contains("stats.latency", doc); + Assert.Contains("`latency_sleep`", doc); + } + + /// + /// Seam S1 on this branch: the None backend is the only one, and present ids are + /// enabled on their own merit (supported, presentable), not as a vendor backend's + /// dependency - the frame identity reaches the display with no pacing backend. The + /// contributors share one pNext chain with device fault and the colour-write tier. + /// + [Fact] + public void PresentIdsAreEnabledWithoutAPacingBackendAndOnlyNoneIsConstructed() + { + string requirements = Read(RequirementsPath); + string contribute = Body(requirements, " public void ContributeDeviceRequirements(DeviceRequirements requirements)"); + Assert.Contains("bool presentable = requirements.IsEnabled(SwapchainExtensionName);", contribute); + Assert.Contains("PresentIdEnabled = presentable && Support.PresentId", contribute); + Assert.Contains("PresentId = true,", contribute); + // Detection only for the vendor extensions: nothing requests them here. + Assert.DoesNotContain("Request(NvLowLatency2ExtensionName", contribute); + Assert.DoesNotContain("Request(AmdAntiLagExtensionName", contribute); + Assert.Contains("public LatencyBackendKind Selected => LatencyBackendKind.None;", requirements); + + string install = Body(Read(DeviceLatencyPath), " private void InstallSelectedLatencyBackend()"); + Assert.Contains("new NoneLatencyBackend(MirrorValidationMessage)", install); + Assert.Single(Regex.Matches(install, @"new \w+LatencyBackend\(")); + + string context = Read(ContextPath); + Assert.Contains("contributor.ContributeDeviceRequirements(requirements);", context); + Assert.Contains("vulkan13.PNext = requirements.Chain;", context); + Assert.DoesNotContain("optionalFeatures", context); + + // The swapchain only chains VkPresentIdKHR when the capability says it may. + Assert.Contains("_swapchain!.PresentIdEnabled = _context.Capabilities.PresentIdEnabled;", Read(DevicePath)); + } + + /// + /// Latency review 2026-09-12. Two placements the review added, both of which + /// a refactor could silently undo: + /// the client's frame cap reaches a pacing backend at the sleep (without it, + /// turning LatencyMode on stands the lib's limiter down and replaces it with + /// nothing), and the swapchain tells the backend when the handle it holds is + /// retired or destroyed, before the replacement is announced. + /// + [Fact] + public void TheFrameCapAndTheSwapchainRetirementReachTheBackend() + { + string frame = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs"); + + // The cap arrives from the lib (window_RenderFrame's effective cap, background + // reduction included) through its own injected virtual, ahead of the sleep. + string apply = Body(frame, " public override void SetLatencyFrameCap(int maxFps)"); + Assert.Contains("ulong interval = FrameCapIntervalUs(maxFps);", apply); + Assert.Contains("backend.Apply(new LatencySettings(current.Mode, interval));", apply); + // Off is off: a disabled backend is never applied to, and an unchanged cap + // never re-arms the driver's heuristic. + Assert.Contains("if (current.Mode == LatencyMode.Off) return;", apply); + Assert.Contains("if (current.MinimumIntervalUs == interval) return;", apply); + + // The sleep itself no longer computes a cap of its own - one source, one site. + string sleep = Body(frame, " public override void LatencySleep()"); + Assert.DoesNotContain("ApplyFrameCap", sleep); + Assert.Contains("backend.Sleep(frameId)", sleep); + + string swapchain = Read(SwapchainPath); + // Once where the old slot is retired (a rebuild, failed or not), once at + // teardown, and nowhere else. + Assert.Equal(2, Regex.Matches(swapchain, @"Latency\.OnSwapchainRetired\(\);").Count); + string build = Body(swapchain, " private bool Build(out string? failureReason)"); + int retired = build.IndexOf("Latency.OnSwapchainRetired();", StringComparison.Ordinal); + int created = build.IndexOf("Latency.OnSwapchainCreated(handle);", StringComparison.Ordinal); + Assert.True(retired >= 0, "a rebuild never tells the backend the old handle is gone:\n" + build); + Assert.True(created > retired, "the new handle is announced before the old one is retired:\n" + build); + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + /// The member starting at , up to its closing brace. + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing " + signature); + int end = source.IndexOf("\n }\n", start, StringComparison.Ordinal); + Assert.True(end > start, "unterminated " + signature); + return source.Substring(start, end - start); + } +} diff --git a/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs index 8d4100dc..43e4ce8a 100644 --- a/Optimum.Tests/vulkan-backend-integration-tests.cs +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -373,7 +373,8 @@ public void ThePresentPathSplitsTheSubmissionAndRecreatesWithoutWaiting() int frameSubmit = device.IndexOf("ulong renderValue = _frames.EndFrame();", present, StringComparison.Ordinal); int acquire = device.IndexOf("_swapchain.TryAcquire(out PresentTarget target)", present, StringComparison.Ordinal); int presentSubmit = device.IndexOf("_frames.SubmitPresent(", present, StringComparison.Ordinal); - int queuePresent = device.IndexOf("_swapchain.Present(target);", present, StringComparison.Ordinal); + // The present carries the frame's latency id since the latency seams (S2). + int queuePresent = device.IndexOf("_swapchain.Present(target, _latencyFrameId);", present, StringComparison.Ordinal); Assert.True(present >= 0 && frameSubmit > present && acquire > frameSubmit && presentSubmit > acquire && queuePresent > presentSubmit, "Present must submit the frame, then acquire, then submit the present path, then present"); diff --git a/docs/taa-acceptance.md b/docs/taa-acceptance.md index 0c5f300e..cc577062 100644 --- a/docs/taa-acceptance.md +++ b/docs/taa-acceptance.md @@ -266,14 +266,15 @@ before it lack the field and still parse, but cannot be compared against a basel [Optimum] fps window= frames= mean= min= max= p99= stddev= ``` -`OPTIMUM_VULKAN_STATS`, Vulkan only, one sample per second of seven lines. The first line is -unchanged from earlier builds; the other six carry stable `key=value` tokens: +`OPTIMUM_VULKAN_STATS`, Vulkan only, one sample per second of nine lines. The first line is +unchanged from earlier builds; the other eight carry stable `key=value` tokens: ``` stats s: frames ( ms/frame), allocations ( live), blocking uploads costing ms (% of the interval), textures +/-, mesh writes dropped , uniform overflows stats.pacing samples= p50_ms= p95_ms= p99_ms= stddev_ms= stutters= -stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= +stats.waits frame_pacing_n= frame_pacing_ms= upload_submit_n= upload_submit_ms= ... present_n= present_ms= queue_submit_n= queue_submit_ms= latency_sleep_n= latency_sleep_ms= stats.counters blocking_uploads= uploads= scopes= barriers= rebar_fallbacks= dynamic_state= uniform_ring_used= uniform_ring_capacity= barrier_commands= barriers_per_frame= mask_restarts= feedback_splits= passes= plan_hits= plan_misses= in_pass_clears= promoted_clears= standalone_clears= pass_splits= push_constants= storage_set_binds= bindless_slots= bindless_placeholders= compute_passes= dispatches= native_passes= native_draws= native_fullscreen_draws= native_mesh_draws= native_instanced_draws= native_indirect_draws= +stats.latency backend= mode= rev= sleep_n= sleep_ms= frames= input_mean_ms= input_p99_ms= sim_mean_ms= sim_p99_ms= render_submit_mean_ms= render_submit_p99_ms= present_mean_ms= present_p99_ms= driver_mean_ms= driver_p99_ms= os_queue_mean_ms= os_queue_p99_ms= gpu_mean_ms= gpu_p99_ms= total_mean_ms= total_p99_ms= stats.memory blocks= dedicated= rebar_used= rebar_cap= rebar_misses= empty_blocks_freed= budget_ext=<0|1> class_bytes=,,,,, heaps=/,... stats.transients transient_mib= aliased_mib= heap_peak_mib= leases= aliased_leases= readself_copies= readself_pool= stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draws_skipped= pending= cache_bytes= saves= @@ -290,7 +291,20 @@ stats.pipelines compiled_sync= compiled_async= prewarmed= warm= draw (readback setup fence), `occlusion_query` (polling a query result), `swapchain_acquire`, `present` (vkQueuePresentKHR including the queue lock), `queue_submit` (vkQueueSubmit of a frame including the queue lock, which a worker's synchronous upload holds through its fence - wait). + wait) and `latency_sleep` (the latency backend's sleep before input is sampled; always 0 with + the None backend, the only one on this branch). +- `stats.latency` (latency seams, S7), always emitted so "off" is as visible as "on": + `backend` (off here; native, nv and amd are the pacing backends of `feat/latency`) and `mode` + (off, on or boost) of the active backend, `rev` (the vendor extension revision behind it, 0 + for off), `sleep_n` and `sleep_ms` (the interval's `latency_sleep` waits, repeated so the line + stands alone), `frames` (frame reports closed in the interval) and each of the eight report + intervals as a mean and a p99 in milliseconds, reduced exactly as `stats.pacing` reduces + frame intervals: `input` (input sample to simulation start), `sim` (simulation start to the + frame's first render stage), `render_submit` (first render stage to Submit A), `present` + (the vkQueuePresentKHR call), `driver`, `os_queue` and `gpu` (0 unless the backend has a + driver report, which none on this branch has) and `total` (input sample to present end: the + frame as the player feels it). A frame that was not presented (acquire failed, headless) + closes no report. - `stats.counters`, per interval: `scopes` (vkCmdBeginRendering), `barriers` (image barriers recorded), `rebar_fallbacks` (per-frame dynamic buffers - uniform ring, indirect ring - that asked for the ReBAR pool class and fell through to host staging memory because no ReBAR type diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index ea93242b..2c5d5f56 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index 8667d95..417226c 100644 +index 8667d95..6baa3a4 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,761 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,781 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -748,6 +748,26 @@ index 8667d95..417226c 100644 + public virtual void EndRenderStage(EnumRenderStage stage) + { + } ++ ++ // Vulkan-native plan, "Latency seams" S3: window_RenderFrame calls LatencySleep() ++ // immediately before it samples the mouse, so a latency backend's wait happens ++ // before input rather than after it, and skips its own FPS limiter when the ++ // platform's backend paces the frame itself. Neutral bodies; the OpenGL path does ++ // not override them, so vanilla pacing is unchanged. ++ public virtual void LatencySleep() ++ { ++ } ++ ++ public virtual bool LatencyOwnsFrameCap => false; ++ ++ // Vulkan-native plan, "Latency seams" S3: the frame cap window_RenderFrame computed for ++ // this frame in frames per second, 0 meaning uncapped. It already includes the ++ // background-window reduction, so a backend that owns the cap paces an unfocused window ++ // to the same number the client's own limiter would have used. Neutral body; the OpenGL ++ // path does not override it. ++ public virtual void SetLatencyFrameCap(int maxFps) ++ { ++ } + public static void DisposeIndexBuffer() { diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index 19b4705e..cda30a0c 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index b1b2eea..0db431e 100644 +index b1b2eea..b6aa736 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -438,7 +438,7 @@ index b1b2eea..0db431e 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +790,155 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +790,177 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -482,8 +482,27 @@ index b1b2eea..0db431e 100644 + Vintagestory.API.Config.OptimumDiagnostics.BackgroundFpsLimiter.Hit(); + } + ++ // Optimum (Vulkan-native plan, "Latency seams" S3): the cap this frame is limited to, ++ // computed once, in frames per second; 0 means uncapped, which is the case under ++ // exactly the conditions the vanilla limiter stays out of - vsync pacing the frame, ++ // or a MaxFps outside the 10..241 window where 241 and up is the client's "unlimited". ++ // The background-window reduction is already folded into effectiveMaxFps. ++ int latencyFrameCap = 0; + if (ClientSettings.VsyncMode != 1 && effectiveMaxFps > 10f && effectiveMaxFps < 241f) + { ++ latencyFrameCap = (int)effectiveMaxFps; ++ } ++ ++ // The pacing backend gets the same number before it sleeps, so an unfocused window ++ // keeps its reduced cap whichever side paces the frame. Neutral on the base platform. ++ SetLatencyFrameCap(latencyFrameCap); ++ ++ // A latency backend that paces the frame itself owns the cap, so the client's own ++ // limiter must stand down - two pacing points would add exactly the latency the ++ // backend is there to remove. LatencyOwnsFrameCap is false on the base platform, so ++ // the OpenGL path keeps the vanilla block verbatim. ++ if (!LatencyOwnsFrameCap && latencyFrameCap > 0) ++ { + long targetTicks = (long)(Stopwatch.Frequency / (double)effectiveMaxFps); + long remainingTicks = targetTicks - frameStopWatch.ElapsedTicks; + @@ -545,6 +564,9 @@ index b1b2eea..0db431e 100644 + } + ScreenManager.FrameProfiler.Mark("sleep"); ++ // Optimum (Vulkan-native plan, "Latency seams" S3): the latency backend's wait, the ++ // last thing before the frame's input is sampled. Neutral on the base platform. ++ LatencySleep(); UpdateMousePosition(); RenderBloom = ClientSettings.Bloom && base.DoPostProcessingEffects; RenderGodRays = ClientSettings.GodRayQuality > 0 && base.DoPostProcessingEffects; @@ -601,7 +623,7 @@ index b1b2eea..0db431e 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +958,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +980,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -614,7 +636,7 @@ index b1b2eea..0db431e 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1129,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1151,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -659,7 +681,7 @@ index b1b2eea..0db431e 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1241,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1263,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -679,7 +701,7 @@ index b1b2eea..0db431e 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1477,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1499,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -692,7 +714,7 @@ index b1b2eea..0db431e 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1038,29 +1492,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1038,29 +1514,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderMesh(modelRef, indices, indicesSizes, groupCount, useSSBOs: false); } @@ -782,7 +804,7 @@ index b1b2eea..0db431e 100644 public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) -@@ -1150,111 +1660,908 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,96 +1682,865 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -873,7 +895,11 @@ index b1b2eea..0db431e 100644 - PixelFormat val = (PixelFormat)6408; - CheckGlError("sdfb-begin"); - FrameBufferRef frameBufferRef = (list[0] = new FrameBufferRef -- { ++ ++ // Reasserted every frame rather than set once: this is the same field ++ // vanilla's cinematic recorder writes, and it resets it when a path stops. ++ if (OptimumHeadless.FixedDeltaTime > 0f) + { - FboId = GL.GenFramebuffer(), - Width = num, - Height = num2 @@ -881,13 +907,20 @@ index b1b2eea..0db431e 100644 - FrameBufferRef frameBufferRef3 = frameBufferRef; - frameBufferRef3.DepthTextureId = GL.GenTexture(); - if (frameBufferRef3.FboId == 0) ++ game.DeltaTimeLimiter = OptimumHeadless.FixedDeltaTime; ++ } + -+ // Reasserted every frame rather than set once: this is the same field -+ // vanilla's cinematic recorder writes, and it resets it when a path stops. -+ if (OptimumHeadless.FixedDeltaTime > 0f) ++ long worldFrame = optimumHeadlessWorldFrames; ++ optimumHeadlessWorldFrames = worldFrame + 1; ++ ++ if (!optimumHeadlessCommandsDone && worldFrame >= OptimumHeadless.CommandFrame) { - base.XPlatInterface.ShowMessageBox("Fatal error", "Unable to generate a new framebuffer. This shouldn't happen, ever. Maybe a restart resolves the problem?"); -+ game.DeltaTimeLimiter = OptimumHeadless.FixedDeltaTime; ++ optimumHeadlessCommandsDone = true; ++ if (OptimumHeadless.CommandScriptPath != null) ++ { ++ OptimumHeadlessRunCommands(game); ++ } } - CurrentFrameBufferKeepVw = frameBufferRef3; - GL.BindTexture((TextureTarget)3553, frameBufferRef3.DepthTextureId); @@ -911,10 +944,7 @@ index b1b2eea..0db431e 100644 - GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36065, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[1], 0); - if (SetupSSAO) + -+ long worldFrame = optimumHeadlessWorldFrames; -+ optimumHeadlessWorldFrames = worldFrame + 1; -+ -+ if (!optimumHeadlessCommandsDone && worldFrame >= OptimumHeadless.CommandFrame) ++ if (!optimumHeadlessCaptureDone && OptimumHeadless.CaptureEnabled) { - GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[2]); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, val, (PixelType)5126, (IntPtr)IntPtr.Zero); @@ -929,20 +959,6 @@ index b1b2eea..0db431e 100644 - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); -- GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); -- DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; -- GL.DrawBuffers(4, array2); -+ optimumHeadlessCommandsDone = true; -+ if (OptimumHeadless.CommandScriptPath != null) -+ { -+ OptimumHeadlessRunCommands(game); -+ } -+ } -+ -+ if (!optimumHeadlessCaptureDone && OptimumHeadless.CaptureEnabled) -+ { + if (OptimumHeadless.ShouldCapture(worldFrame)) + { + OptimumHeadlessCaptureFrame(worldFrame); @@ -1724,16 +1740,12 @@ index b1b2eea..0db431e 100644 + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); -+ GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); -+ DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; -+ GL.DrawBuffers(4, array2); - } - else - { - DrawBuffersEnum[] array3 = (DrawBuffersEnum[])(object)new DrawBuffersEnum[2] - { + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); + GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); + DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; + GL.DrawBuffers(4, array2); +@@ -1251,10 +2552,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1772,7 +1784,7 @@ index b1b2eea..0db431e 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2743,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2765,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1848,7 +1860,7 @@ index b1b2eea..0db431e 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2920,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2942,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1863,7 +1875,7 @@ index b1b2eea..0db431e 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2943,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2965,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1958,7 +1970,7 @@ index b1b2eea..0db431e 100644 } } } -@@ -1591,11 +3037,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +3059,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1977,7 +1989,7 @@ index b1b2eea..0db431e 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +3072,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +3094,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -2064,7 +2076,7 @@ index b1b2eea..0db431e 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3181,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3203,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2155,7 +2167,7 @@ index b1b2eea..0db431e 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3266,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3288,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2241,7 +2253,7 @@ index b1b2eea..0db431e 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,110 +3348,379 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,110 +3370,379 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2690,7 +2702,7 @@ index b1b2eea..0db431e 100644 ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; bilateralblur.Use(); int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); -@@ -1915,35 +3739,270 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,35 +3761,270 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2965,7 +2977,7 @@ index b1b2eea..0db431e 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,26 +4012,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,26 +4034,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -3017,7 +3029,7 @@ index b1b2eea..0db431e 100644 final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; final.SepiaLevel = ShaderUniforms.SepiaLevel + ShaderUniforms.ExtraSepia; -@@ -1987,24 +4065,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +4087,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -3074,7 +3086,7 @@ index b1b2eea..0db431e 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +4120,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +4142,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3572,7 +3584,7 @@ index b1b2eea..0db431e 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4768,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4790,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3612,7 +3624,7 @@ index b1b2eea..0db431e 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +5166,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +5188,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3665,7 +3677,7 @@ index b1b2eea..0db431e 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +5261,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5283,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3710,7 +3722,7 @@ index b1b2eea..0db431e 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5298,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5320,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3731,7 +3743,7 @@ index b1b2eea..0db431e 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5317,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5339,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3752,7 +3764,7 @@ index b1b2eea..0db431e 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5336,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5358,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3773,7 +3785,7 @@ index b1b2eea..0db431e 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5355,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5377,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3794,7 +3806,7 @@ index b1b2eea..0db431e 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5378,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5400,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3815,7 +3827,7 @@ index b1b2eea..0db431e 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5940,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5962,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3839,7 +3851,7 @@ index b1b2eea..0db431e 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6299,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6321,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); From 67e33fbbf929353efe9c8b13a31de75d9f12c255 Mon Sep 17 00:00:00 2001 From: NightHammer1000 Date: Thu, 17 Sep 2026 19:46:41 +0200 Subject: [PATCH 226/226] feat(frame): the world frame and the UI are separate images (Foundation B) The frame is rendered HUD-less and the UI is composed onto it at the end, always on for Vulkan and with no switch: the structure an upscaler or a frame generator needs, because the UI must never enter the image a reconstruction consumes. OpenGL keeps drawing its GUI straight onto the window. Slot 23 holds the HUD-less scene, copied from Primary colour 0 at the end of RenderFinalComposition - the one moment the composited image holds the scene alone. Slot 24 holds the UI image at window size, colour plus its own depth, because the GUI depth-sorts itself over ScreenManager's 0..20000 range. The scope opens at the end of BlitPrimaryToDefault, on every route out of it, and the device resolves Default to the UI image while it is open (VulkanDevice.RedirectDefaultFramebuffer), so no render system has to know it moved - the atlas item renderer's LoadFrameBuffer(Default) included. Standard blending into that image takes ONE as its source alpha factor (AttachmentBlend.ForUiImage), so straight-alpha GUI accumulates the over-operator's coverage instead of squaring it. The compose closes the scope first and is the only draw that writes the window image after the blit; BeginFrame and every framebuffer rebuild close a scope nobody composed. The lib gains only neutral pieces: the OptimumComposeUiTarget virtual, its calls in ClientMain.RenderToDefaultFramebuffer (before the Done stage, so the with-HUD screenshot and the AVI writer still record the finished frame) and in ScreenManager.Render (the menu screens never reach ClientMain), the ui-compose program in both twins, and the two slot names for the parity dump. Verified: solution builds; make patch-il 225/225; Optimum.Tests 1355 passed; Optimum.Render.Vulkan.Tests 1132 passed with sync,best validation and the implicit layers off; headless "serene cave world" on Vulkan (0 validation errors) and OpenGL, both closed themselves; the parity dump at frame 300 shows the snapshot, the UI colour and its coverage behaving as the over-operator, and the composed frame matches what the GUI drawn directly gives. --- Optimum.Patcher/Program.cs | 12 + .../UiSeparationTests.cs | 431 ++++++++++++++++++ Optimum.Render.Vulkan/Core/PipelineState.cs | 27 ++ .../Platform/StatedRenderState.cs | 12 + .../Platform/VulkanClientPlatform.Frame.cs | 4 + .../VulkanClientPlatform.FrameBuffers.cs | 5 + .../Platform/VulkanClientPlatform.Graph.cs | 23 +- .../VulkanClientPlatform.NativeBlit.cs | 8 +- .../VulkanClientPlatform.NativeGui.cs | 6 +- .../VulkanClientPlatform.NativeWorld.cs | 8 +- .../VulkanClientPlatform.UiSeparation.cs | 283 ++++++++++++ .../Platform/VulkanClientPlatform.cs | 2 + Optimum.Render.Vulkan/VulkanDevice.Native.cs | 19 +- ...-platform-windows-vanilla-regions-tests.cs | 2 +- Optimum.Tests/parity-dump-coverage-tests.cs | 15 +- Optimum.Tests/ui-separation-coverage-tests.cs | 187 ++++++++ docs/vulkan-native-shaders.md | 8 + .../ClientMain.cs.patch | 40 +- .../ClientPlatformAbstract.cs.patch | 14 +- .../ClientPlatformWindows.cs.patch | 93 ++-- .../ShaderPrograms.cs.patch | 11 +- .../ShaderRegistry.cs.patch | 12 +- .../ScreenManager.cs.patch | 21 +- sources/shaders-vk/ui-compose.frag | 18 + sources/shaders-vk/ui-compose.interface.glsl | 6 + sources/shaders-vk/ui-compose.vert | 21 + sources/shaders/ui-compose.fsh | 26 ++ sources/shaders/ui-compose.vsh | 15 + 28 files changed, 1250 insertions(+), 79 deletions(-) create mode 100644 Optimum.Render.Vulkan.Tests/UiSeparationTests.cs create mode 100644 Optimum.Render.Vulkan/Platform/VulkanClientPlatform.UiSeparation.cs create mode 100644 Optimum.Tests/ui-separation-coverage-tests.cs create mode 100644 sources/shaders-vk/ui-compose.frag create mode 100644 sources/shaders-vk/ui-compose.interface.glsl create mode 100644 sources/shaders-vk/ui-compose.vert create mode 100644 sources/shaders/ui-compose.fsh create mode 100644 sources/shaders/ui-compose.vsh diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index 5c045c3a..ecf777ec 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -206,6 +206,9 @@ "LatencySleep", "LatencyOwnsFrameCap", "SetLatencyFrameCap", + // World/UI separation: the compose ClientMain.RenderToDefaultFramebuffer and + // ScreenManager.Render call; neutral here, the Vulkan platform overrides it. + "OptimumComposeUiTarget", }, ["Vintagestory.Client.ClientProgram"] = new() { @@ -433,6 +436,9 @@ // BlitPrimaryToDefault both ask so the two never sharpen the same // pixels twice. "OptimumTaaSharpenIndex", + // World/UI separation: the snapshot and UI slots the parity dump names. + "OptimumSceneNoHudIndex", + "OptimumUiTargetIndex", "OptimumFsrBlitActive", "RenderOptimumTaaSharpen", // TAA: the jittered AO multiplied into the scene before the resolve, and @@ -516,6 +522,8 @@ "ChunkLiquidMotion", // TAA P4: the sky / volumetric-cloud motion pass program. "TaaSkyMotion", + // World/UI separation: the UI compose pass program. + "UiCompose", }, ["Vintagestory.Client.NoObf.ShaderRegistry"] = new() { @@ -881,6 +889,10 @@ new("Vintagestory.Client.NoObf.ClientMain", "OnFowChanged", 1), new("Vintagestory.Client.NoObf.ClientMain", "OnResize", 0), new("Vintagestory.Client.NoObf.ClientMain", "RenderAfterPostProcessing", 1), + // World/UI separation: the UI image is composed over the display image after the Ortho + // stage and before TriggerRenderStage(Done), where the with-HUD screenshot and the AVI + // writer are registered. + new("Vintagestory.Client.NoObf.ClientMain", "RenderToDefaultFramebuffer", 1), new("Vintagestory.Client.NoObf.ClientEventManager", "TriggerReloadShaders", 0), // ClientMain: mouse wheel fix (vanilla fields only) new("Vintagestory.Client.NoObf.ClientMain", "OnMouseWheel", 1), diff --git a/Optimum.Render.Vulkan.Tests/UiSeparationTests.cs b/Optimum.Render.Vulkan.Tests/UiSeparationTests.cs new file mode 100644 index 00000000..16cfb70d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/UiSeparationTests.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Optimum.Render.Vulkan.Platform; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// World/UI separation on Vulkan (VulkanClientPlatform.UiSeparation.cs): the frame is rendered +/// HUD-less, the GUI lands in its own image with real coverage, and the compose puts it back. +/// +/// Driven through the real platform bodies - the scope's open, the stated GUI-shaped draws that +/// only name Default, the compose, the snapshot - on a headless device, whose Default target is +/// the window-sized image a headless run reads back. Every judged frame follows unjudged ones with +/// EndFrame between them and no readback in the loop, so an image that is really an earlier +/// frame's fails here. Validation runs with sync and best practices on, as in every GPU test. +/// +public class UiSeparationTests(ITestOutputHelper output) +{ + private const int Size = 16; + private const int Half = Size / 2; + + // The scene under the UI, and two straight-alpha UI layers: one over the whole image at 0.4, + // one over the left half at 0.6. Distinguishable in every channel. + private static readonly double[] Scene = { 40, 90, 200, 255 }; + private static readonly double[] LayerA = { 200, 100, 50, 0.4 }; + private static readonly double[] LayerB = { 20, 180, 240, 0.6 }; + + private const string FullscreenVertex = """ + #version 330 core + void main(void) + { + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + } + """; + + private static readonly string LayerAFragment = """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4(200.0 / 255.0, 100.0 / 255.0, 50.0 / 255.0, 0.4); } + """; + + private static readonly string LayerBFragment = """ + #version 330 core + out vec4 outColor; + void main(void) + { + if (gl_FragCoord.x >= 8.0) discard; + outColor = vec4(20.0 / 255.0, 180.0 / 255.0, 240.0 / 255.0, 0.6); + } + """; + + // The shipped sources/shaders/ui-compose.{vsh,fsh}, read from the tree so the test links + // exactly what ships (its native twin is pinned by NativeShaderParityTests). + private static string ComposeSource(string extension) => + File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders", "ui-compose." + extension)); + + private sealed class SeparationPlatform : VulkanClientPlatform + { + public SeparationPlatform() : base(null!) + { + } + + public override Size2i OptimumWindowClientSize() => new(Size, Size); + } + + /// + /// The three images at their two moments. Before the compose the window image holds the scene + /// and nothing else; the UI image holds exactly the GUI - the over-operator's premultiplied + /// colour and coverage where it drew, transparent black where it did not, including where an + /// earlier frame drew; after the compose the window image is ui.rgb + scene * (1 - ui.a), which + /// is what the same layers drawn straight onto the scene give. + /// + [SkippableFact] + public void TheGuiLandsInItsOwnImageAndIsComposedOverTheScene() + { + using Session session = Open(); + SeparationPlatform platform = session.Platform; + VulkanDevice seam = session.Seam; + FrameBufferRef ui = platform.UiTargetFrameBuffer!; + Assert.Equal(VulkanClientPlatform.OptimumUiTargetIndex, platform.UiTargetFrameBufferIndex); + Assert.Equal(Size, ui.Width); + + for (int frame = 0; frame < 3; frame++) + { + session.RunFrame(layerA: true, layerB: true, read: false); + } + + // Both layers: coverage accumulates as the over-operator, not as alpha squared. + Frame both = session.RunFrame(layerA: true, layerB: true, read: true); + double[] uiRight = Premultiplied(LayerA); + double[] uiLeft = Over(Premultiplied(LayerB), uiRight); + AssertHalf(both.Ui, left: false, uiRight, "UI image, layer A alone"); + AssertHalf(both.Ui, left: true, uiLeft, "UI image, layer B over layer A"); + AssertHalf(both.Before, left: true, Scene, "window before the compose"); + AssertHalf(both.Before, left: false, Scene, "window before the compose"); + AssertHalf(both.After, left: false, Composed(uiRight), "window after the compose"); + AssertHalf(both.After, left: true, Composed(uiLeft), "window after the compose"); + + // Only layer B: the right half of the UI image is empty again, and the window shows the + // scene there - the image is cleared every frame, not accumulated across frames. + Frame onlyB = session.RunFrame(layerA: false, layerB: true, read: true); + AssertHalf(onlyB.Ui, left: false, new double[] { 0, 0, 0, 0 }, "UI image, nothing drawn"); + AssertHalf(onlyB.Ui, left: true, Premultiplied(LayerB), "UI image, layer B alone"); + AssertHalf(onlyB.After, left: false, Scene, "window where no UI drew"); + AssertHalf(onlyB.After, left: true, Composed(Premultiplied(LayerB)), "window after the compose"); + + GpuTest.AssertClean(seam); + } + + /// + /// The scope is a window of the frame and nothing more. Open, Default resolves to the UI image + /// and Standard takes over-operator alpha; the compose closes both, and a second compose in the + /// frame records nothing. A scope nobody composed (a GUI renderer that threw) is closed by the + /// next BeginFrame, so the world pass after it blends with vanilla's factors: one 0.4 layer over + /// transparent black keeps 0.4 * 0.4 = 0.16 coverage, not 0.4. + /// + [SkippableFact] + public void TheScopeClosesAtTheComposeAndAtTheNextFrame() + { + using Session session = Open(); + SeparationPlatform platform = session.Platform; + VulkanDevice seam = session.Seam; + FrameBufferRef ui = platform.UiTargetFrameBuffer!; + + platform.BeginFrame(); + platform.OpenUiScope(); + Assert.True(platform.UiScopeOpen); + Assert.Equal(ui.FboId, seam.DefaultFramebufferRedirect); + platform.OptimumComposeUiTarget(); + Assert.False(platform.UiScopeOpen); + Assert.Equal(0, seam.DefaultFramebufferRedirect); + long passes = seam.NativePassesForTests; + platform.OptimumComposeUiTarget(); + Assert.Equal(passes, seam.NativePassesForTests); + platform.EndFrame(); + + // Left open, as by a GUI renderer that threw past both compose call sites. + platform.BeginFrame(); + platform.OpenUiScope(); + platform.EndFrame(); + + platform.BeginFrame(); + Assert.False(platform.UiScopeOpen); + Assert.Equal(0, seam.DefaultFramebufferRedirect); + + // A world-shaped Standard draw into a target cleared to transparent black. + FrameBufferRef world = platform.SceneNoHudFrameBuffer!; + seam.ClearNativeColor(world.FboId, 0, 0f, 0f, 0f, 0f); + platform.CurrentFrameBuffer = world; + session.DrawLayer(session.LayerAProgram); + byte[] pixels = seam.ReadBackLevel0ForTests(world.ColorTextureIds[0]); + platform.CurrentFrameBuffer = null; + platform.EndFrame(); + + int alpha = pixels[(Half * Size + Half) * 4 + 3]; + output.WriteLine("world-pass coverage after an uncomposed scope: " + alpha + " (vanilla 41, scoped 102)"); + Assert.InRange(alpha, 39, 43); + GpuTest.AssertClean(seam); + } + + /// + /// The snapshot is the composited scene texel for texel: Primary colour 0 copied into slot 23 + /// at the end of the composition, and flagged as captured for that frame only. + /// + [SkippableFact] + public void TheSnapshotIsTheCompositedScene() + { + using Session session = Open(); + SeparationPlatform platform = session.Platform; + VulkanDevice seam = session.Seam; + FrameBufferRef primary = platform.FrameBuffers[0]; + FrameBufferRef snapshot = platform.SceneNoHudFrameBuffer!; + Assert.Equal(VulkanClientPlatform.OptimumSceneNoHudIndex, platform.SceneNoHudFrameBufferIndex); + + byte[] captured = Array.Empty(); + for (int frame = 0; frame < 4; frame++) + { + platform.BeginFrame(); + // A different scene every frame, so a copy of an earlier one cannot pass. + float phase = frame / 4f; + seam.ClearNativeColor(primary.FboId, 0, 0.1f + phase * 0.5f, 0.7f - phase * 0.4f, 0.3f, 1f); + platform.CaptureSceneNoHud(); + Assert.True(platform.SceneNoHudCaptured); + if (frame == 3) captured = seam.ReadBackLevel0ForTests(snapshot.ColorTextureIds[0]); + platform.EndFrame(); + } + + var expected = new double[] { (0.1 + 0.75 * 0.5) * 255, (0.7 - 0.75 * 0.4) * 255, 0.3 * 255, 255 }; + AssertHalf(captured, left: true, expected, "snapshot"); + AssertHalf(captured, left: false, expected, "snapshot"); + GpuTest.AssertClean(seam); + } + + /// + /// The factor rule without a device: only Standard's exact factor set changes, only its source + /// alpha factor, and only for a draw into the UI image while the scope is open. + /// + [Fact] + public void OnlyStandardDrawnIntoTheUiImageTakesOverOperatorAlpha() + { + AttachmentBlend standard = AttachmentBlend.For(true, EnumBlendMode.Standard).ForUiImage(); + Assert.Equal(BlendFactor.SrcAlpha, standard.SrcColor); + Assert.Equal(BlendFactor.OneMinusSrcAlpha, standard.DstColor); + Assert.Equal(BlendFactor.One, standard.SrcAlpha); + Assert.Equal(BlendFactor.OneMinusSrcAlpha, standard.DstAlpha); + + foreach (EnumBlendMode mode in new[] + { + EnumBlendMode.PremultipliedAlpha, EnumBlendMode.Brighten, EnumBlendMode.Multiply, + EnumBlendMode.Glow, EnumBlendMode.Overlay, + }) + { + AttachmentBlend plain = AttachmentBlend.For(true, mode); + Assert.Equal(plain, plain.ForUiImage()); + } + + var stated = new StatedRenderState(); + stated.SetBlendEnabled(true); + stated.SetBlendMode(EnumBlendMode.Standard); + Assert.Equal(BlendFactor.SrcAlpha, stated.AttachmentFor(PassDeclaration.DefaultFramebuffer, 0).SrcAlpha); + + stated.UiImageFramebuffer = 42; + Assert.Equal(BlendFactor.One, stated.AttachmentFor(PassDeclaration.DefaultFramebuffer, 0).SrcAlpha); + Assert.Equal(BlendFactor.One, stated.AttachmentFor(42, 0).SrcAlpha); + Assert.Equal(BlendFactor.SrcAlpha, stated.AttachmentFor(7, 0).SrcAlpha); + + stated.UiImageFramebuffer = 0; + Assert.Equal(BlendFactor.SrcAlpha, stated.AttachmentFor(PassDeclaration.DefaultFramebuffer, 0).SrcAlpha); + } + + // ------------------------------------------------------------------------ arithmetic + + /// A straight-alpha layer (rgb in bytes, alpha 0..1) as premultiplied bytes. + private static double[] Premultiplied(double[] layer) => + new[] { layer[0] * layer[3], layer[1] * layer[3], layer[2] * layer[3], layer[3] * 255 }; + + /// The over-operator on premultiplied bytes. + private static double[] Over(double[] top, double[] under) + { + double keep = 1 - top[3] / 255; + return new[] { top[0] + under[0] * keep, top[1] + under[1] * keep, top[2] + under[2] * keep, top[3] + under[3] * keep }; + } + + /// The window after the compose: the UI over the opaque scene. + private static double[] Composed(double[] ui) => Over(ui, Scene); + + private void AssertHalf(byte[] pixels, bool left, double[] expected, string what) + { + int wrong = 0; + string first = ""; + for (int y = 0; y < Size; y++) + { + for (int x = left ? 0 : Half; x < (left ? Half : Size); x++) + { + int i = (y * Size + x) * 4; + for (int c = 0; c < 4; c++) + { + if (Math.Abs(pixels[i + c] - expected[c]) <= 2) continue; + if (wrong == 0) + { + first = $" first at ({x},{y}): {pixels[i]},{pixels[i + 1]},{pixels[i + 2]},{pixels[i + 3]}"; + } + wrong++; + break; + } + } + } + output.WriteLine($"{what} ({(left ? "left" : "right")}): expected " + + $"{expected[0]:F0},{expected[1]:F0},{expected[2]:F0},{expected[3]:F0}, wrong {wrong}/{Half * Size}{first}"); + Assert.True(wrong == 0, what + ": " + wrong + " pixels off by more than 2/255." + first); + } + + // ------------------------------------------------------------------------ driving + + private readonly record struct Frame(byte[] Ui, byte[] Before, byte[] After); + + private Session Open() + { + Session? session = Session.TryOpen(output); + Skip.If(session == null, "No usable Vulkan device."); + return session!; + } + + private sealed class Session : IDisposable + { + public SeparationPlatform Platform { get; private init; } = null!; + public VulkanDevice Seam => Platform.GraphicsDevice!; + public int LayerAProgram { get; private set; } + public int LayerBProgram { get; private set; } + + private ShaderProgram? previousCompose; + private string dataPath = ""; + + public static Session? TryOpen(ITestOutputHelper output) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-ui-separation-" + Guid.NewGuid().ToString("N")); + var platform = new SeparationPlatform + { + DeviceFactory = GpuTest.NewDevice, + CrashMarkerDataPath = dataPath, + }; + if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason)) + { + output.WriteLine("Vulkan unavailable: " + reason); + platform.ShutdownGraphics(); + return null; + } + + var session = new Session + { + Platform = platform, + dataPath = dataPath, + previousCompose = ShaderPrograms.UiCompose, + }; + VulkanDevice seam = platform.GraphicsDevice!; + + // Primary as the composition leaves it, plus the two separation images, installed + // the way SetupDefaultFrameBuffers installs them. + var list = new List(); + for (int i = 0; i <= 24; i++) list.Add(null!); + list[0] = ColorTarget(seam); + platform.AllocateUiSeparationTargets(list, Size, Size); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic; + typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list); + + session.LayerAProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, LayerAFragment, "ui-layer-a"); + session.LayerBProgram = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, LayerBFragment, "ui-layer-b"); + int compose = VulkanDeviceIntegrationTests.LinkProgram( + seam, ComposeSource("vsh"), ComposeSource("fsh"), "ui-compose"); + ShaderPrograms.UiCompose = new ShaderProgram { ProgramId = compose, PassName = "ui-compose" }; + return session; + } + + /// + /// One frame from the blit on: the window image painted with the scene, the scope opened + /// as the blit's end opens it, the GUI-shaped draws naming only Default, and the compose. + /// + public Frame RunFrame(bool layerA, bool layerB, bool read) + { + VulkanDevice seam = Seam; + FrameBufferRef ui = Platform.UiTargetFrameBuffer!; + Platform.BeginFrame(); + seam.ClearNativeColor(PassDeclaration.DefaultFramebuffer, 0, + (float)(Scene[0] / 255), (float)(Scene[1] / 255), (float)(Scene[2] / 255), 1f); + + Platform.OpenUiScope(); + Platform.CurrentFrameBuffer = null; + if (layerA) DrawLayer(LayerAProgram); + if (layerB) DrawLayer(LayerBProgram); + + byte[] uiPixels = read ? seam.ReadBackLevel0ForTests(ui.ColorTextureIds[0]) : Array.Empty(); + byte[] before = read ? ReadWindow(seam) : Array.Empty(); + Platform.OptimumComposeUiTarget(); + byte[] after = read ? ReadWindow(seam) : Array.Empty(); + Platform.EndFrame(); + return new Frame(uiPixels, before, after); + } + + /// A straight-alpha fullscreen layer under Standard, into whatever is bound. + public void DrawLayer(int program) + { + Platform.GlViewport(0, 0, Size, Size); + Platform.GlDisableDepthTest(); + Platform.GlDepthMask(false); + Platform.GlDisableCullFace(); + Platform.GlToggleBlend(true, EnumBlendMode.Standard); + Platform.UseShaderProgram(program); + Platform.RenderFullscreenTriangle(null!); + Platform.UseShaderProgram(0); + } + + /// The window image itself, whatever Default resolves to right now, in RGBA. + private static unsafe byte[] ReadWindow(VulkanDevice seam) + { + var pixels = new byte[Size * Size * 4]; + fixed (byte* destination = pixels) + { + seam.ReadFramebufferColor(seam.DefaultFramebufferId, 0, 0, Size, Size, (IntPtr)destination); + } + if (seam.DefaultColorFormat == Format.B8G8R8A8Unorm || seam.DefaultColorFormat == Format.B8G8R8A8Srgb) + { + for (int i = 0; i < pixels.Length; i += 4) (pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]); + } + return pixels; + } + + private static FrameBufferRef ColorTarget(VulkanDevice seam) + { + var target = new FrameBufferRef + { + Width = Size, + Height = Size, + FboId = seam.CreateFramebuffer(Size, Size), + ColorTextureIds = new[] + { + seam.CreateTexture2D(Size, Size, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false), + }, + }; + seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + seam.SetDrawBuffers(target.FboId, 1); + Assert.True(seam.CheckFramebufferComplete(target.FboId, out string status), status); + return target; + } + + public void Dispose() + { + ShaderPrograms.UiCompose = previousCompose; + Platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + } +} diff --git a/Optimum.Render.Vulkan/Core/PipelineState.cs b/Optimum.Render.Vulkan/Core/PipelineState.cs index 48d4dc95..e8a1a554 100644 --- a/Optimum.Render.Vulkan/Core/PipelineState.cs +++ b/Optimum.Render.Vulkan/Core/PipelineState.cs @@ -54,6 +54,33 @@ public static AttachmentBlend For(bool enabled, EnumBlendMode mode) return blend; } + /// + /// World/UI separation: the blend a draw into the UI image uses in place of this one. + /// + /// gui.fsh writes straight alpha and the GUI draws under , + /// whose factors are not separate - (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) on the alpha channel too. + /// Onto the opaque window that is correct, which is why it always was; accumulated into an image + /// that starts transparent it gives out_a = src_a * src_a + dst_a * (1 - src_a), roughly alpha + /// squared per layer, instead of the over-operator's out_a = src_a + dst_a * (1 - src_a), and the + /// compose then shows the world through every translucent panel. So Standard's exact factor set + /// takes ONE for the source alpha factor here, and nothing else changes: the RGB factors already + /// accumulate the premultiplied colour the compose blends back with (ONE, ONE_MINUS_SRC_ALPHA). + /// + /// Only Standard, deliberately. PremultipliedAlpha already has (ONE, ONE_MINUS_SRC_ALPHA) on both + /// channels, and the destination-reading modes (Brighten, Multiply, Glow, Overlay) belong to world + /// systems that draw before the blit, never into the UI image. Pinned by UiSeparationTests. + /// + public AttachmentBlend ForUiImage() + { + AttachmentBlend blend = this; + if (blend.SrcColor == BlendFactor.SrcAlpha && blend.DstColor == BlendFactor.OneMinusSrcAlpha && + blend.SrcAlpha == BlendFactor.SrcAlpha && blend.DstAlpha == BlendFactor.OneMinusSrcAlpha) + { + blend.SrcAlpha = BlendFactor.One; + } + return blend; + } + /// The one table of factor pairs, shared by the stated state and by native systems. internal static (BlendFactor SrcColor, BlendFactor DstColor, BlendFactor SrcAlpha, BlendFactor DstAlpha) FactorsFor(EnumBlendMode mode) => mode switch diff --git a/Optimum.Render.Vulkan/Platform/StatedRenderState.cs b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs index 2ff2c0ae..e39e0eb5 100644 --- a/Optimum.Render.Vulkan/Platform/StatedRenderState.cs +++ b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs @@ -126,9 +126,21 @@ public AttachmentBlend AttachmentFor(int framebufferId, int slot) AttachmentBlend blend = _blend[slot]; blend.Enabled = BlendEnabled; blend.WriteMask = ((DrawBuffers(framebufferId) >> slot) & 1) != 0 ? ColorMask : 0; + if (IsUiImage(framebufferId)) blend = blend.ForUiImage(); return blend; } + /// + /// World/UI separation: the UI image's target while the platform's UI scope is open, 0 otherwise + /// (VulkanClientPlatform.UiSeparation.cs). While it is set, Default means that image. + /// + public int UiImageFramebuffer; + + /// Whether a draw into lands in the UI image. + public bool IsUiImage(int framebufferId) => + UiImageFramebuffer > 0 && + (framebufferId == Graph.PassDeclaration.DefaultFramebuffer || framebufferId == UiImageFramebuffer); + public void SetDrawBuffers(int framebufferId, uint mask) => _drawBuffers[framebufferId] = mask; public uint DrawBuffers(int framebufferId) => diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs index 9c2c0435..3968a6d2 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -107,6 +107,10 @@ private ulong NextLatencyFrameId() /// Recycles the frame slot and opens a command buffer. public override void BeginFrame() { + // World/UI separation: a GUI renderer that threw out of the AfterBlit or Ortho stage + // unwound past both compose call sites; the new frame starts with the scope closed, so the + // world pass draws to the targets it names under the factors it states. + CloseUiScope(); device.BeginFrame(); // Until a stage or a post method says otherwise, passes are named after the frame. passContext = "Frame"; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs index 34c809ab..6179ba61 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -291,6 +291,9 @@ public override List SetupDefaultFrameBuffers() device.OptInTransient(transientTarget.ColorTextureIds[0], transientSlot); } + // World/UI separation: the HUD-less scene snapshot and the UI image (UiSeparation.cs). + AllocateUiSeparationTargets(list, width, height); + OptimumFinishDeviceFrameBufferSetup(list); return list; } @@ -492,6 +495,8 @@ public override void DisposeFrameBuffer(FrameBufferRef frameBuffer, bool dispose /// public override void DisposeFrameBuffers(List buffers) { + // The UI image may be what Default resolves to; it is about to go. + CloseUiScope(); // The AO targets are sized to Primary and go with it. ReleaseAmbientOcclusionTargets(); HashSet deletedTextures = new HashSet(); diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs index aa9b0ed4..1e28935a 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -339,9 +339,14 @@ public override void RenderFinalComposition() if (UseNativePostChain) { NativeFinalComposition(); - return; } - LegacyFinalComposition(); + else + { + LegacyFinalComposition(); + } + // World/UI separation: the composited image holds the scene alone for exactly this long - + // RenderAfterFinalComposition draws the world-space overlays onto it next. + CaptureSceneNoHud(); } /// @@ -356,10 +361,16 @@ public override void BlitPrimaryToDefault() if (NativeBlitEnabled && UseNativePostChain) { RenderNativeBlit(); - return; } - SetPassContext("Blit", PassFlags.None); - base.BlitPrimaryToDefault(); - SetPassContext("Frame", PassFlags.AllowSplit); + else + { + SetPassContext("Blit", PassFlags.None); + base.BlitPrimaryToDefault(); + SetPassContext("Frame", PassFlags.AllowSplit); + } + // World/UI separation: the boundary. Everything ScreenManager draws after this call - the + // AfterBlit stage, the menu background, the Ortho stage - goes into the UI image, on every + // route out of the blit (debug view, FSR, plain, no offscreen buffer). + OpenUiScope(); } } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs index 6e5d33ed..c9c0afe5 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs @@ -87,9 +87,11 @@ public void Adopt(NativePipeline pipeline, RenderTargetFormats formats) /// /// The pipeline for one fullscreen program against one target, rebuilt only when the - /// program was relinked or the target's formats changed. + /// program was relinked or the target's formats changed. Opaque unless the pass states a + /// blend; a pass object always states the same one, so the blend is not part of the check. /// - private NativePipeline? NativePipelineFor(NativeFullscreenPass pass, ShaderProgramBase program, int framebufferId) + private NativePipeline? NativePipelineFor(NativeFullscreenPass pass, ShaderProgramBase program, int framebufferId, + AttachmentBlend[]? blend = null) { RenderTargetFormats? formats = device.NativeTargetFormats(framebufferId, 1u); if (formats == null) return null; @@ -104,7 +106,7 @@ public void Adopt(NativePipeline pipeline, RenderTargetFormats formats) { ProgramId = program.ProgramId, PassName = pass.PassName, - Blend = OpaqueColorZero(), + Blend = blend ?? OpaqueColorZero(), DepthTest = false, DepthWrite = false, Cull = CullModeFlags.None, diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs index 47f05521..78e1265c 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -286,7 +286,7 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, NativePipeline? pipeline = NativeMeshPipelineFor(pass, program, framebufferId, slots, layoutId, new NativePipelineDescription { - Blend = GuiSlots(formats, blend, blendMode), + Blend = GuiSlots(formats, blend, framebufferId, blendMode), DepthTest = depthTest, DepthWrite = depthWrite, DepthCompare = depthCompare, @@ -336,11 +336,13 @@ private bool DrawNativeGuiMesh(NativeMeshPass pass, MeshRef mesh, int textureId, /// the tracker's own factor table, and every other slot masked off so an attachment the /// fragment shader never writes keeps its contents as it does on GL (rule 9). /// - private static AttachmentBlend[] GuiSlots(RenderTargetFormats formats, bool blend, + private AttachmentBlend[] GuiSlots(RenderTargetFormats formats, bool blend, int framebufferId, EnumBlendMode mode = EnumBlendMode.Standard) { var slots = new AttachmentBlend[Math.Max(formats.ColorFormats.Length, 1)]; slots[0] = AttachmentBlend.For(blend, mode); + // World/UI separation: straight-alpha GUI accumulates coverage in the UI image. + if (stated.IsUiImage(framebufferId)) slots[0] = slots[0].ForUiImage(); for (int i = 1; i < slots.Length; i++) { slots[i] = AttachmentBlend.Default; diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs index f87c9135..8ba1212f 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -486,6 +486,12 @@ private AttachmentBlend[] StatedWorldBlend(FrameBufferRef target, int count) else { blend[i] = AttachmentBlend.For(statedBlendOn, statedBlendMode); + // World/UI separation: a world-program draw into the UI image (held items in + // a dialog, the reticle's disc) accumulates coverage like the GUI does. + if (stated.IsUiImage(target?.FboId ?? PassDeclaration.DefaultFramebuffer)) + { + blend[i] = blend[i].ForUiImage(); + } } if (i == motion) blend[i] = ReplaceBlend(statedBlendOn); blend[i].WriteMask &= ~statedColorMaskOff; @@ -559,7 +565,7 @@ private bool TryRenderStandardMeshNative(MeshRef mesh) /// private AttachmentBlend[] StatedGuiSlots(RenderTargetFormats formats) { - AttachmentBlend[] slots = GuiSlots(formats, statedBlendOn, statedBlendMode); + AttachmentBlend[] slots = GuiSlots(formats, statedBlendOn, PassDeclaration.DefaultFramebuffer, statedBlendMode); slots[0].WriteMask &= ~statedColorMaskOff; return slots; } diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.UiSeparation.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.UiSeparation.cs new file mode 100644 index 00000000..a9cb296e --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.UiSeparation.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; +using Vintagestory.API.MathTools; +using Vintagestory.Client.NoObf; + +namespace Optimum.Render.Vulkan.Platform; + +/// +/// World/UI separation, a foundation of the Vulkan renderer (owner's call, 2026-09-17: always on, +/// no switch). The frame is rendered HUD-less and the UI is composed onto it at the end - the +/// structure every engine with an upscaler or a frame generator needs, because the UI must never +/// enter an image a reconstruction consumes. OpenGL keeps drawing its GUI straight onto the window. +/// +/// Two images, both published by slot the way MotionAttachmentIndex is: +/// +/// SceneNoHud (slot 23, render size, RGBA8): a copy of Primary colour 0 +/// taken at the end of - the one moment the composited image +/// holds the scene alone. RenderAfterFinalComposition draws selection boxes and work-item guides onto +/// that image next, and the GUI follows after the blit. +/// UI image (slot 24, window size, RGBA8 + depth): everything after the +/// blit - the AfterBlit stage, the main-menu background and the whole Ortho stage - draws into it +/// instead of onto the window, over transparent black, with real coverage in alpha (see +/// ). Its own depth, because the GUI depth-sorts itself over +/// ScreenManager's 0..20000 range. +/// +/// +/// The UI scope runs from (the end of ) +/// to (ClientMain before its Done stage; ScreenManager for the +/// menu screens). While it is open the device resolves Default to the UI image +/// (), so no render system needs to know: what +/// has always drawn "onto the window" draws into the UI image, the atlas item renderer's +/// LoadFrameBuffer(Default) included. The compose closes the scope first and is the only draw that +/// writes the window image after the blit. and every framebuffer rebuild +/// close a scope a throwing GUI renderer left open. +/// +/// The OpenGL bodies this mirrors are the feat/dlss-g ones (design step 3); there the +/// target was gated on frame generation, here it is not gated. Pinned by UiSeparationTests (GPU) +/// and ui-separation-coverage-tests.cs (placement). +/// +public partial class VulkanClientPlatform +{ + /// The HUD-less scene snapshot's slot (named in ClientPlatformWindows' parity dump). + internal const int OptimumSceneNoHudIndex = 23; + + /// The UI image's slot (named in ClientPlatformWindows' parity dump). + internal const int OptimumUiTargetIndex = 24; + + /// + /// The slot holding this frame's HUD-less scene, or -1 when it could not be allocated. Consumers + /// ask this before they index FrameBuffers. + /// + public int SceneNoHudFrameBufferIndex => sceneNoHudIndex; + + /// The slot holding this frame's UI image, or -1 when it could not be allocated. + public int UiTargetFrameBufferIndex => uiTargetIndex; + + private int sceneNoHudIndex = -1; + private int uiTargetIndex = -1; + + /// + /// Whether the last composition really wrote the snapshot. A consumer that reads the slot when + /// this is false reads an earlier frame's image. + /// + public bool SceneNoHudCaptured { get; private set; } + + /// True from to . + internal bool UiScopeOpen => stated.UiImageFramebuffer > 0; + + /// The snapshot copy: the pass-through program, opaque, render size. + private readonly NativeFullscreenPass nativeSceneNoHudCopy = new("ui-compose", Array.Empty(), new[] { "uiTex" }); + + /// The compose: the pass-through program under premultiplied-alpha blending. + private readonly NativeFullscreenPass nativeUiCompose = new("ui-compose", Array.Empty(), new[] { "uiTex" }); + + private static AttachmentBlend[] PremultipliedColorZero() => + new[] { AttachmentBlend.For(true, EnumBlendMode.PremultipliedAlpha) }; + + /// + /// Allocates both images into and publishes their slots. A failure costs + /// that image and nothing else: its slot stays -1, the snapshot is simply not taken, and without a + /// UI image the GUI draws straight onto the window as it does on OpenGL. + /// + internal void AllocateUiSeparationTargets(List list, int renderWidth, int renderHeight) + { + CloseUiScope(); + sceneNoHudIndex = -1; + uiTargetIndex = -1; + SceneNoHudCaptured = false; + + try + { + FrameBufferRef snapshot = CreateOptimumOwnedTarget(renderWidth, renderHeight, withDepth: false); + list[OptimumSceneNoHudIndex] = snapshot; + sceneNoHudIndex = OptimumSceneNoHudIndex; + } + catch (Exception error) + { + Logger.Error("Optimum: no HUD-less scene snapshot: {0}", error.Message); + list[OptimumSceneNoHudIndex] = null; + } + + // The window's size, not the render size: the GUI has always laid itself out in window + // pixels, and the compose puts it back over the window one for one. + Size2i window = OptimumWindowClientSize(); + int windowWidth = window.Width; + int windowHeight = window.Height; + try + { + FrameBufferRef ui = CreateOptimumOwnedTarget(windowWidth, windowHeight, withDepth: true); + list[OptimumUiTargetIndex] = ui; + uiTargetIndex = OptimumUiTargetIndex; + } + catch (Exception error) + { + Logger.Error("Optimum: no separate UI image, the GUI draws onto the window: {0}", error.Message); + list[OptimumUiTargetIndex] = null; + } + } + + /// + /// A persistent single-colour target (RGBA8, nearest, clamped: both images are read one texel for + /// one), with a depth attachment when asked. Not a transient: its contents outlive the pass that + /// wrote them. + /// + private FrameBufferRef CreateOptimumOwnedTarget(int width, int height, bool withDepth) + { + FrameBufferRef target = new FrameBufferRef(); + target.Width = width; + target.Height = height; + target.FboId = device.CreateFramebuffer(width, height); + target.ColorTextureIds = new int[1]; + target.ColorTextureIds[0] = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + SetupOptimumTextureSampler(target.ColorTextureIds[0], 9728, 33071); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + if (withDepth) + { + target.DepthTextureId = device.CreateTexture2D(width, height, + EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + SetupOptimumTextureSampler(target.DepthTextureId, 9728, 33071); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0); + } + StateDrawBuffers(target.FboId, 1); + if (!device.CheckFramebufferComplete(target.FboId, out string status)) + { + throw new Exception("framebuffer incomplete: " + status); + } + return target; + } + + /// + /// Takes the HUD-less snapshot: Primary colour 0 copied texel for texel into slot 23, one native + /// pass. Called at the very end of , whichever route drew it. + /// + internal void CaptureSceneNoHud() + { + SceneNoHudCaptured = false; + List buffers = FrameBuffers; + if (sceneNoHudIndex < 0 || buffers == null || buffers.Count <= sceneNoHudIndex) return; + FrameBufferRef snapshot = buffers[sceneNoHudIndex]; + FrameBufferRef primary = buffers[0]; + if (snapshot == null || primary?.ColorTextureIds == null || primary.ColorTextureIds.Length == 0) return; + ShaderProgram copy = ShaderPrograms.UiCompose; + if (copy == null || copy.LoadError || copy.ProgramId <= 0) return; + + int scene = primary.ColorTextureIds[0]; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + NativePipeline? pipeline = NativePipelineFor(nativeSceneNoHudCopy, copy, snapshot.FboId); + if (pipeline != null && + BeginNativeBlitPass("SceneNoHud/" + OptimumSceneNoHudIndex, snapshot.FboId, + snapshot.Width, snapshot.Height, new[] { scene })) + { + SceneNoHudCaptured = device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeSceneNoHudCopy.Samplers[0], scene), + }); + } + device.EndNativePass(); + SetPassContext(outer, outerFlags); + } + + /// + /// Opens the UI scope: the UI image is cleared to transparent black and depth 1, and Default + /// resolves to it until the compose. Called at the end of on + /// every route out of it, the menu screens' included - the blit is the boundary between the world + /// and everything ScreenManager draws after it. The client still sees the Default target bound. + /// + internal void OpenUiScope() + { + CloseUiScope(); + List buffers = FrameBuffers; + if (uiTargetIndex < 0 || buffers == null || buffers.Count <= uiTargetIndex) return; + FrameBufferRef ui = buffers[uiTargetIndex]; + if (ui == null || ui.ColorTextureIds == null || ui.ColorTextureIds.Length == 0) return; + + // Straight to the device: the image has to start empty whatever colour or depth mask the + // client stated last, which the stated clears would honour. + device.ClearNativeColor(ui.FboId, 0, 0f, 0f, 0f, 0f); + device.ClearNativeDepth(ui.FboId, 1f); + device.RedirectDefaultFramebuffer(ui.FboId); + stated.UiImageFramebuffer = ui.FboId; + } + + /// Ends the scope without composing: Default is the window again and Standard is Standard. + internal void CloseUiScope() + { + stated.UiImageFramebuffer = 0; + device?.RedirectDefaultFramebuffer(0); + } + + /// + /// Puts the UI image back over the window image: one fullscreen pass under premultiplied-alpha + /// blending, dst = ui.rgb + dst.rgb * (1 - ui.a). The OpenGL body (feat/dlss-g) is the same + /// sequence against GL state. + /// + /// Where it is called from, and why there: ClientMain.RenderToDefaultFramebuffer after the Ortho + /// stage and before TriggerRenderStage(Done), where the with-HUD screenshot and the AVI writer are + /// registered, and ScreenManager.Render for the menu screens, which never reach ClientMain. A + /// second call in a frame finds the scope closed and returns. + /// + public override void OptimumComposeUiTarget() + { + if (!UiScopeOpen) return; + FrameBufferRef ui = UiTargetFrameBuffer; + // The scope ends here whatever follows, so a compose that gives up below never leaves Default + // pointing at the UI image or the separate-alpha rule armed for the rest of the frame. + CloseUiScope(); + // The window, bound and with its own viewport, whether or not the compose happens: the Done + // stage and the screenshot read it. + LoadFrameBuffer(EnumFrameBuffer.Default); + // ScreenManager's ClearDefaultDepth landed on the UI image, so the window's depth never got + // this frame's clear; vanilla hands the Done stage a freshly cleared one. + device.ClearNativeDepth(PassDeclaration.DefaultFramebuffer, 1f); + GlToggleBlend(true); + + ShaderProgram compose = ShaderPrograms.UiCompose; + if (ui == null || ui.ColorTextureIds == null || ui.ColorTextureIds.Length == 0) return; + if (compose == null || compose.LoadError || compose.ProgramId <= 0) return; + + int uiColor = ui.ColorTextureIds[0]; + Size2i client = OptimumWindowClientSize(); + string outer = passContext; + PassFlags outerFlags = passContextFlags; + NativePipeline? pipeline = NativePipelineFor(nativeUiCompose, compose, NativeDefaultTarget, + PremultipliedColorZero()); + if (pipeline != null && + BeginNativeBlitPass("UiCompose/Default", NativeDefaultTarget, client.Width, client.Height, new[] { uiColor })) + { + device.DrawNativeFullscreen(pipeline, new[] + { + new NativeTexture(nativeUiCompose.Samplers[0], uiColor), + }); + } + device.EndNativePass(); + SetPassContext(outer, outerFlags); + } + + /// The UI image, or null when its slot is not allocated. + internal FrameBufferRef? UiTargetFrameBuffer + { + get + { + List buffers = FrameBuffers; + if (uiTargetIndex < 0 || buffers == null || buffers.Count <= uiTargetIndex) return null; + return buffers[uiTargetIndex]; + } + } + + /// The HUD-less scene snapshot, or null when its slot is not allocated. + internal FrameBufferRef? SceneNoHudFrameBuffer + { + get + { + List buffers = FrameBuffers; + if (sceneNoHudIndex < 0 || buffers == null || buffers.Count <= sceneNoHudIndex) return null; + return buffers[sceneNoHudIndex]; + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs index 5ddfe86c..4c4ceba3 100644 --- a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -139,6 +139,8 @@ public partial class VulkanClientPlatform : ClientPlatformWindows new(true, "LatencySleep", Array.Empty()), new(true, "get_LatencyOwnsFrameCap", Array.Empty()), new(true, "SetLatencyFrameCap", new[] { "Int32" }), + // World/UI separation: the compose ClientMain and ScreenManager call. + new(true, "OptimumComposeUiTarget", Array.Empty()), // Phase 2 step 2: the TAA post methods declare their frame-graph passes. new(true, "RenderOptimumSkyMotion", Array.Empty()), // Phase 3b stage 2: the sky dome's draw seam, the first world system on the native API. diff --git a/Optimum.Render.Vulkan/VulkanDevice.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs index 81ebc428..efe51801 100644 --- a/Optimum.Render.Vulkan/VulkanDevice.Native.cs +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -373,7 +373,24 @@ internal bool TryNativeSamplerState(int samplerId, out SamplerState state) => _standaloneSamplers.TryGetValue(samplerId, out state); private int ResolveNativeFramebuffer(int framebufferId) => - framebufferId == PassDeclaration.DefaultFramebuffer ? _defaultFramebuffer : framebufferId; + framebufferId == PassDeclaration.DefaultFramebuffer + ? (_defaultRedirect > 0 ? _defaultRedirect : _defaultFramebuffer) + : framebufferId; + + /// + /// World/UI separation: the target stands for + /// while the platform's UI scope is open - its UI image - or 0 for the window image itself. Every + /// draw, clear, format query and readback that names Default resolves through + /// , so each render system that has always drawn "onto the + /// window" draws into the UI image instead without knowing it, and only the compose, which closes + /// the scope first, writes the window image (VulkanClientPlatform.UiSeparation.cs). + /// + internal void RedirectDefaultFramebuffer(int framebufferId) => _defaultRedirect = framebufferId; + + /// The target Default currently resolves to instead of the window image; 0 for none. + internal int DefaultFramebufferRedirect => _defaultRedirect; + + private int _defaultRedirect; /// /// The pipeline for a program and a piece of fixed state, created through the pipeline diff --git a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs index 4975f1a7..ca0c0df2 100644 --- a/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -42,7 +42,7 @@ public class ClientPlatformWindowsVanillaRegionsTests "OptimumParityReadTextureGl", "OptimumParitySlotName", "OptimumRenderSsao", "OptimumRunParityDump", "OptimumRunPendingTaaShaderReload", "OptimumSpinIterations", "OptimumSpinTailMinProcessorCount", "OptimumSsaoKernel", "OptimumTaaHistoryIndexA", "OptimumTaaHistoryIndexB", "OptimumTaaRequested", - "OptimumTaaSharpenIndex", "OptimumTimeBeginPeriod", "OptimumTimeEndPeriod", + "OptimumTaaSharpenIndex", "OptimumSceneNoHudIndex", "OptimumUiTargetIndex", "OptimumTimeBeginPeriod", "OptimumTimeEndPeriod", "OptimumUndershootPercent", "OptimumWindowClientSize", "OptimumYieldThresholdMs", "ProbeThickLineSupport", // Phase 3b: the post chain as one virtual per pass, and the keep-the-viewport bind. diff --git a/Optimum.Tests/parity-dump-coverage-tests.cs b/Optimum.Tests/parity-dump-coverage-tests.cs index 4ced5fd7..3c93107d 100644 --- a/Optimum.Tests/parity-dump-coverage-tests.cs +++ b/Optimum.Tests/parity-dump-coverage-tests.cs @@ -60,7 +60,20 @@ public void DumpedSlotListMatchesTheFramebuffersBothSetupsCreate() var aoSlots = new HashSet(new[] { "OptimumAoWorkingSlot", "OptimumAoEdgesSlot", "OptimumAoDepthSlot", "OptimumAoOutputSlot" }.Select(c => constants[c])); Assert.Equal(4, aoSlots.Count(named.ContainsKey)); Assert.DoesNotContain(glSlots, aoSlots.Contains); - Assert.Equal(glSlots, new SortedSet(named.Keys.Where(slot => !aoSlots.Contains(slot)))); + // World/UI separation: the HUD-less snapshot and the UI image are named for the dump and + // allocated by the Vulkan setup alone, through its separation partial; OpenGL has neither. + var separationSlots = new HashSet(new[] { "OptimumSceneNoHudIndex", "OptimumUiTargetIndex" }.Select(c => constants[c])); + Assert.Equal(2, separationSlots.Count(named.ContainsKey)); + Assert.DoesNotContain(glSlots, separationSlots.Contains); + Assert.Contains("AllocateUiSeparationTargets(list, width, height);", deviceBody); + string separation = File.ReadAllText(PatchReader.FindRepositoryFile( + "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.UiSeparation.cs")); + foreach (string slotConstant in new[] { "OptimumSceneNoHudIndex", "OptimumUiTargetIndex" }) + { + Assert.Contains("list[" + slotConstant + "] = ", separation); + Assert.Contains("internal const int " + slotConstant + " = " + constants[slotConstant] + ";", separation); + } + Assert.Equal(glSlots, new SortedSet(named.Keys.Where(slot => !aoSlots.Contains(slot) && !separationSlots.Contains(slot)))); // Vanilla slots are named exactly as EnumFrameBuffer names them. Dictionary enumValues = EnumValues(); diff --git a/Optimum.Tests/ui-separation-coverage-tests.cs b/Optimum.Tests/ui-separation-coverage-tests.cs new file mode 100644 index 00000000..8cc8df02 --- /dev/null +++ b/Optimum.Tests/ui-separation-coverage-tests.cs @@ -0,0 +1,187 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// World/UI separation (VulkanClientPlatform.UiSeparation.cs): where each piece sits. The GPU +/// behaviour is UiSeparationTests; these pin the placements a refactor could move across the call +/// they belong to - a compose after the Done stage records the HUD-less image in every screenshot, +/// a bind before the blit's last route leaves the GUI on the window, a scope nobody closes blends +/// the next world pass under the UI factors. +/// +public class UiSeparationCoverageTests +{ + private const string PlatformPath = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.UiSeparation.cs"; + private const string GraphPath = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs"; + + [Fact] + public void ClientMainComposesAfterTheOrthoStageAndBeforeDone() + { + string body = StripComments(Body(ReadLib("Vintagestory.Client.NoObf/ClientMain.cs"), + "public void RenderToDefaultFramebuffer(float dt)")); + int ortho = body.IndexOf("rendOrthoDone", StringComparison.Ordinal); + int compose = body.IndexOf("Platform.OptimumComposeUiTarget();", StringComparison.Ordinal); + int done = body.IndexOf("TriggerRenderStage(EnumRenderStage.Done, dt);", StringComparison.Ordinal); + Assert.True(ortho >= 0 && compose > ortho && done > compose, + "the compose must sit between the Ortho stage and the Done stage:\n" + body); + Assert.Single(Regex.Matches(body, @"OptimumComposeUiTarget\(\)")); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientMain\", \"RenderToDefaultFramebuffer\", 1),", patcher); + } + + [Fact] + public void TheMenuScreensComposeInScreenManager() + { + string body = StripComments(Body(ReadLib("Vintagestory.Client/ScreenManager.cs"), "internal void Render(float dt)")); + int blit = body.IndexOf("Platform.BlitPrimaryToDefault();", StringComparison.Ordinal); + int screen = body.IndexOf("CurrentScreen.RenderToDefaultFramebuffer(dt);", StringComparison.Ordinal); + int compose = body.IndexOf("Platform.OptimumComposeUiTarget();", StringComparison.Ordinal); + Assert.True(blit >= 0 && screen > blit && compose > screen, + "the menu compose must follow the screen's own drawing:\n" + body); + Assert.Contains("new(\"Vintagestory.Client.ScreenManager\", \"Render\", 1),", Read("Optimum.Patcher/Program.cs")); + } + + [Fact] + public void TheComposeIsANeutralVirtualThatOnlyVulkanOverrides() + { + string platform = ReadLib("Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + Assert.Equal("{ }", Regex.Replace(Body(platform, "public virtual void OptimumComposeUiTarget()"), @"\s+", " ").Trim()); + Assert.DoesNotContain("OptimumComposeUiTarget", ReadLib("Vintagestory.Client.NoObf/ClientPlatformWindows.cs")); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"OptimumComposeUiTarget\",", patcher); + Assert.Contains("new(true, \"OptimumComposeUiTarget\", Array.Empty()),", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs")); + Assert.Contains("public override void OptimumComposeUiTarget()", Read(PlatformPath)); + } + + [Fact] + public void TheComposeProgramIsRegisteredAndShipsBothTwins() + { + Assert.Contains("public static ShaderProgram UiCompose;", ReadLib("Vintagestory.Client.NoObf/ShaderPrograms.cs")); + string registry = ReadLib("Vintagestory.Client.NoObf/ShaderRegistry.cs"); + Assert.Contains("RegisterOptimumShaderProgram(\"ui-compose\", ShaderPrograms.UiCompose = new ShaderProgram());", registry); + Assert.Contains("shaderProgram == ShaderPrograms.UiCompose)", registry); + Assert.Contains("\"UiCompose\",", Read("Optimum.Patcher/Program.cs")); + + // A pass-through: blit.fsh's forced alpha of 1 would cover the world with the UI image. + foreach (string file in new[] { "sources/shaders/ui-compose.fsh", "sources/shaders-vk/ui-compose.frag" }) + { + string fragment = StripComments(Read(file)); + Assert.DoesNotContain(".a = 1", fragment); + Assert.Matches(new Regex(@"outColor = texture\((optimumTextures2D\[uiTex\]|uiTex), texCoord\);"), fragment); + } + Assert.True(File.Exists(PatchReader.FindRepositoryFile("sources/shaders/ui-compose.vsh"))); + Assert.True(File.Exists(PatchReader.FindRepositoryFile("sources/shaders-vk/ui-compose.vert"))); + Assert.True(File.Exists(PatchReader.FindRepositoryFile("sources/shaders-vk/ui-compose.interface.glsl"))); + } + + [Fact] + public void TheScopeOpensAtTheBlitsEndAndTheSnapshotAtTheCompositionsEnd() + { + string graph = Read(GraphPath); + + string blit = Body(graph, " public override void BlitPrimaryToDefault()"); + int native = blit.IndexOf("RenderNativeBlit();", StringComparison.Ordinal); + int glRoute = blit.IndexOf("base.BlitPrimaryToDefault();", StringComparison.Ordinal); + int open = blit.IndexOf("OpenUiScope();", StringComparison.Ordinal); + Assert.True(native >= 0 && glRoute > native && open > glRoute, "the scope must open after both routes:\n" + blit); + Assert.DoesNotContain("return;", blit); + + string composition = Body(graph, " public override void RenderFinalComposition()"); + int legacy = composition.IndexOf("LegacyFinalComposition();", StringComparison.Ordinal); + int capture = composition.IndexOf("CaptureSceneNoHud();", StringComparison.Ordinal); + Assert.True(legacy >= 0 && capture > legacy, "the snapshot must follow both routes:\n" + composition); + Assert.DoesNotContain("return;", composition); + } + + [Fact] + public void EveryScopeIsClosedBeforeTheWorldDrawsAgain() + { + string frame = Body(Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs"), + " public override void BeginFrame()"); + int close = frame.IndexOf("CloseUiScope();", StringComparison.Ordinal); + int begin = frame.IndexOf("device.BeginFrame();", StringComparison.Ordinal); + Assert.True(close >= 0 && begin > close, "BeginFrame must close the scope first:\n" + frame); + + string framebuffers = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs"); + Assert.Contains("CloseUiScope();", Body(framebuffers, " public override void DisposeFrameBuffers(")); + Assert.Contains("AllocateUiSeparationTargets(list, width, height);", + Body(framebuffers, " public override List SetupDefaultFrameBuffers()")); + + // The compose closes the scope before anything can return or draw. + string compose = Body(Read(PlatformPath), " public override void OptimumComposeUiTarget()"); + int closed = compose.IndexOf("CloseUiScope();", StringComparison.Ordinal); + int firstReturn = compose.IndexOf("return;", compose.IndexOf("UiScopeOpen", StringComparison.Ordinal) + 20, + StringComparison.Ordinal); + int draw = compose.IndexOf("DrawNativeFullscreen", StringComparison.Ordinal); + Assert.True(closed >= 0 && firstReturn > closed && draw > closed, compose); + } + + [Fact] + public void DefaultResolvesToTheUiImageOnlyThroughTheDevicesOneResolver() + { + string native = Read("Optimum.Render.Vulkan/VulkanDevice.Native.cs"); + // Expression-bodied, so read up to the member's semicolon. + int start = native.IndexOf("private int ResolveNativeFramebuffer(int framebufferId) =>", StringComparison.Ordinal); + Assert.True(start >= 0, "the resolver is gone"); + string resolver = native.Substring(start, native.IndexOf(';', start) - start); + Assert.Contains("_defaultRedirect > 0 ? _defaultRedirect : _defaultFramebuffer", resolver); + Assert.Single(Regex.Matches(native, @"_defaultRedirect = ")); + + string stated = Read("Optimum.Render.Vulkan/Platform/StatedRenderState.cs"); + Assert.Contains("if (IsUiImage(framebufferId)) blend = blend.ForUiImage();", stated); + Assert.Contains("stated.IsUiImage(framebufferId)", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs")); + Assert.Contains("stated.IsUiImage(", Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs")); + } + + [Fact] + public void TheParityDumpNamesBothSlots() + { + string windows = ReadLib("Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + Assert.Contains("private const int OptimumSceneNoHudIndex = 23;", windows); + Assert.Contains("private const int OptimumUiTargetIndex = 24;", windows); + Assert.Contains("return \"OptimumSceneNoHud\";", windows); + Assert.Contains("return \"OptimumUiTarget\";", windows); + string platform = Read(PlatformPath); + Assert.Contains("internal const int OptimumSceneNoHudIndex = 23;", platform); + Assert.Contains("internal const int OptimumUiTargetIndex = 24;", platform); + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + private static string ReadLib(string relativePath) + { + try + { + return File.ReadAllText(PatchReader.FindRepositoryFile("build/VintagestoryLib/" + relativePath)); + } + catch (FileNotFoundException) + { + return PatchReader.ReadPatchedContent(PatchReader.FindRepositoryFile( + "patches/VintagestoryLib/" + relativePath + ".patch")); + } + } + + private static string StripComments(string source) => + Regex.Replace(source, @"//[^\n]*", string.Empty); + + private static string Body(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + signature); + int open = source.IndexOf('{', start + signature.Length); + int depth = 0; + for (int i = open; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}' && --depth == 0) return source.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced body: " + signature); + } +} diff --git a/docs/vulkan-native-shaders.md b/docs/vulkan-native-shaders.md index 55aa8d4a..f05773f9 100644 --- a/docs/vulkan-native-shaders.md +++ b/docs/vulkan-native-shaders.md @@ -664,6 +664,14 @@ USEOIT=1); `standard` ALLOWDEPTHOFFSET, GBUFFER, GLOWSUB, TAAMOTION (16); `insta `chunkliquidmotion.vert` remaps after its `w` offset, as the rewriter's wrapper did; `taaPrevClip` stays in GL clip convention, which the motion arithmetic expects. +### ui-compose (world/UI separation, 2026-09-17) + +`ui-compose` joins the Optimum programs: a fullscreen pass-through of the UI image (`uiTex`), push block with the +one sampler slot and no record. The Vulkan platform draws it twice per frame: opaque into the HUD-less snapshot +(slot 23) and under premultiplied-alpha blending into the window image (the compose). Its fragment must never force +alpha (`blit.frag`'s `outColor.a = 1` would cover the world with the UI image); `ui-separation-coverage-tests.cs` +pins that for both twins. + ## 10. Family decisions ### Family post (`ssao`, `godrays`, `findbright`, `blur`, `bilateralblur`, `colorgrade`, `transparentcompose`, `debugdepthbuffer`, `woittest`), 2026-09-15 diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch index d5ab7ba9..9951f6b1 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs -index 6d5300a..d9c08ba 100644 +index 6d5300a..7f55020 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs @@ -200,10 +200,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo @@ -487,7 +487,27 @@ index 6d5300a..d9c08ba 100644 dt = DeltaTimeLimiter; } TriggerRenderStage(EnumRenderStage.AfterPostProcessing, dt); -@@ -1314,11 +1568,16 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1224,10 +1478,19 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo + guiShaderProg.Stop(); + } + ScreenManager.FrameProfiler.Mark("rendOrthoDone"); + PerspectiveMode(); + Platform.GlDepthFunc(EnumDepthFunction.Less); ++ // Optimum (Vulkan foundation, world/UI separation): the UI image goes back over the ++ // display image here - after the Ortho stage, so the whole GUI is in it, and before the ++ // Done stage, because the with-HUD screenshot (SystemScreenshot) and the AVI writer ++ // (SystemVideoRecorder) are registered at Done and the parity dump and the headless ++ // capture read once this call chain returns. Composing after any of them would make ++ // every one of those capture paths record the HUD-less image as the final frame. ++ // Outside the ShouldRender2DOverlays block on purpose: a frame that draws no 2D ++ // overlays still has whatever the AfterBlit stage put in the UI image. ++ Platform.OptimumComposeUiTarget(); + TriggerRenderStage(EnumRenderStage.Done, dt); + ScreenManager.FrameProfiler.Mark("finfr"); + tickSummary = ScreenManager.FrameProfiler.summary; + } + +@@ -1314,11 +1577,16 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo texture2texture.AlphaTest = alphaTest; texture2texture.Xs = targetX / (float)fb.Width; texture2texture.Ys = targetY / (float)fb.Height; @@ -505,7 +525,7 @@ index 6d5300a..d9c08ba 100644 texture2texture.Stop(); currentShaderProgram?.Use(); } -@@ -1343,11 +1602,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1343,11 +1611,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlScale(width, height, 0.0); GlScale(0.5, 0.5, 0.0); GlTranslate(1.0, 1.0, 0.0); @@ -519,7 +539,7 @@ index 6d5300a..d9c08ba 100644 public void Render2DTexture(MultiTextureMeshRef meshRef, float x1, float y1, float width, float height, float z = 10f, Vec4f color = null) { -@@ -1366,11 +1626,11 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1366,11 +1635,11 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo guiShaderProg.ModelViewMatrix = CurrentModelViewMatrix; for (int i = 0; i < meshRef.meshrefs.Length; i++) { @@ -532,7 +552,7 @@ index 6d5300a..d9c08ba 100644 } public void Render2DTexture(int textureid, ModelTransform transform, Vec4f color = null) -@@ -1390,11 +1650,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1390,11 +1659,12 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlScale(transform.ScaleXYZ.X, transform.ScaleXYZ.Y, 0.0); GlScale(0.5, 0.5, 0.0); GlTranslate(1.0, 1.0, 0.0); @@ -546,7 +566,7 @@ index 6d5300a..d9c08ba 100644 public void Render2DTextureFlipped(int textureid, float x1, float y1, float width, float height, float z = 10f, Vec4f color = null) { -@@ -1410,20 +1671,25 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1410,20 +1680,25 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlScale(0.5, 0.5, 0.0); GlTranslate(1.0, 1.0, 0.0); GlRotate(180f, 1.0, 0.0, 0.0); @@ -573,7 +593,7 @@ index 6d5300a..d9c08ba 100644 GlMatrixModeModelView(); } -@@ -1565,21 +1831,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -1565,21 +1840,21 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo GlOrtho(0.0, width, height, 0.0, 0.4000000059604645, 20001.0); } GlMatrixModeModelView(); @@ -597,7 +617,7 @@ index 6d5300a..d9c08ba 100644 public void Connect() { Compression.Reset(); -@@ -2123,12 +2389,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2123,12 +2398,22 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo public void UpdateFreeMouse() { @@ -622,7 +642,7 @@ index 6d5300a..d9c08ba 100644 mouseWorldInteractAnyway = !MouseGrabbed && !flag2; if (!mouseGrabbed && MouseGrabbed) { -@@ -2542,10 +2818,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -2542,10 +2827,13 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo ShouldRedrawAllBlocks = true; } @@ -636,7 +656,7 @@ index 6d5300a..d9c08ba 100644 } public void DoReconnect() -@@ -3530,6 +3809,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo +@@ -3530,6 +3818,17 @@ public sealed class ClientMain : GameMain, IWorldIntersectionSupplier, IClientWo EntityRenderers.TryGetValue(forEntity.EntityId, out var value); value?.Dispose(); EntityRenderers.Remove(forEntity.EntityId); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch index 2c5d5f56..71975491 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -index 8667d95..6baa3a4 100644 +index 8667d95..89597a7 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformAbstract.cs -@@ -327,14 +327,781 @@ public abstract class ClientPlatformAbstract +@@ -327,14 +327,791 @@ public abstract class ClientPlatformAbstract public abstract void ResetGamePauseAndUptimeState(); @@ -768,6 +768,16 @@ index 8667d95..6baa3a4 100644 + public virtual void SetLatencyFrameCap(int maxFps) + { + } ++ ++ // Optimum (Vulkan foundation, world/UI separation): composes the UI image back over the ++ // display image, called by ClientMain.RenderToDefaultFramebuffer between the Ortho stage and ++ // TriggerRenderStage(Done), and again by ScreenManager.Render for the menu screens, which ++ // never reach ClientMain. Neutral here and on OpenGL, which draws its GUI straight onto the ++ // window as it always has; the Vulkan platform renders the GUI into its own target and ++ // composes it in this override. A second call in the same frame finds nothing to compose. ++ public virtual void OptimumComposeUiTarget() ++ { ++ } + public static void DisposeIndexBuffer() { diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch index cda30a0c..0267534d 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs -index b1b2eea..b6aa736 100644 +index b1b2eea..dbf069b 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs @@ -26,11 +26,11 @@ using Vintagestory.ClientNative; @@ -140,7 +140,7 @@ index b1b2eea..b6aa736 100644 private Logger logger; private int doResize; -@@ -93,10 +207,127 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -93,10 +207,134 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private List drawCallStacks = new List(); @@ -166,6 +166,13 @@ index b1b2eea..b6aa736 100644 + // the unsharpened resolve. + private const int OptimumTaaSharpenIndex = 21; + ++ // Optimum (Vulkan foundation, world/UI separation): the slots of the HUD-less scene ++ // snapshot and of the UI image. Only the Vulkan platform allocates them ++ // (VulkanClientPlatform.UiSeparation.cs); named here for the parity dump. ++ private const int OptimumSceneNoHudIndex = 23; ++ ++ private const int OptimumUiTargetIndex = 24; ++ + // Optimum AO: the parity-dump and headless slot numbers of the compute-only AO outputs, + // in OptimumAmbientOcclusionDebugTexture's index order. No framebuffer holds them. + private const int OptimumAoWorkingSlot = 40; @@ -268,7 +275,7 @@ index b1b2eea..b6aa736 100644 private bool serverRunning; private bool gamepause; -@@ -109,10 +340,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -109,10 +347,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private bool RenderFXAA; @@ -303,7 +310,7 @@ index b1b2eea..b6aa736 100644 private int ShadowMapQuality; private float ssaaLevel; -@@ -200,11 +455,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -200,11 +462,15 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return audio.MasterSoundLevel; } @@ -320,7 +327,7 @@ index b1b2eea..b6aa736 100644 public override AssetManager AssetManager => assetManager; -@@ -256,10 +515,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -256,10 +522,23 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } } @@ -344,7 +351,7 @@ index b1b2eea..b6aa736 100644 get { return serverRunning; -@@ -278,34 +550,65 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -278,34 +557,65 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { return curFb; } @@ -418,7 +425,7 @@ index b1b2eea..b6aa736 100644 public override bool GlDebugMode { get -@@ -379,10 +682,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -379,10 +689,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public void StartAudio() { if (audio == null) @@ -438,7 +445,7 @@ index b1b2eea..b6aa736 100644 public override void AddAudioSettingsWatchers() { -@@ -478,40 +790,177 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -478,40 +797,177 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameStopWatch.Start(); } @@ -623,7 +630,7 @@ index b1b2eea..b6aa736 100644 } public void LogAndTestHardwareInfosStage1() -@@ -531,11 +980,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -531,11 +987,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract { logger.Notification("C# Framework: " + GetFrameworkInfos()); logger.Notification("Cairo Graphics Version: " + CairoAPI.VersionString); @@ -636,7 +643,7 @@ index b1b2eea..b6aa736 100644 logger.Notification("Graphics Card Version: " + GL.GetString((StringName)7938)); logger.Notification("Graphics Card Renderer: " + GL.GetString((StringName)7937)); logger.Notification("Graphics Card ShadingLanguageVersion: " + GL.GetString((StringName)35724)); -@@ -702,24 +1151,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -702,24 +1158,42 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBuffers = SetupDefaultFrameBuffers(); minimalGuiShaderProgram = new ShaderProgramMinimalGui(); minimalGuiShaderProgram.Compile(); @@ -681,7 +688,7 @@ index b1b2eea..b6aa736 100644 private void Window_FileDrop(FileDropEventArgs e) { -@@ -796,10 +1263,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -796,10 +1270,19 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract logger.Notification("Window was resized to {0} {1}? Window probably got minimized. Will not rebuild frame buffers", ((NativeWindow)window).ClientSize.X, ((NativeWindow)window).ClientSize.Y); } else if (((NativeWindow)window).ClientSize.X != windowsize.Width || ((NativeWindow)window).ClientSize.Y != windowsize.Height) @@ -701,7 +708,7 @@ index b1b2eea..b6aa736 100644 windowsize.Height = ((NativeWindow)window).ClientSize.Y; if ((int)((NativeWindow)window).WindowState == 0) { -@@ -1023,11 +1499,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1023,11 +1506,11 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.DrawElements(vAO.drawMode, vAO.IndicesCount, (DrawElementsType)5125, 0); GL.BindBuffer((BufferTarget)34963, 0); GL.BindVertexArray(0); @@ -714,7 +721,7 @@ index b1b2eea..b6aa736 100644 GL.BindVertexArray(((VAO)modelRef).VaoId); GL.DrawArrays((PrimitiveType)4, 0, 3); GL.BindVertexArray(0); -@@ -1038,29 +1514,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1038,29 +1521,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract RenderMesh(modelRef, indices, indicesSizes, groupCount, useSSBOs: false); } @@ -804,7 +811,7 @@ index b1b2eea..b6aa736 100644 public override void RenderMeshInstanced(MeshRef modelRef, int quantity = 1) { //IL_002f: Unknown result type (might be due to invalid IL or missing references) -@@ -1150,96 +1682,865 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1150,94 +1689,867 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = null; GL.BindTexture((TextureTarget)3553, 0); return frameBufferRef; @@ -957,8 +964,6 @@ index b1b2eea..b6aa736 100644 - GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[3]); - GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); - GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -- GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); + if (OptimumHeadless.ShouldCapture(worldFrame)) + { + OptimumHeadlessCaptureFrame(worldFrame); @@ -1258,6 +1263,10 @@ index b1b2eea..b6aa736 100644 + return "OptimumTaaHistoryB"; + case OptimumTaaSharpenIndex: + return "OptimumTaaSharpen"; ++ case OptimumSceneNoHudIndex: ++ return "OptimumSceneNoHud"; ++ case OptimumUiTargetIndex: ++ return "OptimumUiTarget"; + case OptimumAoWorkingSlot: + return "OptimumAoWorking"; + case OptimumAoEdgesSlot: @@ -1738,14 +1747,12 @@ index b1b2eea..b6aa736 100644 + GL.BindTexture((TextureTarget)3553, frameBufferRef3.ColorTextureIds[3]); + GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, num, num2, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10241, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); -+ GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)10240, 9729); + GL.TexParameter((TextureTarget)3553, (TextureParameterName)4100, new float[4] { 1f, 1f, 1f, 1f }); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, 33069); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, 33069); GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36067, (TextureTarget)3553, frameBufferRef3.ColorTextureIds[3], 0); - DrawBuffersEnum[] array2 = new DrawBuffersEnum[4] { DrawBuffersEnum.ColorAttachment0, DrawBuffersEnum.ColorAttachment1, DrawBuffersEnum.ColorAttachment2, DrawBuffersEnum.ColorAttachment3 }; - GL.DrawBuffers(4, array2); -@@ -1251,10 +2552,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1251,10 +2563,38 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract (DrawBuffersEnum)36064, (DrawBuffersEnum)36065 }; @@ -1784,7 +1791,7 @@ index b1b2eea..b6aa736 100644 { FboId = GL.GenFramebuffer(), Width = num, -@@ -1436,10 +2765,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1436,10 +2776,75 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract frameBufferRef3 = (CurrentFrameBufferKeepVw = frameBufferRef); frameBufferRef3.ColorTextureIds = new int[1] { GL.GenTexture() }; setupAttachment(frameBufferRef3, num, num2, 0, val, (PixelInternalFormat)34842); @@ -1860,7 +1867,7 @@ index b1b2eea..b6aa736 100644 FboId = GL.GenFramebuffer(), Width = num / 4, Height = num2 / 4 -@@ -1548,10 +2942,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1548,10 +2953,14 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract else { CurrentFrameBufferKeepVw = null; @@ -1875,7 +1882,7 @@ index b1b2eea..b6aa736 100644 } private void setupAttachment(FrameBufferRef frameBuffer, int width, int height, int index, PixelFormat rgbaFormat, PixelInternalFormat dataFormat) -@@ -1567,21 +2965,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1567,21 +2976,91 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.FramebufferTexture2D((FramebufferTarget)36160, (FramebufferAttachment)36064, (TextureTarget)3553, frameBuffer.ColorTextureIds[index], 0); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10242, Convert.ToInt32((object)(TextureWrapMode)33071)); GL.TexParameter((TextureTarget)3553, (TextureParameterName)10243, Convert.ToInt32((object)(TextureWrapMode)33071)); @@ -1970,7 +1977,7 @@ index b1b2eea..b6aa736 100644 } } } -@@ -1591,11 +3059,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1591,11 +3070,18 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract ClearFrameBuffer(framebuffer, clearColor, clearDepth); } @@ -1989,7 +1996,7 @@ index b1b2eea..b6aa736 100644 for (int i = 0; i < framebuffer.ColorTextureIds.Length; i++) { GL.ClearBuffer((ClearBuffer)6144, i, clearColor); -@@ -1619,27 +3094,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1619,27 +3105,85 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract LoadFrameBuffer(EnumFrameBuffer.Primary); } @@ -2076,7 +2083,7 @@ index b1b2eea..b6aa736 100644 break; } case EnumFrameBuffer.LiquidDepth: -@@ -1670,71 +3203,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1670,71 +3214,73 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract //IL_0252: Unknown result type (might be due to invalid IL or missing references) //IL_01c8: Unknown result type (might be due to invalid IL or missing references) //IL_01e7: Unknown result type (might be due to invalid IL or missing references) @@ -2167,7 +2174,7 @@ index b1b2eea..b6aa736 100644 } case EnumFrameBuffer.ShadowmapFar: case EnumFrameBuffer.ShadowmapNear: -@@ -1753,39 +3288,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1753,39 +3299,81 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract break; case EnumFrameBuffer.Primary: if (OffscreenBuffer) @@ -2253,7 +2260,7 @@ index b1b2eea..b6aa736 100644 public override void MergeTransparentRenderPass() { if (OffscreenBuffer) -@@ -1793,110 +3370,379 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1793,110 +3381,379 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract CurrentFrameBufferKeepVw = frameBuffers[0]; } else @@ -2702,7 +2709,7 @@ index b1b2eea..b6aa736 100644 ShaderProgramBilateralblur bilateralblur = ShaderPrograms.Bilateralblur; bilateralblur.Use(); int num2 = ((ClientSettings.SSAOQuality == 1) ? 1 : 3); -@@ -1915,35 +3761,270 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1915,35 +3772,270 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract bilateralblur.InputTexture2D = frameBuffers[15].ColorTextureIds[0]; RenderFullscreenTriangle(screenQuad); } @@ -2977,7 +2984,7 @@ index b1b2eea..b6aa736 100644 //IL_0149: Unknown result type (might be due to invalid IL or missing references) //IL_0167: Unknown result type (might be due to invalid IL or missing references) if (OffscreenBuffer) -@@ -1953,26 +4034,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1953,26 +4045,45 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract int primaryScene2D = frameBuffers[10].ColorTextureIds[0]; if (RenderBloom) { @@ -3029,7 +3036,7 @@ index b1b2eea..b6aa736 100644 final.ContrastLevel = ShaderUniforms.ExtraContrastLevel; final.BrightnessLevel = ClientSettings.BrightnessLevel + Math.Max(0f, ShaderUniforms.DropShadowIntensity * 2f - 1.66f) / 3f; final.SepiaLevel = ShaderUniforms.SepiaLevel + ShaderUniforms.ExtraSepia; -@@ -1987,24 +4087,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -1987,24 +4098,43 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract final.DamageVignetting = ShaderUniforms.DamageVignetting; final.DamageVignettingSide = ShaderUniforms.DamageVignettingSide; final.FrostVignetting = ShaderUniforms.FrostVignetting; @@ -3086,7 +3093,7 @@ index b1b2eea..b6aa736 100644 private void DebugCallback(DebugSource source, DebugType type, int id, DebugSeverity severity, int length, nint message, nint userParam) { -@@ -2023,27 +4142,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2023,27 +4153,496 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract throw new Exception(text); } } @@ -3584,7 +3591,7 @@ index b1b2eea..b6aa736 100644 //IL_0001: Unknown result type (might be due to invalid IL or missing references) CheckFboStatus(target, fbtype.ToString() ?? ""); } -@@ -2202,32 +4790,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2202,32 +4801,39 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.Enable((EnableCap)3042); switch (blendMode) { @@ -3624,7 +3631,7 @@ index b1b2eea..b6aa736 100644 { GL.Disable((EnableCap)3042); } -@@ -2593,15 +5188,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2593,15 +5199,52 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract GL.BindBufferBase((BufferRangeTarget)35345, bindingPoint, num); ScreenManager.Platform.CheckGlError(); UBO uBO = new UBO(); @@ -3677,7 +3684,7 @@ index b1b2eea..b6aa736 100644 //IL_0009: Unknown result type (might be due to invalid IL or missing references) //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0015: Unknown result type (might be due to invalid IL or missing references) -@@ -2651,29 +5283,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2651,29 +5294,34 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); updateIndices(data.Indices, data.IndicesOffset, data.IndicesCount, vAO, persistent); @@ -3722,7 +3729,7 @@ index b1b2eea..b6aa736 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2683,15 +5320,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2683,15 +5331,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(int[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3743,7 +3750,7 @@ index b1b2eea..b6aa736 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 4 * count, data); -@@ -2701,15 +5339,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2701,15 +5350,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(short[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3764,7 +3771,7 @@ index b1b2eea..b6aa736 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2719,15 +5358,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2719,15 +5369,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(ushort[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3785,7 +3792,7 @@ index b1b2eea..b6aa736 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, 2 * count, data); -@@ -2737,15 +5377,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2737,15 +5388,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract private unsafe void updateVAO(byte[] data, int offset, int count, int vboId, nint vboPtr, bool pers) { GL.BindBuffer((BufferTarget)34962, vboId); @@ -3806,7 +3813,7 @@ index b1b2eea..b6aa736 100644 else { GL.BufferSubData((BufferTarget)34962, (IntPtr)offset, count, data); -@@ -2759,15 +5400,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -2759,15 +5411,16 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract return; } GL.BindBuffer((BufferTarget)34963, vao.vboIdIndex); @@ -3827,7 +3834,7 @@ index b1b2eea..b6aa736 100644 else { GL.BufferSubData((BufferTarget)34963, (IntPtr)IndicesOffset, 4 * IndicesCount, Indices); -@@ -3320,16 +5962,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3320,16 +5973,17 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract } GL.BindBuffer((BufferTarget)34962, 0); vAO.IndicesCount = data.IndicesCount; @@ -3851,7 +3858,7 @@ index b1b2eea..b6aa736 100644 public override MeshRef AllocateEmptySSBOMesh(int xyzSize, int normalsSize, int uvSize, int rgbaSize, int flagsSize, int indicesSize, CustomMeshDataPartFloat customFloats, CustomMeshDataPartShort customShorts, CustomMeshDataPartByte customBytes, CustomMeshDataPartInt customInts, EnumDrawMode drawMode = EnumDrawMode.Triangles, bool staticDraw = true) { -@@ -3678,10 +6321,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract +@@ -3678,10 +6332,143 @@ public sealed class ClientPlatformWindows : ClientPlatformAbstract public override int GetUniformLocation(ShaderProgram program, string name) { return GL.GetUniformLocation(program.ProgramId, name); diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch index 3698b89d..ad1a32ac 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -index f19d524..ea5a1d2 100644 +index f19d524..ee8435e 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs -@@ -40,10 +40,38 @@ public static class ShaderPrograms +@@ -40,10 +40,45 @@ public static class ShaderPrograms public static ShaderProgramEntityanimated Entityanimated; @@ -35,6 +35,13 @@ index f19d524..ea5a1d2 100644 + // Registered like the other Optimum-only programs, so a failed compile marks + // LoadError instead of failing the whole shader load. + public static ShaderProgram TaaSkyMotion; ++ ++ // Optimum (Vulkan foundation, world/UI separation): the pass that composes the UI image ++ // over the display image under premultiplied-alpha blending. Its own program rather than ++ // ShaderPrograms.Blit because blit.fsh forces alpha to 1, which under premultiplied ++ // blending would cover the whole world with the UI image. Registered like the other ++ // Optimum-only programs, so a failed compile marks LoadError instead of failing the load. ++ public static ShaderProgram UiCompose; + public static ShaderProgramFindbright Findbright; diff --git a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch index e7641356..be530b91 100644 --- a/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch @@ -1,5 +1,5 @@ diff --git a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs -index d3edef9..c22cb64 100644 +index d3edef9..f57d2d6 100644 --- a/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs +++ b/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs @@ -1,8 +1,10 @@ @@ -13,7 +13,7 @@ index d3edef9..c22cb64 100644 using Vintagestory.API.Config; using Vintagestory.Common; -@@ -181,39 +183,201 @@ public class ShaderRegistry +@@ -181,39 +183,203 @@ public class ShaderRegistry registerDefaultShaderPrograms(); RegisterShaderProgram(EnumShaderProgram.Entityanimated_Oit, new ShaderProgramEntityanimated { @@ -31,6 +31,8 @@ index d3edef9..c22cb64 100644 + RegisterOptimumShaderProgram("chunkliquidmotion", ShaderPrograms.ChunkLiquidMotion = new ShaderProgram()); + // Optimum TAA (P4): the sky / volumetric-cloud motion and reactive pass. + RegisterOptimumShaderProgram("taa-skymotion", ShaderPrograms.TaaSkyMotion = new ShaderProgram()); ++ // Optimum (Vulkan foundation, world/UI separation): the UI compose pass. ++ RegisterOptimumShaderProgram("ui-compose", ShaderPrograms.UiCompose = new ShaderProgram()); + } + + private static void RegisterOptimumShaderProgram(string name, ShaderProgram program) @@ -197,7 +199,7 @@ index d3edef9..c22cb64 100644 + bool abiReady = compiled && OptimumConfig.GreedyMeshEnabled && !OptimumConfig.IsShaderFeatureDisabled("GreedyMesh") && HasOptimumGreedyMeshContract(shaderProgram); + OptimumConfig.SetGreedyMeshShaderAbi(abiReady, abiReady); + } -+ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.TaaSharpen || shaderProgram == ShaderPrograms.SceneSsao || shaderProgram == ShaderPrograms.ChunkLiquidMotion || shaderProgram == ShaderPrograms.TaaSkyMotion) ++ if (shaderProgram == ShaderPrograms.FsrEasu || shaderProgram == ShaderPrograms.FsrRcas || shaderProgram == ShaderPrograms.TaaDebug || shaderProgram == ShaderPrograms.TaaResolve || shaderProgram == ShaderPrograms.TaaSharpen || shaderProgram == ShaderPrograms.SceneSsao || shaderProgram == ShaderPrograms.ChunkLiquidMotion || shaderProgram == ShaderPrograms.TaaSkyMotion || shaderProgram == ShaderPrograms.UiCompose) + { + shaderProgram.LoadError |= !compiled; + } @@ -225,7 +227,7 @@ index d3edef9..c22cb64 100644 if (program.LoadFromFile) { LoadShader(program, EnumShaderType.VertexShader); -@@ -296,11 +460,11 @@ public class ShaderRegistry +@@ -296,11 +462,11 @@ public class ShaderRegistry } private static void registerDefaultShaderCodePrefixes(ShaderProgram program, bool useSSBOs) @@ -238,7 +240,7 @@ index d3edef9..c22cb64 100644 Shader fragmentShader3 = program.FragmentShader; fragmentShader3.PrefixCode = fragmentShader3.PrefixCode + "#define NORMALVIEW " + (NormalView ? 1 : 0) + "\r\n"; Shader fragmentShader4 = program.FragmentShader; -@@ -333,10 +497,56 @@ public class ShaderRegistry +@@ -333,10 +499,56 @@ public class ShaderRegistry vertexShader8.PrefixCode = vertexShader8.PrefixCode + "#define MINBRIGHT " + ClientSettings.Minbrightness + "\r\n"; fragmentShader8 = program.VertexShader; fragmentShader8.PrefixCode = fragmentShader8.PrefixCode + "#define SHADOWQUALITY " + ClientSettings.ShadowMapQuality + "\r\n#define DYNLIGHTS " + ClientSettings.MaxDynamicLights + "\r\n"; diff --git a/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch b/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch index aba4ec7a..ca0eee52 100644 --- a/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch +++ b/patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch @@ -1,8 +1,8 @@ diff --git a/VintagestoryLib/Vintagestory.Client/ScreenManager.cs b/VintagestoryLib/Vintagestory.Client/ScreenManager.cs -index b5000e7..1befc0c 100644 +index b5000e7..d0c7850 100644 --- a/VintagestoryLib/Vintagestory.Client/ScreenManager.cs +++ b/VintagestoryLib/Vintagestory.Client/ScreenManager.cs -@@ -731,13 +731,14 @@ public class ScreenManager : KeyEventHandler, MouseEventHandler, NewFrameHandler +@@ -731,23 +731,31 @@ public class ScreenManager : KeyEventHandler, MouseEventHandler, NewFrameHandler Platform.CheckGlError(); FrameProfiler.Mark("doneRender2Default"); Mat4f.Identity(api.renderapi.pMatrix); @@ -19,3 +19,20 @@ index b5000e7..1befc0c 100644 Platform.GlToggleBlend(on: true); CurrentScreen.RenderAfterBlit(dt); if (CurrentScreen.RenderBg && !Platform.IsShuttingDown) + { + guiMainmenuLeft?.RenderBg(dt, withMainMenu); + withMainMenu = false; + } + CurrentScreen.RenderToDefaultFramebuffer(dt); ++ // Optimum (Vulkan foundation, world/UI separation): ClientMain.RenderToDefaultFramebuffer ++ // composes the UI image before its Done stage, but it is reached only through ++ // GuiScreenRunningGame while the game is not exiting to the menu - every menu screen runs ++ // GuiScreen.RenderToDefaultFramebuffer, which has neither a Done stage nor a compose. ++ // Without this call the whole main menu would stay in the UI image and never reach the ++ // window. A frame ClientMain already composed has nothing left to compose here. ++ Platform.OptimumComposeUiTarget(); + Platform.GlDepthFunc(EnumDepthFunction.Less); + FrameProfiler.Mark("doneAfterRender"); + Platform.CheckGlError(); + } + diff --git a/sources/shaders-vk/ui-compose.frag b/sources/shaders-vk/ui-compose.frag new file mode 100644 index 00000000..a486f2da --- /dev/null +++ b/sources/shaders-vk/ui-compose.frag @@ -0,0 +1,18 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of ui-compose.fsh (docs/vulkan-native-shaders.md): the UI image passed through +// untouched, so the premultiplied blend stage composes it over the display image. +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "ui-compose.interface.glsl" + +layout(location = 0) in vec2 texCoord; + +layout(location = 0) out vec4 outColor; + +void main(void) +{ + outColor = texture(optimumTextures2D[uiTex], texCoord); +} diff --git a/sources/shaders-vk/ui-compose.interface.glsl b/sources/shaders-vk/ui-compose.interface.glsl new file mode 100644 index 00000000..dd906b4a --- /dev/null +++ b/sources/shaders-vk/ui-compose.interface.glsl @@ -0,0 +1,6 @@ +// Program interface of ui-compose (docs/vulkan-native-shaders.md section 4). A fullscreen pass: one draw per +// frame, so the push block holds only the sampler slot and there is no program record. +layout(push_constant, scalar) uniform OptimumDraw +{ + OPTIMUM_SAMPLER_SLOT(sampler2D, uiTex); +}; diff --git a/sources/shaders-vk/ui-compose.vert b/sources/shaders-vk/ui-compose.vert new file mode 100644 index 00000000..31d7aa5c --- /dev/null +++ b/sources/shaders-vk/ui-compose.vert @@ -0,0 +1,21 @@ +#version 450 +#extension GL_EXT_scalar_block_layout : require +#extension GL_GOOGLE_include_directive : require +// Native port of ui-compose.vsh (docs/vulkan-native-shaders.md). +#include "bindings.glsl" +#include "frame.glsl" +#include "specialization.glsl" +#include "ui-compose.interface.glsl" + +layout(location = 0) out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexIndex & 1) << 2); + float y = -1.0 + float((gl_VertexIndex & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); + + // GL clip depth [-w, w] to Vulkan's [0, w]: the statement ShaderRewriter appends to every GLSL 330 vertex stage. + gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5; +} diff --git a/sources/shaders/ui-compose.fsh b/sources/shaders/ui-compose.fsh new file mode 100644 index 00000000..100e89fc --- /dev/null +++ b/sources/shaders/ui-compose.fsh @@ -0,0 +1,26 @@ +#version 330 core + +// Optimum (Vulkan foundation, world/UI separation): the UI compose pass - the UI image over +// the display image, under premultiplied-alpha blending (ONE, ONE_MINUS_SRC_ALPHA): +// +// dst = ui.rgb + dst.rgb * (1 - ui.a) +// +// The UI image already holds premultiplied colour: the GUI draws into it with the RGB factors +// it always had (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) over transparent black, which accumulates the +// over-operator's premultiplied result, while the alpha channel accumulates coverage under the +// separate (ONE, ONE_MINUS_SRC_ALPHA) factors every Standard-blended draw into that image uses. +// So this pass passes the texel through untouched and lets the blend stage do the operator. +// +// Why not blit.fsh: it ends with "outColor.a = 1", right for a blit onto an opaque backbuffer +// and fatal here - a forced alpha of 1 makes every UI texel cover the scene completely. + +uniform sampler2D uiTex; + +in vec2 texCoord; + +out vec4 outColor; + +void main(void) +{ + outColor = texture(uiTex, texCoord); +} diff --git a/sources/shaders/ui-compose.vsh b/sources/shaders/ui-compose.vsh new file mode 100644 index 00000000..c1f4e007 --- /dev/null +++ b/sources/shaders/ui-compose.vsh @@ -0,0 +1,15 @@ +#version 330 core + +// Optimum (Vulkan foundation, world/UI separation): the UI compose pass - the fullscreen +// triangle, generated from gl_VertexID exactly as blit.vsh does, so the pass binds no +// vertex data. + +out vec2 texCoord; + +void main(void) +{ + float x = -1.0 + float((gl_VertexID & 1) << 2); + float y = -1.0 + float((gl_VertexID & 2) << 1); + gl_Position = vec4(x, y, 0.0, 1.0); + texCoord = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5); +}